feat: 登录持久化修复 + 左下角账号区 + 管理页面(管理员/邀请码)

This commit is contained in:
root
2026-10-16 16:02:39 +08:00
parent 1c78fa77a0
commit 1156f97c66
19 changed files with 616 additions and 332 deletions
+108
View File
@@ -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; }
}