feat: 登录持久化修复 + 左下角账号区 + 管理页面(管理员/邀请码)
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Users2, TicketCheck } from 'lucide-react';
|
||||
import { adminCreateInvites, adminListInvites, adminListUsers, adminRevokeInvite, getToken, isTauriRef } from '@/lib/api';
|
||||
import { Badge, Button, Card, CardBody, CardHeader, Empty } from '@/components/ui';
|
||||
import { fmtDateTime, fmtSize } from '@/lib/format';
|
||||
|
||||
function baseUrl() {
|
||||
if (!isTauriRef()) return '';
|
||||
return localStorage.getItem('fp-server-url') || '';
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const qc = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const meQ = useQuery({ queryKey: ['me'], queryFn: () => serverMeSafe(), enabled: !!getToken() });
|
||||
|
||||
const usersQ = useQuery({ queryKey: ['admin-users'], queryFn: () => adminListUsers(baseUrl()), enabled: !!getToken() });
|
||||
const invitesQ = useQuery({ queryKey: ['admin-invites'], queryFn: () => adminListInvites(baseUrl()), enabled: !!getToken() });
|
||||
|
||||
const genMut = useMutation({
|
||||
mutationFn: (count: number) => adminCreateInvites(count, baseUrl()),
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['admin-invites'] }); setBusy(false); },
|
||||
onError: () => setBusy(false)
|
||||
});
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: (id: number) => adminRevokeInvite(id, baseUrl()),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin-invites'] })
|
||||
});
|
||||
|
||||
if (meQ.data && !meQ.data.is_admin) {
|
||||
return <Empty title="需要管理员账号" description="当前登录账号不是管理员。" />;
|
||||
}
|
||||
|
||||
const invites = invitesQ.data?.data?.invites ?? [];
|
||||
const users = usersQ.data?.data?.users ?? [];
|
||||
const totalInvites = invitesQ.data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] px-6 py-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-ink-1">管理</h2>
|
||||
<Button variant="secondary" onClick={() => { void qc.invalidateQueries(); }}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader title={'邀请码(' + totalInvites + ')'} extra={
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => { setBusy(true); genMut.mutate(5); }}>
|
||||
<Plus size={13} /> 批量生成 5 个
|
||||
</Button>
|
||||
} />
|
||||
<CardBody className="p-0">
|
||||
{invites.length ? (
|
||||
<div className="divide-y divide-line max-h-[420px] overflow-y-auto">
|
||||
{invites.map((i: any) => (
|
||||
<div key={i.id} className="flex items-center gap-2 px-4 py-2 text-[12.5px]">
|
||||
<code className={'num rounded-sm px-1.5 py-0.5 ' + (i.valid ? 'bg-success-subtle text-success' : 'bg-surface-3 text-ink-3')}>{i.code}</code>
|
||||
{i.valid ? <Badge color="var(--success)">可用</Badge> : <Badge color="var(--text-3)">已使用</Badge>}
|
||||
{i.used_by_name && <span className="text-ink-3 truncate">被 {i.used_by_name} 使用于 {fmtDateTime(i.used_at)}</span>}
|
||||
{!i.used_by && (
|
||||
<span className="ml-auto flex gap-1">
|
||||
<Button size="sm" variant="secondary" onClick={() => { void navigator.clipboard.writeText(i.code); }}>
|
||||
复制
|
||||
</Button>
|
||||
{!i.valid && <Button size="icon-sm" variant="ghost" aria-label="作废" className="text-ink-3" onClick={() => revokeMut.mutate(i.id)}>
|
||||
<Trash2 size={13} />
|
||||
</Button>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty icon={<TicketCheck size={32} strokeWidth={1.5} />} title="暂无邀请码" description="生成后发给需要注册的用户。" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title={'用户(' + (usersQ.data?.data?.total ?? 0) + ')'} />
|
||||
<CardBody className="p-0">
|
||||
{users.length ? (
|
||||
<div className="divide-y divide-line max-h-[420px] overflow-y-auto">
|
||||
{users.map((u: any) => (
|
||||
<div key={u.id} className="flex items-center gap-2 px-4 py-2 text-[12.5px]">
|
||||
<span className="font-medium text-ink-1">{u.username}</span>
|
||||
{u.is_admin && <Badge color="var(--brand-text)">管理员</Badge>}
|
||||
<span className="num ml-auto text-ink-3">{u.devices} 设备 · {u.records} 记录 · {fmtSize(u.attachment_bytes)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty icon={<Users2 size={32} strokeWidth={1.5} />} title="暂无用户" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{busy && <div className="mt-3 text-[12px] text-ink-3">生成中…</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function serverMeSafe() {
|
||||
const { serverMe } = await import('@/lib/api');
|
||||
try { return await serverMe(baseUrl()); } catch { return null; }
|
||||
}
|
||||
Reference in New Issue
Block a user