Files
jiang13-bbs/frontend/app/admin/users/UsersAdmin.tsx
freefire d7c6b3a961 feat: 帖子收藏与用户关注
- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
2026-09-26 03:02:40 +08:00

1159 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { boardColor } from "@/components/BoardIcon";
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<ColKey>(["id", "role", "status", "points", "last_login", "counts", "created_at", "online"]);
const OPTIONAL_LABELS: Record<OptionalCol, string> = {
online: "在线",
email: "邮箱",
counts: "帖/评数",
created_at: "注册时间",
};
const DEFAULT_OPTIONAL: Record<OptionalCol, boolean> = {
online: false,
email: false,
counts: false,
created_at: false,
};
function loadOptionalCols(): Record<OptionalCol, boolean> {
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<Record<OptionalCol, boolean>>;
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<OptionalCol, boolean>;
onChange: (next: Record<OptionalCol, boolean>) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className="relative shrink-0" ref={ref}>
<button
type="button"
className="btn btn-line btn-sm"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Columns3 size={14} />
列
{enabledCount > 0 ? (
<span className="tabular-nums" style={{ color: "var(--accent)" }}>
+{enabledCount}
</span>
) : null}
</button>
{open && (
<div
role="dialog"
aria-label="显示列"
className="absolute right-0 top-full mt-2 z-40 w-52 p-2 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<p className="px-2 py-1.5 text-[11.5px] uppercase tracking-wide" style={{ color: "var(--ink-3)" }}>
可选列
</p>
<ul className="flex flex-col gap-0.5">
{OPTIONAL_COLS.map((key) => (
<li key={key}>
<label className="flex items-center gap-2.5 px-2 py-2 rounded-[var(--r-sm)] cursor-pointer hover:bg-[var(--panel-2)] text-[13px]">
<input
type="checkbox"
checked={cols[key]}
onChange={() => onChange({ ...cols, [key]: !cols[key] })}
className="accent-[var(--accent)]"
/>
<span style={{ color: "var(--ink)" }}>{OPTIONAL_LABELS[key]}</span>
</label>
</li>
))}
</ul>
<p className="px-2 pt-1.5 pb-1 text-[11px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
成员 / ID / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
</p>
</div>
)}
</div>
);
}
function MobileSortPopover({
sort,
order,
onChange,
}: {
sort: UserSortKey;
order: UserSortDir;
onChange: (key: UserSortKey, dir: UserSortDir) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className="relative shrink-0 md:hidden" ref={ref}>
<button
type="button"
className="btn btn-line btn-sm"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<ArrowUpDown size={14} />
{current?.label ?? "排序"}
{order === "asc" ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{open && (
<div
role="listbox"
aria-label="排序"
className="absolute right-0 top-full mt-2 z-40 w-48 p-2 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<ul className="flex flex-col gap-0.5">
{SORT_OPTIONS.map((o) => {
const active = sort === o.key;
return (
<li key={o.key}>
<button
type="button"
role="option"
aria-selected={active}
className="w-full flex items-center justify-between gap-2 px-2 py-2 rounded-[var(--r-sm)] text-[13px] hover:bg-[var(--panel-2)]"
style={{ color: active ? "var(--accent)" : "var(--ink)" }}
onClick={() => {
onChange(o.key, active && order === "desc" ? "asc" : "desc");
setOpen(false);
}}
>
{o.label}
{active ? (order === "asc" ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
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<AdminUser[]>(initial?.users ?? []);
const [summary, setSummary] = useState<AdminUserSummary>(
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<UserFilter>(initialQuery.filter);
const [keyword, setKeyword] = useState(initialQuery.q);
const [appliedQ, setAppliedQ] = useState(initialQuery.q);
const [sort, setSort] = useState<UserSortKey>(initialQuery.sort);
const [order, setOrder] = useState<UserSortDir>(initialQuery.order);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState<number | null>(null);
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
const [boards, setBoards] = useState<Board[]>([]);
const [optCols, setOptCols] = useState<Record<OptionalCol, boolean>>(DEFAULT_OPTIONAL);
const seqRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const urlFirstRun = useRef(true);
const router = useRouter();
useEffect(() => {
setOptCols(loadOptionalCols());
}, []);
const persistCols = (next: Record<OptionalCol, boolean>) => {
setOptCols(next);
try {
localStorage.setItem(COLS_STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
};
const boardNameMap = useMemo(() => {
const m = new Map<number, string>();
boards.forEach((b) => m.set(b.id, b.name));
return m;
}, [boards]);
const boardColorMap = useMemo(() => {
const m = new Map<number, number>();
boards.forEach((b) => m.set(b.id, boardColor(b)));
return m;
}, [boards]);
useEffect(() => {
apiListBoards()
.then((r) => setBoards(r.boards ?? []))
.catch(() => {});
}, []);
useEffect(() => {
const reconcile = (ids: ReadonlySet<number>) => {
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 (
<span>{label}</span>
);
}
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 (
<div role="columnheader" aria-sort={ariaSort}>
<button
type="button"
onClick={() => toggleSort(key)}
className="-ml-1 inline-flex items-center gap-0.5 rounded-md px-1 py-0.5 uppercase tracking-wide transition-colors hover:bg-[var(--panel)]"
style={{ color: active ? "var(--accent)" : "var(--ink-3)" }}
aria-label={
active
? `${label},当前${order === "asc" ? "升序" : "降序"},点击切换`
: `按${label}排序`
}
>
{label}
<Icon size={13} className={active ? undefined : "opacity-45"} aria-hidden />
</button>
</div>
);
};
const renderStatus = (u: AdminUser) => {
if (u.banned) {
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px]"
style={{ color: "var(--danger)" }}
data-tip="账号已封禁"
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--danger)" }} aria-hidden />
封禁
</span>
);
}
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px]"
style={{ color: "var(--ok)" }}
data-tip="账号正常"
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--ok)" }} aria-hidden />
正常
</span>
);
};
const renderOnline = (u: AdminUser) => {
if (u.online) {
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px]"
style={{ color: "var(--ok)" }}
data-tip={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "在线"}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--ok)" }} aria-hidden />
在线
</span>
);
}
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px]"
style={{ color: "var(--ink-3)" }}
data-tip={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "从未活跃"}
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: "var(--ink-3)", opacity: 0.45 }}
aria-hidden
/>
离线
</span>
);
};
const renderLastLogin = (u: AdminUser) => (
<button
type="button"
onClick={() => setLogsTarget(u)}
className="min-w-0 text-left group rounded-lg -mx-1.5 px-1.5 py-1 transition-colors hover:bg-[var(--panel-2)]"
data-tip="查看登录历史"
aria-label={`查看 ${u.username} 的登录历史`}
>
<span
className="block text-[12px] tabular-nums truncate group-hover:text-[var(--accent)]"
style={{ color: u.last_login_ip ? "var(--ink-2)" : "var(--ink-3)" }}
>
{u.last_login_ip || "暂无记录"}
</span>
<span className="block text-[11px] tabular-nums truncate" style={{ color: "var(--ink-3)" }}>
{u.last_login_at ? formatRelative(u.last_login_at) : "—"}
</span>
</button>
);
const renderActions = (u: AdminUser, isSelf: boolean) => {
const canAudit = isOwner(currentUserRole);
const auditBtn = canAudit ? (
<Link
href={`/admin/users/${u.id}`}
className="btn btn-line btn-sm gap-1"
aria-label={`查看 ${u.username} 的内容档案`}
>
<FolderSearch size={13} />
内容档案
</Link>
) : null;
if (u.role === ROLES.OWNER) {
return (
<div className="flex items-center gap-1.5 flex-wrap">
{auditBtn}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={13} />
主页
</Link>
</div>
);
}
if (isSelf) {
return (
<div className="flex items-center gap-1.5 flex-wrap">
{auditBtn}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={13} />
主页
</Link>
<span
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] whitespace-nowrap"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
</div>
);
}
const busy = actingId === u.id;
// 「角色与账号」用主题色突出主操作;封禁态改用危险色提示需处理
const roleStyle = u.banned
? { color: "var(--danger)", borderColor: "var(--danger)" }
: { color: "var(--accent)", borderColor: "var(--accent)" };
return (
<div className="flex items-center gap-1.5 flex-wrap">
{auditBtn}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={13} />
主页
</Link>
<button
type="button"
disabled={busy}
onClick={() => setRoleTarget(u)}
className="btn btn-line btn-sm gap-1"
style={roleStyle}
aria-label={`设置 ${u.username} 的角色与账号状态`}
>
<ShieldCheck size={13} />
角色与账号
</button>
</div>
);
};
const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => {
switch (c) {
case "id":
return (
<span className="text-[12.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{u.id}
</span>
);
case "member":
return (
<div className="flex items-center gap-2.5 min-w-0">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={34} />
<div className="min-w-0 leading-tight">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</p>
</div>
</div>
);
case "role":
return (
<div className="min-w-0">
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNameMap} boardColors={boardColorMap} truncate />
</div>
);
case "status":
return renderStatus(u);
case "points":
return (
<span className="text-[13px] tabular-nums" style={{ color: "var(--ink-2)" }}>
{(u.points ?? 0).toLocaleString("zh-CN")}
</span>
);
case "online":
return renderOnline(u);
case "email":
return (
<span className="text-[12.5px] truncate block" style={{ color: u.email ? "var(--ink-2)" : "var(--ink-3)" }}>
{u.email || "—"}
</span>
);
case "last_login":
return <div className="min-w-0">{renderLastLogin(u)}</div>;
case "counts":
return (
<p
className="text-[12.5px] tabular-nums"
style={{ color: "var(--ink-2)" }}
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count}
</p>
);
case "created_at":
return (
<span className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</span>
);
case "actions":
return <div className="flex">{renderActions(u, isSelf)}</div>;
}
};
return (
<div className="admin-users-page">
<AdminPageHeader
icon={Users}
title="用户管理"
description="检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护"
/>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<AdminStatCard
label="当前在线"
value={String(summary.online)}
sub="5 分钟内活跃"
icon={<Activity size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<AdminStatCard
label="发帖冷静期中"
value={String(summary.in_cooldown)}
sub="普通用户"
icon={<Hourglass size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="近 7 日活跃"
value={String(summary.active_7d)}
icon={<Monitor size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<AdminStatCard
label="24h 登录失败"
value={String(summary.failed_logins_24h)}
sub="次"
icon={<ShieldAlert size={16} />}
color="var(--danger)"
soft="var(--danger-soft)"
/>
</div>
<div className="panel">
<div className="admin-users-stick">
<div className="admin-toolbar">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
<div className="flex items-center gap-2 flex-wrap shrink-0">
<AdminSegmented<UserFilter>
role="group"
ariaLabel="用户筛选"
value={filter}
onChange={changeFilter}
options={FILTER_TABS.map((t) => ({
key: t.key,
label: t.label,
count: tabCount(t.key),
}))}
/>
<MobileSortPopover
sort={sort}
order={order}
onChange={(key, dir) => load({ page: 1, filter, q: appliedQ, sort: key, order: dir })}
/>
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
</div>
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={submitSearch}
onClear={clearSearch}
placeholder="搜索用户名、昵称或邮箱…"
ariaLabel="搜索用户"
/>
</div>
</div>
<div
className="px-4 sm:px-5 py-2.5 flex items-center justify-between gap-2 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
{appliedQ ? (
<>
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
</>
) : filter === "staff" ? (
"管理团队"
) : filter === "banned" ? (
"已封禁成员"
) : (
"全部成员"
)}
</span>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
共 {total} 人
</span>
</div>
{users.length > 0 && (
<div
className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3 text-[11.5px] uppercase tracking-wide"
style={{
...gridStyle,
color: "var(--ink-3)",
borderBottom: "1px solid var(--line)",
background: "var(--panel-2)",
}}
>
{visibleCols.map((c) => (
<div key={c}>
{renderColHeader(c)}
</div>
))}
</div>
)}
</div>
<div aria-busy={loading}>
{error ? (
<p className="alert-error m-4">{error}</p>
) : users.length === 0 ? (
<AdminEmpty
icon={<Users size={22} />}
title={hasFilter ? "没有匹配的成员" : "暂无成员"}
description={hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
/>
) : (
users.map((u) => {
const isSelf = u.id === currentUserId;
return (
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
<div className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5" style={gridStyle}>
{visibleCols.map((c) => (
<div key={c} className="min-w-0">
{renderCell(c, u, isSelf)}
</div>
))}
</div>
<div className="md:hidden px-4 py-3.5">
<div className="flex items-center gap-3">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
<div className="min-w-0 flex-1 leading-tight">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-medium truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
{u.role !== ROLES.USER && (
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNameMap} boardColors={boardColorMap} />
)}
{isSelf && (
<span
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] leading-[1.55]"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
)}
</div>
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
#{u.id} · @{u.username}
{optCols.email && u.email ? ` · ${u.email}` : ""}
</p>
</div>
{renderStatus(u)}
</div>
<p className="mt-2.5 text-[11.5px] tabular-nums flex flex-wrap gap-x-3 gap-y-1" style={{ color: "var(--ink-3)" }}>
<span>积分 {(u.points ?? 0).toLocaleString("zh-CN")}</span>
<span data-tip="仅统计已发布内容">
帖 {u.post_count} · 评 {u.comment_count}
</span>
<span>注册 {formatDate(u.created_at)}</span>
{optCols.online && <span>{u.online ? "在线" : "离线"}</span>}
</p>
<p className="mt-1 text-[11.5px] tabular-nums flex items-center gap-1.5" style={{ color: "var(--ink-3)" }}>
<Clock size={12} className="shrink-0" />
<button type="button" className="truncate" onClick={() => setLogsTarget(u)}>
上次登录 {u.last_login_ip || "—"}
{u.last_login_at ? ` · ${formatRelative(u.last_login_at)}` : ""}
</button>
</p>
<div className="mt-3">{renderActions(u, isSelf)}</div>
</div>
</div>
);
})
)}
</div>
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={goPage}
summary={`第 ${page} / ${totalPages} 页 · 共 ${total} 人`}
/>
</div>
<RoleAccountModal
target={roleTarget}
boards={boards}
canGrantSuper={currentUserRole === ROLES.OWNER}
acting={actingId === roleTarget?.id}
onClose={() => setRoleTarget(null)}
onSaved={() => {
setRoleTarget(null);
reload();
}}
setActing={(v) => setActingId(v ? roleTarget?.id ?? null : null)}
/>
<LoginLogsDialog target={logsTarget} onClose={() => setLogsTarget(null)} />
</div>
);
}
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
const [logs, setLogs] = useState<LoginLog[]>([]);
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 (
<Modal
open
title={
<span className="inline-flex items-center gap-2">
<History size={17} style={{ color: "var(--accent)" }} />
登录历史
</span>
}
description={`@${target.username} · 共 ${total} 条记录(含失败尝试)`}
onClose={onClose}
dismissOnOverlay
maxWidthClass="max-w-[520px]"
panelClassName="p-6 max-h-[88vh] flex flex-col"
ariaLabel="登录历史"
footer={
total > pageSize ? (
<>
<span className="meta text-[11.5px] tabular-nums mr-auto" style={{ color: "var(--ink-3)" }}>
第 {page} / {totalPages} 页
</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => p + 1)}
>
下一页 <ChevronRight size={14} />
</button>
</>
) : undefined
}
>
<div className="-mx-1.5 px-1.5 overflow-y-auto flex-1 min-h-0" style={{ maxHeight: "min(52vh, 420px)" }}>
{err ? (
<p className="alert-error my-4">{err}</p>
) : logs.length === 0 ? (
loading ? null : (
<div className="text-center py-12">
<Monitor size={22} className="mx-auto mb-2" style={{ color: "var(--ink-3)" }} />
<p className="meta text-[12.5px]">暂无登录记录</p>
</div>
)
) : (
<ul className="flex flex-col">
{logs.map((log) => (
<li
key={log.id}
className="flex items-start gap-3 py-2.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="mt-1 w-2 h-2 rounded-full shrink-0"
style={{ background: log.success ? "var(--ok)" : "var(--danger)" }}
data-tip={log.success ? "成功" : "失败"}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-[12.5px] tabular-nums truncate" style={{ color: "var(--ink)" }}>
{log.ip || "未知 IP"}
<span
className="ml-2 inline-flex items-center rounded-full px-1.5 py-px text-[10.5px]"
style={
log.success
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
}
>
{log.success ? "成功" : "失败"}
</span>
</p>
<p className="text-[11px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }} data-tip={log.user_agent}>
{log.user_agent || "未知设备"}
</p>
</div>
<span className="text-[11px] tabular-nums shrink-0 pt-0.5" style={{ color: "var(--ink-3)" }}>
{formatDate(log.created_at)}{" "}
{new Date(log.created_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}