feat: invite code full CRUD + demo account seeded with sample data + SW v3 (fix /auth/me cache poisoning)

This commit is contained in:
root
2026-10-16 18:47:58 +08:00
parent 4cb7ccbf15
commit 1863e11a7a
17 changed files with 589 additions and 415 deletions
+35 -18
View File
@@ -144,25 +144,42 @@ export const viewUpsert = (p: { id?: number; name: string; filter: Record<string
export const viewDelete = (id: number) => call<{ ok: true }>('view_delete', { id });
// ---- 管理端(is_admin) ----
export async function adminListInvites(baseUrl?: string) {
const res = await fetch((baseUrl || getServerUrl()) + '/admin/invites', { headers: { Authorization: 'Bearer ' + getToken() } });
return res.json();
}
export async function adminCreateInvites(count: number, baseUrl?: string) {
const res = await fetch((baseUrl || getServerUrl()) + '/admin/invites/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ count })
async function adminCall<T>(path: string, body?: unknown, baseUrl?: string): Promise<T> {
const res = await fetch((baseUrl || getServerUrl()) + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify(body ?? {}),
});
return res.json();
const json = await res.json().catch(() => ({ ok: false, error: 'HTTP ' + res.status }));
if (!json.ok) throw new Error(json.error || '请求失败');
return json.data as T;
}
export async function adminRevokeInvite(id: number, baseUrl?: string) {
const res = await fetch((baseUrl || getServerUrl()) + '/admin/invites/revoke', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ id })
});
return res.json();
async function adminGet<T>(path: string, baseUrl?: string): Promise<T> {
const res = await fetch((baseUrl || getServerUrl()) + path, { headers: { Authorization: 'Bearer ' + getToken() } });
const json = await res.json().catch(() => ({ ok: false, error: 'HTTP ' + res.status }));
if (!json.ok) throw new Error(json.error || '请求失败');
return json.data as T;
}
export async function adminListUsers(baseUrl?: string) {
const res = await fetch((baseUrl || getServerUrl()) + '/admin/users', { headers: { Authorization: 'Bearer ' + getToken() } });
return res.json();
export interface InviteRow {
id: number; code: string; remark: string | null; created_at: string;
used_by: string | null; used_at: string | null; valid: boolean;
used_by_name?: string | null; created_by_name?: string | null;
}
export interface AdminUserRow {
id: string; username: string; is_admin: boolean; created_at: string;
devices: number; records: number; attachments: number; attachment_bytes: number;
}
export interface InviteList { total: number; invites: InviteRow[]; }
export interface UserList { total: number; users: AdminUserRow[]; }
export const adminListInvites = (baseUrl?: string) => adminGet<InviteList>('/admin/invites', baseUrl);
export const adminListUsers = (baseUrl?: string) => adminGet<UserList>('/admin/users', baseUrl);
export const adminGenerateInvites = (count: number, baseUrl?: string) =>
adminCall<{ codes: string[] }>('/admin/invites/generate', { count }, baseUrl);
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 adminDeleteInvite = (id: number, baseUrl?: string) =>
adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl);
+180 -47
View File
@@ -1,88 +1,224 @@
import { useState } from 'react';
import { useMemo, 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 } from '@/lib/api';
import { Badge, Button, Card, CardBody, CardHeader, Empty } from '@/components/ui';
import { Check, Copy, Pencil, Plus, RefreshCw, TicketCheck, Trash2, Users2, X } from 'lucide-react';
import {
adminCreateInvite, adminDeleteInvite, adminGenerateInvites, adminListInvites,
adminListUsers, adminUpdateInvite, getServerUrl, getToken,
type InviteRow,
} from '@/lib/api';
import { Badge, Button, Card, CardBody, CardHeader, Empty, Input } from '@/components/ui';
import { fmtDateTime, fmtSize } from '@/lib/format';
function baseUrl() {
return localStorage.getItem('fp-server-url') || '';
type Filter = 'all' | 'valid' | 'used' | 'disabled';
function InviteStatus({ i }: { i: InviteRow }) {
if (i.used_by) return <Badge color="var(--text-3)">已使用</Badge>;
return i.valid ? <Badge color="var(--success)">可用</Badge> : <Badge color="var(--warning)">已停用</Badge>;
}
export function AdminPage() {
const qc = useQueryClient();
const [busy, setBusy] = useState(false);
const meQ = useQuery({ queryKey: ['me'], queryFn: () => import('@/lib/api').then(m => m.serverMe(baseUrl())), enabled: !!getToken() });
const base = getServerUrl();
const meQ = useQuery({ queryKey: ['me-admin'], queryFn: async () => {
const m = await import('@/lib/api'); return m.serverMe(base);
}, 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 usersQ = useQuery({ queryKey: ['admin-users'], queryFn: () => adminListUsers(base), enabled: !!getToken() });
const invitesQ = useQuery({ queryKey: ['admin-invites'], queryFn: () => adminListInvites(base), enabled: !!getToken() });
const [actionError, setActionError] = useState('');
const [copiedId, setCopiedId] = useState<number | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [newCode, setNewCode] = useState('');
const [newRemark, setNewRemark] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState('');
const [filter, setFilter] = useState<Filter>('all');
const invalidate = () => { void qc.invalidateQueries({ queryKey: ['admin-invites'] }); };
const onErr = (e: unknown) => setActionError((e as Error).message || '操作失败');
const genMut = useMutation({
mutationFn: (count: number) => adminCreateInvites(count, baseUrl()),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['admin-invites'] }); setBusy(false); },
onError: () => setBusy(false)
mutationFn: () => adminGenerateInvites(5, base),
onSuccess: (d) => { invalidate(); setActionError(''); void navigator.clipboard.writeText(d.codes.join('\n')).catch(() => {}); },
onError: onErr,
});
const revokeMut = useMutation({
mutationFn: (id: number) => adminRevokeInvite(id, baseUrl()),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin-invites'] })
const createMut = useMutation({
mutationFn: () => adminCreateInvite(newCode.trim(), newRemark.trim(), base),
onSuccess: () => { invalidate(); setCreateOpen(false); setNewCode(''); setNewRemark(''); setActionError(''); },
onError: onErr,
});
const updateMut = useMutation({
mutationFn: (p: { id: number; patch: { remark?: string; valid?: boolean } }) => adminUpdateInvite(p.id, p.patch, base),
onSuccess: () => { invalidate(); setEditingId(null); setActionError(''); },
onError: onErr,
});
const deleteMut = useMutation({
mutationFn: (id: number) => adminDeleteInvite(id, base),
onSuccess: () => { invalidate(); setActionError(''); },
onError: onErr,
});
const invites = useMemo(() => invitesQ.data?.invites ?? [], [invitesQ.data]);
const users = usersQ.data?.users ?? [];
const filtered = useMemo(() => invites.filter((i) => {
if (filter === 'valid') return i.valid && !i.used_by;
if (filter === 'used') return !!i.used_by;
if (filter === 'disabled') return !i.valid && !i.used_by;
return true;
}), [invites, filter]);
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;
const copyCode = (i: InviteRow) => {
void navigator.clipboard.writeText(i.code);
setCopiedId(i.id);
setTimeout(() => setCopiedId((v) => (v === i.id ? null : v)), 1500);
};
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>
<Button variant="secondary" onClick={() => { void qc.invalidateQueries(); }}>
<RefreshCw size={13} /> 刷新
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{actionError && (
<div className="mb-3 flex items-center justify-between rounded-md border border-danger/30 bg-danger-subtle px-3 py-2 text-[12px] text-danger">
<span>{actionError}</span>
<button className="rounded p-0.5 hover:bg-surface-3" onClick={() => setActionError('')} aria-label="关闭"><X size={13} /></button>
</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title={'邀请码(' + totalInvites + ')'} extra={
<Button size="sm" variant="primary" disabled={busy} onClick={() => { setBusy(true); genMut.mutate(5); }}>
<Plus size={13} /> 批量生成 5 个
</Button>
<CardHeader title={'邀请码(' + (invitesQ.data?.total ?? 0) + ')'} extra={
<div className="flex gap-1.5">
<Button size="sm" variant="secondary" onClick={() => { setCreateOpen((v) => !v); setActionError(''); }}>
<Plus size={13} /> 新建
</Button>
<Button size="sm" variant="primary" disabled={genMut.isPending} onClick={() => genMut.mutate()}>
批量生成 5 个
</Button>
</div>
} />
<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); }}>
复制
{createOpen && (
<div className="flex flex-wrap items-end gap-2 border-b border-line bg-surface-2 px-4 py-3">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-ink-3">邀请码(4-32 位字母数字-_)</span>
<Input className="w-[210px]" value={newCode} placeholder="如 XIONG-2025"
onChange={(e) => setNewCode(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-ink-3">备注(可选)</span>
<Input className="w-[180px]" value={newRemark} placeholder="发给谁 / 用途"
onChange={(e) => setNewRemark(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && newCode.trim()) createMut.mutate(); }} />
</label>
<div className="flex gap-1.5 pb-0.5">
<Button size="sm" variant="primary" disabled={!newCode.trim() || createMut.isPending}
onClick={() => createMut.mutate()}>创建</Button>
<Button size="sm" variant="ghost" onClick={() => { setCreateOpen(false); setNewCode(''); setNewRemark(''); }}>取消</Button>
</div>
</div>
)}
<div className="flex gap-1 border-b border-line px-4 py-2 text-[12px]">
{([['all', '全部'], ['valid', '可用'], ['used', '已使用'], ['disabled', '已停用']] as [Filter, string][]).map(([k, label]) => (
<button key={k}
className={'rounded-md px-2.5 py-1 transition-colors ' + (filter === k ? 'bg-brand-subtle font-medium text-brand-text' : 'text-ink-3 hover:bg-surface-3')}
onClick={() => setFilter(k)}>{label}</button>
))}
</div>
{filtered.length ? (
<div className="divide-y divide-line max-h-[430px] overflow-y-auto">
{filtered.map((i) => (
<div key={i.id} className="flex items-center gap-2 px-4 py-2.5 text-[12.5px]">
<button
className="num shrink-0 cursor-pointer rounded-sm bg-surface-3 px-1.5 py-0.5 font-mono text-[12px] text-ink-1 transition-colors hover:bg-brand-subtle hover:text-brand-text"
title="点击复制"
onClick={() => copyCode(i)}
>
{copiedId === i.id ? <span className="inline-flex items-center gap-1 text-success"><Check size={11} />已复制</span> : i.code}
</button>
<InviteStatus i={i} />
{editingId === i.id ? (
<span className="ml-1 flex min-w-0 flex-1 items-center gap-1">
<Input className="h-7 flex-1 text-[12px]" value={editDraft} autoFocus
placeholder="备注(64 字以内)"
onChange={(e) => setEditDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateMut.mutate({ id: i.id, patch: { remark: editDraft } });
if (e.key === 'Escape') setEditingId(null);
}} />
<Button size="icon-sm" variant="ghost" aria-label="保存备注" className="text-success"
onClick={() => updateMut.mutate({ id: i.id, patch: { remark: editDraft } })}>
<Check size={13} />
</Button>
<Button size="icon-sm" variant="ghost" aria-label="取消" className="text-ink-3" onClick={() => setEditingId(null)}>
<X size={13} />
</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>
) : (
<button
className="min-w-0 flex-1 truncate text-left text-[12px] text-ink-3 hover:text-ink-2"
title={i.remark ? '点击编辑备注' : (i.created_by_name ? '由 ' + i.created_by_name + ' 创建 · 点击添加备注' : '点击添加备注')}
onClick={() => { setEditingId(i.id); setEditDraft(i.remark ?? ''); }}
>
{i.remark || (i.created_by_name ? '由 ' + i.created_by_name + ' 创建' : <span className="opacity-60">添加备注…</span>)}
</button>
)}
{i.used_by_name && (
<span className="shrink-0 text-ink-3">被 {i.used_by_name} 使用于 {fmtDateTime(i.used_at)}</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-0.5">
<Button size="icon-sm" variant="ghost" aria-label="复制" className="text-ink-3" onClick={() => copyCode(i)}>
<Copy size={13} />
</Button>
{!i.used_by && (
<>
<Button size="icon-sm" variant="ghost" aria-label="编辑备注" className="text-ink-3"
onClick={() => { setEditingId(i.id); setEditDraft(i.remark ?? ''); }}>
<Pencil size={13} />
</Button>
<Button size="sm" variant="ghost"
className={i.valid ? 'text-ink-3 hover:text-warning' : 'text-ink-3 hover:text-success'}
onClick={() => updateMut.mutate({ id: i.id, patch: { valid: !i.valid } })}>
{i.valid ? '停用' : '启用'}
</Button>
</>
)}
<Button size="icon-sm" variant="ghost" aria-label="删除" className="text-ink-3 hover:text-danger"
onClick={() => { if (window.confirm('删除邀请码 ' + i.code + '?此操作不可恢复。')) deleteMut.mutate(i.id); }}>
<Trash2 size={13} />
</Button>
</span>
</div>
))}
</div>
) : (
<Empty icon={<TicketCheck size={32} strokeWidth={1.5} />} title="暂无邀请码" description="生成后发给需要注册的用户。" />
<Empty icon={<TicketCheck size={32} strokeWidth={1.5} />} title="暂无符合条件的邀请码"
description="新建自定义邀请码,或批量生成后发给需要注册的用户。" />
)}
</CardBody>
</Card>
<Card>
<CardHeader title={'用户(' + (usersQ.data?.data?.total ?? 0) + ')'} />
<CardHeader title={'用户(' + (usersQ.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]">
<div className="divide-y divide-line max-h-[520px] overflow-y-auto">
{users.map((u) => (
<div key={u.id} className="flex items-center gap-2 px-4 py-2.5 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>
@@ -95,9 +231,6 @@ export function AdminPage() {
</CardBody>
</Card>
</div>
{busy && <div className="mt-3 text-[12px] text-ink-3">生成中…</div>}
</div>
);
}