Files
jiang13-bbs/frontend/app/admin/settings/BrandSeoPanel.tsx
freefire 40bbad2c82 公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 00:11:24 +08:00

413 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, 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<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,
mark: initial.brand_mark,
size: initial.brand_logo_size,
fit: initial.brand_logo_fit,
});
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 [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [picker, setPicker] = useState<BrandSlot | null>(null);
const blobs = useRef<string[]>([]);
const saveGen = useRef(0);
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) => {
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<string> => {
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 (
<div key={slot} className="admin-brand-slot-card">
<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={`admin-brand-swatch ${swatchClass}`}>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt={`${label}预览`} />
) : (
<span className="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"
aria-label={`上传${label}`}
disabled={saving}
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" disabled={saving} onClick={() => setPicker(slot)}>
<Images size={14} /> 媒体库
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={saving || !preview}
onClick={() => {
if (draft.blob) URL.revokeObjectURL(draft.blob);
drafts[slot][1](emptyDraft(""));
}}
>
<X size={14} /> 清除
</button>
</div>
</div>
);
};
return (
<AdminSettingsGroup
title="品牌与图标"
description="预览随编辑更新,保存后对访客生效。"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存品牌设置"
error={saveError}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<AdminSettingsRow label="页眉形式" hintBelow="图加文字和纯图共用画幅与铺法">
<AdminSegmented
role="group"
size="sm"
ariaLabel="页眉形式"
value={mark}
onChange={(v) => {
setMark(v);
if (saveError) setSaveError("");
}}
options={[
{ key: "image_text", label: "图加文字" },
{ key: "image", label: "纯图" },
{ key: "text", label: "纯文字" },
]}
/>
</AdminSettingsRow>
<AdminSettingsRow
label="品牌预览"
hintBelow={dirty ? "页眉效果预览;站点仍显示已保存版本,保存后对访客生效" : "页眉效果预览,保存后对访客生效"}
>
<div className="admin-brand-lockup-preview">
<div className="admin-brand-lockup-preview-bar">
<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>
</AdminSettingsRow>
{mark !== "text" ? (
<>
<AdminSettingsRow label="Logo 比例">
<div className="flex flex-wrap gap-2" role="group" aria-label="Logo 比例">
{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);
if (saveError) setSaveError("");
}}
>
<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>
</AdminSettingsRow>
<AdminSettingsRow label="适配方式" hintBelow="图片如何铺进所选画幅">
<AdminSegmented
role="group"
size="sm"
ariaLabel="适配方式"
value={logoFit}
onChange={(v) => {
setLogoFit(v);
if (saveError) setSaveError("");
}}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
/>
</AdminSettingsRow>
</>
) : (
<AdminSettingsRow label="画幅与铺法">
<p className="meta text-[12.5px]">纯文字页眉不显示 Logo。画幅和铺法只作用于图加文字和纯图。</p>
</AdminSettingsRow>
)}
<AdminSettingsRow label="站点图标" hintBelow="设置不同主题的站点标识及浏览器图标" wide>
<div className="admin-brand-slots">
{SLOTS.map((s) =>
renderSlot(
s.id,
s.label,
s.hint,
s.swatch,
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
)
)}
</div>
</AdminSettingsRow>
<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 });
}}
/>
</AdminSettingsGroup>
);
}