Files
jiang13-bbs/frontend/app/admin/settings/SettingsAdmin.tsx
2026-09-19 04:14:13 +08:00

939 lines
32 KiB
TypeScript
Raw 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 {
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<owner>[^/]+)/(?P<repo>[^/]+)/commits/(?P<ref>[^/]+)/?$",
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[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<owner>[^/]+)/(?P<repo>[^/]+)/commits/branch/(?P<ref>.+)/?$",
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[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 (
<div
className="flex items-start justify-between gap-3 flex-wrap py-3 first:pt-0 last:pb-0"
style={bordered ? { borderBottom: "1px solid var(--line)" } : undefined}
>
<div className="min-w-0 flex-1">
<p className="text-[14px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
{title}
</p>
<p className="meta text-[12px] mt-0.5 leading-relaxed">{description}</p>
</div>
<div className="shrink-0">{control}</div>
</div>
);
}
function ComingSoonCard({
title,
description,
icon,
}: {
title: string;
description: string;
icon: ReactNode;
}) {
return (
<AdminSection title={title} description={description}>
<div
className="rounded-[var(--r-sm)] px-4 py-8 text-center"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<span className="inline-flex mb-2 opacity-80" aria-hidden>
{icon}
</span>
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
即将推出
</p>
<p className="meta text-[12.5px] mt-1">此分区尚未开放配置</p>
</div>
</AdminSection>
);
}
function ComingSoonPair({
left,
right,
}: {
left: { title: string; description: string; icon: ReactNode };
right: { title: string; description: string; icon: ReactNode };
}) {
return (
<div className="admin-settings-grid">
<ComingSoonCard {...left} />
<ComingSoonCard {...right} />
</div>
);
}
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<SectionId>("basic");
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
const [savedExts, setSavedExts] = useState<string[]>(() => [...(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<string, unknown> => !!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<HTMLButtonElement>, 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<ReturnType<typeof setTimeout> | 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 (
<div>
<AdminPageHeader
icon={Globe}
title="站点设置"
description="名称、Logo、关键词与页脚都在「基本信息」;主题色请到「外观」页调整"
/>
<div
className="admin-settings-tabs"
role="tablist"
aria-label="设置分区"
>
{NAV.map((item, i) => {
const isActive = active === item.id;
return (
<button
key={item.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
type="button"
role="tab"
id={`settings-tab-${item.id}`}
aria-selected={isActive}
aria-controls={`settings-panel-${item.id}`}
tabIndex={isActive ? 0 : -1}
className="admin-settings-tab"
onClick={() => selectTab(item.id)}
onKeyDown={(e) => onTabKeyDown(e, i)}
>
{item.label}
{!item.ready && <span className="admin-settings-tab-soon">即将</span>}
</button>
);
})}
</div>
<div
role="tabpanel"
id={`settings-panel-${active}`}
aria-labelledby={`settings-tab-${active}`}
className="flex flex-col gap-4"
>
{active === "basic" && (
<div className="flex flex-col gap-4">
<BasicIdentityPanel initial={live} onSaved={setLive} />
<BrandSeoPanel initial={live} onSaved={setLive} />
<FooterLinksPanel initial={live} onSaved={setLive} />
</div>
)}
{active === "content" && (
<div className="admin-settings-grid">
<AdminSection title="内容与互动" description="控制注册、评论、消息与链接打开方式">
<SwitchRow
bordered
title={
<>
<UserPlus size={15} /> 开放注册
</>
}
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
control={
<AdminSwitch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
}
/>
<SwitchRow
bordered
title={
<>
<MessageSquare size={15} /> 开放评论
</>
}
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。"
control={
<AdminSwitch
checked={allowComments}
disabled={togglingComments}
onChange={() => void toggleComments()}
label="开放评论"
/>
}
/>
<SwitchRow
bordered
title={
<>
<MessagesSquare size={15} /> 开放消息
</>
}
description="关闭后前台不展示私聊与群聊入口;已有会话由后台消息管理继续监管。"
control={
<AdminSwitch
checked={allowMessages}
disabled={togglingMessages}
onChange={() => void toggleMessages()}
label="开放消息"
/>
}
/>
<SwitchRow
title={
<>
<ExternalLink size={15} /> 外链新标签打开
</>
}
description="开启后,帖子与评论中的 Markdown 外链在新标签打开;关闭则当前页跳转。"
control={
<AdminSwitch
checked={postLinkNewTab}
disabled={togglingLink}
onChange={() => void togglePostLinkNewTab()}
label="外链新标签打开"
/>
}
/>
</AdminSection>
<AdminSection
title="附件与上传"
description="帖子附件限额、扩展名白名单与正文插图体积;改后经实时通道同步到发帖页"
>
<SwitchRow
bordered
title={
<>
<Paperclip size={15} /> 附件单文件上限
</>
}
description="发帖附件体积上限。过大可能拖垮进程,并须把反代 / Next 请求体上限调到不少于该值。"
control={
<AdminStepper
value={attMaxMB}
min={1}
max={999999}
unit="MB"
ariaLabel="附件单文件上限"
disabled={savingLimits}
onChange={(n) => {
setAttMaxMB(n);
scheduleLimitSave({ attachment_max_mb: n });
}}
/>
}
/>
{attMaxMB >= 50 ? (
<p className="j13-ext-warn !mt-0 !mb-3" role="status">
当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大,否则用户仍会先撞上网关 413。
</p>
) : null}
<SwitchRow
bordered
title={
<>
<Paperclip size={15} /> 每帖附件个数
</>
}
description="单帖(含草稿)最多可绑定的附件数量。"
control={
<AdminStepper
value={attMaxCount}
min={1}
max={20}
unit="个"
ariaLabel="每帖附件个数"
disabled={savingLimits}
onChange={(n) => {
setAttMaxCount(n);
scheduleLimitSave({ attachment_max_count: n });
}}
/>
}
/>
<SwitchRow
bordered
title={
<>
<ImageIcon size={15} /> 正文插图上限
</>
}
description="Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图会在服务端解析头部。"
control={
<AdminStepper
value={imageMaxMB}
min={1}
max={999999}
unit="MB"
ariaLabel="正文插图上限"
disabled={savingLimits}
onChange={(n) => {
setImageMaxMB(n);
scheduleLimitSave({ image_max_mb: n });
}}
/>
}
/>
{imageMaxMB >= 10 ? (
<p className="j13-ext-warn !mt-0 !mb-3" role="status">
插图 ≥10MB:解码头部更耗内存,建议保持较小值。
</p>
) : null}
<SwitchRow
bordered
title={
<>
<Paperclip size={15} /> 限制附件扩展名
</>
}
description={
extLimit
? "仅允许下方列表中的扩展名;安装包 / 脚本 / 网页可按需保留。"
: "任意格式可传,仍受体积上限与下载沙箱约束。"
}
control={
<AdminSwitch
checked={extLimit}
disabled={togglingExtLimit}
onChange={() => void toggleExtLimit()}
label="限制附件扩展名"
/>
}
/>
<div className="pt-2">
<p className="text-[13.5px] font-medium mb-2" style={{ color: "var(--ink)" }}>
允许的扩展名
</p>
<AdminExtChipEditor
value={exts}
onChange={setExts}
disabled={!extLimit || savingExts}
/>
<div className="flex justify-end mt-3">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!extsDirty || savingExts || !extLimit}
onClick={() => void saveExts()}
>
{savingExts ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存扩展名
</button>
</div>
</div>
</AdminSection>
</div>
)}
{active === "gitimport" && (
<div className="admin-settings-grid">
<AdminSection
title="适配器 JSON"
description="声明式匹配规则,非可执行脚本。服务端只按规则拉取。"
>
{gitImportLoading && !gitImportLoaded ? (
<div className="flex items-center gap-2 meta py-8 justify-center">
<Loader2 size={16} className="animate-spin" /> 加载适配器…
</div>
) : (
<>
<label className="field-label sr-only" htmlFor="git-import-json">
适配器 JSON
</label>
<textarea
id="git-import-json"
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
style={{ minHeight: 360 }}
spellCheck={false}
value={gitImportJSON}
onChange={(e) => {
setGitImportJSON(e.target.value);
setGitValidateMsg(null);
}}
placeholder="粘贴或编辑适配器 JSON…"
/>
{gitValidateMsg ? (
<p
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
gitValidateMsg.ok ? "" : "alert-error !mt-2"
}`}
style={gitValidateMsg.ok ? { color: "var(--ok, #137333)" } : undefined}
role="status"
>
{gitValidateMsg.ok ? <CheckCircle2 size={14} className="mt-0.5 shrink-0" /> : null}
{gitValidateMsg.text}
</p>
) : null}
</>
)}
</AdminSection>
<AdminSection
title="说明与操作"
description="用于将 GitHub / Gitea 等提交历史导入时间线"
extra={
<span className="meta text-[11.5px] inline-flex items-center gap-1">
<GitBranch size={12} /> 内置模板
</span>
}
>
<p className="meta text-[12.5px] leading-relaxed mb-4">
通过 JSON 描述 host、路径正则与 API 字段映射。改动不会执行任意代码,保存时服务端会做结构校验。
</p>
{gitPreview ? (
<div
className="rounded-[var(--r-sm)] px-3.5 py-3 mb-4"
style={{ background: "var(--panel-2)" }}
>
<p className="text-[12.5px] font-medium mb-2" style={{ color: "var(--ink)" }}>
当前规则摘要
</p>
<p className="meta text-[12px] mb-2">
最多 {gitPreview.maxPages ?? "—"} 页 · {gitPreview.maxCommits ?? "—"} 条提交
</p>
<ul className="flex flex-col gap-1.5">
{gitPreview.sources.map((s) => (
<li
key={`${s.id}-${s.host}`}
className="text-[12.5px] flex items-center gap-2"
style={{ color: "var(--ink-2)" }}
>
<GitBranch size={12} className="shrink-0" style={{ color: "var(--ink-3)" }} />
<span className="font-medium" style={{ color: "var(--ink)" }}>
{s.id}
</span>
<span className="meta">{s.host}</span>
</li>
))}
</ul>
</div>
) : gitImportLoaded ? (
<p className="meta text-[12.5px] mb-4">JSON 无法解析时不显示摘要,保存前请先校验语法。</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn btn-line btn-sm"
disabled={gitImportLoading || savingGitImport}
onClick={() => {
if (validateGitImportClient()) toast("语法校验通过", "ok");
else toast("JSON 语法无效");
}}
>
<CheckCircle2 size={14} /> 校验
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!gitImportDirty || savingGitImport}
onClick={() => void saveGitImport()}
>
{savingGitImport ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingGitImport}
onClick={() => setRestoreGitOpen(true)}
>
<RotateCcw size={14} /> 恢复默认
</button>
</div>
</AdminSection>
</div>
)}
{active === "security" && (
<ComingSoonPair
left={{
title: "登录与注册限流",
description: "按 IP / 账号的速率与锁定策略",
icon: <Shield size={22} />,
}}
right={{
title: "发帖冷静期",
description: "新用户发帖等待时长与例外规则",
icon: <UserPlus size={22} />,
}}
/>
)}
{active === "mail" && (
<ComingSoonPair
left={{
title: "SMTP 发信",
description: "服务器、端口、加密与发件人身份",
icon: <Mail size={22} />,
}}
right={{
title: "通知模板",
description: "注册验证、重置密码与系统通知",
icon: <FileText size={22} />,
}}
/>
)}
{active === "storage" && (
<ComingSoonPair
left={{
title: "附件存储",
description: "对象存储桶、路径前缀与访问策略",
icon: <HardDrive size={22} />,
}}
right={{
title: "头像存储",
description: "头像目录、体积上限与 CDN 域名",
icon: <ImageIcon size={22} />,
}}
/>
)}
{active === "words" && (
<ComingSoonPair
left={{
title: "过滤词库",
description: "敏感词列表、导入导出与分组",
icon: <Ban size={22} />,
}}
right={{
title: "命中策略",
description: "拦截、替换或送审的处理方式",
icon: <Shield size={22} />,
}}
/>
)}
{active === "maintenance" && (
<ComingSoonPair
left={{
title: "维护模式",
description: "全站只读、公告文案与白名单",
icon: <Wrench size={22} />,
}}
right={{
title: "缓存与诊断",
description: "缓存清理、健康检查与日志下载",
icon: <HardDrive size={22} />,
}}
/>
)}
</div>
<ConfirmDialog
open={restoreGitOpen}
title="恢复默认适配器"
message="将覆盖当前「提交导入」JSON 为内置 GitHub + Gitea 模板,并立即保存。确定继续?"
busy={savingGitImport}
confirmLabel="恢复并保存"
onCancel={() => !savingGitImport && setRestoreGitOpen(false)}
onConfirm={() => void restoreGitDefault()}
/>
</div>
);
}