feat: 评论完了
This commit is contained in:
213
src/components/Comments/index.module.scss
Normal file
213
src/components/Comments/index.module.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
415
src/components/Comments/index.tsx
Normal file
415
src/components/Comments/index.tsx
Normal file
@@ -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<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 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 (
|
||||
<CommonPopup
|
||||
visible={visible}
|
||||
showHeader={false}
|
||||
hideFooter
|
||||
zIndex={1002}
|
||||
onClose={onClose}
|
||||
style={{ height: "60px!important", minHeight: "unset" }}
|
||||
enableDragToClose={false}
|
||||
>
|
||||
<View className={styles.inputContainer}>
|
||||
<View className={styles.inputWrapper}>
|
||||
<Input
|
||||
ref={inputDomRef}
|
||||
className={styles.input}
|
||||
value={value}
|
||||
onInput={(e) => setValue(e.detail.value)}
|
||||
placeholder={
|
||||
params?.reply_to_user_id ? `回复 @${params.nickname}` : "写评论"
|
||||
}
|
||||
focus
|
||||
maxlength={100}
|
||||
/>
|
||||
</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;
|
||||
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 (
|
||||
<View className={styles.commentItem} key={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>上海</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>
|
||||
{isGamePublisher || 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
|
||||
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 && (
|
||||
<View
|
||||
className={styles.viewMore}
|
||||
onClick={() => handleLoadMore(comment)}
|
||||
>
|
||||
展开更多评论
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(function Comments(
|
||||
props: { game_id: number; publisher_id: number },
|
||||
ref
|
||||
) {
|
||||
const { game_id, publisher_id } = props;
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const inputRef = useRef<CommentInputRef>(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 (
|
||||
<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}
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user