后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
865 lines
32 KiB
TypeScript
865 lines
32 KiB
TypeScript
"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>
|
||
);
|
||
}
|