Files
jiang13-bbs/frontend/app/admin/settings/SettingsAdmin.tsx
freefire 40bbad2c82 公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 00:11:24 +08:00

865 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 {
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<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";
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 <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
}
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 navRefs = useRef<(HTMLAnchorElement | 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 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<SectionId | 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 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<HTMLAnchorElement>, 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<ReturnType<typeof setTimeout> | null>(null);
const pendingLimits = useRef<typeof confirmedLimits.current>({ ...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 = (
<nav className="admin-settings-nav" aria-label="设置分类">
{NAV.map((item, i) => {
const isActive = active === item.id;
return (
<a
key={item.id}
ref={(el) => {
navRefs.current[i] = el;
}}
href={`#${item.id}`}
className="admin-settings-nav-link"
aria-current={isActive ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
selectTab(item.id);
}}
onKeyDown={(e) => onNavKeyDown(e, i)}
>
{item.label}
</a>
);
})}
<details className="admin-settings-nav-soon">
<summary className="admin-settings-nav-soon-summary">未开放模块</summary>
<ul className="admin-settings-nav-soon-list">
{COMING_SOON.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</details>
</nav>
);
return (
<AdminSettingsPage className={active === "gitimport" ? "is-fill" : undefined}>
<AdminPageHeader
icon={Globe}
title="站点设置"
description="名称、关键词、Logo 与页脚在「基本信息」;主题色请到「外观」调整"
/>
<AdminSettingsWork nav={nav}>
<div id={`settings-panel-${active}`} className="admin-settings-stack">
{active === "basic" && (
<>
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
</>
)}
{active === "content" && (
<>
<AdminSettingsGroup
title="内容与互动"
description="开关改完立即写入,无需再点保存。"
>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<UserPlus size={15} /> 开放注册
</span>
}
hintBelow="关闭后游客无法提交注册,已有账号仍可登录。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
<InstantNote saving={togglingReg} />
</div>
</AdminSettingsRow>
<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">
<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>
</AdminSettingsGroup>
<AdminSettingsGroup
title="附件与上传"
description="限额停止输入约 0.5 秒后写入。扩展名白名单需点保存。"
footer={
<AdminSettingsActions
dirty={extsDirty}
saving={savingExts}
saveLabel="保存扩展名"
saveDisabled={!extsDirty || savingExts || !extLimit}
onSave={() => void saveExts()}
onDiscard={() => setExts([...savedExts])}
/>
}
>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Paperclip size={15} /> 附件单文件上限
</span>
}
hintBelow={
attMaxMB >= 50
? "发帖附件体积上限。当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大。"
: "发帖附件体积上限。过大可能拖垮进程,反代与 Next 请求体上限须不少于该值。"
}
>
<div className="flex flex-wrap items-center gap-2">
<AdminStepper
value={attMaxMB}
min={1}
max={999999}
unit="MB"
ariaLabel="附件单文件上限"
disabled={savingLimits}
onChange={(n) => {
setAttMaxMB(n);
scheduleLimitSave({ attachment_max_mb: n });
}}
/>
<InstantNote saving={savingLimits} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Paperclip size={15} /> 每帖附件个数
</span>
}
hintBelow="单帖(含草稿)最多可绑定的附件数量。"
>
<div className="flex flex-wrap items-center gap-2">
<AdminStepper
value={attMaxCount}
min={1}
max={20}
unit="个"
ariaLabel="每帖附件个数"
disabled={savingLimits}
onChange={(n) => {
setAttMaxCount(n);
scheduleLimitSave({ attachment_max_count: n });
}}
/>
<InstantNote saving={savingLimits} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ImageIcon size={15} /> 正文插图上限
</span>
}
hintBelow={
imageMaxMB >= 10
? "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图 ≥10MB 时解码更耗内存。"
: "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。"
}
>
<div className="flex flex-wrap items-center gap-2">
<AdminStepper
value={imageMaxMB}
min={1}
max={999999}
unit="MB"
ariaLabel="正文插图上限"
disabled={savingLimits}
onChange={(n) => {
setImageMaxMB(n);
scheduleLimitSave({ image_max_mb: n });
}}
/>
<InstantNote saving={savingLimits} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Paperclip size={15} /> 限制附件扩展名
</span>
}
hintBelow={
extLimit
? "仅允许下方列表中的扩展名。"
: "任意格式可传,仍受体积上限与下载沙箱约束。"
}
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={extLimit}
disabled={togglingExtLimit}
onChange={() => void toggleExtLimit()}
label="限制附件扩展名"
/>
<InstantNote saving={togglingExtLimit} />
</div>
</AdminSettingsRow>
<AdminSettingsRow label="允许的扩展名" hintBelow="列表变更需点保存,不会随开关立即写入。" wide>
<div className="admin-settings-control-long">
<AdminExtChipEditor value={exts} onChange={setExts} disabled={!extLimit || savingExts} />
</div>
</AdminSettingsRow>
</AdminSettingsGroup>
</>
)}
{active === "gitimport" && (
<AdminSettingsGroup
className="is-fill"
title="提交导入适配器"
description="用 JSON 描述 GitHub / Gitea 等匹配规则。保存时服务端会做结构校验,不会执行任意代码。"
footer={
<>
<AdminSettingsActions
dirty={gitImportDirty}
saving={savingGitImport}
saveLabel="保存导入适配器"
onSave={() => void saveGitImport()}
onDiscard={() => {
setGitImportJSON(savedGitImport);
setGitValidateMsg(null);
}}
>
<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-line btn-sm"
disabled={savingGitImport}
onClick={() => setRestoreGitOpen(true)}
>
<RotateCcw size={14} /> 恢复默认
</button>
</AdminSettingsActions>
{(gitPreview || gitImportLoaded) ? (
<div className="admin-settings-git-summary" aria-label="规则摘要">
<span className="admin-settings-git-summary-kicker">规则摘要</span>
{gitPreview ? (
<>
<span>
最多 {gitPreview.maxPages ?? "—"} 页 · {gitPreview.maxCommits ?? "—"} 条提交
</span>
{gitPreview.sources.map((s) => (
<span key={`${s.id}-${s.host}`} className="admin-settings-git-summary-src">
<GitBranch size={12} aria-hidden />
<strong>{s.id}</strong>
<span>{s.host}</span>
</span>
))}
</>
) : (
<span>JSON 无法解析时不显示摘要,保存前请先校验语法。</span>
)}
</div>
) : null}
</>
}
>
{gitImportLoading && !gitImportLoaded ? (
<div className="flex items-center gap-2 meta py-8 justify-center">
<Loader2 size={16} className="animate-spin" /> 加载适配器…
</div>
) : (
<AdminSettingsRow
className="admin-settings-git-editor"
label="适配器 JSON"
htmlFor="git-import-json"
hint="声明式匹配规则。服务端只按规则拉取。"
wide
>
<div className="admin-settings-git-json-wrap">
<textarea
id="git-import-json"
className="j13-compose-field admin-settings-git-json font-mono text-[12.5px] leading-relaxed"
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}
</div>
</AdminSettingsRow>
)}
</AdminSettingsGroup>
)}
</div>
</AdminSettingsWork>
<ConfirmDialog
open={restoreGitOpen}
title="恢复默认适配器"
message="将覆盖当前「提交导入」JSON 为内置 GitHub + Gitea 模板,并立即保存。确定继续?"
busy={savingGitImport}
confirmLabel="恢复并保存"
onCancel={() => !savingGitImport && setRestoreGitOpen(false)}
onConfirm={() => void restoreGitDefault()}
/>
<ConfirmDialog
open={leaveOpen}
title="有未保存的修改"
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={cancelLeave}
onConfirm={confirmLeave}
/>
</AdminSettingsPage>
);
}