Files
jiang13-bbs/frontend/app/admin/settings/BrandSeoPanel.tsx
2026-09-19 04:14:13 +08:00

409 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}