Files
jiang13-bbs/frontend/app/admin/settings/BrandSeoPanel.tsx
freefire 99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00

492 lines
17 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 BrandCropModal from "@/components/BrandCropModal";
import BrandLockup from "@/components/BrandLockup";
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
import {
BRAND_LOGO_FITS,
BRAND_LOGO_SIZES,
brandAccept,
brandAliasPath,
brandFileOk,
normalizeBrandURL,
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 [cropSlot, setCropSlot] = useState<BrandSlot | null>(null);
const [cropSeedFile, setCropSeedFile] = useState<File | null>(null);
const [cropSeedUrl, setCropSeedUrl] = useState("");
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 openCrop = (slot: BrandSlot, file: File | null, url: string) => {
setCropSlot(slot);
setCropSeedFile(file);
setCropSeedUrl(url);
};
const closeCrop = () => {
setCropSlot(null);
setCropSeedFile(null);
setCropSeedUrl("");
};
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(384)) : preview}
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 384) : 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;
}
// SVG / ICO 无需裁剪,直接走原流程
const isVector = file.name.toLowerCase().endsWith(".svg");
const isIco = file.name.toLowerCase().endsWith(".ico");
if (isVector || isIco) {
replaceFile(slot, file);
return;
}
openCrop(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 slot = picker;
setPicker(null);
// 已是品牌目录的历史图(含裁剪产物):直接引用,不再复制
const brandURL = normalizeBrandURL(slot, item.url);
if (brandURL) {
const [prev, set] = drafts[slot];
if (prev.blob) URL.revokeObjectURL(prev.blob);
set({ committed: brandURL, file: null, blob: "", mediaId: null, mediaUrl: "" });
return;
}
const lower = item.url.toLowerCase();
const isVector = lower.endsWith(".svg");
const isIco = lower.endsWith(".ico");
// SVG / ICO 不可裁剪,复制一份进品牌目录后引用
if ((isVector || isIco) && item.attachmentId != null) {
const [prev, set] = drafts[slot];
if (prev.blob) URL.revokeObjectURL(prev.blob);
set({ ...prev, file: null, blob: "", mediaId: item.attachmentId, mediaUrl: item.url });
return;
}
openCrop(slot, null, item.url);
}}
/>
{cropSlot ? (
<BrandCropModal
open
slot={cropSlot}
seedFile={cropSeedFile}
seedUrl={cropSeedUrl}
onClose={closeCrop}
onSeedConsumed={() => {
setCropSeedFile(null);
setCropSeedUrl("");
}}
onCropped={(url) => {
const [, set] = drafts[cropSlot];
set((prev) => {
if (prev.blob) URL.revokeObjectURL(prev.blob);
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
});
closeCrop();
}}
/>
) : null}
</AdminSettingsGroup>
);
}