"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { ArrowUpDown, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, ChevronUp, Clock, Columns3, ExternalLink, FolderSearch, History, Monitor, ShieldAlert, ShieldCheck, Users, Activity, Hourglass, } from "lucide-react"; import { apiAdminListUsers, apiAdminUserLoginLogs, apiListBoards, type AdminUser, type AdminUserSummary, type AdminUsersResponse, type Board, type LoginLog, } from "@/lib/api"; import { isOwner, ROLES } from "@/lib/roles"; import { formatDate, formatRelative } from "@/lib/format"; 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, AdminPageHeader, AdminPager, AdminSearchField, AdminSegmented, AdminStatCard, } from "@/components/admin"; export type UserFilter = "all" | "staff" | "banned"; export type UserSortKey = "id" | "role" | "status" | "points" | "last_login" | "counts" | "created_at" | "online"; export type UserSortDir = "asc" | "desc"; const PAGE_SIZE = 20; const COLS_STORAGE_KEY = "j13-admin-user-cols"; /** 默认始终显示的列 */ const CORE_COLS = ["id", "member", "role", "status", "points", "last_login", "actions"] as const; /** 可选列(localStorage 开关) */ const OPTIONAL_COLS = ["online", "email", "counts", "created_at"] as const; type CoreCol = (typeof CORE_COLS)[number]; type OptionalCol = (typeof OPTIONAL_COLS)[number]; type ColKey = CoreCol | OptionalCol; /** 可服务端排序的列(与后端白名单一致) */ const SORTABLE_COLS = new Set(["id", "role", "status", "points", "last_login", "counts", "created_at", "online"]); const OPTIONAL_LABELS: Record = { online: "在线", email: "邮箱", counts: "帖/评数", created_at: "注册时间", }; const DEFAULT_OPTIONAL: Record = { online: false, email: false, counts: false, created_at: false, }; function loadOptionalCols(): Record { if (typeof window === "undefined") return { ...DEFAULT_OPTIONAL }; try { const raw = localStorage.getItem(COLS_STORAGE_KEY); if (!raw) return { ...DEFAULT_OPTIONAL }; const parsed = JSON.parse(raw) as Partial>; return { online: !!parsed.online, email: !!parsed.email, counts: !!parsed.counts, created_at: !!parsed.created_at, }; } catch { return { ...DEFAULT_OPTIONAL }; } } const FILTER_TABS: { key: UserFilter; label: string }[] = [ { key: "all", label: "全部" }, { key: "staff", label: "管理团队" }, { key: "banned", label: "已封禁" }, ]; const SORT_OPTIONS: { key: UserSortKey; label: string }[] = [ { key: "id", label: "用户 ID" }, { key: "role", label: "角色" }, { key: "status", label: "状态" }, { key: "points", label: "积分" }, { key: "last_login", label: "上次登录" }, { key: "counts", label: "内容" }, { key: "created_at", label: "注册时间" }, { key: "online", label: "在线" }, ]; function filterToParams(filter: UserFilter): { role?: string; status?: string } { if (filter === "staff") return { role: "staff" }; if (filter === "banned") return { status: "banned" }; return {}; } function ColumnConfigPopover({ cols, onChange, }: { cols: Record; onChange: (next: Record) => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); const enabledCount = OPTIONAL_COLS.filter((k) => cols[k]).length; return (
{open && (

可选列

    {OPTIONAL_COLS.map((key) => (
  • ))}

成员 / ID / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示

)}
); } function MobileSortPopover({ sort, order, onChange, }: { sort: UserSortKey; order: UserSortDir; onChange: (key: UserSortKey, dir: UserSortDir) => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); const current = SORT_OPTIONS.find((o) => o.key === sort); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); return (
{open && (
    {SORT_OPTIONS.map((o) => { const active = sort === o.key; return (
  • ); })}
)}
); } export default function UsersAdmin({ initial, initError, initialQuery, currentUserId, currentUserRole, }: { initial: AdminUsersResponse | null; initError: string; initialQuery: { q: string; filter: UserFilter; page: number; sort: UserSortKey; order: UserSortDir }; currentUserId: number; currentUserRole: string; }) { const [users, setUsers] = useState(initial?.users ?? []); const [summary, setSummary] = useState( initial?.summary ?? { online: 0, in_cooldown: 0, active_7d: 0, failed_logins_24h: 0, total: 0, admins: 0, banned: 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 [sort, setSort] = useState(initialQuery.sort); const [order, setOrder] = useState(initialQuery.order); const [loading, setLoading] = useState(false); const [error, setError] = useState(initError); const [actingId, setActingId] = useState(null); const [roleTarget, setRoleTarget] = useState(null); const [logsTarget, setLogsTarget] = useState(null); const [boards, setBoards] = useState([]); const [optCols, setOptCols] = useState>(DEFAULT_OPTIONAL); const seqRef = useRef(0); const debounceRef = useRef | null>(null); const urlFirstRun = useRef(true); const router = useRouter(); useEffect(() => { setOptCols(loadOptionalCols()); }, []); const persistCols = (next: Record) => { setOptCols(next); try { localStorage.setItem(COLS_STORAGE_KEY, JSON.stringify(next)); } catch { /* ignore */ } }; 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(() => {}); }, []); useEffect(() => { const reconcile = (ids: ReadonlySet) => { if (ids.size === 0) return; setUsers((prev) => prev.map((u) => (u.online === ids.has(u.id) ? u : { ...u, online: ids.has(u.id) })) ); }; reconcile(realtime.getOnlineIds()); const off = realtime.onPresence(({ user_id: id, online }) => { setUsers((prev) => prev.map((u) => (u.id === id && u.online !== online ? { ...u, online } : u)) ); }); return off; }, []); const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string; sort: UserSortKey; order: UserSortDir; }) => { const seq = ++seqRef.current; setLoading(true); try { const res = await apiAdminListUsers({ page: opts.page, size: PAGE_SIZE, q: opts.q || undefined, sort: opts.sort, order: opts.order, ...filterToParams(opts.filter), }); if (seq !== seqRef.current) return; setUsers(res.users); setSummary(res.summary); setTotal(res.total); setFilter(opts.filter); setPage(opts.page); setAppliedQ(opts.q); setSort(opts.sort); setOrder(opts.order); setError(""); } catch (e) { if (seq !== seqRef.current) return; setError(e instanceof Error ? e.message : "获取用户列表失败"); } finally { if (seq === seqRef.current) setLoading(false); } }, []); useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { const q = keyword.trim(); if (q !== appliedQ) { load({ page: 1, filter, q, sort, order }); } }, 400); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [keyword, filter, sort, order]); 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)); if (sort !== "id") p.set("sort", sort); if (order === "asc") p.set("order", "asc"); const qs = p.toString(); router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false }); }, [appliedQ, filter, page, sort, order, router]); const submitSearch = () => { const q = keyword.trim(); if (q === appliedQ) return; load({ page: 1, filter, q, sort, order }); }; const clearSearch = () => { setKeyword(""); if (appliedQ !== "" || page !== 1) { load({ page: 1, filter, q: "", sort, order }); } }; const changeFilter = (f: UserFilter) => { if (f === filter) return; load({ page: 1, filter: f, q: appliedQ, sort, order }); }; 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; load({ page: next, filter, q: appliedQ, sort, order }); }; const toggleSort = (key: UserSortKey) => { const nextDir: UserSortDir = sort === key && order === "desc" ? "asc" : "desc"; load({ page: 1, filter, q: appliedQ, sort: key, order: sort === key ? nextDir : "desc" }); }; 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"; const reload = () => load({ page, filter, q: appliedQ, sort, order }); const visibleCols = useMemo(() => { const list: ColKey[] = ["id", "member", "role", "status", "points"]; if (optCols.online) list.push("online"); if (optCols.email) list.push("email"); list.push("last_login"); if (optCols.counts) list.push("counts"); if (optCols.created_at) list.push("created_at"); list.push("actions"); return list; }, [optCols]); // Tailwind 无法动态拼 grid-cols;用 style.gridTemplateColumns const gridStyle = useMemo(() => { const frags: string[] = []; for (const c of visibleCols) { switch (c) { case "id": frags.push("56px"); break; case "member": frags.push("minmax(0,0.7fr)"); break; case "role": frags.push("minmax(92px,0.45fr)"); break; case "status": frags.push("72px"); break; case "points": frags.push("72px"); break; case "online": frags.push("64px"); break; case "email": frags.push("minmax(0,0.55fr)"); break; case "last_login": frags.push("146px"); break; case "counts": frags.push("100px"); break; case "created_at": frags.push("100px"); break; case "actions": // 带文案按钮:档案 / 主页 / 角色与账号 frags.push("minmax(268px,2.2fr)"); break; } } return { gridTemplateColumns: frags.join(" ") }; }, [visibleCols]); const colHeader = (c: ColKey): string => { switch (c) { case "id": return "ID"; case "member": return "成员"; case "role": return "角色"; case "status": return "状态"; case "points": return "积分"; case "online": return "在线"; case "email": return "邮箱"; case "last_login": return "上次登录"; case "counts": return "内容"; case "created_at": return "注册"; case "actions": return "操作"; } }; const renderColHeader = (c: ColKey) => { const label = colHeader(c); if (!SORTABLE_COLS.has(c)) { return ( {label} ); } const key = c as UserSortKey; const active = sort === key; const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none"; const Icon = active ? (order === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown; return (
); }; const renderStatus = (u: AdminUser) => { if (u.banned) { return ( 封禁 ); } return ( 正常 ); }; const renderOnline = (u: AdminUser) => { if (u.online) { return ( 在线 ); } return ( 离线 ); }; const renderLastLogin = (u: AdminUser) => ( ); const renderActions = (u: AdminUser, isSelf: boolean) => { const canAudit = isOwner(currentUserRole); const auditBtn = canAudit ? ( 内容档案 ) : null; if (u.role === ROLES.OWNER) { return (
{auditBtn} 主页
); } if (isSelf) { return (
{auditBtn} 主页 当前账号
); } const busy = actingId === u.id; // 「角色与账号」用主题色突出主操作;封禁态改用危险色提示需处理 const roleStyle = u.banned ? { color: "var(--danger)", borderColor: "var(--danger)" } : { color: "var(--accent)", borderColor: "var(--accent)" }; return (
{auditBtn} 主页
); }; const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => { switch (c) { case "id": return ( {u.id} ); case "member": return (

{u.nickname}

@{u.username}

); case "role": return (
); case "status": return renderStatus(u); case "points": return ( {(u.points ?? 0).toLocaleString("zh-CN")} ); case "online": return renderOnline(u); case "email": return ( {u.email || "—"} ); case "last_login": return
{renderLastLogin(u)}
; case "counts": return (

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

); case "created_at": return ( {formatDate(u.created_at)} ); case "actions": return
{renderActions(u, isSelf)}
; } }; return (
} color="var(--ok)" soft="var(--ok-soft)" /> } color="var(--gold)" soft="var(--gold-soft)" /> } color="var(--accent)" soft="var(--accent-soft)" /> } color="var(--danger)" soft="var(--danger-soft)" />
role="group" ariaLabel="用户筛选" value={filter} onChange={changeFilter} options={FILTER_TABS.map((t) => ({ key: t.key, label: t.label, count: tabCount(t.key), }))} /> load({ page: 1, filter, q: appliedQ, sort: key, order: dir })} />
{appliedQ ? ( <> 搜索“{appliedQ}” ) : filter === "staff" ? ( "管理团队" ) : filter === "banned" ? ( "已封禁成员" ) : ( "全部成员" )} 共 {total} 人
{users.length > 0 && (
{visibleCols.map((c) => (
{renderColHeader(c)}
))}
)}
{error ? (

{error}

) : users.length === 0 ? ( } title={hasFilter ? "没有匹配的成员" : "暂无成员"} description={hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"} /> ) : ( users.map((u) => { const isSelf = u.id === currentUserId; return (
{visibleCols.map((c) => (
{renderCell(c, u, isSelf)}
))}
{u.nickname} {u.role !== ROLES.USER && ( )} {isSelf && ( 当前账号 )}

#{u.id} · @{u.username} {optCols.email && u.email ? ` · ${u.email}` : ""}

{renderStatus(u)}

积分 {(u.points ?? 0).toLocaleString("zh-CN")} 帖 {u.post_count} · 评 {u.comment_count} 注册 {formatDate(u.created_at)} {optCols.online && {u.online ? "在线" : "离线"}}

{renderActions(u, isSelf)}
); }) )}
setRoleTarget(null)} onSaved={() => { setRoleTarget(null); reload(); }} setActing={(v) => setActingId(v ? roleTarget?.id ?? null : null)} /> setLogsTarget(null)} />
); } 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]); if (!target) return null; const pageSize = 15; const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ( 登录历史
} description={`@${target.username} · 共 ${total} 条记录(含失败尝试)`} onClose={onClose} dismissOnOverlay maxWidthClass="max-w-[520px]" panelClassName="p-6 max-h-[88vh] flex flex-col" ariaLabel="登录历史" footer={ total > pageSize ? ( <> 第 {page} / {totalPages} 页 ) : undefined } >
{err ? (

{err}

) : logs.length === 0 ? ( loading ? null : (

暂无登录记录

) ) : (
    {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", })}
  • ))}
)}
); }