"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: , }, demote: { title: "取消管理员", desc: (u) => `将管理员 @${u.username} 降级为普通用户。操作后该账号会被立即强制下线,需要重新登录。`, confirmText: "确认降级", tone: "danger", icon: , }, ban: { title: "封禁账号", desc: (u) => `封禁后 @${u.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`, confirmText: "确认封禁", tone: "danger", icon: , }, unban: { title: "解除封禁", desc: (u) => `解除后 @${u.username} 可以重新登录,并恢复发帖、回复等全部功能。`, confirmText: "解除封禁", tone: "ok", icon: , }, }; // 汇总小卡 function MiniStat({ label, value, sub, icon, color, soft, }: { label: string; value: string; sub?: string; icon: React.ReactNode; color: string; soft: string; }) { return (

{label}

{value} {sub && ( {sub} )}

); } // 角色/状态徽章 function Badge({ color, soft, icon, children, }: { color: string; soft: string; icon: React.ReactNode; children: React.ReactNode; }) { return ( {icon} {children} ); } 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(initial?.users ?? []); const [summary, setSummary] = useState( 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(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(null); // 待确认的危险操作(提权/降级/封禁/解封);null 表示弹窗关闭 const [pending, setPending] = useState(null); // 防止快速翻页/输入时旧响应覆盖新结果 const seqRef = useRef(0); const debounceRef = useRef | 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 ( 当前账号 ); } const busy = actingId === u.id; return (
); }; const renderBadges = (u: AdminUser) => (
{u.role === "admin" ? ( }> 管理员 ) : ( }> 普通用户 )} {u.banned && ( }> 已封禁 )}
); // 桌面端列模板(与表头一致) 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 (
{/* 页头 */}

用户管理

检索全部成员,管理管理员角色与封禁状态;操作即时生效

{/* 汇总 */}
0 ? `今日 +${summary.today_new}` : undefined} icon={} color="var(--accent)" soft="var(--accent-soft)" /> } color="var(--gold)" soft="var(--gold-soft)" /> } color="var(--danger)" soft="var(--danger-soft)" /> } color="var(--ok)" soft="var(--ok-soft)" />
{/* 搜索 + 筛选 */}
setKeyword(e.target.value)} placeholder="搜索用户名、昵称或邮箱…" aria-label="搜索用户" className="field pl-10 pr-9 py-2.5" /> {keyword && ( )}
{FILTER_TABS.map((t) => { const active = filter === t.key; return ( ); })}
{/* 用户列表 */}
{appliedQ ? ( <> 搜索“{appliedQ}” ) : filter === "admin" ? ( "管理员" ) : filter === "banned" ? ( "已封禁成员" ) : ( "全部成员" )} 共 {total} 人
{/* 桌面表头 */} {users.length > 0 && (
成员 角色 状态 内容 注册 最近活跃 操作
)}
{error ? (

{error}

) : loading && users.length === 0 ? (
加载中…
) : users.length === 0 ? (

{hasFilter ? "没有匹配的成员" : "暂无成员"}

{hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}

) : ( users.map((u) => { const isSelf = u.id === currentUserId; return (
{/* 桌面行 */}

{u.nickname}

@{u.username} {u.email ? ` · ${u.email}` : ""}

{u.role === "admin" ? ( }> 管理员 ) : ( 普通用户 )}
{u.banned ? ( }> 已封禁 ) : ( )}

帖 {u.post_count} · 评 {u.comment_count}

{formatDate(u.created_at)}

{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未活跃"}

{renderActions(u, isSelf)}
{/* 移动卡片 */}
{u.nickname} {renderBadges(u)} {isSelf && ( 当前账号 )}

@{u.username} {u.email ? ` · ${u.email}` : ""}

帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)} · 活跃{" "} {u.last_seen_at ? formatRelative(u.last_seen_at) : "从未"}

{!isSelf && (
主页
)}
); }) )}
{/* 分页 */} {total > 0 && (
第 {page} / {totalPages} 页 · 共 {total} 人
)}
{/* 危险操作二次确认(替代浏览器原生 confirm,与站点视觉语言一致) */} setPending(null)} onConfirm={confirmPending} />
); } // 危险操作确认弹窗:遮罩 + 居中卡片;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 (
!busy && onCancel()} >
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="user-action-title" aria-describedby="user-action-desc" >

{cfg.title}

{cfg.desc(action.user)}

{cfg.tone === "danger" ? ( ) : ( )}
); }