公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,135 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AdminField, AdminSection } from "@/components/admin";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { BRAND_LIMITS, siteDocumentTitle } from "@/lib/brand";
|
||||
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export default function BasicIdentityPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial.site_name);
|
||||
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
||||
const [slogan, setSlogan] = useState(initial.site_slogan);
|
||||
const [desc, setDesc] = useState(initial.site_description);
|
||||
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
|
||||
const [kwInput, setKwInput] = useState("");
|
||||
const [saved, setSaved] = useState({
|
||||
name: initial.site_name,
|
||||
wordmark: initial.site_wordmark,
|
||||
slogan: initial.site_slogan,
|
||||
desc: initial.site_description,
|
||||
keywords: initial.site_keywords,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const saveGen = useRef(0);
|
||||
|
||||
const dirty =
|
||||
name.trim() !== saved.name ||
|
||||
wordmark.trim() !== saved.wordmark ||
|
||||
slogan.trim() !== saved.slogan ||
|
||||
desc.trim() !== saved.desc;
|
||||
desc.trim() !== saved.desc ||
|
||||
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const preview = siteDocumentTitle(name, slogan);
|
||||
|
||||
const addKeyword = (raw: string) => {
|
||||
const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length === 0) return;
|
||||
setSaveError("");
|
||||
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("");
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setName(saved.name);
|
||||
setWordmark(saved.wordmark);
|
||||
setSlogan(saved.slogan);
|
||||
setDesc(saved.desc);
|
||||
setKeywords([...saved.keywords]);
|
||||
setKwInput("");
|
||||
setNameError("");
|
||||
setSaveError("");
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
const nextName = name.trim();
|
||||
if (!nextName) {
|
||||
const snap = {
|
||||
name: name.trim(),
|
||||
wordmark: wordmark.trim(),
|
||||
slogan: slogan.trim(),
|
||||
desc: desc.trim(),
|
||||
keywords: [...keywords],
|
||||
};
|
||||
if (!snap.name) {
|
||||
setNameError("显示名称不能为空");
|
||||
toast("显示名称不能为空");
|
||||
return;
|
||||
}
|
||||
setNameError("");
|
||||
setSaveError("");
|
||||
const ticket = ++saveGen.current;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({
|
||||
site_name: nextName,
|
||||
site_wordmark: wordmark.trim(),
|
||||
site_slogan: slogan.trim(),
|
||||
site_description: desc.trim(),
|
||||
site_name: snap.name,
|
||||
site_wordmark: snap.wordmark,
|
||||
site_slogan: snap.slogan,
|
||||
site_description: snap.desc,
|
||||
site_keywords: snap.keywords,
|
||||
});
|
||||
setName(res.site_name);
|
||||
setWordmark(res.site_wordmark);
|
||||
setSlogan(res.site_slogan);
|
||||
setDesc(res.site_description);
|
||||
if (ticket !== saveGen.current) return;
|
||||
setSaved({
|
||||
name: res.site_name,
|
||||
wordmark: res.site_wordmark,
|
||||
slogan: res.site_slogan,
|
||||
desc: res.site_description,
|
||||
keywords: res.site_keywords,
|
||||
});
|
||||
setName((v) => (v.trim() === snap.name ? res.site_name : v));
|
||||
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
|
||||
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
|
||||
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
|
||||
setKeywords((prev) => (JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev));
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-grid">
|
||||
<AdminSection title="名称与字标" description="显示名称用于版权与标题;无 Logo 时页眉改用字标">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
label="显示名称"
|
||||
maxLength={32}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="姜十三论坛"
|
||||
/>
|
||||
<AdminField
|
||||
id="site-wordmark"
|
||||
label="字标"
|
||||
hint="留空则回退为显示名称,最多 16 字"
|
||||
maxLength={BRAND_LIMITS.wordmark}
|
||||
value={wordmark}
|
||||
onChange={setWordmark}
|
||||
placeholder="姜十三"
|
||||
/>
|
||||
</div>
|
||||
</AdminSection>
|
||||
<AdminSection
|
||||
title="标语与简介"
|
||||
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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminSettingsRow label="显示名称" htmlFor="site-name" hintBelow="用于版权行与浏览器标题">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
label="显示名称"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
maxLength={32}
|
||||
value={name}
|
||||
onChange={(v) => {
|
||||
setName(v);
|
||||
if (nameError) setNameError("");
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
placeholder="姜十三论坛"
|
||||
error={nameError}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="字标"
|
||||
htmlFor="site-wordmark"
|
||||
hintBelow="无 Logo 或纯文字页眉时使用;留空则使用显示名称"
|
||||
>
|
||||
<AdminField
|
||||
id="site-wordmark"
|
||||
label="字标"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
maxLength={BRAND_LIMITS.wordmark}
|
||||
value={wordmark}
|
||||
onChange={(v) => {
|
||||
setWordmark(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
placeholder="姜十三"
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="标语"
|
||||
htmlFor="site-slogan"
|
||||
hintBelow={
|
||||
<>
|
||||
出现在浏览器标题和页眉副标题。预览:
|
||||
<span style={{ color: "var(--ink)" }}>{preview}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminField
|
||||
id="site-slogan"
|
||||
label="标语"
|
||||
maxLength={BRAND_LIMITS.slogan}
|
||||
value={slogan}
|
||||
onChange={setSlogan}
|
||||
placeholder="技术分享与讨论"
|
||||
/>
|
||||
<p className="meta text-[12.5px]">
|
||||
标题预览:<span style={{ color: "var(--ink)" }}>{preview}</span>
|
||||
</p>
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
label="简介"
|
||||
as="textarea"
|
||||
minHeight="96px"
|
||||
maxLength={160}
|
||||
value={desc}
|
||||
onChange={setDesc}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
/>
|
||||
<AdminField
|
||||
id="site-slogan"
|
||||
label="标语"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
maxLength={BRAND_LIMITS.slogan}
|
||||
value={slogan}
|
||||
onChange={(v) => {
|
||||
setSlogan(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
placeholder="技术分享与讨论"
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow label="简介" htmlFor="site-desc" hintBelow="用于页面摘要与搜索描述" wide>
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
label="简介"
|
||||
embed
|
||||
as="textarea"
|
||||
className="admin-settings-control-long"
|
||||
minHeight="96px"
|
||||
maxLength={160}
|
||||
value={desc}
|
||||
onChange={(v) => {
|
||||
setDesc(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="SEO 关键词"
|
||||
htmlFor="seo-keyword-input"
|
||||
hintBelow={`用于搜索结果。最多 ${BRAND_LIMITS.keywordCount} 个,每个 ${BRAND_LIMITS.keywordRunes} 字。点击已添加的词可删除。`}
|
||||
wide
|
||||
>
|
||||
<div className="admin-settings-control-long">
|
||||
<div className="admin-settings-keywords">
|
||||
{keywords.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="admin-settings-kw"
|
||||
onClick={() => {
|
||||
setKeywords((prev) => prev.filter((x) => x !== k));
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
>
|
||||
{k}
|
||||
<X size={12} />
|
||||
</button>
|
||||
))}
|
||||
{keywords.length === 0 ? <span className="meta text-[12.5px]">尚未添加</span> : null}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="admin-field-shell flex-1 min-w-0">
|
||||
<input
|
||||
id="seo-keyword-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="admin-field-input"
|
||||
aria-label="添加关键词"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => addKeyword(kwInput)}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSection>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function BrandMediaPicker({
|
||||
data-tip="用作草稿预览,点保存后才生效"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.url} alt="" className="w-full h-full object-contain" loading="lazy" />
|
||||
<img src={item.url} alt={`${SLOT_LABEL[slot]}候选图`} className="w-full h-full object-contain" loading="lazy" />
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AdminField, AdminSection } from "@/components/admin";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import {
|
||||
BRAND_LIMITS,
|
||||
@@ -19,48 +19,72 @@ function clone(links: FooterLink[]): FooterLink[] {
|
||||
export default function FooterLinksPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [links, setLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
|
||||
const [saved, setSaved] = useState<FooterLink[]>(() => clone(initial.footer_links));
|
||||
const [savedLinks, setSavedLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const saveGen = useRef(0);
|
||||
|
||||
const dirty = JSON.stringify(links) !== JSON.stringify(saved);
|
||||
const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const update = (i: number, patch: Partial<FooterLink>) => {
|
||||
setError("");
|
||||
setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item)));
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setLinks(clone(savedLinks));
|
||||
setError("");
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving || !dirty) return;
|
||||
for (const item of links) {
|
||||
if (!item.label.trim()) {
|
||||
setError("链接文案不能为空");
|
||||
toast("链接文案不能为空");
|
||||
return;
|
||||
}
|
||||
if (!footerURLAllowed(item.url)) {
|
||||
setError(`地址无效:${item.url || "(空)"}`);
|
||||
toast(`地址无效:${item.url || "(空)"}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError("");
|
||||
const snapLinks = clone(links).map((item) => ({
|
||||
label: item.label.trim(),
|
||||
url: item.url.trim(),
|
||||
new_tab: item.new_tab,
|
||||
}));
|
||||
const ticket = ++saveGen.current;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = links.map((item) => ({
|
||||
label: item.label.trim(),
|
||||
url: item.url.trim(),
|
||||
new_tab: item.new_tab,
|
||||
}));
|
||||
const res = await apiUpdateSiteSettings({ footer_links: payload });
|
||||
setLinks(clone(res.footer_links));
|
||||
setSaved(clone(res.footer_links));
|
||||
const res = await apiUpdateSiteSettings({
|
||||
footer_links: snapLinks,
|
||||
});
|
||||
if (ticket !== saveGen.current) return;
|
||||
setSavedLinks(clone(res.footer_links));
|
||||
setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev));
|
||||
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 : "保存失败";
|
||||
setError(msg);
|
||||
toast(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
if (ticket === saveGen.current) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,20 +100,28 @@ export default function FooterLinksPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-grid">
|
||||
<AdminSection
|
||||
title="页脚与合规"
|
||||
description="用于 ICP 备案、用户协议等。最多 8 条。外链默认新标签打开。"
|
||||
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={error}
|
||||
onSave={() => void save()}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminSettingsRow
|
||||
label="页脚链接"
|
||||
hintBelow="用于 ICP 备案、用户协议等,最多 8 条。外链默认新标签打开。"
|
||||
wide
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="admin-settings-control-long flex flex-col gap-3">
|
||||
{links.map((item, i) => (
|
||||
<div key={i} className="rounded-xl p-3 flex flex-col gap-3" style={{ boxShadow: "inset 0 0 0 1px var(--line)" }}>
|
||||
<div key={i} className="admin-footer-item">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="meta text-[12px]">第 {i + 1} 条</span>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -154,20 +186,30 @@ export default function FooterLinksPanel({
|
||||
<Plus size={14} /> 新增链接
|
||||
</button>
|
||||
</div>
|
||||
</AdminSection>
|
||||
<AdminSection title="页脚预览" description="保存前仅在此处预览,前台页脚不会跟着变">
|
||||
<div className="rounded-xl px-4 py-3 flex flex-wrap items-center gap-x-4 gap-y-2" style={{ background: "var(--panel-2)" }}>
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {new Date().getFullYear()} {initial.site_name}</span>
|
||||
{links.filter((l) => l.label.trim() && l.url.trim()).map((item) => (
|
||||
<span key={`${item.label}-${item.url}`} className="text-[12px]" style={{ color: "var(--ink-2)" }}>
|
||||
{item.label.trim()}
|
||||
{item.new_tab ? " ↗" : ""}
|
||||
</AdminSettingsRow>
|
||||
|
||||
<AdminSettingsRow label="页脚预览" hintBelow="当前编辑预览,保存后页脚才会更新">
|
||||
<div className="admin-footer-preview">
|
||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
|
||||
<span className="meta text-[12px] whitespace-nowrap">
|
||||
© {new Date().getFullYear()} {initial.site_name}
|
||||
</span>
|
||||
))}
|
||||
<span className="meta text-[12px] ml-auto">性能指标</span>
|
||||
<span className="meta text-[12px] whitespace-nowrap">版本:— · 页面:— · 模板:—</span>
|
||||
</div>
|
||||
{links.filter((l) => l.label.trim() && l.url.trim()).length > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
|
||||
{links
|
||||
.filter((l) => l.label.trim() && l.url.trim())
|
||||
.map((item) => (
|
||||
<span key={`${item.label}-${item.url}`} className="text-[12px] break-all" style={{ color: "var(--ink-2)" }}>
|
||||
{item.label.trim()}
|
||||
{item.new_tab ? " ↗" : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="meta text-[12px] mt-3">外链保存后带 target=_blank 与 rel=noopener。站内路径留在本页。</p>
|
||||
</AdminSection>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user