polish(admin): 抽出后台控件包并统一各页布局与交互

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 19:03:21 +08:00
parent cfc6c1403b
commit f73d2a89e8
23 changed files with 1720 additions and 1412 deletions

View File

@@ -209,7 +209,11 @@ export function AdminSidebar({ user }: { user: User }) {
: { color: "var(--ink-2)" }
}
>
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
<Icon
size={16}
className="shrink-0"
style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}
/>
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
@@ -274,30 +278,25 @@ export function AdminNavMobile({ user }: { user: User }) {
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
className="admin-seg shrink-0"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }
? undefined
: { background: "var(--panel)", border: "1px solid var(--line)" }
}
>
<Icon size={14} />
{item.label}
{item.pendingBadge && pendingTotal > 0 && (
<span
className="min-w-[18px] h-[18px] px-1 rounded-full inline-flex items-center justify-center text-[10px] font-medium tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
{pendingTotal > 99 ? "99+" : pendingTotal}
</span>
<span className="admin-seg-count">{pendingTotal > 99 ? "99+" : pendingTotal}</span>
)}
</Link>
);
})}
<Link
href="/"
className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
style={{ color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }}
className="admin-seg shrink-0"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<ArrowLeft size={14} />
返回站点

View File

@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
Plus,
Pencil,
@@ -24,6 +23,12 @@ import {
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
} from "@/components/admin";
const COLORS: { value: AnnouncementColor; label: string }[] = [
{ value: "blue", label: "蓝色" },
@@ -35,6 +40,7 @@ const COLORS: { value: AnnouncementColor; label: string }[] = [
];
type FormState = AnnouncementInput & { id: number | null };
type PublishKey = "published" | "draft";
const EMPTY_FORM: FormState = {
id: null,
@@ -130,26 +136,18 @@ export default function AnnouncementAdmin() {
return (
<div>
{/* 页头 */}
<div className="flex items-center justify-between gap-3 flex-wrap mb-5">
<div>
<h1 className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5" style={{ color: "var(--ink)" }}>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Megaphone size={18} />
</span>
站点公告管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色</p>
</div>
{form?.id == null && (
<button onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
)}
</div>
<AdminPageHeader
icon={Megaphone}
title="站点公告管理"
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
actions={
form?.id == null ? (
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
) : undefined
}
/>
<div className="grid grid-cols-1 gap-6 items-start">
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */}
@@ -204,25 +202,17 @@ export default function AnnouncementAdmin() {
</div>
<p className="field-label">发布状态</p>
<div className="flex items-center gap-2 mb-4" role="group" aria-label="发布状态">
<button
type="button"
className="j13-tab"
aria-pressed={form.published}
onClick={() => setForm({ ...form, published: true })}
style={form.published ? { background: "var(--accent-soft)", borderColor: "color-mix(in srgb, var(--accent) 38%, var(--line))", color: "var(--accent)" } : undefined}
>
立即发布
</button>
<button
type="button"
className="j13-tab"
aria-pressed={!form.published}
onClick={() => setForm({ ...form, published: false })}
style={!form.published ? { background: "var(--gold-soft)", borderColor: "color-mix(in srgb, var(--gold) 38%, var(--line))", color: "var(--gold)" } : undefined}
>
存为草稿
</button>
<div className="mb-4">
<AdminSegmented<PublishKey>
role="group"
ariaLabel="发布状态"
value={form.published ? "published" : "draft"}
onChange={(key) => setForm({ ...form, published: key === "published" })}
options={[
{ key: "published", label: "立即发布" },
{ key: "draft", label: "存为草稿" },
]}
/>
</div>
<label className="field-label" htmlFor="anno-content">
@@ -237,7 +227,7 @@ export default function AnnouncementAdmin() {
/>
<div className="flex items-center gap-2.5 mt-4">
<button onClick={save} disabled={saving} className="btn btn-primary flex-1 justify-center">
<button type="button" onClick={save} disabled={saving} className="btn btn-primary flex-1 justify-center">
{saving ? (
<>
<Loader2 size={15} className="animate-spin" /> 保存中…
@@ -249,6 +239,7 @@ export default function AnnouncementAdmin() {
)}
</button>
<button
type="button"
onClick={() => setForm(null)}
disabled={saving}
className="btn btn-line"
@@ -279,7 +270,11 @@ export default function AnnouncementAdmin() {
) : loadError ? (
<p className="alert-error m-4">{loadError}</p>
) : list.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">还没有公告,点击「新建公告」发布第一条吧</p>
<AdminEmpty
icon={<Megaphone size={22} />}
title="还没有公告"
description="点击「新建公告」发布第一条吧"
/>
) : (
list.map((a) => (
<div
@@ -310,38 +305,26 @@ export default function AnnouncementAdmin() {
</div>
<div className="flex items-center gap-1 shrink-0">
{a.published && (
<Link
<AdminIconButton
href={`/announcement/${a.id}`}
target="_blank"
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
data-tip="查看"
aria-label="查看公告"
tip="查看"
ariaLabel="查看公告"
>
<ExternalLink size={15} />
</Link>
</AdminIconButton>
)}
<button
onClick={() => startEdit(a)}
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
data-tip="编辑"
aria-label="编辑公告"
>
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
<Pencil size={15} />
</button>
<button
onClick={() => setDeleteTarget(a)}
</AdminIconButton>
<AdminIconButton
tip="删除"
ariaLabel="删除公告"
tone="danger"
disabled={deletingId === a.id}
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] disabled:opacity-50"
style={{ color: "var(--ink-3)" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--danger)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--ink-3)")}
data-tip="删除"
aria-label="删除公告"
onClick={() => setDeleteTarget(a)}
>
{deletingId === a.id ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}
</button>
</AdminIconButton>
</div>
</div>
))

View File

@@ -1,7 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Palette,
@@ -10,9 +9,9 @@ import {
Save,
Undo2,
RotateCcw,
ChevronRight,
Pipette,
} from "lucide-react";
import { AdminPageHeader, AdminSection } from "@/components/admin";
import { apiUpdateSiteSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
import {
@@ -78,181 +77,184 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
};
return (
<div className="max-w-3xl mx-auto">
{/* 面包屑 */}
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>外观设置</span>
</nav>
<div className="max-w-3xl">
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色,全站按钮、链接、选中态会统一联动"
/>
<header className="panel p-6 sm:p-7 mb-5 flex items-center gap-4">
<span
className="w-12 h-12 rounded-2xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
<div className="flex flex-col gap-4 mb-6">
<AdminSection
title="预设主题色"
description="浅/暗两套色板均已调校,保证文字对比度达标"
>
<Palette size={24} />
</span>
<div>
<h1 className="text-xl font-extrabold" style={{ color: "var(--ink)" }}>外观设置</h1>
<p className="meta mt-1 text-[13px]">配置站点主题色,全站按钮、链接、选中态会统一联动</p>
</div>
</header>
{/* 预设色板 */}
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-semibold mb-1" style={{ color: "var(--ink)" }}>
预设主题色
</h2>
<p className="meta text-[12.5px] mb-4">浅/暗两套色板均已调校,保证文字对比度达标</p>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
{/* 上半圆=浅色模式,下半圆=暗色模式 */}
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{active && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] leading-none"
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
{p.name}
</span>
</button>
);
})}
</div>
</section>
{/* 自定义颜色 */}
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-semibold mb-1 flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<Pipette size={15} /> 自定义颜色
</h2>
<p className="meta text-[12.5px] mb-4">
取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读
</p>
<div className="flex flex-wrap items-center gap-3">
{/* 原生取色器 */}
<label
className="relative w-12 h-12 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
data-tip="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
{/* HEX 文本输入 */}
<div className="flex flex-col gap-1">
<div className="flex items-center rounded-[var(--r-sm)] border bg-[var(--panel)] px-3 h-12 font-mono text-[14px] w-40"
style={{ borderColor: invalid ? "var(--danger)" : "var(--line-2)" }}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
className="flex-1 min-w-0 bg-transparent outline-none ml-1 uppercase"
style={{ color: "var(--ink)" }}
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
/>
</div>
<span className="relative flex items-center justify-center">
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{active && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] leading-none"
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span className="w-3.5 h-3.5 rounded-full border" style={{ background: pair.light, borderColor: "var(--line)" }} />
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="w-3.5 h-3.5 rounded-full border" style={{ background: pair.dark, borderColor: "var(--line)" }} />
暗色 {pair.dark}
</span>
</div>
)}
</div>
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生 {pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</section>
</AdminSection>
{/* 实时预览(整页已实时换肤,此处展示典型组件) */}
<section className="j13-module p-5 sm:p-6 mb-6">
<h2 className="text-[15px] font-semibold mb-4" style={{ color: "var(--ink)" }}>实时预览</h2>
<div className="rounded-[var(--r-sm)] border p-4 space-y-4" style={{ borderColor: "var(--line)", background: "var(--bg)" }}>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">次要按钮</button>
<a className="text-[13px]" style={{ color: "var(--accent)" }} href="#preview" onClick={(e) => e.preventDefault()}>
文字链接示例
</a>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="j13-tab" aria-selected="false">未选中</span>
<span className="j13-tab" aria-selected="true">选中标签</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
<AdminSection
title={
<span className="inline-flex items-center gap-1.5">
<Pipette size={15} /> 自定义颜色
</span>
}
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读"
>
<div className="flex flex-wrap items-center gap-3">
<label
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
data-tip="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
<div className="flex flex-col gap-1">
<div
className="flex items-center rounded-[var(--r-sm)] border bg-[var(--panel)] px-3 h-11 font-mono text-[14px] w-40"
style={{ borderColor: invalid ? "var(--danger)" : "var(--line-2)" }}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
className="flex-1 min-w-0 bg-transparent outline-none ml-1 uppercase"
style={{ color: "var(--ink)" }}
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
/>
</div>
</div>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.light, borderColor: "var(--line)" }}
/>
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.dark, borderColor: "var(--line)" }}
/>
暗色 {pair.dark}
</span>
</div>
)}
</div>
<div className="max-w-xs">
<div className="j13-bcard" aria-current="page">
<span className="j13-bcard-ico" style={{ background: "var(--accent-soft)", color: "var(--accent)" }}>
<Palette size={16} />
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
{pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</AdminSection>
<AdminSection title="实时预览">
<div
className="rounded-[var(--r-sm)] border p-4 space-y-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">
次要按钮
</button>
<a
className="text-[13px]"
style={{ color: "var(--accent)" }}
href="#preview"
onClick={(e) => e.preventDefault()}
>
文字链接示例
</a>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="admin-seg">未选中</span>
<span className="admin-seg" aria-selected="true">
选中标签
</span>
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
</span>
</div>
<div className="max-w-xs">
<div className="j13-bcard" aria-current="page">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Palette size={16} />
</span>
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
</div>
</div>
</div>
</div>
</section>
</AdminSection>
</div>
{/* 操作栏 */}
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
<p className="text-[12.5px] meta hidden sm:block">
{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}

View File

@@ -3,15 +3,12 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
FileText,
FolderOpen,
History,
Loader2,
MessagesSquare,
RotateCcw,
Search,
Trash2,
} from "lucide-react";
import {
@@ -38,6 +35,18 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
import DeleteCommentModal from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminSegmented,
AdminSettingRow,
AdminStatusChip,
AdminSwitch,
contentStatusChip,
} from "@/components/admin";
const PAGE_SIZE = 15;
@@ -51,14 +60,6 @@ const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
{ key: "deleted", label: "已删除" },
];
function statusLabel(status: string, deleted: boolean) {
if (deleted) return "已删除";
if (status === "pending") return "待审";
if (status === "rejected") return "已拒绝";
if (status === "published") return "已发布";
return status;
}
function excerpt(md: string, max = 120): string {
const text = md
.replace(/```[\s\S]*?```/g, " ")
@@ -69,36 +70,6 @@ function excerpt(md: string, max = 120): string {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
function Switch({
checked,
disabled,
onChange,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: () => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onChange}
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
>
<span
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
/>
</button>
);
}
export default function ContentAdmin({
initialPosts,
initialPostsTotal,
@@ -209,14 +180,19 @@ export default function ContentAdmin({
else void loadComments(1, st, appliedQ);
};
const applySearch = (e: React.FormEvent) => {
e.preventDefault();
const applySearch = () => {
const q = keyword.trim();
setAppliedQ(q);
if (kind === "posts") void loadPosts(1, status, q);
else void loadComments(1, status, q);
};
const clearSearch = () => {
setAppliedQ("");
if (kind === "posts") void loadPosts(1, status, "");
else void loadComments(1, status, "");
};
const runAction = (key: string, fn: () => Promise<void>, okMsg: string) => {
setActingKey(key);
fn()
@@ -271,7 +247,6 @@ export default function ContentAdmin({
const total = kind === "posts" ? postsTotal : commentsTotal;
const page = kind === "posts" ? postsPage : commentsPage;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const statusCount = (st: AdminContentStatus) => {
if (kind === "posts") {
@@ -288,52 +263,38 @@ export default function ContentAdmin({
return null;
};
const goPage = (p: number) => {
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
};
return (
<div>
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<FolderOpen size={18} />
</span>
内容管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">
浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改
</p>
</div>
<AdminPageHeader
icon={FolderOpen}
title="内容管理"
description="浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改"
/>
{canConfigureSettings && (
<div className="grid gap-3 mb-5">
<section className="panel p-4 sm:p-5 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
已过审用户后续免审
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed">
开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。
</p>
</div>
<Switch
checked={trustOn}
disabled={savingPolicy}
onChange={() => void toggleTrust()}
label="已过审用户后续免审"
/>
</section>
<div className="flex flex-col gap-3 mb-5">
<AdminSettingRow
title="已过审用户后续免审"
description="开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。"
control={
<AdminSwitch
checked={trustOn}
disabled={savingPolicy}
onChange={() => void toggleTrust()}
label="已过审用户后续免审"
/>
}
/>
<section className="panel p-4 sm:p-5">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
新用户发帖冷静期
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed mb-3">
注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。
</p>
<AdminSettingRow
title="新用户发帖冷静期"
description="注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。"
>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="number"
@@ -354,89 +315,52 @@ export default function ContentAdmin({
保存
</button>
</div>
</section>
</AdminSettingRow>
</div>
)}
<div className="flex items-center gap-1.5 mb-3 flex-wrap" role="tablist" aria-label="内容类型">
{(
[
{ key: "posts" as Kind, label: "帖子", icon: <FileText size={14} />, count: counts.posts_live },
{
key: "comments" as Kind,
label: "评论",
icon: <MessagesSquare size={14} />,
count: counts.comments_live,
},
]
).map((t) => {
const active = kind === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchKind(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.icon}
{t.label}
<span className="text-[11px] tabular-nums" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}>
{t.count}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="状态筛选">
{STATUS_TABS.map((t) => {
const active = status === t.key;
const cnt = statusCount(t.key);
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchStatus(t.key)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12.5px] transition-colors"
style={
active
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-3)", background: "var(--panel)" }
}
>
{t.label}
{cnt != null && (
<span className="tabular-nums text-[11px] opacity-80">{cnt}</span>
)}
</button>
);
})}
</div>
<form onSubmit={applySearch} className="panel p-3 mb-4 flex gap-2 items-center" role="search">
<Search size={15} style={{ color: "var(--ink-3)" }} aria-hidden />
<input
className="field flex-1 !border-0 !shadow-none !bg-transparent py-1.5"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
aria-label="搜索内容"
/>
<button type="submit" className="btn btn-line btn-sm">
搜索
</button>
</form>
<div className="panel overflow-hidden">
<div className="admin-toolbar">
<AdminSegmented<Kind>
ariaLabel="内容类型"
value={kind}
onChange={switchKind}
options={[
{
key: "posts",
label: "帖子",
icon: <FileText size={14} />,
count: counts.posts_live,
},
{
key: "comments",
label: "评论",
icon: <MessagesSquare size={14} />,
count: counts.comments_live,
},
]}
/>
<AdminSegmented<AdminContentStatus>
ariaLabel="状态筛选"
size="sm"
value={status}
onChange={switchStatus}
options={STATUS_TABS.map((t) => ({
key: t.key,
label: t.label,
count: statusCount(t.key) ?? undefined,
}))}
/>
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={applySearch}
onClear={clearSearch}
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
ariaLabel="搜索内容"
/>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
@@ -444,40 +368,43 @@ export default function ContentAdmin({
) : error ? (
<p className="alert-error m-4">{error}</p>
) : kind === "posts" && posts.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">暂无帖子</p>
<AdminEmpty icon={<FileText size={22} />} title="暂无帖子" />
) : kind === "comments" && comments.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">暂无评论</p>
<AdminEmpty icon={<MessagesSquare size={22} />} title="暂无评论" />
) : kind === "posts" ? (
posts.map((p) => {
posts.map((p, idx) => {
const key = `p-${p.id}`;
const busy = actingKey === key;
const chip = contentStatusChip(p.status, p.deleted);
return (
<div
key={p.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar name={p.user?.nickname || p.user?.username || "?"} src={p.user?.avatar || undefined} size={34} />
<Avatar
name={p.user?.nickname || p.user?.username || "?"}
src={p.user?.avatar || undefined}
size={34}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[11px] px-2 py-0.5 rounded-full"
style={{
background: p.deleted ? "var(--danger-soft)" : "var(--tint)",
color: p.deleted ? "var(--danger)" : "var(--ink-3)",
}}
>
{statusLabel(p.status, p.deleted)}
</span>
<AdminStatusChip tone={chip.tone}>{chip.label}</AdminStatusChip>
<span className="meta text-[11.5px]">{p.board?.name}</span>
<span className="meta text-[11.5px] tabular-nums">{formatRelative(p.created_at)}</span>
<span className="meta text-[11.5px] tabular-nums">
{formatRelative(p.created_at)}
</span>
</div>
{p.deleted ? (
<p className="mt-1 text-[14px]" style={{ color: "var(--ink-2)" }}>
{p.title}
</p>
) : (
<Link href={`/post/${p.id}`} className="mt-1 block text-[14px] hover:underline" style={{ color: "var(--ink)" }}>
<Link
href={`/post/${p.id}`}
className="mt-1 block text-[14px] hover:underline"
style={{ color: "var(--ink)" }}
>
{p.title}
</Link>
)}
@@ -488,20 +415,24 @@ export default function ContentAdmin({
<div className="flex items-center gap-1 shrink-0">
{p.deleted ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
<AdminIconButton
tip="恢复"
ariaLabel="恢复帖子"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复")
}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
恢复
</button>
<button
type="button"
className="btn btn-danger btn-sm"
{busy ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
</AdminIconButton>
<AdminIconButton
tip="清除"
ariaLabel="彻底删除帖子"
tone="danger"
disabled={busy}
onClick={() =>
askConfirm(
@@ -516,47 +447,46 @@ export default function ContentAdmin({
)
}
>
<Trash2 size={14} /> 清除
</button>
<Trash2 size={14} />
</AdminIconButton>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
<AdminIconButton
tip="删除"
ariaLabel="删除帖子"
disabled={busy}
onClick={() => setStaffDeletePost(p)}
>
<Trash2 size={14} /> 删除
</button>
<Trash2 size={14} />
</AdminIconButton>
)}
</div>
</div>
);
})
) : (
comments.map((c) => {
comments.map((c, idx) => {
const key = `c-${c.id}`;
const busy = actingKey === key;
const chip = contentStatusChip(c.status, c.deleted);
return (
<div
key={c.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar name={c.user?.nickname || c.user?.username || "?"} src={c.user?.avatar || undefined} size={34} />
<Avatar
name={c.user?.nickname || c.user?.username || "?"}
src={c.user?.avatar || undefined}
size={34}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[11px] px-2 py-0.5 rounded-full"
style={{
background: c.deleted ? "var(--danger-soft)" : "var(--tint)",
color: c.deleted ? "var(--danger)" : "var(--ink-3)",
}}
>
{statusLabel(c.status, c.deleted)}
</span>
<AdminStatusChip tone={chip.tone}>{chip.label}</AdminStatusChip>
<span className="meta text-[11.5px]">{c.board?.name}</span>
<span className="meta text-[11.5px] tabular-nums">{formatRelative(c.created_at)}</span>
<span className="meta text-[11.5px] tabular-nums">
{formatRelative(c.created_at)}
</span>
</div>
<p className="mt-1 text-[13.5px] leading-relaxed" style={{ color: "var(--ink)" }}>
{excerpt(c.content)}
@@ -566,36 +496,44 @@ export default function ContentAdmin({
{c.deleted ? (
c.post_title
) : (
<Link href={`/post/${c.post_id}`} className="hover:underline" style={{ color: "var(--accent)" }}>
<Link
href={`/post/${c.post_id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
{c.post_title || `帖子 #${c.post_id}`}
</Link>
)}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
className="btn btn-line btn-sm"
<AdminIconButton
tip="历史"
ariaLabel="查看编辑历史"
onClick={() => setHistoryCommentId(c.id)}
>
<History size={14} /> 历史
</button>
<History size={14} />
</AdminIconButton>
{c.deleted ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
<AdminIconButton
tip="恢复"
ariaLabel="恢复评论"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复")
}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
恢复
</button>
<button
type="button"
className="btn btn-danger btn-sm"
{busy ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
</AdminIconButton>
<AdminIconButton
tip="清除"
ariaLabel="彻底删除评论"
tone="danger"
disabled={busy}
onClick={() =>
askConfirm(
@@ -610,18 +548,18 @@ export default function ContentAdmin({
)
}
>
<Trash2 size={14} /> 清除
</button>
<Trash2 size={14} />
</AdminIconButton>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
<AdminIconButton
tip="删除"
ariaLabel="删除评论"
disabled={busy}
onClick={() => setStaffDeleteComment(c)}
>
<Trash2 size={14} /> 删除
</button>
<Trash2 size={14} />
</AdminIconButton>
)}
</div>
</div>
@@ -629,40 +567,13 @@ export default function ContentAdmin({
})
)}
{totalPages > 1 && (
<div
className="flex items-center justify-center gap-2 px-4 py-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page <= 1 || loading}
onClick={() => {
const p = page - 1;
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
}}
>
<ChevronLeft size={14} /> 上一页
</button>
<span className="meta text-[12.5px] tabular-nums">
{page} / {totalPages}
</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page >= totalPages || loading}
onClick={() => {
const p = page + 1;
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
}}
>
下一页 <ChevronRight size={14} />
</button>
</div>
)}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={goPage}
/>
</div>
<ConfirmDialog

View File

@@ -9,6 +9,7 @@ import { isAdminOrAbove } from "@/lib/roles";
import { postTypeLabel } from "@/lib/postMeta";
import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
@@ -39,67 +40,50 @@ export default async function AdminEconomyPage() {
);
}
const cards = [
{
label: "用户余额合计",
value: fmt(stats.total_balance),
icon: <Coins size={18} />,
hint: "全站可用积分之和",
},
{
label: "今日签到",
value: fmt(stats.checkin_today),
icon: <Flame size={18} />,
hint: "今日已签到人数",
},
{
label: "托管中悬赏",
value: `${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`,
icon: <Gift size={18} />,
hint: "未结算帖数 / 托管积分",
},
{
label: "流水条数",
value: fmt(stats.ledger_count),
icon: <Layers size={18} />,
hint: "历史入账出账记录",
},
];
const typeEntries = Object.entries(stats.posts_by_type || {}).sort(
(a, b) => b[1] - a[1]
);
return (
<div>
<div className="mb-6">
<h1 className="text-[22px] font-extrabold" style={{ color: "var(--ink)" }}>
积分与类型帖看板
</h1>
<p className="meta text-[13px] mt-1">
观察全站积分流通、悬赏托管与帖子类型分布
</p>
<AdminPageHeader
icon={Coins}
title="积分与类型帖看板"
description="观察全站积分流通、悬赏托管与帖子类型分布"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-5">
<AdminStatCard
label="用户余额合计"
value={fmt(stats.total_balance)}
hint="全站可用积分之和"
icon={<Coins size={16} />}
/>
<AdminStatCard
label="今日签到"
value={fmt(stats.checkin_today)}
hint="今日已签到人数"
icon={<Flame size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
<AdminStatCard
label="托管中悬赏"
value={`${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`}
hint="未结算帖数 / 托管积分"
icon={<Gift size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="流水条数"
value={fmt(stats.ledger_count)}
hint="历史入账出账记录"
icon={<Layers size={16} />}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
{cards.map((c) => (
<div key={c.label} className="panel p-5">
<div className="flex items-center gap-2 meta text-[12.5px]">
<span style={{ color: "var(--accent)" }}>{c.icon}</span>
{c.label}
</div>
<p
className="mt-3 text-[22px] font-extrabold tabular-nums leading-none"
style={{ color: "var(--ink)" }}
>
{c.value}
</p>
<p className="meta text-[12px] mt-2">{c.hint}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-medium text-[15px]"
@@ -111,11 +95,11 @@ export default async function AdminEconomyPage() {
<p className="meta text-center py-10">暂无数据</p>
) : (
<ul>
{typeEntries.map(([t, n]) => (
{typeEntries.map(([t, n], idx) => (
<li
key={t}
className="px-5 py-3 flex items-center justify-between"
style={{ borderTop: "1px solid var(--line)" }}
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<span className="text-[14px]" style={{ color: "var(--ink)" }}>
{postTypeLabel(t)}
@@ -138,11 +122,11 @@ export default async function AdminEconomyPage() {
<p className="meta text-center py-10">暂无流水</p>
) : (
<ul>
{stats.recent_ledger.map((it) => (
{stats.recent_ledger.map((it, idx) => (
<li
key={it.id}
className="px-5 py-3 flex items-start justify-between gap-3"
style={{ borderTop: "1px solid var(--line)" }}
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>

View File

@@ -4,8 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
Check,
ChevronLeft,
ChevronRight,
ClipboardCheck,
FileText,
Loader2,
@@ -27,6 +25,12 @@ import {
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSegmented,
} from "@/components/admin";
const PAGE_SIZE = 15;
@@ -190,198 +194,106 @@ export default function ModerationAdmin({
}
};
const totalPages = tab === "posts"
? Math.max(1, Math.ceil(postsTotal / PAGE_SIZE))
: Math.max(1, Math.ceil(commentsTotal / PAGE_SIZE));
const page = tab === "posts" ? postsPage : commentsPage;
const total = tab === "posts" ? postsTotal : commentsTotal;
return (
<div>
{/* 页头 */}
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<ClipboardCheck size={18} />
</span>
内容审核
</h1>
<p className="meta mt-1.5 text-[12.5px]">
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。
</p>
</div>
{/* 队列切换 */}
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
{(
[
{ key: "posts" as Tab, label: "待审帖子", count: counts.posts, icon: <FileText size={14} /> },
{ key: "comments" as Tab, label: "待审评论", count: counts.comments, icon: <MessagesSquare size={14} /> },
]
).map((t) => {
const active = tab === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchTab(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.icon}
{t.label}
<span
className="text-[11px] tabular-nums rounded-full px-1.5 leading-[1.7]"
style={{
background: "var(--panel)",
color: active ? "var(--nav-active-ink)" : "var(--ink-3)",
}}
>
{t.count}
</span>
</button>
);
})}
</div>
<AdminPageHeader
icon={ClipboardCheck}
title="内容审核"
description="普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。"
/>
{error && <p className="alert-error mb-4">{error}</p>}
<div aria-busy={loading} style={{ opacity: loading ? 0.6 : 1, transition: "opacity .15s" }}>
<div
className="panel overflow-hidden"
aria-busy={loading}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity .15s" }}
>
<div className="admin-toolbar">
<AdminSegmented<Tab>
ariaLabel="审核队列"
value={tab}
onChange={switchTab}
options={[
{
key: "posts",
label: "待审帖子",
count: counts.posts,
icon: <FileText size={14} />,
},
{
key: "comments",
label: "待审评论",
count: counts.comments,
icon: <MessagesSquare size={14} />,
},
]}
/>
</div>
{tab === "posts" ? (
posts.length === 0 ? (
<EmptyQueue icon={<FileText size={22} />} text="暂无待审核帖子" />
<AdminEmpty
icon={<FileText size={22} />}
title="暂无待审核帖子"
description="新提交的内容会按时间顺序出现在这里"
/>
) : (
<div className="flex flex-col gap-3">
{posts.map((p) => {
const key = `posts-${p.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div key={p.id} className="panel p-4 sm:p-5">
<div className="flex items-center gap-2 flex-wrap mb-2.5">
<Link
href={`/?board=${p.board.id}`}
className="chip"
style={{ fontSize: 11.5 }}
>
{p.board.name}
</Link>
<span className="meta text-[11.5px]">{formatRelative(p.created_at)}</span>
</div>
<Link
href={`/post/${p.id}`}
target="_blank"
className="block text-[15.5px] font-semibold tracking-tight leading-snug hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{p.title}
</Link>
<p
className="mt-1.5 text-[13px] leading-relaxed line-clamp-3"
style={{ color: "var(--ink-3)" }}
>
{excerpt(p.content) || "(无正文内容)"}
</p>
<div
className="mt-3.5 flex items-center justify-between gap-3 flex-wrap pt-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="flex items-center gap-2 min-w-0">
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={26} />
<span className="text-[12.5px] truncate" style={{ color: "var(--ink-2)" }}>
{p.user.nickname}
</span>
<span className="meta text-[11.5px] truncate">@{p.user.username}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
disabled={busy}
onClick={() => act(key, "posts", p.id, true, postsPage, posts.length)}
className="btn btn-sm"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
通过
</button>
<button
type="button"
disabled={busy}
onClick={() => act(key, "posts", p.id, false, postsPage, posts.length)}
className="btn btn-sm"
style={{
background: confirming ? "var(--danger)" : "var(--danger-soft)",
color: confirming ? "var(--accent-on)" : "var(--danger)",
}}
>
{confirming ? "确认拒绝?" : <><X size={13} /> 拒绝</>}
</button>
</div>
</div>
</div>
);
})}
</div>
)
) : comments.length === 0 ? (
<EmptyQueue icon={<MessagesSquare size={22} />} text="暂无待审核评论" />
) : (
<div className="panel overflow-hidden">
{comments.map((c, idx) => {
const key = `comments-${c.id}`;
posts.map((p, idx) => {
const key = `posts-${p.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div
key={c.id}
className="p-4 sm:p-5 flex items-start gap-3.5"
key={p.id}
className="px-4 sm:px-5 py-4"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{c.user.nickname}
</span>
<Link
href={`/?board=${c.board.id}`}
className="chip"
style={{ fontSize: 11 }}
>
{c.board.name}
</Link>
<span className="meta text-[11.5px]">{formatRelative(c.created_at)}</span>
</div>
<p
className="mt-1.5 text-[13.5px] leading-relaxed rounded-xl px-3.5 py-2.5"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
{c.content}
</p>
<div className="flex items-center gap-2 flex-wrap mb-2">
<Link
href={`/post/${c.post_id}`}
target="_blank"
className="mt-1.5 block meta text-[12px] truncate hover:text-[var(--accent)] transition-colors"
href={`/?board=${p.board.id}`}
className="chip"
style={{ fontSize: 11.5 }}
>
所属帖子:《{c.post_title}》
{p.board.name}
</Link>
<div className="flex items-center gap-2 mt-3">
<span className="meta text-[11.5px]">{formatRelative(p.created_at)}</span>
</div>
<Link
href={`/post/${p.id}`}
target="_blank"
className="block text-[15px] font-semibold tracking-tight leading-snug hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{p.title}
</Link>
<p
className="mt-1.5 text-[13px] leading-relaxed line-clamp-3"
style={{ color: "var(--ink-3)" }}
>
{excerpt(p.content) || "(无正文内容)"}
</p>
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<Avatar
name={p.user.nickname || p.user.username}
src={p.user.avatar || undefined}
size={26}
/>
<span className="text-[12.5px] truncate" style={{ color: "var(--ink-2)" }}>
{p.user.nickname}
</span>
<span className="meta text-[11.5px] truncate">@{p.user.username}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
disabled={busy}
onClick={() => act(key, "comments", c.id, true, commentsPage, comments.length)}
className="btn btn-sm"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
onClick={() => act(key, "posts", p.id, true, postsPage, posts.length)}
className="btn btn-sm btn-ok"
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
通过
@@ -389,69 +301,115 @@ export default function ModerationAdmin({
<button
type="button"
disabled={busy}
onClick={() => act(key, "comments", c.id, false, commentsPage, comments.length)}
className="btn btn-sm"
style={{
background: confirming ? "var(--danger)" : "var(--danger-soft)",
color: confirming ? "var(--accent-on)" : "var(--danger)",
}}
onClick={() => act(key, "posts", p.id, false, postsPage, posts.length)}
className="btn btn-sm btn-danger"
>
{confirming ? "确认拒绝?" : <><X size={13} /> 拒绝</>}
{confirming ? (
"确认拒绝?"
) : (
<>
<X size={13} /> 拒绝
</>
)}
</button>
</div>
</div>
</div>
);
})}
</div>
})
)
) : comments.length === 0 ? (
<AdminEmpty
icon={<MessagesSquare size={22} />}
title="暂无待审核评论"
description="新提交的内容会按时间顺序出现在这里"
/>
) : (
comments.map((c, idx) => {
const key = `comments-${c.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div
key={c.id}
className="px-4 sm:px-5 py-4 flex items-start gap-3.5"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar
name={c.user.nickname || c.user.username}
src={c.user.avatar || undefined}
size={34}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{c.user.nickname}
</span>
<Link
href={`/?board=${c.board.id}`}
className="chip"
style={{ fontSize: 11 }}
>
{c.board.name}
</Link>
<span className="meta text-[11.5px]">{formatRelative(c.created_at)}</span>
</div>
<p
className="mt-1.5 text-[13.5px] leading-relaxed rounded-xl px-3.5 py-2.5"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
{c.content}
</p>
<Link
href={`/post/${c.post_id}`}
target="_blank"
className="mt-1.5 block meta text-[12px] truncate hover:text-[var(--accent)] transition-colors"
>
所属帖子:《{c.post_title}》
</Link>
<div className="flex items-center gap-2 mt-3">
<button
type="button"
disabled={busy}
onClick={() =>
act(key, "comments", c.id, true, commentsPage, comments.length)
}
className="btn btn-sm btn-ok"
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
通过
</button>
<button
type="button"
disabled={busy}
onClick={() =>
act(key, "comments", c.id, false, commentsPage, comments.length)
}
className="btn btn-sm btn-danger"
>
{confirming ? (
"确认拒绝?"
) : (
<>
<X size={13} /> 拒绝
</>
)}
</button>
</div>
</div>
</div>
);
})
)}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={(p) => (tab === "posts" ? goPosts(p) : goComments(p))}
/>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div
className="mt-4 flex items-center justify-between gap-2 panel px-4 sm:px-5 py-3"
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {tab === "posts" ? postsPage : commentsPage} / {totalPages} 页
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => (tab === "posts" ? goPosts(postsPage - 1) : goComments(commentsPage - 1))}
disabled={(tab === "posts" ? postsPage : commentsPage) <= 1 || loading}
className="btn btn-line btn-sm"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => (tab === "posts" ? goPosts(postsPage + 1) : goComments(commentsPage + 1))}
disabled={(tab === "posts" ? postsPage : commentsPage) >= totalPages || loading}
className="btn btn-line btn-sm"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
);
}
function EmptyQueue({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="panel text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
{icon}
</span>
<p className="text-[14.5px]" style={{ color: "var(--ink)" }}>
{text}
</p>
<p className="meta mt-1.5 text-[12.5px]">新提交的内容会按时间顺序出现在这里</p>
</div>
);
}

View File

@@ -9,6 +9,8 @@ import {
MessageSquareText,
FileText,
Users,
LayoutDashboard,
Coins,
} from "lucide-react";
import {
fetchOverview,
@@ -19,6 +21,7 @@ import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
import Avatar from "@/components/Avatar";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import { formatRelative } from "@/lib/format";
export const metadata: Metadata = {
@@ -53,7 +56,7 @@ function PanelHead({ title, extra }: { title: string; extra?: string }) {
);
}
/** 待办工作区:待审帖子 / 评论数量,整块进入审核页 */
/** 待办:待审帖子 / 评论,整块进入审核页 */
function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
const total = counts.posts + counts.comments;
const idle = total === 0;
@@ -70,7 +73,7 @@ function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
background: idle ? "var(--tint)" : "var(--ok-soft)",
color: idle ? "var(--ink-3)" : "var(--ok)",
}}
aria-hidden="true"
aria-hidden
>
<ClipboardCheck size={20} />
</span>
@@ -81,30 +84,26 @@ function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
</p>
{idle ? (
<span
className="text-[11.5px] px-2 py-0.5 rounded-full"
style={{ background: "var(--tint)", color: "var(--ink-3)" }}
className="admin-status is-muted"
>
暂无待审
</span>
) : (
<span
className="text-[11.5px] px-2 py-0.5 rounded-full tabular-nums"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
>
<span className="admin-status is-ok tabular-nums">
{fmt(total)} 项待处理
</span>
)}
</div>
<div className="mt-3 flex flex-wrap gap-3 sm:gap-5">
<span className="inline-flex items-center gap-1.5 text-[13px]" style={{ color: "var(--ink-2)" }}>
<FileText size={14} style={{ color: "var(--ink-3)" }} aria-hidden="true" />
<FileText size={14} style={{ color: "var(--ink-3)" }} aria-hidden />
待审帖子
<strong className="tabular-nums font-extrabold" style={{ color: "var(--ink)" }}>
{fmt(counts.posts)}
</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[13px]" style={{ color: "var(--ink-2)" }}>
<MessageSquareText size={14} style={{ color: "var(--ink-3)" }} aria-hidden="true" />
<MessageSquareText size={14} style={{ color: "var(--ink-3)" }} aria-hidden />
待审评论
<strong className="tabular-nums font-extrabold" style={{ color: "var(--ink)" }}>
{fmt(counts.comments)}
@@ -152,31 +151,35 @@ export default async function AdminDashboardPage() {
return (
<div>
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight"
style={{ color: "var(--ink)" }}
>
{greeting(now.getHours())},{me.user?.nickname}
</h1>
<p className="meta mt-1.5 text-[12.5px]">{dateLabel} · 站点管理</p>
</div>
<AdminPageHeader
icon={LayoutDashboard}
title={`${greeting(now.getHours())},${me.user?.nickname ?? ""}`}
description={`${dateLabel} · 站点管理`}
/>
{canMod && pendingCounts && <PendingWorkPanel counts={pendingCounts} />}
{canEconomy && (
<Link
href="/admin/economy"
className="panel mb-4 px-4 sm:px-5 py-4 flex items-center justify-between gap-3 transition-colors hover:bg-[var(--panel-2)]"
>
<div>
<p className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
积分与类型帖看板
</p>
<p className="meta text-[12.5px] mt-0.5">余额合计、悬赏托管、类型分布与最近流水</p>
</div>
<ChevronRight size={18} style={{ color: "var(--ink-3)" }} />
</Link>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<Link href="/admin/economy" className="block hover-lift rounded-[var(--r)]">
<AdminStatCard
label="积分看板"
value="查看"
hint="余额合计、悬赏托管与流水"
icon={<Coins size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
</Link>
{stats && (
<AdminStatCard
label="注册成员"
value={fmt(stats.users)}
hint="全站用户总量"
icon={<Users size={16} />}
/>
)}
</div>
)}
<div className="grid lg:grid-cols-2 gap-4 items-start">
@@ -201,16 +204,10 @@ export default async function AdminDashboardPage() {
size={34}
/>
<span className="min-w-0 flex-1 leading-tight">
<span
className="block text-[13.5px] truncate"
style={{ color: "var(--ink)" }}
>
<span className="block text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
<span
className="block text-[11.5px] truncate"
style={{ color: "var(--ink-3)" }}
>
<span className="block text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</span>
</span>
@@ -218,7 +215,7 @@ export default async function AdminDashboardPage() {
className="shrink-0 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden="true" />
<UserPlus size={12} aria-hidden />
{formatRelative(u.created_at)}加入
</span>
</Link>
@@ -233,9 +230,9 @@ export default async function AdminDashboardPage() {
color: "var(--accent)",
}}
>
<Users size={14} aria-hidden="true" />
<Users size={14} aria-hidden />
前往用户权限
<ChevronRight size={14} aria-hidden="true" />
<ChevronRight size={14} aria-hidden />
</Link>
)}
</div>
@@ -255,16 +252,13 @@ export default async function AdminDashboardPage() {
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--panel-2)", color: "var(--accent)" }}
aria-hidden="true"
aria-hidden
>
<LayoutGrid size={14} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span
className="text-[13.5px] truncate"
style={{ color: "var(--ink)" }}
>
<span className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{b.name}
</span>
<span
@@ -277,7 +271,7 @@ export default async function AdminDashboardPage() {
<span
className="block mt-1.5 h-1 rounded-full overflow-hidden"
style={{ background: "var(--tint)" }}
aria-hidden="true"
aria-hidden
>
<span
className="block h-full rounded-full"

View File

@@ -1,6 +1,5 @@
"use client";
import { useState } from "react";
import {
Code2,
Globe,
@@ -10,42 +9,16 @@ import {
Sparkles,
UserPlus,
} from "lucide-react";
import { useState } from "react";
import {
apiUpdateSiteSettings,
type PublicSettings,
} from "@/lib/api";
AdminPageHeader,
AdminSection,
AdminSettingRow,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
function Switch({
checked,
disabled,
onChange,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: () => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onChange}
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
>
<span
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
/>
</button>
);
}
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
const [siteName, setSiteName] = useState(initial.site_name);
const [siteDescription, setSiteDescription] = useState(initial.site_description);
@@ -204,229 +177,214 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
return (
<div className="max-w-3xl">
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
<AdminPageHeader
icon={Globe}
title="站点设置"
description="配置站点名称、简介与注册/评论开关;主题色请到「外观」页调整"
/>
<div className="flex flex-col gap-4">
<AdminSection
title="基本信息"
description="用于页眉品牌、页脚版权与 SEO 元信息"
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Globe size={18} />
</span>
站点设置
</h1>
<p className="meta mt-1.5 text-[12.5px]">
配置站点名称、简介与注册/评论开关;主题色请到「外观」页调整
</p>
</div>
<label className="field-label" htmlFor="site-name">
站点名称
</label>
<input
id="site-name"
className="field mb-4"
maxLength={32}
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="姜十三论坛"
/>
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-semibold mb-1" style={{ color: "var(--ink)" }}>
基本信息
</h2>
<p className="meta text-[12.5px] mb-4">用于页眉品牌、页脚版权与 SEO 元信息</p>
<label className="field-label" htmlFor="site-desc">
站点简介
</label>
<textarea
id="site-desc"
className="field min-h-[96px] mb-4"
maxLength={160}
value={siteDescription}
onChange={(e) => setSiteDescription(e.target.value)}
placeholder="一句话介绍社区定位…"
/>
<label className="field-label" htmlFor="site-name">
站点名称
</label>
<input
id="site-name"
className="field mb-4"
maxLength={32}
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="姜十三论坛"
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={() => void saveIdentity()}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存基本信息
</button>
</div>
</AdminSection>
<AdminSettingRow
title={
<>
<UserPlus size={15} /> 开放注册
</>
}
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
control={
<AdminSwitch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
}
/>
<label className="field-label" htmlFor="site-desc">
站点简介
</label>
<textarea
id="site-desc"
className="field min-h-[96px] mb-4"
maxLength={160}
value={siteDescription}
onChange={(e) => setSiteDescription(e.target.value)}
placeholder="一句话介绍社区定位…"
<AdminSettingRow
title={
<>
<MessageSquare size={15} /> 开放评论
</>
}
description="关闭后全站禁止发表新评论与回复,已有评论仍可浏览。适用于 ICP/IPC 备案审核等合规需要;前台仅提示「暂停评论」,不会展示备案事由。"
control={
<AdminSwitch
checked={allowComments}
disabled={togglingComments}
onChange={() => void toggleComments()}
label="开放评论"
/>
}
/>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={() => void saveIdentity()}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存基本信息
</button>
</div>
</section>
<section className="j13-module p-5 sm:p-6 mb-5 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<UserPlus size={15} /> 开放注册
</h2>
<p className="meta text-[12.5px] mt-1 leading-relaxed">
关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。
</p>
</div>
<Switch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
</section>
<section className="j13-module p-5 sm:p-6 mb-5 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<MessageSquare size={15} /> 开放评论
</h2>
<p className="meta text-[12.5px] mt-1 leading-relaxed">
关闭后全站禁止发表新评论与回复,已有评论仍可浏览。适用于 ICP/IPC
备案审核等合规需要;前台仅提示「暂停评论」,不会展示备案事由。
</p>
</div>
<Switch
checked={allowComments}
disabled={togglingComments}
onChange={() => void toggleComments()}
label="开放评论"
/>
</section>
<section className="j13-module p-5 sm:p-6 mb-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</h2>
<p className="meta text-[12.5px] mt-1 leading-relaxed">
开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。
</p>
</div>
<Switch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
</div>
{codeAutoFold ? (
<div className="mt-4 pt-4 border-t border-[var(--line)]">
<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>
</div>
) : null}
</section>
</>
}
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>
<section className="j13-module p-5 sm:p-6 mb-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<AdminSettingRow
title={
<>
<Sparkles size={15} /> 界面动画
</h2>
<p className="meta text-[12.5px] mt-1 leading-relaxed">
总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。
</p>
</div>
<Switch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
</div>
{uiAnimations ? (
<div className="mt-4 pt-4 border-t border-[var(--line)] 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>
</>
}
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>
<Switch
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 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>
<Switch
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 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>
<Switch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
</div>
</div>
) : null}
</section>
) : null}
</AdminSettingRow>
</div>
</div>
);
}

View File

@@ -12,13 +12,11 @@ import {
History,
Loader2,
Monitor,
Search,
ShieldAlert,
ShieldCheck,
Users as UsersIcon,
Users,
Activity,
Hourglass,
X,
} from "lucide-react";
import {
apiAdminListUsers,
@@ -32,12 +30,20 @@ import {
} from "@/lib/api";
import { isOwner, ROLES } from "@/lib/roles";
import { formatDate, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { realtime } from "@/lib/realtime";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import Modal from "@/components/Modal";
import RoleAccountModal from "@/components/RoleAccountModal";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminSegmented,
AdminStatCard,
} from "@/components/admin";
export type UserFilter = "all" | "staff" | "banned";
@@ -55,48 +61,6 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string }
return {};
}
// 汇总小卡
function MiniStat({
label,
value,
sub,
icon,
color,
soft,
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
<div className="panel p-4 flex items-center gap-3">
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
<div className="min-w-0 leading-tight">
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<p className="text-[19px] font-extrabold tabular-nums" style={{ color: "var(--ink)" }}>
{value}
{sub && (
<span className="ml-1.5 text-[11px]" style={{ color: "var(--ink-3)" }}>
{sub}
</span>
)}
</p>
</div>
</div>
);
}
export default function UsersAdmin({
initial,
initError,
@@ -227,8 +191,8 @@ export default function UsersAdmin({
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
}, [appliedQ, filter, page, router]);
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
// Enter 立即提交,跳过防抖
const submitSearch = () => {
const q = keyword.trim();
if (q === appliedQ) return;
setAppliedQ(q);
@@ -267,10 +231,6 @@ export default function UsersAdmin({
// 有关键词或非默认筛选时,空态文案与"无成员"区分
const hasFilter = appliedQ !== "" || filter !== "all";
// 行内操作按钮(圆形幽灵按钮,32px)
const ghostBtn =
"w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed";
// 角色授权成功 / 封禁成功后的统一回刷
const reload = () => load({ page, filter, q: appliedQ });
@@ -338,15 +298,14 @@ export default function UsersAdmin({
const renderActions = (u: AdminUser, isSelf: boolean) => {
const canAudit = isOwner(currentUserRole);
const auditBtn = canAudit ? (
<Link
<AdminIconButton
href={`/admin/users/${u.id}`}
data-tip="内容档案"
aria-label={`查看 ${u.username} 的内容档案`}
className={ghostBtn}
style={{ color: "var(--accent)" }}
tip="内容档案"
ariaLabel={`查看 ${u.username} 的内容档案`}
tone="accent"
>
<FolderSearch size={15} />
</Link>
</AdminIconButton>
) : null;
// 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案
@@ -354,14 +313,9 @@ export default function UsersAdmin({
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<Link
href={`/u/${u.id}`}
data-tip="查看个人主页"
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
>
<AdminIconButton href={`/u/${u.id}`} tip="查看个人主页" ariaLabel={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={15} />
</Link>
</AdminIconButton>
</div>
);
}
@@ -379,47 +333,26 @@ export default function UsersAdmin({
);
}
const busy = actingId === u.id;
const roleTone = u.banned ? "danger" : u.role !== ROLES.USER ? "accent" : "default";
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<Link
<AdminIconButton
href={`/u/${u.id}`}
data-tip="查看个人主页"
aria-label={`查看 ${u.nickname} 的个人主页`}
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
tip="查看个人主页"
ariaLabel={`查看 ${u.nickname} 的个人主页`}
>
<ExternalLink size={15} />
</Link>
<button
type="button"
data-tip="角色与账号"
aria-label={`设置 ${u.username} 的角色与账号状态`}
</AdminIconButton>
<AdminIconButton
tip="角色与账号"
ariaLabel={`设置 ${u.username} 的角色与账号状态`}
tone={roleTone}
disabled={busy}
onClick={() => setRoleTarget(u)}
className={ghostBtn}
style={
u.banned
? { color: "var(--danger)" }
: u.role === ROLES.USER
? { color: "var(--ink-3)" }
: { color: "var(--accent)" }
}
onMouseEnter={(e) => {
e.currentTarget.style.background = u.banned ? "var(--danger-soft)" : "var(--accent-soft)";
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.banned
? "var(--danger)"
: u.role === ROLES.USER
? "var(--ink-3)"
: "var(--accent)";
}}
>
<ShieldCheck size={15} />
</button>
</AdminIconButton>
</div>
);
};
@@ -432,28 +365,15 @@ export default function UsersAdmin({
return (
<div>
{/* 页头 */}
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<UsersIcon size={18} />
</span>
用户管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">
检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护
</p>
</div>
<AdminPageHeader
icon={Users}
title="用户管理"
description="检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护"
/>
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<MiniStat
<AdminStatCard
label="当前在线"
value={String(summary.online)}
sub="5 分钟内活跃"
@@ -461,7 +381,7 @@ export default function UsersAdmin({
color="var(--ok)"
soft="var(--ok-soft)"
/>
<MiniStat
<AdminStatCard
label="发帖冷静期中"
value={String(summary.in_cooldown)}
sub="普通用户"
@@ -469,14 +389,14 @@ export default function UsersAdmin({
color="var(--gold)"
soft="var(--gold-soft)"
/>
<MiniStat
<AdminStatCard
label="近 7 日活跃"
value={String(summary.active_7d)}
icon={<Monitor size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<MiniStat
<AdminStatCard
label="24h 登录失败"
value={String(summary.failed_logins_24h)}
sub="次"
@@ -486,70 +406,30 @@ export default function UsersAdmin({
/>
</div>
{/* 搜索 + 筛选 */}
<div className="panel p-3 sm:p-4 mb-4">
<div className="flex flex-col md:flex-row md:items-center gap-3">
<form onSubmit={submitSearch} className="relative flex-1" role="search">
<Search
size={15}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索用户名、昵称或邮箱…"
aria-label="搜索用户"
className="field pl-10 pr-9 py-2.5"
/>
{keyword && (
<button
type="button"
onClick={clearSearch}
aria-label="清除搜索"
className="absolute right-3 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)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
)}
</form>
<div className="flex items-center gap-1.5 flex-wrap" role="group" aria-label="用户筛选">
{FILTER_TABS.map((t) => {
const active = filter === t.key;
return (
<button
key={t.key}
type="button"
aria-pressed={active}
onClick={() => changeFilter(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.label}
<span
className="text-[11px] tabular-nums rounded-full px-1.5 leading-[1.7]"
style={{
background: "var(--panel)",
color: active ? "var(--nav-active-ink)" : "var(--ink-3)",
}}
>
{tabCount(t.key)}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* 用户列表 */}
{/* 搜索 + 筛选 + 用户列表 */}
<div className="panel overflow-hidden">
<div className="admin-toolbar">
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={submitSearch}
onClear={clearSearch}
placeholder="搜索用户名、昵称或邮箱…"
ariaLabel="搜索用户"
/>
<AdminSegmented<UserFilter>
role="group"
ariaLabel="用户筛选"
value={filter}
onChange={changeFilter}
options={FILTER_TABS.map((t) => ({
key: t.key,
label: t.label,
count: tabCount(t.key),
}))}
/>
</div>
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
@@ -595,20 +475,11 @@ export default function UsersAdmin({
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : users.length === 0 ? (
<div className="text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<UsersIcon size={22} />
</span>
<p className="text-[14.5px]" style={{ color: "var(--ink)" }}>
{hasFilter ? "没有匹配的成员" : "暂无成员"}
</p>
<p className="meta mt-1.5 text-[12.5px]">
{hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
</p>
</div>
<AdminEmpty
icon={<Users size={22} />}
title={hasFilter ? "没有匹配的成员" : "暂无成员"}
description={hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
/>
) : (
users.map((u) => {
const isSelf = u.id === currentUserId;
@@ -648,7 +519,7 @@ export default function UsersAdmin({
<div className="flex justify-end">{renderActions(u, isSelf)}</div>
</div>
{/* 移动卡片 */}
{/* 移动卡片:空间紧,保留 btn-line btn-sm */}
<div className="md:hidden px-4 py-3.5">
<div className="flex items-center gap-3">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
@@ -732,35 +603,14 @@ export default function UsersAdmin({
)}
</div>
{/* 分页 */}
{total > 0 && (
<div
className="px-4 sm:px-5 py-3 flex items-center justify-between gap-2"
style={{ borderTop: "1px solid var(--line)" }}
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {page} / {totalPages} 页 · 共 {total} 人
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => goPage(page - 1)}
disabled={page <= 1 || loading}
className="btn btn-line btn-sm"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => goPage(page + 1)}
disabled={page >= totalPages || loading}
className="btn btn-line btn-sm"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
)}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={goPage}
summary={`第 ${page} / ${totalPages} 页 · 共 ${total} 人`}
/>
</div>
<RoleAccountModal
@@ -836,6 +686,7 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos
panelClassName="p-6 max-h-[88vh] flex flex-col"
ariaLabel="登录历史"
footer={
// Modal footer 不好塞 AdminPager,保留原紧凑按钮
total > pageSize ? (
<>
<span className="meta text-[11.5px] tabular-nums mr-auto" style={{ color: "var(--ink-3)" }}>

View File

@@ -4,8 +4,6 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
ExternalLink,
FileText,
Loader2,
@@ -24,18 +22,20 @@ import {
import { formatDate, formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import {
AdminEmpty,
AdminPager,
AdminSegmented,
AdminStatusChip,
type AdminStatusTone,
} from "@/components/admin";
type TabKey = "posts" | "comments" | "messages";
const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [
{ key: "posts", label: "帖子", icon: <FileText size={14} /> },
{ key: "comments", label: "评论", icon: <MessageCircle size={14} /> },
{ key: "messages", label: "私信与群消息", icon: <MessagesSquare size={14} /> },
];
const PAGE_SIZE = 20;
function StatusChip({
/** 内容/消息状态粒:可叠加(如已删除 + 待审) */
function StatusChips({
status,
deleted,
recalled,
@@ -44,72 +44,28 @@ function StatusChip({
deleted?: boolean;
recalled?: boolean;
}) {
const chips: { label: string; color: string; soft: string }[] = [];
if (deleted) chips.push({ label: "已删除", color: "var(--danger)", soft: "var(--danger-soft)" });
if (recalled) chips.push({ label: "已撤回", color: "var(--ink-3)", soft: "var(--tint)" });
if (status === "pending") chips.push({ label: "待审", color: "var(--gold)", soft: "var(--gold-soft)" });
if (status === "rejected") chips.push({ label: "已驳回", color: "var(--danger)", soft: "var(--danger-soft)" });
const chips: { label: string; tone: AdminStatusTone }[] = [];
if (deleted) chips.push({ label: "已删除", tone: "danger" });
if (recalled) chips.push({ label: "已撤回", tone: "muted" });
if (status === "pending") chips.push({ label: "待审", tone: "warn" });
if (status === "rejected") chips.push({ label: "已驳回", tone: "danger" });
if (status === "published" && !deleted) {
chips.push({ label: "已发布", color: "var(--ok)", soft: "var(--ok-soft)" });
chips.push({ label: "已发布", tone: "ok" });
}
if (chips.length === 0 && !recalled) {
chips.push({ label: "正常", color: "var(--ink-3)", soft: "var(--tint)" });
chips.push({ label: "正常", tone: "muted" });
}
return (
<span className="inline-flex flex-wrap gap-1">
{chips.map((c) => (
<span
key={c.label}
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px]"
style={{ background: c.soft, color: c.color }}
>
<AdminStatusChip key={c.label} tone={c.tone}>
{c.label}
</span>
</AdminStatusChip>
))}
</span>
);
}
function Pager({
page,
total,
onChange,
loading,
}: {
page: number;
total: number;
onChange: (p: number) => void;
loading?: boolean;
}) {
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (pages <= 1) return null;
return (
<div className="flex items-center justify-center gap-2 py-3">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => onChange(page - 1)}
className="btn btn-line btn-sm"
aria-label="上一页"
>
<ChevronLeft size={14} />
</button>
<span className="text-[12.5px] tabular-nums meta">
{page} / {pages}
</span>
<button
type="button"
disabled={page >= pages || loading}
onClick={() => onChange(page + 1)}
className="btn btn-line btn-sm"
aria-label="下一页"
>
<ChevronRight size={14} />
</button>
</div>
);
}
export default function UserAuditClient({
userId,
initial,
@@ -171,6 +127,15 @@ export default function UserAuditClient({
return profile.messages_total;
};
const emptyIcon =
tab === "posts" ? (
<FileText size={22} />
) : tab === "comments" ? (
<MessageCircle size={22} />
) : (
<MessagesSquare size={22} />
);
return (
<div>
<Link
@@ -194,14 +159,7 @@ export default function UserAuditClient({
{u.nickname}
</h1>
<RoleBadge role={u.role} />
{u.banned && (
<span
className="text-[11px] px-2 py-0.5 rounded-full"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
已封禁
</span>
)}
{u.banned && <AdminStatusChip tone="danger">已封禁</AdminStatusChip>}
</div>
<p className="meta text-[12.5px] mt-0.5 truncate">@{u.username}</p>
<p className="meta text-[12px] mt-2">
@@ -240,34 +198,35 @@ export default function UserAuditClient({
</div>
</div>
<div
className="flex gap-1 mb-3 overflow-x-auto"
style={{ borderBottom: "1px solid var(--line)" }}
>
{TABS.map((t) => {
const active = tab === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => switchTab(t.key)}
className="inline-flex items-center gap-1.5 px-3 py-2.5 text-[13px] whitespace-nowrap -mb-px"
style={{
color: active ? "var(--accent)" : "var(--ink-3)",
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
}}
>
{t.icon}
{t.label}
<span className="tabular-nums opacity-70">
{tabCount(t.key).toLocaleString("zh-CN")}
</span>
</button>
);
})}
</div>
<div className="panel overflow-hidden">
<div className="admin-toolbar">
<AdminSegmented<TabKey>
ariaLabel="内容分类"
value={tab}
onChange={switchTab}
options={[
{
key: "posts",
label: "帖子",
icon: <FileText size={14} />,
count: tabCount("posts"),
},
{
key: "comments",
label: "评论",
icon: <MessageCircle size={14} />,
count: tabCount("comments"),
},
{
key: "messages",
label: "私信与群消息",
icon: <MessagesSquare size={14} />,
count: tabCount("messages"),
},
]}
/>
</div>
{error && (
<p className="text-center py-10 text-[13px]" style={{ color: "var(--danger)" }}>
{error}
@@ -279,7 +238,7 @@ export default function UserAuditClient({
</div>
)}
{!error && !loading && total === 0 && (
<p className="meta text-center py-14 text-[13px]">该分类暂无内容</p>
<AdminEmpty icon={emptyIcon} title="该分类暂无内容" />
)}
{tab === "posts" &&
@@ -292,7 +251,7 @@ export default function UserAuditClient({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<StatusChip status={p.status} deleted={p.deleted} />
<StatusChips status={p.status} deleted={p.deleted} />
{p.board_name && (
<span className="text-[11.5px] meta truncate">{p.board_name}</span>
)}
@@ -333,7 +292,7 @@ export default function UserAuditClient({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<StatusChip status={c.status} deleted={c.deleted} />
<StatusChips status={c.status} deleted={c.deleted} />
{c.deleted ? (
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
) : (
@@ -370,7 +329,7 @@ export default function UserAuditClient({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<StatusChip deleted={m.deleted} recalled={m.recalled} />
<StatusChips deleted={m.deleted} recalled={m.recalled} />
<span className="text-[11.5px] meta truncate">
{m.room_name}
{m.room_type === "direct" ? " · 私聊" : m.room_type === "group" ? " · 群聊" : ""}
@@ -393,7 +352,13 @@ export default function UserAuditClient({
</div>
))}
<Pager page={page} total={total} onChange={setPage} loading={loading} />
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={setPage}
/>
</div>
</div>
);

View File

@@ -1681,6 +1681,197 @@ a.j13-mention-token:hover {
.btn-sm { padding: 7px 14px; font-size: 13px; }
/* ----- 成功操作(审核通过等) ----- */
.btn-ok {
background: var(--ok-soft);
color: var(--ok);
}
.btn-ok:hover:not(:disabled) {
background: var(--ok);
color: #fff;
}
.dark .btn-ok:hover:not(:disabled) {
color: #0a1a10;
}
/* ============================================================
管理后台控件(仅 /admin;颜色全部走令牌)
============================================================ */
.admin-switch {
position: relative;
flex-shrink: 0;
width: 2.75rem;
height: 1.625rem;
border-radius: 999px;
border: none;
padding: 0;
cursor: pointer;
transition: background 0.15s ease;
background: var(--line-2);
}
.admin-switch[aria-checked="true"] {
background: var(--accent);
}
.admin-switch:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.admin-switch:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.admin-switch-knob {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.375rem;
height: 1.375rem;
border-radius: 999px;
background: var(--panel);
box-shadow: var(--shadow-sm);
transition: transform 0.15s ease;
}
.admin-switch[aria-checked="true"] .admin-switch-knob {
transform: translateX(1.125rem);
}
.admin-seg {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 999px;
border: none;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
color: var(--ink-2);
background: var(--panel-2);
}
.admin-seg:hover:not(:disabled):not([aria-selected="true"]):not([aria-pressed="true"]) {
color: var(--ink);
background: var(--tint);
}
.admin-seg[aria-selected="true"],
.admin-seg[aria-pressed="true"],
.admin-seg[aria-current="page"] {
background: var(--nav-active-bg);
color: var(--nav-active-ink);
}
.admin-seg:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-seg:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.admin-seg-count {
min-width: 1.25rem;
padding: 0 0.375rem;
border-radius: 999px;
font-size: 11px;
font-variant-numeric: tabular-nums;
line-height: 1.7;
background: var(--panel);
color: var(--ink-3);
}
.admin-seg[aria-selected="true"] .admin-seg-count,
.admin-seg[aria-pressed="true"] .admin-seg-count,
.admin-seg[aria-current="page"] .admin-seg-count {
color: var(--nav-active-ink);
}
.admin-seg-sm {
padding: 0.375rem 0.75rem;
font-size: 12.5px;
}
.admin-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 999px;
border: none;
padding: 0;
cursor: pointer;
color: var(--ink-3);
background: transparent;
transition: background 0.15s ease, color 0.15s ease;
}
.admin-icon-btn:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--accent);
}
.admin-icon-btn.is-danger:hover:not(:disabled) {
background: var(--danger-soft);
color: var(--danger);
}
.admin-icon-btn.is-danger.is-active,
.admin-icon-btn.is-danger[aria-pressed="true"] {
color: var(--danger);
}
.admin-icon-btn.is-accent {
color: var(--accent);
}
.admin-icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.admin-status {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
line-height: 1.55;
white-space: nowrap;
}
.admin-status.is-ok {
background: var(--ok-soft);
color: var(--ok);
}
.admin-status.is-warn {
background: var(--gold-soft);
color: var(--gold);
}
.admin-status.is-danger {
background: var(--danger-soft);
color: var(--danger);
}
.admin-status.is-muted {
background: var(--tint);
color: var(--ink-3);
}
.admin-status.is-accent {
background: var(--accent-soft);
color: var(--accent);
}
.admin-toolbar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
@media (min-width: 640px) {
.admin-toolbar {
padding: 0.875rem 1.25rem;
}
}
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
.j13-like {
border-color: var(--line-2);

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
/** 图标 + 主文案 + 辅文案 */
export default function AdminEmpty({
icon,
title,
description,
className = "",
}: {
icon: ReactNode;
title: string;
description?: string;
className?: string;
}) {
return (
<div className={`text-center py-16 px-6 ${className}`.trim()}>
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
aria-hidden
>
{icon}
</span>
<p className="text-[14.5px]" style={{ color: "var(--ink)" }}>
{title}
</p>
{description ? <p className="meta mt-1.5 text-[12.5px]">{description}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
type Tone = "default" | "accent" | "danger";
const toneClass: Record<Tone, string> = {
default: "",
accent: "is-accent",
danger: "is-danger",
};
/** 32px 圆形行内操作;hover/focus 走 CSS,禁止 JS 改 style */
export default function AdminIconButton({
children,
tip,
ariaLabel,
tone = "default",
className = "",
href,
disabled,
onClick,
type = "button",
}: {
children: ReactNode;
tip?: string;
ariaLabel: string;
tone?: Tone;
className?: string;
href?: string;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit";
}) {
const cls = `admin-icon-btn ${toneClass[tone]} ${className}`.trim();
if (href && !disabled) {
return (
<Link href={href} className={cls} aria-label={ariaLabel} data-tip={tip}>
{children}
</Link>
);
}
return (
<button
type={type}
className={cls}
aria-label={ariaLabel}
data-tip={tip}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,39 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
/** 后台统一页头:36px 图标槽 + 标题 + 说明 + 可选右侧 CTA */
export default function AdminPageHeader({
icon: Icon,
title,
description,
actions,
}: {
icon: LucideIcon;
title: string;
description?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="mb-5 flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Icon size={18} />
</span>
{title}
</h1>
{description != null && description !== "" && (
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">{description}</p>
)}
</div>
{actions ? <div className="flex items-center gap-2 shrink-0 flex-wrap">{actions}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
/** 紧凑上一页 / n / N / 下一页(客户端列表) */
export default function AdminPager({
page,
total,
pageSize,
onChange,
loading,
summary,
className = "",
}: {
page: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
loading?: boolean;
/** 左侧摘要,默认「第 n / N 页」 */
summary?: string;
className?: string;
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1 && total === 0) return null;
if (totalPages <= 1) return null;
return (
<div
className={`flex items-center justify-between gap-2 px-4 sm:px-5 py-3 ${className}`.trim()}
style={{ borderTop: "1px solid var(--line)" }}
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{summary ?? `第 ${page} / ${totalPages} 页`}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onChange(page - 1)}
disabled={page <= 1 || loading}
className="btn btn-line btn-sm"
aria-label="上一页"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => onChange(page + 1)}
disabled={page >= totalPages || loading}
className="btn btn-line btn-sm"
aria-label="下一页"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { Search, X } from "lucide-react";
import type { FormEvent } from "react";
/** 左图标、可清除、Enter 提交;不要再并排「搜索」按钮 */
export default function AdminSearchField({
value,
onChange,
onSubmit,
onClear,
placeholder,
ariaLabel,
}: {
value: string;
onChange: (v: string) => void;
onSubmit?: () => void;
onClear?: () => void;
placeholder?: string;
ariaLabel: string;
}) {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSubmit?.();
};
const clear = () => {
onChange("");
onClear?.();
};
return (
<form onSubmit={handleSubmit} className="relative flex-1 min-w-0" role="search">
<Search
size={15}
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={ariaLabel}
className="field pl-10 pr-9 py-2.5"
/>
{value ? (
<button
type="button"
onClick={clear}
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)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
) : null}
</form>
);
}

View File

@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
/** 控制台模块:标题 / 说明 / 右上 extra;列表页一张表一块 */
export default function AdminSection({
title,
description,
extra,
children,
className = "",
bodyClassName = "",
flush = false,
}: {
title?: ReactNode;
description?: ReactNode;
extra?: ReactNode;
children: ReactNode;
className?: string;
bodyClassName?: string;
/** 无内边距,适合表格/列表铺满 */
flush?: boolean;
}) {
const hasHead = title != null || description != null || extra != null;
return (
<section className={`panel overflow-hidden ${className}`.trim()}>
{hasHead && (
<div
className="px-4 sm:px-5 py-3.5 flex items-start justify-between gap-3 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
>
<div className="min-w-0">
{title != null && (
<h2 className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
{title}
</h2>
)}
{description != null && description !== "" && (
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">{description}</p>
)}
</div>
{extra != null && <div className="shrink-0">{extra}</div>}
</div>
)}
<div className={`${flush ? "" : "p-4 sm:p-5"} ${bodyClassName}`.trim()}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import type { ReactNode } from "react";
export type AdminSegmentOption<T extends string = string> = {
key: T;
label: string;
icon?: ReactNode;
count?: number | string;
disabled?: boolean;
};
/** 分段筛选:可带 count / icon;选中态走 --nav-active-* */
export default function AdminSegmented<T extends string = string>({
options,
value,
onChange,
ariaLabel,
size = "md",
role = "tablist",
}: {
options: AdminSegmentOption<T>[];
value: T;
onChange: (key: T) => void;
ariaLabel: string;
size?: "md" | "sm";
role?: "tablist" | "group";
}) {
const useTabs = role === "tablist";
return (
<div
className="flex items-center gap-1.5 flex-wrap"
role={role}
aria-label={ariaLabel}
>
{options.map((opt) => {
const active = value === opt.key;
return (
<button
key={opt.key}
type="button"
role={useTabs ? "tab" : undefined}
aria-selected={useTabs ? active : undefined}
aria-pressed={!useTabs ? active : undefined}
disabled={opt.disabled}
onClick={() => {
if (!active) onChange(opt.key);
}}
className={`admin-seg ${size === "sm" ? "admin-seg-sm" : ""}`.trim()}
>
{opt.icon}
{opt.label}
{opt.count != null && <span className="admin-seg-count">{opt.count}</span>}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
/** 「标题 + 说明 | 控件」一行,设置页与内容策略共用 */
export default function AdminSettingRow({
title,
description,
control,
children,
}: {
title: ReactNode;
description?: ReactNode;
control?: ReactNode;
/** 展开区(如阈值输入),画在分隔线下方 */
children?: ReactNode;
}) {
return (
<div className="panel p-4 sm:p-5">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
{title}
</h2>
{description != null && description !== "" && (
<p className="meta text-[12.5px] mt-1 leading-relaxed">{description}</p>
)}
</div>
{control != null && <div className="shrink-0">{control}</div>}
</div>
{children != null && (
<div className="mt-4 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
{children}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
/** KPI:图标软底 + 数字 + 说明 */
export default function AdminStatCard({
label,
value,
sub,
hint,
icon,
color = "var(--accent)",
soft = "var(--accent-soft)",
}: {
label: string;
value: ReactNode;
sub?: string;
hint?: string;
icon: ReactNode;
color?: string;
soft?: string;
}) {
return (
<div className="panel p-4 sm:p-5 flex items-center gap-3">
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden
>
{icon}
</span>
<div className="min-w-0 leading-tight">
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<p className="text-[19px] sm:text-[22px] font-extrabold tabular-nums leading-none mt-1" style={{ color: "var(--ink)" }}>
{value}
{sub ? (
<span className="ml-1.5 text-[11px] font-medium" style={{ color: "var(--ink-3)" }}>
{sub}
</span>
) : null}
</p>
{hint ? <p className="meta text-[12px] mt-1.5">{hint}</p> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
export type AdminStatusTone = "ok" | "warn" | "danger" | "muted" | "accent";
const toneClass: Record<AdminStatusTone, string> = {
ok: "is-ok",
warn: "is-warn",
danger: "is-danger",
muted: "is-muted",
accent: "is-accent",
};
/** 待审 / 已发布 / 已拒绝 / 草稿 / 已删除 等状态粒 */
export default function AdminStatusChip({
children,
tone = "muted",
className = "",
}: {
children: ReactNode;
tone?: AdminStatusTone;
className?: string;
}) {
return (
<span className={`admin-status ${toneClass[tone]} ${className}`.trim()}>{children}</span>
);
}
/** 内容状态 → 文案 + tone */
export function contentStatusChip(
status: string,
deleted: boolean
): { label: string; tone: AdminStatusTone } {
if (deleted) return { label: "已删除", tone: "danger" };
if (status === "pending") return { label: "待审", tone: "warn" };
if (status === "rejected") return { label: "已拒绝", tone: "danger" };
if (status === "published") return { label: "已发布", tone: "ok" };
return { label: status, tone: "muted" };
}

View File

@@ -0,0 +1,28 @@
"use client";
/** 后台唯一开关控件 */
export default function AdminSwitch({
checked,
disabled,
onChange,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: () => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onChange}
className="admin-switch"
>
<span className="admin-switch-knob" aria-hidden />
</button>
);
}

View File

@@ -0,0 +1,13 @@
export { default as AdminPageHeader } from "./AdminPageHeader";
export { default as AdminSection } from "./AdminSection";
export { default as AdminSwitch } from "./AdminSwitch";
export { default as AdminSettingRow } from "./AdminSettingRow";
export { default as AdminSegmented } from "./AdminSegmented";
export type { AdminSegmentOption } from "./AdminSegmented";
export { default as AdminSearchField } from "./AdminSearchField";
export { default as AdminPager } from "./AdminPager";
export { default as AdminEmpty } from "./AdminEmpty";
export { default as AdminStatCard } from "./AdminStatCard";
export { default as AdminIconButton } from "./AdminIconButton";
export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip";
export type { AdminStatusTone } from "./AdminStatusChip";