超管/站长可在他人主页就地管理账号;待审评论支持拒绝;角色弹窗前后台共用。 Co-authored-by: Cursor <cursoragent@cursor.com>
923 lines
33 KiB
TypeScript
923 lines
33 KiB
TypeScript
"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 (
|
||
<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] 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]" 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 ?? {
|
||
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 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]"
|
||
style={{ color: "var(--danger)" }}
|
||
data-tip="账号已封禁"
|
||
>
|
||
<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]"
|
||
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}`}
|
||
data-tip="内容档案"
|
||
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}`}
|
||
data-tip="查看个人主页"
|
||
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] 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}
|
||
<Link
|
||
href={`/u/${u.id}`}
|
||
data-tip="查看个人主页"
|
||
aria-label={`查看 ${u.nickname} 的个人主页`}
|
||
className={ghostBtn}
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
<ExternalLink size={15} />
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
data-tip="角色与账号"
|
||
aria-label={`设置 ${u.username} 的角色与账号状态`}
|
||
disabled={busy}
|
||
onClick={() => setRoleTarget(u)}
|
||
className={ghostBtn}
|
||
style={
|
||
u.banned
|
||
? { color: "var(--danger)" }
|
||
: u.role === ROLES.USER
|
||
? { color: "var(--ink-3)" }
|
||
: { color: "var(--accent)" }
|
||
}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = u.banned ? "var(--danger-soft)" : "var(--accent-soft)";
|
||
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--accent)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = "";
|
||
e.currentTarget.style.color = u.banned
|
||
? "var(--danger)"
|
||
: u.role === ROLES.USER
|
||
? "var(--ink-3)"
|
||
: "var(--accent)";
|
||
}}
|
||
>
|
||
<ShieldCheck 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.online)}
|
||
sub="5 分钟内活跃"
|
||
icon={<Activity size={16} />}
|
||
color="var(--ok)"
|
||
soft="var(--ok-soft)"
|
||
/>
|
||
<MiniStat
|
||
label="发帖冷静期中"
|
||
value={String(summary.in_cooldown)}
|
||
sub="普通用户"
|
||
icon={<Hourglass size={16} />}
|
||
color="var(--gold)"
|
||
soft="var(--gold-soft)"
|
||
/>
|
||
<MiniStat
|
||
label="近 7 日活跃"
|
||
value={String(summary.active_7d)}
|
||
icon={<Monitor size={16} />}
|
||
color="var(--accent)"
|
||
soft="var(--accent-soft)"
|
||
/>
|
||
<MiniStat
|
||
label="24h 登录失败"
|
||
value={String(summary.failed_logins_24h)}
|
||
sub="次"
|
||
icon={<ShieldAlert size={16} />}
|
||
color="var(--danger)"
|
||
soft="var(--danger-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] 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-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={`${rowGrid} text-[11.5px] 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="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] 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] tabular-nums"
|
||
style={{ color: "var(--ink-2)" }}
|
||
data-tip="仅统计已发布内容;驳回/删除见内容档案"
|
||
>
|
||
帖 {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-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}
|
||
{u.email ? ` · ${u.email}` : ""}
|
||
</p>
|
||
</div>
|
||
{renderStatus(u)}
|
||
</div>
|
||
<p
|
||
className="mt-2.5 text-[11.5px] tabular-nums"
|
||
style={{ color: "var(--ink-3)" }}
|
||
data-tip="仅统计已发布内容;驳回/删除见内容档案"
|
||
>
|
||
帖 {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.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>
|
||
|
||
{/* 分页 */}
|
||
{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>
|
||
|
||
<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)" }}>
|
||
{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)" }}
|
||
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>
|
||
);
|
||
}
|