完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:00 +08:00
parent acde7ee288
commit 6f054a903c
98 changed files with 8239 additions and 2073 deletions

View File

@@ -2,8 +2,8 @@
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 { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
import { toast } from "@/lib/toast";
@@ -17,6 +17,12 @@ export default function BasicIdentityPanel({
onDirtyChange?: (dirty: boolean) => void;
}) {
const [name, setName] = useState(initial.site_name);
const [siteURL, setSiteURL] = useState("");
useEffect(() => {
apiOperations<{ site_url: string }>("/api/site-state")
.then((s) => setSiteURL(s.site_url))
.catch(() => {});
}, []);
const [wordmark, setWordmark] = useState(initial.site_wordmark);
const [slogan, setSlogan] = useState(initial.site_slogan);
const [desc, setDesc] = useState(initial.site_description);
@@ -48,7 +54,10 @@ export default function BasicIdentityPanel({
const preview = siteDocumentTitle(name, slogan);
const addKeyword = (raw: string) => {
const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean);
const parts = raw
.split(/[,,;;]/)
.map((s) => s.trim())
.filter(Boolean);
if (parts.length === 0) return;
setSaveError("");
setKeywords((prev) => {
@@ -121,9 +130,11 @@ export default function BasicIdentityPanel({
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));
setKeywords((prev) =>
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
);
onSaved?.(res);
toast("基础资料已保存", "ok");
toast("已保存", "ok");
} catch (e) {
if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
@@ -134,145 +145,147 @@ export default function BasicIdentityPanel({
}
};
const clearSaveError = () => {
if (saveError) setSaveError("");
};
return (
<AdminSettingsGroup
title="基础资料"
description="用于页眉、浏览器标题、公开页摘要与搜索关键词。保存后对访客生效。"
id="settings-identity"
title="站点身份"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存基础资料"
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="admin-identity-studio">
<div className="admin-identity-url" role="group" aria-label="正式站点地址">
<span className="admin-identity-url-label">正式地址</span>
<code className="admin-identity-url-value">{siteURL || "尚未部署 SITE_URL"}</code>
</div>
<div className="admin-identity-grid">
<AdminField
id="site-name"
label="显示名称"
className="admin-identity-field"
maxLength={32}
value={name}
onChange={(v) => {
setName(v);
if (nameError) setNameError("");
clearSaveError();
}}
placeholder="姜十三论坛"
error={nameError}
/>
<AdminField
id="site-wordmark"
label="字标"
className="admin-identity-field"
maxLength={BRAND_LIMITS.wordmark}
value={wordmark}
onChange={(v) => {
setWordmark(v);
clearSaveError();
}}
placeholder="姜十三"
/>
</div>
<AdminField
id="site-slogan"
label="标语"
embed
className="admin-settings-control-text"
className="admin-identity-field"
maxLength={BRAND_LIMITS.slogan}
value={slogan}
onChange={(v) => {
setSlogan(v);
if (saveError) setSaveError("");
clearSaveError();
}}
placeholder="技术分享与讨论"
/>
</AdminSettingsRow>
<AdminSettingsRow label="简介" htmlFor="site-desc" hintBelow="用于页面摘要与搜索描述" wide>
<p className="admin-identity-title-preview" aria-live="polite">
浏览器标题预览 · <strong>{preview}</strong>
</p>
<AdminField
id="site-desc"
label="简介"
embed
as="textarea"
className="admin-settings-control-long"
minHeight="96px"
className="admin-identity-field"
minHeight="72px"
maxLength={160}
value={desc}
onChange={(v) => {
setDesc(v);
if (saveError) setSaveError("");
clearSaveError();
}}
placeholder="一句话介绍社区定位…"
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 className="admin-identity-keywords">
<div className="admin-identity-keywords-head">
<label htmlFor="seo-keyword-input" className="field-label">
关键词
</label>
<span className="meta text-[12px]">
{keywords.length}/{BRAND_LIMITS.keywordCount}
</span>
</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 className="admin-identity-keywords-box">
{keywords.length > 0 ? (
<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));
clearSaveError();
}}
>
{k}
<X size={12} />
</button>
))}
</div>
) : null}
<div className="admin-identity-keywords-add">
<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>
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => addKeyword(kwInput)}>
添加
</button>
</div>
</div>
</AdminSettingsRow>
</div>
</AdminSettingsGroup>
);
}