Files
jiang13-bbs/frontend/app/admin/settings/AccessSettings.tsx
freefire 5270fee2b7 feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
2026-09-25 23:41:45 +08:00

423 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer, Lock, Hourglass, Coins, History } from "lucide-react";
import { useState } from "react";
import {
AdminSettingsGroup,
AdminSettingsRow,
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
import OperationsPanel from "./OperationsPanel";
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);
const [commentsRequireLogin, setCommentsRequireLogin] = useState(
initial.comments_require_login === true,
);
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
const [trustOn, setTrustOn] = useState(initial.trust_reviewed_publish !== false);
const [withoutReviewOn, setWithoutReviewOn] = useState(initial.publish_without_review === true);
const [cooldown, setCooldown] = useState(initial.post_cooldown_hours);
const [savedCooldown, setSavedCooldown] = useState(initial.post_cooldown_hours);
const [togglingComments, setTogglingComments] = useState(false);
const [togglingCommentsLogin, setTogglingCommentsLogin] = useState(false);
const [togglingMessages, setTogglingMessages] = useState(false);
const [togglingLink, setTogglingLink] = useState(false);
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 lockDirty = (Object.keys(savedLock) as LockKey[]).some((k) => lockForm[k] !== savedLock[k]);
const leave = useUnsavedGuard(moduleDirty || lockDirty);
const toggleComments = async () => {
if (togglingComments) return;
const next = !allowComments;
setTogglingComments(true);
try {
const res = await apiUpdateSiteSettings({ allow_comments: next });
setAllowComments(res.allow_comments);
toast(res.allow_comments ? "已开放评论" : "已关闭评论", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingComments(false);
}
};
const toggleCommentsRequireLogin = async () => {
if (togglingCommentsLogin || !allowComments) return;
const next = !commentsRequireLogin;
setTogglingCommentsLogin(true);
try {
const res = await apiUpdateSiteSettings({ comments_require_login: next });
setCommentsRequireLogin(res.comments_require_login === true);
toast(res.comments_require_login ? "评论仅登录用户可见" : "游客也可查看评论", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingCommentsLogin(false);
}
};
const toggleMessages = async () => {
if (togglingMessages) return;
const next = !allowMessages;
setTogglingMessages(true);
try {
const res = await apiUpdateSiteSettings({ allow_messages: next });
setAllowMessages(res.allow_messages);
toast(res.allow_messages ? "已开放消息" : "已关闭消息", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingMessages(false);
}
};
const togglePostLinkNewTab = async () => {
if (togglingLink) return;
const next = !postLinkNewTab;
setTogglingLink(true);
try {
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
setPostLinkNewTab(res.post_link_new_tab !== false);
toast(
res.post_link_new_tab !== false
? "帖子与评论外链将在新标签打开"
: "帖子与评论外链将在当前页打开",
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingLink(false);
}
};
const toggleWithoutReview = async () => {
if (togglingWithoutReview) return;
setTogglingWithoutReview(true);
try {
const res = await apiUpdateSiteSettings({ publish_without_review: !withoutReviewOn });
setWithoutReviewOn(res.publish_without_review === true);
toast(
res.publish_without_review
? "已开启全站免审(发帖与评论直接公开,无需过审记录)"
: "已关闭全站免审(将按过审免审规则决定是否待审)",
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingWithoutReview(false);
}
};
const toggleTrust = async () => {
if (togglingTrust) return;
setTogglingTrust(true);
try {
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
setTrustOn(res.trust_reviewed_publish);
toast(
res.trust_reviewed_publish
? "已开启过审免审(帖子与评论)"
: "已关闭过审免审(帖子与评论将按规则待审)",
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingTrust(false);
}
};
const saveCooldown = async () => {
if (savingCooldown) return;
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
setSavingCooldown(true);
try {
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
setCooldown(res.post_cooldown_hours);
setSavedCooldown(res.post_cooldown_hours);
toast(
res.post_cooldown_hours === 0
? "已关闭新用户发帖冷静期"
: `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`,
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingCooldown(false);
}
};
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="内容与互动">
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<MessageSquare size={15} /> 开放评论
</span>
}
hintBelow="关闭后全站不提供评论功能。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={allowComments}
disabled={togglingComments}
onChange={() => void toggleComments()}
label="开放评论"
/>
<InstantNote saving={togglingComments} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<LogIn size={15} /> 登录可见评论
</span>
}
hintBelow="开启后游客无法查看评论内容,需登录后阅读与参与。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={commentsRequireLogin}
disabled={!allowComments || togglingCommentsLogin}
onChange={() => void toggleCommentsRequireLogin()}
label="登录可见评论"
/>
<InstantNote saving={togglingCommentsLogin} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<MessagesSquare size={15} /> 开放消息
</span>
}
hintBelow="关闭后前台隐藏私聊与群聊入口。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={allowMessages}
disabled={togglingMessages}
onChange={() => void toggleMessages()}
label="开放消息"
/>
<InstantNote saving={togglingMessages} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ExternalLink size={15} /> 外链新标签打开
</span>
}
hintBelow="帖子与评论中的外链是否在新标签打开。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={postLinkNewTab}
disabled={togglingLink}
onChange={() => void togglePostLinkNewTab()}
label="外链新标签打开"
/>
<InstantNote saving={togglingLink} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ShieldOff size={15} /> 全站免审
</span>
}
hintBelow="所有人发帖与评论直接公开,无需有过审记录;开启后优先生效。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={withoutReviewOn}
disabled={togglingWithoutReview}
onChange={() => void toggleWithoutReview()}
label="全站免审"
/>
<InstantNote saving={togglingWithoutReview} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ShieldCheck size={15} /> 过审免审
</span>
}
hintBelow={
withoutReviewOn
? "全站免审已开启,所有内容直接公开,本开关暂不参与判定。"
: "用户曾有过审内容后,后续发帖与评论直接公开。"
}
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={trustOn}
disabled={withoutReviewOn || togglingTrust}
onChange={() => void toggleTrust()}
label="过审免审"
/>
<InstantNote saving={togglingTrust} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Timer size={15} /> 新用户发帖冷静期
</span>
}
hintBelow="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受限。"
>
<div className="flex items-center gap-2 flex-wrap">
<AdminStepper
value={cooldown}
onChange={setCooldown}
min={0}
max={168}
unit="小时"
ariaLabel="新用户发帖冷静期小时数"
disabled={savingCooldown}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingCooldown || cooldown === savedCooldown}
onClick={() => void saveCooldown()}
>
{savingCooldown ? "保存中…" : "保存"}
</button>
</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}
title="有未保存的修改"
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={leave.cancelLeave}
onConfirm={leave.confirmLeave}
/>
</div>
);
}