polish(admin): 抽出后台控件包并统一各页布局与交互
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)" }}>
|
||||
|
||||
Reference in New Issue
Block a user