"use client"; import { Globe, Loader2, MessageSquare, Save, UserPlus, MessagesSquare, ExternalLink, Shield, Mail, HardDrive, Ban, Wrench, FileText, Paperclip, Image as ImageIcon, GitBranch, RotateCcw, CheckCircle2, } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { AdminExtChipEditor, AdminField, AdminPageHeader, AdminSection, AdminStepper, AdminSwitch, } from "@/components/admin"; import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { toast } from "@/lib/toast"; import BasicIdentityPanel from "./BasicIdentityPanel"; import BrandSeoPanel from "./BrandSeoPanel"; import FooterLinksPanel from "./FooterLinksPanel"; import ConfirmDialog from "@/components/ConfirmDialog"; function extsEqual(a: string[], b: string[]) { if (a.length !== b.length) return false; return a.every((v, i) => v === b[i]); } /** 与后端 DefaultTimelineGitAdapterJSON 对齐的内置默认适配器 */ const DEFAULT_TIMELINE_GIT_IMPORT = `{ "max_pages": 10, "max_commits": 100, "sources": [ { "id": "github", "host": "github.com", "list_path": "^/(?P[^/]+)/(?P[^/]+)/commits/(?P[^/]+)/?$", "commit_path": "^/(?P[^/]+)/(?P[^/]+)/commit/(?P[0-9a-fA-F]{7,64})/?$", "api_url": "https://api.github.com/repos/{owner}/{repo}/commits", "commit_api": "https://api.github.com/repos/{owner}/{repo}/commits/{sha}", "query": { "sha": "{ref}", "per_page": "100", "page": "{page}" }, "headers": { "User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json" }, "pagination": "link_header", "item": { "sha": "sha", "date": "commit.committer.date", "message": "commit.message", "source_url": "html_url" } }, { "id": "gitea", "host": "*", "list_path": "^/(?P[^/]+)/(?P[^/]+)/commits/branch/(?P.+)/?$", "commit_path": "^/(?P[^/]+)/(?P[^/]+)/commit/(?P[0-9a-fA-F]{7,64})/?$", "api_url": "https://{host}/api/v1/repos/{owner}/{repo}/commits", "commit_api": "https://{host}/api/v1/repos/{owner}/{repo}/git/commits/{sha}", "query": { "sha": "{ref}", "limit": "50", "page": "{page}" }, "headers": { "User-Agent": "jiang13-bbs", "Accept": "application/json" }, "pagination": "query_page", "item": { "sha": "sha", "date": "created", "message": "commit.message", "source_url": "html_url" } } ] }`; type SectionId = | "basic" | "content" | "gitimport" | "security" | "mail" | "storage" | "words" | "maintenance"; const NAV: { id: SectionId; label: string; ready?: boolean }[] = [ { id: "basic", label: "基本信息", ready: true }, { id: "content", label: "内容与互动", ready: true }, { id: "gitimport", label: "提交导入", ready: true }, { 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}

即将推出

此分区尚未开放配置

); } function ComingSoonPair({ left, right, }: { left: { title: string; description: string; icon: ReactNode }; right: { title: string; description: string; icon: ReactNode }; }) { return (
); } export default function SettingsAdmin({ initial }: { initial: PublicSettings }) { const [live, setLive] = useState(initial); 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 [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 tabRefs = useRef<(HTMLButtonElement | null)[]>([]); 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 [gitImportJSON, setGitImportJSON] = useState(""); const [savedGitImport, setSavedGitImport] = useState(""); const [gitImportLoading, setGitImportLoading] = useState(false); const [gitImportLoaded, setGitImportLoaded] = useState(false); const [savingGitImport, setSavingGitImport] = useState(false); const [restoreGitOpen, setRestoreGitOpen] = useState(false); const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null); const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]); const gitImportDirty = gitImportJSON !== savedGitImport; const gitPreview = useMemo(() => { try { const parsed = JSON.parse(gitImportJSON) as { max_pages?: unknown; max_commits?: unknown; sources?: unknown; }; if (!Array.isArray(parsed.sources)) return null; const sources = parsed.sources .filter((s): s is Record => !!s && typeof s === "object") .map((s) => ({ id: typeof s.id === "string" ? s.id : "—", host: typeof s.host === "string" ? s.host : "*", })); return { maxPages: typeof parsed.max_pages === "number" ? parsed.max_pages : null, maxCommits: typeof parsed.max_commits === "number" ? parsed.max_commits : null, sources, }; } catch { return null; } }, [gitImportJSON]); const loadGitImport = async () => { if (gitImportLoading) return; setGitImportLoading(true); try { const res = await apiGetAdminSettings(); const raw = res.timeline_git_import?.trim() || DEFAULT_TIMELINE_GIT_IMPORT; let pretty = raw; try { pretty = JSON.stringify(JSON.parse(raw), null, 2); } catch { /* 保持原文 */ } setGitImportJSON(pretty); setSavedGitImport(pretty); setGitImportLoaded(true); setGitValidateMsg(null); } catch (e) { toast(e instanceof Error ? e.message : "加载适配器失败"); } finally { setGitImportLoading(false); } }; useEffect(() => { void loadGitImport(); // 仅挂载时拉取一次 // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const selectTab = (id: SectionId) => { if (id !== active) { setActive(id); document.getElementById("main")?.scrollTo({ top: 0 }); } if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) { void loadGitImport(); } }; const onTabKeyDown = (e: KeyboardEvent, i: number) => { if (e.key !== "ArrowRight" && e.key !== "ArrowLeft" && e.key !== "Home" && e.key !== "End") return; e.preventDefault(); let next = i; if (e.key === "ArrowRight") next = (i + 1) % NAV.length; else if (e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length; else if (e.key === "Home") next = 0; else next = NAV.length - 1; selectTab(NAV[next].id); tabRefs.current[next]?.focus(); }; const validateGitImportClient = (): boolean => { try { JSON.parse(gitImportJSON); setGitValidateMsg({ ok: true, text: "JSON 语法有效(服务端保存时还会做结构校验)" }); return true; } catch (e) { setGitValidateMsg({ ok: false, text: e instanceof Error ? `JSON 无效:${e.message}` : "JSON 无效", }); return false; } }; const saveGitImport = async () => { if (savingGitImport) return; if (!validateGitImportClient()) { toast("请先修正 JSON 语法"); return; } setSavingGitImport(true); try { await apiUpdateSiteSettings({ timeline_git_import: gitImportJSON }); // 保存后以服务端规范化结果为准 const res = await apiGetAdminSettings(); const raw = res.timeline_git_import?.trim() || gitImportJSON; let pretty = raw; try { pretty = JSON.stringify(JSON.parse(raw), null, 2); } catch { /* 保持 */ } setGitImportJSON(pretty); setSavedGitImport(pretty); toast("提交导入适配器已保存", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSavingGitImport(false); } }; const restoreGitDefault = async () => { setSavingGitImport(true); try { let pretty = DEFAULT_TIMELINE_GIT_IMPORT; try { pretty = JSON.stringify(JSON.parse(DEFAULT_TIMELINE_GIT_IMPORT), null, 2); } catch { /* 保持 */ } await apiUpdateSiteSettings({ timeline_git_import: pretty }); setGitImportJSON(pretty); setSavedGitImport(pretty); setGitValidateMsg(null); setRestoreGitOpen(false); toast("已恢复默认适配器", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "恢复失败"); } finally { setSavingGitImport(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 (
{NAV.map((item, i) => { const isActive = active === item.id; return ( ); })}
{active === "basic" && (
)} {active === "content" && (
开放注册 } 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="限制附件扩展名" /> } />

允许的扩展名

)} {active === "gitimport" && (
{gitImportLoading && !gitImportLoaded ? (
加载适配器…
) : ( <>