Files
mini-programs/src/components/Comments/index.tsx

562 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
});