Files
jiang13-bbs/frontend/app/admin/users/UsersAdmin.tsx
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

1333 lines
48 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 {
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 } 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";
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;
icon: React.ReactNode;
token: string;
}
// 汇总小卡
function MiniStat({
label,
value,
sub,
icon,
color,
soft,
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
<div className="panel p-4 flex items-center gap-3">
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
<div className="min-w-0 leading-tight">
<p className="text-[11.5px] font-semibold truncate" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<p className="text-[19px] font-extrabold tabular-nums" style={{ color: "var(--ink)" }}>
{value}
{sub && (
<span className="ml-1.5 text-[11px] font-semibold" style={{ color: "var(--ink-3)" }}>
{sub}
</span>
)}
</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 ?? { 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<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);
// 封禁二次确认弹窗;null 关闭
const [banTarget, setBanTarget] = useState<AdminUser | null>(null);
// 角色授权弹窗 / 登录历史弹窗
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
// 板块字典(角色弹窗勾选 + 徽章板块名)
const [boards, setBoards] = useState<Board[]>([]);
// 防止快速翻页/输入时旧响应覆盖新结果
const seqRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const urlFirstRun = useRef(true);
const router = useRouter();
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(() => {});
}, []);
// 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变;
// 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确
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);
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 (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-bold"
style={{ color: "var(--danger)" }}
title="账号已封禁"
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--danger)" }} aria-hidden />
封禁
</span>
);
}
if (u.online) {
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-bold"
style={{ color: "var(--ok)" }}
title={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] font-semibold"
style={{ color: "var(--ink-3)" }}
title={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)]"
title="查看登录历史"
aria-label={`查看 ${u.username} 的登录历史`}
>
<span
className="block text-[12px] font-semibold 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}`}
title="内容档案"
aria-label={`查看 ${u.username} 的内容档案`}
className={ghostBtn}
style={{ color: "var(--accent)" }}
>
<FolderSearch size={15} />
</Link>
) : null;
// 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案
if (u.role === ROLES.OWNER) {
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<Link
href={`/u/${u.id}`}
title="查看个人主页"
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
>
<ExternalLink size={15} />
</Link>
</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] font-bold whitespace-nowrap"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
</div>
);
}
const busy = actingId === u.id;
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<button
type="button"
title="登录历史"
aria-label={`查看 ${u.username} 的登录历史`}
onClick={() => setLogsTarget(u)}
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--panel-2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
}}
>
<History size={15} />
</button>
<Link
href={`/u/${u.id}`}
title="查看个人主页"
aria-label={`查看 ${u.nickname} 的个人主页`}
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
>
<ExternalLink size={15} />
</Link>
<button
type="button"
title="设置管理角色"
aria-label={`设置 ${u.username} 的管理角色`}
disabled={busy}
onClick={() => setRoleTarget(u)}
className={ghostBtn}
style={u.role === ROLES.USER ? { color: "var(--ink-3)" } : { color: "var(--accent)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--accent-soft)";
e.currentTarget.style.color = "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.role === ROLES.USER ? "var(--ink-3)" : "var(--accent)";
}}
>
<ShieldCheck size={15} />
</button>
<button
type="button"
title={u.banned ? "解封" : "封禁"}
aria-label={u.banned ? `解封 ${u.username}` : `封禁 ${u.username}`}
disabled={busy}
onClick={() => setBanTarget(u)}
className={ghostBtn}
style={u.banned ? { color: "var(--danger)" } : { color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--danger-soft)";
e.currentTarget.style.color = "var(--danger)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--ink-3)";
}}
>
{u.banned ? <RotateCcw size={15} /> : <Ban size={15} />}
</button>
</div>
);
};
// 桌面端列模板(成员列收窄,新增在线状态与上次登录 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 (
<div>
{/* 页头 */}
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<UsersIcon size={18} />
</span>
用户管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">
检索全部成员,配置管理角色与板块授权;站长账号受保护,操作即时生效
</p>
</div>
{/* 汇总 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<MiniStat
label="注册用户"
value={String(summary.total)}
sub={summary.today_new > 0 ? `今日 +${summary.today_new}` : undefined}
icon={<UsersIcon size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<MiniStat
label="管理团队"
value={String(summary.admins)}
icon={<ShieldCheck size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<MiniStat
label="已封禁"
value={String(summary.banned)}
icon={<Ban size={16} />}
color="var(--danger)"
soft="var(--danger-soft)"
/>
<MiniStat
label="当前筛选"
value={String(total)}
sub="人"
icon={<UserPlus size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
</div>
{/* 搜索 + 筛选 */}
<div className="panel p-3 sm:p-4 mb-4">
<div className="flex flex-col md:flex-row md:items-center gap-3">
<form onSubmit={submitSearch} className="relative flex-1" role="search">
<Search
size={15}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索用户名、昵称或邮箱…"
aria-label="搜索用户"
className="field pl-10 pr-9 py-2.5"
/>
{keyword && (
<button
type="button"
onClick={clearSearch}
aria-label="清除搜索"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
)}
</form>
<div className="flex items-center gap-1.5 flex-wrap" role="group" aria-label="用户筛选">
{FILTER_TABS.map((t) => {
const active = filter === t.key;
return (
<button
key={t.key}
type="button"
aria-pressed={active}
onClick={() => changeFilter(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.label}
<span
className="text-[11px] tabular-nums rounded-full px-1.5 leading-[1.7]"
style={{
background: "var(--panel)",
color: active ? "var(--nav-active-ink)" : "var(--ink-3)",
}}
>
{tabCount(t.key)}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* 用户列表 */}
<div className="panel overflow-hidden">
<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-bold 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={`${rowGrid} text-[11.5px] font-semibold uppercase tracking-wide`}
style={{ color: "var(--ink-3)", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span>成员</span>
<span>状态</span>
<span>角色</span>
<span>上次登录 IP</span>
<span className="xl:block hidden">内容 / 注册</span>
<span className="text-right">操作</span>
</div>
)}
<div aria-busy={loading} style={{ opacity: loading ? 0.55 : 1, transition: "opacity .15s" }}>
{error ? (
<p className="alert-error m-4">{error}</p>
) : loading && users.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : users.length === 0 ? (
<div className="text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<UsersIcon size={22} />
</span>
<p className="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
{hasFilter ? "没有匹配的成员" : "暂无成员"}
</p>
<p className="meta mt-1.5 text-[12.5px]">
{hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
</p>
</div>
) : (
users.map((u) => {
const isSelf = u.id === currentUserId;
return (
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
{/* 桌面行 */}
<div className={rowGrid}>
<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] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
</div>
</div>
<div>{renderStatus(u)}</div>
<div className="min-w-0">
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNameMap} truncate />
</div>
<div className="min-w-0">{renderLastLogin(u)}</div>
<div className="hidden xl:block">
<p
className="text-[12.5px] font-semibold tabular-nums"
style={{ color: "var(--ink-2)" }}
title="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count}
</p>
<p className="text-[11px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</p>
</div>
<div className="flex justify-end">{renderActions(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-bold 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] font-bold 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}
{u.email ? ` · ${u.email}` : ""}
</p>
</div>
{renderStatus(u)}
</div>
<p
className="mt-2.5 text-[11.5px] tabular-nums"
style={{ color: "var(--ink-3)" }}
title="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}
</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.role !== ROLES.USER ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
>
<ShieldCheck size={13} />
角色权限
</button>
<button
type="button"
disabled={actingId === u.id}
onClick={() => setBanTarget(u)}
className="btn btn-sm"
style={
u.banned
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
}
>
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
{u.banned ? "解封" : "封禁"}
</button>
</>
)}
</div>
</div>
</div>
);
})
)}
</div>
{/* 分页 */}
{total > 0 && (
<div
className="px-4 sm:px-5 py-3 flex items-center justify-between gap-2"
style={{ borderTop: "1px solid var(--line)" }}
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {page} / {totalPages} 页 · 共 {total} 人
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => goPage(page - 1)}
disabled={page <= 1 || loading}
className="btn btn-line btn-sm"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => goPage(page + 1)}
disabled={page >= totalPages || loading}
className="btn btn-line btn-sm"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
<RoleAssignDialog
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)}
/>
<BanConfirmDialog
target={banTarget}
busy={actingId === banTarget?.id}
onCancel={() => setBanTarget(null)}
onConfirm={async () => {
const u = banTarget;
if (!u) return;
setActingId(u.id);
try {
await apiAdminSetUserBan(u.id, !u.banned);
toast(u.banned ? "已解封" : "已封禁", "ok");
setBanTarget(null);
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setActingId(null);
}
}}
/>
<LoginLogsDialog target={logsTarget} onClose={() => setLogsTarget(null)} />
</div>
);
}
// ===== 角色授权弹窗 =====
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 options: RoleOption[] = [
{
value: ROLES.SUPER_ADMIN,
title: "超级管理员",
desc: "可操作全站后台:用户权限、公告、外观设置、内容审核;站长账号除外",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.ADMIN,
title: "管理员",
desc: "可管理公告,审核全站帖子与评论,可置顶/加精",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.BOARD_ADMIN,
title: "板块管理员",
desc: "仅可审核被授权板块的帖子与评论",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.USER,
title: "普通用户",
desc: "无后台权限;发帖与评论需通过审核",
icon: <UsersIcon size={18} />,
token: "--ink-3",
},
].filter((o) => canGrantSuper || o.value !== ROLES.SUPER_ADMIN);
const [role, setRole] = useState<string>(ROLES.USER);
const [boardIds, setBoardIds] = useState<number[]>([]);
const [err, setErr] = useState("");
// 每次打开按目标当前角色/授权初始化
useEffect(() => {
if (target) {
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
setBoardIds(target.board_ids ?? []);
setErr("");
}
}, [target]);
useEffect(() => {
if (!target) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !acting) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [target, acting, onClose]);
if (!target) return null;
const toggleBoard = (id: number) => {
setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id]));
};
const submit = async () => {
if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) {
setErr("请至少选择一个授权板块");
return;
}
setActing(true);
try {
await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []);
toast("角色权限已更新", "ok");
onSaved();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setActing(false);
}
};
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={() => !acting && onClose()}
>
<div
className="j13-toast-in panel w-full max-w-[460px] rounded-2xl p-6 max-h-[88vh] overflow-y-auto"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="设置管理角色"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
设置管理角色
</h3>
<p className="meta mt-1 text-[12.5px] truncate">
@{target.username}({target.nickname})
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={acting}
aria-label="关闭"
className="w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={16} />
</button>
</div>
<div className="mt-4 flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
{options.map((o) => {
const active = role === o.value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => {
setRole(o.value);
setErr("");
}}
className="text-left rounded-xl p-3 flex items-start gap-3 transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
style={{
background: active ? "var(--accent)" : "var(--panel)",
color: active ? "var(--accent-on)" : `var(${o.token})`,
}}
aria-hidden="true"
>
{o.icon}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
{o.title}
</span>
<span className="block mt-0.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{o.desc}
</span>
</span>
<span
className="mt-1 w-4 h-4 rounded-full shrink-0 flex items-center justify-center"
style={{ border: `2px solid ${active ? "var(--accent)" : "var(--line-2)"}` }}
aria-hidden="true"
>
{active && <span className="w-2 h-2 rounded-full" style={{ background: "var(--accent)" }} />}
</span>
</button>
);
})}
</div>
{/* 板块授权(仅板块管理员) */}
{role === ROLES.BOARD_ADMIN && (
<div className="mt-4">
<p className="text-[12.5px] font-bold mb-2" style={{ color: "var(--ink)" }}>
授权板块 <span style={{ color: "var(--danger)" }}>*</span>
</p>
{boards.length === 0 ? (
<p className="meta text-[12px]">板块加载中…</p>
) : (
<div className="grid grid-cols-2 gap-2">
{boards.map((b) => {
const checked = boardIds.includes(b.id);
return (
<button
key={b.id}
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => toggleBoard(b.id)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12.5px] font-semibold transition-colors"
style={{
border: `1.5px solid ${checked ? "var(--accent)" : "var(--line)"}`,
background: checked ? "var(--accent-soft)" : "var(--panel-2)",
color: checked ? "var(--accent)" : "var(--ink-2)",
}}
>
<span
className="w-4 h-4 rounded shrink-0 flex items-center justify-center"
style={{ border: `2px solid ${checked ? "var(--accent)" : "var(--line-2)"}` }}
aria-hidden="true"
>
{checked && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
)}
</span>
<span className="truncate">{b.name}</span>
</button>
);
})}
</div>
)}
{err && <p className="mt-2 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>{err}</p>}
</div>
)}
<div className="flex items-center justify-end gap-2 mt-5">
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
取消
</button>
<button type="button" className="btn" onClick={submit} disabled={acting}>
{acting && <Loader2 size={15} className="animate-spin" />}
保存角色
</button>
</div>
</div>
</div>
);
}
// ===== 封禁确认弹窗 =====
function BanConfirmDialog({
target,
busy,
onCancel,
onConfirm,
}: {
target: AdminUser | null;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
useEffect(() => {
if (!target) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [target, busy, onCancel]);
if (!target) return null;
const banning = !target.banned;
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={() => !busy && onCancel()}
>
<div
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="flex items-start gap-4">
<span
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
style={{
background: banning ? "var(--danger-soft)" : "var(--ok-soft)",
color: banning ? "var(--danger)" : "var(--ok)",
}}
aria-hidden="true"
>
{banning ? <Ban size={23} /> : <RotateCcw size={21} />}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{banning ? "封禁账号" : "解除封禁"}
</h3>
<p className="meta mt-1.5 text-[13px] leading-relaxed">
{banning
? `封禁后 @${target.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`
: `解除后 @${target.username} 可以重新登录,并恢复发帖、回复等全部功能。`}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button type="button" className="btn btn-line" onClick={onCancel} disabled={busy} autoFocus>
取消
</button>
{banning ? (
<button type="button" className="btn btn-danger" onClick={onConfirm} disabled={busy}>
{busy && <Loader2 size={15} className="animate-spin" />}
确认封禁
</button>
) : (
<button
type="button"
className="btn"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
解除封禁
</button>
)}
</div>
</div>
</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]);
useEffect(() => {
if (!target) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [target, onClose]);
if (!target) return null;
const pageSize = 15;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={onClose}
>
<div
className="j13-toast-in panel w-full max-w-[520px] rounded-2xl p-6 max-h-[88vh] flex flex-col"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="登录历史"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[16px] font-extrabold tracking-tight flex items-center gap-2" style={{ color: "var(--ink)" }}>
<History size={17} style={{ color: "var(--accent)" }} />
登录历史
</h3>
<p className="meta mt-1 text-[12.5px] truncate">
@{target.username} · 共 {total} 条记录(含失败尝试)
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={16} />
</button>
</div>
<div className="mt-4 -mx-1.5 px-1.5 overflow-y-auto flex-1">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : err ? (
<p className="alert-error my-4">{err}</p>
) : logs.length === 0 ? (
<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)" }}
title={log.success ? "成功" : "失败"}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-[12.5px] font-semibold 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] font-bold"
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)" }} title={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>
{total > pageSize && (
<div className="flex items-center justify-between pt-3 mt-2" style={{ borderTop: "1px solid var(--line)" }}>
<span className="meta text-[11.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {page} / {totalPages} 页
</span>
<div className="flex items-center gap-2">
<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>
</div>
</div>
)}
</div>
</div>
);
}