Files
jiang13-bbs/frontend/app/admin/settings/BasicIdentityPanel.tsx
freefire 40bbad2c82 公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 00:11:24 +08:00

279 lines
8.9 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 { 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, 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 ||
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 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: snap.name,
site_wordmark: snap.wordmark,
site_slogan: snap.slogan,
site_description: snap.desc,
site_keywords: snap.keywords,
});
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");
} 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);
}
};
return (
<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>
</>
}
>
<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>
</AdminSettingsRow>
</AdminSettingsGroup>
);
}