Files
jiang13-bbs/frontend/app/admin/settings/BrandSeoPanel.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

423 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 { 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 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 ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} 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>
);
}