后台站点设计UIUX优化

This commit is contained in:
2026-09-19 04:14:13 +08:00
parent a8efb9ecef
commit 2b3d122e56
24 changed files with 2952 additions and 306 deletions

View File

@@ -13,7 +13,6 @@ import {
HardDrive,
Ban,
Wrench,
Palette,
FileText,
Paperclip,
Image as ImageIcon,
@@ -21,7 +20,7 @@ import {
RotateCcw,
CheckCircle2,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import {
AdminExtChipEditor,
AdminField,
@@ -32,6 +31,9 @@ import {
} 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[]) {
@@ -85,8 +87,6 @@ type SectionId =
| "basic"
| "content"
| "gitimport"
| "brand"
| "footer"
| "security"
| "mail"
| "storage"
@@ -97,8 +97,6 @@ const NAV: { id: SectionId; label: string; ready?: boolean }[] = [
{ id: "basic", label: "基本信息", ready: true },
{ id: "content", label: "内容与互动", ready: true },
{ id: "gitimport", label: "提交导入", ready: true },
{ id: "brand", label: "品牌与 SEO" },
{ id: "footer", label: "页脚与合规" },
{ id: "security", label: "安全与限流" },
{ id: "mail", label: "邮件" },
{ id: "storage", label: "对象存储" },
@@ -119,14 +117,14 @@ function SwitchRow({
}) {
return (
<div
className="flex items-start justify-between gap-4 flex-wrap py-4 first:pt-0 last:pb-0"
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-[14.5px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<p className="text-[14px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
{title}
</p>
<p className="meta text-[12.5px] mt-1 leading-relaxed">{description}</p>
<p className="meta text-[12px] mt-0.5 leading-relaxed">{description}</p>
</div>
<div className="shrink-0">{control}</div>
</div>
@@ -145,7 +143,7 @@ function ComingSoonCard({
return (
<AdminSection title={title} description={description}>
<div
className="rounded-[var(--r-sm)] px-4 py-10 text-center"
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>
@@ -160,21 +158,33 @@ function ComingSoonCard({
);
}
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 [siteName, setSiteName] = useState(initial.site_name);
const [siteDescription, setSiteDescription] = useState(initial.site_description);
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 [savedName, setSavedName] = useState(initial.site_name);
const [savedDesc, setSavedDesc] = useState(initial.site_description);
const [saving, setSaving] = useState(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 || [])]);
@@ -194,9 +204,31 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
const [restoreGitOpen, setRestoreGitOpen] = useState(false);
const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null);
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
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;
@@ -227,35 +259,28 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const ids = NAV.map((n) => n.id);
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]?.target?.id) {
const id = visible[0].target.id.replace(/^settings-/, "") as SectionId;
if (ids.includes(id)) setActive(id);
}
},
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5] }
);
ids.forEach((id) => {
const el = document.getElementById(`settings-${id}`);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
const scrollTo = (id: SectionId) => {
setActive(id);
document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
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);
@@ -320,31 +345,6 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
}
};
const saveIdentity = async () => {
if (saving) return;
const name = siteName.trim();
if (!name) {
toast("站点名称不能为空");
return;
}
setSaving(true);
try {
const res = await apiUpdateSiteSettings({
site_name: name,
site_description: siteDescription.trim(),
});
setSiteName(res.site_name);
setSiteDescription(res.site_description);
setSavedName(res.site_name);
setSavedDesc(res.site_description);
toast("站点信息已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
const toggleRegister = async () => {
if (togglingReg) return;
const next = !allowRegister;
@@ -479,80 +479,55 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
<AdminPageHeader
icon={Globe}
title="站点设置"
description="配置站点名称、互动开关与后续扩展分区;主题色请到「外观」页调整"
description="名称、Logo、关键词与页脚都在「基本信息」;主题色请到「外观」页调整"
/>
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
<nav
className="w-full lg:w-52 shrink-0 lg:sticky lg:top-4 flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-1 lg:pb-0"
aria-label="设置分区"
>
{NAV.map((item) => {
const isActive = active === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => scrollTo(item.id)}
className="shrink-0 text-left px-3 py-2 rounded-[var(--r-sm)] text-[13.5px] transition-colors whitespace-nowrap"
style={{
background: isActive ? "var(--accent-soft)" : "transparent",
color: isActive ? "var(--accent)" : "var(--ink-2)",
fontWeight: isActive ? 600 : 400,
}}
aria-current={isActive ? "true" : undefined}
>
{item.label}
{!item.ready && (
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--ink-3)" }}>
即将
</span>
)}
</button>
);
})}
</nav>
<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 className="flex-1 min-w-0 flex flex-col gap-4 w-full">
<div id="settings-basic" className="scroll-mt-4">
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
<AdminField
id="site-name"
className="mb-4"
label="站点名称"
maxLength={32}
value={siteName}
onChange={setSiteName}
placeholder="姜十三论坛"
/>
<AdminField
id="site-desc"
className="mb-4"
label="站点简介"
as="textarea"
minHeight="96px"
maxLength={160}
value={siteDescription}
onChange={setSiteDescription}
placeholder="一句话介绍社区定位…"
/>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={() => void saveIdentity()}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存基本信息
</button>
</div>
</AdminSection>
<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>
)}
<div id="settings-content" className="scroll-mt-4 flex flex-col gap-4">
{active === "content" && (
<div className="admin-settings-grid">
<AdminSection title="内容与互动" description="控制注册、评论、消息与链接打开方式">
<SwitchRow
bordered
@@ -754,29 +729,27 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
</div>
</AdminSection>
</div>
)}
<div id="settings-gitimport" className="scroll-mt-4">
{active === "gitimport" && (
<div className="admin-settings-grid">
<AdminSection
title="提交导入"
description="声明式匹配规则,非可执行脚本。用于将 GitHub / Gitea 等提交历史导入时间线。"
title="适配器 JSON"
description="声明式匹配规则,非可执行脚本。服务端只按规则拉取。"
>
<p className="meta text-[12.5px] mb-3 leading-relaxed">
通过 JSON 描述 host、路径正则与 API 字段映射;服务端只按规则拉取,不会执行任意代码。
</p>
{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" htmlFor="git-import-json">
<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: 320 }}
style={{ minHeight: 360 }}
spellCheck={false}
value={gitImportJSON}
onChange={(e) => {
@@ -785,7 +758,6 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
}}
placeholder="粘贴或编辑适配器 JSON…"
/>
{gitValidateMsg ? (
<p
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
@@ -798,91 +770,158 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
{gitValidateMsg.text}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2 mt-4">
<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>
<span className="meta text-[11.5px] inline-flex items-center gap-1 ml-auto">
<GitBranch size={12} /> GitHub + Gitea 模板
</span>
</div>
</>
)}
</AdminSection>
</div>
<div id="settings-brand" className="scroll-mt-4">
<ComingSoonCard
title="品牌与 SEO"
description="站点图标、OG 图与结构化元数据"
icon={<Palette size={22} />}
/>
<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>
<div id="settings-footer" className="scroll-mt-4">
<ComingSoonCard
title="页脚与合规"
description="备案号、版权声明与合规链接"
icon={<FileText size={22} />}
/>
</div>
<div id="settings-security" className="scroll-mt-4">
<ComingSoonCard
title="安全与限流"
description="登录/注册速率与发帖冷静期等"
icon={<Shield size={22} />}
/>
</div>
<div id="settings-mail" className="scroll-mt-4">
<ComingSoonCard title="邮件" description="SMTP 与通知邮件模板" icon={<Mail size={22} />} />
</div>
<div id="settings-storage" className="scroll-mt-4">
<ComingSoonCard
title="对象存储"
description="附件与头像的云端存储配置"
icon={<HardDrive size={22} />}
/>
</div>
<div id="settings-words" className="scroll-mt-4">
<ComingSoonCard title="敏感词" description="内容过滤词库与命中策略" icon={<Ban size={22} />} />
</div>
<div id="settings-maintenance" className="scroll-mt-4">
<ComingSoonCard
title="系统维护"
description="维护模式、缓存与诊断工具"
icon={<Wrench size={22} />}
/>
</div>
</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