"use client"; import { ImagePlus, Images, X } from "lucide-react"; import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { AdminSegmented, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow, } from "@/components/admin"; import BrandLockup from "@/components/BrandLockup"; import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api"; import { BRAND_LOGO_FITS, BRAND_LOGO_SIZES, brandAccept, brandFileOk, type BrandLogoFit, type BrandLogoSize, type BrandMark, type BrandSlot, } from "@/lib/brand"; import { toast } from "@/lib/toast"; import BrandMediaPicker from "./BrandMediaPicker"; type SlotDraft = { committed: string; file: File | null; blob: string; mediaId: number | null; mediaUrl: string; }; function emptyDraft(committed: string): SlotDraft { return { committed, file: null, blob: "", mediaId: null, mediaUrl: "" }; } function previewOf(d: SlotDraft): string { return d.blob || d.mediaUrl || d.committed; } function slotDirty(d: SlotDraft, saved: string): boolean { if (d.file || d.mediaId != null) return true; return d.committed !== saved; } const SLOTS: { id: BrandSlot; label: string; hint: string; swatch: "light" | "dark" | "neutral" }[] = [ { id: "logo_light", label: "浅色主题 Logo", hint: "浅色主题页眉。只传一套时,暗色共用这张。", swatch: "light" }, { id: "logo_dark", label: "深色主题 Logo", hint: "深色主题页眉。PNG / WebP / SVG,不超过 2MB。", swatch: "dark" }, { id: "favicon", label: "Favicon", hint: "浏览器标签图标。另支持 ICO,不超过 512KB。", swatch: "neutral" }, ]; export default function BrandSeoPanel({ initial, onSaved, onDirtyChange, }: { initial: PublicSettings; onSaved?: (next: PublicSettings) => void; onDirtyChange?: (dirty: boolean) => void; }) { const [light, setLight] = useState(() => emptyDraft(initial.logo_light_url)); const [dark, setDark] = useState(() => emptyDraft(initial.logo_dark_url)); const [favicon, setFavicon] = useState(() => emptyDraft(initial.favicon_url)); const [saved, setSaved] = useState({ light: initial.logo_light_url, dark: initial.logo_dark_url, favicon: initial.favicon_url, mark: initial.brand_mark, size: initial.brand_logo_size, fit: initial.brand_logo_fit, }); const [mark, setMark] = useState(initial.brand_mark); const [logoSize, setLogoSize] = useState(initial.brand_logo_size); const [logoFit, setLogoFit] = useState(initial.brand_logo_fit); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(""); const [picker, setPicker] = useState(null); const blobs = useRef([]); const saveGen = useRef(0); useEffect(() => { const held = blobs; return () => { held.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); const drafts: Record>]> = { logo_light: [light, setLight], logo_dark: [dark, setDark], favicon: [favicon, setFavicon], }; const replaceFile = (slot: BrandSlot, file: File | null) => { setSaveError(""); const [, set] = drafts[slot]; set((prev) => { if (prev.blob) URL.revokeObjectURL(prev.blob); const blob = file ? URL.createObjectURL(file) : ""; if (blob) blobs.current.push(blob); return { ...prev, file, blob, mediaId: null, mediaUrl: "" }; }); }; const dirty = slotDirty(light, saved.light) || slotDirty(dark, saved.dark) || slotDirty(favicon, saved.favicon) || mark !== saved.mark || logoSize !== saved.size || logoFit !== saved.fit; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise => { if (draft.file) { const url = await apiUploadBrand(slot, draft.file); return url; } if (draft.mediaId != null) { return apiBrandFromMedia(slot, draft.mediaId); } return draft.committed; }; const discard = () => { [light, dark, favicon].forEach((d) => { if (d.blob) URL.revokeObjectURL(d.blob); }); setLight(emptyDraft(saved.light)); setDark(emptyDraft(saved.dark)); setFavicon(emptyDraft(saved.favicon)); setMark(saved.mark); setLogoSize(saved.size); setLogoFit(saved.fit); setSaveError(""); }; const save = async () => { if (saving || !dirty) return; const ticket = ++saveGen.current; const snap = { mark, size: logoSize, fit: logoFit, }; setSaving(true); setSaveError(""); try { const logoLight = await resolveSlot("logo_light", light); const logoDark = await resolveSlot("logo_dark", dark); const fav = await resolveSlot("favicon", favicon); const res = await apiUpdateSiteSettings({ logo_light_url: logoLight, logo_dark_url: logoDark, favicon_url: fav, brand_mark: mark, brand_logo_size: logoSize, brand_logo_fit: logoFit, }); if (ticket !== saveGen.current) return; setSaved({ light: res.logo_light_url, dark: res.logo_dark_url, favicon: res.favicon_url, mark: res.brand_mark, size: res.brand_logo_size, fit: res.brand_logo_fit, }); setMark((v) => (v === snap.mark ? res.brand_mark : v)); setLogoSize((v) => (v === snap.size ? res.brand_logo_size : v)); setLogoFit((v) => (v === snap.fit ? res.brand_logo_fit : v)); setLight((cur) => { if (cur.file === light.file && cur.mediaId === light.mediaId && cur.committed === light.committed) { if (cur.blob) URL.revokeObjectURL(cur.blob); return emptyDraft(res.logo_light_url); } return cur; }); setDark((cur) => { if (cur.file === dark.file && cur.mediaId === dark.mediaId && cur.committed === dark.committed) { if (cur.blob) URL.revokeObjectURL(cur.blob); return emptyDraft(res.logo_dark_url); } return cur; }); setFavicon((cur) => { if (cur.file === favicon.file && cur.mediaId === favicon.mediaId && cur.committed === favicon.committed) { if (cur.blob) URL.revokeObjectURL(cur.blob); return emptyDraft(res.favicon_url); } return cur; }); onSaved?.(res); toast("品牌设置已保存", "ok"); } catch (e) { if (ticket !== saveGen.current) return; const msg = e instanceof Error ? e.message : "保存失败"; setSaveError(msg); toast(msg); } finally { if (ticket === saveGen.current) setSaving(false); } }; const renderSlot = (slot: BrandSlot, label: string, hint: string, swatch: "light" | "dark" | "neutral", draft: SlotDraft) => { const preview = previewOf(draft); const swatchClass = swatch === "light" ? "admin-brand-swatch-light" : swatch === "dark" ? "admin-brand-swatch-dark" : "admin-brand-swatch-neutral"; return (

{label}

{hint}

{preview ? ( // eslint-disable-next-line @next/next/no-img-element {`${label}预览`} ) : ( 未设置 )}
); }; return ( void save()} onDiscard={discard} /> } > { setMark(v); if (saveError) setSaveError(""); }} options={[ { key: "image_text", label: "图加文字" }, { key: "image", label: "纯图" }, { key: "text", label: "纯文字" }, ]} />
{mark !== "text" ? ( <>
{BRAND_LOGO_SIZES.map((item) => { const on = logoSize === item.id; const thumbH = 16; const thumbW = Math.round((item.w / item.h) * thumbH); return ( ); })}
{ setLogoFit(v); if (saveError) setSaveError(""); }} options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))} /> ) : (

纯文字页眉不显示 Logo。画幅和铺法只作用于图加文字和纯图。

)}
{SLOTS.map((s) => renderSlot( s.id, s.label, s.hint, s.swatch, s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light ) )}
setPicker(null)} onPick={(item) => { if (!picker) return; const [prev, set] = drafts[picker]; if (prev.blob) URL.revokeObjectURL(prev.blob); set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url }); }} />
); }