"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, FolderSearch, 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 { isOwner, ROLES, ROLE_META } 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"; 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; 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); // 角色与账号弹窗 / 登录历史弹窗 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(() => {}); }, []); // 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变; // 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确 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 }) => { 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) => { const canAudit = isOwner(currentUserRole); const auditBtn = canAudit ? ( ) : null; // 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案 if (u.role === ROLES.OWNER) { return (
{auditBtn}
); } if (isSelf) { return (
{auditBtn} 当前账号
); } const busy = actingId === u.id; return (
{auditBtn}
); }; // 桌面端列模板(成员列收窄,新增在线状态与上次登录 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)}

{isOwner(currentUserRole) && ( 内容档案 )} {(isSelf || u.role === ROLES.OWNER) && ( 主页 )} {!isSelf && u.role !== ROLES.OWNER && ( <> 主页 )}
); }) )}
{/* 分页 */} {total > 0 && (
第 {page} / {totalPages} 页 · 共 {total} 人
)}
setRoleTarget(null)} onSaved={() => { setRoleTarget(null); reload(); }} setActing={(v) => setActingId(v ? roleTarget?.id ?? null : 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 grantKeys = [ ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.BOARD_ADMIN, ROLES.USER, ] as const; const options: RoleOption[] = grantKeys .filter((k) => canGrantSuper || k !== ROLES.SUPER_ADMIN) .map((k) => { const meta = ROLE_META[k]; return { value: k, title: meta.label, desc: meta.grantDesc || "", token: meta.colorToken, }; }); const [role, setRole] = useState(ROLES.USER); const [boardIds, setBoardIds] = useState([]); const [banned, setBanned] = useState(false); const [err, setErr] = useState(""); useEffect(() => { if (target) { setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role); setBoardIds(target.board_ids ?? []); setBanned(!!target.banned); setErr(""); } }, [target]); if (!target) return null; const toggleBoard = (id: number) => { setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id])); }; const sameBoards = JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) === JSON.stringify([...boardIds].sort((a, b) => a - b)); const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards); const banChanged = banned !== !!target.banned; const willBan = banChanged && banned; const willUnban = banChanged && !banned; const submit = async () => { if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) { setErr("请至少选择一个授权板块"); return; } if (!roleChanged && !banChanged) { onClose(); return; } setActing(true); try { if (roleChanged) { await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []); } if (banChanged) { await apiAdminSetUserBan(target.id, banned); } const parts: string[] = []; if (roleChanged) parts.push("角色已更新"); if (willBan) parts.push("已封禁"); if (willUnban) parts.push("已解封"); toast(parts.join(" · ") || "已保存", "ok"); onSaved(); } catch (e) { toast(e instanceof Error ? e.message : "操作失败"); } finally { setActing(false); } }; const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存"; return ( @{target.username}({target.nickname}) 角色决定后台入口与前台管理能力;封禁立即强制下线。登录历史、站长内容档案仍走列表其他入口。 } onClose={onClose} busy={acting} maxWidthClass="max-w-[500px]" panelClassName="p-6 max-h-[88vh] overflow-y-auto" ariaLabel="角色与账号" footer={ <> } >

管理角色

{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}

)}
)}

账号状态

{willBan && (

封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。

)}
); } // ===== 登录历史弹窗 ===== 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 } >
{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", })}
  • ))}
)}
); }