"use client"; import { ImagePlus, Images, Loader2, Save, X } from "lucide-react"; import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { AdminSection, AdminSegmented } from "@/components/admin"; import BrandLockup from "@/components/BrandLockup"; import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api"; import { BRAND_LIMITS, BRAND_LOGO_FITS, BRAND_LOGO_SIZES, brandAccept, brandFileOk, runeCount, 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 }[] = [ { id: "logo_light", label: "浅色 Logo", hint: "浅色主题。只传一套时,暗色共用这张。" }, { id: "logo_dark", label: "暗色 Logo", hint: "暗色主题。PNG / WebP / SVG,不超过 2MB。" }, { id: "favicon", label: "Favicon", hint: "标签图标。另支持 ICO,不超过 512KB。" }, ]; export default function BrandSeoPanel({ initial, onSaved, }: { initial: PublicSettings; onSaved?: (next: PublicSettings) => 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, keywords: initial.site_keywords, mark: initial.brand_mark, size: initial.brand_logo_size, fit: initial.brand_logo_fit, }); const [keywords, setKeywords] = useState(initial.site_keywords); const [mark, setMark] = useState(initial.brand_mark); const [logoSize, setLogoSize] = useState(initial.brand_logo_size); const [logoFit, setLogoFit] = useState(initial.brand_logo_fit); const [kwInput, setKwInput] = useState(""); const [saving, setSaving] = useState(false); const [picker, setPicker] = useState(null); const blobs = useRef([]); 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) => { 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 keywordsDirty = JSON.stringify(keywords) !== JSON.stringify(saved.keywords); const dirty = slotDirty(light, saved.light) || slotDirty(dark, saved.dark) || slotDirty(favicon, saved.favicon) || keywordsDirty || mark !== saved.mark || logoSize !== saved.size || logoFit !== saved.fit; const addKeyword = (raw: string) => { const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean); if (parts.length === 0) return; setKeywords((prev) => { const next = [...prev]; const seen = new Set(next.map((k) => k.toLowerCase())); for (const k of parts) { if (runeCount(k) > BRAND_LIMITS.keywordRunes) { toast(`关键词「${k}」超过 ${BRAND_LIMITS.keywordRunes} 字`); continue; } if (seen.has(k.toLowerCase())) continue; if (next.length >= BRAND_LIMITS.keywordCount) { toast(`最多 ${BRAND_LIMITS.keywordCount} 个关键词`); break; } seen.add(k.toLowerCase()); next.push(k); } return next; }); setKwInput(""); }; const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise => { if (draft.file) { const url = await apiUploadBrand(slot, draft.file); const [, set] = drafts[slot]; set((prev) => { if (prev.blob) URL.revokeObjectURL(prev.blob); return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" }; }); return url; } if (draft.mediaId != null) { const url = await apiBrandFromMedia(slot, draft.mediaId); const [, set] = drafts[slot]; set({ committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" }); return url; } return draft.committed; }; const save = async () => { if (saving || !dirty) return; setSaving(true); 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, site_keywords: keywords, brand_mark: mark, brand_logo_size: logoSize, brand_logo_fit: logoFit, }); setLight(emptyDraft(res.logo_light_url)); setDark(emptyDraft(res.logo_dark_url)); setFavicon(emptyDraft(res.favicon_url)); setKeywords(res.site_keywords); setMark(res.brand_mark); setLogoSize(res.brand_logo_size); setLogoFit(res.brand_logo_fit); setSaved({ light: res.logo_light_url, dark: res.logo_dark_url, favicon: res.favicon_url, keywords: res.site_keywords, mark: res.brand_mark, size: res.brand_logo_size, fit: res.brand_logo_fit, }); onSaved?.(res); toast("品牌已保存", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } }; const renderSlot = (slot: BrandSlot, label: string, hint: string, draft: SlotDraft) => { const preview = previewOf(draft); return (

{label}

{hint}

{preview ? ( // eslint-disable-next-line @next/next/no-img-element ) : ( 未设置 )}
); }; return ( void save()}> {saving ? : } 保存品牌 } >
{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 ( ); })}
({ key: item.id, label: item.label }))} /> ) : (

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

)}
{SLOTS.map((s) => renderSlot( s.id, s.label, s.hint, s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light ) )}

SEO 关键词

最多 16 个,每个 20 字。点芯片可删除。

{keywords.map((k) => ( ))} {keywords.length === 0 ? 尚未添加 : null}
setKwInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === "," || e.key === ",") { e.preventDefault(); addKeyword(kwInput); } }} placeholder="输入后回车添加" className="flex-1 min-w-0 px-3 py-2 text-sm rounded-lg" style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }} aria-label="添加关键词" />

{dirty ? "未保存,访客仍看到旧 Logo" : "已与线上一致"}

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 }); }} />
); }