feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer } from "lucide-react";
|
||||
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer, Lock, Hourglass, Coins, History } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AdminSettingsGroup,
|
||||
@@ -18,6 +18,59 @@ function InstantNote({ saving }: { saving?: boolean }) {
|
||||
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
|
||||
}
|
||||
|
||||
/** 内容锁定 4 键(小时上限 8760 / 积分上限 100,后端裁剪) */
|
||||
type LockKeys = Pick<
|
||||
PublicSettings,
|
||||
"post_edit_lock_hours" | "comment_edit_lock_hours" | "necro_reply_after_hours" | "necro_reply_penalty"
|
||||
>;
|
||||
type LockKey = keyof LockKeys;
|
||||
|
||||
const LOCK_META: { key: LockKey; icon: React.ReactNode; label: string; hint: string; max: number; unit: string; toast: (v: number) => string }[] = [
|
||||
{
|
||||
key: "post_edit_lock_hours",
|
||||
icon: <Lock size={15} />,
|
||||
label: "帖子可编辑时长",
|
||||
hint: "发帖后仅在该时长内可编辑(0=不限制,上限 8760 小时),到期后编辑入口隐藏;管理团队不受限。",
|
||||
max: 8760,
|
||||
unit: "小时",
|
||||
toast: (v) => (v === 0 ? "已关闭帖子编辑时限" : `帖子可编辑时长已设为 ${v} 小时`),
|
||||
},
|
||||
{
|
||||
key: "comment_edit_lock_hours",
|
||||
icon: <Hourglass size={15} />,
|
||||
label: "评论可编辑时长",
|
||||
hint: "评论发布后仅在该时长内可编辑(0=不限制,上限 8760 小时);管理团队不受限。",
|
||||
max: 8760,
|
||||
unit: "小时",
|
||||
toast: (v) => (v === 0 ? "已关闭评论编辑时限" : `评论可编辑时长已设为 ${v} 小时`),
|
||||
},
|
||||
{
|
||||
key: "necro_reply_after_hours",
|
||||
icon: <History size={15} />,
|
||||
label: "旧帖回复判定时长",
|
||||
hint: "回复超过该时长的帖子将提醒作者并按下方分值扣分(0=关闭,上限 8760 小时);管理团队不受限。",
|
||||
max: 8760,
|
||||
unit: "小时",
|
||||
toast: (v) => (v === 0 ? "已关闭旧帖回复提醒" : `旧帖判定时长已设为 ${v} 小时`),
|
||||
},
|
||||
{
|
||||
key: "necro_reply_penalty",
|
||||
icon: <Coins size={15} />,
|
||||
label: "旧帖回复扣除积分",
|
||||
hint: "触发旧帖回复时扣除的积分(0=仅提醒不扣分,上限 100);积分不足将无法回复。",
|
||||
max: 100,
|
||||
unit: "分",
|
||||
toast: (v) => (v === 0 ? "旧帖回复仅提醒不扣分" : `旧帖回复将扣除 ${v} 积分`),
|
||||
},
|
||||
];
|
||||
|
||||
const pickLock = (s: PublicSettings): LockKeys => ({
|
||||
post_edit_lock_hours: s.post_edit_lock_hours,
|
||||
comment_edit_lock_hours: s.comment_edit_lock_hours,
|
||||
necro_reply_after_hours: s.necro_reply_after_hours,
|
||||
necro_reply_penalty: s.necro_reply_penalty,
|
||||
});
|
||||
|
||||
/** 内容互动开关 + 访问安全模块 */
|
||||
export default function AccessSettings({ initial }: { initial: PublicSettings }) {
|
||||
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
||||
@@ -37,8 +90,12 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
const [togglingTrust, setTogglingTrust] = useState(false);
|
||||
const [togglingWithoutReview, setTogglingWithoutReview] = useState(false);
|
||||
const [savingCooldown, setSavingCooldown] = useState(false);
|
||||
const [lockForm, setLockForm] = useState<LockKeys>(() => pickLock(initial));
|
||||
const [savedLock, setSavedLock] = useState<LockKeys>(() => pickLock(initial));
|
||||
const [savingLockKey, setSavingLockKey] = useState<LockKey | null>(null);
|
||||
const [moduleDirty, setModuleDirty] = useState(false);
|
||||
const leave = useUnsavedGuard(moduleDirty);
|
||||
const lockDirty = (Object.keys(savedLock) as LockKey[]).some((k) => lockForm[k] !== savedLock[k]);
|
||||
const leave = useUnsavedGuard(moduleDirty || lockDirty);
|
||||
|
||||
const toggleComments = async () => {
|
||||
if (togglingComments) return;
|
||||
@@ -164,6 +221,23 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
}
|
||||
};
|
||||
|
||||
const saveLock = async (key: LockKey) => {
|
||||
if (savingLockKey) return;
|
||||
const n = Math.max(0, Math.min(8760, Math.round(Number(lockForm[key])) || 0));
|
||||
setSavingLockKey(key);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ [key]: n });
|
||||
const saved = pickLock(res);
|
||||
setLockForm((f) => ({ ...f, [key]: saved[key] }));
|
||||
setSavedLock((f) => ({ ...f, [key]: saved[key] }));
|
||||
toast(LOCK_META.find((m) => m.key === key)?.toast(saved[key]) || "已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingLockKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<AdminSettingsGroup title="内容与互动">
|
||||
@@ -308,6 +382,31 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
<AdminSettingsGroup title="内容锁定与旧帖回复">
|
||||
{LOCK_META.map((m) => (
|
||||
<AdminSettingsRow key={m.key} label={<span className="inline-flex items-center gap-1.5">{m.icon} {m.label}</span>} hintBelow={m.hint}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<AdminStepper
|
||||
value={lockForm[m.key]}
|
||||
onChange={(v) => setLockForm((f) => ({ ...f, [m.key]: v }))}
|
||||
min={0}
|
||||
max={m.max}
|
||||
unit={m.unit}
|
||||
ariaLabel={`${m.label}${m.unit}`}
|
||||
disabled={savingLockKey !== null}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={savingLockKey !== null || lockForm[m.key] === savedLock[m.key]}
|
||||
onClick={() => void saveLock(m.key)}
|
||||
>
|
||||
{savingLockKey === m.key ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
))}
|
||||
</AdminSettingsGroup>
|
||||
<OperationsPanel name="security" onDirtyChange={setModuleDirty} />
|
||||
<ConfirmDialog
|
||||
open={leave.leaveOpen}
|
||||
|
||||
Reference in New Issue
Block a user