import { App, Button, Form, Input, Skeleton, Tabs } from "antd"; import { Switch } from "@/pages/admin/ui/controls"; import { Copyright, Globe2, Image as ImageIcon, MonitorPlay, Moon, Palette, RefreshCw, RotateCcw, Save, Search, Sun, Type, Undo2, Upload } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { useBlocker } from "react-router"; import { AdminPageFrame } from "@/pages/admin/components/admin-shell"; import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/admin-ui"; import { cn } from "@/lib/utils"; import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, isSkinButtonFill, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes"; import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor"; import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting"; import { deleteAdminResources } from "@/services/api/admin-storage"; import { getAdminAppearance, resetAdminAppearance, updateAdminAppearance, uploadAppearanceAsset, type AdminAppearance, type AppearanceAssetSlot } from "@/services/api/appearance"; import { commitPublicAppearance, DEFAULT_PUBLIC_APPEARANCE } from "@/stores/use-appearance-store"; type DraftFiles = Record; type ResetState = Record; const EMPTY_FILES: DraftFiles = { logo: null, "logo-dark": null, video: null, poster: null }; const EMPTY_RESETS: ResetState = { logo: false, "logo-dark": false, video: false, poster: false }; const FILE_RULES: Record = { logo: { accept: "image/png,image/jpeg,image/webp", maxBytes: 5 << 20, label: "浅色模式 Logo" }, "logo-dark": { accept: "image/png,image/jpeg,image/webp", maxBytes: 5 << 20, label: "深色模式 Logo" }, poster: { accept: "image/png,image/jpeg,image/webp", maxBytes: 10 << 20, label: "视频封面" }, video: { accept: "video/mp4,video/webm", maxBytes: 256 << 20, label: "品牌视频" }, }; export default function AppearanceSettingsPage() { const { message, modal } = App.useApp(); const [activeTab, setActiveTab] = useState("brand"); const [setting, setSetting] = useState(null); const [brandName, setBrandName] = useState(""); const [brandSlug, setBrandSlug] = useState(""); const [authHeroTitle, setAuthHeroTitle] = useState(""); const [authHeroDescription, setAuthHeroDescription] = useState(""); const [authVideoAutoplay, setAuthVideoAutoplay] = useState(true); const [logoFrameEnabled, setLogoFrameEnabled] = useState(true); const [skinId, setSkinId] = useState("classic"); const [skinThemes, setSkinThemes] = useState([DEFAULT_CLASSIC_SKIN]); const [seoTitle, setSeoTitle] = useState(""); const [seoDescription, setSeoDescription] = useState(""); const [seoKeywords, setSeoKeywords] = useState(""); const [footerCopyright, setFooterCopyright] = useState(""); const [icpFilingEnabled, setIcpFilingEnabled] = useState(false); const [icpFilingNumber, setIcpFilingNumber] = useState(""); const [files, setFiles] = useState(EMPTY_FILES); const [resets, setResets] = useState(EMPTY_RESETS); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [saving, setSaving] = useState(false); const [restoring, setRestoring] = useState(false); const [loadError, setLoadError] = useState(""); const requestVersionRef = useRef(0); const inputRefs = { logo: useRef(null), "logo-dark": useRef(null), video: useRef(null), poster: useRef(null), }; const dirty = Boolean(setting) && (brandName.trim() !== setting?.brandName || brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug || normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle || normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription || authVideoAutoplay !== setting?.authVideoAutoplay || logoFrameEnabled !== setting?.logoFrameEnabled || skinId !== setting?.skinId || JSON.stringify(skinThemes) !== JSON.stringify(setting?.skinThemes) || normalizeSingleLine(seoTitle) !== setting?.seoTitle || normalizeDraftCopy(seoDescription) !== setting?.seoDescription || normalizeSingleLine(seoKeywords) !== setting?.seoKeywords || normalizeSingleLine(footerCopyright) !== setting?.footerCopyright || icpFilingEnabled !== setting?.icpFilingEnabled || normalizeSingleLine(icpFilingNumber) !== setting?.icpFilingNumber || Object.values(files).some(Boolean) || Object.values(resets).some(Boolean)); const blocker = useBlocker(dirty && !saving && !restoring); const applySetting = useCallback((value: AdminAppearance) => { const themes = value.skinThemes.length ? value.skinThemes.map((theme) => normalizeSkinDefinition(theme)) : [cloneSkinDefinition(DEFAULT_CLASSIC_SKIN)]; const selectedID = themes.some((theme) => theme.id === value.skinId) ? value.skinId : "classic"; setSetting({ ...value, skinThemes: themes, skinId: selectedID }); setBrandName(value.brandName); setBrandSlug(value.brandSlug); setAuthHeroTitle(value.authHeroTitle); setAuthHeroDescription(value.authHeroDescription); setAuthVideoAutoplay(value.authVideoAutoplay); setLogoFrameEnabled(value.logoFrameEnabled); setSkinThemes(themes); setSkinId(selectedID); setSeoTitle(value.seoTitle); setSeoDescription(value.seoDescription); setSeoKeywords(value.seoKeywords); setFooterCopyright(value.footerCopyright); setIcpFilingEnabled(value.icpFilingEnabled); setIcpFilingNumber(value.icpFilingNumber); setFiles(EMPTY_FILES); setResets(EMPTY_RESETS); setLoadError(""); }, []); const load = useCallback( async (initial = false) => { const requestVersion = ++requestVersionRef.current; if (initial) setLoading(true); else setRefreshing(true); try { const value = await getAdminAppearance(); if (requestVersion === requestVersionRef.current) applySetting(value); } catch (error) { if (requestVersion !== requestVersionRef.current) return; const detail = error instanceof Error ? error.message : "读取外观配置失败"; setLoadError(detail); if (!initial) message.error(detail); } finally { if (requestVersion === requestVersionRef.current) { setLoading(false); setRefreshing(false); } } }, [applySetting, message], ); useEffect(() => { void load(true); return () => { requestVersionRef.current += 1; }; }, [load]); useEffect(() => { if (blocker.state !== "blocked") return; modal.confirm({ title: "放弃站点及外观调整?", content: "当前品牌、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。", okText: "放弃并离开", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: () => blocker.proceed(), onCancel: () => blocker.reset(), }); }, [blocker, modal]); useEffect(() => { const beforeUnload = (event: BeforeUnloadEvent) => { if (!dirty || saving || restoring) return; event.preventDefault(); }; window.addEventListener("beforeunload", beforeUnload); return () => window.removeEventListener("beforeunload", beforeUnload); }, [dirty, restoring, saving]); const selectFile = (slot: AppearanceAssetSlot, file?: File) => { if (!file) return; const rule = FILE_RULES[slot]; if (!rule.accept.split(",").includes(file.type) || file.size <= 0 || file.size > rule.maxBytes) { message.error(`${rule.label}须使用支持的格式,且不超过 ${rule.maxBytes >> 20}MB`); if (inputRefs[slot].current) inputRefs[slot].current.value = ""; return; } setFiles((current) => ({ ...current, [slot]: file, ...(slot === "video" ? { poster: null } : {}), })); setResets((current) => ({ ...current, [slot]: false, ...(slot === "video" ? { poster: true } : {}), })); if (slot === "video" && inputRefs.poster.current) inputRefs.poster.current.value = ""; }; const resetAsset = (slot: AppearanceAssetSlot) => { setFiles((current) => ({ ...current, [slot]: null, ...(slot === "video" ? { poster: null } : {}), })); setResets((current) => ({ ...current, [slot]: true, ...(slot === "video" ? { poster: true } : {}), })); if (inputRefs[slot].current) inputRefs[slot].current.value = ""; if (slot === "video" && inputRefs.poster.current) inputRefs.poster.current.value = ""; }; const discardDraft = () => { if (!setting || saving || restoring) return; setBrandName(setting.brandName); setBrandSlug(setting.brandSlug); setAuthHeroTitle(setting.authHeroTitle); setAuthHeroDescription(setting.authHeroDescription); setAuthVideoAutoplay(setting.authVideoAutoplay); setLogoFrameEnabled(setting.logoFrameEnabled); setSkinId(setting.skinId); setSkinThemes(setting.skinThemes.map((theme) => cloneSkinDefinition(theme))); setSeoTitle(setting.seoTitle); setSeoDescription(setting.seoDescription); setSeoKeywords(setting.seoKeywords); setFooterCopyright(setting.footerCopyright); setIcpFilingEnabled(setting.icpFilingEnabled); setIcpFilingNumber(setting.icpFilingNumber); setFiles(EMPTY_FILES); setResets(EMPTY_RESETS); Object.values(inputRefs).forEach((ref) => { if (ref.current) ref.current.value = ""; }); message.info("已撤销未保存的外观调整"); }; const requestRefresh = () => { if (!dirty) { void load(false); return; } modal.confirm({ title: "放弃调整并重新读取?", content: "重新读取会丢弃当前品牌、SEO、备案、皮肤和待上传文件。", okText: "放弃并刷新", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: () => load(false), }); }; const restoreBuiltInAppearance = () => { if (!setting?.configured || saving || refreshing || restoring) return; modal.confirm({ title: "恢复影策默认品牌标识?", content: "品牌名称、英文标识、Logo、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。", okText: "恢复默认", cancelText: "取消", okButtonProps: { danger: true }, onOk: async () => { setRestoring(true); requestVersionRef.current += 1; try { const restored = await resetAdminAppearance(); applySetting(restored); commitPublicAppearance(restored.public); Object.values(inputRefs).forEach((ref) => { if (ref.current) ref.current.value = ""; }); message.success("已恢复影策默认品牌标识"); } catch (error) { message.error(error instanceof Error ? error.message : "恢复默认外观失败"); throw error; } finally { setRestoring(false); } }, }); }; const save = async () => { if (!setting || saving || restoring) return; const nextBrandName = brandName.trim(); const nextBrandSlug = brandSlug.trim().toLocaleLowerCase(); const nextAuthHeroTitle = normalizeDraftCopy(authHeroTitle); const nextAuthHeroDescription = normalizeDraftCopy(authHeroDescription); const nextSeoTitle = normalizeSingleLine(seoTitle); const nextSeoDescription = normalizeDraftCopy(seoDescription); const nextSeoKeywords = normalizeSingleLine(seoKeywords); const nextFooterCopyright = normalizeSingleLine(footerCopyright); const nextIcpFilingNumber = normalizeSingleLine(icpFilingNumber); if (!nextBrandName || Array.from(nextBrandName).length > 40) { setActiveTab("brand"); message.error("品牌名称必须为 1 到 40 个字符"); return; } if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/.test(nextBrandSlug)) { setActiveTab("brand"); message.error("英文品牌标识须为 1 到 48 位小写字母、数字或连字符"); return; } if (!nextAuthHeroTitle || Array.from(nextAuthHeroTitle).length > 80 || hasUnsupportedControlCharacter(nextAuthHeroTitle)) { setActiveTab("auth"); message.error("登录页主标题必须为 1 到 80 个字符,可使用换行"); return; } if (Array.from(nextAuthHeroDescription).length > 160 || hasUnsupportedControlCharacter(nextAuthHeroDescription)) { setActiveTab("auth"); message.error("登录页说明文案不能超过 160 个字符,可使用换行"); return; } for (const [value, label, max, tab] of [ [nextSeoTitle, "SEO 标题", 70, "seo"], [nextSeoDescription, "SEO 描述", 200, "seo"], [nextSeoKeywords, "SEO 关键词", 300, "seo"], [nextFooterCopyright, "版权信息", 160, "footer"], [nextIcpFilingNumber, "备案号", 64, "footer"], ] as const) { if (Array.from(value).length > max || hasUnsupportedControlCharacter(value)) { setActiveTab(tab); message.error(`${label}不能超过 ${max} 个字符,且不能包含控制字符`); return; } } if (icpFilingEnabled && !nextIcpFilingNumber) { setActiveTab("footer"); message.error("显示备案号前请先填写备案号"); return; } const skinError = validateSkinDrafts(skinThemes, skinId); if (skinError) { setActiveTab("skins"); message.error(skinError); return; } setSaving(true); const uploadedIDs: string[] = []; try { const ids: Record = { logo: resets.logo ? "" : setting.logoResourceId, "logo-dark": resets["logo-dark"] ? "" : setting.darkLogoResourceId, video: resets.video ? "" : setting.authVideoResourceId, poster: resets.poster ? "" : setting.authVideoPosterResourceId, }; for (const slot of ["logo", "logo-dark", "video", "poster"] as AppearanceAssetSlot[]) { const file = files[slot]; if (!file) continue; const resource = await uploadAppearanceAsset(slot, file); uploadedIDs.push(resource.id); ids[slot] = resource.id; } const updated = await updateAdminAppearance({ brandName: nextBrandName, brandSlug: nextBrandSlug, authHeroTitle: nextAuthHeroTitle, authHeroDescription: nextAuthHeroDescription, logoResourceId: ids.logo, darkLogoResourceId: ids["logo-dark"], logoFrameEnabled, authVideoResourceId: ids.video, authVideoPosterResourceId: ids.poster, authVideoAutoplay, skinId, skinThemes, seoTitle: nextSeoTitle, seoDescription: nextSeoDescription, seoKeywords: nextSeoKeywords, footerCopyright: nextFooterCopyright, icpFilingEnabled, icpFilingNumber: nextIcpFilingNumber, }); applySetting(updated); commitPublicAppearance(updated.public); Object.values(inputRefs).forEach((ref) => { if (ref.current) ref.current.value = ""; }); message.success("站点及外观配置已保存并立即生效"); } catch (error) { const detail = error instanceof Error ? error.message : "保存外观配置失败"; if (uploadedIDs.length) { try { await deleteAdminResources(uploadedIDs); } catch { message.warning("保存失败,且临时上传文件未能自动清理;可在存储资源中检查"); } } message.error(detail); } finally { setSaving(false); } }; const previews = useAppearancePreviews(setting, files, resets); const lightLogoSelected = Boolean(files.logo || (!resets.logo && setting?.logoResourceId)); const darkLogoSelected = Boolean(files["logo-dark"] || (!resets["logo-dark"] && setting?.darkLogoResourceId)); const status = setting?.configured ? : ; const copyCustomized = normalizeDraftCopy(authHeroTitle) !== DEFAULT_PUBLIC_APPEARANCE.authHeroTitle || normalizeDraftCopy(authHeroDescription) !== DEFAULT_PUBLIC_APPEARANCE.authHeroDescription; const draftBrandName = brandName.trim() || "站点名称"; const selectedSkin = skinThemes.find((skin) => skin.id === skinId) || skinThemes[0] || DEFAULT_CLASSIC_SKIN; const changeSkin = (next: SkinDefinition) => { if (next.locked) return; setSkinThemes((current) => current.map((theme) => (theme.id === next.id ? next : theme))); }; const duplicateSkin = (sourceID: string) => { if (skinThemes.length >= 16) return; const source = skinThemes.find((theme) => theme.id === sourceID) || DEFAULT_CLASSIC_SKIN; const copy = duplicateSkinDefinition( source, skinThemes.map((theme) => theme.id), ); setSkinThemes((current) => [...current, copy]); setSkinId(copy.id); }; const deleteSkin = (targetID: string) => { const target = skinThemes.find((theme) => theme.id === targetID); if (!target || target.locked) return; modal.confirm({ title: `删除主题“${target.name}”?`, content: "删除会随本页其他调整一起保存;保存前仍可点击“撤销调整”恢复。若它当前启用,将自动切回经典黑白。", okText: "删除主题", cancelText: "取消", okButtonProps: { danger: true }, onOk: () => { setSkinThemes((current) => current.filter((theme) => theme.id !== targetID)); if (skinId === targetID) setSkinId("classic"); }, }); }; return ( {loading ? ( ) : loadError || !setting ? (

无法读取外观配置

{loadError || "当前没有可显示的配置,请稍后重试。"}

) : (
{dirty ? "站点配置有调整待保存" : "站点及外观已与服务端同步"}

{dirty ? "切换分类保留草稿;保存修改会一次应用品牌、登录页、SEO、备案和皮肤调整。" : "按分类管理站点配置;欢迎页开关独立即时保存,其余修改统一保存。"}

{dirty ? ( ) : null}
)}
); } function AssetPicker({ slot, title, description, configured, file, inputRef, onSelect, onReset, disabled, emptyLabel, }: { slot: AppearanceAssetSlot; title: string; description: string; configured: boolean; file: File | null; inputRef: RefObject; onSelect: (slot: AppearanceAssetSlot, file?: File) => void; onReset: (slot: AppearanceAssetSlot) => void; disabled: boolean; emptyLabel?: string; }) { const rule = FILE_RULES[slot]; return (
{slot === "video" ? : } {title} {description} {file ? `${file.name} · ${formatBytes(file.size)}` : configured ? "已配置自定义文件" : emptyLabel || "使用项目原始文件"} onSelect(slot, event.target.files?.[0])} />
); } function useAppearancePreviews(setting: AdminAppearance | null, files: DraftFiles, resets: ResetState) { const logoObjectURL = useObjectURL(files.logo); const darkLogoObjectURL = useObjectURL(files["logo-dark"]); const videoObjectURL = useObjectURL(files.video); const posterObjectURL = useObjectURL(files.poster); return useMemo(() => { if (!setting) return { logoLight: DEFAULT_PUBLIC_APPEARANCE.logoUrl, logoDark: DEFAULT_PUBLIC_APPEARANCE.darkLogoUrl, video: DEFAULT_PUBLIC_APPEARANCE.authVideoUrl, poster: DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl }; const customVideo = Boolean(files.video || (!resets.video && setting.authVideoResourceId)); const lightLogo = logoObjectURL || (!resets.logo && setting.logoResourceId ? setting.public.logoUrl : ""); const darkLogo = darkLogoObjectURL || (!resets["logo-dark"] && setting.darkLogoResourceId ? setting.public.darkLogoUrl : ""); return { logoLight: lightLogo || darkLogo || DEFAULT_PUBLIC_APPEARANCE.logoUrl, logoDark: darkLogo || lightLogo || DEFAULT_PUBLIC_APPEARANCE.darkLogoUrl, video: videoObjectURL || (resets.video ? DEFAULT_PUBLIC_APPEARANCE.authVideoUrl : setting.public.authVideoUrl), poster: posterObjectURL || (resets.poster ? (customVideo ? "" : DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl) : setting.public.authVideoPosterUrl), }; }, [darkLogoObjectURL, files.video, logoObjectURL, posterObjectURL, resets, setting, videoObjectURL]); } function LogoThemePreview({ label, icon, src, dark, frameEnabled }: { label: string; icon: ReactNode; src: string; dark: boolean; frameEnabled: boolean }) { return (
{icon} {label}
); } function useObjectURL(file: File | null) { const url = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]); useEffect( () => () => { if (url) URL.revokeObjectURL(url); }, [url], ); return url; } function AppearanceSkeleton() { return (
); } function normalizeDraftCopy(value: string) { return value.replace(/\r\n?/g, "\n").trim(); } function normalizeSingleLine(value: string) { return value.replace(/\r\n?/g, " ").trim(); } function hasUnsupportedControlCharacter(value: string) { return Array.from(value).some((character) => character !== "\n" && /[\u0000-\u001f\u007f]/.test(character)); } function validateSkinDrafts(themes: SkinDefinition[], selectedID: string) { if (!themes.length || themes.length > 16) return "皮肤主题数量必须为 1 到 16 套"; const ids = new Set(); for (const theme of themes) { if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(theme.id) || ids.has(theme.id)) return "皮肤主题 ID 无效或重复"; ids.add(theme.id); if (!theme.name.trim() || Array.from(theme.name.trim()).length > 40) return "皮肤主题名称必须为 1 到 40 个字符"; if (Array.from(theme.description.trim()).length > 100) return "皮肤主题说明不能超过 100 个字符"; const invalidColor = [...Object.values(theme.tokens.light), ...Object.values(theme.tokens.dark)].some((color) => !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color)); if (invalidColor) return `主题“${theme.name}”存在无效颜色,请使用 6 或 8 位十六进制色值`; if (!isSkinButtonFill(theme.tokens.buttons.light) || !isSkinButtonFill(theme.tokens.buttons.dark)) return `主题“${theme.name}”的主按钮参数无效,请检查颜色和渐变角度(0–360°)`; if (theme.tokens.components.controlHeightSmall > theme.tokens.components.controlHeight || theme.tokens.components.controlHeight > theme.tokens.components.controlHeightLarge) return `主题“${theme.name}”的控件高度顺序无效`; if (theme.tokens.components.motionFast > theme.tokens.components.motionNormal) return `主题“${theme.name}”的快速动效不能慢于常规动效`; } if (!ids.has("classic") || !ids.has(selectedID)) return "默认主题或当前启用主题不存在"; return ""; } function formatBytes(bytes: number) { if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; }