完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 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

@@ -34,12 +34,10 @@ import {
apiAdminRejectPost,
apiAdminApproveComment,
apiAdminRejectComment,
apiUpdateSiteSettings,
type AdminContentComment,
type AdminContentCounts,
type AdminContentPost,
type AdminContentStatus,
type PublicSettings,
} from "@/lib/api";
import { canModerateAuthor } from "@/lib/roles";
import { formatRelative } from "@/lib/format";
@@ -58,8 +56,6 @@ import {
AdminSearchField,
AdminSegmented,
AdminStatusChip,
AdminStepper,
AdminSwitch,
contentStatusChip,
} from "@/components/admin";
@@ -184,29 +180,6 @@ function PendingActions({
);
}
/** 策略项:标题、控件与保存按钮成组换行,不拆散单项 */
function PolicyChip({
title,
hint,
control,
instant,
}: {
title: string;
hint?: string;
control: ReactNode;
instant?: boolean;
}) {
return (
<div className="admin-content-policy-chip" data-tip={hint || undefined}>
<span className="admin-content-policy-chip-copy">
<span className="admin-content-policy-chip-label">{title}</span>
{instant ? <span className="admin-content-policy-chip-how">立即生效</span> : null}
</span>
<div className="admin-content-policy-chip-ctrl shrink-0 flex items-center gap-1.5">{control}</div>
</div>
);
}
function ColumnShell({
icon,
title,
@@ -290,10 +263,8 @@ export default function ContentAdmin({
initialCommentsTotal,
initialCounts,
initError,
canConfigureSettings,
canPurge,
viewerRole,
initialSettings,
}: {
initialKind: ContentKind;
initialStatus: AdminContentStatus;
@@ -303,10 +274,8 @@ export default function ContentAdmin({
initialCommentsTotal: number;
initialCounts: AdminContentCounts;
initError: string;
canConfigureSettings: boolean;
canPurge: boolean;
viewerRole: string;
initialSettings: PublicSettings;
}) {
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
const [mobileKind, setMobileKind] = useState<ContentKind>(initialKind);
@@ -338,11 +307,6 @@ export default function ContentAdmin({
const [actingKey, setActingKey] = useState<string | null>(null);
const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish);
const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours);
const [postLinkNewTab, setPostLinkNewTab] = useState(initialSettings.post_link_new_tab !== false);
const [savingPolicy, setSavingPolicy] = useState(false);
const [confirm, setConfirm] = useState<{
title: string;
message: string;
@@ -463,65 +427,6 @@ export default function ContentAdmin({
);
};
const toggleTrust = async () => {
if (!canConfigureSettings || savingPolicy) return;
setSavingPolicy(true);
try {
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
setTrustOn(res.trust_reviewed_publish);
toast(
res.trust_reviewed_publish
? "已开启过审免审(帖子与评论)"
: "已关闭过审免审(帖子与评论将按规则待审)",
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingPolicy(false);
}
};
const saveCooldown = async () => {
if (!canConfigureSettings || savingPolicy) return;
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
setSavingPolicy(true);
try {
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
setCooldown(res.post_cooldown_hours);
toast(
res.post_cooldown_hours === 0
? "已关闭新用户发帖冷静期"
: `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`,
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingPolicy(false);
}
};
const togglePostLinkNewTab = async () => {
if (!canConfigureSettings || savingPolicy) return;
const next = !postLinkNewTab;
setSavingPolicy(true);
try {
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
setPostLinkNewTab(res.post_link_new_tab !== false);
toast(
res.post_link_new_tab !== false
? "帖子与评论外链将在新标签打开"
: "帖子与评论外链将在当前页打开",
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingPolicy(false);
}
};
const postsToolbar = (
<div className="admin-content-col-toolbar">
<div className="admin-content-status-scroll">
@@ -922,73 +827,9 @@ export default function ContentAdmin({
<AdminPageHeader
icon={FolderOpen}
title="内容管理"
description="审核与管理帖子、评论。共用策略作用于全站发帖与评论,与下方列表筛选无关。"
description="审核与管理帖子、评论。"
/>
{canConfigureSettings && (
<div className="admin-content-policy" role="group" aria-label="全站内容策略">
<div className="admin-content-policy-lead">
<span className="admin-content-policy-title">共用策略</span>
<span className="admin-content-policy-scope">
过审免审与外链开关改完立即写入;冷静期需点保存。失败时恢复为已确认值。
</span>
</div>
<div className="admin-content-policy-row">
<PolicyChip
title="过审免审"
instant
hint="帖子 / 评论:用户曾有过审内容后,后续发帖与评论直接公开,不再进待审。修改后立即生效。"
control={
<AdminSwitch
checked={trustOn}
disabled={savingPolicy}
onChange={() => void toggleTrust()}
label="过审免审(帖子与评论)"
/>
}
/>
<PolicyChip
title="新用户冷静期"
hint="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受此限制。需点保存后生效。"
control={
<div className="flex items-center gap-1.5">
<AdminStepper
value={cooldown}
onChange={setCooldown}
min={0}
max={168}
unit="小时"
ariaLabel="新用户发帖冷静期小时数"
disabled={savingPolicy}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingPolicy}
onClick={() => void saveCooldown()}
>
{savingPolicy ? "保存中…" : "保存"}
</button>
</div>
}
/>
<PolicyChip
title="外链新开"
instant
hint="帖子与评论中的外链在新标签打开。修改后立即生效。"
control={
<AdminSwitch
checked={postLinkNewTab}
disabled={savingPolicy}
onChange={() => void togglePostLinkNewTab()}
label="外链新开(帖子与评论)"
/>
}
/>
</div>
</div>
)}
{/* 窄屏:顶部分段切换双栏 */}
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="内容类型">
<button

View File

@@ -9,8 +9,8 @@ import {
type AdminContentPost,
type AdminContentStatus,
} from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner, isOwner } from "@/lib/roles";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
@@ -51,7 +51,7 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
let initError = "";
// PC 双栏同时展示:SSR 并行预取帖子与评论
const [postsSettled, commentsSettled, counts, settings] = await Promise.all([
const [postsSettled, commentsSettled, counts] = await Promise.all([
fetchAdminContentPosts(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, res }))
.catch((e) => ({
@@ -65,7 +65,6 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
error: e instanceof Error ? e.message : "获取评论失败",
})),
fetchAdminContentCounts(cookie || undefined),
getPublicSettingsCached(),
]);
if (postsSettled.ok) {
@@ -92,10 +91,8 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
initialCommentsTotal={initialCommentsTotal}
initialCounts={counts}
initError={initError}
canConfigureSettings={isSuperOrOwner(me.user.role)}
canPurge={isOwner(me.user.role)}
viewerRole={me.user.role}
initialSettings={settings}
/>
);
}

View File

@@ -0,0 +1,280 @@
"use client";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, Timer } from "lucide-react";
import { useState } from "react";
import {
AdminSettingsGroup,
AdminSettingsRow,
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
import OperationsPanel from "./OperationsPanel";
function InstantNote({ saving }: { saving?: boolean }) {
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
}
/** 内容互动开关 + 访问安全模块 */
export default function AccessSettings({ initial }: { initial: PublicSettings }) {
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
const [commentsRequireLogin, setCommentsRequireLogin] = useState(
initial.comments_require_login === true,
);
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
const [trustOn, setTrustOn] = useState(initial.trust_reviewed_publish !== false);
const [cooldown, setCooldown] = useState(initial.post_cooldown_hours);
const [savedCooldown, setSavedCooldown] = useState(initial.post_cooldown_hours);
const [togglingComments, setTogglingComments] = useState(false);
const [togglingCommentsLogin, setTogglingCommentsLogin] = useState(false);
const [togglingMessages, setTogglingMessages] = useState(false);
const [togglingLink, setTogglingLink] = useState(false);
const [togglingTrust, setTogglingTrust] = useState(false);
const [savingCooldown, setSavingCooldown] = useState(false);
const [moduleDirty, setModuleDirty] = useState(false);
const leave = useUnsavedGuard(moduleDirty);
const toggleComments = async () => {
if (togglingComments) return;
const next = !allowComments;
setTogglingComments(true);
try {
const res = await apiUpdateSiteSettings({ allow_comments: next });
setAllowComments(res.allow_comments);
toast(res.allow_comments ? "已开放评论" : "已关闭评论", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingComments(false);
}
};
const toggleCommentsRequireLogin = async () => {
if (togglingCommentsLogin || !allowComments) return;
const next = !commentsRequireLogin;
setTogglingCommentsLogin(true);
try {
const res = await apiUpdateSiteSettings({ comments_require_login: next });
setCommentsRequireLogin(res.comments_require_login === true);
toast(res.comments_require_login ? "评论仅登录用户可见" : "游客也可查看评论", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingCommentsLogin(false);
}
};
const toggleMessages = async () => {
if (togglingMessages) return;
const next = !allowMessages;
setTogglingMessages(true);
try {
const res = await apiUpdateSiteSettings({ allow_messages: next });
setAllowMessages(res.allow_messages);
toast(res.allow_messages ? "已开放消息" : "已关闭消息", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingMessages(false);
}
};
const togglePostLinkNewTab = async () => {
if (togglingLink) return;
const next = !postLinkNewTab;
setTogglingLink(true);
try {
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
setPostLinkNewTab(res.post_link_new_tab !== false);
toast(
res.post_link_new_tab !== false
? "帖子与评论外链将在新标签打开"
: "帖子与评论外链将在当前页打开",
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingLink(false);
}
};
const toggleTrust = async () => {
if (togglingTrust) return;
setTogglingTrust(true);
try {
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
setTrustOn(res.trust_reviewed_publish);
toast(
res.trust_reviewed_publish
? "已开启过审免审(帖子与评论)"
: "已关闭过审免审(帖子与评论将按规则待审)",
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingTrust(false);
}
};
const saveCooldown = async () => {
if (savingCooldown) return;
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
setSavingCooldown(true);
try {
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
setCooldown(res.post_cooldown_hours);
setSavedCooldown(res.post_cooldown_hours);
toast(
res.post_cooldown_hours === 0
? "已关闭新用户发帖冷静期"
: `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`,
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingCooldown(false);
}
};
return (
<div className="admin-settings-stack">
<AdminSettingsGroup title="内容与互动">
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<MessageSquare size={15} /> 开放评论
</span>
}
hintBelow="关闭后全站不提供评论功能。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={allowComments}
disabled={togglingComments}
onChange={() => void toggleComments()}
label="开放评论"
/>
<InstantNote saving={togglingComments} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<LogIn size={15} /> 登录可见评论
</span>
}
hintBelow="开启后游客无法查看评论内容,需登录后阅读与参与。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={commentsRequireLogin}
disabled={!allowComments || togglingCommentsLogin}
onChange={() => void toggleCommentsRequireLogin()}
label="登录可见评论"
/>
<InstantNote saving={togglingCommentsLogin} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<MessagesSquare size={15} /> 开放消息
</span>
}
hintBelow="关闭后前台隐藏私聊与群聊入口。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={allowMessages}
disabled={togglingMessages}
onChange={() => void toggleMessages()}
label="开放消息"
/>
<InstantNote saving={togglingMessages} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ExternalLink size={15} /> 外链新标签打开
</span>
}
hintBelow="帖子与评论中的外链是否在新标签打开。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={postLinkNewTab}
disabled={togglingLink}
onChange={() => void togglePostLinkNewTab()}
label="外链新标签打开"
/>
<InstantNote saving={togglingLink} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ShieldCheck size={15} /> 过审免审
</span>
}
hintBelow="用户曾有过审内容后,后续发帖与评论直接公开。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={trustOn}
disabled={togglingTrust}
onChange={() => void toggleTrust()}
label="过审免审"
/>
<InstantNote saving={togglingTrust} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Timer size={15} /> 新用户发帖冷静期
</span>
}
hintBelow="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受限。"
>
<div className="flex items-center gap-2 flex-wrap">
<AdminStepper
value={cooldown}
onChange={setCooldown}
min={0}
max={168}
unit="小时"
ariaLabel="新用户发帖冷静期小时数"
disabled={savingCooldown}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingCooldown || cooldown === savedCooldown}
onClick={() => void saveCooldown()}
>
{savingCooldown ? "保存中…" : "保存"}
</button>
</div>
</AdminSettingsRow>
</AdminSettingsGroup>
<OperationsPanel name="security" onDirtyChange={setModuleDirty} />
<ConfirmDialog
open={leave.leaveOpen}
title="有未保存的修改"
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={leave.cancelLeave}
onConfirm={leave.confirmLeave}
/>
</div>
);
}

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

View File

@@ -0,0 +1,39 @@
"use client";
import { useCallback, useState } from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import BasicIdentityPanel from "./BasicIdentityPanel";
import BrandSeoPanel from "./BrandSeoPanel";
import FooterLinksPanel from "./FooterLinksPanel";
import type { PublicSettings } from "@/lib/api";
export default function BasicSettings({ initial }: { initial: PublicSettings }) {
const [live, setLive] = useState(initial);
const [basicDirty, setBasicDirty] = useState(false);
const [brandDirty, setBrandDirty] = useState(false);
const [seoDirty, setSeoDirty] = useState(false);
const dirty = basicDirty || brandDirty || seoDirty;
const leave = useUnsavedGuard(dirty);
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
return (
<div className="admin-settings-stack">
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
<ConfirmDialog
open={leave.leaveOpen}
title="有未保存的修改"
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={leave.cancelLeave}
onConfirm={leave.confirmLeave}
/>
</div>
);
}

View File

@@ -6,7 +6,6 @@ import {
AdminSegmented,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsRow,
} from "@/components/admin";
import BrandLockup from "@/components/BrandLockup";
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
@@ -44,10 +43,10 @@ function slotDirty(d: SlotDraft, saved: string): boolean {
return d.committed !== saved;
}
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" },
const SLOTS: { id: BrandSlot; label: string; swatch: "light" | "dark" | "neutral"; compact?: boolean }[] = [
{ id: "logo_light", label: "浅色 Logo", swatch: "light" },
{ id: "logo_dark", label: "深色 Logo", swatch: "dark" },
{ id: "favicon", label: "Favicon", swatch: "neutral", compact: true },
];
export default function BrandSeoPanel({
@@ -116,13 +115,8 @@ export default function BrandSeoPanel({
}, [dirty, onDirtyChange]);
const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise<string> => {
if (draft.file) {
const url = await apiUploadBrand(slot, draft.file);
return url;
}
if (draft.mediaId != null) {
return apiBrandFromMedia(slot, draft.mediaId);
}
if (draft.file) return apiUploadBrand(slot, draft.file);
if (draft.mediaId != null) return apiBrandFromMedia(slot, draft.mediaId);
return draft.committed;
};
@@ -142,11 +136,7 @@ export default function BrandSeoPanel({
const save = async () => {
if (saving || !dirty) return;
const ticket = ++saveGen.current;
const snap = {
mark,
size: logoSize,
fit: logoFit,
};
const snap = { mark, size: logoSize, fit: logoFit };
setSaving(true);
setSaveError("");
try {
@@ -188,14 +178,18 @@ export default function BrandSeoPanel({
return cur;
});
setFavicon((cur) => {
if (cur.file === favicon.file && cur.mediaId === favicon.mediaId && cur.committed === favicon.committed) {
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) {
if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
@@ -206,7 +200,11 @@ export default function BrandSeoPanel({
}
};
const renderSlot = (slot: BrandSlot, label: string, hint: string, swatch: "light" | "dark" | "neutral", draft: SlotDraft) => {
const slotDraft = (id: BrandSlot) =>
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
const draft = slotDraft(slot);
const preview = previewOf(draft);
const swatchClass =
swatch === "light"
@@ -215,55 +213,60 @@ export default function BrandSeoPanel({
? "admin-brand-swatch-dark"
: "admin-brand-swatch-neutral";
return (
<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 key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
<div className={`admin-brand-swatch ${swatchClass}`}>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt={`${label}预览`} />
<img src={preview} alt="" />
) : (
<span className="text-[12.5px]">未设置</span>
<span>未设置</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
<label className="btn btn-line btn-sm cursor-pointer">
<ImagePlus size={14} /> 上传
<input
type="file"
accept={brandAccept(slot)}
className="hidden"
aria-label={`上传${label}`}
<div className="admin-brand-asset-meta">
<span className="admin-brand-asset-label">{label}</span>
<div className="admin-brand-asset-actions">
<label className="btn btn-line btn-sm cursor-pointer">
<ImagePlus size={14} /> 上传
<input
type="file"
accept={brandAccept(slot)}
className="hidden"
aria-label={`上传${label}`}
disabled={saving}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
const err = brandFileOk(slot, file);
if (err) {
toast(err);
return;
}
replaceFile(slot, file);
}}
/>
</label>
<button
type="button"
className="btn btn-line btn-sm"
disabled={saving}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
const err = brandFileOk(slot, file);
if (err) {
toast(err);
return;
}
replaceFile(slot, file);
onClick={() => setPicker(slot)}
>
<Images size={14} /> 库
</button>
<button
type="button"
className="admin-icon-btn"
aria-label={`清除${label}`}
disabled={saving || !preview}
onClick={() => {
if (draft.blob) URL.revokeObjectURL(draft.blob);
drafts[slot][1](emptyDraft(""));
}}
/>
</label>
<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={saving || !preview}
onClick={() => {
if (draft.blob) URL.revokeObjectURL(draft.blob);
drafts[slot][1](emptyDraft(""));
}}
>
<X size={14} /> 清除
</button>
>
<X size={14} />
</button>
</div>
</div>
</div>
);
@@ -271,41 +274,26 @@ export default function BrandSeoPanel({
return (
<AdminSettingsGroup
title="品牌与图标"
description="预览随编辑更新,保存后对访客生效。"
id="settings-brand"
title="品牌"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存品牌设置"
saveLabel="保存"
error={saveError}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<AdminSettingsRow label="页眉形式" hintBelow="图加文字和纯图共用画幅与铺法">
<AdminSegmented
role="group"
size="sm"
ariaLabel="页眉形式"
value={mark}
onChange={(v) => {
setMark(v);
if (saveError) setSaveError("");
}}
options={[
{ key: "image_text", label: "图加文字" },
{ key: "image", label: "纯图" },
{ key: "text", label: "纯文字" },
]}
/>
</AdminSettingsRow>
<AdminSettingsRow
label="品牌预览"
hintBelow={dirty ? "页眉效果预览;站点仍显示已保存版本,保存后对访客生效" : "页眉效果预览,保存后对访客生效"}
>
<div className="admin-brand-lockup-preview">
<div className="admin-brand-studio">
<div className="admin-brand-studio-preview" aria-label="页眉预览">
<div className="admin-brand-studio-chrome">
<span aria-hidden />
<span aria-hidden />
<span aria-hidden />
</div>
<div className="admin-brand-lockup-preview-bar">
<BrandLockup
mode={mark}
@@ -320,82 +308,75 @@ export default function BrandSeoPanel({
/>
</div>
</div>
</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;
const thumbW = Math.round((item.w / item.h) * thumbH);
return (
<button
key={item.id}
type="button"
className="rounded-xl px-3 py-2 text-left inline-flex items-center gap-2"
style={{
background: on ? "var(--accent-soft)" : "var(--panel)",
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);
if (saveError) setSaveError("");
}}
>
<span
aria-hidden
className="shrink-0 rounded-sm"
style={{
width: thumbW,
height: thumbH,
boxShadow: "inset 0 0 0 1.5px currentColor",
}}
/>
<span>
<span className="block text-[13px] font-medium">{item.label}</span>
<span className="meta text-[12px]">
{item.w}×{item.h}
</span>
</span>
</button>
);
})}
</div>
</AdminSettingsRow>
<AdminSettingsRow label="适配方式" hintBelow="图片如何铺进所选画幅">
<div className="admin-brand-studio-toolbar">
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">形式</span>
<AdminSegmented
role="group"
size="sm"
ariaLabel="适配方式"
value={logoFit}
ariaLabel="页眉形式"
value={mark}
onChange={(v) => {
setLogoFit(v);
setMark(v);
if (saveError) setSaveError("");
}}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
options={[
{ key: "image_text", label: "图加文字" },
{ key: "image", label: "纯图" },
{ key: "text", label: "纯文字" },
]}
/>
</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>
{mark !== "text" ? (
<>
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">比例</span>
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
{BRAND_LOGO_SIZES.map((item) => {
const on = logoSize === item.id;
return (
<button
key={item.id}
type="button"
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
onClick={() => {
setLogoSize(item.id);
if (saveError) setSaveError("");
}}
>
<span className="admin-brand-size-chip-label">{item.label}</span>
<span className="admin-brand-size-chip-px">
{item.w}×{item.h}
</span>
</button>
);
})}
</div>
</div>
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">铺法</span>
<AdminSegmented
role="group"
size="sm"
ariaLabel="适配方式"
value={logoFit}
onChange={(v) => {
setLogoFit(v);
if (saveError) setSaveError("");
}}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
/>
</div>
</>
) : null}
</div>
</AdminSettingsRow>
<div className="admin-brand-studio-assets" aria-label="品牌素材">
{SLOTS.map((s) => renderSlot(s.id, s.label, s.swatch, s.compact))}
</div>
</div>
<BrandMediaPicker
open={picker != null}
slot={picker || "logo_light"}

View File

@@ -0,0 +1,382 @@
"use client";
import { useMemo, useState } from "react";
import { Download, Plus, Upload } from "lucide-react";
import Modal from "@/components/Modal";
import ConfirmDialog from "@/components/ConfirmDialog";
export type Rule = {
id: string;
word: string;
scopes: string[];
action: string;
enabled: boolean;
exceptions: string[];
note: string;
};
const scopes = [
["username", "用户名"],
["title", "帖子标题"],
["body", "正文"],
["comment", "评论"],
] as const;
const empty = (): Rule => ({
id: crypto.randomUUID(),
word: "",
scopes: ["title", "body", "comment"],
action: "block",
enabled: true,
exceptions: [],
note: "",
});
export default function FilterRules({
rules,
onChange,
}: {
rules: Rule[];
onChange: (rules: Rule[]) => void;
}) {
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [editing, setEditing] = useState<Rule | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const [importText, setImportText] = useState("");
const [replace, setReplace] = useState(false);
const [confirm, setConfirm] = useState(false);
const [error, setError] = useState("");
const filtered = rules.filter((r) => r.word.includes(query) || r.note.includes(query));
const pageCount = Math.max(1, Math.ceil(filtered.length / 12));
const pageItems = filtered.slice((page - 1) * 12, page * 12);
const preview = useMemo(() => {
const known = new Set(rules.map((r) => r.word.normalize("NFKC").toLowerCase()));
const seen = new Set<string>();
const added: string[] = [];
let duplicate = 0;
let invalid = 0;
for (const line of importText.split(/\r?\n/)) {
const word = line.trim();
if (!word) continue;
if ([...word].length > 80) {
invalid++;
continue;
}
const n = word.normalize("NFKC").toLowerCase();
if (seen.has(n) || (!replace && known.has(n))) {
duplicate++;
continue;
}
seen.add(n);
added.push(word);
}
return { added, duplicate, invalid };
}, [importText, rules, replace]);
const doImport = () => {
const next = preview.added.map((word) => ({ ...empty(), word }));
if ((replace ? 0 : rules.length) + next.length > 2000) {
setError("最多 2000 条规则");
return;
}
onChange(replace ? next : [...rules, ...next]);
setImportText("");
setConfirm(false);
};
const exportRules = () => {
const blob = new Blob([rules.map((r) => r.word).join("\n")], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "content-filter.txt";
a.click();
URL.revokeObjectURL(url);
};
const toggleSelectAll = (on: boolean) => {
setSelected(on ? pageItems.map((r) => r.id) : []);
};
return (
<div className="ops-filter">
<div className="ops-filter-toolbar">
<input
className="field ops-input"
aria-label="搜索规则"
placeholder="搜索词语或备注"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
<span className="meta text-[12.5px] whitespace-nowrap">
{rules.length}/2000
</span>
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(empty())}>
<Plus size={14} /> 添加
</button>
<button type="button" className="btn btn-line btn-sm" onClick={exportRules}>
<Download size={14} /> 导出
</button>
</div>
{selected.length > 0 && (
<div className="ops-filter-bulk">
<span className="meta text-[12.5px]">已选 {selected.length}</span>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => onChange(rules.map((r) => (selected.includes(r.id) ? { ...r, enabled: true } : r)))}
>
启用
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => onChange(rules.map((r) => (selected.includes(r.id) ? { ...r, enabled: false } : r)))}
>
停用
</button>
<button type="button" className="ops-text-btn" onClick={() => setSelected([])}>
取消选择
</button>
</div>
)}
<div className="ops-filter-table" role="table" aria-label="过滤规则">
<div className="ops-filter-head" role="row">
<label className="ops-filter-check">
<input
type="checkbox"
checked={pageItems.length > 0 && pageItems.every((r) => selected.includes(r.id))}
onChange={(e) => toggleSelectAll(e.target.checked)}
aria-label="全选本页"
/>
</label>
<span>词语</span>
<span>范围</span>
<span>处理</span>
<span>状态</span>
<span />
</div>
{pageItems.map((r) => (
<div className="ops-filter-row" role="row" key={r.id}>
<label className="ops-filter-check">
<input
type="checkbox"
checked={selected.includes(r.id)}
onChange={(e) =>
setSelected((a) => (e.target.checked ? [...a, r.id] : a.filter((id) => id !== r.id)))
}
aria-label={`选择 ${r.word}`}
/>
</label>
<strong className="ops-filter-word">{r.word}</strong>
<span className="meta text-[12.5px]">
{r.scopes.map((s) => scopes.find(([k]) => k === s)?.[1]).filter(Boolean).join("、")}
</span>
<span className="meta text-[12.5px]">{r.action === "block" ? "拦截" : "仅记录"}</span>
<span className={`ops-filter-state ${r.enabled ? "is-on" : ""}`}>{r.enabled ? "启用" : "停用"}</span>
<div className="ops-filter-row-actions">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setEditing({ ...r, scopes: [...r.scopes], exceptions: [...r.exceptions] })}
>
编辑
</button>
<button
type="button"
className="ops-text-btn"
onClick={() => onChange(rules.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))}
>
{r.enabled ? "停用" : "启用"}
</button>
</div>
</div>
))}
{!filtered.length && <p className="ops-hint ops-filter-empty">尚无规则</p>}
</div>
{pageCount > 1 && (
<div className="ops-filter-pager">
<button type="button" className="btn btn-line btn-sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
上一页
</button>
<span className="meta text-[12.5px]">
{page} / {pageCount}
</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page >= pageCount}
onClick={() => setPage((p) => p + 1)}
>
下一页
</button>
</div>
)}
<details className="ops-advanced ops-filter-import">
<summary>
<Upload size={14} aria-hidden /> 从 TXT 导入
</summary>
<input
type="file"
accept=".txt,text/plain"
aria-label="导入 TXT"
className="mt-3"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 300000) {
setError("TXT 文件上限 300 KB");
return;
}
setImportText(await file.text());
}}
/>
<textarea
rows={4}
className="field mt-3 ops-input"
aria-label="导入词语"
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="每行一个词语"
/>
<label className="ops-filter-import-opt">
<input type="checkbox" checked={replace} onChange={(e) => setReplace(e.target.checked)} />
覆盖全部规则(默认合并)
</label>
<p className="meta text-[12.5px]">
新增 {preview.added.length} · 重复 {preview.duplicate} · 无效 {preview.invalid}
</p>
<button
type="button"
className="btn btn-line btn-sm"
disabled={!preview.added.length}
onClick={() => (replace ? setConfirm(true) : doImport())}
>
应用到表单
</button>
</details>
{error && (
<p role="alert" className="alert-error">
{error}
</p>
)}
{editing && (
<Modal open title="编辑过滤规则" onClose={() => setEditing(null)} maxWidthClass="max-w-xl">
<div className="ops-filter-editor">
<label>
词语
<input
autoFocus
className="field"
maxLength={80}
value={editing.word}
onChange={(e) => setEditing({ ...editing, word: e.target.value })}
/>
</label>
<fieldset>
<legend>适用范围</legend>
{scopes.map(([k, l]) => (
<label key={k} className="mr-3 inline-flex items-center gap-1.5">
<input
type="checkbox"
checked={editing.scopes.includes(k)}
onChange={(e) =>
setEditing({
...editing,
scopes: e.target.checked
? [...editing.scopes, k]
: editing.scopes.filter((s) => s !== k),
})
}
/>
{l}
</label>
))}
</fieldset>
<label>
命中处理
<select
className="field ops-select"
value={editing.action}
onChange={(e) => setEditing({ ...editing, action: e.target.value })}
>
<option value="block">拦截提交</option>
<option value="log">仅记录</option>
</select>
</label>
<label>
例外短语(每行一个)
<textarea
className="field"
rows={3}
value={editing.exceptions.join("\n")}
onChange={(e) => setEditing({ ...editing, exceptions: e.target.value.split("\n") })}
/>
</label>
<label>
备注
<input
className="field"
value={editing.note}
onChange={(e) => setEditing({ ...editing, note: e.target.value })}
/>
</label>
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={editing.enabled}
onChange={(e) => setEditing({ ...editing, enabled: e.target.checked })}
/>
启用
</label>
<div className="ops-actions">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!editing.word.trim() || !editing.scopes.length}
onClick={() => {
const value = {
...editing,
word: editing.word.trim(),
exceptions: editing.exceptions.map((s) => s.trim()).filter(Boolean),
};
onChange(
rules.some((r) => r.id === value.id)
? rules.map((r) => (r.id === value.id ? value : r))
: [...rules, value]
);
setEditing(null);
}}
>
应用到表单
</button>
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(null)}>
取消
</button>
</div>
</div>
</Modal>
)}
<ConfirmDialog
open={confirm}
title="覆盖当前词库"
message={`将用 ${preview.added.length} 条导入规则替换现有 ${rules.length} 条;保存本页后生效。`}
onConfirm={doImport}
onCancel={() => setConfirm(false)}
/>
</div>
);
}

View File

@@ -1,14 +1,16 @@
"use client";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import { ArrowDown, ArrowUp, ExternalLink, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import {
BRAND_LIMITS,
FOOTER_LINKS_ALIGNS,
footerURLAllowed,
isExternalFooterURL,
type FooterLink,
type FooterLinksAlign,
} from "@/lib/brand";
import { toast } from "@/lib/toast";
@@ -16,6 +18,10 @@ function clone(links: FooterLink[]): FooterLink[] {
return links.map((l) => ({ ...l }));
}
function zoneOf(links: FooterLink[], align: FooterLinksAlign): FooterLink[] {
return links.filter((l) => (l.align || "right") === align && l.label.trim() && l.url.trim());
}
export default function FooterLinksPanel({
initial,
onSaved,
@@ -32,6 +38,10 @@ export default function FooterLinksPanel({
const saveGen = useRef(0);
const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks);
const left = zoneOf(links, "left");
const center = zoneOf(links, "center");
const right = zoneOf(links, "right");
const hasPreview = left.length + center.length + right.length > 0;
useEffect(() => {
onDirtyChange?.(dirty);
@@ -66,6 +76,7 @@ export default function FooterLinksPanel({
label: item.label.trim(),
url: item.url.trim(),
new_tab: item.new_tab,
align: item.align || "right",
}));
const ticket = ++saveGen.current;
setSaving(true);
@@ -77,7 +88,7 @@ export default function FooterLinksPanel({
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) {
if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
@@ -99,33 +110,150 @@ export default function FooterLinksPanel({
});
};
const renderPreviewLinks = (items: FooterLink[]) =>
items.map((item) => (
<span key={`${item.align}-${item.label}-${item.url}`} className="admin-footer-preview-link">
{item.label.trim()}
{item.new_tab ? <ExternalLink size={11} aria-hidden /> : null}
</span>
));
return (
<AdminSettingsGroup
title="页脚"
description="备案、协议等链接。保存后出现在站点页脚右侧。"
id="settings-footer"
title="页脚链接"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存页脚"
saveLabel="保存"
error={error}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<AdminSettingsRow
label="页脚链接"
hintBelow="用于 ICP 备案、用户协议等,最多 8 条。外链默认新标签打开。"
wide
>
<div className="admin-settings-control-long flex flex-col gap-3">
{links.map((item, i) => (
<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">
<button type="button" className="admin-icon-btn" aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}>
<div className="admin-footer-studio">
<div className="admin-footer-preview" aria-label="页脚预览">
<div className="admin-footer-preview-zone admin-footer-preview-zone-left">
<span className="admin-footer-preview-copy">
© {new Date().getFullYear()} {initial.site_name}
</span>
{left.length > 0 ? <nav className="admin-footer-preview-links">{renderPreviewLinks(left)}</nav> : null}
</div>
<div className="admin-footer-preview-zone admin-footer-preview-zone-center">
{center.length > 0 ? (
<nav className="admin-footer-preview-links">{renderPreviewLinks(center)}</nav>
) : null}
</div>
<div className="admin-footer-preview-zone admin-footer-preview-zone-right">
{right.length > 0 ? (
<nav className="admin-footer-preview-links">{renderPreviewLinks(right)}</nav>
) : !hasPreview ? (
<span className="admin-footer-preview-empty meta text-[12px]">暂无链接</span>
) : null}
</div>
</div>
<div className="admin-footer-table" role="table" aria-label="页脚链接列表">
<div className="admin-footer-table-head" role="row">
<span className="admin-footer-col-idx" aria-hidden>
#
</span>
<span>文案</span>
<span>地址</span>
<span className="admin-footer-col-align">位置</span>
<span className="admin-footer-col-open">打开方式</span>
<span className="admin-footer-col-ops">
<span className="sr-only">操作</span>
</span>
</div>
{links.length === 0 ? (
<p className="admin-footer-empty">暂无链接,可添加备案号、用户协议等</p>
) : (
links.map((item, i) => (
<div key={i} className="admin-footer-table-row" role="row">
<span className="admin-footer-col-idx meta">{i + 1}</span>
<div className="admin-field-shell admin-footer-cell-label">
<input
id={`footer-label-${i}`}
className="admin-field-input"
maxLength={BRAND_LIMITS.footerLabel}
value={item.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="如:京 ICP 备…"
aria-label={`第 ${i + 1} 条文案`}
/>
</div>
<div className="admin-field-shell admin-footer-cell-url">
<input
id={`footer-url-${i}`}
className="admin-field-input"
maxLength={BRAND_LIMITS.footerURL}
value={item.url}
onChange={(e) => {
const v = e.target.value;
const wasExt = isExternalFooterURL(item.url);
const nowExt = isExternalFooterURL(v);
update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
}}
placeholder="https://… 或 /about"
aria-label={`第 ${i + 1} 条地址`}
/>
</div>
<div className="admin-footer-col-align">
<div
className="admin-footer-align-toggle"
role="group"
aria-label={`第 ${i + 1} 条位置`}
>
{FOOTER_LINKS_ALIGNS.map((opt) => (
<button
key={opt.id}
type="button"
className={`admin-footer-align-opt${(item.align || "right") === opt.id ? " is-on" : ""}`}
aria-pressed={(item.align || "right") === opt.id}
onClick={() => update(i, { align: opt.id })}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="admin-footer-col-open">
<div
className="admin-footer-open-toggle"
role="group"
aria-label={`第 ${i + 1} 条打开方式`}
>
<button
type="button"
className={`admin-footer-open-opt${!item.new_tab ? " is-on" : ""}`}
aria-pressed={!item.new_tab}
onClick={() => update(i, { new_tab: false })}
>
本页
</button>
<button
type="button"
className={`admin-footer-open-opt${item.new_tab ? " is-on" : ""}`}
aria-pressed={item.new_tab}
onClick={() => update(i, { new_tab: true })}
>
<ExternalLink size={12} aria-hidden />
新标签
</button>
</div>
</div>
<div className="admin-footer-col-ops">
<button
type="button"
className="admin-icon-btn"
aria-label="上移"
disabled={i === 0}
onClick={() => move(i, -1)}
>
<ArrowUp size={14} />
</button>
<button
@@ -147,69 +275,24 @@ export default function FooterLinksPanel({
</button>
</div>
</div>
<AdminField
id={`footer-label-${i}`}
label="文案"
maxLength={BRAND_LIMITS.footerLabel}
value={item.label}
onChange={(v) => update(i, { label: v })}
placeholder="京 ICP 备xxxxxxxx号"
/>
<AdminField
id={`footer-url-${i}`}
label="地址"
maxLength={BRAND_LIMITS.footerURL}
value={item.url}
onChange={(v) => {
const wasExt = isExternalFooterURL(item.url);
const nowExt = isExternalFooterURL(v);
update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
}}
placeholder="https://beian.miit.gov.cn/ 或 /about"
/>
<label className="flex items-center gap-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
<input
type="checkbox"
checked={item.new_tab}
onChange={(e) => update(i, { new_tab: e.target.checked })}
/>
新标签打开
</label>
</div>
))}
<button
type="button"
className="btn btn-line btn-sm self-start"
disabled={links.length >= BRAND_LIMITS.footerLinks}
onClick={() => setLinks((prev) => [...prev, { label: "", url: "", new_tab: false }])}
>
<Plus size={14} /> 新增链接
</button>
))
)}
</div>
</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] 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>
</AdminSettingsRow>
<button
type="button"
className="btn btn-line btn-sm self-start"
disabled={links.length >= BRAND_LIMITS.footerLinks}
onClick={() =>
setLinks((prev) => [...prev, { label: "", url: "", new_tab: false, align: "right" }])
}
>
<Plus size={14} /> 新增链接
<span className="meta text-[12px] ml-1">
{links.length}/{BRAND_LIMITS.footerLinks}
</span>
</button>
</div>
</AdminSettingsGroup>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useState } from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import OperationsPanel, { type ModuleName } from "./OperationsPanel";
/** 运营模块页:保存单元离开保护 */
export default function ModuleSettings({ name }: { name: Exclude<ModuleName, "security"> }) {
const [dirty, setDirty] = useState(false);
const leave = useUnsavedGuard(dirty);
return (
<div className="admin-settings-stack">
<OperationsPanel name={name} onDirtyChange={setDirty} />
<ConfirmDialog
open={leave.leaveOpen}
title="有未保存的修改"
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={leave.cancelLeave}
onConfirm={leave.confirmLeave}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,864 +0,0 @@
"use client";
import {
CheckCircle2,
ExternalLink,
GitBranch,
Globe,
Image as ImageIcon,
Loader2,
MessageSquare,
MessagesSquare,
Paperclip,
RotateCcw,
UserPlus,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import {
AdminExtChipEditor,
AdminPageHeader,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsPage,
AdminSettingsRow,
AdminSettingsWork,
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import BasicIdentityPanel from "./BasicIdentityPanel";
import BrandSeoPanel from "./BrandSeoPanel";
import FooterLinksPanel from "./FooterLinksPanel";
import ConfirmDialog from "@/components/ConfirmDialog";
function extsEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
/** 与后端 DefaultTimelineGitAdapterJSON 对齐的内置默认适配器 */
const DEFAULT_TIMELINE_GIT_IMPORT = `{
"max_pages": 10,
"max_commits": 100,
"sources": [
{
"id": "github",
"host": "github.com",
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/(?P<ref>[^/]+)/?$",
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
"api_url": "https://api.github.com/repos/{owner}/{repo}/commits",
"commit_api": "https://api.github.com/repos/{owner}/{repo}/commits/{sha}",
"query": { "sha": "{ref}", "per_page": "100", "page": "{page}" },
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json" },
"pagination": "link_header",
"item": {
"sha": "sha",
"date": "commit.committer.date",
"message": "commit.message",
"source_url": "html_url"
}
},
{
"id": "gitea",
"host": "*",
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/branch/(?P<ref>.+)/?$",
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
"api_url": "https://{host}/api/v1/repos/{owner}/{repo}/commits",
"commit_api": "https://{host}/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
"query": { "sha": "{ref}", "limit": "50", "page": "{page}" },
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/json" },
"pagination": "query_page",
"item": {
"sha": "sha",
"date": "created",
"message": "commit.message",
"source_url": "html_url"
}
}
]
}`;
type SectionId = "basic" | "content" | "gitimport";
const NAV: { id: SectionId; label: string }[] = [
{ id: "basic", label: "基本信息" },
{ id: "content", label: "内容与互动" },
{ id: "gitimport", label: "提交导入" },
];
const COMING_SOON = ["安全与限流", "邮件", "对象存储", "敏感词", "系统维护"];
function parseSection(hash: string): SectionId {
const id = hash.replace(/^#/, "");
if (id === "content" || id === "gitimport" || id === "basic") return id;
return "basic";
}
function InstantNote({ saving }: { saving?: boolean }) {
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
}
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
const [live, setLive] = useState(initial);
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
const [togglingReg, setTogglingReg] = useState(false);
const [togglingComments, setTogglingComments] = useState(false);
const [togglingMessages, setTogglingMessages] = useState(false);
const [togglingLink, setTogglingLink] = useState(false);
const [active, setActive] = useState<SectionId>("basic");
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
const [savedExts, setSavedExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
const [attMaxMB, setAttMaxMB] = useState(initial.attachment_max_mb || 20);
const [attMaxCount, setAttMaxCount] = useState(initial.attachment_max_count || 10);
const [imageMaxMB, setImageMaxMB] = useState(initial.image_max_mb || 5);
const [togglingExtLimit, setTogglingExtLimit] = useState(false);
const [savingLimits, setSavingLimits] = useState(false);
const [savingExts, setSavingExts] = useState(false);
const confirmedLimits = useRef({
attachment_max_mb: initial.attachment_max_mb || 20,
attachment_max_count: initial.attachment_max_count || 10,
image_max_mb: initial.image_max_mb || 5,
});
const limitsReq = useRef(0);
const [gitImportJSON, setGitImportJSON] = useState("");
const [savedGitImport, setSavedGitImport] = useState("");
const [gitImportLoading, setGitImportLoading] = useState(false);
const [gitImportLoaded, setGitImportLoaded] = useState(false);
const [savingGitImport, setSavingGitImport] = useState(false);
const [restoreGitOpen, setRestoreGitOpen] = useState(false);
const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [basicDirty, setBasicDirty] = useState(false);
const [brandDirty, setBrandDirty] = useState(false);
const [seoDirty, setSeoDirty] = useState(false);
const [pendingSection, setPendingSection] = useState<SectionId | null>(null);
const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]);
const gitImportDirty = gitImportJSON !== savedGitImport;
const gitPreview = useMemo(() => {
try {
const parsed = JSON.parse(gitImportJSON) as {
max_pages?: unknown;
max_commits?: unknown;
sources?: unknown;
};
if (!Array.isArray(parsed.sources)) return null;
const sources = parsed.sources
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s) => ({
id: typeof s.id === "string" ? s.id : "—",
host: typeof s.host === "string" ? s.host : "*",
}));
return {
maxPages: typeof parsed.max_pages === "number" ? parsed.max_pages : null,
maxCommits: typeof parsed.max_commits === "number" ? parsed.max_commits : null,
sources,
};
} catch {
return null;
}
}, [gitImportJSON]);
const tabDirty =
active === "basic" ? basicDirty || brandDirty || seoDirty : active === "content" ? extsDirty : gitImportDirty;
const leave = useUnsavedGuard(tabDirty);
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
const loadGitImport = async () => {
if (gitImportLoading) return;
setGitImportLoading(true);
try {
const res = await apiGetAdminSettings();
const raw = res.timeline_git_import?.trim() || DEFAULT_TIMELINE_GIT_IMPORT;
let pretty = raw;
try {
pretty = JSON.stringify(JSON.parse(raw), null, 2);
} catch {
/* 保持原文 */
}
setGitImportJSON(pretty);
setSavedGitImport(pretty);
setGitImportLoaded(true);
setGitValidateMsg(null);
} catch (e) {
toast(e instanceof Error ? e.message : "加载适配器失败");
} finally {
setGitImportLoading(false);
}
};
useEffect(() => {
void loadGitImport();
// 仅挂载时拉取一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const apply = () => setActive(parseSection(window.location.hash));
apply();
if (!window.location.hash) {
history.replaceState(null, "", `${window.location.pathname}${window.location.search}#basic`);
}
window.addEventListener("hashchange", apply);
return () => window.removeEventListener("hashchange", apply);
}, []);
const goSection = (id: SectionId) => {
if (id === active) return;
setActive(id);
const next = `${window.location.pathname}${window.location.search}#${id}`;
history.replaceState(null, "", next);
document.getElementById("main")?.scrollTo({ top: 0 });
if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) {
void loadGitImport();
}
};
const selectTab = (id: SectionId) => {
if (id === active) return;
if (tabDirty) {
setPendingSection(id);
return;
}
goSection(id);
};
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End" && e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
return;
}
e.preventDefault();
let next = i;
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length;
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length;
else if (e.key === "Home") next = 0;
else next = NAV.length - 1;
selectTab(NAV[next].id);
navRefs.current[next]?.focus();
};
const validateGitImportClient = (): boolean => {
try {
JSON.parse(gitImportJSON);
setGitValidateMsg({ ok: true, text: "JSON 语法有效(服务端保存时还会做结构校验)" });
return true;
} catch (e) {
setGitValidateMsg({
ok: false,
text: e instanceof Error ? `JSON 无效:${e.message}` : "JSON 无效",
});
return false;
}
};
const saveGitImport = async () => {
if (savingGitImport) return;
if (!validateGitImportClient()) {
toast("请先修正 JSON 语法");
return;
}
const snap = gitImportJSON;
setSavingGitImport(true);
try {
await apiUpdateSiteSettings({ timeline_git_import: snap });
const res = await apiGetAdminSettings();
const raw = res.timeline_git_import?.trim() || snap;
let pretty = raw;
try {
pretty = JSON.stringify(JSON.parse(raw), null, 2);
} catch {
/* 保持 */
}
setSavedGitImport(pretty);
setGitImportJSON((cur) => (cur === snap ? pretty : cur));
toast("提交导入适配器已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingGitImport(false);
}
};
const restoreGitDefault = async () => {
setSavingGitImport(true);
try {
let pretty = DEFAULT_TIMELINE_GIT_IMPORT;
try {
pretty = JSON.stringify(JSON.parse(DEFAULT_TIMELINE_GIT_IMPORT), null, 2);
} catch {
/* 保持 */
}
await apiUpdateSiteSettings({ timeline_git_import: pretty });
setGitImportJSON(pretty);
setSavedGitImport(pretty);
setGitValidateMsg(null);
setRestoreGitOpen(false);
toast("已恢复默认适配器", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "恢复失败");
} finally {
setSavingGitImport(false);
}
};
const toggleRegister = async () => {
if (togglingReg) return;
const next = !allowRegister;
setTogglingReg(true);
try {
const res = await apiUpdateSiteSettings({ allow_register: next });
setAllowRegister(res.allow_register);
toast(res.allow_register ? "已开放注册" : "已关闭注册", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingReg(false);
}
};
const toggleComments = async () => {
if (togglingComments) return;
const next = !allowComments;
setTogglingComments(true);
try {
const res = await apiUpdateSiteSettings({ allow_comments: next });
setAllowComments(res.allow_comments);
toast(res.allow_comments ? "已开放评论" : "已关闭评论", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingComments(false);
}
};
const toggleMessages = async () => {
if (togglingMessages) return;
const next = !allowMessages;
setTogglingMessages(true);
try {
const res = await apiUpdateSiteSettings({ allow_messages: next });
setAllowMessages(res.allow_messages);
toast(res.allow_messages ? "已开放消息" : "已关闭消息", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingMessages(false);
}
};
const togglePostLinkNewTab = async () => {
if (togglingLink) return;
const next = !postLinkNewTab;
setTogglingLink(true);
try {
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
setPostLinkNewTab(res.post_link_new_tab !== false);
toast(res.post_link_new_tab !== false ? "帖子与评论外链将在新标签打开" : "帖子与评论外链将在当前页打开", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingLink(false);
}
};
const toggleExtLimit = async () => {
if (togglingExtLimit) return;
const next = !extLimit;
setTogglingExtLimit(true);
try {
const res = await apiUpdateSiteSettings({ attachment_ext_limit: next });
setExtLimit(res.attachment_ext_limit !== false);
toast(res.attachment_ext_limit !== false ? "已开启附件类型限制" : "已关闭附件类型限制", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingExtLimit(false);
}
};
const saveUploadLimits = async (patch: {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
}) => {
const ticket = ++limitsReq.current;
setSavingLimits(true);
try {
const res = await apiUpdateSiteSettings(patch);
if (ticket !== limitsReq.current) return;
confirmedLimits.current = {
attachment_max_mb: res.attachment_max_mb,
attachment_max_count: res.attachment_max_count,
image_max_mb: res.image_max_mb,
};
setAttMaxMB(res.attachment_max_mb);
setAttMaxCount(res.attachment_max_count);
setImageMaxMB(res.image_max_mb);
toast("上传限额已保存", "ok");
} catch (e) {
if (ticket !== limitsReq.current) return;
setAttMaxMB(confirmedLimits.current.attachment_max_mb);
setAttMaxCount(confirmedLimits.current.attachment_max_count);
setImageMaxMB(confirmedLimits.current.image_max_mb);
toast(e instanceof Error ? e.message : "保存失败");
} finally {
if (ticket === limitsReq.current) setSavingLimits(false);
}
};
const limitsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingLimits = useRef<typeof confirmedLimits.current>({ ...confirmedLimits.current });
const scheduleLimitSave = (patch: {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
}) => {
pendingLimits.current = { ...pendingLimits.current, ...patch };
if (limitsTimer.current) clearTimeout(limitsTimer.current);
limitsTimer.current = setTimeout(() => {
const next = pendingLimits.current;
void saveUploadLimits({
attachment_max_mb: next.attachment_max_mb,
attachment_max_count: next.attachment_max_count,
image_max_mb: next.image_max_mb,
});
}, 450);
};
useEffect(() => {
return () => {
if (limitsTimer.current) clearTimeout(limitsTimer.current);
};
}, []);
const saveExts = async () => {
if (savingExts || !extsDirty) return;
const snap = [...exts];
setSavingExts(true);
try {
const res = await apiUpdateSiteSettings({ attachment_exts: snap });
setSavedExts([...res.attachment_exts]);
setExts((cur) => (extsEqual(cur, snap) ? [...res.attachment_exts] : cur));
toast("附件扩展名已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingExts(false);
}
};
const leaveOpen = leave.leaveOpen || pendingSection != null;
const confirmLeave = () => {
if (pendingSection) {
const next = pendingSection;
setPendingSection(null);
goSection(next);
return;
}
leave.confirmLeave();
};
const cancelLeave = () => {
setPendingSection(null);
leave.cancelLeave();
};
const nav = (
<nav className="admin-settings-nav" aria-label="设置分类">
{NAV.map((item, i) => {
const isActive = active === item.id;
return (
<a
key={item.id}
ref={(el) => {
navRefs.current[i] = el;
}}
href={`#${item.id}`}
className="admin-settings-nav-link"
aria-current={isActive ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
selectTab(item.id);
}}
onKeyDown={(e) => onNavKeyDown(e, i)}
>
{item.label}
</a>
);
})}
<details className="admin-settings-nav-soon">
<summary className="admin-settings-nav-soon-summary">未开放模块</summary>
<ul className="admin-settings-nav-soon-list">
{COMING_SOON.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</details>
</nav>
);
return (
<AdminSettingsPage className={active === "gitimport" ? "is-fill" : undefined}>
<AdminPageHeader
icon={Globe}
title="站点设置"
description="名称、关键词、Logo 与页脚在「基本信息」;主题色请到「外观」调整"
/>
<AdminSettingsWork nav={nav}>
<div id={`settings-panel-${active}`} className="admin-settings-stack">
{active === "basic" && (
<>
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
</>
)}
{active === "content" && (
<>
<AdminSettingsGroup
title="内容与互动"
description="开关改完立即写入,无需再点保存。"
>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<UserPlus size={15} /> 开放注册
</span>
}
hintBelow="关闭后游客无法提交注册,已有账号仍可登录。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
<InstantNote saving={togglingReg} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<MessageSquare size={15} /> 开放评论
</span>
}
hintBelow="关闭后前台不展示评论入口、评论数量与已有评论。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={allowComments}
disabled={togglingComments}
onChange={() => void toggleComments()}
label="开放评论"
/>
<InstantNote saving={togglingComments} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<MessagesSquare size={15} /> 开放消息
</span>
}
hintBelow="关闭后前台不展示私聊与群聊入口;已有会话仍可由后台监管。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={allowMessages}
disabled={togglingMessages}
onChange={() => void toggleMessages()}
label="开放消息"
/>
<InstantNote saving={togglingMessages} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ExternalLink size={15} /> 外链新标签打开
</span>
}
hintBelow="开启后,帖子与评论中的外链在新标签打开;关闭则当前页跳转。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={postLinkNewTab}
disabled={togglingLink}
onChange={() => void togglePostLinkNewTab()}
label="外链新标签打开"
/>
<InstantNote saving={togglingLink} />
</div>
</AdminSettingsRow>
</AdminSettingsGroup>
<AdminSettingsGroup
title="附件与上传"
description="限额停止输入约 0.5 秒后写入。扩展名白名单需点保存。"
footer={
<AdminSettingsActions
dirty={extsDirty}
saving={savingExts}
saveLabel="保存扩展名"
saveDisabled={!extsDirty || savingExts || !extLimit}
onSave={() => void saveExts()}
onDiscard={() => setExts([...savedExts])}
/>
}
>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Paperclip size={15} /> 附件单文件上限
</span>
}
hintBelow={
attMaxMB >= 50
? "发帖附件体积上限。当前 ≥50MB:请确认服务器内存与反代 body 上限已同步调大。"
: "发帖附件体积上限。过大可能拖垮进程,反代与 Next 请求体上限须不少于该值。"
}
>
<div className="flex flex-wrap items-center gap-2">
<AdminStepper
value={attMaxMB}
min={1}
max={999999}
unit="MB"
ariaLabel="附件单文件上限"
disabled={savingLimits}
onChange={(n) => {
setAttMaxMB(n);
scheduleLimitSave({ attachment_max_mb: n });
}}
/>
<InstantNote saving={savingLimits} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Paperclip size={15} /> 每帖附件个数
</span>
}
hintBelow="单帖(含草稿)最多可绑定的附件数量。"
>
<div className="flex flex-wrap items-center gap-2">
<AdminStepper
value={attMaxCount}
min={1}
max={20}
unit="个"
ariaLabel="每帖附件个数"
disabled={savingLimits}
onChange={(n) => {
setAttMaxCount(n);
scheduleLimitSave({ attachment_max_count: n });
}}
/>
<InstantNote saving={savingLimits} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ImageIcon size={15} /> 正文插图上限
</span>
}
hintBelow={
imageMaxMB >= 10
? "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。插图 ≥10MB 时解码更耗内存。"
: "Markdown 拖拽/粘贴图片(仅 JPEG / PNG / WebP)。"
}
>
<div className="flex flex-wrap items-center gap-2">
<AdminStepper
value={imageMaxMB}
min={1}
max={999999}
unit="MB"
ariaLabel="正文插图上限"
disabled={savingLimits}
onChange={(n) => {
setImageMaxMB(n);
scheduleLimitSave({ image_max_mb: n });
}}
/>
<InstantNote saving={savingLimits} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Paperclip size={15} /> 限制附件扩展名
</span>
}
hintBelow={
extLimit
? "仅允许下方列表中的扩展名。"
: "任意格式可传,仍受体积上限与下载沙箱约束。"
}
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={extLimit}
disabled={togglingExtLimit}
onChange={() => void toggleExtLimit()}
label="限制附件扩展名"
/>
<InstantNote saving={togglingExtLimit} />
</div>
</AdminSettingsRow>
<AdminSettingsRow label="允许的扩展名" hintBelow="列表变更需点保存,不会随开关立即写入。" wide>
<div className="admin-settings-control-long">
<AdminExtChipEditor value={exts} onChange={setExts} disabled={!extLimit || savingExts} />
</div>
</AdminSettingsRow>
</AdminSettingsGroup>
</>
)}
{active === "gitimport" && (
<AdminSettingsGroup
className="is-fill"
title="提交导入适配器"
description="用 JSON 描述 GitHub / Gitea 等匹配规则。保存时服务端会做结构校验,不会执行任意代码。"
footer={
<>
<AdminSettingsActions
dirty={gitImportDirty}
saving={savingGitImport}
saveLabel="保存导入适配器"
onSave={() => void saveGitImport()}
onDiscard={() => {
setGitImportJSON(savedGitImport);
setGitValidateMsg(null);
}}
>
<button
type="button"
className="btn btn-line btn-sm"
disabled={gitImportLoading || savingGitImport}
onClick={() => {
if (validateGitImportClient()) toast("语法校验通过", "ok");
else toast("JSON 语法无效");
}}
>
<CheckCircle2 size={14} /> 校验
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingGitImport}
onClick={() => setRestoreGitOpen(true)}
>
<RotateCcw size={14} /> 恢复默认
</button>
</AdminSettingsActions>
{(gitPreview || gitImportLoaded) ? (
<div className="admin-settings-git-summary" aria-label="规则摘要">
<span className="admin-settings-git-summary-kicker">规则摘要</span>
{gitPreview ? (
<>
<span>
最多 {gitPreview.maxPages ?? "—"} 页 · {gitPreview.maxCommits ?? "—"} 条提交
</span>
{gitPreview.sources.map((s) => (
<span key={`${s.id}-${s.host}`} className="admin-settings-git-summary-src">
<GitBranch size={12} aria-hidden />
<strong>{s.id}</strong>
<span>{s.host}</span>
</span>
))}
</>
) : (
<span>JSON 无法解析时不显示摘要,保存前请先校验语法。</span>
)}
</div>
) : null}
</>
}
>
{gitImportLoading && !gitImportLoaded ? (
<div className="flex items-center gap-2 meta py-8 justify-center">
<Loader2 size={16} className="animate-spin" /> 加载适配器…
</div>
) : (
<AdminSettingsRow
className="admin-settings-git-editor"
label="适配器 JSON"
htmlFor="git-import-json"
hint="声明式匹配规则。服务端只按规则拉取。"
wide
>
<div className="admin-settings-git-json-wrap">
<textarea
id="git-import-json"
className="j13-compose-field admin-settings-git-json font-mono text-[12.5px] leading-relaxed"
spellCheck={false}
value={gitImportJSON}
onChange={(e) => {
setGitImportJSON(e.target.value);
setGitValidateMsg(null);
}}
placeholder="粘贴或编辑适配器 JSON…"
/>
{gitValidateMsg ? (
<p
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
gitValidateMsg.ok ? "" : "alert-error !mt-2"
}`}
style={gitValidateMsg.ok ? { color: "var(--ok, #137333)" } : undefined}
role="status"
>
{gitValidateMsg.ok ? <CheckCircle2 size={14} className="mt-0.5 shrink-0" /> : null}
{gitValidateMsg.text}
</p>
) : null}
</div>
</AdminSettingsRow>
)}
</AdminSettingsGroup>
)}
</div>
</AdminSettingsWork>
<ConfirmDialog
open={restoreGitOpen}
title="恢复默认适配器"
message="将覆盖当前「提交导入」JSON 为内置 GitHub + Gitea 模板,并立即保存。确定继续?"
busy={savingGitImport}
confirmLabel="恢复并保存"
onCancel={() => !savingGitImport && setRestoreGitOpen(false)}
onConfirm={() => void restoreGitDefault()}
/>
<ConfirmDialog
open={leaveOpen}
title="有未保存的修改"
message="离开后,当前保存单元里尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={cancelLeave}
onConfirm={confirmLeave}
/>
</AdminSettingsPage>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
import { Globe } from "lucide-react";
import {
AdminPageHeader,
AdminSettingsPage,
AdminSettingsWork,
} from "@/components/admin";
const NAV: { href: string; label: string }[] = [
{ href: "/admin/settings/basic", label: "基本信息" },
{ href: "/admin/settings/access", label: "互动与安全" },
{ href: "/admin/settings/mail", label: "邮件服务" },
{ href: "/admin/settings/storage", label: "文件与存储" },
{ href: "/admin/settings/filter", label: "内容过滤" },
{ href: "/admin/settings/maintenance", label: "维护与诊断" },
];
/** 旧 #hash / ?section= → 独立路由 */
const LEGACY: Record<string, string> = {
basic: "/admin/settings/basic",
content: "/admin/settings/access",
security: "/admin/settings/access",
access: "/admin/settings/access",
mail: "/admin/settings/mail",
storage: "/admin/settings/storage",
filter: "/admin/settings/filter",
maintenance: "/admin/settings/maintenance",
};
export default function SettingsShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
useLayoutEffect(() => {
const hash = window.location.hash.replace(/^#/, "").trim();
const section = new URLSearchParams(window.location.search).get("section") || hash;
if (!section) return;
const dest = LEGACY[section];
if (!dest || dest === pathname) return;
router.replace(dest);
}, [pathname, router]);
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
if (
e.key !== "ArrowDown" &&
e.key !== "ArrowUp" &&
e.key !== "Home" &&
e.key !== "End" &&
e.key !== "ArrowRight" &&
e.key !== "ArrowLeft"
) {
return;
}
e.preventDefault();
let next = i;
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length;
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length;
else if (e.key === "Home") next = 0;
else next = NAV.length - 1;
navRefs.current[next]?.focus();
router.push(NAV[next].href);
};
const nav = (
<nav className="admin-settings-nav" aria-label="设置分类">
{NAV.map((item, i) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
ref={(el) => {
navRefs.current[i] = el;
}}
href={item.href}
className="admin-settings-nav-link"
aria-current={isActive ? "page" : undefined}
onKeyDown={(e) => onNavKeyDown(e, i)}
>
{item.label}
</Link>
);
})}
</nav>
);
return (
<AdminSettingsPage>
<AdminPageHeader icon={Globe} title="站点设置" description="基本信息、互动安全与运营模块" />
<AdminSettingsWork nav={nav}>{children}</AdminSettingsWork>
</AdminSettingsPage>
);
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { getPublicSettingsCached } from "@/lib/serverData";
import AccessSettings from "../AccessSettings";
export const metadata: Metadata = {
title: "互动与安全",
};
export default async function AccessSettingsPage() {
const settings = await getPublicSettingsCached();
return <AccessSettings initial={settings} />;
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { getPublicSettingsCached } from "@/lib/serverData";
import BasicSettings from "../BasicSettings";
export const metadata: Metadata = {
title: "基本信息",
};
export default async function BasicSettingsPage() {
const settings = await getPublicSettingsCached();
return <BasicSettings initial={settings} />;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import ModuleSettings from "../ModuleSettings";
export const metadata: Metadata = {
title: "内容过滤",
};
export default function FilterSettingsPage() {
return <ModuleSettings name="filter" />;
}

View File

@@ -0,0 +1,15 @@
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import SettingsShell from "./SettingsShell";
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
}
return <SettingsShell>{children}</SettingsShell>;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import ModuleSettings from "../ModuleSettings";
export const metadata: Metadata = {
title: "邮件服务",
};
export default function MailSettingsPage() {
return <ModuleSettings name="mail" />;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import ModuleSettings from "../ModuleSettings";
export const metadata: Metadata = {
title: "维护与诊断",
};
export default function MaintenanceSettingsPage() {
return <ModuleSettings name="maintenance" />;
}

View File

@@ -1,21 +1,28 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import SettingsAdmin from "./SettingsAdmin";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "站点设置",
};
export default async function AdminSettingsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
}
const settings = await getPublicSettingsCached();
return <SettingsAdmin initial={settings} />;
interface PageProps {
searchParams: Promise<{ section?: string }>;
}
const LEGACY: Record<string, string> = {
basic: "/admin/settings/basic",
content: "/admin/settings/access",
security: "/admin/settings/access",
access: "/admin/settings/access",
mail: "/admin/settings/mail",
storage: "/admin/settings/storage",
filter: "/admin/settings/filter",
maintenance: "/admin/settings/maintenance",
};
/** /admin/settings 与旧 ?section= 统一跳到子路由 */
export default async function SettingsIndexPage({ searchParams }: PageProps) {
const sp = await searchParams;
const raw = typeof sp.section === "string" ? sp.section.trim() : "";
redirect(LEGACY[raw] || "/admin/settings/basic");
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import ModuleSettings from "../ModuleSettings";
export const metadata: Metadata = {
title: "文件与存储",
};
export default function StorageSettingsPage() {
return <ModuleSettings name="storage" />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import OperationalBanner from "@/components/OperationalBanner";
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
@@ -22,7 +23,8 @@ import { accentStyleCSS } from "@/lib/theme";
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
import { siteDocumentTitle } from "@/lib/brand";
import { motionConfigFromSettings } from "@/lib/motion";
import { requestMetadataBase } from "@/lib/canonicalMeta";
import { peekPublicMetaSnapshot, publishPublicMetaSnapshot } from "@/lib/publicMetaSnapshot";
import { metadataBaseURL } from "@/lib/siteUrl";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
@@ -47,15 +49,16 @@ const jetbrainsMono = JetBrains_Mono({
display: "swap",
});
export async function generateMetadata(): Promise<Metadata> {
const settings = await getPublicSettingsCached();
// 必须同步:根 layout 若在此 await(拉设置 / headers),Next 流式 metadata
// 会在等待期把标签临时写成 localhost:3000,开发态 RSC/HMR 一密就来回闪。
export function generateMetadata(): Metadata {
const settings = peekPublicMetaSnapshot();
const name = settings.site_name || "姜十三论坛";
const title = siteDocumentTitle(name, settings.site_slogan || "");
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined;
const metadataBase = await requestMetadataBase();
const metadataBase = metadataBaseURL();
return {
// 只提供解析相对 URL 的基址;Canonical 由各公开页自行声明,避免子路由继承首页
...(metadataBase ? { metadataBase } : {}),
title: {
default: title,
@@ -63,9 +66,7 @@ export async function generateMetadata(): Promise<Metadata> {
},
description: desc,
keywords,
...(settings.favicon_url
? { icons: { icon: settings.favicon_url } }
: {}),
...(settings.favicon_url ? { icons: { icon: settings.favicon_url } } : {}),
openGraph: {
title,
description: desc,
@@ -94,6 +95,7 @@ export default async function RootLayout({
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
const settings = await getPublicSettingsCached();
publishPublicMetaSnapshot(settings);
const accent = settings.accent;
const accentCSS = accentStyleCSS(accent || "");
const bgCfg = bgConfigFromSettings(settings);
@@ -147,7 +149,10 @@ export default async function RootLayout({
</a>
<SiteBrandProvider initial={settings}>
<MotionProvider initial={motionCfg}>
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
<CommentsPolicyProvider
allowComments={settings.allow_comments !== false}
requireLogin={settings.comments_require_login === true}
>
<MessagesPolicyProvider allowMessages={settings.allow_messages !== false}>
<PostLinkProvider openInNewTab={settings.post_link_new_tab !== false}>
<CodeFoldProvider
@@ -160,8 +165,8 @@ export default async function RootLayout({
unread={unread}
chatUnread={settings.allow_messages !== false ? chatUnread : 0}
theme={theme}
allowRegister={settings.allow_register}
>
<OperationalBanner />
{children}
</SiteChrome>
</CodeFoldProvider>

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { MessagesSquare } from "lucide-react";
import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
import { apiLogin, apiMe } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
@@ -18,17 +18,6 @@ function safeRedirect(to: string | null): string {
}
function RegisterHint() {
const [allow, setAllow] = useState(true);
useEffect(() => {
apiGetSettings()
.then((s) => setAllow(s.allow_register))
.catch(() => {});
}, []);
if (!allow) {
return (
<p className="meta text-center mt-5">当前站点暂不开放注册</p>
);
}
return (
<p className="meta text-center mt-5">
还没有账号?
@@ -133,6 +122,7 @@ export default function LoginPage() {
</button>
</form>
<p className="text-center mt-4"><Link href="/reset-password">找回密码</Link></p>
<RegisterHint />
</div>
);

View File

@@ -452,8 +452,10 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
);
}
// 站点设置已在 layout 缓存;关评时不拉评论树,避免首屏 HTML 泄露历史评论
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
// 站点设置已在 layout 缓存;关评或「登录可见」且游客时不拉评论树,避免首屏泄露
const publicSettings = await getPublicSettingsCached();
const allowComments = publicSettings.allow_comments !== false;
const commentsRequireLogin = publicSettings.comments_require_login === true;
const emptyComments: CommentsResponse = {
comments: [],
total: 0,
@@ -461,12 +463,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
page: 1,
size: 20,
};
const [commentsData, me, profile, relatedData] = await Promise.all([
allowComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
getMeCached(cookie || undefined),
const meFirst = await getMeCached(cookie || undefined);
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
const [commentsData, profile, relatedData] = await Promise.all([
canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
]);
const me = meFirst;
const comments = commentsData.comments;
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
@@ -517,7 +521,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) ||
entityUrl;
const authorName = post.user.nickname || post.user.username;
const visibleFloors: JsonLdVisibleComment[] = allowComments
const visibleFloors: JsonLdVisibleComment[] = canFetchComments
? comments
.filter((c) => !c.deleted && (c.content || "").trim())
.map((c) => ({
@@ -530,7 +534,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
}))
: [];
const acceptedForLd: JsonLdVisibleComment | null =
allowComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
canFetchComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
? {
id: acceptedAnswer.id,
content: acceptedAnswer.content,
@@ -565,7 +569,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
author: { id: post.user.id, name: authorName },
postId: post.id,
pageUrl,
commentCount: allowComments ? post.comment_count : undefined,
commentCount: canFetchComments ? post.comment_count : undefined,
viewCount: post.view_count,
comments: visibleFloors,
});

View File

@@ -0,0 +1,225 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { apiRegister, apiMe, apiOperations } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
/** 关闭注册时的说明页(提交失败切到关闭态时复用) */
export function RegisterClosedPanel({ notice }: { notice: string }) {
return (
<div className="max-w-md mx-auto pt-8 sm:pt-14">
<div className="panel p-8 text-center">
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
暂不开放注册
</h1>
<p className="meta mt-2 text-[13px]">
{notice || "站点当前已关闭新用户注册,已有账号可直接登录。"}
</p>
<Link href="/login" className="btn btn-primary mt-6">
去登录
</Link>
</div>
</div>
);
}
export default function RegisterClient({
verifyEmail: initialVerifyEmail,
registerNotice,
}: {
verifyEmail: boolean;
registerNotice: string;
}) {
const router = useRouter();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [registerClosed, setRegisterClosed] = useState(false);
const [code, setCode] = useState("");
const [notice, setNotice] = useState(registerNotice);
const [sending, setSending] = useState(false);
const [sentMessage, setSentMessage] = useState("");
const verifyEmail = initialVerifyEmail;
// 已登录用户不必再注册,直接回首页
useEffect(() => {
if (!hasAuthCookieHint()) return;
apiMe()
.then((res) => {
if (res.user) router.replace("/");
})
.catch(() => {});
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await apiRegister(username, email, password, code);
if (res.user) {
router.push("/");
router.refresh();
} else if (res.id) {
router.push("/login");
} else {
const msg = res.error || "注册失败";
if (msg.includes("关闭注册")) {
// 热关闭:补拉提示文案后切关闭态
try {
const s = await apiOperations<{ register_notice: string }>("/api/site-state");
setNotice(s.register_notice || notice);
} catch {
/* 沿用 SSR 提示 */
}
setRegisterClosed(true);
}
setError(msg);
}
} finally {
setLoading(false);
}
};
if (registerClosed) {
return <RegisterClosedPanel notice={notice} />;
}
return (
<div className="max-w-md mx-auto pt-8 sm:pt-14">
<div className="mb-7 text-center">
<span
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<UserPlus size={26} />
</span>
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
创建账号
</h1>
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
加入社区,开始你的第一帖
</p>
</div>
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
{error && (
<div className="alert-error" role="alert">
{error}
</div>
)}
<div>
<label className="field-label" htmlFor="reg-username">
用户名
</label>
<input
id="reg-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
maxLength={32}
autoFocus
autoComplete="username"
className="field"
placeholder="3–32 位"
/>
</div>
<div>
<label className="field-label" htmlFor="reg-email">
{verifyEmail ? "邮箱(必填)" : "邮箱(可选)"}
</label>
<input
id="reg-email"
required={verifyEmail}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="field"
placeholder="you@example.com"
/>
</div>
{verifyEmail && (
<div>
<label className="field-label" htmlFor="reg-code">
邮箱验证码
</label>
<div className="flex gap-2 items-stretch">
<input
id="reg-code"
className="field min-w-0 flex-1"
value={code}
onChange={(e) => setCode(e.target.value)}
required
autoComplete="one-time-code"
inputMode="numeric"
placeholder="6 位验证码"
/>
<button
type="button"
className="btn btn-line shrink-0"
disabled={sending || !email}
onClick={async () => {
setSending(true);
setError("");
try {
const r = await apiOperations<{ message: string }>("/api/auth/code", "POST", {
email,
purpose: "register",
});
setSentMessage(r.message);
} catch (err) {
setError((err as Error).message);
} finally {
setSending(false);
}
}}
>
{sending ? "正在排队…" : "发送验证码"}
</button>
</div>
{sentMessage && (
<p className="meta mt-2" role="status">
{sentMessage}
</p>
)}
</div>
)}
<div>
<label className="field-label" htmlFor="reg-password">
密码
</label>
<input
id="reg-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="field"
placeholder="至少 6 位"
/>
</div>
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
{loading ? "注册中..." : "注册"}
</button>
</form>
<p className="meta text-center mt-5">
已有账号?
<Link href="/login" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
去登录
</Link>
</p>
</div>
);
}

View File

@@ -1,150 +1,18 @@
"use client";
import type { Metadata } from "next";
import { getSiteStateCached } from "@/lib/serverData";
import RegisterClient, { RegisterClosedPanel } from "./RegisterClient";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { apiRegister, apiMe, apiGetSettings } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
export const metadata: Metadata = {
title: "注册",
};
export default function RegisterPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [registerClosed, setRegisterClosed] = useState(false);
// 已登录用户不必再注册,直接回首页
useEffect(() => {
if (!hasAuthCookieHint()) return;
apiMe()
.then((res) => {
if (res.user) router.replace("/");
})
.catch(() => {});
}, [router]);
useEffect(() => {
apiGetSettings()
.then((s) => {
if (!s.allow_register) setRegisterClosed(true);
})
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await apiRegister(username, email, password);
if (res.user) {
// 后端注册即签发登录态 cookie:直接进入社区,无需再手动登录
router.push("/");
router.refresh();
} else if (res.id) {
// 极端情况:注册成功但自动签发失败,引导去登录
router.push("/login");
} else {
const msg = res.error || "注册失败";
if (msg.includes("关闭注册")) setRegisterClosed(true);
setError(msg);
}
} finally {
setLoading(false);
}
};
if (registerClosed) {
return (
<div className="max-w-md mx-auto pt-8 sm:pt-14">
<div className="panel p-8 text-center">
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
暂不开放注册
</h1>
<p className="meta mt-2 text-[13px]">站点当前已关闭新用户注册,已有账号可直接登录。</p>
<Link href="/login" className="btn btn-primary mt-6">
去登录
</Link>
</div>
</div>
);
/** SSR 直出开放/关闭态,避免先闪注册表单再切「暂不开放」 */
export default async function RegisterPage() {
const state = await getSiteStateCached();
if (!state.allow_register) {
return <RegisterClosedPanel notice={state.register_notice} />;
}
return (
<div className="max-w-md mx-auto pt-8 sm:pt-14">
<div className="mb-7 text-center">
<span
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<UserPlus size={26} />
</span>
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
创建账号
</h1>
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
加入社区,开始你的第一帖
</p>
</div>
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
{error && <div className="alert-error" role="alert">{error}</div>}
<div>
<label className="field-label" htmlFor="reg-username">用户名</label>
<input
id="reg-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
maxLength={32}
autoFocus
autoComplete="username"
className="field"
placeholder="3–32 位"
/>
</div>
<div>
<label className="field-label" htmlFor="reg-email">邮箱(可选)</label>
<input
id="reg-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="field"
placeholder="you@example.com"
/>
</div>
<div>
<label className="field-label" htmlFor="reg-password">密码</label>
<input
id="reg-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="field"
placeholder="至少 6 位"
/>
</div>
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
{loading ? "注册中..." : "注册"}
</button>
</form>
<p className="meta text-center mt-5">
已有账号?
<Link href="/login" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
去登录
</Link>
</p>
</div>
<RegisterClient verifyEmail={state.verify_email} registerNotice={state.register_notice} />
);
}

View File

@@ -0,0 +1,169 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { KeyRound } from "lucide-react";
import { apiOperations } from "@/lib/api";
export default function ResetPasswordPage() {
const [enabled, setEnabled] = useState(false);
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
useEffect(() => {
apiOperations<{ password_reset: boolean }>("/api/site-state")
.then((s) => setEnabled(s.password_reset))
.catch(() => setError("暂时无法读取站点状态"));
}, []);
async function sendCode() {
if (sending || !email) return;
setSending(true);
setError("");
setMessage("");
try {
const r = await apiOperations<{ message: string }>("/api/auth/code", "POST", {
email,
purpose: "reset",
});
setMessage(r.message);
} catch (e) {
setError((e as Error).message);
} finally {
setSending(false);
}
}
async function resetPassword(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError("");
setMessage("");
try {
const r = await apiOperations<{ message: string }>("/api/auth/reset-password", "POST", {
email,
code,
password,
});
setMessage(r.message);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="max-w-md mx-auto pt-8 sm:pt-14">
<div className="mb-7 text-center">
<span
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<KeyRound size={26} />
</span>
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
找回密码
</h1>
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
通过邮箱验证码重置登录密码
</p>
</div>
<form onSubmit={resetPassword} className="panel p-6 sm:p-7 space-y-4">
{error && (
<div className="alert-error" role="alert">
{error}
</div>
)}
{!enabled ? (
<p className="meta text-[13px]">站点暂未开放邮件找回密码。</p>
) : (
<>
<div>
<label className="field-label" htmlFor="reset-email">
邮箱
</label>
<input
id="reset-email"
type="email"
required
className="field"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
placeholder="you@example.com"
/>
</div>
<div>
<label className="field-label" htmlFor="reset-code">
邮箱验证码
</label>
<div className="flex gap-2 items-stretch">
<input
id="reset-code"
className="field min-w-0 flex-1"
value={code}
onChange={(e) => setCode(e.target.value)}
required
autoComplete="one-time-code"
inputMode="numeric"
placeholder="6 位验证码"
/>
<button
type="button"
className="btn btn-line shrink-0"
disabled={sending || busy || !email}
onClick={() => void sendCode()}
>
{sending ? "正在排队…" : "发送验证码"}
</button>
</div>
</div>
<div>
<label className="field-label" htmlFor="reset-password">
新密码
</label>
<input
id="reset-password"
type="password"
required
minLength={6}
maxLength={64}
autoComplete="new-password"
className="field"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位"
/>
</div>
<button
type="submit"
className="btn btn-primary w-full justify-center mt-2"
disabled={busy || !code || password.length < 6}
>
{busy ? "处理中…" : "更新密码"}
</button>
</>
)}
{message && (
<p className="meta" role="status">
{message}
</p>
)}
</form>
<p className="meta text-center mt-5">
<Link href="/login" className="font-semibold" style={{ color: "var(--accent)" }}>
返回登录
</Link>
</p>
</div>
);
}

View File

@@ -83,7 +83,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const me = await getMeCached(cookie || undefined);
const settings = await getPublicSettingsCached();
const allowComments = settings.allow_comments !== false;
const commentsRequireLogin = settings.comments_require_login === true;
const allowMessages = settings.allow_messages !== false;
const canReadComments = allowComments && (!commentsRequireLogin || !!me.user);
let data: UserProfile;
try {
@@ -108,12 +110,12 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const currentUser = me.user;
let effectiveTab: UserTab =
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
if (!allowComments && effectiveTab === "comments") effectiveTab = "posts";
if (!canReadComments && effectiveTab === "comments") effectiveTab = "posts";
// 评论 tab:SSR 直出对应分页
let commentData: UserCommentsResponse | null = null;
let commentError = false;
if (allowComments && effectiveTab === "comments") {
if (canReadComments && effectiveTab === "comments") {
try {
commentData = await fetchUserComments(id, page);
} catch {
@@ -147,7 +149,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
// 导航项(桌面左栏 / 移动 chips 共用定义)
const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
{ key: "posts", label: "发帖", count: data.stats.post_count, icon: <FileText size={15} /> },
...(allowComments
...(canReadComments
? [{ key: "comments" as const, label: "评论", count: data.stats.comment_count, icon: <MessageCircle size={15} /> }]
: []),
{ key: "settings", label: "资料设置", icon: <Settings size={15} />, ownerOnly: true },
@@ -157,7 +159,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
const profileQuery: Record<string, string | number> = {};
if (allowComments && effectiveTab === "comments") profileQuery.tab = "comments";
if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments";
if (page > 1) profileQuery.page = page;
const profileLd =
effectiveTab === "settings" || effectiveTab === "security"
@@ -274,7 +276,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.post_count}</span>
{" "}帖子
</span>
{allowComments && (
{canReadComments && (
<>
<span aria-hidden className="mx-1.5">·</span>
<span>