feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Columns3,
|
||||
ExternalLink,
|
||||
FolderSearch,
|
||||
History,
|
||||
@@ -47,6 +48,47 @@ import {
|
||||
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: "全部" },
|
||||
@@ -60,6 +102,90 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string }
|
||||
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,
|
||||
@@ -93,19 +219,29 @@ export default function UsersAdmin({
|
||||
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));
|
||||
@@ -118,8 +254,6 @@ export default function UsersAdmin({
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变;
|
||||
// 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确
|
||||
useEffect(() => {
|
||||
const reconcile = (ids: ReadonlySet<number>) => {
|
||||
if (ids.size === 0) return;
|
||||
@@ -162,7 +296,6 @@ export default function UsersAdmin({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
@@ -177,7 +310,6 @@ export default function UsersAdmin({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keyword]);
|
||||
|
||||
// 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
|
||||
useEffect(() => {
|
||||
if (urlFirstRun.current) {
|
||||
urlFirstRun.current = false;
|
||||
@@ -191,14 +323,12 @@ export default function UsersAdmin({
|
||||
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
|
||||
}, [appliedQ, filter, page, router]);
|
||||
|
||||
// Enter 立即提交,跳过防抖
|
||||
const submitSearch = () => {
|
||||
const q = keyword.trim();
|
||||
if (q === appliedQ) return;
|
||||
load({ page: 1, filter, q });
|
||||
};
|
||||
|
||||
// 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
|
||||
const clearSearch = () => {
|
||||
setKeyword("");
|
||||
if (appliedQ !== "" || page !== 1) {
|
||||
@@ -221,12 +351,85 @@ export default function UsersAdmin({
|
||||
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 (
|
||||
@@ -240,6 +443,19 @@ export default function UsersAdmin({
|
||||
</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
|
||||
@@ -301,7 +517,6 @@ export default function UsersAdmin({
|
||||
</AdminIconButton>
|
||||
) : null;
|
||||
|
||||
// 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案
|
||||
if (u.role === ROLES.OWNER) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
@@ -350,11 +565,66 @@ export default function UsersAdmin({
|
||||
);
|
||||
};
|
||||
|
||||
// 桌面端列模板(成员列收窄,新增在线状态与上次登录 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]";
|
||||
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>
|
||||
@@ -364,7 +634,6 @@ export default function UsersAdmin({
|
||||
description="检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护"
|
||||
/>
|
||||
|
||||
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<AdminStatCard
|
||||
label="当前在线"
|
||||
@@ -399,28 +668,32 @@ export default function UsersAdmin({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 搜索 + 筛选 + 用户列表 */}
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="admin-toolbar">
|
||||
<AdminSearchField
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSubmit={submitSearch}
|
||||
onClear={clearSearch}
|
||||
placeholder="搜索用户名、昵称或邮箱…"
|
||||
ariaLabel="搜索用户"
|
||||
/>
|
||||
<AdminSegmented<UserFilter>
|
||||
role="group"
|
||||
ariaLabel="用户筛选"
|
||||
value={filter}
|
||||
onChange={changeFilter}
|
||||
options={FILTER_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
count: tabCount(t.key),
|
||||
}))}
|
||||
/>
|
||||
<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
|
||||
@@ -445,18 +718,21 @@ export default function UsersAdmin({
|
||||
</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)" }}
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<span>成员</span>
|
||||
<span>状态</span>
|
||||
<span>角色</span>
|
||||
<span>上次登录 IP</span>
|
||||
<span className="xl:block hidden">内容 / 注册</span>
|
||||
<span className="text-right">操作</span>
|
||||
{visibleCols.map((c) => (
|
||||
<span key={c} className={c === "actions" ? "text-right" : undefined}>
|
||||
{colHeader(c)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -474,41 +750,14 @@ export default function UsersAdmin({
|
||||
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 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>{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>
|
||||
|
||||
{/* 移动卡片:空间紧,保留 btn-line btn-sm */}
|
||||
<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} />
|
||||
@@ -531,17 +780,18 @@ export default function UsersAdmin({
|
||||
</div>
|
||||
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||
@{u.username}
|
||||
{u.email ? ` · ${u.email}` : ""}
|
||||
{optCols.email && 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 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" />
|
||||
@@ -620,8 +870,6 @@ export default function UsersAdmin({
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 登录历史弹窗 =====
|
||||
|
||||
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
|
||||
const [logs, setLogs] = useState<LoginLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -675,7 +923,6 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos
|
||||
panelClassName="p-6 max-h-[88vh] flex flex-col"
|
||||
ariaLabel="登录历史"
|
||||
footer={
|
||||
// Modal footer 不好塞 AdminPager,保留原紧凑按钮
|
||||
total > pageSize ? (
|
||||
<>
|
||||
<span className="meta text-[11.5px] tabular-nums mr-auto" style={{ color: "var(--ink-3)" }}>
|
||||
|
||||
Reference in New Issue
Block a user