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>
);
}
@@ -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
*
* 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
*
* 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
*
* 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="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-BIUFbnUa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CheIHuTy.css">
<script type="module" crossorigin src="/assets/index-C58ZhVdG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-zuv9BCiF.css">
</head>
<body>
<div id="root"></div>
+25 -17
View File
@@ -1,9 +1,9 @@
// PWA Service Worker v2
// - 页面导航:网络优先(构建更新后强制拿到新 index.html),离线才回落缓存
// - 静态资源:缓存优先
// - /api:直连不缓存
const CACHE = 'fp-static-v2';
const CORE = ['/', '/manifest.webmanifest', '/icons/pwa-192.png', '/icons/pwa-512.png'];
// PWA Service Worker v3
// - /api /auth /admin /sync 一律直连,绝不缓存(修复切换账号后 /auth/me 命中旧缓存的问题)
// - 仅指纹化静态资源(/assets/、/icons/)缓存优先
// - 页面导航网络优先,离线回落缓存的 index.html
const CACHE = 'fp-static-v3';
const CORE = ['/manifest.webmanifest', '/icons/pwa-192.png', '/icons/pwa-512.png'];
self.addEventListener('install', (e) => {
self.skipWaiting();
@@ -20,8 +20,25 @@ self.addEventListener('activate', (e) => {
self.addEventListener('fetch', (e) => {
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') {
e.respondWith(
fetch(e.request).then((res) => {
@@ -32,14 +49,5 @@ self.addEventListener('fetch', (e) => {
);
return;
}
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;
}))
);
// 其余(manifest 等):网络直连
});