562 lines
16 KiB
TypeScript
562 lines
16 KiB
TypeScript
import React, {
|
||
useState,
|
||
useEffect,
|
||
forwardRef,
|
||
useRef,
|
||
useImperativeHandle,
|
||
} from "react";
|
||
import { View, Text, Image, Input, Textarea } from "@tarojs/components";
|
||
import Taro from "@tarojs/taro";
|
||
import dayjs from "dayjs";
|
||
import classnames from "classnames";
|
||
import CommentServices from "@/services/commentServices";
|
||
import messageService from "@/services/messageService";
|
||
import { delay } from "@/utils";
|
||
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";
|
||
import { useKeyboardHeight } from "@/store/keyboardStore";
|
||
|
||
// const PAGESIZE = 4;
|
||
const PAGESIZE = 1000;
|
||
|
||
function toast(msg) {
|
||
Taro.showToast({ title: msg, icon: "none" });
|
||
}
|
||
|
||
interface CommentInputProps {
|
||
onConfirm?: (
|
||
value: { content: string } & Partial<CommentInputReplyParamsType>,
|
||
) => void;
|
||
}
|
||
|
||
// 2️⃣ 定义通过 ref 暴露出去的方法类型
|
||
interface CommentInputRef {
|
||
show: (params?: CommentInputReplyParamsType) => void;
|
||
}
|
||
|
||
interface CommentInputReplyParamsType {
|
||
parent_id: number;
|
||
reply_to_user_id: number;
|
||
nickname: string;
|
||
}
|
||
|
||
const CommentInput = forwardRef<CommentInputRef, CommentInputProps>(
|
||
function (props, ref) {
|
||
const { onConfirm } = props;
|
||
const [visible, setVisible] = useState(false);
|
||
const [value, setValue] = useState("");
|
||
const [params, setParams] = useState<
|
||
CommentInputReplyParamsType | undefined
|
||
>();
|
||
|
||
const {
|
||
keyboardHeight,
|
||
isKeyboardVisible,
|
||
addListener,
|
||
initializeKeyboardListener,
|
||
} = useKeyboardHeight();
|
||
|
||
// 使用全局键盘状态监听
|
||
useEffect(() => {
|
||
// 初始化全局键盘监听器
|
||
initializeKeyboardListener();
|
||
|
||
// 添加本地监听器
|
||
const removeListener = addListener(() => {
|
||
// 布局是否响应交由 shouldReactToKeyboard 决定
|
||
});
|
||
|
||
return () => {
|
||
removeListener();
|
||
};
|
||
}, [initializeKeyboardListener, addListener]);
|
||
|
||
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;
|
||
}
|
||
if (value.length > 200) {
|
||
return;
|
||
}
|
||
onConfirm?.({ content: value, ...params });
|
||
onClose();
|
||
}
|
||
|
||
function onClose() {
|
||
setVisible(false);
|
||
setValue("");
|
||
inputDomRef.current && inputDomRef.current?.blur();
|
||
}
|
||
return (
|
||
<CommonPopup
|
||
visible={visible}
|
||
showHeader={false}
|
||
hideFooter
|
||
zIndex={1002}
|
||
onClose={onClose}
|
||
style={{
|
||
// height: "60px!important",
|
||
minHeight: "unset",
|
||
bottom:
|
||
isKeyboardVisible && keyboardHeight > 0
|
||
? `${keyboardHeight}px`
|
||
: "0",
|
||
}}
|
||
enableDragToClose={false}
|
||
>
|
||
<View className={styles.inputContainer}>
|
||
<View className={styles.inputWrapper}>
|
||
<Textarea
|
||
adjustPosition={false}
|
||
ref={inputDomRef}
|
||
className={styles.input}
|
||
value={value}
|
||
onInput={(e) => setValue(e.detail.value)}
|
||
placeholder={
|
||
params?.reply_to_user_id ? `回复 @${params.nickname}` : "写评论"
|
||
}
|
||
confirmType="send"
|
||
onConfirm={handleSend}
|
||
focus
|
||
maxlength={-1}
|
||
autoHeight
|
||
// showCount
|
||
/>
|
||
<View
|
||
className={classnames(
|
||
styles.limit,
|
||
value.length > 200 ? styles.red : "",
|
||
)}
|
||
>
|
||
<Text>{value.length}</Text>/<Text>200</Text>
|
||
</View>
|
||
</View>
|
||
<View className={styles.sendIcon} onClick={handleSend}>
|
||
<Image className={styles.sendImage} src={sendImg} />
|
||
</View>
|
||
</View>
|
||
</CommonPopup>
|
||
);
|
||
},
|
||
);
|
||
|
||
function isReplyComment(item: BaseComment<any>): 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;
|
||
blink_id: number | undefined;
|
||
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,
|
||
blink_id,
|
||
} = props;
|
||
const currentUserInfo = useUserInfo();
|
||
// 判断评论的作者是否是组织者
|
||
const isGamePublisher = publisher_id === comment.user.id;
|
||
// 当前用户是否是球局发布者
|
||
const currentIsGamePublisher = publisher_id === currentUserInfo.id;
|
||
// 判断当前登录用户是否是评论的作者
|
||
const isCommentPublisher = currentUserInfo.id === comment.user.id;
|
||
return (
|
||
<View
|
||
className={classnames(
|
||
styles.commentItem,
|
||
blink_id === comment.id && styles.blink,
|
||
styles.weight_super,
|
||
)}
|
||
key={comment.id}
|
||
id={`comment_id_${comment.id}`}
|
||
>
|
||
<View style={{ width: level === 1 ? "36px" : "28px" }}>
|
||
<Image
|
||
className={styles.avatar}
|
||
src={comment.user.avatar_url}
|
||
mode="aspectFill"
|
||
style={
|
||
level === 1
|
||
? { width: "36px", height: "36px" }
|
||
: { width: "28px", height: "28px" }
|
||
}
|
||
/>
|
||
</View>
|
||
<View className={styles.contents}>
|
||
<View className={styles.main}>
|
||
<View className={styles.publisherInfo}>
|
||
<View className={styles.nickname}>
|
||
<Text>{comment.user.nickname}</Text>
|
||
</View>
|
||
{isGamePublisher && (
|
||
<View className={styles.role}>
|
||
<Text>组织者</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
<View className={styles.content}>
|
||
<Text className={styles.atPeople}>
|
||
{isReplyComment(comment) && comment.reply_to_user
|
||
? `@${comment.reply_to_user.nickname} `
|
||
: ""}
|
||
</Text>
|
||
<Text>{comment.content}</Text>
|
||
</View>
|
||
<View className={styles.addons}>
|
||
<View className={styles.time}>
|
||
<Text>{getRelativeDay(comment.create_time)}</Text>
|
||
</View>
|
||
<View className={styles.location}>
|
||
<Text>{comment.user.province}</Text>
|
||
</View>
|
||
<View
|
||
className={styles.reply}
|
||
onClick={() =>
|
||
handleReply({
|
||
parent_id: comment.parent_id || comment.id,
|
||
reply_to_user_id: comment.user.id,
|
||
nickname: comment.user.nickname,
|
||
})
|
||
}
|
||
>
|
||
<Text>回复</Text>
|
||
</View>
|
||
{(currentIsGamePublisher || isCommentPublisher) && (
|
||
<View
|
||
className={styles.delete}
|
||
onClick={() =>
|
||
handleDelete({
|
||
parent_id: comment.parent_id,
|
||
id: comment.id,
|
||
})
|
||
}
|
||
>
|
||
<Text>删除</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
{!isReplyComment(comment) &&
|
||
comment.replies.map((item: ReplyComment) => (
|
||
<CommentItem
|
||
blink_id={blink_id}
|
||
key={comment.id}
|
||
publisher_id={publisher_id}
|
||
comment={item}
|
||
level={2}
|
||
loadMore={handleLoadMore}
|
||
handleReply={handleReply}
|
||
handleDelete={handleDelete}
|
||
/>
|
||
))}
|
||
{!isReplyComment(comment) &&
|
||
comment.replies.length !== comment.reply_count &&
|
||
comment.replies.length > 3 && (
|
||
<View
|
||
className={styles.viewMore}
|
||
onClick={() => handleLoadMore(comment)}
|
||
>
|
||
展开更多评论
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export default forwardRef(function Comments(
|
||
props: {
|
||
game_id: number;
|
||
publisher_id: number;
|
||
message_id?: number;
|
||
onScrollTo: (id: string) => void;
|
||
},
|
||
ref,
|
||
) {
|
||
const { game_id, publisher_id, message_id, onScrollTo } = props;
|
||
const [comments, setComments] = useState<Comment[]>([]);
|
||
const inputRef = useRef<CommentInputRef>(null);
|
||
const [blink_id, setBlinkId] = useState<number | undefined>();
|
||
|
||
const commentCountUpdateRef = useRef();
|
||
|
||
useEffect(() => {
|
||
init();
|
||
}, [game_id]);
|
||
|
||
async function init() {
|
||
if (!game_id) return;
|
||
await getComments(1);
|
||
if (message_id) {
|
||
scrollToComment();
|
||
// 标记评论已读
|
||
markCommentAsRead();
|
||
}
|
||
}
|
||
|
||
// 标记评论已读
|
||
async function markCommentAsRead() {
|
||
if (!message_id) return;
|
||
try {
|
||
await messageService.markAsRead("comment", [message_id]);
|
||
} catch (e) {
|
||
console.error("标记评论已读失败:", e);
|
||
}
|
||
}
|
||
|
||
async function scrollToComment() {
|
||
const res = await CommentServices.getCommentDetail({
|
||
comment_id: message_id as number,
|
||
});
|
||
if (res.code === 0) {
|
||
// 判断当前评论是否渲染到页面上,有的话直接跳转,没有的话先插入再跳转
|
||
const query = Taro.createSelectorQuery();
|
||
query
|
||
.select(`#comment_id_${res.data.id}`)
|
||
.boundingClientRect() // 或 .fields({id:true}) 根据需求
|
||
.exec(async (resArr) => {
|
||
// resArr 是数组,长度为选择器数量
|
||
const nodeInfo = resArr[0];
|
||
if (!nodeInfo) {
|
||
// 节点不存在,执行插入逻辑
|
||
const parent_id = res.data.parent_id;
|
||
if (parent_id) {
|
||
setComments((prev) => {
|
||
return prev.map((item) => {
|
||
if (item.id !== parent_id) return item;
|
||
return {
|
||
...item,
|
||
replies: [res.data, ...item.replies].sort((a, b) =>
|
||
dayjs(a.create_time).isAfter(dayjs(b.create_time))
|
||
? 1
|
||
: -1,
|
||
),
|
||
};
|
||
});
|
||
});
|
||
}
|
||
}
|
||
await delay(200);
|
||
onScrollTo?.(`#comment_id_${res.data.id}`);
|
||
// Taro.pageScrollTo({
|
||
// selector: `#comment_id_${res.data.id}`,
|
||
// duration: 300,
|
||
// });
|
||
setBlinkId(res.data.id);
|
||
setTimeout(() => {
|
||
setBlinkId(undefined);
|
||
}, 4300);
|
||
});
|
||
}
|
||
}
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
addComment: handleReply,
|
||
getCommentCount: (onUpdate) => {
|
||
commentCountUpdateRef.current = onUpdate;
|
||
onUpdate(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);
|
||
commentCountUpdateRef.current?.(res.length);
|
||
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;
|
||
}
|
||
});
|
||
commentCountUpdateRef.current?.(newComments.length);
|
||
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) => {
|
||
commentCountUpdateRef.current?.(prev.length + 1);
|
||
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) => {
|
||
commentCountUpdateRef.current?.(prev.length - 1);
|
||
return prev.filter((item) => item.id !== id);
|
||
});
|
||
}
|
||
toast("评论已删除");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<View className={styles.container}>
|
||
<View className={styles.header}>
|
||
<View className={styles.commentCount}>
|
||
{comments.length > 0 ? `${comments.length} 条` : ""}评论
|
||
</View>
|
||
<View className={styles.addComment} onClick={() => handleReply()}>
|
||
<Image className={styles.addCommentImage} src={addComment} />
|
||
<Text className={styles.addCommentText}>写评论</Text>
|
||
</View>
|
||
</View>
|
||
{comments.length > 0 ? (
|
||
<View className={styles.list}>
|
||
{comments.map((comment) => {
|
||
return (
|
||
<CommentItem
|
||
key={comment.id}
|
||
blink_id={blink_id}
|
||
publisher_id={publisher_id}
|
||
level={1}
|
||
comment={comment}
|
||
loadMore={getReplies}
|
||
handleReply={handleReply}
|
||
handleDelete={deleteComment}
|
||
/>
|
||
);
|
||
})}
|
||
</View>
|
||
) : (
|
||
<View className={styles.empty}>
|
||
<Image className={styles.emptyImage} src={emptyComment} />
|
||
<Text className={styles.emptyTip}>快来发表第一条评论</Text>
|
||
</View>
|
||
)}
|
||
|
||
<CommentInput ref={inputRef} onConfirm={onSend} />
|
||
</View>
|
||
);
|
||
});
|