完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
AdminSegmented,
|
||||
AdminSettingsActions,
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsRow,
|
||||
} from "@/components/admin";
|
||||
import BrandLockup from "@/components/BrandLockup";
|
||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||
@@ -44,10 +43,10 @@ function slotDirty(d: SlotDraft, saved: string): boolean {
|
||||
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" },
|
||||
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({
|
||||
@@ -116,13 +115,8 @@ export default function BrandSeoPanel({
|
||||
}, [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);
|
||||
}
|
||||
if (draft.file) return apiUploadBrand(slot, draft.file);
|
||||
if (draft.mediaId != null) return apiBrandFromMedia(slot, draft.mediaId);
|
||||
return draft.committed;
|
||||
};
|
||||
|
||||
@@ -142,11 +136,7 @@ export default function BrandSeoPanel({
|
||||
const save = async () => {
|
||||
if (saving || !dirty) return;
|
||||
const ticket = ++saveGen.current;
|
||||
const snap = {
|
||||
mark,
|
||||
size: logoSize,
|
||||
fit: logoFit,
|
||||
};
|
||||
const snap = { mark, size: logoSize, fit: logoFit };
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
try {
|
||||
@@ -188,14 +178,18 @@ export default function BrandSeoPanel({
|
||||
return cur;
|
||||
});
|
||||
setFavicon((cur) => {
|
||||
if (cur.file === favicon.file && cur.mediaId === favicon.mediaId && cur.committed === favicon.committed) {
|
||||
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) {
|
||||
if (ticket !== saveGen.current) return;
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
@@ -206,7 +200,11 @@ export default function BrandSeoPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const renderSlot = (slot: BrandSlot, label: string, hint: string, swatch: "light" | "dark" | "neutral", draft: SlotDraft) => {
|
||||
const slotDraft = (id: BrandSlot) =>
|
||||
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
|
||||
|
||||
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
|
||||
const draft = slotDraft(slot);
|
||||
const preview = previewOf(draft);
|
||||
const swatchClass =
|
||||
swatch === "light"
|
||||
@@ -215,55 +213,60 @@ export default function BrandSeoPanel({
|
||||
? "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 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={`${label}预览`} />
|
||||
<img src={preview} alt="" />
|
||||
) : (
|
||||
<span className="text-[12.5px]">未设置</span>
|
||||
<span>未设置</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}`}
|
||||
<div className="admin-brand-asset-meta">
|
||||
<span className="admin-brand-asset-label">{label}</span>
|
||||
<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}
|
||||
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);
|
||||
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(""));
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -271,41 +274,26 @@ export default function BrandSeoPanel({
|
||||
|
||||
return (
|
||||
<AdminSettingsGroup
|
||||
title="品牌与图标"
|
||||
description="预览随编辑更新,保存后对访客生效。"
|
||||
id="settings-brand"
|
||||
title="品牌"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveLabel="保存品牌设置"
|
||||
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-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}
|
||||
@@ -320,82 +308,75 @@ export default function BrandSeoPanel({
|
||||
/>
|
||||
</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="图片如何铺进所选画幅">
|
||||
|
||||
<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={logoFit}
|
||||
ariaLabel="页眉形式"
|
||||
value={mark}
|
||||
onChange={(v) => {
|
||||
setLogoFit(v);
|
||||
setMark(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||
options={[
|
||||
{ key: "image_text", label: "图加文字" },
|
||||
{ key: "image", label: "纯图" },
|
||||
{ key: "text", 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>
|
||||
{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>
|
||||
</AdminSettingsRow>
|
||||
|
||||
<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"}
|
||||
|
||||
Reference in New Issue
Block a user