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 }); export const viewDelete = (id: number) => call<{ ok: true }>('view_delete', { id });
// ---- 管理端(is_admin) ---- // ---- 管理端(is_admin) ----
export async function adminListInvites(baseUrl?: string) { async function adminCall<T>(path: string, body?: unknown, baseUrl?: string): Promise<T> {
const res = await fetch((baseUrl || getServerUrl()) + '/admin/invites', { headers: { Authorization: 'Bearer ' + getToken() } }); const res = await fetch((baseUrl || getServerUrl()) + path, {
return res.json(); method: 'POST',
} headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
export async function adminCreateInvites(count: number, baseUrl?: string) { body: JSON.stringify(body ?? {}),
const res = await fetch((baseUrl || getServerUrl()) + '/admin/invites/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ count })
}); });
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) { async function adminGet<T>(path: string, baseUrl?: string): Promise<T> {
const res = await fetch((baseUrl || getServerUrl()) + '/admin/invites/revoke', { const res = await fetch((baseUrl || getServerUrl()) + path, { headers: { Authorization: 'Bearer ' + getToken() } });
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, const json = await res.json().catch(() => ({ ok: false, error: 'HTTP ' + res.status }));
body: JSON.stringify({ id }) if (!json.ok) throw new Error(json.error || '请求失败');
}); return json.data as T;
return res.json();
} }
export async function adminListUsers(baseUrl?: string) {
const res = await fetch((baseUrl || getServerUrl()) + '/admin/users', { headers: { Authorization: 'Bearer ' + getToken() } }); export interface InviteRow {
return res.json(); 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 { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { Plus, Trash2, Users2, TicketCheck } from 'lucide-react'; import { Check, Copy, Pencil, Plus, RefreshCw, TicketCheck, Trash2, Users2, X } from 'lucide-react';
import { adminCreateInvites, adminListInvites, adminListUsers, adminRevokeInvite, getToken } from '@/lib/api'; import {
import { Badge, Button, Card, CardBody, CardHeader, Empty } from '@/components/ui'; 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'; import { fmtDateTime, fmtSize } from '@/lib/format';
function baseUrl() { type Filter = 'all' | 'valid' | 'used' | 'disabled';
return localStorage.getItem('fp-server-url') || '';
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() { export function AdminPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [busy, setBusy] = useState(false); const base = getServerUrl();
const meQ = useQuery({ queryKey: ['me'], queryFn: () => import('@/lib/api').then(m => m.serverMe(baseUrl())), enabled: !!getToken() }); 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 usersQ = useQuery({ queryKey: ['admin-users'], queryFn: () => adminListUsers(base), enabled: !!getToken() });
const invitesQ = useQuery({ queryKey: ['admin-invites'], queryFn: () => adminListInvites(baseUrl()), 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({ const genMut = useMutation({
mutationFn: (count: number) => adminCreateInvites(count, baseUrl()), mutationFn: () => adminGenerateInvites(5, base),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['admin-invites'] }); setBusy(false); }, onSuccess: (d) => { invalidate(); setActionError(''); void navigator.clipboard.writeText(d.codes.join('\n')).catch(() => {}); },
onError: () => setBusy(false) onError: onErr,
}); });
const revokeMut = useMutation({ const createMut = useMutation({
mutationFn: (id: number) => adminRevokeInvite(id, baseUrl()), mutationFn: () => adminCreateInvite(newCode.trim(), newRemark.trim(), base),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin-invites'] }) 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) { if (meQ.data && !meQ.data.is_admin) {
return <Empty title="需要管理员账号" description="当前登录账号不是管理员。" />; return <Empty title="需要管理员账号" description="当前登录账号不是管理员。" />;
} }
const invites = invitesQ.data?.data?.invites ?? []; const copyCode = (i: InviteRow) => {
const users = usersQ.data?.data?.users ?? []; void navigator.clipboard.writeText(i.code);
const totalInvites = invitesQ.data?.data?.total ?? 0; setCopiedId(i.id);
setTimeout(() => setCopiedId((v) => (v === i.id ? null : v)), 1500);
};
return ( return (
<div className="mx-auto max-w-[1080px] px-6 py-5"> <div className="mx-auto max-w-[1080px] px-6 py-5">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-[15px] font-semibold text-ink-1">管理</h2> <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>
<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> <Card>
<CardHeader title={'邀请码(' + totalInvites + ')'} extra={ <CardHeader title={'邀请码(' + (invitesQ.data?.total ?? 0) + ')'} extra={
<Button size="sm" variant="primary" disabled={busy} onClick={() => { setBusy(true); genMut.mutate(5); }}> <div className="flex gap-1.5">
<Plus size={13} /> 批量生成 5 个 <Button size="sm" variant="secondary" onClick={() => { setCreateOpen((v) => !v); setActionError(''); }}>
</Button> <Plus size={13} /> 新建
</Button>
<Button size="sm" variant="primary" disabled={genMut.isPending} onClick={() => genMut.mutate()}>
批量生成 5 个
</Button>
</div>
} /> } />
<CardBody className="p-0"> <CardBody className="p-0">
{invites.length ? ( {createOpen && (
<div className="divide-y divide-line max-h-[420px] overflow-y-auto"> <div className="flex flex-wrap items-end gap-2 border-b border-line bg-surface-2 px-4 py-3">
{invites.map((i: any) => ( <label className="flex flex-col gap-1">
<div key={i.id} className="flex items-center gap-2 px-4 py-2 text-[12.5px]"> <span className="text-[11px] font-medium text-ink-3">邀请码(4-32 位字母数字-_)</span>
<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> <Input className="w-[210px]" value={newCode} placeholder="如 XIONG-2025"
{i.valid ? <Badge color="var(--success)">可用</Badge> : <Badge color="var(--text-3)">已使用</Badge>} onChange={(e) => setNewCode(e.target.value)} />
{i.used_by_name && <span className="text-ink-3 truncate">被 {i.used_by_name} 使用于 {fmtDateTime(i.used_at)}</span>} </label>
{!i.used_by && ( <label className="flex flex-col gap-1">
<span className="ml-auto flex gap-1"> <span className="text-[11px] font-medium text-ink-3">备注(可选)</span>
<Button size="sm" variant="secondary" onClick={() => { void navigator.clipboard.writeText(i.code); }}> <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> </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> </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>
))} ))}
</div> </div>
) : ( ) : (
<Empty icon={<TicketCheck size={32} strokeWidth={1.5} />} title="暂无邀请码" description="生成后发给需要注册的用户。" /> <Empty icon={<TicketCheck size={32} strokeWidth={1.5} />} title="暂无符合条件的邀请码"
description="新建自定义邀请码,或批量生成后发给需要注册的用户。" />
)} )}
</CardBody> </CardBody>
</Card> </Card>
<Card> <Card>
<CardHeader title={'用户(' + (usersQ.data?.data?.total ?? 0) + ')'} /> <CardHeader title={'用户(' + (usersQ.data?.total ?? 0) + ')'} />
<CardBody className="p-0"> <CardBody className="p-0">
{users.length ? ( {users.length ? (
<div className="divide-y divide-line max-h-[420px] overflow-y-auto"> <div className="divide-y divide-line max-h-[520px] overflow-y-auto">
{users.map((u: any) => ( {users.map((u) => (
<div key={u.id} className="flex items-center gap-2 px-4 py-2 text-[12.5px]"> <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> <span className="font-medium text-ink-1">{u.username}</span>
{u.is_admin && <Badge color="var(--brand-text)">管理员</Badge>} {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> <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> </CardBody>
</Card> </Card>
</div> </div>
{busy && <div className="mt-3 text-[12px] text-ink-3">生成中…</div>}
</div> </div>
); );
} }
@@ -1,12 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-BIUFbnUa.js","assets/index-CheIHuTy.css"])))=>i.map(i=>d[i]);
import{c as Q,u as M,r as w,e as d,Y as r,f as N,j as e,E as c,B as t,P as B,A as l,i as F,T as K,Z as P,_ as R,$ as S,a0 as L,a1 as U,a2 as A}from"./index-BIUFbnUa.js";import{C,a as q,b as z}from"./card-z3MMuU5H.js";/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const D=[["path",{d:"M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z",key:"qn84l0"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],H=Q("TicketCheck",D);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const V=[["path",{d:"M18 21a8 8 0 0 0-16 0",key:"3ypg7q"}],["circle",{cx:"10",cy:"8",r:"5",key:"o932ke"}],["path",{d:"M22 20c0-3.37-2-6.5-4-8a5 5 0 0 0-.45-8.3",key:"10s06x"}]],W=Q("UsersRound",V);function a(){return localStorage.getItem("fp-server-url")||""}function O(){var p,y,j,k,f,g,_,b;const i=M(),[o,n]=w.useState(!1),m=d({queryKey:["me"],queryFn:()=>L(()=>import("./index-BIUFbnUa.js").then(s=>s.a3),__vite__mapDeps([0,1])).then(s=>s.serverMe(a())),enabled:!!r()}),x=d({queryKey:["admin-users"],queryFn:()=>U(a()),enabled:!!r()}),u=d({queryKey:["admin-invites"],queryFn:()=>A(a()),enabled:!!r()}),T=N({mutationFn:s=>R(s,a()),onSuccess:()=>{i.invalidateQueries({queryKey:["admin-invites"]}),n(!1)},onError:()=>n(!1)}),E=N({mutationFn:s=>S(s,a()),onSuccess:()=>void i.invalidateQueries({queryKey:["admin-invites"]})});if(m.data&&!m.data.is_admin)return e.jsx(c,{title:"需要管理员账号",description:"当前登录账号不是管理员。"});const v=((y=(p=u.data)==null?void 0:p.data)==null?void 0:y.invites)??[],h=((k=(j=x.data)==null?void 0:j.data)==null?void 0:k.users)??[],I=((g=(f=u.data)==null?void 0:f.data)==null?void 0:g.total)??0;return e.jsxs("div",{className:"mx-auto max-w-[1080px] px-6 py-5",children:[e.jsxs("div",{className:"mb-4 flex items-center justify-between",children:[e.jsx("h2",{className:"text-[15px] font-semibold text-ink-1",children:"管理"}),e.jsx(t,{variant:"secondary",onClick:()=>{i.invalidateQueries()},children:"刷新"})]}),e.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[e.jsxs(C,{children:[e.jsx(q,{title:"邀请码("+I+")",extra:e.jsxs(t,{size:"sm",variant:"primary",disabled:o,onClick:()=>{n(!0),T.mutate(5)},children:[e.jsx(B,{size:13})," 批量生成 5 个"]})}),e.jsx(z,{className:"p-0",children:v.length?e.jsx("div",{className:"divide-y divide-line max-h-[420px] overflow-y-auto",children:v.map(s=>e.jsxs("div",{className:"flex items-center gap-2 px-4 py-2 text-[12.5px]",children:[e.jsx("code",{className:"num rounded-sm px-1.5 py-0.5 "+(s.valid?"bg-success-subtle text-success":"bg-surface-3 text-ink-3"),children:s.code}),s.valid?e.jsx(l,{color:"var(--success)",children:"可用"}):e.jsx(l,{color:"var(--text-3)",children:"已使用"}),s.used_by_name&&e.jsxs("span",{className:"text-ink-3 truncate",children:["被 ",s.used_by_name," 使用于 ",F(s.used_at)]}),!s.used_by&&e.jsxs("span",{className:"ml-auto flex gap-1",children:[e.jsx(t,{size:"sm",variant:"secondary",onClick:()=>{navigator.clipboard.writeText(s.code)},children:"复制"}),!s.valid&&e.jsx(t,{size:"icon-sm",variant:"ghost","aria-label":"作废",className:"text-ink-3",onClick:()=>E.mutate(s.id),children:e.jsx(K,{size:13})})]})]},s.id))}):e.jsx(c,{icon:e.jsx(H,{size:32,strokeWidth:1.5}),title:"暂无邀请码",description:"生成后发给需要注册的用户。"})})]}),e.jsxs(C,{children:[e.jsx(q,{title:"用户("+(((b=(_=x.data)==null?void 0:_.data)==null?void 0:b.total)??0)+")"}),e.jsx(z,{className:"p-0",children:h.length?e.jsx("div",{className:"divide-y divide-line max-h-[420px] overflow-y-auto",children:h.map(s=>e.jsxs("div",{className:"flex items-center gap-2 px-4 py-2 text-[12.5px]",children:[e.jsx("span",{className:"font-medium text-ink-1",children:s.username}),s.is_admin&&e.jsx(l,{color:"var(--brand-text)",children:"管理员"}),e.jsxs("span",{className:"num ml-auto text-ink-3",children:[s.devices," 设备 · ",s.records," 记录 · ",P(s.attachment_bytes)]})]},s.id))}):e.jsx(c,{icon:e.jsx(W,{size:32,strokeWidth:1.5}),title:"暂无用户"})})]})]}),o&&e.jsx("div",{className:"mt-3 text-[12px] text-ink-3",children:"生成中…"})]})}export{O as AdminPage};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as g,e as v,j as t,S as j,s as S}from"./index-BIUFbnUa.js";import{C as c,a as d,b as x}from"./card-z3MMuU5H.js";import{u as N,i as u,a as m}from"./useChart-Dd0AMDI_.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-6 py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage}; import{r as g,e as v,j as t,S as j,s as S}from"./index-C58ZhVdG.js";import{C as c,a as d,b as x}from"./card-DTW0IIxx.js";import{u as N,i as u,a as m}from"./useChart-BSpPh9BI.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-6 py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
@@ -1,4 +1,4 @@
import{c as K,e as I,j as e,E as O,t as $,u as k,b as S,r as u,f as y,v as H,w as W,B as g,P as q,C as Q,x as J,y as T,z as U,A as N,k as G,T as E,I as j,D as Y,F as C,H as B,J as V}from"./index-BIUFbnUa.js";import{C as f,a as z,b as _}from"./card-z3MMuU5H.js";import{u as X,i as M,a as L}from"./useChart-Dd0AMDI_.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/** import{c as K,e as I,j as e,E as O,t as $,u as k,b as S,r as u,f as y,v as H,w as W,B as g,P as q,C as Q,x as J,y as T,z as U,A as N,k as G,T as E,I as j,D as Y,F as C,H as B,J as V}from"./index-C58ZhVdG.js";import{C as f,a as z,b as _}from"./card-DTW0IIxx.js";import{u as X,i as M,a as L}from"./useChart-BSpPh9BI.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as re,j as e,a as u,r as t,u as oe,b as ce,d as de,e as C,f as ue,I as xe,S as m,B as b,P as Y,g as pe,L as me,G as ve,R as he,h as je,i as ge,k as fe,T as ye,E as be,l as Ne,m as ke,C as Se,n as we,o as Ce,p as Ee,s as qe,q as _e}from"./index-BIUFbnUa.js";/** import{c as re,j as e,a as u,r as t,u as oe,b as ce,d as de,e as C,f as ue,I as xe,S as m,B as b,P as Y,g as pe,L as me,G as ve,R as he,h as je,i as ge,k as fe,T as ye,E as be,l as Ne,m as ke,C as Se,n as we,o as Ce,p as Ee,s as qe,q as _e}from"./index-C58ZhVdG.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as P,u as Q,b as z,e as B,r as i,f as T,j as e,B as p,P as F,K as q,A as E,k as D,T as I,C as K,M as L,D as G,F as r,I as j,N as H,S as R,O as W,o as J}from"./index-BIUFbnUa.js";import{C as U}from"./card-z3MMuU5H.js";/** import{c as P,u as Q,b as z,e as B,r as i,f as T,j as e,B as p,P as F,K as q,A as E,k as D,T as I,C as K,M as L,D as G,F as r,I as j,N as H,S as R,O as W,o as J}from"./index-C58ZhVdG.js";import{C as U}from"./card-DTW0IIxx.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-BIUFbnUa.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b}; import{j as s,a}from"./index-C58ZhVdG.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r}from"./index-BIUFbnUa.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u}; import{r}from"./index-C58ZhVdG.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="icon" href="/icons/pwa-192.png" /> <link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" /> <link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title> <title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-BIUFbnUa.js"></script> <script type="module" crossorigin src="/assets/index-C58ZhVdG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CheIHuTy.css"> <link rel="stylesheet" crossorigin href="/assets/index-zuv9BCiF.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+25 -17
View File
@@ -1,9 +1,9 @@
// PWA Service Worker v2 // PWA Service Worker v3
// - 页面导航:网络优先(构建更新后强制拿到新 index.html),离线才回落缓存 // - /api /auth /admin /sync 一律直连,绝不缓存(修复切换账号后 /auth/me 命中旧缓存的问题)
// - 静态资源:缓存优先 // - 仅指纹化静态资源(/assets/、/icons/)缓存优先
// - /api:直连不缓存 // - 页面导航网络优先,离线回落缓存的 index.html
const CACHE = 'fp-static-v2'; const CACHE = 'fp-static-v3';
const CORE = ['/', '/manifest.webmanifest', '/icons/pwa-192.png', '/icons/pwa-512.png']; const CORE = ['/manifest.webmanifest', '/icons/pwa-192.png', '/icons/pwa-512.png'];
self.addEventListener('install', (e) => { self.addEventListener('install', (e) => {
self.skipWaiting(); self.skipWaiting();
@@ -20,8 +20,25 @@ self.addEventListener('activate', (e) => {
self.addEventListener('fetch', (e) => { self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url); const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.pathname.startsWith('/api')) return; if (e.request.method !== 'GET') return;
// 后端接口直连(含 /api /auth /admin /sync),绝不缓存
if (/^\/(api|auth|admin|sync)(\/|$)/.test(url.pathname)) return;
// 指纹化静态资源:缓存优先(内容不可变)
if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/icons/')) {
e.respondWith(
caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => {
if (res.ok && url.origin === location.origin) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(e.request, copy));
}
return res;
}))
);
return;
}
// 页面导航:网络优先,离线回落
if (e.request.mode === 'navigate') { if (e.request.mode === 'navigate') {
e.respondWith( e.respondWith(
fetch(e.request).then((res) => { fetch(e.request).then((res) => {
@@ -32,14 +49,5 @@ self.addEventListener('fetch', (e) => {
); );
return; return;
} }
// 其余(manifest 等):网络直连
e.respondWith(
caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => {
if (res.ok && url.origin === location.origin) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(e.request, copy));
}
return res;
}))
);
}); });