"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Palette, Check, Code2, Loader2, Save, RotateCcw, Pipette, Sparkles, ImageIcon, Monitor, LayoutDashboard, Trash2, Upload, Images, } from "lucide-react"; import { AdminPageHeader, AdminSettingsActions, AdminSettingsGroup, AdminSettingsPage, AdminSettingsRow, AdminStepper, AdminSwitch, } from "@/components/admin"; import BgModePicker from "./BgModePicker"; import BgMediaPicker from "./BgMediaPicker"; import ConfirmDialog from "@/components/ConfirmDialog"; import { apiBackgroundFromMedia, apiUpdateSiteSettings, apiUploadSiteBackground, type PublicSettings, } from "@/lib/api"; import { toast } from "@/lib/toast"; import useUnsavedGuard from "@/hooks/useUnsavedGuard"; 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"; function InstantNote({ saving }: { saving?: boolean }) { return {saving ? "正在保存…" : "修改后立即生效"}; } function bgEqual(a: SiteBgConfig, b: SiteBgConfig): boolean { return ( a.bg_site_url === b.bg_site_url && a.bg_site_mode === b.bg_site_mode && a.bg_admin_url === b.bg_admin_url && a.bg_admin_mode === b.bg_admin_mode ); } // 后台「外观设置」:单页分区;主题色/背景图草稿本地预览,保存后才写库并广播。 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 [savingTheme, setSavingTheme] = useState(false); const [themeError, setThemeError] = useState(""); const [savedBg, setSavedBg] = useState(() => bgConfigFromSettings(initial)); const [bgDraft, setBgDraft] = useState(() => bgConfigFromSettings(initial)); const [siteFile, setSiteFile] = useState(null); const [adminFile, setAdminFile] = useState(null); const [siteBlob, setSiteBlob] = useState(""); const [adminBlob, setAdminBlob] = useState(""); const [siteMediaId, setSiteMediaId] = useState(null); const [adminMediaId, setAdminMediaId] = useState(null); const [siteMediaUrl, setSiteMediaUrl] = useState(""); const [adminMediaUrl, setAdminMediaUrl] = useState(""); const [bgPicker, setBgPicker] = useState(null); const [savingBg, setSavingBg] = useState(false); const [bgError, setBgError] = useState(""); const siteFileRef = useRef(null); const adminFileRef = useRef(null); const blobsRef = useRef([]); const savedRef = useRef(savedHex); savedRef.current = savedHex; const savedBgRef = useRef(savedBg); savedBgRef.current = savedBg; 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 themeDirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); const themeInvalid = draft.trim() !== "" && !normalized; const bgDirty = !bgEqual(bgDraft, savedBg) || siteFile != null || adminFile != null || siteMediaId != null || adminMediaId != null; const leave = useUnsavedGuard(themeDirty || bgDirty); useEffect(() => { const held = blobsRef; return () => { held.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); useEffect(() => { if (normalized) applyAccentTheme(normalized); return () => { applyAccentTheme(savedRef.current || null); }; }, [normalized]); // 离开页时恢复已保存背景,避免草稿残留在本标签 useEffect(() => { return () => { applySiteBackground(savedBgRef.current); }; }, []); const handleSaveTheme = async () => { if (!normalized || savingTheme || themeInvalid) return; setSavingTheme(true); setThemeError(""); try { const res = await apiUpdateSiteSettings({ accent: submitHex }); const next = (res.accent || "").toLowerCase(); setSavedHex(next); applyAccentTheme(next || null); router.refresh(); toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色"); } catch (e) { const msg = e instanceof Error ? e.message : "保存失败"; setThemeError(msg); toast(msg, "error"); applyAccentTheme(savedRef.current || null); } finally { setSavingTheme(false); } }; const discardTheme = () => { setDraft(savedHex || DEFAULT_ACCENT); setThemeError(""); applyAccentTheme(savedHex || null); }; const revokeBlob = (url: string) => { if (!url) return; URL.revokeObjectURL(url); blobsRef.current = blobsRef.current.filter((u) => u !== url); }; const clearPending = (surface: BgSurface) => { setBgError(""); if (surface === "site") { setSiteFile(null); setSiteBlob((prev) => { revokeBlob(prev); return ""; }); setSiteMediaId(null); setSiteMediaUrl(""); } else { setAdminFile(null); setAdminBlob((prev) => { revokeBlob(prev); return ""; }); setAdminMediaId(null); setAdminMediaUrl(""); } }; const setPendingFile = (surface: BgSurface, file: File | null) => { setBgError(""); if (!file) { clearPending(surface); return; } const blob = URL.createObjectURL(file); blobsRef.current.push(blob); if (surface === "site") { setSiteFile(file); setSiteBlob((prev) => { revokeBlob(prev); return blob; }); setSiteMediaId(null); setSiteMediaUrl(""); } else { setAdminFile(file); setAdminBlob((prev) => { revokeBlob(prev); return blob; }); setAdminMediaId(null); setAdminMediaUrl(""); } }; const setPendingMedia = (surface: BgSurface, mediaId: number, mediaUrl: string) => { setBgError(""); if (surface === "site") { setSiteFile(null); setSiteBlob((prev) => { revokeBlob(prev); return ""; }); setSiteMediaId(mediaId); setSiteMediaUrl(mediaUrl); } else { setAdminFile(null); setAdminBlob((prev) => { revokeBlob(prev); return ""; }); setAdminMediaId(mediaId); setAdminMediaUrl(mediaUrl); } }; const discardBg = () => { setBgDraft(savedBg); clearPending("site"); clearPending("admin"); setBgError(""); }; const resolveSurfaceUrl = async ( surface: BgSurface, committed: string, file: File | null, mediaId: number | null ): Promise => { if (file) return apiUploadSiteBackground(surface, file); if (mediaId != null) return apiBackgroundFromMedia(surface, mediaId); return committed; }; const handleSaveBg = async () => { if (savingBg || !bgDirty) return; setSavingBg(true); setBgError(""); try { const siteUrl = await resolveSurfaceUrl("site", bgDraft.bg_site_url, siteFile, siteMediaId); const adminUrl = await resolveSurfaceUrl( "admin", bgDraft.bg_admin_url, adminFile, adminMediaId ); const res = await apiUpdateSiteSettings({ bg_site_url: siteUrl, bg_site_mode: bgDraft.bg_site_mode, bg_admin_url: adminUrl, bg_admin_mode: bgDraft.bg_admin_mode, }); const next = bgConfigFromSettings(res); setSavedBg(next); setBgDraft(next); clearPending("site"); clearPending("admin"); applySiteBackground(next); router.refresh(); toast("背景图已保存并对全站生效", "ok"); } catch (e) { const msg = e instanceof Error ? e.message : "保存失败"; setBgError(msg); toast(msg, "error"); } finally { setSavingBg(false); } }; const onPickBg = (surface: BgSurface, file: File | undefined) => { if (!file || savingBg) 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; } setPendingFile(surface, file); }; const onChangeBgMode = (surface: BgSurface, mode: BgMode) => { const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode"; setBgDraft((prev) => ({ ...prev, [key]: mode })); }; const onClearBg = (surface: BgSurface) => { const key = surface === "site" ? "bg_site_url" : "bg_admin_url"; setBgDraft((prev) => ({ ...prev, [key]: "" })); clearPending(surface); }; const previewUrl = (surface: BgSurface) => { if (surface === "site") return siteBlob || siteMediaUrl || bgDraft.bg_site_url; return adminBlob || adminMediaUrl || bgDraft.bg_admin_url; }; 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 renderBgRail = ( surface: BgSurface, title: string, desc: string, Icon: typeof Monitor, fileRef: typeof siteFileRef ) => { const url = previewUrl(surface); const mode = surface === "site" ? bgDraft.bg_site_mode : bgDraft.bg_admin_mode; const busy = savingBg; const hasCommitted = surface === "site" ? !!bgDraft.bg_site_url : !!bgDraft.bg_admin_url; const hasPending = surface === "site" ? siteFile != null || siteMediaId != null : adminFile != null || adminMediaId != null; return ( {title} {desc} {!url && ( 默认纹饰画布 )} 16:9 { const f = e.target.files?.[0]; e.target.value = ""; onPickBg(surface, f); }} /> fileRef.current?.click()} > {busy ? : } {hasCommitted || hasPending ? "更换图片" : "选择图片"} setBgPicker(surface)} > 媒体库 {hasCommitted || hasPending ? ( onClearBg(surface)} > 恢复默认 ) : null} 铺放方式 onChangeBgMode(surface, next)} /> ); }; return ( void handleSaveTheme()} onDiscard={discardTheme} saveDisabled={!normalized || savingTheme || !themeDirty || themeInvalid} error={themeError} > { setThemeError(""); setDraft(DEFAULT_ACCENT); }} > 恢复默认 } > {ACCENT_PRESETS.map((p) => { const selected = normalized === p.light; const isDefault = p.light === DEFAULT_ACCENT; return ( { setThemeError(""); setDraft(p.light); }} className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors" style={{ borderColor: selected ? "var(--accent)" : "var(--line)", background: selected ? "var(--accent-soft)" : "var(--panel)", cursor: "pointer", }} aria-pressed={selected} data-tip={isDefault ? `${p.name}(默认)` : p.name} > {selected && ( )} {p.name} ); })} 自定义 } hintBelow="取色器或 HEX;浅色对比不足时会自动校正。" error={themeInvalid ? "颜色格式无效,请输入 6 位 HEX(如 3B5BDB)" : undefined} > { setThemeError(""); setDraft(e.target.value); }} aria-label="自定义主题色取色器" /> # { setThemeError(""); 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} )} {pair?.adjusted && ( 该颜色与白底对比度不足,浅色已校正为 {pair.light}(暗色派生 {pair.dark}) )} {preset && preset.light === DEFAULT_ACCENT && ( 当前为内置默认色,保存时会清除自定义配置 )} 主按钮 次要按钮 e.preventDefault()} > 文字链接示例 未选中 选中标签 导航选中态 板块目录 选中的板块 void handleSaveBg()} onDiscard={discardBg} error={bgError} /> } > 前台 / 后台 } hintBelow="可从本机上传或媒体库选用;铺放方式随草稿一起保存。" wide > {renderBgRail( "site", "前台", "访客看到的论坛页面,含首页、帖子与个人主页。", Monitor, siteFileRef )} {renderBgRail( "admin", "后台", "管理面板专用。与前台互不影响,可配更克制的图案。", LayoutDashboard, adminFileRef )} 代码自动折叠 } hintBelow="超过阈值行数的 Markdown 代码块默认折叠,读者可展开。" > void toggleCodeFold()} label="代码块自动折叠" /> {codeAutoFold ? ( void saveFoldLines()} > {savingFoldLines ? ( ) : ( )} 保存阈值 ) : null} 界面动画 } hintBelow="总开关关闭后全站即时减少动态效果。" > void toggleMotionField("ui_animations", !uiAnimations)} label="界面动画总开关" /> {uiAnimations ? ( <> void toggleMotionField("anim_code_fold", !animCodeFold)} label="代码折叠动画" /> void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)} label="平滑滚动" /> void toggleMotionField("anim_chrome", !animChrome)} label="装饰入场" /> > ) : null} setBgPicker(null)} onPick={(item) => { if (!bgPicker) return; setPendingMedia(bgPicker, item.id, item.url); }} /> ); }
{desc}
铺放方式
该颜色与白底对比度不足,浅色已校正为 {pair.light}(暗色派生 {pair.dark})
当前为内置默认色,保存时会清除自定义配置
板块目录