新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
|
||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||
import {
|
||
AdminSegmented,
|
||
AdminSettingsActions,
|
||
AdminSettingsGroup,
|
||
} 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,
|
||
brandAliasPath,
|
||
brandFileOk,
|
||
type BrandLogoFit,
|
||
type BrandLogoSize,
|
||
type BrandMark,
|
||
type BrandSlot,
|
||
} from "@/lib/brand";
|
||
import { toast } from "@/lib/toast";
|
||
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||
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; swatch: "light" | "dark" | "neutral"; compact?: boolean }[] = [
|
||
{ id: "logo_light", label: "浅色 Logo", swatch: "light" },
|
||
{ id: "logo_dark", label: "深色 Logo", swatch: "dark" },
|
||
{ id: "favicon", label: "Favicon", swatch: "neutral", compact: true },
|
||
];
|
||
|
||
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 [copiedSlot, setCopiedSlot] = 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) return apiUploadBrand(slot, draft.file);
|
||
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 slotDraft = (id: BrandSlot) =>
|
||
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
||
|
||
// 已保存的槽位 URL:存在才展示固定地址(未保存过无图可引用)
|
||
const savedOf = (slot: BrandSlot) =>
|
||
slot === "logo_dark" ? saved.dark : slot === "favicon" ? saved.favicon : saved.light;
|
||
|
||
const copyAlias = async (slot: BrandSlot) => {
|
||
try {
|
||
await navigator.clipboard.writeText(`${window.location.origin}${brandAliasPath(slot)}`);
|
||
setCopiedSlot(slot);
|
||
toast("已复制固定地址", "ok");
|
||
window.setTimeout(() => setCopiedSlot((cur) => (cur === slot ? null : cur)), 1500);
|
||
} catch {
|
||
toast("复制失败,请手动复制");
|
||
}
|
||
};
|
||
|
||
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
||
const draft = slotDraft(slot);
|
||
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-asset${compact ? " is-compact" : ""}`}>
|
||
<div className={`admin-brand-swatch ${swatchClass}`}>
|
||
{preview ? (
|
||
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(160)) : preview}
|
||
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 160) : undefined}
|
||
alt=""
|
||
/>
|
||
) : (
|
||
<span>未设置</span>
|
||
)}
|
||
</div>
|
||
<div className="admin-brand-asset-meta">
|
||
<span className="admin-brand-asset-label">{label}</span>
|
||
{savedOf(slot) ? (
|
||
<button
|
||
type="button"
|
||
className="admin-brand-alias"
|
||
title="点击复制固定地址(换图不变链,友链引用请用此地址)"
|
||
onClick={() => void copyAlias(slot)}
|
||
>
|
||
<Link2 size={12} aria-hidden />
|
||
<span className="admin-brand-alias-url">{brandAliasPath(slot)}</span>
|
||
{copiedSlot === slot ? <Check size={12} aria-hidden /> : null}
|
||
</button>
|
||
) : null}
|
||
<div className="admin-brand-asset-actions">
|
||
<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="admin-icon-btn"
|
||
aria-label={`清除${label}`}
|
||
disabled={saving || !preview}
|
||
onClick={() => {
|
||
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
||
drafts[slot][1](emptyDraft(""));
|
||
}}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<AdminSettingsGroup
|
||
id="settings-brand"
|
||
title="品牌"
|
||
footer={
|
||
<AdminSettingsActions
|
||
dirty={dirty}
|
||
saving={saving}
|
||
saveLabel="保存"
|
||
error={saveError}
|
||
onSave={() => void save()}
|
||
onDiscard={discard}
|
||
/>
|
||
}
|
||
>
|
||
<div className="admin-brand-studio">
|
||
<div className="admin-brand-studio-preview" aria-label="页眉预览">
|
||
<div className="admin-brand-studio-chrome">
|
||
<span aria-hidden />
|
||
<span aria-hidden />
|
||
<span aria-hidden />
|
||
</div>
|
||
<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>
|
||
|
||
<div className="admin-brand-studio-toolbar">
|
||
<div className="admin-brand-studio-field">
|
||
<span className="admin-brand-studio-kicker">形式</span>
|
||
<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: "纯文字" },
|
||
]}
|
||
/>
|
||
</div>
|
||
{mark !== "text" ? (
|
||
<>
|
||
<div className="admin-brand-studio-field">
|
||
<span className="admin-brand-studio-kicker">比例</span>
|
||
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
|
||
{BRAND_LOGO_SIZES.map((item) => {
|
||
const on = logoSize === item.id;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
|
||
onClick={() => {
|
||
setLogoSize(item.id);
|
||
if (saveError) setSaveError("");
|
||
}}
|
||
>
|
||
<span className="admin-brand-size-chip-label">{item.label}</span>
|
||
<span className="admin-brand-size-chip-px">
|
||
{item.w}×{item.h}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="admin-brand-studio-field">
|
||
<span className="admin-brand-studio-kicker">铺法</span>
|
||
<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 }))}
|
||
/>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="admin-brand-studio-assets" aria-label="品牌素材">
|
||
{SLOTS.map((s) => renderSlot(s.id, s.label, s.swatch, s.compact))}
|
||
</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 });
|
||
}}
|
||
/>
|
||
</AdminSettingsGroup>
|
||
);
|
||
}
|