feat: 新增站点公告系统与多项体验优化

此提交完成了以下核心功能与优化:
1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示
2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制
3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频
4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化
5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
2026-09-13 21:38:01 +08:00
parent 9bae6f2513
commit 996a870015
41 changed files with 2846 additions and 786 deletions

View File

@@ -1,30 +1,54 @@
"use client";
import { useState, useEffect } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Trash2, MessagesSquare } from "lucide-react";
import { apiCreateComment, apiDeleteComment, apiMe, type Comment, type User } from "@/lib/api";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { apiCreateComment, apiDeleteComment, type CommentNode, type User } from "@/lib/api";
import { toast } from "@/lib/toast";
import Avatar from "./Avatar";
import Pagination from "./Pagination";
interface CommentSectionProps {
postId: string;
comments: Comment[];
comments: CommentNode[];
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
totalComments: number; // 全部评论数(含回复)→ 头部徽标
page: number;
size: number;
// SSR 直出的登录用户(layout 已取,避免客户端再请求一次 /api/me)
user: User | null;
}
export default function CommentSection({ postId, comments }: CommentSectionProps) {
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
const CONFIRM_RESET_MS = 3000;
// 递归统计子树回复数(删除级联提示用)
function countReplies(node: CommentNode): number {
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
}
export default function CommentSection({
postId,
comments,
total,
totalComments,
page,
size,
user,
}: CommentSectionProps) {
const router = useRouter();
const [content, setContent] = useState("");
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
const [replyContent, setReplyContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
useEffect(() => {
apiMe()
.then((res) => setUser(res.user || null))
.catch(() => setUser(null));
}, []);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -34,6 +58,7 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
const res = await apiCreateComment(postId, content);
if (res.comment) {
setContent("");
// 新楼层在最后一页:刷新后由服务端直出最新分页
router.refresh();
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
@@ -47,8 +72,38 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
}
};
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyTarget || !replyContent.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, replyContent, replyTarget.id);
if (res.comment) {
setReplyTarget(null);
setReplyContent("");
// 刷新后由 SSR 直出新树(子评论全量挂在本楼)
router.refresh();
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
} else {
toast(res.error || "回复失败");
}
} catch {
toast("网络错误,回复失败");
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: number) => {
if (!confirm("确定要删除这条评论吗?")) return;
if (confirmingId !== commentId) {
setConfirmingId(commentId);
setTimeout(() => {
setConfirmingId((cur) => (cur === commentId ? null : cur));
}, CONFIRM_RESET_MS);
return;
}
setConfirmingId(null);
setDeletingId(commentId);
try {
const res = await apiDeleteComment(postId, commentId);
@@ -64,26 +119,271 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
}
};
const canDelete = (comment: Comment) =>
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
const canDelete = (comment: CommentNode) =>
user && (user.id === comment.user.id || user.role === "admin");
const openReply = (c: CommentNode) => {
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
setReplyContent("");
};
// 切换某楼层子回复的展开/折叠
const toggleFloor = (floorId: number) => {
setExpandedFloors((prev) => {
const next = new Set(prev);
if (next.has(floorId)) next.delete(floorId);
else next.add(floorId);
return next;
});
};
const totalPages = Math.max(1, Math.ceil(total / size));
// 内联回复表单(出现在目标评论正下方)
const inlineReplyForm = replyTarget && (
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-3">
<div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}>
回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span>
</div>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={2}
autoFocus
className="field"
aria-label={`回复 ${replyTarget.nickname}`}
placeholder="友善回复..."
/>
<div className="flex items-center justify-between gap-3 mt-2">
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
<div className="flex gap-2">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => {
setReplyTarget(null);
setReplyContent("");
}}
>
取消
</button>
<button type="submit" disabled={submitting || !replyContent.trim()} className="btn btn-primary btn-sm">
{submitting ? "发送中..." : "回复"}
</button>
</div>
</div>
</form>
);
// 单条评论渲染(递归):depth 0 = 楼层主评论,>0 = 子回复
// 主评论下方子回复默认折叠,点击"展开 X 条回复"展开
// depth=1(回复楼层)不显示指向;depth≥2(回复子评论)显示"回复者 ▶ 被回复者"
const renderComment = (node: CommentNode, depth: number, floor?: number, parent?: CommentNode) => {
const c = node;
const replyCount = countReplies(node);
const isFloor = depth === 0;
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
const parentAuthor = parent?.user.nickname || parent?.user.username;
if (!isFloor) {
// 子回复:紧凑行式布局(抖音风格)
const isReplyToReply = depth >= 2;
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div className="flex gap-2.5">
<Link href={`/u/${c.user.id}`} className="shrink-0" aria-label={c.user.nickname || c.user.username}>
<Avatar name={c.user.nickname || c.user.username} size={28} />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<Link href={`/u/${c.user.id}`} className="group shrink-0">
<span className="cmt-reply-name group-hover:text-[var(--accent)] transition-colors">
{c.user.nickname || c.user.username}
</span>
</Link>
{isReplyToReply && parent && (
<>
<ChevronRight size={12} className="cmt-reply-arrow" aria-hidden="true" />
<a href={`#comment-${parent.id}`} className="cmt-reply-target">
{parentAuthor}
</a>
</>
)}
</div>
<div className="prose-content prose-comment mt-0.5">
<ReactMarkdown>{c.content}</ReactMarkdown>
</div>
{/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-1.5">
<div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{user && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
title="回复"
>
回复
</button>
)}
</div>
{canDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
disabled={deletingId === c.id}
className={`meta transition-colors disabled:opacity-50 p-1 ${
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
</span>
) : (
<Trash2 size={14} />
)}
</button>
)}
</div>
{replyTarget?.id === c.id && inlineReplyForm}
</div>
</div>
{node.replies && node.replies.length > 0 && (
<div className="cmt-children">
{node.replies.map((r) => renderComment(r, depth + 1, undefined, node))}
</div>
)}
</div>
);
}
// 主评论(楼层)
return (
<div
key={c.id}
id={`comment-${c.id}`}
className="py-5 border-t first:border-t-0 scroll-mt-24"
style={{ borderColor: "var(--line)" }}
>
<div className="flex gap-3">
<Link href={`/u/${c.user.id}`} className="shrink-0 mt-0.5" aria-label={c.user.nickname || c.user.username}>
<Avatar name={c.user.nickname || c.user.username} size={38} />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<Link href={`/u/${c.user.id}`} className="group">
<span className="font-semibold truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
{c.user.nickname || c.user.username}
</span>
</Link>
{floor && (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
)}
</div>
<div className="prose-content prose-comment">
<ReactMarkdown>{c.content}</ReactMarkdown>
</div>
{/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{user && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
title="回复"
>
回复
</button>
)}
</div>
{canDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
disabled={deletingId === c.id}
className={`meta transition-colors disabled:opacity-50 p-1 ${
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
</span>
) : (
<Trash2 size={14} />
)}
</button>
)}
</div>
{replyTarget?.id === c.id && inlineReplyForm}
{/* 折叠/展开子回复 */}
{node.replies && node.replies.length > 0 && (
<div className="mt-3">
<button
type="button"
onClick={() => toggleFloor(c.id)}
className="cmt-fold-btn"
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>
{isExpanded ? "收起回复" : `展开 ${replyCount} 条回复`}
</span>
</button>
{isExpanded && (
<div className="cmt-replies-wrap">
{node.replies.map((r) => renderComment(r, 1, undefined, node))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
return (
<section className="panel p-6 sm:p-8 mt-6">
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
<div className="flex items-center gap-2.5 mb-6">
<h2 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
评论
</h2>
<span
className="text-[12px] font-bold px-2 py-0.5 rounded-full"
className="text-[12px] font-bold px-2 py-0.5 rounded-full tabular-nums"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{comments.length}
{totalComments}
</span>
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
</div>
{user ? (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
ref={textareaRef}
id="comment-input"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
@@ -92,9 +392,10 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
e.currentTarget.form?.requestSubmit();
}
}}
placeholder="友善发言,说说你的看法..."
placeholder="友善发言,发表一个新楼层..."
rows={3}
className="field"
aria-label="写评论"
/>
<div className="flex items-center justify-between gap-3 mt-2.5">
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
@@ -111,52 +412,14 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
登录后参与讨论
</span>
<Link href="/login" className="btn btn-line btn-sm">
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
去登录
</Link>
</div>
)}
<div>
{comments.map((comment) => (
<div
key={comment.id}
className="flex gap-3.5 py-5 border-t first:border-t-0"
style={{ borderColor: "var(--line)" }}
>
<Link href={`/u/${comment.user.id}`} className="shrink-0 mt-0.5" aria-label={comment.user.nickname}>
<Avatar name={comment.user.nickname || comment.user.username} size={38} />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-3 mb-1.5">
<div className="flex items-center gap-2.5 min-w-0 flex-wrap">
<Link href={`/u/${comment.user.id}`} className="group">
<span
className="text-sm font-semibold truncate group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{comment.user.nickname}
</span>
</Link>
<span className="meta text-xs">{new Date(comment.created_at).toLocaleString("zh-CN")}</span>
</div>
{canDelete(comment) && (
<button
onClick={() => handleDelete(comment.id)}
disabled={deletingId === comment.id}
className="meta hover:text-[var(--danger)] transition-colors disabled:opacity-50 p-1"
aria-label="删除评论"
>
<Trash2 size={14} />
</button>
)}
</div>
<p className="text-[15px] leading-relaxed" style={{ color: "var(--ink)" }}>
{comment.content}
</p>
</div>
</div>
))}
{comments.map((node, i) => renderComment(node, 0, (page - 1) * size + i + 1))}
{comments.length === 0 && (
<div className="text-center py-14">
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
@@ -167,6 +430,8 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
</div>
)}
</div>
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
</section>
);
}