409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
"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<SlotDraft>(() => emptyDraft(initial.logo_light_url));
|
||
const [dark, setDark] = useState<SlotDraft>(() => emptyDraft(initial.logo_dark_url));
|
||
const [favicon, setFavicon] = useState<SlotDraft>(() => 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<string[]>(initial.site_keywords);
|
||
const [mark, setMark] = useState<BrandMark>(initial.brand_mark);
|
||
const [logoSize, setLogoSize] = useState<BrandLogoSize>(initial.brand_logo_size);
|
||
const [logoFit, setLogoFit] = useState<BrandLogoFit>(initial.brand_logo_fit);
|
||
const [kwInput, setKwInput] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||
const blobs = useRef<string[]>([]);
|
||
|
||
useEffect(() => {
|
||
const held = blobs;
|
||
return () => {
|
||
held.current.forEach((u) => URL.revokeObjectURL(u));
|
||
};
|
||
}, []);
|
||
|
||
const drafts: Record<BrandSlot, [SlotDraft, Dispatch<SetStateAction<SlotDraft>>]> = {
|
||
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<string> => {
|
||
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 (
|
||
<div key={slot}>
|
||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||
{label}
|
||
</p>
|
||
<p className="meta text-[12px] mt-0.5 mb-2">{hint}</p>
|
||
<div
|
||
className="h-28 rounded-xl flex items-center justify-center px-3"
|
||
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||
>
|
||
{preview ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={preview} alt="" className="max-h-20 max-w-full object-contain" />
|
||
) : (
|
||
<span className="meta text-[12.5px]">未设置</span>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||
<label className="btn btn-line btn-sm cursor-pointer">
|
||
<ImagePlus size={14} /> 上传
|
||
<input
|
||
type="file"
|
||
accept={brandAccept(slot)}
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
e.target.value = "";
|
||
if (!file) return;
|
||
const err = brandFileOk(slot, file);
|
||
if (err) {
|
||
toast(err);
|
||
return;
|
||
}
|
||
replaceFile(slot, file);
|
||
}}
|
||
/>
|
||
</label>
|
||
<button type="button" className="btn btn-line btn-sm" onClick={() => setPicker(slot)}>
|
||
<Images size={14} /> 媒体库
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm"
|
||
disabled={!preview}
|
||
onClick={() => {
|
||
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
||
drafts[slot][1](emptyDraft(""));
|
||
}}
|
||
>
|
||
<X size={14} /> 清除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<AdminSection
|
||
title="品牌与 SEO"
|
||
description="图加文字和纯图共用画幅与铺法。预览条即时变化,保存后访客才看到。"
|
||
extra={
|
||
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}>
|
||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||
保存品牌
|
||
</button>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-4 mb-5">
|
||
<AdminSegmented
|
||
role="group"
|
||
size="sm"
|
||
ariaLabel="页眉形式"
|
||
value={mark}
|
||
onChange={setMark}
|
||
options={[
|
||
{ key: "image_text", label: "图加文字" },
|
||
{ key: "image", label: "纯图" },
|
||
{ key: "text", label: "纯文字" },
|
||
]}
|
||
/>
|
||
<div
|
||
className="rounded-xl overflow-hidden"
|
||
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||
>
|
||
<div className="h-[68px] px-4 flex items-center">
|
||
<BrandLockup
|
||
mode={mark}
|
||
size={logoSize}
|
||
fit={logoFit}
|
||
frame
|
||
name={initial.site_name}
|
||
wordmark={initial.site_wordmark}
|
||
slogan={initial.site_slogan}
|
||
logoLight={previewOf(light)}
|
||
logoDark={previewOf(dark)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{mark !== "text" ? (
|
||
<>
|
||
<div className="flex flex-wrap gap-2" role="group" aria-label="图槽画幅">
|
||
{BRAND_LOGO_SIZES.map((item) => {
|
||
const on = logoSize === item.id;
|
||
const thumbH = 16;
|
||
const thumbW = Math.round((item.w / item.h) * thumbH);
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className="rounded-xl px-3 py-2 text-left inline-flex items-center gap-2"
|
||
style={{
|
||
background: on ? "var(--accent-soft)" : "var(--panel)",
|
||
color: on ? "var(--accent)" : "var(--ink)",
|
||
boxShadow: on ? "inset 0 0 0 1px var(--accent)" : "inset 0 0 0 1px var(--line)",
|
||
}}
|
||
onClick={() => setLogoSize(item.id)}
|
||
>
|
||
<span
|
||
aria-hidden
|
||
className="shrink-0 rounded-sm"
|
||
style={{
|
||
width: thumbW,
|
||
height: thumbH,
|
||
boxShadow: "inset 0 0 0 1.5px currentColor",
|
||
}}
|
||
/>
|
||
<span>
|
||
<span className="block text-[13px] font-medium">{item.label}</span>
|
||
<span className="meta text-[12px]">{item.w}×{item.h}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<AdminSegmented
|
||
role="group"
|
||
size="sm"
|
||
ariaLabel="图槽铺法"
|
||
value={logoFit}
|
||
onChange={setLogoFit}
|
||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||
/>
|
||
</>
|
||
) : (
|
||
<p className="meta text-[12.5px]">纯文字不显示图槽。画幅和铺法只作用于图加文字和纯图。</p>
|
||
)}
|
||
</div>
|
||
<div className="admin-brand-grid">
|
||
{SLOTS.map((s) =>
|
||
renderSlot(
|
||
s.id,
|
||
s.label,
|
||
s.hint,
|
||
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
|
||
)
|
||
)}
|
||
<div className="admin-brand-keywords">
|
||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||
SEO 关键词
|
||
</p>
|
||
<p className="meta text-[12px] mt-0.5 mb-2">最多 16 个,每个 20 字。点芯片可删除。</p>
|
||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||
{keywords.map((k) => (
|
||
<button
|
||
key={k}
|
||
type="button"
|
||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[12.5px]"
|
||
style={{ background: "var(--panel-2)", color: "var(--ink)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||
onClick={() => setKeywords((prev) => prev.filter((x) => x !== k))}
|
||
>
|
||
{k}
|
||
<X size={12} />
|
||
</button>
|
||
))}
|
||
{keywords.length === 0 ? <span className="meta text-[12.5px]">尚未添加</span> : null}
|
||
</div>
|
||
<div className="flex gap-2 max-w-xl">
|
||
<input
|
||
value={kwInput}
|
||
onChange={(e) => 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="添加关键词"
|
||
/>
|
||
<button type="button" className="btn btn-line btn-sm" onClick={() => addKeyword(kwInput)}>
|
||
添加
|
||
</button>
|
||
</div>
|
||
<p className="meta text-[12.5px] mt-3">{dirty ? "未保存,访客仍看到旧 Logo" : "已与线上一致"}</p>
|
||
</div>
|
||
</div>
|
||
<BrandMediaPicker
|
||
open={picker != null}
|
||
slot={picker || "logo_light"}
|
||
onClose={() => 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 });
|
||
}}
|
||
/>
|
||
</AdminSection>
|
||
);
|
||
}
|