- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|