feat: 全站评论开关,并统一后台表单控件
关闭评论后前台隐藏评论能力;外观/设置/公告改用 AdminField 与 Stepper。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,6 +36,21 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if allow, err := h.Setting.AllowComments(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||||||
|
return
|
||||||
|
} else if !allow {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"comments": []any{},
|
||||||
|
"total": 0,
|
||||||
|
"total_comments": 0,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boardID, _ := h.Post.GetBoardID(uint(id))
|
boardID, _ := h.Post.GetBoardID(uint(id))
|
||||||
|
|
||||||
comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), boardID, viewerID, actor, page, size)
|
comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), boardID, viewerID, actor, page, size)
|
||||||
@@ -70,7 +85,7 @@ func (h *Handlers) CreateComment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||||||
return
|
return
|
||||||
} else if !allow {
|
} else if !allow {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "站点已关闭评论"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "本站不支持评论"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req CreateCommentRequest
|
var req CreateCommentRequest
|
||||||
|
|||||||
@@ -151,6 +151,21 @@ func (h *Handlers) UserComments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if allow, err := h.Setting.AllowComments(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||||||
|
return
|
||||||
|
} else if !allow {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"comments": []any{},
|
||||||
|
"total": 0,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { toast } from "@/lib/toast";
|
|||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import {
|
import {
|
||||||
AdminEmpty,
|
AdminEmpty,
|
||||||
|
AdminField,
|
||||||
AdminIconButton,
|
AdminIconButton,
|
||||||
AdminPageHeader,
|
AdminPageHeader,
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
@@ -155,29 +156,24 @@ export default function AnnouncementAdmin({
|
|||||||
{form.id == null ? "新建公告" : "编辑公告"}
|
{form.id == null ? "新建公告" : "编辑公告"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="field-label" htmlFor="anno-title">
|
<AdminField
|
||||||
标题
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="anno-title"
|
id="anno-title"
|
||||||
className="field mb-4"
|
className="mb-4"
|
||||||
|
label="标题"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
onChange={(title) => setForm({ ...form, title })}
|
||||||
placeholder="请输入公告标题"
|
placeholder="请输入公告标题"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-end gap-4 mb-4 flex-wrap">
|
<div className="flex items-end gap-4 mb-4 flex-wrap">
|
||||||
<div className="w-36">
|
<div className="w-36">
|
||||||
<label className="field-label" htmlFor="anno-tag">
|
<AdminField
|
||||||
标签
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="anno-tag"
|
id="anno-tag"
|
||||||
className="field"
|
label="标签"
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
value={form.tag}
|
value={form.tag}
|
||||||
onChange={(e) => setForm({ ...form, tag: e.target.value })}
|
onChange={(tag) => setForm({ ...form, tag })}
|
||||||
placeholder="公告"
|
placeholder="公告"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,14 +209,13 @@ export default function AnnouncementAdmin({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="field-label" htmlFor="anno-content">
|
<AdminField
|
||||||
正文(支持 Markdown)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="anno-content"
|
id="anno-content"
|
||||||
className="field min-h-[260px]"
|
label="正文(支持 Markdown)"
|
||||||
|
as="textarea"
|
||||||
|
minHeight="260px"
|
||||||
value={form.content}
|
value={form.content}
|
||||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
onChange={(content) => setForm({ ...form, content })}
|
||||||
placeholder="请输入公告正文,支持 Markdown 语法…"
|
placeholder="请输入公告正文,支持 Markdown 语法…"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ import { useRouter } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
Palette,
|
Palette,
|
||||||
Check,
|
Check,
|
||||||
|
Code2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Save,
|
Save,
|
||||||
Undo2,
|
Undo2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Pipette,
|
Pipette,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AdminPageHeader, AdminSection } from "@/components/admin";
|
import {
|
||||||
import { apiUpdateSiteSettings } from "@/lib/api";
|
AdminPageHeader,
|
||||||
|
AdminSection,
|
||||||
|
AdminSettingRow,
|
||||||
|
AdminStepper,
|
||||||
|
AdminSwitch,
|
||||||
|
} from "@/components/admin";
|
||||||
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import {
|
import {
|
||||||
ACCENT_PRESETS,
|
ACCENT_PRESETS,
|
||||||
@@ -23,13 +31,27 @@ import {
|
|||||||
normalizeHex,
|
normalizeHex,
|
||||||
} from "@/lib/theme";
|
} from "@/lib/theme";
|
||||||
|
|
||||||
// 后台「外观设置」:站点主题色预设 + 自定义取色 + 实时预览。
|
// 后台「外观设置」:主题色、代码块折叠与界面动画。
|
||||||
// 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
|
// 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
|
||||||
export default function AppearanceAdmin({ initialAccent }: { initialAccent: string }) {
|
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
|
||||||
const router = useRouter();
|
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 [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 [saving, setSaving] = useState(false);
|
||||||
const savedRef = useRef(savedHex);
|
const savedRef = useRef(savedHex);
|
||||||
savedRef.current = savedHex;
|
savedRef.current = savedHex;
|
||||||
@@ -76,12 +98,79 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
|
|||||||
applyAccentTheme(savedHex || null);
|
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 (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={Palette}
|
icon={Palette}
|
||||||
title="外观设置"
|
title="外观设置"
|
||||||
description="配置站点主题色,全站按钮、链接、选中态会统一联动"
|
description="配置站点主题色、代码块折叠与界面动画"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mb-6">
|
<div className="flex flex-col gap-4 mb-6">
|
||||||
@@ -157,15 +246,13 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
|
|||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
className="flex items-center rounded-[var(--r-sm)] border bg-[var(--panel)] px-3 h-11 font-mono text-[14px] w-40"
|
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
|
||||||
style={{ borderColor: invalid ? "var(--danger)" : "var(--line-2)" }}
|
|
||||||
>
|
>
|
||||||
<span style={{ color: "var(--ink-3)" }}>#</span>
|
<span style={{ color: "var(--ink-3)" }}>#</span>
|
||||||
<input
|
<input
|
||||||
value={draft.startsWith("#") ? draft.slice(1) : draft}
|
value={draft.startsWith("#") ? draft.slice(1) : draft}
|
||||||
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
|
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"
|
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
maxLength={7}
|
maxLength={7}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
aria-label="主题色 HEX 值"
|
aria-label="主题色 HEX 值"
|
||||||
@@ -253,6 +340,131 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AdminSection>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
|
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ export default async function AdminAppearancePage() {
|
|||||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||||
return <Forbidden text="外观设置仅超级管理员和站长可操作。" />;
|
return <Forbidden text="外观设置仅超级管理员和站长可操作。" />;
|
||||||
}
|
}
|
||||||
const { accent } = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
return <AppearanceAdmin initialAccent={accent || ""} />;
|
return <AppearanceAdmin initial={settings} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Globe, Loader2, MessageSquare, Save, UserPlus } from "lucide-react";
|
||||||
Code2,
|
|
||||||
Globe,
|
|
||||||
Loader2,
|
|
||||||
MessageSquare,
|
|
||||||
Save,
|
|
||||||
Sparkles,
|
|
||||||
UserPlus,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
AdminField,
|
||||||
AdminPageHeader,
|
AdminPageHeader,
|
||||||
AdminSection,
|
AdminSection,
|
||||||
AdminSettingRow,
|
AdminSettingRow,
|
||||||
@@ -24,33 +17,13 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
const [siteDescription, setSiteDescription] = useState(initial.site_description);
|
const [siteDescription, setSiteDescription] = useState(initial.site_description);
|
||||||
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
||||||
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
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 [savedName, setSavedName] = useState(initial.site_name);
|
||||||
const [savedDesc, setSavedDesc] = useState(initial.site_description);
|
const [savedDesc, setSavedDesc] = useState(initial.site_description);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [togglingReg, setTogglingReg] = useState(false);
|
const [togglingReg, setTogglingReg] = useState(false);
|
||||||
const [togglingComments, setTogglingComments] = 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 =
|
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
|
||||||
siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
|
|
||||||
const foldLinesDirty = foldLines !== savedFoldLines;
|
|
||||||
|
|
||||||
const saveIdentity = async () => {
|
const saveIdentity = async () => {
|
||||||
if (saving) return;
|
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 (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title="站点设置"
|
title="站点设置"
|
||||||
description="配置站点名称、简介与注册/评论开关;主题色请到「外观」页调整"
|
description="配置站点名称、简介与注册/评论开关;主题色、代码折叠与动画请到「外观」页调整"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<AdminSection
|
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
|
||||||
title="基本信息"
|
<AdminField
|
||||||
description="用于页眉品牌、页脚版权与 SEO 元信息"
|
|
||||||
>
|
|
||||||
<label className="field-label" htmlFor="site-name">
|
|
||||||
站点名称
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="site-name"
|
id="site-name"
|
||||||
className="field mb-4"
|
className="mb-4"
|
||||||
|
label="站点名称"
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
value={siteName}
|
value={siteName}
|
||||||
onChange={(e) => setSiteName(e.target.value)}
|
onChange={setSiteName}
|
||||||
placeholder="姜十三论坛"
|
placeholder="姜十三论坛"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="field-label" htmlFor="site-desc">
|
<AdminField
|
||||||
站点简介
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="site-desc"
|
id="site-desc"
|
||||||
className="field min-h-[96px] mb-4"
|
className="mb-4"
|
||||||
|
label="站点简介"
|
||||||
|
as="textarea"
|
||||||
|
minHeight="96px"
|
||||||
maxLength={160}
|
maxLength={160}
|
||||||
value={siteDescription}
|
value={siteDescription}
|
||||||
onChange={(e) => setSiteDescription(e.target.value)}
|
onChange={setSiteDescription}
|
||||||
placeholder="一句话介绍社区定位…"
|
placeholder="一句话介绍社区定位…"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -248,7 +148,7 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
<MessageSquare size={15} /> 开放评论
|
<MessageSquare size={15} /> 开放评论
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
description="关闭后全站禁止发表新评论与回复,已有评论仍可浏览。适用于 ICP/IPC 备案审核等合规需要;前台仅提示「暂停评论」,不会展示备案事由。"
|
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。适用于备案审核等合规需要。"
|
||||||
control={
|
control={
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
checked={allowComments}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1512,6 +1512,47 @@ h1, h2, h3, h4 {
|
|||||||
.j13-chat-composer-input.has-value::placeholder {
|
.j13-chat-composer-input.has-value::placeholder {
|
||||||
color: transparent;
|
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 {
|
.j13-chat-composer-mirror {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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 同色系填充;已赞为浅底实心态 ----- */
|
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
|
||||||
.j13-like {
|
.j13-like {
|
||||||
border-color: var(--line-2);
|
border-color: var(--line-2);
|
||||||
@@ -2593,7 +2744,7 @@ a.j13-mention-token:hover {
|
|||||||
.j13-pulse-cell:nth-child(odd) {
|
.j13-pulse-cell:nth-child(odd) {
|
||||||
border-right: 1px solid var(--line);
|
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);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.j13-pulse-lab {
|
.j13-pulse-lab {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import ThemeSync from "@/components/ThemeSync";
|
|||||||
import RealtimeProvider from "@/components/RealtimeProvider";
|
import RealtimeProvider from "@/components/RealtimeProvider";
|
||||||
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
|
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
|
||||||
import MotionProvider from "@/components/MotionProvider";
|
import MotionProvider from "@/components/MotionProvider";
|
||||||
|
import CommentsPolicyProvider from "@/components/CommentsPolicyProvider";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
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"
|
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}>
|
<MotionProvider initial={motionCfg}>
|
||||||
|
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
|
||||||
<CodeFoldProvider
|
<CodeFoldProvider
|
||||||
initialEnabled={settings.code_block_auto_fold}
|
initialEnabled={settings.code_block_auto_fold}
|
||||||
initialFoldLines={settings.code_block_fold_lines}
|
initialFoldLines={settings.code_block_fold_lines}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</CodeFoldProvider>
|
</CodeFoldProvider>
|
||||||
|
</CommentsPolicyProvider>
|
||||||
</MotionProvider>
|
</MotionProvider>
|
||||||
</main>
|
</main>
|
||||||
<Footer siteName={settings.site_name} />
|
<Footer siteName={settings.site_name} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
|||||||
import { MessagesSquare } from "lucide-react";
|
import { MessagesSquare } from "lucide-react";
|
||||||
import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
|
import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
|
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||||
|
|
||||||
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
|
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
|
||||||
function safeRedirect(to: string | null): string {
|
function safeRedirect(to: string | null): string {
|
||||||
@@ -40,6 +41,7 @@ function RegisterHint() {
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const allowComments = useAllowComments();
|
||||||
const redirect = safeRedirect(searchParams.get("redirect"));
|
const redirect = safeRedirect(searchParams.get("redirect"));
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -91,7 +93,7 @@ export default function LoginPage() {
|
|||||||
欢迎回来
|
欢迎回来
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
|
||||||
登录后发帖、评论与点赞
|
登录后发帖{allowComments ? "、评论" : ""}与点赞
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type BoardSidebarResponse,
|
type BoardSidebarResponse,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import PostRow from "@/components/PostRow";
|
import PostRow from "@/components/PostRow";
|
||||||
import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs";
|
import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs";
|
||||||
@@ -32,8 +32,11 @@ interface HomePageProps {
|
|||||||
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
|
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSort(v?: string): FeedSort {
|
function normalizeSort(v: string | undefined, allowComments: boolean): FeedSort {
|
||||||
if (v === "new" || v === "recommended") return v;
|
if (v === "recommended") return "recommended";
|
||||||
|
if (v === "new") return "new";
|
||||||
|
if (!allowComments) return "new";
|
||||||
|
if (v === "latest") return "latest";
|
||||||
return "latest";
|
return "latest";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +78,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const keyword = sp.q?.trim() || "";
|
const keyword = sp.q?.trim() || "";
|
||||||
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
|
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 boardId = Math.max(0, parseInt(sp.board || "0", 10) || 0);
|
||||||
const isSearch = keyword.length > 0;
|
const isSearch = keyword.length > 0;
|
||||||
|
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
||||||
|
const sort = normalizeSort(sp.sort, allowComments);
|
||||||
// 与 layout 共享同一份 /me 请求(React cache 去重)
|
// 与 layout 共享同一份 /me 请求(React cache 去重)
|
||||||
const mePromise = getMeCached(cookie || undefined);
|
const mePromise = getMeCached(cookie || undefined);
|
||||||
const postsPromise = fetchPosts(
|
const postsPromise = fetchPosts(
|
||||||
@@ -216,6 +220,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
hot={boardSidebar.hot}
|
hot={boardSidebar.hot}
|
||||||
activeUsers={boardSidebar.active_users}
|
activeUsers={boardSidebar.active_users}
|
||||||
checkin={boardSidebar.checkin ?? undefined}
|
checkin={boardSidebar.checkin ?? undefined}
|
||||||
|
allowComments={allowComments}
|
||||||
/>
|
/>
|
||||||
) : boardPanelsMissing ? (
|
) : boardPanelsMissing ? (
|
||||||
<BoardMissingPanel />
|
<BoardMissingPanel />
|
||||||
@@ -229,6 +234,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
announcements={overview.announcements}
|
announcements={overview.announcements}
|
||||||
newUsers={overview.new_users}
|
newUsers={overview.new_users}
|
||||||
checkin={overview.checkin ?? undefined}
|
checkin={overview.checkin ?? undefined}
|
||||||
|
allowComments={allowComments}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -245,6 +251,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
hot={boardSidebar.hot}
|
hot={boardSidebar.hot}
|
||||||
activeUsers={boardSidebar.active_users}
|
activeUsers={boardSidebar.active_users}
|
||||||
checkin={boardSidebar.checkin ?? undefined}
|
checkin={boardSidebar.checkin ?? undefined}
|
||||||
|
allowComments={allowComments}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
@@ -258,6 +265,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
announcements={overview.announcements}
|
announcements={overview.announcements}
|
||||||
newUsers={overview.new_users}
|
newUsers={overview.new_users}
|
||||||
checkin={overview.checkin ?? undefined}
|
checkin={overview.checkin ?? undefined}
|
||||||
|
allowComments={allowComments}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type UserProfile,
|
type UserProfile,
|
||||||
type PostListItem,
|
type PostListItem,
|
||||||
type Board,
|
type Board,
|
||||||
|
type CommentsResponse,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
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 u = profile.user;
|
||||||
const signature = (u.signature || "").trim();
|
const signature = (u.signature || "").trim();
|
||||||
const { post_count, comment_count, points, streak } = profile.stats;
|
const { post_count, comment_count, points, streak } = profile.stats;
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "发帖", value: post_count },
|
{ label: "发帖", value: post_count },
|
||||||
{ label: "评论", value: comment_count },
|
...(allowComments ? [{ label: "评论", value: comment_count }] : []),
|
||||||
{ label: "积分", value: points },
|
{ label: "积分", value: points },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
@@ -197,7 +204,7 @@ function AuthorRailCard({ profile }: { profile: UserProfile }) {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<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)" }}
|
style={{ borderColor: "var(--line)" }}
|
||||||
>
|
>
|
||||||
{stats.map((s, i) => (
|
{stats.map((s, i) => (
|
||||||
@@ -271,11 +278,13 @@ function RelatedPosts({
|
|||||||
posts,
|
posts,
|
||||||
boardId,
|
boardId,
|
||||||
uid = "related",
|
uid = "related",
|
||||||
|
allowComments,
|
||||||
}: {
|
}: {
|
||||||
posts: PostListItem[];
|
posts: PostListItem[];
|
||||||
boardId: number;
|
boardId: number;
|
||||||
// 右栏与移动端内联实例同时存在,id 必须区分以免 aria-labelledby 指向隐藏副本
|
// 右栏与移动端内联实例同时存在,id 必须区分以免 aria-labelledby 指向隐藏副本
|
||||||
uid?: string;
|
uid?: string;
|
||||||
|
allowComments: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (posts.length === 0) return null;
|
if (posts.length === 0) return null;
|
||||||
const titleId = `${uid}-title`;
|
const titleId = `${uid}-title`;
|
||||||
@@ -294,9 +303,11 @@ function RelatedPosts({
|
|||||||
{p.title}
|
{p.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
||||||
|
{allowComments ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<MessageCircle size={11.5} /> {p.comment_count}
|
<MessageCircle size={11.5} /> {p.comment_count}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Heart size={11.5} /> {p.like_count}
|
<Heart size={11.5} /> {p.like_count}
|
||||||
</span>
|
</span>
|
||||||
@@ -318,7 +329,13 @@ function RelatedPosts({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */
|
/* 移动端 / 窄屏作者卡(xl 以下内联在正文与评论之间,替代左栏作者模块) */
|
||||||
function AuthorCardStacked({ profile }: { profile: UserProfile }) {
|
function AuthorCardStacked({
|
||||||
|
profile,
|
||||||
|
allowComments,
|
||||||
|
}: {
|
||||||
|
profile: UserProfile;
|
||||||
|
allowComments: boolean;
|
||||||
|
}) {
|
||||||
const u = profile.user;
|
const u = profile.user;
|
||||||
const signature = (u.signature || "").trim();
|
const signature = (u.signature || "").trim();
|
||||||
const { post_count, comment_count, points, streak } = profile.stats;
|
const { post_count, comment_count, points, streak } = profile.stats;
|
||||||
@@ -349,8 +366,12 @@ function AuthorCardStacked({ profile }: { profile: UserProfile }) {
|
|||||||
<span>@{u.username}</span>
|
<span>@{u.username}</span>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span>发帖 {post_count}</span>
|
<span>发帖 {post_count}</span>
|
||||||
|
{allowComments ? (
|
||||||
|
<>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span>评论 {comment_count}</span>
|
<span>评论 {comment_count}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span>积分 {points}</span>
|
<span>积分 {points}</span>
|
||||||
{streak > 0 && (
|
{streak > 0 && (
|
||||||
@@ -441,11 +462,18 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 评论 / 当前用户 / 站点设置 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
|
// 站点设置已在 layout 缓存;关评时不拉评论树,避免首屏 HTML 泄露历史评论
|
||||||
const [commentsData, me, siteSettings, profile, relatedData] = await Promise.all([
|
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
||||||
fetchComments(id, commentPage, cookie),
|
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),
|
getMeCached(cookie || undefined),
|
||||||
getPublicSettingsCached(),
|
|
||||||
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
||||||
fetchPosts(1, 8, post.board_id, "recommended", "", false, 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,
|
datePublished: post.created_at,
|
||||||
dateModified: post.updated_at,
|
dateModified: post.updated_at,
|
||||||
author: { "@type": "Person", name: post.user.nickname },
|
author: { "@type": "Person", name: post.user.nickname },
|
||||||
commentCount: post.comment_count,
|
...(allowComments ? { commentCount: post.comment_count } : {}),
|
||||||
interactionStatistic: {
|
interactionStatistic: {
|
||||||
"@type": "InteractionCounter",
|
"@type": "InteractionCounter",
|
||||||
interactionType: { "@type": "ReadAction" },
|
interactionType: { "@type": "ReadAction" },
|
||||||
userInteractionCount: post.view_count,
|
userInteractionCount: post.view_count,
|
||||||
},
|
},
|
||||||
...(acceptedAnswer &&
|
...(allowComments &&
|
||||||
|
acceptedAnswer &&
|
||||||
!acceptedAnswer.deleted &&
|
!acceptedAnswer.deleted &&
|
||||||
!post.content_locked && {
|
!post.content_locked && {
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
@@ -546,7 +575,12 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
<>
|
<>
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
<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;以下单栏 */}
|
{/* 三栏阅读布局:宽度与首页工作台一致——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">
|
<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="帖子导航"
|
aria-label="帖子导航"
|
||||||
>
|
>
|
||||||
<BoardCard board={post.board} />
|
<BoardCard board={post.board} />
|
||||||
{profile && <AuthorRailCard profile={profile} />}
|
{profile && <AuthorRailCard profile={profile} allowComments={allowComments} />}
|
||||||
{profile && <AuthorPosts posts={authorPosts} />}
|
{profile && <AuthorPosts posts={authorPosts} />}
|
||||||
</aside>
|
</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} 次`}>
|
<span className="inline-flex items-center gap-1.5" aria-label={`浏览 ${post.view_count} 次`}>
|
||||||
<IconEye size={14} /> {post.view_count} 浏览
|
<IconEye size={14} /> {post.view_count} 浏览
|
||||||
</span>
|
</span>
|
||||||
|
{allowComments ? (
|
||||||
<span className="inline-flex items-center gap-1.5" aria-label={`评论 ${post.comment_count} 条`}>
|
<span className="inline-flex items-center gap-1.5" aria-label={`评论 ${post.comment_count} 条`}>
|
||||||
<IconComment size={14} /> {post.comment_count} 评论
|
<IconComment size={14} /> {post.comment_count} 评论
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1.5" aria-label={`阅读约需 ${readingMinutes} 分钟`}>
|
<span className="inline-flex items-center gap-1.5" aria-label={`阅读约需 ${readingMinutes} 分钟`}>
|
||||||
<Clock size={14} /> 约 {readingMinutes} 分钟读完
|
<Clock size={14} /> 约 {readingMinutes} 分钟读完
|
||||||
</span>
|
</span>
|
||||||
@@ -707,7 +743,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
lottery={post.lottery}
|
lottery={post.lottery}
|
||||||
loggedIn={!!me.user}
|
loggedIn={!!me.user}
|
||||||
/>
|
/>
|
||||||
{!post.content_locked && acceptedAnswer ? (
|
{!post.content_locked && allowComments && acceptedAnswer ? (
|
||||||
<AcceptedAnswerCard
|
<AcceptedAnswerCard
|
||||||
answer={acceptedAnswer}
|
answer={acceptedAnswer}
|
||||||
variant={acceptedAnswerVariant}
|
variant={acceptedAnswerVariant}
|
||||||
@@ -738,11 +774,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
|
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
|
||||||
{profile && (
|
{profile && (
|
||||||
<div className="lg:hidden mt-6">
|
<div className="lg:hidden mt-6">
|
||||||
<AuthorCardStacked profile={profile} />
|
<AuthorCardStacked profile={profile} allowComments={allowComments} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 非公开帖不开放评论(后端同样拒绝),给出状态提示 */}
|
{/* 关评:前台仅提示不支持评论,不展示历史楼层;非公开帖不提及评论 */}
|
||||||
{isPublished ? (
|
{isPublished ? (
|
||||||
<CommentSection
|
<CommentSection
|
||||||
postId={id}
|
postId={id}
|
||||||
@@ -754,7 +790,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
page={commentsData.page}
|
page={commentsData.page}
|
||||||
size={commentsData.size}
|
size={commentsData.size}
|
||||||
user={me.user}
|
user={me.user}
|
||||||
allowComments={siteSettings.allow_comments}
|
|
||||||
acceptMode={acceptMode}
|
acceptMode={acceptMode}
|
||||||
acceptedCommentId={
|
acceptedCommentId={
|
||||||
post.question?.accepted_comment_id ||
|
post.question?.accepted_comment_id ||
|
||||||
@@ -765,11 +800,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
post.lottery?.drawn
|
post.lottery?.drawn
|
||||||
? (post.lottery.winners || [])
|
? (post.lottery.winners || [])
|
||||||
.map((w) => w.comment_id || 0)
|
.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="评论区">
|
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">
|
||||||
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
|
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
|
||||||
{post.status === "pending" ? "帖子审核通过后开放评论" : "帖子未通过审核,评论已关闭"}
|
{post.status === "pending" ? "帖子审核通过后开放评论" : "帖子未通过审核,评论已关闭"}
|
||||||
@@ -782,11 +817,16 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
: "审核通过后评论区将自动开放"}
|
: "审核通过后评论区将自动开放"}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* 窄屏相关讨论(lg 起由右栏承载) */}
|
{/* 窄屏相关讨论(lg 起由右栏承载) */}
|
||||||
<div className="lg:hidden mt-6">
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -797,7 +837,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
aria-label="阅读辅助"
|
aria-label="阅读辅助"
|
||||||
>
|
>
|
||||||
{toc.length > 0 && <PostToc items={toc} />}
|
{toc.length > 0 && <PostToc items={toc} />}
|
||||||
<RelatedPosts posts={related} boardId={post.board_id} />
|
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function UserProfileContent({
|
|||||||
commentData,
|
commentData,
|
||||||
commentError,
|
commentError,
|
||||||
page,
|
page,
|
||||||
|
allowComments = true,
|
||||||
}: {
|
}: {
|
||||||
userId: string;
|
userId: string;
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
@@ -23,6 +24,7 @@ export default function UserProfileContent({
|
|||||||
commentData: UserCommentsResponse | null;
|
commentData: UserCommentsResponse | null;
|
||||||
commentError: boolean;
|
commentError: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
|
allowComments?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -50,9 +52,11 @@ export default function UserProfileContent({
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-4 meta text-[12px]">
|
<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="tabular-nums">{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
|
||||||
|
{allowComments ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<IconComment size={12} /> {post.comment_count}
|
<IconComment size={12} /> {post.comment_count}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<IconEye size={12} /> {post.view_count}
|
<IconEye size={12} /> {post.view_count}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { fetchUserProfile, fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
|
import { fetchUserProfile, fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import UserProfileContent from "./UserProfileContent";
|
import UserProfileContent from "./UserProfileContent";
|
||||||
import ProfileForm from "./ProfileForm";
|
import ProfileForm from "./ProfileForm";
|
||||||
import PasswordForm from "./PasswordForm";
|
import PasswordForm from "./PasswordForm";
|
||||||
@@ -28,9 +28,12 @@ export async function generateMetadata({ params }: UserPageProps): Promise<Metad
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const data = await fetchUserProfile(id, 1);
|
const data = await fetchUserProfile(id, 1);
|
||||||
|
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
||||||
return {
|
return {
|
||||||
title: data.user.nickname || data.user.username,
|
title: data.user.nickname || data.user.username,
|
||||||
description: `查看 ${data.user.nickname} 的资料、发帖与评论`,
|
description: allowComments
|
||||||
|
? `查看 ${data.user.nickname} 的资料、发帖与评论`
|
||||||
|
: `查看 ${data.user.nickname} 的资料与发帖`,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { title: "用户不存在" };
|
return { title: "用户不存在" };
|
||||||
@@ -53,6 +56,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
|
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
||||||
|
|
||||||
let data: UserProfile;
|
let data: UserProfile;
|
||||||
try {
|
try {
|
||||||
@@ -75,13 +79,14 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
// 判断是否本人(决定能否看到 settings / security tab)
|
// 判断是否本人(决定能否看到 settings / security tab)
|
||||||
const isOwner = me.user?.id === data.user.id;
|
const isOwner = me.user?.id === data.user.id;
|
||||||
const currentUser = me.user;
|
const currentUser = me.user;
|
||||||
const effectiveTab: UserTab =
|
let effectiveTab: UserTab =
|
||||||
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
|
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
|
||||||
|
if (!allowComments && effectiveTab === "comments") effectiveTab = "posts";
|
||||||
|
|
||||||
// 评论 tab:SSR 直出对应分页
|
// 评论 tab:SSR 直出对应分页
|
||||||
let commentData: UserCommentsResponse | null = null;
|
let commentData: UserCommentsResponse | null = null;
|
||||||
let commentError = false;
|
let commentError = false;
|
||||||
if (effectiveTab === "comments") {
|
if (allowComments && effectiveTab === "comments") {
|
||||||
try {
|
try {
|
||||||
commentData = await fetchUserComments(id, page);
|
commentData = await fetchUserComments(id, page);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -96,7 +101,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
// 导航项(桌面左栏 / 移动 chips 共用定义)
|
// 导航项(桌面左栏 / 移动 chips 共用定义)
|
||||||
const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
|
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: "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: "settings", label: "资料设置", icon: <Settings size={15} />, ownerOnly: true },
|
||||||
{ key: "security", label: "安全设置", icon: <KeyRound 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 = [
|
const heroStats = [
|
||||||
{ label: "发帖", value: data.stats.post_count },
|
{ 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 },
|
{ 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>
|
||||||
|
|
||||||
{/* 统计条 */}
|
{/* 统计条 */}
|
||||||
<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) => (
|
{heroStats.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={s.label}
|
||||||
@@ -315,6 +325,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
commentData={commentData}
|
commentData={commentData}
|
||||||
commentError={commentError}
|
commentError={commentError}
|
||||||
page={page}
|
page={page}
|
||||||
|
allowComments={allowComments}
|
||||||
/>
|
/>
|
||||||
) : effectiveTab === "settings" && isOwner && currentUser ? (
|
) : effectiveTab === "settings" && isOwner && currentUser ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -423,7 +434,11 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
</li>
|
</li>
|
||||||
<li className="flex gap-2">
|
<li className="flex gap-2">
|
||||||
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
|
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
|
||||||
<span>昵称和头像会公开展示在帖子、评论和个人主页。</span>
|
<span>
|
||||||
|
{allowComments
|
||||||
|
? "昵称和头像会公开展示在帖子、评论和个人主页。"
|
||||||
|
: "昵称和头像会公开展示在帖子和个人主页。"}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex gap-2">
|
<li className="flex gap-2">
|
||||||
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
|
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Link from "next/link";
|
|||||||
import {
|
import {
|
||||||
Trash2,
|
Trash2,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
MessageSquareOff,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
@@ -31,7 +30,8 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { canModerateBoard } from "@/lib/roles";
|
import { canModerateBoard } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||||
|
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Pagination from "./Pagination";
|
import Pagination from "./Pagination";
|
||||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||||
@@ -57,8 +57,6 @@ interface CommentSectionProps {
|
|||||||
acceptedCommentId?: number;
|
acceptedCommentId?: number;
|
||||||
/** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */
|
/** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */
|
||||||
winnerCommentIds?: number[];
|
winnerCommentIds?: number[];
|
||||||
/** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */
|
|
||||||
allowComments?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 递归统计子树回复数(彻底删除级联提示用)
|
// 递归统计子树回复数(彻底删除级联提示用)
|
||||||
@@ -116,10 +114,10 @@ export default function CommentSection({
|
|||||||
acceptMode = null,
|
acceptMode = null,
|
||||||
acceptedCommentId = 0,
|
acceptedCommentId = 0,
|
||||||
winnerCommentIds = [],
|
winnerCommentIds = [],
|
||||||
allowComments: allowCommentsProp = true,
|
|
||||||
}: CommentSectionProps) {
|
}: CommentSectionProps) {
|
||||||
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
|
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const commentsOpen = useAllowComments();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
|
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
|
||||||
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
||||||
@@ -141,25 +139,14 @@ export default function CommentSection({
|
|||||||
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
|
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
|
||||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||||
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCommentsOpen(allowCommentsProp !== false);
|
if (!commentsOpen) {
|
||||||
}, [allowCommentsProp]);
|
|
||||||
|
|
||||||
// 管理后台开关变更后即时隐藏发评入口(后端仍硬拒绝)
|
|
||||||
useEffect(() => {
|
|
||||||
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
|
||||||
if (typeof data?.allow_comments === "boolean") {
|
|
||||||
setCommentsOpen(data.allow_comments);
|
|
||||||
if (!data.allow_comments) {
|
|
||||||
setReplyTarget(null);
|
setReplyTarget(null);
|
||||||
setReplyContent("");
|
setReplyContent("");
|
||||||
}
|
}
|
||||||
}
|
}, [commentsOpen]);
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1017,6 +1004,10 @@ export default function CommentSection({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!commentsOpen) {
|
||||||
|
return <CommentsUnsupported />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
|
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
|
||||||
<div className="flex items-center gap-2.5 mb-6">
|
<div className="flex items-center gap-2.5 mb-6">
|
||||||
@@ -1032,34 +1023,7 @@ export default function CommentSection({
|
|||||||
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
|
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!commentsOpen ? (
|
{user ? (
|
||||||
<div
|
|
||||||
className="mb-8 rounded-2xl border px-5 py-6 text-center sm:px-6"
|
|
||||||
style={{
|
|
||||||
borderColor: "color-mix(in srgb, var(--ink-3) 35%, var(--line))",
|
|
||||||
background:
|
|
||||||
"repeating-linear-gradient(-45deg, transparent, transparent 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 7px), var(--panel-2)",
|
|
||||||
}}
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
|
|
||||||
style={{
|
|
||||||
background: "color-mix(in srgb, var(--ink-3) 14%, transparent)",
|
|
||||||
color: "var(--ink-3)",
|
|
||||||
}}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<MessageSquareOff size={22} />
|
|
||||||
</span>
|
|
||||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
|
||||||
评论功能暂时关闭
|
|
||||||
</p>
|
|
||||||
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
|
||||||
站点当前暂停发表新评论与回复,历史评论仍可浏览。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : user ? (
|
|
||||||
<form onSubmit={handleSubmit} className="mb-8">
|
<form onSubmit={handleSubmit} className="mb-8">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -1106,9 +1070,7 @@ export default function CommentSection({
|
|||||||
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
|
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
|
||||||
暂无评论
|
暂无评论
|
||||||
</p>
|
</p>
|
||||||
<p className="meta text-[12.5px] mt-1">
|
<p className="meta text-[12.5px] mt-1">来说两句,抢占沙发</p>
|
||||||
{commentsOpen ? "来说两句,抢占沙发" : "评论开放后即可参与讨论"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
52
frontend/components/CommentsPolicyProvider.tsx
Normal file
52
frontend/components/CommentsPolicyProvider.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||||
|
|
||||||
|
const AllowCommentsContext = createContext(true);
|
||||||
|
|
||||||
|
/** 前台是否开放评论(SSR 初值 + 后台开关热更新) */
|
||||||
|
export function useAllowComments(): boolean {
|
||||||
|
return useContext(AllowCommentsContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根布局注入:关闭评论后前台立即隐藏评论区/数量;开启则刷新以拉回评论。
|
||||||
|
*/
|
||||||
|
export default function CommentsPolicyProvider({
|
||||||
|
allowComments,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
allowComments: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(allowComments);
|
||||||
|
const openRef = useRef(open);
|
||||||
|
openRef.current = open;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(allowComments);
|
||||||
|
}, [allowComments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||||
|
if (typeof data?.allow_comments !== "boolean") return;
|
||||||
|
if (openRef.current === data.allow_comments) return;
|
||||||
|
setOpen(data.allow_comments);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AllowCommentsContext.Provider value={open}>{children}</AllowCommentsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/components/CommentsUnsupported.tsx
Normal file
25
frontend/components/CommentsUnsupported.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { MessageSquareOff } from "lucide-react";
|
||||||
|
|
||||||
|
/** 全站关闭评论时的前台说明:表达「不支持」而非暂时关闭,避免审核误判。 */
|
||||||
|
export default function CommentsUnsupported() {
|
||||||
|
return (
|
||||||
|
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论">
|
||||||
|
<span
|
||||||
|
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--ink-3) 14%, transparent)",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<MessageSquareOff size={22} />
|
||||||
|
</span>
|
||||||
|
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
本站不支持评论
|
||||||
|
</p>
|
||||||
|
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
||||||
|
站点未提供评论功能,讨论请通过发帖进行。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,10 +13,12 @@ export default function MobilePostBar({
|
|||||||
postId,
|
postId,
|
||||||
liked,
|
liked,
|
||||||
likeCount,
|
likeCount,
|
||||||
|
allowComments = true,
|
||||||
}: {
|
}: {
|
||||||
postId: string;
|
postId: string;
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
|
allowComments?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [showTop, setShowTop] = useState(false);
|
const [showTop, setShowTop] = useState(false);
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ export default function MobilePostBar({
|
|||||||
WebkitBackdropFilter: "blur(14px)",
|
WebkitBackdropFilter: "blur(14px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{allowComments ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={scrollToComments}
|
onClick={scrollToComments}
|
||||||
@@ -62,6 +65,7 @@ export default function MobilePostBar({
|
|||||||
>
|
>
|
||||||
<MessageSquare size={18} />
|
<MessageSquare size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
{/* 放大点赞按钮的触控区域(样式见 globals.css .j13-bar-like) */}
|
{/* 放大点赞按钮的触控区域(样式见 globals.css .j13-bar-like) */}
|
||||||
<div className="j13-bar-like">
|
<div className="j13-bar-like">
|
||||||
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
|
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
|
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||||
|
|
||||||
export default function PostInteractPanels({
|
export default function PostInteractPanels({
|
||||||
postId,
|
postId,
|
||||||
@@ -177,6 +178,7 @@ function DeadlineCountdown({
|
|||||||
|
|
||||||
/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */
|
/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */
|
||||||
export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) {
|
export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) {
|
||||||
|
const allowComments = useAllowComments();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -241,7 +243,7 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
|
|||||||
|
|
||||||
<p className="j13-typed-meta">
|
<p className="j13-typed-meta">
|
||||||
{state.solved
|
{state.solved
|
||||||
? state.accepted_comment_id
|
? state.accepted_comment_id && allowComments
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
已采纳{" "}
|
已采纳{" "}
|
||||||
@@ -255,7 +257,9 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: "作者已标记为已解决"
|
: "作者已标记为已解决"
|
||||||
: "可在评论旁采纳答案,或手动标记已解决"}
|
: allowComments
|
||||||
|
? "可在评论旁采纳答案,或手动标记已解决"
|
||||||
|
: "可由作者标记为已解决"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="j13-typed-foot">
|
<div className="j13-typed-foot">
|
||||||
@@ -466,6 +470,7 @@ function PollPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
||||||
|
const allowComments = useAllowComments();
|
||||||
const statusBadge = state.accepted_comment_id
|
const statusBadge = state.accepted_comment_id
|
||||||
? { cls: "badge-status badge-status-solved", label: "已采纳" }
|
? { cls: "badge-status badge-status-solved", label: "已采纳" }
|
||||||
: state.expired || state.refunded
|
: state.expired || state.refunded
|
||||||
@@ -507,7 +512,7 @@ function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
|||||||
<p className="j13-typed-meta">
|
<p className="j13-typed-meta">
|
||||||
<span className="j13-bounty-points tabular-nums">{state.points} 积分</span>
|
<span className="j13-bounty-points tabular-nums">{state.points} 积分</span>
|
||||||
{bodyHint ? ` · ${bodyHint}` : null}
|
{bodyHint ? ` · ${bodyHint}` : null}
|
||||||
{state.can_accept ? " · 在评论旁点击「采纳」发放积分" : null}
|
{state.can_accept && allowComments ? " · 在评论旁点击「采纳」发放积分" : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!state.accepted_comment_id && state.ends_at ? (
|
{!state.accepted_comment_id && state.ends_at ? (
|
||||||
@@ -518,7 +523,7 @@ function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.accepted_comment_id > 0 && (
|
{allowComments && state.accepted_comment_id > 0 && (
|
||||||
<p className="j13-typed-meta">
|
<p className="j13-typed-meta">
|
||||||
已采纳{" "}
|
已采纳{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import type { PostListItem } from "@/lib/api";
|
import type { PostListItem } from "@/lib/api";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
||||||
import LikeButton from "./LikeButton";
|
import LikeButton from "./LikeButton";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
@@ -15,7 +16,7 @@ import { IconComment, IconEye } from "./Icons";
|
|||||||
* 点击:标题 stretch-link 铺满整行进帖;仅头像/作者/回复者/点赞等加 above-link 抬层。
|
* 点击:标题 stretch-link 铺满整行进帖;仅头像/作者/回复者/点赞等加 above-link 抬层。
|
||||||
* 板块胶囊仅展示,不可点,避免误进板块筛选。
|
* 板块胶囊仅展示,不可点,避免误进板块筛选。
|
||||||
*/
|
*/
|
||||||
export default function PostRow({
|
export default async function PostRow({
|
||||||
post,
|
post,
|
||||||
showBoard = false,
|
showBoard = false,
|
||||||
index = 0,
|
index = 0,
|
||||||
@@ -31,8 +32,12 @@ export default function PostRow({
|
|||||||
showViews?: boolean;
|
showViews?: boolean;
|
||||||
showLike?: boolean;
|
showLike?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
|
||||||
const lastReply = post.last_reply;
|
const lastReply = post.last_reply;
|
||||||
const replied = lastReply && new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
|
const replied =
|
||||||
|
allowComments &&
|
||||||
|
lastReply &&
|
||||||
|
new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
|
||||||
const pt = normalizePostType(post.post_type);
|
const pt = normalizePostType(post.post_type);
|
||||||
const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
|
const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
|
||||||
const statusClass = statusBadge ? typeStatusClass(pt, post.type_status) : "";
|
const statusClass = statusBadge ? typeStatusClass(pt, post.type_status) : "";
|
||||||
@@ -115,7 +120,7 @@ export default function PostRow({
|
|||||||
<span className="tabular-nums shrink-0">{formatRelative(lastReply!.created_at)}</span>
|
<span className="tabular-nums shrink-0">{formatRelative(lastReply!.created_at)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!replied && post.comment_count === 0 && (
|
{!replied && allowComments && post.comment_count === 0 && (
|
||||||
<span className="hidden sm:inline" aria-hidden>
|
<span className="hidden sm:inline" aria-hidden>
|
||||||
· 暂无回复
|
· 暂无回复
|
||||||
</span>
|
</span>
|
||||||
@@ -124,6 +129,7 @@ export default function PostRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 sm:gap-4 shrink-0">
|
<div className="flex items-center gap-3 sm:gap-4 shrink-0">
|
||||||
|
{allowComments ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/post/${post.id}`}
|
href={`/post/${post.id}`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -133,6 +139,7 @@ export default function PostRow({
|
|||||||
<IconComment size={17} />
|
<IconComment size={17} />
|
||||||
{post.comment_count}
|
{post.comment_count}
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
{showViews && (
|
{showViews && (
|
||||||
<span
|
<span
|
||||||
className="hidden md:inline-flex items-center gap-1.5 meta tabular-nums"
|
className="hidden md:inline-flex items-center gap-1.5 meta tabular-nums"
|
||||||
|
|||||||
81
frontend/components/admin/AdminField.tsx
Normal file
81
frontend/components/admin/AdminField.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { ChangeEvent, ReactNode } from "react";
|
||||||
|
|
||||||
|
/** 后台文本框:与聊天搜索同款壳层,聚焦只画外框一圈 */
|
||||||
|
export default function AdminField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
maxLength,
|
||||||
|
as = "input",
|
||||||
|
type = "text",
|
||||||
|
autoComplete,
|
||||||
|
disabled,
|
||||||
|
className = "",
|
||||||
|
minHeight,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: ReactNode;
|
||||||
|
hint?: ReactNode;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
as?: "input" | "textarea";
|
||||||
|
type?: string;
|
||||||
|
autoComplete?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
}) {
|
||||||
|
const handle = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
onChange(e.target.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="admin-field-head">
|
||||||
|
<label className="field-label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{maxLength != null && (
|
||||||
|
<span className="admin-field-count" aria-hidden>
|
||||||
|
{value.length}/{maxLength}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hint != null && hint !== "" && (
|
||||||
|
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">{hint}</p>
|
||||||
|
)}
|
||||||
|
{as === "textarea" ? (
|
||||||
|
<div className="admin-field-shell is-area">
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
className="admin-field-input"
|
||||||
|
style={minHeight ? { minHeight } : undefined}
|
||||||
|
maxLength={maxLength}
|
||||||
|
value={value}
|
||||||
|
onChange={handle}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-field-shell">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
className="admin-field-input"
|
||||||
|
maxLength={maxLength}
|
||||||
|
value={value}
|
||||||
|
onChange={handle}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
|
|
||||||
/** 左图标、可清除、Enter 提交;不要再并排「搜索」按钮 */
|
/** 左图标、可清除、Enter 提交;壳层聚焦与聊天搜索一致 */
|
||||||
export default function AdminSearchField({
|
export default function AdminSearchField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -30,11 +30,11 @@ export default function AdminSearchField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="relative flex-1 min-w-0" role="search">
|
<form onSubmit={handleSubmit} className="admin-field-shell admin-search-shell relative flex-1 min-w-0" role="search">
|
||||||
<Search
|
<Search
|
||||||
size={15}
|
size={15}
|
||||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
|
className="shrink-0 pointer-events-none"
|
||||||
style={{ color: "var(--ink-3)" }}
|
style={{ color: value ? "var(--accent)" : "var(--ink-3)" }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -43,14 +43,14 @@ export default function AdminSearchField({
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className="field pl-10 pr-9 py-2.5"
|
className="admin-field-input"
|
||||||
/>
|
/>
|
||||||
{value ? (
|
{value ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
aria-label="清除搜索"
|
aria-label="清除搜索"
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 w-6 h-6 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
className="w-6 h-6 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--accent-soft)]"
|
||||||
style={{ color: "var(--ink-3)" }}
|
style={{ color: "var(--ink-3)" }}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
|
|||||||
94
frontend/components/admin/AdminStepper.tsx
Normal file
94
frontend/components/admin/AdminStepper.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Minus, Plus } from "lucide-react";
|
||||||
|
import { useEffect, useState, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
/** 后台数字步进器:0 是合法值;加减与手输都会提交数字 */
|
||||||
|
export default function AdminStepper({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max = 999,
|
||||||
|
step = 1,
|
||||||
|
unit,
|
||||||
|
ariaLabel,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (n: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
unit?: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const clamp = (n: number) => Math.min(max, Math.max(min, Math.round(n)));
|
||||||
|
const [text, setText] = useState(() => String(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setText(String(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const emit = (n: number) => {
|
||||||
|
const next = clamp(Number.isFinite(n) ? n : min);
|
||||||
|
onChange(next);
|
||||||
|
setText(String(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
emit(value + step);
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
emit(value - step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`admin-stepper${disabled ? " is-disabled" : ""}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-stepper-btn"
|
||||||
|
onClick={() => emit(value - step)}
|
||||||
|
disabled={disabled || value <= min}
|
||||||
|
aria-label="减少"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className="admin-stepper-input"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={text}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.replace(/[^\d]/g, "");
|
||||||
|
setText(raw);
|
||||||
|
if (raw === "") return;
|
||||||
|
onChange(clamp(Number(raw)));
|
||||||
|
}}
|
||||||
|
onBlur={() => emit(text === "" ? min : Number(text))}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
{unit ? <span className="admin-stepper-unit">{unit}</span> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-stepper-btn"
|
||||||
|
onClick={() => emit(value + step)}
|
||||||
|
disabled={disabled || value >= max}
|
||||||
|
aria-label="增加"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ export { default as AdminSettingRow } from "./AdminSettingRow";
|
|||||||
export { default as AdminSegmented } from "./AdminSegmented";
|
export { default as AdminSegmented } from "./AdminSegmented";
|
||||||
export type { AdminSegmentOption } from "./AdminSegmented";
|
export type { AdminSegmentOption } from "./AdminSegmented";
|
||||||
export { default as AdminSearchField } from "./AdminSearchField";
|
export { default as AdminSearchField } from "./AdminSearchField";
|
||||||
|
export { default as AdminField } from "./AdminField";
|
||||||
|
export { default as AdminStepper } from "./AdminStepper";
|
||||||
export { default as AdminPager } from "./AdminPager";
|
export { default as AdminPager } from "./AdminPager";
|
||||||
export { default as AdminEmpty } from "./AdminEmpty";
|
export { default as AdminEmpty } from "./AdminEmpty";
|
||||||
export { default as AdminStatCard } from "./AdminStatCard";
|
export { default as AdminStatCard } from "./AdminStatCard";
|
||||||
|
|||||||
@@ -85,9 +85,11 @@ function BoardIntro({ board }: { board: BoardWithCount }) {
|
|||||||
function BoardPulse({
|
function BoardPulse({
|
||||||
stats,
|
stats,
|
||||||
checkin,
|
checkin,
|
||||||
|
allowComments,
|
||||||
}: {
|
}: {
|
||||||
stats: BoardSidebarStats;
|
stats: BoardSidebarStats;
|
||||||
checkin?: CheckinStatus | null;
|
checkin?: CheckinStatus | null;
|
||||||
|
allowComments: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cells = [
|
const cells = [
|
||||||
{
|
{
|
||||||
@@ -104,6 +106,8 @@ function BoardPulse({
|
|||||||
foot: "本日新增",
|
foot: "本日新增",
|
||||||
up: stats.today_posts > 0,
|
up: stats.today_posts > 0,
|
||||||
},
|
},
|
||||||
|
...(allowComments
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
icon: <MessageCircle size={12.5} />,
|
icon: <MessageCircle size={12.5} />,
|
||||||
label: "回复总数",
|
label: "回复总数",
|
||||||
@@ -118,6 +122,8 @@ function BoardPulse({
|
|||||||
foot: "本日新增",
|
foot: "本日新增",
|
||||||
up: stats.today_comments > 0,
|
up: stats.today_comments > 0,
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="板块数据">
|
<section className="j13-module" aria-label="板块数据">
|
||||||
@@ -150,7 +156,7 @@ function BoardPulse({
|
|||||||
|
|
||||||
/* ---------- 4. 本板热门 ---------- */
|
/* ---------- 4. 本板热门 ---------- */
|
||||||
|
|
||||||
function BoardHot({ posts }: { posts: PostListItem[] }) {
|
function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="本板热门">
|
<section className="j13-module" aria-label="本板热门">
|
||||||
<p className="j13-module-title mb-1.5">
|
<p className="j13-module-title mb-1.5">
|
||||||
@@ -169,9 +175,11 @@ function BoardHot({ posts }: { posts: PostListItem[] }) {
|
|||||||
{p.title}
|
{p.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
||||||
|
{allowComments ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<MessageCircle size={11.5} /> {p.comment_count}
|
<MessageCircle size={11.5} /> {p.comment_count}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Heart size={11.5} /> {p.like_count}
|
<Heart size={11.5} /> {p.like_count}
|
||||||
</span>
|
</span>
|
||||||
@@ -188,7 +196,7 @@ function BoardHot({ posts }: { posts: PostListItem[] }) {
|
|||||||
|
|
||||||
/* ---------- 5. 本板活跃 ---------- */
|
/* ---------- 5. 本板活跃 ---------- */
|
||||||
|
|
||||||
function BoardActive({ users }: { users: ActiveUser[] }) {
|
function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="本板活跃成员">
|
<section className="j13-module" aria-label="本板活跃成员">
|
||||||
<p className="j13-module-title mb-2">
|
<p className="j13-module-title mb-2">
|
||||||
@@ -221,17 +229,23 @@ function BoardActive({ users }: { users: ActiveUser[] }) {
|
|||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<PenLine size={10.5} /> 发帖 {u.post_count}
|
<PenLine size={10.5} /> 发帖 {u.post_count}
|
||||||
</span>
|
</span>
|
||||||
|
{allowComments ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 text-[13px] tabular-nums"
|
className="shrink-0 text-[13px] tabular-nums"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
aria-label={`本周共 ${u.post_count + u.comment_count} 次互动`}
|
aria-label={
|
||||||
|
allowComments
|
||||||
|
? `本周共 ${u.post_count + u.comment_count} 次互动`
|
||||||
|
: `本周发帖 ${u.post_count}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{u.post_count + u.comment_count}
|
{allowComments ? u.post_count + u.comment_count : u.post_count}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -252,6 +266,7 @@ type BoardPanelsProps = {
|
|||||||
hot: PostListItem[];
|
hot: PostListItem[];
|
||||||
activeUsers: ActiveUser[];
|
activeUsers: ActiveUser[];
|
||||||
checkin?: CheckinStatus | null;
|
checkin?: CheckinStatus | null;
|
||||||
|
allowComments: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BoardPanelsBody(props: BoardPanelsProps) {
|
function BoardPanelsBody(props: BoardPanelsProps) {
|
||||||
@@ -259,9 +274,9 @@ function BoardPanelsBody(props: BoardPanelsProps) {
|
|||||||
<>
|
<>
|
||||||
<Moderators items={props.moderators} />
|
<Moderators items={props.moderators} />
|
||||||
<BoardIntro board={props.board} />
|
<BoardIntro board={props.board} />
|
||||||
<BoardPulse stats={props.stats} checkin={props.checkin} />
|
<BoardPulse stats={props.stats} checkin={props.checkin} allowComments={props.allowComments} />
|
||||||
<BoardHot posts={props.hot} />
|
<BoardHot posts={props.hot} allowComments={props.allowComments} />
|
||||||
<BoardActive users={props.activeUsers} />
|
<BoardActive users={props.activeUsers} allowComments={props.allowComments} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,15 @@ import { formatCompact, formatShortDate } from "@/lib/format";
|
|||||||
|
|
||||||
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
||||||
|
|
||||||
function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: CheckinStatus | null }) {
|
function PulseCard({
|
||||||
|
stats,
|
||||||
|
checkin,
|
||||||
|
allowComments,
|
||||||
|
}: {
|
||||||
|
stats: OverviewStats;
|
||||||
|
checkin?: CheckinStatus | null;
|
||||||
|
allowComments: boolean;
|
||||||
|
}) {
|
||||||
const cells = [
|
const cells = [
|
||||||
{
|
{
|
||||||
icon: <Users size={12.5} />,
|
icon: <Users size={12.5} />,
|
||||||
@@ -43,6 +51,8 @@ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: Checkin
|
|||||||
foot: `+${formatCompact(stats.today_posts)} 今日`,
|
foot: `+${formatCompact(stats.today_posts)} 今日`,
|
||||||
up: true,
|
up: true,
|
||||||
},
|
},
|
||||||
|
...(allowComments
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
icon: <MessageCircle size={12.5} />,
|
icon: <MessageCircle size={12.5} />,
|
||||||
label: "回复总数",
|
label: "回复总数",
|
||||||
@@ -50,6 +60,16 @@ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: Checkin
|
|||||||
foot: `+${formatCompact(stats.today_comments)} 今日`,
|
foot: `+${formatCompact(stats.today_comments)} 今日`,
|
||||||
up: true,
|
up: true,
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
icon: <TrendingUp size={12.5} />,
|
||||||
|
label: "今日新帖",
|
||||||
|
value: formatCompact(stats.today_posts),
|
||||||
|
foot: "本日新增",
|
||||||
|
up: stats.today_posts > 0,
|
||||||
|
},
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
icon: <Activity size={12.5} />,
|
icon: <Activity size={12.5} />,
|
||||||
label: "在线用户",
|
label: "在线用户",
|
||||||
@@ -138,7 +158,7 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
|||||||
|
|
||||||
/* ---------- 热门榜 ---------- */
|
/* ---------- 热门榜 ---------- */
|
||||||
|
|
||||||
function HotRanking({ posts }: { posts: PostListItem[] }) {
|
function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="热门讨论">
|
<section className="j13-module" aria-label="热门讨论">
|
||||||
<p className="j13-module-title mb-1.5">
|
<p className="j13-module-title mb-1.5">
|
||||||
@@ -157,9 +177,11 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
|
|||||||
{p.title}
|
{p.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
|
||||||
|
{allowComments ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<MessageCircle size={11.5} /> {p.comment_count}
|
<MessageCircle size={11.5} /> {p.comment_count}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Heart size={11.5} /> {p.like_count}
|
<Heart size={11.5} /> {p.like_count}
|
||||||
</span>
|
</span>
|
||||||
@@ -211,7 +233,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
|||||||
|
|
||||||
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
|
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
|
||||||
|
|
||||||
function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="本周活跃成员">
|
<section className="j13-module" aria-label="本周活跃成员">
|
||||||
<p className="j13-module-title mb-2">
|
<p className="j13-module-title mb-2">
|
||||||
@@ -241,17 +263,23 @@ function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
|||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<PenLine size={10.5} /> 发帖 {u.post_count}
|
<PenLine size={10.5} /> 发帖 {u.post_count}
|
||||||
</span>
|
</span>
|
||||||
|
{allowComments ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 text-[13px] tabular-nums"
|
className="shrink-0 text-[13px] tabular-nums"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
aria-label={`本周共 ${u.post_count + u.comment_count} 次互动`}
|
aria-label={
|
||||||
|
allowComments
|
||||||
|
? `本周共 ${u.post_count + u.comment_count} 次互动`
|
||||||
|
: `本周发帖 ${u.post_count}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{u.post_count + u.comment_count}
|
{allowComments ? u.post_count + u.comment_count : u.post_count}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -309,46 +337,44 @@ type PanelsProps = {
|
|||||||
announcements: AnnouncementSummary[];
|
announcements: AnnouncementSummary[];
|
||||||
newUsers: NewUser[];
|
newUsers: NewUser[];
|
||||||
checkin?: CheckinStatus | null;
|
checkin?: CheckinStatus | null;
|
||||||
|
allowComments: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CommunityPanels({
|
function CommunityPanelsBody({
|
||||||
stats,
|
stats,
|
||||||
hot,
|
hot,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
announcements,
|
announcements,
|
||||||
newUsers,
|
newUsers,
|
||||||
checkin,
|
checkin,
|
||||||
|
allowComments,
|
||||||
}: PanelsProps) {
|
}: PanelsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
||||||
|
<Announcements items={announcements} />
|
||||||
|
<HotRanking posts={hot} allowComments={allowComments} />
|
||||||
|
<NewUsers users={newUsers} />
|
||||||
|
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||||
|
<FriendLinks />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommunityPanels(props: PanelsProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
||||||
<div className="sticky top-24 space-y-4">
|
<div className="sticky top-24 space-y-4">
|
||||||
<PulseCard stats={stats} checkin={checkin} />
|
<CommunityPanelsBody {...props} />
|
||||||
<Announcements items={announcements} />
|
|
||||||
<HotRanking posts={hot} />
|
|
||||||
<NewUsers users={newUsers} />
|
|
||||||
<ActiveUsers users={activeUsers} />
|
|
||||||
<FriendLinks />
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommunityPanelsMobile({
|
export function CommunityPanelsMobile(props: PanelsProps) {
|
||||||
stats,
|
|
||||||
hot,
|
|
||||||
activeUsers,
|
|
||||||
announcements,
|
|
||||||
newUsers,
|
|
||||||
checkin,
|
|
||||||
}: PanelsProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 lg:hidden" aria-label="社区面板">
|
<div className="space-y-4 lg:hidden" aria-label="社区面板">
|
||||||
<PulseCard stats={stats} checkin={checkin} />
|
<CommunityPanelsBody {...props} />
|
||||||
<Announcements items={announcements} />
|
|
||||||
<HotRanking posts={hot} />
|
|
||||||
<NewUsers users={newUsers} />
|
|
||||||
<ActiveUsers users={activeUsers} />
|
|
||||||
<FriendLinks />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { MessageSquareText, Clock, Sparkles } from "lucide-react";
|
import { MessageSquareText, Clock, Sparkles } from "lucide-react";
|
||||||
|
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||||
|
|
||||||
export type FeedSort = "latest" | "new" | "recommended";
|
export type FeedSort = "latest" | "new" | "recommended";
|
||||||
|
|
||||||
@@ -22,18 +23,20 @@ function tabHref(key: FeedSort, boardId: number) {
|
|||||||
export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort; boardId?: number }) {
|
export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort; boardId?: number }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||||
|
const allowComments = useAllowComments();
|
||||||
|
const keys = allowComments ? TAB_KEYS : TAB_KEYS.filter((k) => k !== "latest");
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent, i: number) => {
|
const handleKeyDown = (e: React.KeyboardEvent, i: number) => {
|
||||||
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const next = (i + (e.key === "ArrowRight" ? 1 : TAB_KEYS.length - 1)) % TAB_KEYS.length;
|
const next = (i + (e.key === "ArrowRight" ? 1 : keys.length - 1)) % keys.length;
|
||||||
tabRefs.current[next]?.focus();
|
tabRefs.current[next]?.focus();
|
||||||
router.push(tabHref(TAB_KEYS[next], boardId));
|
router.push(tabHref(keys[next], boardId));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2" role="tablist" aria-label="帖子排序">
|
<div className="flex items-center gap-1.5 sm:gap-2" role="tablist" aria-label="帖子排序">
|
||||||
{TAB_KEYS.map((key, i) => {
|
{keys.map((key, i) => {
|
||||||
const Icon = key === "latest" ? MessageSquareText : key === "new" ? Clock : Sparkles;
|
const Icon = key === "latest" ? MessageSquareText : key === "new" ? Clock : Sparkles;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -594,7 +594,7 @@ export interface PublicSettings {
|
|||||||
site_name: string;
|
site_name: string;
|
||||||
site_description: string;
|
site_description: string;
|
||||||
allow_register: boolean;
|
allow_register: boolean;
|
||||||
/** 全站是否开放评论/回复;缺省 true */
|
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
||||||
allow_comments: boolean;
|
allow_comments: boolean;
|
||||||
post_cooldown_hours: number;
|
post_cooldown_hours: number;
|
||||||
/** 超过 N 行的代码块是否默认折叠;缺省 true */
|
/** 超过 N 行的代码块是否默认折叠;缺省 true */
|
||||||
|
|||||||
Reference in New Issue
Block a user