"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 = { 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 (
{/* 主题色列 */}
{ACCENT_PRESETS.map((p) => { const active = normalized === p.light; const isDefault = p.light === DEFAULT_ACCENT; return ( ); })}
自定义颜色 } description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正" >
# 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 值" />
{pair && (
浅色 {pair.light} 暗色 {pair.dark}
)}
{invalid && (

颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)

)} {pair?.adjusted && (

该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} {pair.dark})

)} {preset && preset.light === DEFAULT_ACCENT && (

当前为内置默认色,保存时会清除自定义配置

)}
未选中 选中标签 导航选中态
选中的板块卡片
{/* 代码折叠 + 动画列 */}
代码块自动折叠 } description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。" control={ void toggleCodeFold()} label="代码块自动折叠" /> } > {codeAutoFold ? ( <>

超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。

) : null}
界面动画 } description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。" control={ void toggleMotionField("ui_animations", !uiAnimations)} label="界面动画总开关" /> } > {uiAnimations ? (

代码折叠动画

代码块展开/收起的高度过渡与跟滚。

void toggleMotionField("anim_code_fold", !animCodeFold)} label="代码折叠动画" />

平滑滚动

目录跳转、回顶、新内容提示等 scroll 行为。

void toggleMotionField("anim_smooth_scroll", !animSmoothScroll) } label="平滑滚动" />

装饰入场

列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。

void toggleMotionField("anim_chrome", !animChrome)} label="装饰入场" />
) : null}
自定义代码 } description="注入全站 HTML。空配置时先显示注释示例(不会自动写入);保存后才对访客生效。勿包含 或 。" className="mb-6" >
{customCSS !== SAMPLE_CUSTOM_CSS && ( )}

覆盖令牌或组件类。本页即时预览;去掉注释后的规则才会改变外观。上限{" "} {MAX_CUSTOM_CSS_CHARS.toLocaleString()} 字。