Files
jiang13-bbs/frontend/app/admin/users/UsersAdmin.tsx

1008 lines
33 KiB
TypeScript
Raw 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 {
ChevronLeft,
ChevronRight,
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,
AdminIconButton,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminSegmented,
AdminStatCard,
} from "@/components/admin";
export type UserFilter = "all" | "staff" | "banned";
const PAGE_SIZE = 20;
const COLS_STORAGE_KEY = "j13-admin-user-cols";
/** 默认始终显示的列 */
const CORE_COLS = ["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 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: "已封禁" },
];
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)" }}>
成员 / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
</p>
</div>
)}
</div>
);
}
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<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 [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]);
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 }) => {
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);
setFilter(opts.filter);
setPage(opts.page);
setAppliedQ(opts.q);
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 });
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
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 = () => {
const q = keyword.trim();
if (q === appliedQ) return;
load({ page: 1, filter, q });
};
const clearSearch = () => {
setKeyword("");
if (appliedQ !== "" || page !== 1) {
load({ page: 1, filter, q: "" });
}
};
const changeFilter = (f: UserFilter) => {
if (f === filter) return;
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;
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";
const reload = () => load({ page, filter, q: appliedQ });
const visibleCols = useMemo(() => {
const list: ColKey[] = ["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 "member":
frags.push("minmax(0,1.35fr)");
break;
case "role":
frags.push("minmax(96px,0.9fr)");
break;
case "status":
frags.push("72px");
break;
case "points":
frags.push("72px");
break;
case "online":
frags.push("64px");
break;
case "email":
frags.push("minmax(120px,1fr)");
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("136px");
break;
}
}
return { gridTemplateColumns: frags.join(" ") };
}, [visibleCols]);
const colHeader = (c: ColKey): string => {
switch (c) {
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 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 ? (
<AdminIconButton
href={`/admin/users/${u.id}`}
tip="内容档案"
ariaLabel={`查看 ${u.username} 的内容档案`}
tone="accent"
>
<FolderSearch size={15} />
</AdminIconButton>
) : null;
if (u.role === ROLES.OWNER) {
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<AdminIconButton href={`/u/${u.id}`} tip="查看个人主页" ariaLabel={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={15} />
</AdminIconButton>
</div>
);
}
if (isSelf) {
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<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 roleTone = u.banned ? "danger" : u.role !== ROLES.USER ? "accent" : "default";
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<AdminIconButton
href={`/u/${u.id}`}
tip="查看个人主页"
ariaLabel={`查看 ${u.nickname} 的个人主页`}
>
<ExternalLink size={15} />
</AdminIconButton>
<AdminIconButton
tip="角色与账号"
ariaLabel={`设置 ${u.username} 的角色与账号状态`}
tone={roleTone}
disabled={busy}
onClick={() => setRoleTarget(u)}
>
<ShieldCheck size={15} />
</AdminIconButton>
</div>
);
};
const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => {
switch (c) {
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} 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 justify-end">{renderActions(u, isSelf)}</div>;
}
};
return (
<div>
<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 overflow-hidden">
<div className="admin-toolbar">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={submitSearch}
onClear={clearSearch}
placeholder="搜索用户名、昵称或邮箱…"
ariaLabel="搜索用户"
/>
<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),
}))}
/>
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
</div>
</div>
</div>
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" 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.5 text-[11.5px] uppercase tracking-wide"
style={{
...gridStyle,
color: "var(--ink-3)",
borderBottom: "1px solid var(--line)",
background: "var(--panel-2)",
}}
>
{visibleCols.map((c) => (
<span key={c} className={c === "actions" ? "text-right" : undefined}>
{colHeader(c)}
</span>
))}
</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} />
)}
{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.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="flex items-center gap-2 mt-3 flex-wrap">
{isOwner(currentUserRole) && (
<Link href={`/admin/users/${u.id}`} className="btn btn-line btn-sm">
<FolderSearch size={13} /> 内容档案
</Link>
)}
{(isSelf || u.role === ROLES.OWNER) && (
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
<ExternalLink size={13} /> 主页
</Link>
)}
{!isSelf && u.role !== ROLES.OWNER && (
<>
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
<ExternalLink size={13} /> 主页
</Link>
<button
type="button"
disabled={actingId === u.id}
onClick={() => setRoleTarget(u)}
className="btn btn-line btn-sm"
style={
u.banned
? { color: "var(--danger)", borderColor: "var(--danger)" }
: u.role !== ROLES.USER
? { color: "var(--accent)", borderColor: "var(--accent)" }
: undefined
}
>
<ShieldCheck size={13} />
角色与账号
</button>
</>
)}
</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>
);
}