"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Ban, ChevronLeft, ChevronRight, Clock, ExternalLink, History, Loader2, Monitor, RotateCcw, Search, ShieldCheck, Users as UsersIcon, UserPlus, X, } from "lucide-react"; import { apiAdminListUsers, apiAdminSetUserBan, apiAdminSetUserRole, apiAdminUserLoginLogs, apiListBoards, type AdminUser, type AdminUserSummary, type AdminUsersResponse, type Board, type LoginLog, } from "@/lib/api"; import { ROLES } from "@/lib/roles"; import { formatDate, formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; export type UserFilter = "all" | "staff" | "banned"; const PAGE_SIZE = 20; const FILTER_TABS: { key: UserFilter; label: string }[] = [ { key: "all", label: "全部" }, { key: "staff", label: "管理团队" }, { key: "banned", label: "已封禁" }, ]; function filterToParams(filter: UserFilter): { role?: string; status?: string } { if (filter === "staff") return { role: "staff" }; if (filter === "banned") return { status: "banned" }; return {}; } // 可授予的角色选项(超级管理员仅站长可见可授;owner 不出现也不可授予) interface RoleOption { value: string; title: string; desc: string; icon: React.ReactNode; token: string; } // 汇总小卡 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} )}

); } export default function UsersAdmin({ initial, initError, initialQuery, currentUserId, currentUserRole, }: { initial: AdminUsersResponse | null; initError: string; initialQuery: { q: string; filter: UserFilter; page: number }; currentUserId: number; currentUserRole: string; }) { 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 [banTarget, setBanTarget] = useState(null); // 角色授权弹窗 / 登录历史弹窗 const [roleTarget, setRoleTarget] = useState(null); const [logsTarget, setLogsTarget] = useState(null); // 板块字典(角色弹窗勾选 + 徽章板块名) const [boards, setBoards] = useState([]); // 防止快速翻页/输入时旧响应覆盖新结果 const seqRef = useRef(0); const debounceRef = useRef | null>(null); const urlFirstRun = useRef(true); const router = useRouter(); const boardNameMap = useMemo(() => { const m = new Map(); boards.forEach((b) => m.set(b.id, b.name)); return m; }, [boards]); useEffect(() => { apiListBoards() .then((r) => setBoards(r.boards ?? [])) .catch(() => {}); }, []); 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 }); }; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const tabCount = (f: UserFilter) => f === "all" ? summary.total : f === "staff" ? 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 reload = () => load({ page, filter, q: appliedQ }); const renderStatus = (u: AdminUser) => { if (u.banned) { return ( 封禁 ); } if (u.online) { return ( 在线 ); } return ( 离线 ); }; const renderLastLogin = (u: AdminUser) => ( ); const renderActions = (u: AdminUser, isSelf: boolean) => { // 站长账号:任何人不可操作(后端 403 兜底),仅保留主页入口与站长标识 if (u.role === ROLES.OWNER) { return (
); } if (isSelf) { return ( 当前账号 ); } const busy = actingId === u.id; return (
); }; // 桌面端列模板(成员列收窄,新增在线状态与上次登录 IP;xl 起展示内容/注册列) const rowGrid = "hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 " + "md:grid-cols-[minmax(0,1fr)_78px_minmax(108px,0.95fr)_146px_136px] " + "xl:grid-cols-[minmax(0,1.25fr)_78px_minmax(108px,0.95fr)_146px_116px_136px]"; 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 === "staff" ? ( "管理团队" ) : filter === "banned" ? ( "已封禁成员" ) : ( "全部成员" )} 共 {total} 人
{/* 桌面表头 */} {users.length > 0 && (
成员 状态 角色 上次登录 IP 内容 / 注册 操作
)}
{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}` : ""}

{renderStatus(u)}
{renderLastLogin(u)}

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

{formatDate(u.created_at)}

{renderActions(u, isSelf)}
{/* 移动卡片 */}
{u.nickname} {u.role !== ROLES.USER && ( )} {isSelf && ( 当前账号 )}

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

{renderStatus(u)}

帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}

{!isSelf && u.role !== ROLES.OWNER && (
主页
)}
); }) )}
{/* 分页 */} {total > 0 && (
第 {page} / {totalPages} 页 · 共 {total} 人
)}
setRoleTarget(null)} onSaved={() => { setRoleTarget(null); reload(); }} setActing={(v) => setActingId(v ? roleTarget?.id ?? null : null)} /> setBanTarget(null)} onConfirm={async () => { const u = banTarget; if (!u) return; setActingId(u.id); try { await apiAdminSetUserBan(u.id, !u.banned); toast(u.banned ? "已解封" : "已封禁", "ok"); setBanTarget(null); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "操作失败"); } finally { setActingId(null); } }} /> setLogsTarget(null)} />
); } // ===== 角色授权弹窗 ===== function RoleAssignDialog({ target, boards, canGrantSuper, acting, onClose, onSaved, setActing, }: { target: AdminUser | null; boards: Board[]; canGrantSuper: boolean; acting: boolean; onClose: () => void; onSaved: () => void; setActing: (v: boolean) => void; }) { // 可授予角色(按等级从高到低);超管选项仅站长可见 const options: RoleOption[] = [ { value: ROLES.SUPER_ADMIN, title: "超级管理员", desc: "可操作全站后台:用户权限、公告、外观设置、内容审核;站长账号除外", icon: , token: "--accent", }, { value: ROLES.ADMIN, title: "管理员", desc: "可管理公告,审核全站帖子与评论,可置顶/加精", icon: , token: "--accent", }, { value: ROLES.BOARD_ADMIN, title: "板块管理员", desc: "仅可审核被授权板块的帖子与评论", icon: , token: "--accent", }, { value: ROLES.USER, title: "普通用户", desc: "无后台权限;发帖与评论需通过审核", icon: , token: "--ink-3", }, ].filter((o) => canGrantSuper || o.value !== ROLES.SUPER_ADMIN); const [role, setRole] = useState(ROLES.USER); const [boardIds, setBoardIds] = useState([]); const [err, setErr] = useState(""); // 每次打开按目标当前角色/授权初始化 useEffect(() => { if (target) { setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role); setBoardIds(target.board_ids ?? []); setErr(""); } }, [target]); useEffect(() => { if (!target) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !acting) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [target, acting, onClose]); if (!target) return null; const toggleBoard = (id: number) => { setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id])); }; const submit = async () => { if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) { setErr("请至少选择一个授权板块"); return; } setActing(true); try { await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []); toast("角色权限已更新", "ok"); onSaved(); } catch (e) { toast(e instanceof Error ? e.message : "操作失败"); } finally { setActing(false); } }; return (
!acting && onClose()} >
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="设置管理角色" >

设置管理角色

@{target.username}({target.nickname})

{options.map((o) => { const active = role === o.value; return ( ); })}
{/* 板块授权(仅板块管理员) */} {role === ROLES.BOARD_ADMIN && (

授权板块 *

{boards.length === 0 ? (

板块加载中…

) : (
{boards.map((b) => { const checked = boardIds.includes(b.id); return ( ); })}
)} {err &&

{err}

}
)}
); } // ===== 封禁确认弹窗 ===== function BanConfirmDialog({ target, busy, onCancel, onConfirm, }: { target: AdminUser | null; busy: boolean; onCancel: () => void; onConfirm: () => void; }) { useEffect(() => { if (!target) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !busy) onCancel(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [target, busy, onCancel]); if (!target) return null; const banning = !target.banned; return (
!busy && onCancel()} >
e.stopPropagation()} role="dialog" aria-modal="true" >

{banning ? "封禁账号" : "解除封禁"}

{banning ? `封禁后 @${target.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。` : `解除后 @${target.username} 可以重新登录,并恢复发帖、回复等全部功能。`}

{banning ? ( ) : ( )}
); } // ===== 登录历史弹窗 ===== function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) { const [logs, setLogs] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [err, setErr] = useState(""); useEffect(() => { if (!target) return; let cancelled = false; setLoading(true); setErr(""); apiAdminUserLoginLogs(target.id, page) .then((res) => { if (cancelled) return; setLogs(res.logs); setTotal(res.total); }) .catch((e) => { if (!cancelled) setErr(e instanceof Error ? e.message : "获取登录历史失败"); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [target, page]); useEffect(() => { if (target) setPage(1); }, [target]); useEffect(() => { if (!target) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [target, onClose]); if (!target) return null; const pageSize = 15; const totalPages = Math.max(1, Math.ceil(total / pageSize)); return (
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="登录历史" >

登录历史

@{target.username} · 共 {total} 条记录(含失败尝试)

{loading ? (
加载中…
) : err ? (

{err}

) : logs.length === 0 ? (

暂无登录记录

) : (
    {logs.map((log) => (
  • {log.ip || "未知 IP"} {log.success ? "成功" : "失败"}

    {log.user_agent || "未知设备"}

    {formatDate(log.created_at)} {new Date(log.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
  • ))}
)}
{total > pageSize && (
第 {page} / {totalPages} 页
)}
); }