feat(admin): user management - list + high-risk delete with full cascade (lsky, user db, attachments, auth)
This commit is contained in:
+1
-1
@@ -1374,7 +1374,7 @@ function listFootprints(d, query) {
|
||||
};
|
||||
}
|
||||
|
||||
return { handleCommand, getDb, pullWire, applyWireBatch };
|
||||
return { handleCommand, getDb, pullWire, applyWireBatch, close: () => { try { db.close(); } catch {} } };
|
||||
}
|
||||
|
||||
// ---------- 默认单例(开发桥接 / seed 兼容) ----------
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user