"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
Clock,
ExternalLink,
FolderSearch,
History,
Loader2,
Monitor,
Search,
ShieldAlert,
ShieldCheck,
Users as UsersIcon,
Activity,
Hourglass,
X,
} 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 { toast } from "@/lib/toast";
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";
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 {};
}
// 汇总小卡
function MiniStat({
label,
value,
sub,
icon,
color,
soft,
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
{icon}
{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 ?? {
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 [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 (
{/* 页头 */}
用户管理
检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
}
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)"
/>
{/* 搜索 + 筛选 */}
{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 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",
})}
))}
)}
);
}