939 lines
32 KiB
TypeScript
939 lines
32 KiB
TypeScript
"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>
|
||
);
|
||
}
|