feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user