Files
jiang13-bbs/frontend/app/admin/appearance/AppearanceAdmin.tsx
freefire 5f7193042f feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 04:16:32 +08:00

630 lines
25 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 { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
Palette,
Check,
Code2,
Loader2,
Save,
Undo2,
RotateCcw,
Pipette,
Sparkles,
FileCode2,
} from "lucide-react";
import {
AdminPageHeader,
AdminSection,
AdminSettingRow,
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings, type UpdateSiteSettingsBody } from "@/lib/api";
import { toast } from "@/lib/toast";
import {
ACCENT_PRESETS,
DEFAULT_ACCENT,
applyAccentTheme,
deriveAccentPair,
findPreset,
normalizeHex,
} from "@/lib/theme";
import {
MAX_CUSTOM_CSS_CHARS,
MAX_CUSTOM_JS_CHARS,
SAMPLE_CUSTOM_CSS,
SAMPLE_CUSTOM_JS,
applyCustomCSS,
clipCustomSnippet,
} from "@/lib/customCode";
// 后台「外观设置」:主题色、自定义 CSS/JS、代码块折叠与界面动画。
// 主题色 / 自定义 CSS 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
// 自定义 JS 不在编辑时执行,保存后整页加载生效。
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 [savedCustomCSS, setSavedCustomCSS] = useState(initial.custom_css || "");
const [customCSS, setCustomCSS] = useState(initial.custom_css || SAMPLE_CUSTOM_CSS);
const [savedCustomJS, setSavedCustomJS] = useState(initial.custom_js || "");
const [customJS, setCustomJS] = useState(initial.custom_js || SAMPLE_CUSTOM_JS);
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
const [foldLines, setFoldLines] = useState(
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
);
const [togglingFold, setTogglingFold] = useState(false);
const [savingFoldLines, setSavingFoldLines] = useState(false);
const [uiAnimations, setUiAnimations] = useState(initial.ui_animations !== false);
const [animCodeFold, setAnimCodeFold] = useState(initial.anim_code_fold !== false);
const [animSmoothScroll, setAnimSmoothScroll] = useState(
initial.anim_smooth_scroll !== false
);
const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false);
const [togglingMotion, setTogglingMotion] = useState(false);
const [saving, setSaving] = useState(false);
const savedRef = useRef(savedHex);
savedRef.current = savedHex;
const savedCSSRef = useRef(savedCustomCSS);
savedCSSRef.current = savedCustomCSS;
const normalized = useMemo(() => normalizeHex(draft), [draft]);
const pair = useMemo(
() => (normalized ? deriveAccentPair(normalized) : null),
[normalized]
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
const accentDirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const cssIsGuide = !savedCustomCSS && customCSS === SAMPLE_CUSTOM_CSS;
const jsIsGuide = !savedCustomJS && customJS === SAMPLE_CUSTOM_JS;
const cssDirty = customCSS !== savedCustomCSS && !cssIsGuide;
const jsDirty = customJS !== savedCustomJS && !jsIsGuide;
const dirty = accentDirty || cssDirty || jsDirty;
const invalid = draft.trim() !== "" && !normalized;
useEffect(() => {
if (normalized) applyAccentTheme(normalized);
return () => {
applyAccentTheme(savedRef.current || null);
};
}, [normalized]);
useEffect(() => {
applyCustomCSS(customCSS, true);
return () => {
applyCustomCSS(savedCSSRef.current, false);
};
}, [customCSS]);
const handleSave = async () => {
if (!normalized || saving) return;
const body: UpdateSiteSettingsBody = {};
if (accentDirty) body.accent = submitHex;
if (cssDirty) body.custom_css = customCSS;
if (jsDirty) body.custom_js = customJS;
if (Object.keys(body).length === 0) return;
setSaving(true);
try {
const res = await apiUpdateSiteSettings(body);
const next = (res.accent || "").toLowerCase();
setSavedHex(next);
setSavedCustomCSS(res.custom_css || "");
setSavedCustomJS(res.custom_js || "");
applyAccentTheme(next || null);
applyCustomCSS(res.custom_css || "", false);
router.refresh();
toast(jsDirty ? "外观已保存;自定义 JS 将在整页刷新后执行" : "外观已保存并对全站生效");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
applyAccentTheme(savedRef.current || null);
applyCustomCSS(savedCSSRef.current, false);
} finally {
setSaving(false);
}
};
const discard = () => {
setDraft(savedHex || DEFAULT_ACCENT);
setCustomCSS(savedCustomCSS || SAMPLE_CUSTOM_CSS);
setCustomJS(savedCustomJS || SAMPLE_CUSTOM_JS);
applyAccentTheme(savedHex || null);
applyCustomCSS(savedCustomCSS, true);
};
const toggleCodeFold = async () => {
if (togglingFold) return;
const next = !codeAutoFold;
setTogglingFold(true);
try {
const res = await apiUpdateSiteSettings({ code_block_auto_fold: next });
setCodeAutoFold(res.code_block_auto_fold);
toast(
res.code_block_auto_fold
? `已开启代码块自动折叠(超过 ${res.code_block_fold_lines} 行)`
: "已关闭代码块自动折叠",
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingFold(false);
}
};
const saveFoldLines = async () => {
if (savingFoldLines) return;
const raw = Math.round(Number(foldLines));
const n = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 5;
setSavingFoldLines(true);
try {
const res = await apiUpdateSiteSettings({ code_block_fold_lines: n });
setFoldLines(res.code_block_fold_lines);
toast(
res.code_block_fold_lines === 0
? "折叠预览已设为 0 行(无预览)"
: `折叠阈值已设为超过 ${res.code_block_fold_lines} 行`,
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingFoldLines(false);
}
};
const toggleMotionField = async (
key: "ui_animations" | "anim_code_fold" | "anim_smooth_scroll" | "anim_chrome",
next: boolean
) => {
if (togglingMotion) return;
setTogglingMotion(true);
try {
const res = await apiUpdateSiteSettings({ [key]: next });
setUiAnimations(res.ui_animations);
setAnimCodeFold(res.anim_code_fold);
setAnimSmoothScroll(res.anim_smooth_scroll);
setAnimChrome(res.anim_chrome);
const labels: Record<typeof key, [string, string]> = {
ui_animations: ["已开启界面动画", "已关闭界面动画(全站减少动态效果)"],
anim_code_fold: ["已开启代码折叠动画", "已关闭代码折叠动画"],
anim_smooth_scroll: ["已开启平滑滚动", "已关闭平滑滚动"],
anim_chrome: ["已开启装饰入场动画", "已关闭装饰入场动画"],
};
toast(next ? labels[key][0] : labels[key][1], "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingMotion(false);
}
};
return (
<div>
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色、自定义 CSS/JS、代码块折叠与界面动画"
/>
<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="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="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 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="panel p-3">
<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>
</div>
</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>
<AdminSection
title={
<span className="inline-flex items-center gap-1.5">
<FileCode2 size={15} /> 自定义代码
</span>
}
description="注入全站 HTML。空配置时先显示注释示例(不会自动写入);保存后才对访客生效。勿包含 </style> 或 </script>。"
className="mb-6"
>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<label className="field-label !mb-0" htmlFor="custom-css">
自定义 CSS
</label>
{customCSS !== SAMPLE_CUSTOM_CSS && (
<button
type="button"
className="btn btn-line btn-sm !h-7 !px-2.5 !text-[12px]"
onClick={() => setCustomCSS(SAMPLE_CUSTOM_CSS)}
>
填入示例
</button>
)}
</div>
<p className="meta text-[12.5px] mb-2 leading-relaxed">
覆盖令牌或组件类。本页即时预览;去掉注释后的规则才会改变外观。上限{" "}
{MAX_CUSTOM_CSS_CHARS.toLocaleString()} 字。
</p>
<textarea
id="custom-css"
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
style={{ minHeight: 280 }}
spellCheck={false}
value={customCSS}
onChange={(e) => setCustomCSS(clipCustomSnippet(e.target.value, MAX_CUSTOM_CSS_CHARS))}
aria-label="自定义 CSS"
/>
<p className="meta text-[11.5px] mt-1.5 tabular-nums">
{customCSS.length.toLocaleString()} / {MAX_CUSTOM_CSS_CHARS.toLocaleString()}
{cssIsGuide ? " · 示例未保存" : ""}
</p>
</div>
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<label className="field-label !mb-0" htmlFor="custom-js">
自定义 JS
</label>
{customJS !== SAMPLE_CUSTOM_JS && (
<button
type="button"
className="btn btn-line btn-sm !h-7 !px-2.5 !text-[12px]"
onClick={() => setCustomJS(SAMPLE_CUSTOM_JS)}
>
填入示例
</button>
)}
</div>
<p className="meta text-[12.5px] mb-2 leading-relaxed">
仅站长/超管可写。不在编辑时运行,保存后需整页刷新。上限{" "}
{MAX_CUSTOM_JS_CHARS.toLocaleString()} 字。
</p>
<textarea
id="custom-js"
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
style={{ minHeight: 280 }}
spellCheck={false}
value={customJS}
onChange={(e) => setCustomJS(clipCustomSnippet(e.target.value, MAX_CUSTOM_JS_CHARS))}
aria-label="自定义 JS"
/>
<p className="meta text-[11.5px] mt-1.5 tabular-nums">
{customJS.length.toLocaleString()} / {MAX_CUSTOM_JS_CHARS.toLocaleString()}
{jsIsGuide ? " · 示例未保存" : ""}
</p>
</div>
</div>
</AdminSection>
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
<p className="text-[12.5px] meta hidden sm:block">
{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
</p>
<div className="flex items-center gap-2.5 ml-auto">
{dirty && (
<button type="button" className="btn btn-line btn-sm" onClick={discard}>
<Undo2 size={14} /> 撤销改动
</button>
)}
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => {
setDraft(DEFAULT_ACCENT);
}}
>
<RotateCcw size={14} /> 恢复默认
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={!normalized || saving || !dirty}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存并全站生效
</button>
</div>
</div>
</div>
);
}