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

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

View File

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

View File

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