"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Palette, Check, Code2, Loader2, Save, Undo2, RotateCcw, Pipette, Sparkles, ImageIcon, Monitor, LayoutDashboard, Trash2, Upload, } from "lucide-react"; import { AdminPageHeader, AdminSection, AdminSettingRow, AdminStepper, AdminSwitch, } from "@/components/admin"; import BgModePicker from "./BgModePicker"; import { apiUpdateSiteSettings, apiUploadSiteBackground, type PublicSettings, } from "@/lib/api"; import { toast } from "@/lib/toast"; import { ACCENT_PRESETS, DEFAULT_ACCENT, applyAccentTheme, deriveAccentPair, findPreset, normalizeHex, } from "@/lib/theme"; import { BG_PREVIEW_ASPECT, applySiteBackground, bgConfigFromSettings, previewBoxStyle, type BgMode, type BgSurface, type SiteBgConfig, } from "@/lib/bg"; // 后台「外观设置」:主题色、代码块折叠与界面动画。 // 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。 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); 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 [bg, setBg] = useState(() => bgConfigFromSettings(initial)); const [bgBusy, setBgBusy] = useState(null); const siteFileRef = useRef(null); const adminFileRef = useRef(null); const savedRef = useRef(savedHex); savedRef.current = savedHex; 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 dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); const invalid = draft.trim() !== "" && !normalized; useEffect(() => { if (normalized) applyAccentTheme(normalized); return () => { applyAccentTheme(savedRef.current || null); }; }, [normalized]); const handleSave = async () => { if (!normalized || saving) return; setSaving(true); try { const res = await apiUpdateSiteSettings({ accent: submitHex }); const next = (res.accent || "").toLowerCase(); setSavedHex(next); applyAccentTheme(next || null); router.refresh(); toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败", "error"); applyAccentTheme(savedRef.current || null); } finally { setSaving(false); } }; const discard = () => { setDraft(savedHex || DEFAULT_ACCENT); applyAccentTheme(savedHex || null); }; 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); } }; const commitBg = (next: SiteBgConfig) => { setBg(next); applySiteBackground(next); }; const saveBgPatch = async (patch: Partial, toastOk: string) => { const res = await apiUpdateSiteSettings(patch); commitBg(bgConfigFromSettings(res)); toast(toastOk, "ok"); }; const onPickBg = async (surface: BgSurface, file: File | undefined) => { if (!file || bgBusy) return; if (!/^image\/(jpeg|png|webp)$/i.test(file.type) && !/\.(jpe?g|png|webp)$/i.test(file.name)) { toast("仅支持 JPEG / PNG / WebP", "error"); return; } if (file.size > 8 * 1024 * 1024) { toast("背景图不能超过 8MB", "error"); return; } setBgBusy(surface); try { const pendingMode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode; let res = await apiUploadSiteBackground(surface, file); let next = bgConfigFromSettings(res); const savedMode = surface === "site" ? next.bg_site_mode : next.bg_admin_mode; if (pendingMode !== savedMode) { const modeKey = surface === "site" ? "bg_site_mode" : "bg_admin_mode"; res = await apiUpdateSiteSettings({ [modeKey]: pendingMode }); next = bgConfigFromSettings(res); } commitBg(next); toast(surface === "site" ? "前台背景已更新" : "后台背景已更新", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "上传失败", "error"); } finally { setBgBusy(null); } }; const onChangeBgMode = async (surface: BgSurface, mode: BgMode) => { if (bgBusy) return; const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode"; const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url; if (!url) { commitBg({ ...bg, [key]: mode }); return; } setBgBusy(surface); try { await saveBgPatch( { [key]: mode }, surface === "site" ? "前台铺放方式已保存" : "后台铺放方式已保存" ); } catch (e) { toast(e instanceof Error ? e.message : "保存失败", "error"); } finally { setBgBusy(null); } }; const onClearBg = async (surface: BgSurface) => { if (bgBusy) return; const key = surface === "site" ? "bg_site_url" : "bg_admin_url"; setBgBusy(surface); try { await saveBgPatch( { [key]: "" }, surface === "site" ? "已恢复前台默认画布" : "已恢复后台默认画布" ); } catch (e) { toast(e instanceof Error ? e.message : "保存失败", "error"); } finally { setBgBusy(null); } }; const renderBgRail = ( surface: BgSurface, title: string, desc: string, Icon: typeof Monitor, fileRef: typeof siteFileRef ) => { const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url; const mode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode; const busy = bgBusy === surface; return (

{title}

{desc}

{!url && ( 默认纹饰画布 )} 16:9
{ const f = e.target.files?.[0]; e.target.value = ""; void onPickBg(surface, f); }} />
{url ? ( ) : null}

铺放方式

void onChangeBgMode(surface, next)} />
); }; 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="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。预览按 16:9 常见显示器比例。未配置时使用默认纹饰画布。" >
{renderBgRail( "site", "前台", "访客看到的论坛页面,含首页、帖子与个人主页。", Monitor, siteFileRef )} {renderBgRail( "admin", "后台", "管理面板专用。与前台互不影响,可配更克制的图案。", LayoutDashboard, adminFileRef )}

{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}

{dirty && ( )}
); }