公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。

后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 00:11:24 +08:00
parent 2b3d122e56
commit 40bbad2c82
51 changed files with 3042 additions and 1318 deletions

View File

@@ -1,17 +1,20 @@
"use client";
import { ImagePlus, Images, Loader2, Save, X } from "lucide-react";
import { ImagePlus, Images, X } from "lucide-react";
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { AdminSection, AdminSegmented } from "@/components/admin";
import {
AdminSegmented,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsRow,
} 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,
@@ -41,18 +44,20 @@ function slotDirty(d: SlotDraft, saved: string): boolean {
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。" },
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));
@@ -61,19 +66,18 @@ export default function BrandSeoPanel({
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 [saveError, setSaveError] = useState("");
const [picker, setPicker] = useState<BrandSlot | null>(null);
const blobs = useRef<string[]>([]);
const saveGen = useRef(0);
useEffect(() => {
const held = blobs;
@@ -89,6 +93,7 @@ export default function BrandSeoPanel({
};
const replaceFile = (slot: BrandSlot, file: File | null) => {
setSaveError("");
const [, set] = drafts[slot];
set((prev) => {
if (prev.blob) URL.revokeObjectURL(prev.blob);
@@ -98,62 +103,52 @@ export default function BrandSeoPanel({
});
};
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("");
};
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
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 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);
@@ -162,53 +157,75 @@ export default function BrandSeoPanel({
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);
if (ticket !== saveGen.current) return;
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,
});
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");
toast("品牌设置已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
setSaveError(msg);
toast(msg);
} finally {
setSaving(false);
if (ticket === saveGen.current) setSaving(false);
}
};
const renderSlot = (slot: BrandSlot, label: string, hint: string, draft: SlotDraft) => {
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}>
<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="h-28 rounded-xl flex items-center justify-center px-3"
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
>
<div className={`admin-brand-swatch ${swatchClass}`}>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" className="max-h-20 max-w-full object-contain" />
<img src={preview} alt={`${label}预览`} />
) : (
<span className="meta text-[12.5px]">未设置</span>
<span className="text-[12.5px]">未设置</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
@@ -218,6 +235,8 @@ export default function BrandSeoPanel({
type="file"
accept={brandAccept(slot)}
className="hidden"
aria-label={`上传${label}`}
disabled={saving}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
@@ -231,13 +250,13 @@ export default function BrandSeoPanel({
}}
/>
</label>
<button type="button" className="btn btn-line btn-sm" onClick={() => setPicker(slot)}>
<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={!preview}
disabled={saving || !preview}
onClick={() => {
if (draft.blob) URL.revokeObjectURL(draft.blob);
drafts[slot][1](emptyDraft(""));
@@ -251,34 +270,43 @@ export default function BrandSeoPanel({
};
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>
<AdminSettingsGroup
title="品牌与图标"
description="预览随编辑更新,保存后对访客生效。"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存品牌设置"
error={saveError}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<div className="flex flex-col gap-4 mb-5">
<AdminSettingsRow label="页眉形式" hintBelow="图加文字和纯图共用画幅与铺法">
<AdminSegmented
role="group"
size="sm"
ariaLabel="页眉形式"
value={mark}
onChange={setMark}
onChange={(v) => {
setMark(v);
if (saveError) setSaveError("");
}}
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">
</AdminSettingsRow>
<AdminSettingsRow
label="品牌预览"
hintBelow={dirty ? "页眉效果预览;站点仍显示已保存版本,保存后对访客生效" : "页眉效果预览,保存后对访客生效"}
>
<div className="admin-brand-lockup-preview">
<div className="admin-brand-lockup-preview-bar">
<BrandLockup
mode={mark}
size={logoSize}
@@ -292,9 +320,11 @@ export default function BrandSeoPanel({
/>
</div>
</div>
{mark !== "text" ? (
<>
<div className="flex flex-wrap gap-2" role="group" aria-label="图槽画幅">
</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;
@@ -309,7 +339,10 @@ export default function BrandSeoPanel({
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)}
onClick={() => {
setLogoSize(item.id);
if (saveError) setSaveError("");
}}
>
<span
aria-hidden
@@ -322,76 +355,47 @@ export default function BrandSeoPanel({
/>
<span>
<span className="block text-[13px] font-medium">{item.label}</span>
<span className="meta text-[12px]">{item.w}×{item.h}</span>
<span className="meta text-[12px]">
{item.w}×{item.h}
</span>
</span>
</button>
);
})}
</div>
</AdminSettingsRow>
<AdminSettingsRow label="适配方式" hintBelow="图片如何铺进所选画幅">
<AdminSegmented
role="group"
size="sm"
ariaLabel="图槽铺法"
ariaLabel="适配方式"
value={logoFit}
onChange={setLogoFit}
onChange={(v) => {
setLogoFit(v);
if (saveError) setSaveError("");
}}
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>
</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>
</div>
</AdminSettingsRow>
<BrandMediaPicker
open={picker != null}
slot={picker || "logo_light"}
@@ -403,6 +407,6 @@ export default function BrandSeoPanel({
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
}}
/>
</AdminSection>
</AdminSettingsGroup>
);
}