import { commitPublicAppearance, DEFAULT_PUBLIC_APPEARANCE } from "@/stores/use-appearance-store"; import { deleteAdminResources } from "@/services/api/admin-storage"; import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/admin-ui"; import { AdminPageFrame } from "@/pages/admin/components/admin-shell"; import { Palette, RefreshCw, Undo2, RotateCcw, Save, Sun, Moon, MonitorPlay, Globe2, Search, Copyright, Type } from "lucide-react"; import { cn } from "@/lib/utils"; import { Switch } from "@/pages/admin/ui/controls"; import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting"; import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor"; import { CanvasAppearanceEditor } from "./components/canvas-appearance-editor"; import { UpdateAnnouncementEditor } from "./components/update-announcement-editor"; import { DEFAULT_UPDATE_ANNOUNCEMENT, normalizeUpdateAnnouncement, validateUpdateAnnouncement, type UpdateAnnouncement } from "@/lib/update-announcement"; import { App, Button, Tabs, Form, Input } from "antd"; import { useCallback, useEffect, useRef, useState } from "react"; import { useBlocker } from "react-router"; import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition, type SkinDefinition, duplicateSkinDefinition } from "@/lib/skin-themes"; import { DEFAULT_CANVAS_APPEARANCE, type CanvasAppearance } from "@/lib/canvas/agent-appearance"; import { getAdminAppearance, type AdminAppearance, type AppearanceAssetSlot, resetAdminAppearance, uploadAppearanceAsset, updateAdminAppearance } from "@/services/api/appearance"; import { normalizeDraftCopy, normalizeSingleLine, hasUnsupportedControlCharacter, validateSkinDrafts, useAppearancePreviews, AppearanceSkeleton, AssetPicker, LogoThemePreview } from "./appearance-settings-parts"; import { type DraftFiles, FILE_RULES, type ResetState } from "./appearance-settings-parts"; export { type DraftFiles, FILE_RULES, type ResetState } from "./appearance-settings-parts"; 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 }; export default function AppearanceSettingsPage() { const { message, modal } = App.useApp(); const [activeTab, setActiveTab] = useState("brand"); const [setting, setSetting] = useState(null); const [brandName, setBrandName] = useState(""); const [canvas, setCanvas] = useState(DEFAULT_CANVAS_APPEARANCE); const [updates, setUpdates] = useState(DEFAULT_UPDATE_ANNOUNCEMENT); const [canvasUploading, setCanvasUploading] = useState(false); 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) && (JSON.stringify(updates) !== JSON.stringify(normalizeUpdateAnnouncement(setting?.updates)) || JSON.stringify(canvas) !== JSON.stringify(setting?.canvas || DEFAULT_CANVAS_APPEARANCE) || canvasUploading || 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); setCanvas(value.canvas || DEFAULT_CANVAS_APPEARANCE); setUpdates(normalizeUpdateAnnouncement(value.updates)); 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: "当前品牌、画布 Agent、更新公告、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 || canvasUploading) return; setCanvas(setting.canvas || DEFAULT_CANVAS_APPEARANCE); setUpdates(normalizeUpdateAnnouncement(setting.updates)); 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 (canvasUploading) return; if (!dirty) { void load(false); return; } modal.confirm({ title: "放弃调整并重新读取?", content: "重新读取会丢弃当前品牌、画布 Agent、更新公告、SEO、备案、皮肤和待上传文件。", okText: "放弃并刷新", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: () => load(false), }); }; const restoreBuiltInAppearance = () => { if (!setting?.configured || saving || refreshing || restoring || canvasUploading) return; modal.confirm({ title: "恢复影策默认品牌标识?", content: "品牌名称、英文标识、Logo、画布 Agent 名称/文案/形象、更新公告、登录页文案、视频、封面、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 || canvasUploading) return; const updatesError = validateUpdateAnnouncement(updates); if (updatesError) { setActiveTab("updates"); message.error(updatesError); return; } if (![canvas.agentName, canvas.panelTitle, canvas.welcomeTitle, canvas.inputPlaceholder].every((text) => text.trim())) { setActiveTab("canvas"); message.error("请填写助手名称、面板标题、欢迎标题和输入框提示"); 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({ canvas, updates, 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}
)}
); }