feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板

后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 04:16:32 +08:00
parent fa713bfc3f
commit 5f7193042f
41 changed files with 2297 additions and 873 deletions

View File

@@ -12,6 +12,7 @@ import {
RotateCcw,
Pipette,
Sparkles,
FileCode2,
} from "lucide-react";
import {
AdminPageHeader,
@@ -20,7 +21,7 @@ import {
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { apiUpdateSiteSettings, type PublicSettings, type UpdateSiteSettingsBody } from "@/lib/api";
import { toast } from "@/lib/toast";
import {
ACCENT_PRESETS,
@@ -30,14 +31,27 @@ import {
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
@@ -54,6 +68,8 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
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(
@@ -62,7 +78,12 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
const dirty = (savedHex || 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(() => {
@@ -72,19 +93,35 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
};
}, [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({ accent: submitHex });
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(next ? "主题色已保存并对全站生效" : "已恢复默认主题色");
toast(jsDirty ? "外观已保存;自定义 JS 将在整页刷新后执行" : "外观已保存并对全站生效");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
applyAccentTheme(savedRef.current || null);
applyCustomCSS(savedCSSRef.current, false);
} finally {
setSaving(false);
}
@@ -92,7 +129,10 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
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 () => {
@@ -167,7 +207,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色、代码块折叠与界面动画"
description="配置站点主题色、自定义 CSS/JS、代码块折叠与界面动画"
/>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
@@ -296,7 +336,10 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
<AdminSection title="实时预览">
<div
className="rounded-[var(--r-sm)] border p-4 space-y-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
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">
@@ -327,14 +370,16 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
</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 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>
@@ -470,6 +515,85 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
</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 ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}