feat(admin): user management - list + high-risk delete with full cascade (lsky, user db, attachments, auth)

This commit is contained in:
root
2026-10-17 13:37:15 +08:00
parent 68043d309e
commit 5452611a63
20 changed files with 181 additions and 55 deletions
+1 -1
View File
@@ -1374,7 +1374,7 @@ function listFootprints(d, query) {
};
}
return { handleCommand, getDb, pullWire, applyWireBatch };
return { handleCommand, getDb, pullWire, applyWireBatch, close: () => { try { db.close(); } catch {} } };
}
// ---------- 默认单例(开发桥接 / seed 兼容) ----------
+2
View File
@@ -211,6 +211,8 @@ export const adminCreateInvite = (code: string, remark: string, baseUrl?: string
adminCall<{ code: string }>('/admin/invites/create', { code, remark }, baseUrl);
export const adminUpdateInvite = (id: number, patch: { remark?: string; valid?: boolean }, baseUrl?: string) =>
adminCall<{ id: number }>('/admin/invites/update', { id, ...patch }, baseUrl);
export const adminDeleteUser = (userId: number | string, baseUrl?: string) =>
adminCall<{ ok: true; username: string; remote_deleted: number }>('/admin/users/delete', { user_id: userId }, baseUrl);
export const adminDeleteInvite = (id: number, baseUrl?: string) =>
adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl);
+81 -3
View File
@@ -1,12 +1,12 @@
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { Check, Copy, Pencil, Plus, RefreshCw, TicketCheck, Trash2, Users2, X } from 'lucide-react';
import { AlertTriangle, Check, Copy, Pencil, Plus, RefreshCw, TicketCheck, Trash2, Users2, X } from 'lucide-react';
import {
adminCreateInvite, adminDeleteInvite, adminGenerateInvites, adminListInvites,
adminCreateInvite, adminDeleteInvite, adminDeleteUser, adminGenerateInvites, adminListInvites,
adminListUsers, adminUpdateInvite, getServerUrl, getToken,
type InviteRow,
} from '@/lib/api';
import { Badge, Button, Card, CardBody, CardHeader, Empty, Input } from '@/components/ui';
import { Badge, Button, Card, CardBody, CardHeader, Dialog, Empty, Input, useToast } from '@/components/ui';
import { fmtDateTime, fmtSize } from '@/lib/format';
type Filter = 'all' | 'valid' | 'used' | 'disabled';
@@ -18,6 +18,7 @@ function InviteStatus({ i }: { i: InviteRow }) {
export function AdminPage() {
const qc = useQueryClient();
const toast = useToast();
const base = getServerUrl();
const meQ = useQuery({ queryKey: ['me-admin'], queryFn: async () => {
const m = await import('@/lib/api'); return m.serverMe(base);
@@ -34,9 +35,22 @@ export function AdminPage() {
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState('');
const [filter, setFilter] = useState<Filter>('all');
const [delUser, setDelUser] = useState<{ id: number | string; username: string } | null>(null);
const [delConfirm, setDelConfirm] = useState('');
const invalidate = () => { void qc.invalidateQueries({ queryKey: ['admin-invites'] }); };
const onErr = (e: unknown) => setActionError((e as Error).message || '操作失败');
const delUserMut = useMutation({
mutationFn: (id: number | string) => adminDeleteUser(id, base),
onSuccess: (d) => {
void qc.invalidateQueries({ queryKey: ['admin-users'] });
setDelUser(null); setDelConfirm(''); setActionError('');
toast('success', '用户「' + d.username + '」及其全部数据已删除');
},
onError: onErr,
});
const genMut = useMutation({
mutationFn: () => adminGenerateInvites(5, base),
@@ -94,6 +108,39 @@ export function AdminPage() {
</div>
)}
{/* 用户管理:查看 + 高危删除 */}
<Card>
<CardHeader title={'用户管理(' + (usersQ.data?.total ?? 0) + ')'} extra={<Users2 size={14} className="text-ink-3" />} />
<CardBody className="p-0">
<div className="divide-y divide-line">
{(usersQ.data?.users ?? []).map(u => {
const isMe = String(u.id) === String(meQ.data?.user_id ?? '');
return (
<div key={u.id} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-ink-1">{u.username}</span>
{u.is_admin && <Badge color="var(--brand-text)">管理员</Badge>}
{isMe && <Badge color="var(--text-3)">当前登录</Badge>}
</div>
<div className="num mt-0.5 text-[11px] text-ink-3">
{fmtDateTime(u.created_at)} · 记录 {u.records} · 附件 {u.attachments}({fmtSize(u.attachment_bytes || 0)})
</div>
</div>
{!isMe && (
<Button size="sm" variant="ghost" className="text-danger hover:bg-danger-subtle"
onClick={() => { setDelUser({ id: u.id, username: u.username }); setDelConfirm(''); }}>
<Trash2 size={13} /> 删除
</Button>
)}
</div>
);
})}
{!(usersQ.data?.users ?? []).length && <Empty title="暂无用户" />}
</div>
</CardBody>
</Card>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title={'邀请码(' + (invitesQ.data?.total ?? 0) + ')'} extra={
@@ -231,6 +278,37 @@ export function AdminPage() {
</CardBody>
</Card>
</div>
<Dialog
open={!!delUser}
onClose={() => setDelUser(null)}
width={460}
title="删除用户(高危操作)"
footer={<>
<Button variant="ghost" onClick={() => setDelUser(null)}>取消</Button>
<Button variant="primary" className="!bg-danger !text-white" disabled={delConfirm !== delUser?.username || delUserMut.isPending}
onClick={() => delUser && delUserMut.mutate(delUser.id)}>
<AlertTriangle size={13} /> 永久删除
</Button>
</>}>
<div className="flex flex-col gap-3 text-[12.5px]">
<div className="flex items-start gap-2 rounded-md border border-danger/30 bg-danger-subtle px-3 py-2.5 text-danger">
<AlertTriangle size={15} className="mt-0.5 shrink-0" />
<div>
<b>此操作不可撤销!</b>将永久删除用户「{delUser?.username}」的全部数据:
<ul className="mt-1 list-disc pl-4 text-[12px] leading-relaxed">
<li>账号与登录会话(所有设备将被登出)</li>
<li>全部记录、成绩、维度与统计数据</li>
<li>全部附件文件(服务器本地与图床 COS 上的副本)</li>
</ul>
</div>
</div>
<div>
<div className="mb-1.5 text-ink-2">请输入用户名 <b className="text-danger">{delUser?.username}</b> 以确认:</div>
<Input value={delConfirm} onChange={e => setDelConfirm(e.target.value)} placeholder={delUser?.username ?? ''} autoFocus />
</div>
</div>
</Dialog>
</div>
);
}