feat: 评论/帖子编辑历史与编辑标记,内容锁定

- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
This commit is contained in:
2026-09-25 23:41:45 +08:00
parent cab8230803
commit 5270fee2b7
40 changed files with 2521 additions and 350 deletions

View File

@@ -1,4 +1,4 @@
"use client";
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
@@ -7,6 +7,7 @@ import {
MessagesSquare,
ChevronRight,
ChevronDown,
Lock,
} from "lucide-react";
import {
apiCreateComment,
@@ -16,13 +17,14 @@ import {
apiUpdateComment,
apiAcceptBounty,
apiAcceptQuestion,
apiCommentHistory,
apiAdminApproveComment,
apiAdminRejectComment,
postDeleteTypeLabel,
type CommentNode,
type User,
} from "@/lib/api";
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
import { canModerateBoard, canStaffSoftDelete, canPurgeContent, isStaff } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { formatCommentTime, formatDateTime } from "@/lib/format";
import { useAllowComments, useCommentsRequireLogin } from "@/components/CommentsPolicyProvider";
@@ -36,7 +38,7 @@ import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import MarkdownEditor from "@/components/MarkdownEditor";
import ConfirmDialog from "@/components/ConfirmDialog";
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
import EditHistoryModal from "@/components/EditHistoryModal";
interface CommentSectionProps {
postId: string;
@@ -56,6 +58,12 @@ interface CommentSectionProps {
acceptedCommentId?: number;
/** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */
winnerCommentIds?: number[];
/** 帖子是否被管理员手动锁定(普通用户不可回复/编辑评论,staff 豁免) */
postLocked: boolean;
/** 评论可编辑时长(小时);0=不锁定;staff 豁免 */
commentEditLockHours: number;
/** 旧帖回复确认提示(后端按查看者计算;存在时提交回复前需用户确认,确认后发送并扣分) */
necroReply?: { after_hours: number; penalty: number } | null;
}
// 递归统计子树回复数(彻底删除级联提示用)
@@ -63,6 +71,9 @@ function countReplies(node: CommentNode): number {
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
}
/** 评论正文字符上限(与后端 handler/comment.go 校验一致) */
const COMMENT_MAX = 5000;
function findComment(nodes: CommentNode[], id: number): CommentNode | null {
for (const n of nodes) {
if (n.id === id) return n;
@@ -196,11 +207,22 @@ export default function CommentSection({
acceptMode = null,
acceptedCommentId = 0,
winnerCommentIds = [],
postLocked,
commentEditLockHours,
necroReply = null,
}: CommentSectionProps) {
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
const router = useRouter();
const commentsOpen = useAllowComments();
const commentsRequireLogin = useCommentsRequireLogin();
const staffViewer = !!user && isStaff(user.role);
/** 锁帖后普通用户禁止回复与编辑自己的评论,staff 豁免 */
const replyBlocked = postLocked && !staffViewer;
/** 评论是否已超过可编辑时限(staff 豁免;0=不启用) */
const commentTimeLocked = (createdAt: string) =>
!staffViewer &&
commentEditLockHours > 0 &&
Date.now() - new Date(createdAt).getTime() > commentEditLockHours * 3_600_000;
const [content, setContent] = useState("");
// 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离
const [replyTarget, setReplyTarget] = useState<{
@@ -221,6 +243,8 @@ export default function CommentSection({
const [replyUploading, setReplyUploading] = useState(false);
const [editUploading, setEditUploading] = useState(false);
const [awayHref, setAwayHref] = useState<string | null>(null);
// 旧帖回复确认:记录待确认的提交入口(主楼层 / 内联回复)
const [necroConfirm, setNecroConfirm] = useState<null | "floor" | "reply">(null);
const [discardReplyOpen, setDiscardReplyOpen] = useState(false);
const [discardEditOpen, setDiscardEditOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -237,6 +261,7 @@ export default function CommentSection({
const [editContent, setEditContent] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
const [historyCommentContent, setHistoryCommentContent] = useState("");
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
@@ -304,6 +329,11 @@ export default function CommentSection({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 旧帖回复:确认后才发送并扣分(后端按查看者下发提示,staff/待审用户不会拿到)
if (necroReply) {
setNecroConfirm("floor");
return;
}
void submitFloor();
};
@@ -347,6 +377,11 @@ export default function CommentSection({
const handleSubmitReply = (e: React.FormEvent) => {
e.preventDefault();
// 旧帖回复:确认后才发送并扣分
if (necroReply) {
setNecroConfirm("reply");
return;
}
void submitReply();
};
@@ -472,9 +507,16 @@ export default function CommentSection({
(user.id === comment.user?.id ||
canStaffSoftDelete(user, boardId, comment.user?.role));
const canEdit = (comment: CommentNode) => canSoftDelete(comment);
const canEdit = (comment: CommentNode) => {
if (!canSoftDelete(comment)) return false;
// 作者本人编辑:锁帖与可编辑时限限制(staff 豁免;版主编辑他人不受此限)
if (user?.id === comment.user?.id && (replyBlocked || commentTimeLocked(comment.created_at))) {
return false;
}
return true;
};
const canViewHistory = !!user && canModerateBoard(user, boardId);
const canViewHistory = !!user;
const canPurge = (comment: CommentNode) =>
!!user && canPurgeContent(user.role) && canModerateBoard(user, boardId);
@@ -615,9 +657,10 @@ export default function CommentSection({
density="comment"
value={editContent}
onChange={(v) => {
setEditContent(v);
setEditContent(v.slice(0, COMMENT_MAX));
if (editError) setEditError("");
}}
maxChars={COMMENT_MAX}
disabled={savingEdit}
autoFocus
allowHide={false}
@@ -671,7 +714,14 @@ export default function CommentSection({
if (!c.edited) return null;
if (canViewHistory) {
return (
<button type="button" className="cmt-edited" onClick={() => setHistoryCommentId(c.id)}>
<button
type="button"
className="cmt-edited"
onClick={() => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
}}
>
已编辑
</button>
);
@@ -693,11 +743,15 @@ export default function CommentSection({
onSelect: () => startEdit(c),
});
}
if (canViewHistory) {
// 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录
if (canViewHistory && c.edited) {
items.push({
key: "history",
label: "编辑历史",
onSelect: () => setHistoryCommentId(c.id),
onSelect: () => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
},
});
}
if (canRestore(c)) {
@@ -758,9 +812,10 @@ export default function CommentSection({
density="comment"
value={replyContent}
onChange={(v) => {
setReplyContent(v);
setReplyContent(v.slice(0, COMMENT_MAX));
if (replyError) setReplyError("");
}}
maxChars={COMMENT_MAX}
disabled={submitting}
autoFocus
allowHide={false}
@@ -1015,7 +1070,7 @@ export default function CommentSection({
</div>
{renderContentOrEditor(c)}
<div className="cmt-actions">
{commentsOpen && user && editingId !== c.id && (
{commentsOpen && user && !replyBlocked && editingId !== c.id && (
<button
type="button"
onClick={() => openReply(c, floor)}
@@ -1117,7 +1172,7 @@ export default function CommentSection({
</div>
{renderContentOrEditor(c)}
<div className="cmt-actions">
{commentsOpen && user && editingId !== c.id && (
{commentsOpen && user && !replyBlocked && editingId !== c.id && (
<button
type="button"
onClick={() => openReply(c, floor)}
@@ -1214,7 +1269,18 @@ export default function CommentSection({
) : null}
</div>
{user ? (
{replyBlocked ? (
<div
className="mb-8 px-5 py-4 rounded-2xl flex items-center gap-2.5 border"
style={{ borderColor: "var(--line)", background: "transparent" }}
role="status"
>
<Lock size={15} style={{ color: "var(--ink-3)" }} className="shrink-0" />
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
帖子已被锁定,暂时无法回复
</span>
</div>
) : user ? (
<form onSubmit={handleSubmit} className="mb-6">
<div className="cmt-md-editor cmt-md-editor-floor scroll-mt-28">
<MarkdownEditor
@@ -1222,9 +1288,10 @@ export default function CommentSection({
density="comment"
value={content}
onChange={(v) => {
setContent(v);
setContent(v.slice(0, COMMENT_MAX));
if (floorError) setFloorError("");
}}
maxChars={COMMENT_MAX}
disabled={submitting}
allowHide={false}
ariaLabel="发表评论"
@@ -1355,9 +1422,29 @@ export default function CommentSection({
onCancel={() => setDiscardEditOpen(false)}
/>
<CommentEditHistoryModal
{/* 旧帖回复确认:确认后才发送并扣分(提示由后端按查看者下发) */}
<ConfirmDialog
open={necroConfirm !== null}
title="回复旧帖提醒"
message={`该帖已超过 ${necroReply?.after_hours ?? 0} 小时没有新回复,回复将扣除 ${necroReply?.penalty ?? 0} 积分。确定继续吗?`}
confirmLabel="继续回复"
onConfirm={() => {
const kind = necroConfirm;
setNecroConfirm(null);
if (kind === "floor") void submitFloor();
else if (kind === "reply") void submitReply();
}}
onCancel={() => setNecroConfirm(null)}
/>
<EditHistoryModal
open={historyCommentId != null}
commentId={historyCommentId}
title="评论编辑历史"
currentContent={historyCommentContent}
fetchPage={(p, s) => {
if (historyCommentId == null) return Promise.reject(new Error("未选择评论"));
return apiCommentHistory(historyCommentId, p, s);
}}
onClose={() => setHistoryCommentId(null)}
/>
</section>