feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View File

@@ -36,7 +36,6 @@ import {
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
const router = useRouter();
const initialAccent = initial.accent || "";
// 已保存值:空串=内置默认(靛蓝)
const [savedHex, setSavedHex] = useState(initialAccent);
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
@@ -62,12 +61,10 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
[normalized]
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
// 保存提交值:默认靛蓝落库为空串(前端不注入覆盖,保持内置令牌单一来源)
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const invalid = draft.trim() !== "" && !normalized;
// 实时预览:合法颜色即应用;离开页面时还原为已保存值,避免未保存预览残留
useEffect(() => {
if (normalized) applyAccentTheme(normalized);
return () => {
@@ -166,305 +163,311 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
};
return (
<div className="max-w-3xl">
<div>
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色、代码块折叠与界面动画"
/>
<div className="flex flex-col gap-4 mb-6">
<AdminSection
title="预设主题色"
description="浅/暗两套色板均已调校,保证文字对比度达标"
>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{active && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] leading-none"
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
</AdminSection>
<AdminSection
title={
<span className="inline-flex items-center gap-1.5">
<Pipette size={15} /> 自定义颜色
</span>
}
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读"
>
<div className="flex flex-wrap items-center gap-3">
<label
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
data-tip="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
<div className="flex flex-col gap-1">
<div
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
/>
</div>
</div>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.light, borderColor: "var(--line)" }}
/>
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.dark, borderColor: "var(--line)" }}
/>
暗色 {pair.dark}
</span>
</div>
)}
</div>
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
{pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</AdminSection>
<AdminSection title="实时预览">
<div
className="rounded-[var(--r-sm)] border p-4 space-y-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
{/* 主题色列 */}
<div className="flex flex-col gap-4 xl:col-span-2">
<AdminSection
title="预设主题色"
description="浅/暗两套色板均已调校,保证文字对比度达标"
>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">
次要按钮
</button>
<a
className="text-[13px]"
style={{ color: "var(--accent)" }}
href="#preview"
onClick={(e) => e.preventDefault()}
>
文字链接示例
</a>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{active && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] leading-none"
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="admin-seg">未选中</span>
<span className="admin-seg" aria-selected="true">
选中标签
</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
</span>
</div>
<div className="max-w-xs">
<div className="j13-bcard" aria-current="page">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Palette size={16} />
</span>
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
</div>
</div>
</div>
</AdminSection>
</AdminSection>
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</>
}
description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
control={
<AdminSwitch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
}
>
{codeAutoFold ? (
<>
<label className="field-label" htmlFor="fold-lines">
折叠行数阈值
<AdminSection
title={
<span className="inline-flex items-center gap-1.5">
<Pipette size={15} /> 自定义颜色
</span>
}
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正"
>
<div className="flex flex-wrap items-center gap-3">
<label
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
data-tip="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<AdminStepper
id="fold-lines"
value={foldLines}
onChange={setFoldLines}
min={0}
max={100}
unit="行"
ariaLabel="折叠行数阈值"
disabled={savingFoldLines}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingFoldLines}
onClick={() => void saveFoldLines()}
<div className="flex flex-col gap-1">
<div
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
>
{savingFoldLines ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
保存
</button>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
/>
</div>
</div>
</>
) : null}
</AdminSettingRow>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.light, borderColor: "var(--line)" }}
/>
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.dark, borderColor: "var(--line)" }}
/>
暗色 {pair.dark}
</span>
</div>
)}
</div>
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
{pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</AdminSection>
<AdminSettingRow
title={
<>
<Sparkles size={15} /> 界面动画
</>
}
description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
control={
<AdminSwitch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
}
>
{uiAnimations ? (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
代码折叠动画
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
代码块展开/收起的高度过渡与跟滚。
</p>
</div>
<AdminSwitch
checked={animCodeFold}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="代码折叠动画"
/>
<AdminSection title="实时预览">
<div
className="rounded-[var(--r-sm)] border p-4 space-y-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">
次要按钮
</button>
<a
className="text-[13px]"
style={{ color: "var(--accent)" }}
href="#preview"
onClick={(e) => e.preventDefault()}
>
文字链接示例
</a>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
平滑滚动
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
目录跳转、回顶、新内容提示等 scroll 行为。
</p>
</div>
<AdminSwitch
checked={animSmoothScroll}
disabled={togglingMotion}
onChange={() =>
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
}
label="平滑滚动"
/>
<div className="flex flex-wrap items-center gap-2">
<span className="admin-seg">未选中</span>
<span className="admin-seg" aria-selected="true">
选中标签
</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
</span>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
装饰入场
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
</p>
<div className="max-w-xs">
<div className="j13-bcard" aria-current="page">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Palette size={16} />
</span>
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
</div>
<AdminSwitch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
</div>
</div>
) : null}
</AdminSettingRow>
</AdminSection>
</div>
{/* 代码折叠 + 动画列 */}
<div className="flex flex-col gap-4">
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</>
}
description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。"
control={
<AdminSwitch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
}
>
{codeAutoFold ? (
<>
<label className="field-label" htmlFor="fold-lines">
折叠行数阈值
</label>
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<AdminStepper
id="fold-lines"
value={foldLines}
onChange={setFoldLines}
min={0}
max={100}
unit="行"
ariaLabel="折叠行数阈值"
disabled={savingFoldLines}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingFoldLines}
onClick={() => void saveFoldLines()}
>
{savingFoldLines ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
保存
</button>
</div>
</>
) : null}
</AdminSettingRow>
<AdminSettingRow
title={
<>
<Sparkles size={15} /> 界面动画
</>
}
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。"
control={
<AdminSwitch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
}
>
{uiAnimations ? (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
代码折叠动画
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
代码块展开/收起的高度过渡与跟滚。
</p>
</div>
<AdminSwitch
checked={animCodeFold}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="代码折叠动画"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
平滑滚动
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
目录跳转、回顶、新内容提示等 scroll 行为。
</p>
</div>
<AdminSwitch
checked={animSmoothScroll}
disabled={togglingMotion}
onChange={() =>
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
}
label="平滑滚动"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
装饰入场
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
</p>
</div>
<AdminSwitch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
</div>
</div>
) : null}
</AdminSettingRow>
</div>
</div>
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">