From aafb48bacd4c436a817d714efd6e286c7c7f7afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=9D=B0?= Date: Thu, 18 Sep 2025 13:36:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA=E5=AE=8C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Comments/index.module.scss | 213 +++++++++++ src/components/Comments/index.tsx | 415 ++++++++++++++++++++++ src/components/GameManagePopup/index.tsx | 3 +- src/components/index.ts | 2 + src/game_pages/detail/index.tsx | 46 ++- src/services/commentServices.ts | 85 +++++ src/static/detail/icon-sendup.svg | 4 + src/static/detail/icon-write.svg | 4 + 8 files changed, 755 insertions(+), 17 deletions(-) create mode 100644 src/components/Comments/index.module.scss create mode 100644 src/components/Comments/index.tsx create mode 100644 src/services/commentServices.ts create mode 100644 src/static/detail/icon-sendup.svg create mode 100644 src/static/detail/icon-write.svg diff --git a/src/components/Comments/index.module.scss b/src/components/Comments/index.module.scss new file mode 100644 index 0000000..e94914a --- /dev/null +++ b/src/components/Comments/index.module.scss @@ -0,0 +1,213 @@ +.container { + .header { + padding: 20px 20px 0; + + .commentCount { + border-bottom: 1px solid rgba(255, 255, 255, 0.10); + padding-bottom: 8px; + color: #FFF; + font-feature-settings: 'liga' off, 'clig' off; + text-overflow: ellipsis; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 24px; + } + + .addComment { + height: 40px; + padding: 0 12px; + margin: 12px 0; + display: flex; + justify-content: flex-start; + align-items: center; + gap: 6px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.20); + + .addCommentImage { + width: 18px; + height: 18px; + } + + .addCommentText { + color: rgba(255, 255, 255, 0.65); + font-feature-settings: 'liga' off, 'clig' off; + text-overflow: ellipsis; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } + } + } + .list { + padding: 12px 20px; + + & > .commentItem { + padding-bottom: 12px; + } + + .commentItem { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + + .avatar { + border-radius: 50%; + } + + .contents { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 12px; + + .main { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 4px; + + .publisherInfo { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + + .nickname { + color: rgba(255, 255, 255, 0.65); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 18px; + } + + .role { + padding: 0 4px; + height: 18px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.10); + color: rgba(255, 255, 255, 0.65); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 10px; + font-style: normal; + font-weight: 500; + line-height: 18px; + } + } + + .content { + color: #FFF; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + + .atPeople { + color: rgba(255, 255, 255, 0.45); + } + } + + .addons { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + + .time, .location, .reply, .delete { + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 12px; + font-style: normal; + line-height: 18px; + + &.time, &.location { + color: rgba(255, 255, 255, 0.45); + font-weight: 400; + } + + &.reply, &.delete { + color: rgba(255, 255, 255, 0.85); + font-weight: 600; + } + } + } + } + + .viewMore { + color: #FFF; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + } + } + } + } +} + +.inputContainer { + height: 36px; + padding: 12px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + .inputWrapper { + flex: 1; + display: flex; + justify-content: flex-start; + align-items: center; + + & > .input { + width: 100%; + } + } + + .sendIcon { + width: 36px; + height: 36px; + border-radius: 50%; + background-color: #000; + border: 0.5px solid rgba(0, 0, 0, 0.06); + + .sendImage { + width: 36px; + height: 36px; + } + } +} + +.empty { + height: 40vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + + .emptyTip { + color: rgba(255, 255, 255, 0.85); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 24px; + } +} \ No newline at end of file diff --git a/src/components/Comments/index.tsx b/src/components/Comments/index.tsx new file mode 100644 index 0000000..784901c --- /dev/null +++ b/src/components/Comments/index.tsx @@ -0,0 +1,415 @@ +import React, { + useState, + useEffect, + forwardRef, + useRef, + useImperativeHandle, +} from "react"; +import { View, Text, Image, Input } from "@tarojs/components"; +import Taro from "@tarojs/taro"; +import dayjs from "dayjs"; +import CommentServices from "@/services/commentServices"; +import type { + BaseComment, + Comment, + ReplyComment, +} from "@/services/commentServices"; +import { useUserInfo } from "@/store/userStore"; +import sendImg from "@/static/detail/icon-sendup.svg"; +import addComment from "@/static/detail/icon-write.svg"; +import emptyComment from "@/static/emptyStatus/comment-empty.png"; +import CommonPopup from "../CommonPopup"; +import styles from "./index.module.scss"; + +const PAGESIZE = 4; +// const PAGESIZE = 1000; + +function toast(msg) { + Taro.showToast({ title: msg, icon: "none" }); +} + +interface CommentInputProps { + onConfirm?: ( + value: { content: string } & Partial + ) => void; +} + +// 2️⃣ 定义通过 ref 暴露出去的方法类型 +interface CommentInputRef { + show: (params?: CommentInputReplyParamsType) => void; +} + +interface CommentInputReplyParamsType { + parent_id: number; + reply_to_user_id: number; + nickname: string; +} + +const CommentInput = forwardRef(function ( + props, + ref +) { + const { onConfirm } = props; + const [visible, setVisible] = useState(false); + const [value, setValue] = useState(""); + const [params, setParams] = useState< + CommentInputReplyParamsType | undefined + >(); + + const inputDomRef = useRef(null); + + useImperativeHandle(ref, () => ({ + show: (_params: CommentInputReplyParamsType | undefined) => { + setVisible(true); + setTimeout(() => { + inputDomRef.current && inputDomRef.current?.focus(); + }, 100); + setParams(_params); + }, + })); + + function handleSend() { + if (!value) { + toast("评论内容不得为空"); + return; + } + onConfirm?.({ content: value, ...params }); + onClose(); + } + + function onClose() { + setVisible(false); + setValue(""); + inputDomRef.current && inputDomRef.current?.blur(); + } + return ( + + + + setValue(e.detail.value)} + placeholder={ + params?.reply_to_user_id ? `回复 @${params.nickname}` : "写评论" + } + focus + maxlength={100} + /> + + + + + + + ); +}); + +function isReplyComment(item: BaseComment): item is ReplyComment { + return "reply_to_user" in item; +} + +function getRelativeDay(time) { + const theTime = dayjs(time); + const isThisYear = dayjs().isSame(theTime, "year"); + const diffDay = dayjs().startOf("day").diff(theTime, "day"); + return diffDay <= 3 + ? diffDay >= 1 + ? `${diffDay}天前` + : theTime.format("HH:mm:ss") + : theTime.format(isThisYear ? "MM-DD HH:mm:ss" : "YYYY-MM-DD HH:mm:ss"); +} + +function CommentItem(props: { + level: number; + publisher_id: number; + comment: Comment | ReplyComment; + loadMore: (c: Comment) => void; + handleReply: (options: CommentInputReplyParamsType) => void; + handleDelete: (options: { parent_id: number | null; id: number }) => void; +}) { + const { + level, + publisher_id, + comment, + loadMore: handleLoadMore, + handleReply, + handleDelete, + } = props; + const currentUserInfo = useUserInfo(); + const isGamePublisher = publisher_id === comment.user.id; + const isCommentPublisher = currentUserInfo.id === comment.user.id; + return ( + + + + + + + + + {comment.user.nickname} + + {isGamePublisher && ( + + 组织者 + + )} + + + + {isReplyComment(comment) && comment.reply_to_user + ? `@${comment.reply_to_user.nickname} ` + : ""} + + {comment.content} + + + + {getRelativeDay(comment.create_time)} + + + 上海 + + + handleReply({ + parent_id: comment.parent_id || comment.id, + reply_to_user_id: comment.user.id, + nickname: comment.user.nickname, + }) + } + > + 回复 + + {isGamePublisher || isCommentPublisher} + + handleDelete({ + parent_id: comment.parent_id, + id: comment.id, + }) + } + > + 删除 + + + + {!isReplyComment(comment) && + comment.replies.map((item: ReplyComment) => ( + + ))} + {!isReplyComment(comment) && + comment.replies.length !== comment.reply_count && ( + handleLoadMore(comment)} + > + 展开更多评论 + + )} + + + ); +} + +export default forwardRef(function Comments( + props: { game_id: number; publisher_id: number }, + ref +) { + const { game_id, publisher_id } = props; + const [comments, setComments] = useState([]); + const inputRef = useRef(null); + + useEffect(() => { + getComments(1); + }, [game_id]); + + useImperativeHandle(ref, () => ({ + addComment: handleReply, + getCommentCount: () => comments.length, + })); + + async function getComments(page) { + if (!game_id) return; + const res = await CommentServices.getComments({ + page, + pageSize: PAGESIZE, + game_id, + }); + if (res.code === 0) { + const newComments: Comment[] = res.data.rows; + setComments((prev) => { + const res = [...prev]; + res.splice(page * PAGESIZE - 1, newComments.length, ...newComments); + return res; + }); + } + } + + async function getReplies(c: Comment) { + const { replies, id: comment_id } = c; + const page = replies.length < PAGESIZE ? 1 : replies.length / PAGESIZE + 1; + const res = await CommentServices.getReplies({ + comment_id, + page, + pageSize: PAGESIZE, + }); + if (res.code === 0) { + const newReplies = res.data.rows; + setComments((prev) => { + const newComments = [...prev]; + newComments.forEach((item) => { + if (item.id === comment_id) { + item.replies.splice( + page === 1 ? 0 : page * PAGESIZE - 1, + newReplies.length, + ...newReplies + ); + item.reply_count = res.data.count; + } + }); + return newComments; + }); + } + } + + function handleReply(options?: CommentInputReplyParamsType) { + inputRef.current?.show(options); + } + + function onSend({ content, parent_id, reply_to_user_id }) { + if (!parent_id) { + createComment(content); + return; + } + replyComment({ content, parent_id, reply_to_user_id }); + } + + async function createComment(val: string) { + const res = await CommentServices.createComment({ game_id, content: val }); + if (res.code === 0) { + setComments((prev) => { + return [{ ...res.data, replies: [] }, ...prev]; + }); + toast("发布成功"); + } + } + + async function replyComment({ parent_id, reply_to_user_id, content }) { + const res = await CommentServices.replyComment({ + parent_id, + reply_to_user_id, + content, + }); + if (res.code === 0) { + setComments((prev) => { + return prev.map((item) => { + if (item.id === parent_id) { + return { + ...item, + replies: [res.data, ...item.replies], + reply_count: item.reply_count + 1, + }; + } + return item; + }); + }); + toast("回复成功"); + } + } + + async function deleteComment({ parent_id, id }) { + const res = await CommentServices.deleteComment({ comment_id: id }); + if (res.code === 0) { + if (parent_id) { + setComments((prev) => { + return prev.map((item) => { + if (item.id === parent_id) { + return { + ...item, + replies: item.replies.filter( + (replyItem) => replyItem.id !== id + ), + reply_count: item.reply_count - 1, + }; + } + return item; + }); + }); + } else { + setComments((prev) => { + console.log(prev, parent_id, id); + return prev.filter((item) => item.id !== id); + }); + } + toast("评论已删除"); + } + } + + return ( + + + + {comments.length > 0 ? `${comments.length} 条` : ""}评论 + + handleReply()}> + + 写评论 + + + {comments.length > 0 ? ( + + {comments.map((comment) => { + return ( + + ); + })} + + ) : ( + + + 快来发表第一条评论 + + )} + + + + ); +}); diff --git a/src/components/GameManagePopup/index.tsx b/src/components/GameManagePopup/index.tsx index e4c7283..d321287 100644 --- a/src/components/GameManagePopup/index.tsx +++ b/src/components/GameManagePopup/index.tsx @@ -32,7 +32,7 @@ const CancelPopup = forwardRef((props, ref) => { onFinish.current = onAct; setVisible(true); setTimeout(() => { - inputRef.current.focus(); + inputRef.current && inputRef.current.focus(); }, 0); }, })); @@ -87,6 +87,7 @@ const CancelPopup = forwardRef((props, ref) => { focus value={cancelReason} onInput={(e) => setCancelReason(e.detail.value)} + maxlength={100} /> )} diff --git a/src/components/index.ts b/src/components/index.ts index da44688..3f281d6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -19,6 +19,7 @@ import NTRPEvaluatePopup from "./NTRPEvaluatePopup"; import RefundPopup from "./refundPopup"; import GameManagePopup from './GameManagePopup'; import FollowUserCard from './FollowUserCard/index'; +import Comments from "./Comments"; export { ActivityTypeSwitch, @@ -43,4 +44,5 @@ export { RefundPopup, GameManagePopup, FollowUserCard, + Comments, }; diff --git a/src/game_pages/detail/index.tsx b/src/game_pages/detail/index.tsx index 88f8fef..d15244f 100644 --- a/src/game_pages/detail/index.tsx +++ b/src/game_pages/detail/index.tsx @@ -6,7 +6,7 @@ import React, { forwardRef, } from "react"; import { View, Text, Image, Map, ScrollView } from "@tarojs/components"; -import { Avatar } from "@nutui/nutui-react-taro"; +// import { Avatar } from "@nutui/nutui-react-taro"; import Taro, { useRouter, useShareAppMessage, @@ -22,6 +22,7 @@ import { withAuth, NTRPEvaluatePopup, GameManagePopup, + Comments, } from "@/components"; import { EvaluateType, @@ -40,6 +41,7 @@ dayjs.locale("zh-cn"); // 将·作为连接符插入到标签文本之间 function insertDotInTags(tags: string[]) { + if (!tags) return [] return tags.join("-·-").split("-"); } @@ -104,7 +106,6 @@ function Coursel(props) { async function getImagesMsg(imageList) { const latest_list: CourselItemType[] = []; const sys_info = await Taro.getSystemInfo(); - console.log(sys_info, "info"); const max_width = sys_info.screenWidth - 30; const max_height = 240; const current_aspect_ratio = max_width / max_height; @@ -239,7 +240,7 @@ function isFull (counts) { // 底部操作栏 function StickyButton(props) { - const { handleShare, handleJoinGame, detail, onStatusChange } = props; + const { handleShare, handleJoinGame, detail, onStatusChange, handleAddComment, getCommentCount } = props; const ntrpRef = useRef(null); const { id, price, user_action_status, match_status, start_time, end_time, is_organizer } = detail || {}; @@ -363,6 +364,8 @@ function StickyButton(props) { }; } + const commentCount = getCommentCount() + return ( <> @@ -378,14 +381,15 @@ function StickyButton(props) { { - Taro.showToast({ title: "To be continued", icon: "none" }); + // Taro.showToast({ title: "To be continued", icon: "none" }); + handleAddComment() }} > - 32 + {commentCount > 0 ? commentCount : '评论'} @@ -580,7 +584,7 @@ function VenueInfo(props) { function previewImage(current_url) { Taro.previewImage({ current: current_url, - urls: venue_image_list.map((c) => c.url), + urls: venue_image_list?.length > 0 ? venue_image_list.map((c) => c.url) : [], }); } return ( @@ -629,7 +633,7 @@ function VenueInfo(props) { 预定截图 - {venue_image_list.map((item) => { + {venue_image_list?.length > 0 && venue_image_list.map((item) => { return ( @@ -808,7 +811,7 @@ function SupplementalNotes(props) { {/* supplemental notes tags */} - {insertDotInTags(description_tag).map((tag, index) => ( + {insertDotInTags(description_tag || []).map((tag, index) => ( {tag} @@ -873,6 +876,7 @@ function OrganizerInfo(props) { currentLocation: location, onUpdateUserInfo = () => {}, handleViewUserInfo, + handleAddComment, } = props; const { id, @@ -956,7 +960,7 @@ function OrganizerInfo(props) { )} )} - + handleAddComment()}> (null); + const commentRef = useRef(); useDidShow(async () => { await updateLocation(); @@ -1084,10 +1089,16 @@ function Index() { const fetchDetail = async () => { if (!id) return; - const res = await DetailService.getDetail(Number(id)); - if (res.code === 0) { - setDetail(res.data); - fetchUserInfoById(res.data.publisher_id); + try { + const res = await DetailService.getDetail(Number(id)); + if (res.code === 0) { + setDetail(res.data); + fetchUserInfoById(res.data.publisher_id); + } + } catch (e) { + if (e.message === '球局不存在') { + handleBack() + } } }; @@ -1139,7 +1150,6 @@ function Index() { navto(`/user_pages/other/index?userid=${userId}`); } - console.log("detail", detail); const backgroundImage = detail?.image_list?.[0] ? { backgroundImage: `url(${detail?.image_list?.[0]})` } : {}; @@ -1199,13 +1209,17 @@ function Index() { currentLocation={currentLocation} onUpdateUserInfo={onUpdateUserInfo} handleViewUserInfo={handleViewUserInfo} + handleAddComment={() => { commentRef.current && commentRef.current.addComment() }} /> + {/* sticky bottom action bar */} { commentRef.current && commentRef.current.addComment() }} + getCommentCount={() => commentRef.current && commentRef.current.getCommentCount()} /> {/* share popup */} = { + create_time: string + last_modify_time: string + id: number + game_id: number + user_id: number + parent_id: number | null + reply_to_user_id: number | null + content: string + like_count: number + reply_count: number + user: UserInfo + is_liked?: boolean +} & T + +export type ReplyComment = BaseComment<{ + parent_id: number + reply_to_user_id: number + reply_to_user: UserInfo +}> + +export type Comment = BaseComment<{ + replies: ReplyComment[] +}> + +// 接口响应 +export interface ReplyCommentResponse { + count: number + rows: ReplyComment[] +} + +export interface ToggleLikeType { + is_liked: boolean, + like_count: number, + message: string +} + +// 评论管理类 +class CommentService { + // 查询评论列表 + async getComments(req: { game_id: number, page: number, pageSize: number }): Promise> { + return httpService.post("/comments/list", req, { showLoading: true }); + } + + // 发表评论 + async createComment(req: { game_id: number, content: string }): Promise> { + return httpService.post("/comments/create", req, { showLoading: true }); + } + + // 回复评论 + async replyComment(req: { parent_id: number, reply_to_user_id: number, content: string }): Promise> { + return httpService.post("/comments/reply", req, { showLoading: true }); + } + + // 点赞取消点赞评论 + async toggleLike(req: { comment_id: number }): Promise> { + return httpService.post("/comments/like", req, { showLoading: true }); + } + + // 删除评论 + async deleteComment(req: { comment_id: number }): Promise> { + return httpService.post("/comments/delete", req, { showLoading: true }); + } + + // 获取评论的所有回复 + async getReplies(req: { comment_id: number, page: number, pageSize: number }): Promise> { + return httpService.post("/comments/replies", req, { showLoading: true }); + } +} + +export default new CommentService(); diff --git a/src/static/detail/icon-sendup.svg b/src/static/detail/icon-sendup.svg new file mode 100644 index 0000000..0c1b746 --- /dev/null +++ b/src/static/detail/icon-sendup.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/static/detail/icon-write.svg b/src/static/detail/icon-write.svg new file mode 100644 index 0000000..f315a98 --- /dev/null +++ b/src/static/detail/icon-write.svg @@ -0,0 +1,4 @@ + + + +