polish(admin): 抽出后台控件包并统一各页布局与交互
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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} />
|
||||
返回站点
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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 ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
30
frontend/components/admin/AdminEmpty.tsx
Normal file
30
frontend/components/admin/AdminEmpty.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/components/admin/AdminIconButton.tsx
Normal file
58
frontend/components/admin/AdminIconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/components/admin/AdminPageHeader.tsx
Normal file
39
frontend/components/admin/AdminPageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/components/admin/AdminPager.tsx
Normal file
58
frontend/components/admin/AdminPager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
frontend/components/admin/AdminSearchField.tsx
Normal file
61
frontend/components/admin/AdminSearchField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/components/admin/AdminSection.tsx
Normal file
46
frontend/components/admin/AdminSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/components/admin/AdminSegmented.tsx
Normal file
59
frontend/components/admin/AdminSegmented.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/components/admin/AdminSettingRow.tsx
Normal file
36
frontend/components/admin/AdminSettingRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/components/admin/AdminStatCard.tsx
Normal file
46
frontend/components/admin/AdminStatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
frontend/components/admin/AdminStatusChip.tsx
Normal file
38
frontend/components/admin/AdminStatusChip.tsx
Normal 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" };
|
||||
}
|
||||
28
frontend/components/admin/AdminSwitch.tsx
Normal file
28
frontend/components/admin/AdminSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/components/admin/index.ts
Normal file
13
frontend/components/admin/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user