feat: invite code full CRUD + demo account seeded with sample data + SW v3 (fix /auth/me cache poisoning)
This commit is contained in:
+35
-18
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user