feat: 新增管理员用户管理功能与账号封禁体系
此提交完成了全站管理员后台核心功能开发: 1. 新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封 2. 重构认证链路,统一账号封禁错误码与处理逻辑 3. 新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态 4. 重构管理员后台路由与布局,统一权限校验逻辑 5. 优化通知轮询逻辑,页面切回前台时立即刷新未读计数 6. 合并原公告、外观管理页到统一后台面板
This commit is contained in:
872
frontend/app/admin/users/UsersAdmin.tsx
Normal file
872
frontend/app/admin/users/UsersAdmin.tsx
Normal file
@@ -0,0 +1,872 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Ban,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Users as UsersIcon,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminListUsers,
|
||||
apiAdminSetUserBan,
|
||||
apiAdminSetUserRole,
|
||||
type AdminUser,
|
||||
type AdminUserSummary,
|
||||
type AdminUsersResponse,
|
||||
} from "@/lib/api";
|
||||
import { formatDate, formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
|
||||
export type UserFilter = "all" | "admin" | "banned";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const FILTER_TABS: { key: UserFilter; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "admin", label: "管理员" },
|
||||
{ key: "banned", label: "已封禁" },
|
||||
];
|
||||
|
||||
function filterToParams(filter: UserFilter): { role?: string; status?: string } {
|
||||
if (filter === "admin") return { role: "admin" };
|
||||
if (filter === "banned") return { status: "banned" };
|
||||
return {};
|
||||
}
|
||||
|
||||
// 待二次确认的危险操作种类
|
||||
export type PendingKind = "promote" | "demote" | "ban" | "unban";
|
||||
|
||||
export interface PendingAction {
|
||||
kind: PendingKind;
|
||||
user: AdminUser;
|
||||
}
|
||||
|
||||
// 确认弹窗的文案与视觉配置(说明需拼入用户名,故 desc 为函数)
|
||||
const CONFIRM_CONFIG: Record<
|
||||
PendingKind,
|
||||
{
|
||||
title: string;
|
||||
desc: (u: AdminUser) => string;
|
||||
confirmText: string;
|
||||
tone: "accent" | "danger" | "ok";
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
> = {
|
||||
promote: {
|
||||
title: "设为管理员",
|
||||
desc: (u) =>
|
||||
`@${u.username} 将获得管理面板访问权限,可管理公告、外观设置与用户账号。`,
|
||||
confirmText: "设为管理员",
|
||||
tone: "accent",
|
||||
icon: <ShieldCheck size={23} />,
|
||||
},
|
||||
demote: {
|
||||
title: "取消管理员",
|
||||
desc: (u) =>
|
||||
`将管理员 @${u.username} 降级为普通用户。操作后该账号会被立即强制下线,需要重新登录。`,
|
||||
confirmText: "确认降级",
|
||||
tone: "danger",
|
||||
icon: <ShieldOff size={23} />,
|
||||
},
|
||||
ban: {
|
||||
title: "封禁账号",
|
||||
desc: (u) =>
|
||||
`封禁后 @${u.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`,
|
||||
confirmText: "确认封禁",
|
||||
tone: "danger",
|
||||
icon: <Ban size={23} />,
|
||||
},
|
||||
unban: {
|
||||
title: "解除封禁",
|
||||
desc: (u) => `解除后 @${u.username} 可以重新登录,并恢复发帖、回复等全部功能。`,
|
||||
confirmText: "解除封禁",
|
||||
tone: "ok",
|
||||
icon: <RotateCcw size={21} />,
|
||||
},
|
||||
};
|
||||
|
||||
// 汇总小卡
|
||||
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] font-semibold 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] font-semibold" style={{ color: "var(--ink-3)" }}>
|
||||
{sub}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 角色/状态徽章
|
||||
function Badge({
|
||||
color,
|
||||
soft,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
color: string;
|
||||
soft: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold leading-[1.6] whitespace-nowrap"
|
||||
style={{ background: soft, color }}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersAdmin({
|
||||
initial,
|
||||
initError,
|
||||
initialQuery,
|
||||
currentUserId,
|
||||
}: {
|
||||
initial: AdminUsersResponse | null;
|
||||
initError: string;
|
||||
initialQuery: { q: string; filter: UserFilter; page: number };
|
||||
currentUserId: number;
|
||||
}) {
|
||||
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
|
||||
const [summary, setSummary] = useState<AdminUserSummary>(
|
||||
initial?.summary ?? { total: 0, admins: 0, banned: 0, today_new: 0 }
|
||||
);
|
||||
const [total, setTotal] = useState(initial?.total ?? 0);
|
||||
const [page, setPage] = useState(initialQuery.page);
|
||||
const [filter, setFilter] = useState<UserFilter>(initialQuery.filter);
|
||||
const [keyword, setKeyword] = useState(initialQuery.q);
|
||||
const [appliedQ, setAppliedQ] = useState(initialQuery.q);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(initError);
|
||||
const [actingId, setActingId] = useState<number | null>(null);
|
||||
// 待确认的危险操作(提权/降级/封禁/解封);null 表示弹窗关闭
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
|
||||
// 防止快速翻页/输入时旧响应覆盖新结果
|
||||
const seqRef = useRef(0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const urlFirstRun = useRef(true);
|
||||
const router = useRouter();
|
||||
|
||||
const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => {
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiAdminListUsers({
|
||||
page: opts.page,
|
||||
size: PAGE_SIZE,
|
||||
q: opts.q || undefined,
|
||||
...filterToParams(opts.filter),
|
||||
});
|
||||
if (seq !== seqRef.current) return;
|
||||
setUsers(res.users);
|
||||
setSummary(res.summary);
|
||||
setTotal(res.total);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
setError(e instanceof Error ? e.message : "获取用户列表失败");
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const q = keyword.trim();
|
||||
if (q !== appliedQ) {
|
||||
setAppliedQ(q);
|
||||
setPage(1);
|
||||
load({ page: 1, filter, q });
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keyword]);
|
||||
|
||||
// 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
|
||||
useEffect(() => {
|
||||
if (urlFirstRun.current) {
|
||||
urlFirstRun.current = false;
|
||||
return;
|
||||
}
|
||||
const p = new URLSearchParams();
|
||||
if (appliedQ) p.set("q", appliedQ);
|
||||
if (filter !== "all") p.set("filter", filter);
|
||||
if (page > 1) p.set("page", String(page));
|
||||
const qs = p.toString();
|
||||
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
|
||||
}, [appliedQ, filter, page, router]);
|
||||
|
||||
const submitSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = keyword.trim();
|
||||
if (q === appliedQ) return;
|
||||
setAppliedQ(q);
|
||||
setPage(1);
|
||||
load({ page: 1, filter, q });
|
||||
};
|
||||
|
||||
// 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
|
||||
const clearSearch = () => {
|
||||
setKeyword("");
|
||||
if (appliedQ !== "" || page !== 1) {
|
||||
setAppliedQ("");
|
||||
setPage(1);
|
||||
load({ page: 1, filter, q: "" });
|
||||
}
|
||||
};
|
||||
|
||||
const changeFilter = (f: UserFilter) => {
|
||||
if (f === filter) return;
|
||||
setFilter(f);
|
||||
setPage(1);
|
||||
load({ page: 1, filter: f, q: appliedQ });
|
||||
};
|
||||
|
||||
const goPage = (p: number) => {
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const next = Math.min(Math.max(1, p), totalPages);
|
||||
if (next === page) return;
|
||||
setPage(next);
|
||||
load({ page: next, filter, q: appliedQ });
|
||||
};
|
||||
|
||||
// 危险操作不再使用浏览器原生 confirm,统一走站点风格的二次确认弹窗
|
||||
const requestRoleChange = (u: AdminUser) => {
|
||||
setPending({ kind: u.role === "admin" ? "demote" : "promote", user: u });
|
||||
};
|
||||
|
||||
const requestBanChange = (u: AdminUser) => {
|
||||
setPending({ kind: u.banned ? "unban" : "ban", user: u });
|
||||
};
|
||||
|
||||
// 确认弹窗中点击"确认"后执行;成功关闭弹窗并重拉列表,
|
||||
// 保证筛选结果集与汇总计数一致;失败保留弹窗,错误走 toast
|
||||
const confirmPending = async () => {
|
||||
const action = pending;
|
||||
if (!action) return;
|
||||
const { kind, user: u } = action;
|
||||
setActingId(u.id);
|
||||
try {
|
||||
if (kind === "promote" || kind === "demote") {
|
||||
await apiAdminSetUserRole(u.id, kind === "promote" ? "admin" : "user");
|
||||
toast(kind === "promote" ? "已设为管理员" : "已取消管理员", "ok");
|
||||
} else {
|
||||
await apiAdminSetUserBan(u.id, kind === "ban");
|
||||
toast(kind === "ban" ? "已封禁" : "已解封", "ok");
|
||||
}
|
||||
setPending(null);
|
||||
await load({ page, filter, q: appliedQ });
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "操作失败");
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const tabCount = (f: UserFilter) =>
|
||||
f === "all" ? summary.total : f === "admin" ? summary.admins : summary.banned;
|
||||
// 有关键词或非默认筛选时,空态文案与"无成员"区分
|
||||
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 renderActions = (u: AdminUser, isSelf: boolean) => {
|
||||
if (isSelf) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-bold whitespace-nowrap"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||
>
|
||||
当前账号
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const busy = actingId === u.id;
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
title="查看个人主页"
|
||||
aria-label={`查看 ${u.nickname} 的个人主页`}
|
||||
className={ghostBtn}
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={u.role === "admin" ? "取消管理员" : "设为管理员"}
|
||||
aria-label={u.role === "admin" ? `取消 ${u.username} 的管理员` : `将 ${u.username} 设为管理员`}
|
||||
disabled={busy}
|
||||
onClick={() => requestRoleChange(u)}
|
||||
className={ghostBtn}
|
||||
style={u.role === "admin" ? { color: "var(--accent)" } : { color: "var(--ink-3)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--accent-soft)";
|
||||
e.currentTarget.style.color = "var(--accent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "";
|
||||
e.currentTarget.style.color = u.role === "admin" ? "var(--accent)" : "var(--ink-3)";
|
||||
}}
|
||||
>
|
||||
{busy ? <Loader2 size={15} className="animate-spin" /> : u.role === "admin" ? <ShieldOff size={15} /> : <ShieldCheck size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={u.banned ? "解封" : "封禁"}
|
||||
aria-label={u.banned ? `解封 ${u.username}` : `封禁 ${u.username}`}
|
||||
disabled={busy}
|
||||
onClick={() => requestBanChange(u)}
|
||||
className={ghostBtn}
|
||||
style={u.banned ? { color: "var(--danger)" } : { color: "var(--ink-3)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--danger-soft)";
|
||||
e.currentTarget.style.color = "var(--danger)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "";
|
||||
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--ink-3)";
|
||||
}}
|
||||
>
|
||||
{u.banned ? <RotateCcw size={15} /> : <Ban size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBadges = (u: AdminUser) => (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{u.role === "admin" ? (
|
||||
<Badge color="var(--accent)" soft="var(--accent-soft)" icon={<ShieldCheck size={11} />}>
|
||||
管理员
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="var(--ink-3)" soft="var(--panel-2)" icon={<UsersIcon size={11} />}>
|
||||
普通用户
|
||||
</Badge>
|
||||
)}
|
||||
{u.banned && (
|
||||
<Badge color="var(--danger)" soft="var(--danger-soft)" icon={<Ban size={11} />}>
|
||||
已封禁
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 桌面端列模板(与表头一致)
|
||||
const rowGrid =
|
||||
"hidden md:grid md:grid-cols-[minmax(0,1.7fr)_88px_92px_104px_100px_108px_150px] gap-3 items-center px-4 sm:px-5 py-3.5";
|
||||
|
||||
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>
|
||||
|
||||
{/* 汇总 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<MiniStat
|
||||
label="注册用户"
|
||||
value={String(summary.total)}
|
||||
sub={summary.today_new > 0 ? `今日 +${summary.today_new}` : undefined}
|
||||
icon={<UsersIcon size={16} />}
|
||||
color="var(--accent)"
|
||||
soft="var(--accent-soft)"
|
||||
/>
|
||||
<MiniStat
|
||||
label="管理员"
|
||||
value={String(summary.admins)}
|
||||
icon={<ShieldCheck size={16} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<MiniStat
|
||||
label="已封禁"
|
||||
value={String(summary.banned)}
|
||||
icon={<Ban size={16} />}
|
||||
color="var(--danger)"
|
||||
soft="var(--danger-soft)"
|
||||
/>
|
||||
<MiniStat
|
||||
label="当前筛选"
|
||||
value={String(total)}
|
||||
sub="人"
|
||||
icon={<UserPlus size={16} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft)"
|
||||
/>
|
||||
</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] font-semibold 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: active ? "var(--panel)" : "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="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2 flex-wrap"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
{appliedQ ? (
|
||||
<>
|
||||
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
|
||||
</>
|
||||
) : filter === "admin" ? (
|
||||
"管理员"
|
||||
) : filter === "banned" ? (
|
||||
"已封禁成员"
|
||||
) : (
|
||||
"全部成员"
|
||||
)}
|
||||
</span>
|
||||
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
共 {total} 人
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 桌面表头 */}
|
||||
{users.length > 0 && (
|
||||
<div
|
||||
className={`${rowGrid} text-[11.5px] font-semibold uppercase tracking-wide`}
|
||||
style={{ color: "var(--ink-3)", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
|
||||
>
|
||||
<span>成员</span>
|
||||
<span>角色</span>
|
||||
<span>状态</span>
|
||||
<span>内容</span>
|
||||
<span>注册</span>
|
||||
<span>最近活跃</span>
|
||||
<span className="text-right">操作</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div aria-busy={loading} style={{ opacity: loading ? 0.55 : 1, transition: "opacity .15s" }}>
|
||||
{error ? (
|
||||
<p className="alert-error m-4">{error}</p>
|
||||
) : loading && users.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
|
||||
<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="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
|
||||
{hasFilter ? "没有匹配的成员" : "暂无成员"}
|
||||
</p>
|
||||
<p className="meta mt-1.5 text-[12.5px]">
|
||||
{hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
users.map((u) => {
|
||||
const isSelf = u.id === currentUserId;
|
||||
return (
|
||||
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
|
||||
{/* 桌面行 */}
|
||||
<div className={rowGrid}>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
|
||||
{u.nickname}
|
||||
</p>
|
||||
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
|
||||
@{u.username}
|
||||
{u.email ? ` · ${u.email}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{u.role === "admin" ? (
|
||||
<Badge color="var(--accent)" soft="var(--accent-soft)" icon={<ShieldCheck size={11} />}>
|
||||
管理员
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-[12.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
|
||||
普通用户
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{u.banned ? (
|
||||
<Badge color="var(--danger)" soft="var(--danger-soft)" icon={<Ban size={11} />}>
|
||||
已封禁
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold" style={{ color: "var(--ok)" }}>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--ok)" }} aria-hidden="true" />
|
||||
正常
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12.5px] font-semibold tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||
帖 {u.post_count}
|
||||
<span className="mx-1" style={{ color: "var(--line-2)" }}>·</span>
|
||||
评 {u.comment_count}
|
||||
</p>
|
||||
<p className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
{formatDate(u.created_at)}
|
||||
</p>
|
||||
<p className="text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未活跃"}
|
||||
</p>
|
||||
<div className="flex justify-end">{renderActions(u, isSelf)}</div>
|
||||
</div>
|
||||
|
||||
{/* 移动卡片 */}
|
||||
<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} />
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}>
|
||||
{u.nickname}
|
||||
</span>
|
||||
{renderBadges(u)}
|
||||
{isSelf && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] font-bold leading-[1.55]"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||
>
|
||||
当前账号
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||
@{u.username}
|
||||
{u.email ? ` · ${u.email}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2.5 text-[11.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)} · 活跃{" "}
|
||||
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未"}
|
||||
</p>
|
||||
{!isSelf && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
|
||||
<ExternalLink size={13} /> 主页
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actingId === u.id}
|
||||
onClick={() => requestRoleChange(u)}
|
||||
className="btn btn-line btn-sm"
|
||||
style={u.role === "admin" ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
|
||||
>
|
||||
{actingId === u.id ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : u.role === "admin" ? (
|
||||
<ShieldOff size={13} />
|
||||
) : (
|
||||
<ShieldCheck size={13} />
|
||||
)}
|
||||
{u.role === "admin" ? "取消管理员" : "设为管理员"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actingId === u.id}
|
||||
onClick={() => requestBanChange(u)}
|
||||
className="btn btn-sm"
|
||||
style={
|
||||
u.banned
|
||||
? { background: "var(--ok-soft)", color: "var(--ok)" }
|
||||
: { background: "var(--danger-soft)", color: "var(--danger)" }
|
||||
}
|
||||
>
|
||||
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
|
||||
{u.banned ? "解封" : "封禁"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 危险操作二次确认(替代浏览器原生 confirm,与站点视觉语言一致) */}
|
||||
<ActionConfirmDialog
|
||||
action={pending}
|
||||
busy={actingId === pending?.user.id}
|
||||
onCancel={() => setPending(null)}
|
||||
onConfirm={confirmPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 危险操作确认弹窗:遮罩 + 居中卡片;ESC/点遮罩等同取消,执行中锁定所有关闭途径
|
||||
function ActionConfirmDialog({
|
||||
action,
|
||||
busy,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
action: PendingAction | null;
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!action) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !busy) onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [action, busy, onCancel]);
|
||||
|
||||
if (!action) return null;
|
||||
const cfg = CONFIRM_CONFIG[action.kind];
|
||||
const iconColor =
|
||||
cfg.tone === "danger" ? "var(--danger)" : cfg.tone === "ok" ? "var(--ok)" : "var(--accent)";
|
||||
const iconSoft =
|
||||
cfg.tone === "danger"
|
||||
? "var(--danger-soft)"
|
||||
: cfg.tone === "ok"
|
||||
? "var(--ok-soft)"
|
||||
: "var(--accent-soft)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
|
||||
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
|
||||
onClick={() => !busy && onCancel()}
|
||||
>
|
||||
<div
|
||||
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6"
|
||||
style={{ boxShadow: "var(--shadow-lg)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-action-title"
|
||||
aria-describedby="user-action-desc"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ background: iconSoft, color: iconColor }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
id="user-action-title"
|
||||
className="text-[16px] font-extrabold tracking-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{cfg.title}
|
||||
</h3>
|
||||
<p id="user-action-desc" className="meta mt-1.5 text-[13px] leading-relaxed">
|
||||
{cfg.desc(action.user)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{cfg.tone === "danger" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <Loader2 size={15} className="animate-spin" />}
|
||||
{cfg.confirmText}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={
|
||||
cfg.tone === "ok"
|
||||
? { background: "var(--ok-soft)", color: "var(--ok)" }
|
||||
: undefined
|
||||
}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <Loader2 size={15} className="animate-spin" />}
|
||||
{cfg.confirmText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user