feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View File

@@ -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)" }}>