公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。

后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 00:11:24 +08:00
parent 2b3d122e56
commit 40bbad2c82
51 changed files with 3042 additions and 1318 deletions

View File

@@ -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>
);
}