feat: 全站评论开关,并统一后台表单控件

关闭评论后前台隐藏评论能力;外观/设置/公告改用 AdminField 与 Stepper。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 01:22:00 +08:00
parent 442af625f6
commit d93635c961
27 changed files with 989 additions and 479 deletions

View File

@@ -25,6 +25,7 @@ import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminField,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
@@ -155,29 +156,24 @@ export default function AnnouncementAdmin({
{form.id == null ? "新建公告" : "编辑公告"}
</p>
<label className="field-label" htmlFor="anno-title">
标题
</label>
<input
<AdminField
id="anno-title"
className="field mb-4"
className="mb-4"
label="标题"
maxLength={200}
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
onChange={(title) => setForm({ ...form, title })}
placeholder="请输入公告标题"
/>
<div className="flex items-end gap-4 mb-4 flex-wrap">
<div className="w-36">
<label className="field-label" htmlFor="anno-tag">
标签
</label>
<input
<AdminField
id="anno-tag"
className="field"
label="标签"
maxLength={32}
value={form.tag}
onChange={(e) => setForm({ ...form, tag: e.target.value })}
onChange={(tag) => setForm({ ...form, tag })}
placeholder="公告"
/>
</div>
@@ -213,14 +209,13 @@ export default function AnnouncementAdmin({
/>
</div>
<label className="field-label" htmlFor="anno-content">
正文(支持 Markdown)
</label>
<textarea
<AdminField
id="anno-content"
className="field min-h-[260px]"
label="正文(支持 Markdown)"
as="textarea"
minHeight="260px"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
onChange={(content) => setForm({ ...form, content })}
placeholder="请输入公告正文,支持 Markdown 语法…"
/>

View File

@@ -5,14 +5,22 @@ import { useRouter } from "next/navigation";
import {
Palette,
Check,
Code2,
Loader2,
Save,
Undo2,
RotateCcw,
Pipette,
Sparkles,
} from "lucide-react";
import { AdminPageHeader, AdminSection } from "@/components/admin";
import { apiUpdateSiteSettings } from "@/lib/api";
import {
AdminPageHeader,
AdminSection,
AdminSettingRow,
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
import {
ACCENT_PRESETS,
@@ -23,13 +31,27 @@ import {
normalizeHex,
} from "@/lib/theme";
// 后台「外观设置」:站点主题色预设 + 自定义取色 + 实时预览。
// 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
export default function AppearanceAdmin({ initialAccent }: { initialAccent: string }) {
// 后台「外观设置」:主题色、代码块折叠与界面动画。
// 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
const router = useRouter();
const initialAccent = initial.accent || "";
// 已保存值:空串=内置默认(靛蓝)
const [savedHex, setSavedHex] = useState(initialAccent || "");
const [savedHex, setSavedHex] = useState(initialAccent);
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
const [foldLines, setFoldLines] = useState(
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
);
const [togglingFold, setTogglingFold] = useState(false);
const [savingFoldLines, setSavingFoldLines] = useState(false);
const [uiAnimations, setUiAnimations] = useState(initial.ui_animations !== false);
const [animCodeFold, setAnimCodeFold] = useState(initial.anim_code_fold !== false);
const [animSmoothScroll, setAnimSmoothScroll] = useState(
initial.anim_smooth_scroll !== false
);
const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false);
const [togglingMotion, setTogglingMotion] = useState(false);
const [saving, setSaving] = useState(false);
const savedRef = useRef(savedHex);
savedRef.current = savedHex;
@@ -76,12 +98,79 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
applyAccentTheme(savedHex || null);
};
const toggleCodeFold = async () => {
if (togglingFold) return;
const next = !codeAutoFold;
setTogglingFold(true);
try {
const res = await apiUpdateSiteSettings({ code_block_auto_fold: next });
setCodeAutoFold(res.code_block_auto_fold);
toast(
res.code_block_auto_fold
? `已开启代码块自动折叠(超过 ${res.code_block_fold_lines} 行)`
: "已关闭代码块自动折叠",
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingFold(false);
}
};
const saveFoldLines = async () => {
if (savingFoldLines) return;
const raw = Math.round(Number(foldLines));
const n = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 5;
setSavingFoldLines(true);
try {
const res = await apiUpdateSiteSettings({ code_block_fold_lines: n });
setFoldLines(res.code_block_fold_lines);
toast(
res.code_block_fold_lines === 0
? "折叠预览已设为 0 行(无预览)"
: `折叠阈值已设为超过 ${res.code_block_fold_lines} 行`,
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingFoldLines(false);
}
};
const toggleMotionField = async (
key: "ui_animations" | "anim_code_fold" | "anim_smooth_scroll" | "anim_chrome",
next: boolean
) => {
if (togglingMotion) return;
setTogglingMotion(true);
try {
const res = await apiUpdateSiteSettings({ [key]: next });
setUiAnimations(res.ui_animations);
setAnimCodeFold(res.anim_code_fold);
setAnimSmoothScroll(res.anim_smooth_scroll);
setAnimChrome(res.anim_chrome);
const labels: Record<typeof key, [string, string]> = {
ui_animations: ["已开启界面动画", "已关闭界面动画(全站减少动态效果)"],
anim_code_fold: ["已开启代码折叠动画", "已关闭代码折叠动画"],
anim_smooth_scroll: ["已开启平滑滚动", "已关闭平滑滚动"],
anim_chrome: ["已开启装饰入场动画", "已关闭装饰入场动画"],
};
toast(next ? labels[key][0] : labels[key][1], "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingMotion(false);
}
};
return (
<div className="max-w-3xl">
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色,全站按钮、链接、选中态会统一联动"
description="配置站点主题色、代码块折叠与界面动画"
/>
<div className="flex flex-col gap-4 mb-6">
@@ -157,15 +246,13 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
</label>
<div className="flex flex-col gap-1">
<div
className="flex items-center rounded-[var(--r-sm)] border bg-[var(--panel)] px-3 h-11 font-mono text-[14px] w-40"
style={{ borderColor: invalid ? "var(--danger)" : "var(--line-2)" }}
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
className="flex-1 min-w-0 bg-transparent outline-none ml-1 uppercase"
style={{ color: "var(--ink)" }}
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
@@ -253,6 +340,131 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
</div>
</div>
</AdminSection>
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</>
}
description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
control={
<AdminSwitch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
}
>
{codeAutoFold ? (
<>
<label className="field-label" htmlFor="fold-lines">
折叠行数阈值
</label>
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<AdminStepper
id="fold-lines"
value={foldLines}
onChange={setFoldLines}
min={0}
max={100}
unit="行"
ariaLabel="折叠行数阈值"
disabled={savingFoldLines}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingFoldLines}
onClick={() => void saveFoldLines()}
>
{savingFoldLines ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
保存
</button>
</div>
</>
) : null}
</AdminSettingRow>
<AdminSettingRow
title={
<>
<Sparkles size={15} /> 界面动画
</>
}
description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
control={
<AdminSwitch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
}
>
{uiAnimations ? (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
代码折叠动画
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
代码块展开/收起的高度过渡与跟滚。
</p>
</div>
<AdminSwitch
checked={animCodeFold}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="代码折叠动画"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
平滑滚动
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
目录跳转、回顶、新内容提示等 scroll 行为。
</p>
</div>
<AdminSwitch
checked={animSmoothScroll}
disabled={togglingMotion}
onChange={() =>
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
}
label="平滑滚动"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
装饰入场
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
</p>
</div>
<AdminSwitch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
</div>
</div>
) : null}
</AdminSettingRow>
</div>
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">

View File

@@ -18,6 +18,6 @@ export default async function AdminAppearancePage() {
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="外观设置仅超级管理员和站长可操作。" />;
}
const { accent } = await getPublicSettingsCached();
return <AppearanceAdmin initialAccent={accent || ""} />;
const settings = await getPublicSettingsCached();
return <AppearanceAdmin initial={settings} />;
}

View File

@@ -1,16 +1,9 @@
"use client";
import {
Code2,
Globe,
Loader2,
MessageSquare,
Save,
Sparkles,
UserPlus,
} from "lucide-react";
import { Globe, Loader2, MessageSquare, Save, UserPlus } from "lucide-react";
import { useState } from "react";
import {
AdminField,
AdminPageHeader,
AdminSection,
AdminSettingRow,
@@ -24,33 +17,13 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
const [siteDescription, setSiteDescription] = useState(initial.site_description);
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
const [codeAutoFold, setCodeAutoFold] = useState(
initial.code_block_auto_fold !== false
);
const [foldLines, setFoldLines] = useState(
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
);
const [savedFoldLines, setSavedFoldLines] = useState(
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
);
const [savedName, setSavedName] = useState(initial.site_name);
const [savedDesc, setSavedDesc] = useState(initial.site_description);
const [saving, setSaving] = useState(false);
const [togglingReg, setTogglingReg] = useState(false);
const [togglingComments, setTogglingComments] = useState(false);
const [togglingFold, setTogglingFold] = useState(false);
const [savingFoldLines, setSavingFoldLines] = useState(false);
const [uiAnimations, setUiAnimations] = useState(initial.ui_animations !== false);
const [animCodeFold, setAnimCodeFold] = useState(initial.anim_code_fold !== false);
const [animSmoothScroll, setAnimSmoothScroll] = useState(
initial.anim_smooth_scroll !== false
);
const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false);
const [togglingMotion, setTogglingMotion] = useState(false);
const dirty =
siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
const foldLinesDirty = foldLines !== savedFoldLines;
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
const saveIdentity = async () => {
if (saving) return;
@@ -107,108 +80,35 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
}
};
const toggleCodeFold = async () => {
if (togglingFold) return;
const next = !codeAutoFold;
setTogglingFold(true);
try {
const res = await apiUpdateSiteSettings({ code_block_auto_fold: next });
setCodeAutoFold(res.code_block_auto_fold);
toast(
res.code_block_auto_fold
? `已开启代码块自动折叠(超过 ${res.code_block_fold_lines} 行)`
: "已关闭代码块自动折叠",
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingFold(false);
}
};
const saveFoldLines = async () => {
if (savingFoldLines) return;
const raw = Math.round(Number(foldLines));
const n = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 5;
setSavingFoldLines(true);
try {
const res = await apiUpdateSiteSettings({ code_block_fold_lines: n });
setFoldLines(res.code_block_fold_lines);
setSavedFoldLines(res.code_block_fold_lines);
toast(
res.code_block_fold_lines === 0
? "折叠预览已设为 0 行(无预览)"
: `折叠阈值已设为超过 ${res.code_block_fold_lines} 行`,
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingFoldLines(false);
}
};
const toggleMotionField = async (
key: "ui_animations" | "anim_code_fold" | "anim_smooth_scroll" | "anim_chrome",
next: boolean
) => {
if (togglingMotion) return;
setTogglingMotion(true);
try {
const res = await apiUpdateSiteSettings({ [key]: next });
setUiAnimations(res.ui_animations);
setAnimCodeFold(res.anim_code_fold);
setAnimSmoothScroll(res.anim_smooth_scroll);
setAnimChrome(res.anim_chrome);
const labels: Record<typeof key, [string, string]> = {
ui_animations: ["已开启界面动画", "已关闭界面动画(全站减少动态效果)"],
anim_code_fold: ["已开启代码折叠动画", "已关闭代码折叠动画"],
anim_smooth_scroll: ["已开启平滑滚动", "已关闭平滑滚动"],
anim_chrome: ["已开启装饰入场动画", "已关闭装饰入场动画"],
};
toast(next ? labels[key][0] : labels[key][1], "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingMotion(false);
}
};
return (
<div className="max-w-3xl">
<AdminPageHeader
icon={Globe}
title="站点设置"
description="配置站点名称、简介与注册/评论开关;主题色请到「外观」页调整"
description="配置站点名称、简介与注册/评论开关;主题色、代码折叠与动画请到「外观」页调整"
/>
<div className="flex flex-col gap-4">
<AdminSection
title="基本信息"
description="用于页眉品牌、页脚版权与 SEO 元信息"
>
<label className="field-label" htmlFor="site-name">
站点名称
</label>
<input
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
<AdminField
id="site-name"
className="field mb-4"
className="mb-4"
label="站点名称"
maxLength={32}
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
onChange={setSiteName}
placeholder="姜十三论坛"
/>
<label className="field-label" htmlFor="site-desc">
站点简介
</label>
<textarea
<AdminField
id="site-desc"
className="field min-h-[96px] mb-4"
className="mb-4"
label="站点简介"
as="textarea"
minHeight="96px"
maxLength={160}
value={siteDescription}
onChange={(e) => setSiteDescription(e.target.value)}
onChange={setSiteDescription}
placeholder="一句话介绍社区定位…"
/>
@@ -248,7 +148,7 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
<MessageSquare size={15} /> 开放评论
</>
}
description="关闭后全站禁止发表新评论与回复,已有评论仍可浏览。适用于 ICP/IPC 备案审核等合规需要;前台仅提示「暂停评论」,不会展示备案事由。"
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。适用于备案审核等合规需要。"
control={
<AdminSwitch
checked={allowComments}
@@ -258,132 +158,6 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
/>
}
/>
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</>
}
description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
control={
<AdminSwitch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
}
>
{codeAutoFold ? (
<>
<label className="field-label" htmlFor="fold-lines">
折叠行数阈值
</label>
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
id="fold-lines"
type="number"
min={0}
max={100}
className="field w-24"
value={foldLines}
onChange={(e) => setFoldLines(Number(e.target.value))}
aria-label="折叠行数阈值"
/>
<span className="meta text-[12.5px]">行</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={!foldLinesDirty || savingFoldLines}
onClick={() => void saveFoldLines()}
>
{savingFoldLines ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
保存
</button>
</div>
</>
) : null}
</AdminSettingRow>
<AdminSettingRow
title={
<>
<Sparkles size={15} /> 界面动画
</>
}
description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
control={
<AdminSwitch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
}
>
{uiAnimations ? (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
代码折叠动画
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
代码块展开/收起的高度过渡与跟滚。
</p>
</div>
<AdminSwitch
checked={animCodeFold}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="代码折叠动画"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
平滑滚动
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
目录跳转、回顶、新内容提示等 scroll 行为。
</p>
</div>
<AdminSwitch
checked={animSmoothScroll}
disabled={togglingMotion}
onChange={() =>
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
}
label="平滑滚动"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
装饰入场
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
</p>
</div>
<AdminSwitch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
</div>
</div>
) : null}
</AdminSettingRow>
</div>
</div>
);

View File

@@ -1512,6 +1512,47 @@ h1, h2, h3, h4 {
.j13-chat-composer-input.has-value::placeholder {
color: transparent;
}
/* 后台输入:与聊天搜索同一套壳层聚焦(未分层,才能压过全局 :focus-visible 双线) */
.admin-field-shell,
.admin-stepper {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 12px;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.admin-field-shell:hover:not(:focus-within),
.admin-stepper:hover:not(:focus-within) {
border-color: color-mix(in srgb, var(--line-2) 40%, var(--line));
}
.admin-field-shell:focus-within,
.admin-stepper:focus-within {
border-color: var(--accent) !important;
box-shadow: 0 0 0 3px var(--accent-soft);
background: var(--panel);
}
.admin-field-input,
.admin-field-input:focus,
.admin-field-input:focus-visible,
.admin-stepper-input,
.admin-stepper-input:focus,
.admin-stepper-input:focus-visible {
outline: none !important;
box-shadow: none !important;
border: 0 !important;
background: transparent !important;
}
.admin-stepper-btn:focus,
.admin-stepper-btn:focus-visible {
outline: none !important;
box-shadow: none !important;
}
.admin-field-shell.is-invalid {
border-color: var(--danger) !important;
}
.admin-field-shell.is-invalid:focus-within {
box-shadow: 0 0 0 3px var(--danger-soft);
}
.j13-chat-composer-mirror {
position: absolute;
inset: 0;
@@ -1968,6 +2009,116 @@ a.j13-mention-token:hover {
}
}
.admin-field-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.admin-field-head .field-label {
margin-bottom: 0;
}
.admin-field-count {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ink-3);
flex-shrink: 0;
}
.admin-field-shell {
display: flex;
align-items: center;
width: 100%;
}
.admin-field-shell.is-area {
display: block;
}
.admin-field-input {
width: 100%;
min-width: 0;
padding: 10px 12px;
color: var(--ink);
font-size: 14px;
line-height: 1.5;
font-family: var(--font-sans);
}
.admin-field-input::placeholder {
color: var(--ink-3);
}
.admin-field-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
textarea.admin-field-input {
display: block;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.7;
resize: vertical;
}
.admin-search-shell {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding: 0 0.75rem;
}
.admin-search-shell .admin-field-input {
padding: 0;
height: 100%;
font-size: 13px;
}
.admin-stepper {
display: inline-flex;
align-items: stretch;
flex-shrink: 0;
height: 2.5rem;
overflow: hidden;
}
.admin-stepper.is-disabled {
opacity: 0.6;
}
.admin-stepper-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
border: none;
padding: 0;
background: transparent;
color: var(--ink-2);
cursor: pointer;
}
.admin-stepper-btn:hover:not(:disabled) {
background: var(--tint);
color: var(--ink);
}
.admin-stepper-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.admin-stepper-input {
width: 3.25rem;
min-width: 0;
padding: 0;
margin: 0;
text-align: center;
color: var(--ink);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-family: var(--font-sans);
}
.admin-stepper-unit {
display: inline-flex;
align-items: center;
padding-right: 0.25rem;
font-size: 12.5px;
color: var(--ink-3);
user-select: none;
}
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
.j13-like {
border-color: var(--line-2);
@@ -2593,7 +2744,7 @@ a.j13-mention-token:hover {
.j13-pulse-cell:nth-child(odd) {
border-right: 1px solid var(--line);
}
.j13-pulse-cell:nth-child(-n + 2) {
.j13-pulse-cell:nth-child(-n + 2):nth-last-child(n + 3) {
border-bottom: 1px solid var(--line);
}
.j13-pulse-lab {

View File

@@ -11,6 +11,7 @@ import ThemeSync from "@/components/ThemeSync";
import RealtimeProvider from "@/components/RealtimeProvider";
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
import MotionProvider from "@/components/MotionProvider";
import CommentsPolicyProvider from "@/components/CommentsPolicyProvider";
import type { User } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
@@ -126,12 +127,14 @@ export default async function RootLayout({
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-9 outline-none"
>
<MotionProvider initial={motionCfg}>
<CodeFoldProvider
initialEnabled={settings.code_block_auto_fold}
initialFoldLines={settings.code_block_fold_lines}
>
{children}
</CodeFoldProvider>
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
<CodeFoldProvider
initialEnabled={settings.code_block_auto_fold}
initialFoldLines={settings.code_block_fold_lines}
>
{children}
</CodeFoldProvider>
</CommentsPolicyProvider>
</MotionProvider>
</main>
<Footer siteName={settings.site_name} />

View File

@@ -6,6 +6,7 @@ import Link from "next/link";
import { MessagesSquare } from "lucide-react";
import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
function safeRedirect(to: string | null): string {
@@ -40,6 +41,7 @@ function RegisterHint() {
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const allowComments = useAllowComments();
const redirect = safeRedirect(searchParams.get("redirect"));
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -91,7 +93,7 @@ export default function LoginPage() {
欢迎回来
</h1>
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
登录后发帖、评论与点赞
登录后发帖{allowComments ? "、评论" : ""}与点赞
</p>
</div>

View File

@@ -10,7 +10,7 @@ import {
type BoardSidebarResponse,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs";
@@ -32,8 +32,11 @@ interface HomePageProps {
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
}
function normalizeSort(v?: string): FeedSort {
if (v === "new" || v === "recommended") return v;
function normalizeSort(v: string | undefined, allowComments: boolean): FeedSort {
if (v === "recommended") return "recommended";
if (v === "new") return "new";
if (!allowComments) return "new";
if (v === "latest") return "latest";
return "latest";
}
@@ -75,11 +78,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const sp = await searchParams;
const keyword = sp.q?.trim() || "";
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const sort = normalizeSort(sp.sort);
const boardId = Math.max(0, parseInt(sp.board || "0", 10) || 0);
const isSearch = keyword.length > 0;
const cookie = authCookieHeader(await cookies());
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
const sort = normalizeSort(sp.sort, allowComments);
// 与 layout 共享同一份 /me 请求(React cache 去重)
const mePromise = getMeCached(cookie || undefined);
const postsPromise = fetchPosts(
@@ -216,6 +220,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
hot={boardSidebar.hot}
activeUsers={boardSidebar.active_users}
checkin={boardSidebar.checkin ?? undefined}
allowComments={allowComments}
/>
) : boardPanelsMissing ? (
<BoardMissingPanel />
@@ -229,6 +234,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
announcements={overview.announcements}
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
allowComments={allowComments}
/>
)
)}
@@ -245,6 +251,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
hot={boardSidebar.hot}
activeUsers={boardSidebar.active_users}
checkin={boardSidebar.checkin ?? undefined}
allowComments={allowComments}
/>
</div>
) : null
@@ -258,6 +265,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
announcements={overview.announcements}
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
allowComments={allowComments}
/>
</div>
)

View File

@@ -22,6 +22,7 @@ import {
type UserProfile,
type PostListItem,
type Board,
type CommentsResponse,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
@@ -152,13 +153,19 @@ function BoardCard({ board }: { board: Board }) {
);
}
function AuthorRailCard({ profile }: { profile: UserProfile }) {
function AuthorRailCard({
profile,
allowComments,
}: {
profile: UserProfile;
allowComments: boolean;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats;
const stats = [
{ label: "发帖", value: post_count },
{ label: "评论", value: comment_count },
...(allowComments ? [{ label: "评论", value: comment_count }] : []),
{ label: "积分", value: points },
];
return (
@@ -197,7 +204,7 @@ function AuthorRailCard({ profile }: { profile: UserProfile }) {
</p>
) : null}
<div
className="mt-3 grid grid-cols-3 border-t"
className={`mt-3 grid border-t ${stats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
style={{ borderColor: "var(--line)" }}
>
{stats.map((s, i) => (
@@ -271,11 +278,13 @@ function RelatedPosts({
posts,
boardId,
uid = "related",
allowComments,
}: {
posts: PostListItem[];
boardId: number;
// 右栏与移动端内联实例同时存在,id 必须区分以免 aria-labelledby 指向隐藏副本
uid?: string;
allowComments: boolean;
}) {
if (posts.length === 0) return null;
const titleId = `${uid}-title`;
@@ -294,9 +303,11 @@ function RelatedPosts({
{p.title}
</span>
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
<span className="inline-flex items-center gap-1">
<MessageCircle size={11.5} /> {p.comment_count}
</span>
{allowComments ? (
<span className="inline-flex items-center gap-1">
<MessageCircle size={11.5} /> {p.comment_count}
</span>
) : null}
<span className="inline-flex items-center gap-1">
<Heart size={11.5} /> {p.like_count}
</span>
@@ -318,7 +329,13 @@ function RelatedPosts({
}
/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */
function AuthorCardStacked({ profile }: { profile: UserProfile }) {
function AuthorCardStacked({
profile,
allowComments,
}: {
profile: UserProfile;
allowComments: boolean;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats;
@@ -349,8 +366,12 @@ function AuthorCardStacked({ profile }: { profile: UserProfile }) {
<span>@{u.username}</span>
<span aria-hidden>·</span>
<span>发帖 {post_count}</span>
<span aria-hidden>·</span>
<span>评论 {comment_count}</span>
{allowComments ? (
<>
<span aria-hidden>·</span>
<span>评论 {comment_count}</span>
</>
) : null}
<span aria-hidden>·</span>
<span>积分 {points}</span>
{streak > 0 && (
@@ -441,11 +462,18 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
);
}
// 评论 / 当前用户 / 站点设置 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
const [commentsData, me, siteSettings, profile, relatedData] = await Promise.all([
fetchComments(id, commentPage, cookie),
// 站点设置已在 layout 缓存;关评时不拉评论树,避免首屏 HTML 泄露历史评论
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
const emptyComments: CommentsResponse = {
comments: [],
total: 0,
total_comments: 0,
page: 1,
size: 20,
};
const [commentsData, me, profile, relatedData] = await Promise.all([
allowComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
getMeCached(cookie || undefined),
getPublicSettingsCached(),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
]);
@@ -504,13 +532,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
datePublished: post.created_at,
dateModified: post.updated_at,
author: { "@type": "Person", name: post.user.nickname },
commentCount: post.comment_count,
...(allowComments ? { commentCount: post.comment_count } : {}),
interactionStatistic: {
"@type": "InteractionCounter",
interactionType: { "@type": "ReadAction" },
userInteractionCount: post.view_count,
},
...(acceptedAnswer &&
...(allowComments &&
acceptedAnswer &&
!acceptedAnswer.deleted &&
!post.content_locked && {
acceptedAnswer: {
@@ -546,7 +575,12 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* 移动端浮动操作条:长文阅读中途可点赞 / 跳评论 / 回顶部 */}
<MobilePostBar postId={id} liked={post.liked} likeCount={post.like_count} />
<MobilePostBar
postId={id}
liked={post.liked}
likeCount={post.like_count}
allowComments={allowComments}
/>
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
@@ -558,7 +592,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
aria-label="帖子导航"
>
<BoardCard board={post.board} />
{profile && <AuthorRailCard profile={profile} />}
{profile && <AuthorRailCard profile={profile} allowComments={allowComments} />}
{profile && <AuthorPosts posts={authorPosts} />}
</aside>
@@ -656,9 +690,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<span className="inline-flex items-center gap-1.5" aria-label={`浏览 ${post.view_count} 次`}>
<IconEye size={14} /> {post.view_count} 浏览
</span>
<span className="inline-flex items-center gap-1.5" aria-label={`评论 ${post.comment_count} 条`}>
<IconComment size={14} /> {post.comment_count} 评论
</span>
{allowComments ? (
<span className="inline-flex items-center gap-1.5" aria-label={`评论 ${post.comment_count} 条`}>
<IconComment size={14} /> {post.comment_count} 评论
</span>
) : null}
<span className="inline-flex items-center gap-1.5" aria-label={`阅读约需 ${readingMinutes} 分钟`}>
<Clock size={14} /> 约 {readingMinutes} 分钟读完
</span>
@@ -707,7 +743,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
lottery={post.lottery}
loggedIn={!!me.user}
/>
{!post.content_locked && acceptedAnswer ? (
{!post.content_locked && allowComments && acceptedAnswer ? (
<AcceptedAnswerCard
answer={acceptedAnswer}
variant={acceptedAnswerVariant}
@@ -738,11 +774,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
{profile && (
<div className="lg:hidden mt-6">
<AuthorCardStacked profile={profile} />
<AuthorCardStacked profile={profile} allowComments={allowComments} />
</div>
)}
{/* 非公开帖不开放评论(后端同样拒绝),给出状态提示 */}
{/* 关评:前台仅提示不支持评论,不展示历史楼层;非公开帖不提及评论 */}
{isPublished ? (
<CommentSection
postId={id}
@@ -754,7 +790,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
page={commentsData.page}
size={commentsData.size}
user={me.user}
allowComments={siteSettings.allow_comments}
acceptMode={acceptMode}
acceptedCommentId={
post.question?.accepted_comment_id ||
@@ -765,11 +800,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
post.lottery?.drawn
? (post.lottery.winners || [])
.map((w) => w.comment_id || 0)
.filter((id) => id > 0)
.filter((cid) => cid > 0)
: []
}
/>
) : (
) : allowComments ? (
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
{post.status === "pending" ? "帖子审核通过后开放评论" : "帖子未通过审核,评论已关闭"}
@@ -782,11 +817,16 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
: "审核通过后评论区将自动开放"}
</p>
</section>
)}
) : null}
{/* 窄屏相关讨论(lg 起由右栏承载) */}
<div className="lg:hidden mt-6">
<RelatedPosts posts={related} boardId={post.board_id} uid="related-inline" />
<RelatedPosts
posts={related}
boardId={post.board_id}
uid="related-inline"
allowComments={allowComments}
/>
</div>
</article>
</div>
@@ -797,7 +837,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
aria-label="阅读辅助"
>
{toc.length > 0 && <PostToc items={toc} />}
<RelatedPosts posts={related} boardId={post.board_id} />
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
</aside>
</div>

View File

@@ -16,6 +16,7 @@ export default function UserProfileContent({
commentData,
commentError,
page,
allowComments = true,
}: {
userId: string;
profile: UserProfile;
@@ -23,6 +24,7 @@ export default function UserProfileContent({
commentData: UserCommentsResponse | null;
commentError: boolean;
page: number;
allowComments?: boolean;
}) {
return (
<div>
@@ -50,9 +52,11 @@ export default function UserProfileContent({
</h2>
<div className="flex items-center gap-4 meta text-[12px]">
<span className="tabular-nums">{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
<span className="inline-flex items-center gap-1">
<IconComment size={12} /> {post.comment_count}
</span>
{allowComments ? (
<span className="inline-flex items-center gap-1">
<IconComment size={12} /> {post.comment_count}
</span>
) : null}
<span className="inline-flex items-center gap-1">
<IconEye size={12} /> {post.view_count}
</span>

View File

@@ -7,7 +7,7 @@ import {
} from "lucide-react";
import { fetchUserProfile, fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import UserProfileContent from "./UserProfileContent";
import ProfileForm from "./ProfileForm";
import PasswordForm from "./PasswordForm";
@@ -28,9 +28,12 @@ export async function generateMetadata({ params }: UserPageProps): Promise<Metad
const { id } = await params;
try {
const data = await fetchUserProfile(id, 1);
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
return {
title: data.user.nickname || data.user.username,
description: `查看 ${data.user.nickname} 的资料、发帖与评论`,
description: allowComments
? `查看 ${data.user.nickname} 的资料、发帖与评论`
: `查看 ${data.user.nickname} 的资料与发帖`,
};
} catch {
return { title: "用户不存在" };
@@ -53,6 +56,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
let data: UserProfile;
try {
@@ -75,13 +79,14 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
// 判断是否本人(决定能否看到 settings / security tab)
const isOwner = me.user?.id === data.user.id;
const currentUser = me.user;
const effectiveTab: UserTab =
let effectiveTab: UserTab =
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
if (!allowComments && effectiveTab === "comments") effectiveTab = "posts";
// 评论 tab:SSR 直出对应分页
let commentData: UserCommentsResponse | null = null;
let commentError = false;
if (effectiveTab === "comments") {
if (allowComments && effectiveTab === "comments") {
try {
commentData = await fetchUserComments(id, page);
} catch {
@@ -96,7 +101,9 @@ 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} /> },
{ key: "comments", label: "评论", count: data.stats.comment_count, icon: <MessageCircle size={15} /> },
...(allowComments
? [{ key: "comments" as const, label: "评论", count: data.stats.comment_count, icon: <MessageCircle size={15} /> }]
: []),
{ key: "settings", label: "资料设置", icon: <Settings size={15} />, ownerOnly: true },
{ key: "security", label: "安全设置", icon: <KeyRound size={15} />, ownerOnly: true },
];
@@ -106,7 +113,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
// 统计条数据
const heroStats = [
{ label: "发帖", value: data.stats.post_count },
{ label: "评论", value: data.stats.comment_count },
...(allowComments ? [{ label: "评论", value: data.stats.comment_count }] : []),
{ label: "积分", value: data.stats.points, foot: data.stats.streak > 0 ? `连签 ${data.stats.streak} 天` : undefined },
];
@@ -227,7 +234,10 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
</div>
{/* 统计条 */}
<div className="relative z-10 grid grid-cols-3 mt-4 border-t" style={{ borderColor: "var(--line)" }}>
<div
className={`relative z-10 grid mt-4 border-t ${heroStats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
style={{ borderColor: "var(--line)" }}
>
{heroStats.map((s, i) => (
<div
key={s.label}
@@ -315,6 +325,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
commentData={commentData}
commentError={commentError}
page={page}
allowComments={allowComments}
/>
) : effectiveTab === "settings" && isOwner && currentUser ? (
<div>
@@ -423,7 +434,11 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
</li>
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
<span>昵称和头像会公开展示在帖子、评论和个人主页。</span>
<span>
{allowComments
? "昵称和头像会公开展示在帖子、评论和个人主页。"
: "昵称和头像会公开展示在帖子和个人主页。"}
</span>
</li>
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>