"use client"; import { Globe, Loader2, MessageSquare, Save, UserPlus, MessagesSquare, ExternalLink, Shield, Mail, HardDrive, Ban, Wrench, Palette, FileText, Paperclip, Image as ImageIcon, } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AdminExtChipEditor, AdminField, AdminPageHeader, AdminSection, AdminStepper, AdminSwitch, } from "@/components/admin"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { toast } from "@/lib/toast"; function extsEqual(a: string[], b: string[]) { if (a.length !== b.length) return false; return a.every((v, i) => v === b[i]); } type SectionId = | "basic" | "content" | "brand" | "footer" | "security" | "mail" | "storage" | "words" | "maintenance"; const NAV: { id: SectionId; label: string; ready?: boolean }[] = [ { id: "basic", label: "基本信息", ready: true }, { id: "content", label: "内容与互动", ready: true }, { id: "brand", label: "品牌与 SEO" }, { id: "footer", label: "页脚与合规" }, { id: "security", label: "安全与限流" }, { id: "mail", label: "邮件" }, { id: "storage", label: "对象存储" }, { id: "words", label: "敏感词" }, { id: "maintenance", label: "系统维护" }, ]; function SwitchRow({ title, description, control, bordered, }: { title: ReactNode; description: string; control: ReactNode; bordered?: boolean; }) { return (

{title}

{description}

{control}
); } function ComingSoonCard({ title, description, icon, }: { title: string; description: string; icon: ReactNode; }) { return (
{icon}

即将推出

此分区尚未开放配置

); } export default function SettingsAdmin({ initial }: { initial: PublicSettings }) { const [siteName, setSiteName] = useState(initial.site_name); const [siteDescription, setSiteDescription] = useState(initial.site_description); const [allowRegister, setAllowRegister] = useState(initial.allow_register); const [allowComments, setAllowComments] = useState(initial.allow_comments !== false); const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false); const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false); const [savedName, setSavedName] = useState(initial.site_name); const [savedDesc, setSavedDesc] = useState(initial.site_description); const [saving, setSaving] = useState(false); const [togglingReg, setTogglingReg] = useState(false); const [togglingComments, setTogglingComments] = useState(false); const [togglingMessages, setTogglingMessages] = useState(false); const [togglingLink, setTogglingLink] = useState(false); const [active, setActive] = useState("basic"); const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false); const [exts, setExts] = useState(() => [...(initial.attachment_exts || [])]); const [savedExts, setSavedExts] = useState(() => [...(initial.attachment_exts || [])]); const [attMaxMB, setAttMaxMB] = useState(initial.attachment_max_mb || 20); const [attMaxCount, setAttMaxCount] = useState(initial.attachment_max_count || 10); const [imageMaxMB, setImageMaxMB] = useState(initial.image_max_mb || 5); const [togglingExtLimit, setTogglingExtLimit] = useState(false); const [savingLimits, setSavingLimits] = useState(false); const [savingExts, setSavingExts] = useState(false); const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc; const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]); useEffect(() => { const ids = NAV.map((n) => n.id); const observer = new IntersectionObserver( (entries) => { const visible = entries .filter((e) => e.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); if (visible[0]?.target?.id) { const id = visible[0].target.id.replace(/^settings-/, "") as SectionId; if (ids.includes(id)) setActive(id); } }, { rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5] } ); ids.forEach((id) => { const el = document.getElementById(`settings-${id}`); if (el) observer.observe(el); }); return () => observer.disconnect(); }, []); const scrollTo = (id: SectionId) => { setActive(id); document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); }; const saveIdentity = async () => { if (saving) return; const name = siteName.trim(); if (!name) { toast("站点名称不能为空"); return; } setSaving(true); try { const res = await apiUpdateSiteSettings({ site_name: name, site_description: siteDescription.trim(), }); setSiteName(res.site_name); setSiteDescription(res.site_description); setSavedName(res.site_name); setSavedDesc(res.site_description); toast("站点信息已保存", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } }; const toggleRegister = async () => { if (togglingReg) return; const next = !allowRegister; setTogglingReg(true); try { const res = await apiUpdateSiteSettings({ allow_register: next }); setAllowRegister(res.allow_register); toast(res.allow_register ? "已开放注册" : "已关闭注册", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setTogglingReg(false); } }; 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 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 toggleExtLimit = async () => { if (togglingExtLimit) return; const next = !extLimit; setTogglingExtLimit(true); try { const res = await apiUpdateSiteSettings({ attachment_ext_limit: next }); setExtLimit(res.attachment_ext_limit !== false); toast(res.attachment_ext_limit !== false ? "已开启附件类型限制" : "已关闭附件类型限制", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setTogglingExtLimit(false); } }; const saveUploadLimits = async (patch: { attachment_max_mb?: number; attachment_max_count?: number; image_max_mb?: number; }) => { if (savingLimits) return; setSavingLimits(true); try { const res = await apiUpdateSiteSettings(patch); setAttMaxMB(res.attachment_max_mb); setAttMaxCount(res.attachment_max_count); setImageMaxMB(res.image_max_mb); toast("上传限额已保存", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSavingLimits(false); } }; // 步进器手输会连续触发 onChange,防抖后再写库 const limitsTimer = useRef | null>(null); const scheduleLimitSave = (patch: { attachment_max_mb?: number; attachment_max_count?: number; image_max_mb?: number; }) => { if (limitsTimer.current) clearTimeout(limitsTimer.current); limitsTimer.current = setTimeout(() => { void saveUploadLimits(patch); }, 450); }; useEffect(() => { return () => { if (limitsTimer.current) clearTimeout(limitsTimer.current); }; }, []); const saveExts = async () => { if (savingExts || !extsDirty) return; setSavingExts(true); try { const res = await apiUpdateSiteSettings({ attachment_exts: exts }); setExts([...res.attachment_exts]); setSavedExts([...res.attachment_exts]); toast("附件扩展名已保存", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSavingExts(false); } }; return (
开放注册 } description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。" control={ void toggleRegister()} label="开放注册" /> } /> 开放评论 } description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。" control={ void toggleComments()} label="开放评论" /> } /> 开放消息 } description="关闭后前台不展示私聊与群聊入口;已有会话由后台消息管理继续监管。" control={ void toggleMessages()} label="开放消息" /> } /> 外链新标签打开 } description="开启后,帖子与评论中的 Markdown 外链在新标签打开;关闭则当前页跳转。" control={ void togglePostLinkNewTab()} label="外链新标签打开" /> } /> 附件单文件上限 } description="发帖附件体积上限。过大可能拖垮进程,并须把反代 / Next 请求体上限调到不少于该值。" control={ { setAttMaxMB(n); scheduleLimitSave({ attachment_max_mb: n }); }} /> } /> {attMaxMB >= 50 ? (

当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大,否则用户仍会先撞上网关 413。

) : null} 每帖附件个数 } description="单帖(含草稿)最多可绑定的附件数量。" control={ { setAttMaxCount(n); scheduleLimitSave({ attachment_max_count: n }); }} /> } /> 正文插图上限 } description="Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图会在服务端解析头部。" control={ { setImageMaxMB(n); scheduleLimitSave({ image_max_mb: n }); }} /> } /> {imageMaxMB >= 10 ? (

插图 ≥10MB:解码头部更耗内存,建议保持较小值。

) : null} 限制附件扩展名 } description={ extLimit ? "仅允许下方列表中的扩展名;安装包 / 脚本 / 网页可按需保留。" : "任意格式可传,仍受体积上限与下载沙箱约束。" } control={ void toggleExtLimit()} label="限制附件扩展名" /> } />

允许的扩展名

} />
} />
} />
} />
} />
} />
); }