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

@@ -3,9 +3,9 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api";
import { canStaffSoftDelete, canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck, Lock, LockOpen } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiTogglePostLock, apiAdminApprovePost, type User } from "@/lib/api";
import { canStaffSoftDelete, canModerateBoard, isAdminOrAbove, isStaff } from "@/lib/roles";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
@@ -19,6 +19,12 @@ interface PostActionsProps {
recommended: boolean;
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */
status: string;
/** 帖子创建时间:自动编辑锁计时基准(staff 豁免) */
createdAt: string;
/** 帖子是否被管理员手动锁定(staff 豁免) */
locked: boolean;
/** 帖子可编辑时长(小时);0=不锁定;staff 豁免 */
postEditLockHours: number;
/** 供删除确认展示 */
title?: string;
}
@@ -31,6 +37,9 @@ export default function PostActions({
pinned,
recommended,
status,
createdAt,
locked,
postEditLockHours,
title,
}: PostActionsProps) {
const router = useRouter();
@@ -39,6 +48,7 @@ export default function PostActions({
const [deleting, setDeleting] = useState(false);
const [isPinned, setIsPinned] = useState(pinned > 0);
const [isRecommended, setIsRecommended] = useState(recommended);
const [isLocked, setIsLocked] = useState(locked);
const [acting, setActing] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [staffDeleteOpen, setStaffDeleteOpen] = useState(false);
@@ -70,6 +80,13 @@ export default function PostActions({
!!user &&
(user.id === authorId || canStaffSoftDelete(user, boardId, authorRole));
const isAdmin = !!user && isAdminOrAbove(user.role);
const staffUser = !!user && isStaff(user.role);
// 编辑入口:手动锁帖或超过可编辑时限时对普通用户隐藏,staff 豁免
const editTimeLocked =
!staffUser &&
postEditLockHours > 0 &&
Date.now() - new Date(createdAt).getTime() > postEditLockHours * 3_600_000;
const showEdit = canManage && !(isLocked && !staffUser) && !editTimeLocked;
const canApprove =
!!user && status === "pending" && canModerateBoard(user, boardId);
// staff 删他人帖:走类型+理由;作者自删:简单确认
@@ -139,6 +156,21 @@ export default function PostActions({
}
};
const handleLock = async () => {
setActing(true);
setOpen(false);
try {
const res = await apiTogglePostLock(postId);
setIsLocked(res.locked);
toast(res.locked ? "已锁定,普通用户不可编辑与回复" : "已解除锁定", "ok");
router.refresh();
} catch {
toast("操作失败,请稍后重试");
} finally {
setActing(false);
}
};
if (!canManage) return null;
const itemCls =
@@ -212,18 +244,35 @@ export default function PostActions({
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>
{isRecommended && <Check size={14} className="shrink-0" />}
</button>
<button
type="button"
role="menuitem"
onClick={handleLock}
className={itemCls}
style={isLocked ? { color: "var(--danger)" } : { color: "var(--ink)" }}
>
{isLocked ? (
<LockOpen size={14} className="shrink-0" />
) : (
<Lock size={14} className="shrink-0" />
)}
<span className="flex-1 text-left">{isLocked ? "解除锁定" : "锁定"}</span>
{isLocked && <Check size={14} className="shrink-0" />}
</button>
<div style={{ height: 1, background: "var(--line)" }} />
</>
)}
<Link
href={`/post/${postId}/edit`}
role="menuitem"
className={`${itemCls} items-center`}
style={{ color: "var(--ink)" }}
>
<Pencil size={14} className="shrink-0" />
<span className="flex-1 text-left">编辑帖子</span>
</Link>
{showEdit && (
<Link
href={`/post/${postId}/edit`}
role="menuitem"
className={`${itemCls} items-center`}
style={{ color: "var(--ink)" }}
>
<Pencil size={14} className="shrink-0" />
<span className="flex-1 text-left">编辑帖子</span>
</Link>
)}
<button
type="button"
role="menuitem"