feat: 重构评论功能,支持删除和点赞,更新国际化文本,优化组件结构

This commit is contained in:
2025-09-09 20:11:31 +08:00
parent dd7641bf6e
commit ad9dfb0c4c
15 changed files with 504 additions and 85 deletions

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
root = true
[*]
indent_style = space
indent_size = 2
[*.ts]
indent_style = space
indent_size = 2
[*.tsx]
indent_style = space
indent_size = 2

View File

@ -1,15 +1,33 @@
import axiosClient from './client' import axiosClient from './client'
import { CreateCommentRequest, UpdateCommentRequest, Comment } from '@/models/comment' import { UpdateCommentRequest, Comment } from '@/models/comment'
import type { PaginationParams } from '@/models/common' import { PaginationParams } from '@/models/common'
import { OrderBy } from '@/models/common' import { OrderBy } from '@/models/common'
import type { BaseResponse } from '@/models/resp' import type { BaseResponse } from '@/models/resp'
import { TargetType } from '@/models/types' import { TargetType } from '@/models/types'
export async function createComment( export async function createComment(
data: CreateCommentRequest, {
targetType,
targetId,
content,
replyId = null,
isPrivate = false
}: {
targetType: TargetType
targetId: number
content: string
replyId: number | null
isPrivate: boolean
}
): Promise<BaseResponse<Comment>> { ): Promise<BaseResponse<Comment>> {
const res = await axiosClient.post<BaseResponse<Comment>>('/comment/c', data) const res = await axiosClient.post<BaseResponse<Comment>>('/comment/c', {
targetType,
targetId,
content,
replyId,
isPrivate
})
return res.data return res.data
} }
@ -24,28 +42,23 @@ export async function deleteComment(id: number): Promise<void> {
await axiosClient.delete(`/comment/c/${id}`) await axiosClient.delete(`/comment/c/${id}`)
} }
export interface ListCommentsParams {
targetType: TargetType
targetId: number
depth?: number
orderBy?: OrderBy
desc?: boolean
page?: number
size?: number
commentId?: number
}
export async function listComments(params: ListCommentsParams): Promise<BaseResponse<Comment[]>> { export async function listComments({
const {
targetType, targetType,
targetId, targetId,
depth = 0, depth = 0,
commentId = 0,
orderBy = OrderBy.CreatedAt, orderBy = OrderBy.CreatedAt,
desc = true, desc = true,
page = 1, page = 1,
size = 10, size = 10,
commentId = 0, }: {
} = params targetType: TargetType
targetId: number
depth: number
commentId: number
} & PaginationParams
) {
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, { const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
params: { params: {
targetType, targetType,

View File

@ -4,7 +4,7 @@ import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, Square
import { RenderMarkdown } from "@/components/common/markdown"; import { RenderMarkdown } from "@/components/common/markdown";
import { isMobileByUA } from "@/utils/server/device"; import { isMobileByUA } from "@/utils/server/device";
import { calculateReadingTime } from "@/utils/common/post"; import { calculateReadingTime } from "@/utils/common/post";
import CommentSection from "@/components/comment"; import {CommentSection} from "@/components/neo-comment";
import { TargetType } from '../../models/types'; import { TargetType } from '../../models/types';
function PostMeta({ post }: { post: Post }) { function PostMeta({ post }: { post: Post }) {

View File

@ -10,7 +10,7 @@ import { createComment } from "@/api/comment";
import { CircleUser } from "lucide-react"; import { CircleUser } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { TargetType } from "@/models/types"; import { TargetType } from "@/models/types";
import { useToLogin } from "@/hooks/use-to-login"; import { useToLogin } from "@/hooks/use-route";
import NeedLogin from "../common/need-login"; import NeedLogin from "../common/need-login";
@ -57,6 +57,7 @@ export function CommentInput(
); );
}); });
}; };
return ( return (
<div className="fade-in-up"> <div className="fade-in-up">
<div className="flex py-4 fade-in"> <div className="flex py-4 fade-in">

View File

@ -16,7 +16,7 @@ import type { User } from "@/models/user";
import Link from "next/link"; import Link from "next/link";
import "./comment-animations.css"; import "./comment-animations.css";
export function CommentItem({comment, parentComment}:{comment: Comment, parentComment: Comment | null}) { export function CommentItem({comment, parentComment, onCommentDelete}:{comment: Comment, parentComment: Comment | null, onCommentDelete: () => void}) {
const t = useTranslations("Comment") const t = useTranslations("Comment")
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [liked, setLiked] = useState(comment.isLiked); const [liked, setLiked] = useState(comment.isLiked);
@ -50,6 +50,7 @@ export function CommentItem({comment, parentComment}:{comment: Comment, parentCo
deleteComment(id) deleteComment(id)
.then(() => { .then(() => {
toast.success(t("delete_success")); toast.success(t("delete_success"));
onCommentDelete();
}) })
.catch(error => { .catch(error => {
toast.error(t("delete_failed") + ": " + error.message); toast.error(t("delete_failed") + ": " + error.message);
@ -143,11 +144,28 @@ function RepliesList({ parentComment }: { parentComment: Comment }) {
}); });
}, [parentComment]) }, [parentComment])
const onCommentDelete = () => {
listComments({
targetType: parentComment.targetType,
targetId: parentComment.targetId,
commentId: parentComment.id,
depth: parentComment.depth + 1,
orderBy: OrderBy.CreatedAt,
desc: false,
page: 1,
size: 9999,
}).then(res => {
setReplies(res.data);
}).catch(error => {
toast.error(t("load_replies_failed") + ": " + error.message);
});
}
return ( return (
<div className="mt-4 border-l border-slate-300 pl-4"> <div className="mt-4 border-l border-slate-300 pl-4">
{replies.map(reply => ( {replies.map(reply => (
<div key={reply.id} className="mb-4"> <div key={reply.id} className="mb-4">
<CommentItem comment={reply} parentComment={parentComment} /> <CommentItem comment={reply} parentComment={parentComment} onCommentDelete={onCommentDelete} />
</div> </div>
))} ))}
</div> </div>

View File

@ -38,7 +38,7 @@ export default function CommentSection(props: CommentAreaProps) {
}) })
}, [targetType, targetId]); }, [targetType, targetId]);
const onCommentSubmitted = () => { const onCommentsChange = () => {
// 重新加载评论列表 // 重新加载评论列表
listComments({ listComments({
targetType, targetType,
@ -54,17 +54,19 @@ export default function CommentSection(props: CommentAreaProps) {
}) })
} }
// TODO: 支持分页加载更多评论
return ( return (
<div> <div>
<Separator className="my-16" /> <Separator className="my-16" />
<div className="font-bold text-2xl"></div> <div className="font-bold text-2xl"></div>
<CommentInput targetType={targetType} targetId={targetId} replyId={0} onCommentSubmitted={onCommentSubmitted} /> <CommentInput targetType={targetType} targetId={targetId} replyId={0} onCommentSubmitted={onCommentsChange} />
<div className="mt-4"> <div className="mt-4">
<Suspense fallback={<CommentLoading />}> <Suspense fallback={<CommentLoading />}>
{comments.map((comment, idx) => ( {comments.map((comment, idx) => (
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}> <div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}>
<Separator className="my-2" /> <Separator className="my-2" />
<CommentItem comment={comment} parentComment={null} /> <CommentItem comment={comment} parentComment={null} onCommentDelete={onCommentsChange} />
</div> </div>
))} ))}
</Suspense> </Suspense>

View File

@ -1,4 +1,4 @@
import { useToLogin } from "@/hooks/use-to-login"; import { useToLogin } from "@/hooks/use-route";
export default function NeedLogin( export default function NeedLogin(
{ children }: { children?: React.ReactNode } { children }: { children?: React.ReactNode }

View File

@ -0,0 +1,279 @@
"use client"
import { clickToUserprofile, useToLogin } from "@/hooks/use-route";
import { User } from "@/models/user";
import { useTranslations } from "next-intl";
import { Suspense, use, useEffect, useState } from "react";
import NeedLogin from "../common/need-login";
import { toast } from "sonner";
import { getGravatarByUser } from "../common/gravatar";
import { CircleUser, Reply, Trash } from "lucide-react";
import { Textarea } from "../ui/textarea";
import { Comment } from "@/models/comment";
import { createComment, deleteComment, listComments } from "@/api/comment";
import { TargetType } from "@/models/types";
import { OrderBy } from "@/models/common";
import { Separator } from "../ui/separator";
import { getLoginUser } from "@/api/user";
import { Skeleton } from "../ui/skeleton";
import { toggleLike } from "@/api/like";
import { useDoubleConfirm } from "@/hooks/use-double-confirm";
import "./style.css";
const DEFAULT_PAGE_SIZE = 20;
export function CommentSection(
{
targetType,
targetId
}: {
targetType: TargetType,
targetId: number
}
) {
const t = useTranslations('Comment')
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [refreshCommentsKey, setRefreshCommentsKey] = useState(0);
// 获取当前登录用户
useEffect(() => {
getLoginUser()
.then(response => {
setCurrentUser(response.data);
})
}, [])
// 加载0/顶层评论
useEffect(() => {
listComments({
targetType,
targetId,
depth: 0,
orderBy: OrderBy.CreatedAt,
desc: true,
page: 1,
size: DEFAULT_PAGE_SIZE,
commentId: 0
}).then(response => {
setComments(response.data);
});
}, [refreshCommentsKey])
const onCommentSubmitted = (commentContent: string) => {
createComment({
targetType,
targetId,
content: commentContent,
replyId: null,
isPrivate: false
}).then(() => {
toast.success(t("comment_success"));
setRefreshCommentsKey(k => k + 1);
})
}
const onCommentDelete = (commentId: number) => {
deleteComment(commentId).then(() => {
toast.success(t("delete_success"));
setRefreshCommentsKey(k => k + 1);
}).catch(error => {
toast.error(t("delete_failed") + ": " + error.message);
});
}
return (
<div>
<Separator className="my-16" />
<div className="font-bold text-2xl">{t("comment")}</div>
<CommentInput
user={currentUser}
onCommentSubmitted={onCommentSubmitted}
/>
<div className="mt-4">
<Suspense fallback={<CommentLoading />}>
{comments.map((comment, idx) => (
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}>
<Separator className="my-2" />
<CommentItem
user={currentUser}
comment={comment}
parentComment={null}
onCommentDelete={onCommentDelete}
/>
</div>
))}
</Suspense>
</div>
</div>
)
}
export function CommentInput(
{ user, onCommentSubmitted, }: { user: User | null, onCommentSubmitted: (commentContent: string) => void }
) {
const t = useTranslations('Comment')
const handleToLogin = useToLogin()
const [commentContent, setCommentContent] = useState("");
const handleCommentSubmit = async () => {
if (!user) {
toast.error(<NeedLogin>{t("login_required")}</NeedLogin>);
return;
}
if (!commentContent.trim()) {
toast.error(t("content_required"));
return;
}
onCommentSubmitted(commentContent);
setCommentContent("");
};
return (
<div className="fade-in-up">
<div className="flex py-4 fade-in">
<div onClick={user ? clickToUserprofile(user.username) : handleToLogin} className="flex-shrink-0 w-10 h-10 fade-in">
{user && getGravatarByUser(user)}
{!user && <CircleUser className="w-full h-full fade-in" />}
</div>
<div className="flex-1 pl-2 fade-in-up">
<Textarea
placeholder={t("placeholder")}
className="w-full p-2 border border-gray-300 rounded-md fade-in-up"
value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}
/>
</div>
</div>
{commentContent.trim() && (
<div className="flex justify-end fade-in-up">
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors fade-in-up">
{t("submit")}
</button>
</div>
)}
</div>
);
}
export function CommentItem(
{
user,
comment,
parentComment,
onCommentDelete,
}: {
user: User | null,
comment: Comment,
parentComment: Comment | null,
onCommentDelete: (commentId: number) => void,
}
) {
const t = useTranslations("Comment")
const { confirming, onClick, onBlur } = useDoubleConfirm();
const [likeCount, setLikeCount] = useState(comment.likeCount);
const [liked, setLiked] = useState(comment.isLiked);
const [replyCount, setReplyCount] = useState(comment.replyCount);
const [showReplies, setShowReplies] = useState(false);
const [showReplyInput, setShowReplyInput] = useState(false);
const handleToggleLike = () => {
toggleLike(
{ targetType: TargetType.Comment, targetId: comment.id }
).then(res => {
toast.success(res.data.status ? t("like_success") : t("unlike_success"));
setLiked(res.data.status);
setLikeCount(res.data.status ? likeCount + 1 : likeCount - 1);
}).catch(error => {
toast.error(t("like_failed") + ": " + error.message);
});
}
const onReply = (replyContent: string) => {
setShowReplies(true);
setShowReplyInput(false);
setReplyCount(replyCount + 1);
}
return (
<div className="flex">
<div className="fade-in">
{getGravatarByUser(comment.user)}
</div>
<div className="flex-1 pl-2 fade-in-up">
<div className="font-bold text-base text-slate-800 dark:text-slate-100 fade-in-up">{comment.user.nickname}</div>
<p className="text-lg text-slate-600 dark:text-slate-400 fade-in">
{
parentComment &&
<>{t("reply")} <button onClick={clickToUserprofile(parentComment.user.nickname)} className="text-primary">{parentComment?.user.nickname}</button>: </>
}
{comment.content}
</p>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in">
<span>{new Date(comment.updatedAt).toLocaleString()}</span>
{/* 点赞按钮 */}
<button
title={t(liked ? "unlike" : "like")}
onClick={handleToggleLike}
className={`flex items-center justify-center px-2 py-1 h-5 text-xs rounded
${liked ? 'bg-primary text-primary-foreground dark:text-white' : 'bg-slate-400 hover:bg-slate-600'}
dark:hover:bg-slate-500 fade-in`}
>
👍 {likeCount}
</button>
{/* 回复按钮 */}
<button
title={t("reply")}
onClick={() => setShowReplyInput(!showReplyInput)}
className="flex items-center justify-center px-2 py-1 h-5
text-primary-foreground dark:text-white text-xs
rounded bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up">
<Reply className="w-3 h-3" />
</button>
{/* 删除按钮 仅自己的评论可见 */}
{user?.id === comment.user.id && (
<button
title={t("delete")}
onClick={() => onClick(() => { comment.id && onCommentDelete(comment.id); })}
onBlur={onBlur}
className={`flex items-center justify-center px-2 py-1 h-5 text-xs rounded bg-red-500 text-white hover:bg-red-600 transition animated-btn fade-in`}
>
<Trash className="w-3 h-3" />
{confirming && (
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
)}
</button>)}
{replyCount > 0 &&
<button onClick={() => setShowReplies(!showReplies)} className="fade-in-up">
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
</button>
}
</div>
{showReplyInput && <CommentInput user={user} onCommentSubmitted={onReply} />}
</div>
</div>
)
}
function CommentLoading() {
return (
<div className="space-y-6 py-8">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex gap-3 fade-in-up" style={{ animationDelay: `${i * 80}ms` }}>
<Skeleton className="w-10 h-10 rounded-full fade-in" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/4 fade-in" />
<Skeleton className="h-4 w-3/4 fade-in" />
<Skeleton className="h-4 w-2/3 fade-in" />
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,58 @@
/* 淡入上移 */
.fade-in-up {
animation: fadeInUp 0.4s cubic-bezier(0.4,0,0.2,1);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 淡入 */
.fade-in {
animation: fadeIn 0.4s cubic-bezier(0.4,0,0.2,1);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 删除确认动画:缩放+淡入+颜色渐变 */
.confirm-delete-anim {
animation: fadeInScale 0.25s cubic-bezier(0.4,0,0.2,1);
transition: color 0.25s, background 0.25s;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.8);
color: #fff;
background: #f87171;
}
to {
opacity: 1;
transform: scale(1);
color: #fff;
background: #dc2626;
}
}
/* 按钮颜色、宽度、文本变化动画 */
.animated-btn {
transition:
background 0.25s cubic-bezier(0.4,0,0.2,1),
color 0.25s cubic-bezier(0.4,0,0.2,1),
width 0.25s cubic-bezier(0.4,0,0.2,1),
min-width 0.25s cubic-bezier(0.4,0,0.2,1);
will-change: background, color, width, min-width;
}
/* 输入框高度变化动画 */
.animated-textarea {
transition: min-height 0.25s cubic-bezier(0.4,0,0.2,1), height 0.25s cubic-bezier(0.4,0,0.2,1);
}

View File

@ -0,0 +1,25 @@
import { useRef, useState } from "react";
export function useDoubleConfirm(timeout = 2000) {
const [confirming, setConfirming] = useState(false);
const timer = useRef<NodeJS.Timeout | null>(null);
const onClick = (callback: () => void) => {
if (confirming) {
setConfirming(false);
if (timer.current) clearTimeout(timer.current);
callback();
} else {
setConfirming(true);
timer.current = setTimeout(() => setConfirming(false), timeout);
}
};
// 可选:失焦时自动取消
const onBlur = () => {
setConfirming(false);
if (timer.current) clearTimeout(timer.current);
};
return { confirming, onClick, onBlur };
}

View File

@ -0,0 +1,27 @@
import { useRouter, usePathname } from "next/navigation"
/**
* 用于跳转到登录页并自动带上 redirect_back 参数
* 用法const toLogin = useToLogin(); <Button onClick={toLogin}>去登录</Button>
*/
export function useToLogin() {
const router = useRouter()
const pathname = usePathname()
return () => {
router.push(`/login?redirect_back=${encodeURIComponent(pathname)}`)
}
}
export function clickToUserprofile(username: string) {
const router = useRouter()
return () => {
router.push(`/user/${username}`)
}
}
export function clickToPost(postId: number) {
const router = useRouter()
return () => {
router.push(`/p/${postId}`)
}
}

View File

@ -1,13 +0,0 @@
import { useRouter, usePathname } from "next/navigation"
/**
* 用于跳转到登录页并自动带上 redirect_back 参数
* 用法const toLogin = useToLogin(); <Button onClick={toLogin}>去登录</Button>
*/
export function useToLogin() {
const router = useRouter()
const pathname = usePathname()
return () => {
router.push(`/login?redirect_back=${encodeURIComponent(pathname)}`)
}
}

View File

@ -3,17 +3,21 @@
"title": "Hello world!" "title": "Hello world!"
}, },
"Comment": { "Comment": {
"comment": "评论",
"placeholder": "写你的评论...", "placeholder": "写你的评论...",
"submit": "提交", "submit": "提交",
"login_required": "请先登录后再评论。", "login_required": "请先登录后再评论。",
"content_required": "评论内容不能为空。", "content_required": "评论内容不能为空。",
"comment_success": "评论提交成功!", "comment_success": "评论成功!",
"comment_failed": "评论提交失败", "comment_failed": "评论失败",
"delete": "删除", "delete": "删除",
"delete_success": "评论删除成功", "delete_success": "评论已经删除",
"confirm_delete": "确定删除?", "delete_failed": "删除评论失败",
"like_success": "点赞评论成功", "confirm_delete": "确定吗?",
"unlike_success": "取消点赞成功", "like": "点赞",
"unlike": "取消点赞",
"like_success": "点赞成功",
"unlike_success": "已取消点赞",
"like_failed": "点赞失败", "like_failed": "点赞失败",
"reply": "回复", "reply": "回复",
"expand_replies": "展开 {count} 条回复", "expand_replies": "展开 {count} 条回复",

View File

@ -17,14 +17,6 @@ export interface Comment {
isLiked: boolean isLiked: boolean
} }
export interface CreateCommentRequest {
targetType: TargetType
targetId: number
content: string
replyId: number | null// 可选字段,默认为 null
isPrivate: boolean// 可选字段,默认为 false
}
export interface UpdateCommentRequest { export interface UpdateCommentRequest {
id: number id: number
content: string content: string