后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
279 lines
8.9 KiB
TypeScript
279 lines
8.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|