"use client"; import { CheckCircle2, ExternalLink, GitBranch, Globe, Image as ImageIcon, Loader2, MessageSquare, MessagesSquare, Paperclip, RotateCcw, UserPlus, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { AdminExtChipEditor, AdminPageHeader, AdminSettingsActions, AdminSettingsGroup, AdminSettingsPage, AdminSettingsRow, AdminSettingsWork, AdminStepper, AdminSwitch, } from "@/components/admin"; import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { toast } from "@/lib/toast"; import useUnsavedGuard from "@/hooks/useUnsavedGuard"; 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"; const NAV: { id: SectionId; label: string }[] = [ { id: "basic", label: "基本信息" }, { id: "content", label: "内容与互动" }, { id: "gitimport", label: "提交导入" }, ]; const COMING_SOON = ["安全与限流", "邮件", "对象存储", "敏感词", "系统维护"]; function parseSection(hash: string): SectionId { const id = hash.replace(/^#/, ""); if (id === "content" || id === "gitimport" || id === "basic") return id; return "basic"; } function InstantNote({ saving }: { saving?: boolean }) { return {saving ? "正在保存…" : "修改后立即生效"}; } 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 navRefs = useRef<(HTMLAnchorElement | 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 confirmedLimits = useRef({ attachment_max_mb: initial.attachment_max_mb || 20, attachment_max_count: initial.attachment_max_count || 10, image_max_mb: initial.image_max_mb || 5, }); const limitsReq = useRef(0); 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 [basicDirty, setBasicDirty] = useState(false); const [brandDirty, setBrandDirty] = useState(false); const [seoDirty, setSeoDirty] = useState(false); const [pendingSection, setPendingSection] = useState(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 tabDirty = active === "basic" ? basicDirty || brandDirty || seoDirty : active === "content" ? extsDirty : gitImportDirty; const leave = useUnsavedGuard(tabDirty); const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []); const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []); const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []); 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 }, []); useEffect(() => { const apply = () => setActive(parseSection(window.location.hash)); apply(); if (!window.location.hash) { history.replaceState(null, "", `${window.location.pathname}${window.location.search}#basic`); } window.addEventListener("hashchange", apply); return () => window.removeEventListener("hashchange", apply); }, []); const goSection = (id: SectionId) => { if (id === active) return; setActive(id); const next = `${window.location.pathname}${window.location.search}#${id}`; history.replaceState(null, "", next); document.getElementById("main")?.scrollTo({ top: 0 }); if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) { void loadGitImport(); } }; const selectTab = (id: SectionId) => { if (id === active) return; if (tabDirty) { setPendingSection(id); return; } goSection(id); }; const onNavKeyDown = (e: KeyboardEvent, i: number) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End" && e.key !== "ArrowRight" && e.key !== "ArrowLeft") { return; } e.preventDefault(); let next = i; if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length; else if (e.key === "ArrowUp" || 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); navRefs.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; } const snap = gitImportJSON; setSavingGitImport(true); try { await apiUpdateSiteSettings({ timeline_git_import: snap }); const res = await apiGetAdminSettings(); const raw = res.timeline_git_import?.trim() || snap; let pretty = raw; try { pretty = JSON.stringify(JSON.parse(raw), null, 2); } catch { /* 保持 */ } setSavedGitImport(pretty); setGitImportJSON((cur) => (cur === snap ? pretty : cur)); 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; }) => { const ticket = ++limitsReq.current; setSavingLimits(true); try { const res = await apiUpdateSiteSettings(patch); if (ticket !== limitsReq.current) return; confirmedLimits.current = { attachment_max_mb: res.attachment_max_mb, attachment_max_count: res.attachment_max_count, image_max_mb: res.image_max_mb, }; setAttMaxMB(res.attachment_max_mb); setAttMaxCount(res.attachment_max_count); setImageMaxMB(res.image_max_mb); toast("上传限额已保存", "ok"); } catch (e) { if (ticket !== limitsReq.current) return; setAttMaxMB(confirmedLimits.current.attachment_max_mb); setAttMaxCount(confirmedLimits.current.attachment_max_count); setImageMaxMB(confirmedLimits.current.image_max_mb); toast(e instanceof Error ? e.message : "保存失败"); } finally { if (ticket === limitsReq.current) setSavingLimits(false); } }; const limitsTimer = useRef | null>(null); const pendingLimits = useRef({ ...confirmedLimits.current }); const scheduleLimitSave = (patch: { attachment_max_mb?: number; attachment_max_count?: number; image_max_mb?: number; }) => { pendingLimits.current = { ...pendingLimits.current, ...patch }; if (limitsTimer.current) clearTimeout(limitsTimer.current); limitsTimer.current = setTimeout(() => { const next = pendingLimits.current; void saveUploadLimits({ attachment_max_mb: next.attachment_max_mb, attachment_max_count: next.attachment_max_count, image_max_mb: next.image_max_mb, }); }, 450); }; useEffect(() => { return () => { if (limitsTimer.current) clearTimeout(limitsTimer.current); }; }, []); const saveExts = async () => { if (savingExts || !extsDirty) return; const snap = [...exts]; setSavingExts(true); try { const res = await apiUpdateSiteSettings({ attachment_exts: snap }); setSavedExts([...res.attachment_exts]); setExts((cur) => (extsEqual(cur, snap) ? [...res.attachment_exts] : cur)); toast("附件扩展名已保存", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSavingExts(false); } }; const leaveOpen = leave.leaveOpen || pendingSection != null; const confirmLeave = () => { if (pendingSection) { const next = pendingSection; setPendingSection(null); goSection(next); return; } leave.confirmLeave(); }; const cancelLeave = () => { setPendingSection(null); leave.cancelLeave(); }; const nav = ( ); return (
{active === "basic" && ( <> )} {active === "content" && ( <> 开放注册 } hintBelow="关闭后游客无法提交注册,已有账号仍可登录。" >
void toggleRegister()} label="开放注册" />
开放评论 } hintBelow="关闭后前台不展示评论入口、评论数量与已有评论。" >
void toggleComments()} label="开放评论" />
开放消息 } hintBelow="关闭后前台不展示私聊与群聊入口;已有会话仍可由后台监管。" >
void toggleMessages()} label="开放消息" />
外链新标签打开 } hintBelow="开启后,帖子与评论中的外链在新标签打开;关闭则当前页跳转。" >
void togglePostLinkNewTab()} label="外链新标签打开" />
void saveExts()} onDiscard={() => setExts([...savedExts])} /> } > 附件单文件上限 } hintBelow={ attMaxMB >= 50 ? "发帖附件体积上限。当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大。" : "发帖附件体积上限。过大可能拖垮进程,反代与 Next 请求体上限须不少于该值。" } >
{ setAttMaxMB(n); scheduleLimitSave({ attachment_max_mb: n }); }} />
每帖附件个数 } hintBelow="单帖(含草稿)最多可绑定的附件数量。" >
{ setAttMaxCount(n); scheduleLimitSave({ attachment_max_count: n }); }} />
正文插图上限 } hintBelow={ imageMaxMB >= 10 ? "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图 ≥10MB 时解码更耗内存。" : "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。" } >
{ setImageMaxMB(n); scheduleLimitSave({ image_max_mb: n }); }} />
限制附件扩展名 } hintBelow={ extLimit ? "仅允许下方列表中的扩展名。" : "任意格式可传,仍受体积上限与下载沙箱约束。" } >
void toggleExtLimit()} label="限制附件扩展名" />
)} {active === "gitimport" && ( void saveGitImport()} onDiscard={() => { setGitImportJSON(savedGitImport); setGitValidateMsg(null); }} > {(gitPreview || gitImportLoaded) ? (
规则摘要 {gitPreview ? ( <> 最多 {gitPreview.maxPages ?? "—"} 页 · {gitPreview.maxCommits ?? "—"} 条提交 {gitPreview.sources.map((s) => ( {s.id} {s.host} ))} ) : ( JSON 无法解析时不显示摘要,保存前请先校验语法。 )}
) : null} } > {gitImportLoading && !gitImportLoaded ? (
加载适配器…
) : (