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

This commit is contained in:
root
2026-10-16 16:02:39 +08:00
parent 1c78fa77a0
commit 1156f97c66
19 changed files with 616 additions and 332 deletions
+2
View File
@@ -9,6 +9,7 @@ const DashboardPage = lazy(() => import('@/pages/dashboard/DashboardPage').then(
const GradesPage = lazy(() => import('@/pages/grades/GradesPage').then(m => ({ default: m.GradesPage })));
const TypesPage = lazy(() => import('@/pages/types/TypesPage').then(m => ({ default: m.TypesPage })));
const TaxonomyPage = lazy(() => import('@/pages/taxonomy/TaxonomyPage').then(m => ({ default: m.TaxonomyPage })));
const AdminPage = lazy(() => import('@/pages/admin/AdminPage').then(m => ({ default: m.AdminPage })));
function PageLoading() {
return <div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>;
@@ -25,6 +26,7 @@ export default function App() {
<Route path="/dashboard" element={<Suspense fallback={<PageLoading />}><DashboardPage /></Suspense>} />
<Route path="/types" element={<Suspense fallback={<PageLoading />}><TypesPage /></Suspense>} />
<Route path="/taxonomy" element={<Suspense fallback={<PageLoading />}><TaxonomyPage /></Suspense>} />
<Route path="/admin" element={<Suspense fallback={<PageLoading />}><AdminPage /></Suspense>} />
<Route path="*" element={<Navigate to="/timeline" replace />} />
</Route>
</Routes>
+69
View File
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { serverLogin, serverRegister } from '@/lib/api';
import { Button, Dialog, Field, Input } from '@/components/ui';
export function LoginDialog({ open, onClose, defaultUrl }: { open: boolean; onClose: () => void; defaultUrl: string }) {
const qc = useQueryClient();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [serverUrl, setServerUrl] = useState(defaultUrl);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [invite, setInvite] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!username.trim() || !password) { setError('请填写账号与密码'); return; }
let base = serverUrl.trim();
if (base && !base.startsWith('http')) base = 'https://' + base;
setBusy(true); setError('');
try {
if (mode === 'login') {
await serverLogin(username.trim(), password, base);
} else {
if (!invite.trim()) throw new Error('请填写邀请码');
await serverRegister(username.trim(), password, invite.trim(), base);
}
void qc.invalidateQueries();
onClose();
window.location.reload(); // 触发侧边栏与同步引擎重启
} catch (e) {
setError((e as Error).message);
} finally { setBusy(false); }
};
return (
<Dialog open={open} onClose={onClose} width={440} title={mode === 'login' ? '登录账号' : '注册账号'}
footer={
<>
<Button variant="secondary" onClick={onClose}>取消</Button>
<Button variant="primary" onClick={() => void submit()} disabled={busy}>{busy ? '请稍候…' : '确定'}</Button>
</>
}
>
<div className="flex flex-col gap-3">
<Field label="服务器地址" hint="默认与网页版同一账号体系">
<Input value={serverUrl} onChange={e => setServerUrl(e.target.value)} placeholder="https://footpr.linyux.cn" />
</Field>
<Field label="用户名" required>
<Input value={username} onChange={e => setUsername(e.target.value)} placeholder="3-32 位字母数字-_" />
</Field>
<Field label="密码" required>
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="至少 8 位" />
</Field>
{mode === 'register' && (
<Field label="邀请码" required hint="向管理员索取">
<Input value={invite} onChange={e => setInvite(e.target.value)} placeholder="邀请码" />
</Field>
)}
{error && <div className="text-[12px] text-danger">{error}</div>}
<div className="flex text-[12px] text-ink-3 gap-2">
<button className="underline hover:text-ink-1" onClick={() => { setMode(mode === 'login' ? 'register' : 'login'); setError(''); }}>
{mode === 'login' ? '没有账号?注册' : '已有账号?登录'}
</button>
</div>
</div>
</Dialog>
);
}
+3 -2
View File
@@ -4,10 +4,11 @@ import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { ToastProvider } from '@/components/ui';
import { LoginPage } from '@/components/auth/LoginPage';
import { isLocalMode } from '@/lib/api';
import { getToken } from '@/lib/api';
import { isTauriRef } from '@/lib/api';
export function AppShell() {
const [authed, setAuthed] = useState(() => isLocalMode());
const [authed, setAuthed] = useState(() => isTauriRef() || !!getToken());
const [navOpen, setNavOpen] = useState(false);
useEffect(() => {
+77 -15
View File
@@ -1,7 +1,9 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, ChevronsLeft } from 'lucide-react';
import { appInfo, syncStatus } from '@/lib/api';
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, ShieldCheck } from 'lucide-react';
import { syncStatus, serverMe, clearToken, isTauriRef, getToken } from '@/lib/api';
import { LoginDialog } from '@/components/auth/LoginDialog';
import { cn } from '@/lib/utils';
const NAV = [
@@ -17,6 +19,7 @@ const NAV = [
{
section: '配置',
items: [
{ to: '/admin', label: '管理', icon: ShieldCheck },
{ to: '/types', label: '记录类型', icon: Shapes },
{ to: '/taxonomy', label: '字典与维度', icon: SlidersHorizontal }
]
@@ -33,9 +36,21 @@ function LogoMark() {
);
}
function baseUrl() {
return isTauriRef() ? (localStorage.getItem('fp-server-url') || '') : '';
}
export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
const info = useQuery({ queryKey: ['app-info'], queryFn: appInfo, staleTime: Infinity });
const isWeb = !isTauriRef();
const sync = useQuery({ queryKey: ['sync-status'], queryFn: syncStatus, refetchInterval: 15000 });
const meQ = useQuery({
queryKey: ['me'],
queryFn: () => serverMe(baseUrl()),
enabled: !!getToken(),
retry: 0
});
const me = meQ.data ?? null;
const [loginOpen, setLoginOpen] = useState(false);
return (
<aside
@@ -52,7 +67,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
<div className="text-[10px] font-medium tracking-[0.18em] text-ink-3">FOOTPRINTS</div>
</div>
</div>
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} defaultUrl={localStorage.getItem('fp-server-url') || 'https://footpr.linyux.cn'} />
<nav className="flex-1 overflow-y-auto px-2 py-3">
{NAV.map(group => (
<div key={group.section} className="mb-4">
@@ -82,18 +97,65 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
</nav>
<div className="border-t border-line px-4 py-3">
<div className="truncate text-[11px] text-ink-3" title={info.data?.db_path ?? ''}>
{info.data ? '数据库:' + info.data.db_path.split(/[\\/]/).slice(-1)[0] : '加载中…'}
</div>
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-ink-3">
<ChevronsLeft size={11} />
{sync.data
? (sync.data.pending_ops > 0
? '本地模式 · 待同步 ' + sync.data.pending_ops
: '本地模式 · 数据已就绪')
: '…'}
</div>
{/* 左下角:账号区 */}
{getToken() ? (
<div className="mb-2">
{me && (
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[12px] font-medium text-ink-1" title={me.username}>
{me.is_admin && <span className="mr-1 text-brand-text">管理员</span>}
{me.username}
</div>
<button
className="shrink-0 text-[11px] text-ink-3 underline transition-colors hover:text-danger"
onClick={() => {
clearToken();
localStorage.removeItem('fp-server-url');
window.location.reload();
}}
>
退出
</button>
</div>
)}
{!isWeb && (
<button
className="flex w-full items-center gap-1.5 rounded-md px-1 py-1 text-[12px] text-ink-2 transition-colors hover:bg-surface-3 hover:text-ink-1"
onClick={() => setLoginOpen(true)}
>
<LogIn size={13} />
{me ? "账号与同步设置" : "已注册 · 登录同步"}
</button>
)}
{!isWeb && (
<div className="num text-[10px] text-ink-3">
{sync.data ? ("同步 · 待推送 " + sync.data.pending_ops) : "…"}
</div>
)}
</div>
) : (
<div className="flex flex-col gap-1.5">
<button
className="flex w-full items-center gap-1.5 rounded-md bg-brand-subtle px-2 py-1.5 text-[12.5px] font-medium text-brand-text transition-colors hover:brightness-[0.97]"
onClick={() => setLoginOpen(true)}
>
<LogIn size={13} />
登录 / 注册账号
</button>
<div className="text-[10px] text-ink-3">未登录 = 本地模式,数据仅存本机</div>
</div>
)}
{!isWeb && (
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-ink-3">
<ChevronsLeftSvg />
数据库 · 本地存储
</div>
)}
</div>
</aside>
);
}
function ChevronsLeftSvg() {
return <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 17l-5-5 5-5" /><path d="M18 17l-5-5 5-5" /></svg>
}
+34 -9
View File
@@ -5,6 +5,7 @@ import type {
} from './types';
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
export function isTauriRef() { return isTauri; }
const TOKEN_KEY = 'fp-token';
export function getToken() { return localStorage.getItem(TOKEN_KEY); }
@@ -14,10 +15,10 @@ export const isLocalMode = () => isTauri || !getToken();
export interface ServerSession { token: string; user_id: string; device_id: string; device_name: string; }
export async function serverLogin(username: string, password: string): Promise<ServerSession> {
const res = await fetch('/auth/login', {
export async function serverLogin(username: string, password: string, baseUrl = ''): Promise<ServerSession> {
const res = await fetch(baseUrl + '/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, device_name: 'Web 浏览器' })
body: JSON.stringify({ username, password, device_name: isTauri ? '桌面应用' : 'Web 浏览器' })
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || '登录失败');
@@ -25,10 +26,10 @@ export async function serverLogin(username: string, password: string): Promise<S
return json.data;
}
export async function serverRegister(username: string, password: string, invite_code: string): Promise<ServerSession> {
const res = await fetch('/auth/register', {
export async function serverRegister(username: string, password: string, invite_code: string, baseUrl = ''): Promise<ServerSession> {
const res = await fetch(baseUrl + '/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, invite_code, device_name: 'Web 浏览器' })
body: JSON.stringify({ username, password, invite_code, device_name: isTauri ? '桌面应用' : 'Web 浏览器' })
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || '注册失败');
@@ -36,9 +37,9 @@ export async function serverRegister(username: string, password: string, invite_
return json.data;
}
export interface MeInfo { user_id: string; username: string; device_id: string; }
export async function serverMe(): Promise<MeInfo> {
const res = await fetch('/auth/me', { headers: { Authorization: 'Bearer ' + getToken() } });
export interface MeInfo { user_id: string; username: string; is_admin?: boolean; device_id: string; }
export async function serverMe(baseUrl = ''): Promise<MeInfo> {
const res = await fetch(baseUrl + '/auth/me', { headers: { Authorization: 'Bearer ' + getToken() } });
const json = await res.json();
if (!json.ok) throw new Error(json.error || '会话失效');
return json.data;
@@ -125,3 +126,27 @@ export const viewList = () => call<{ items: SavedView[] }>('view_list');
export const viewUpsert = (p: { id?: number; name: string; filter: Record<string, unknown>; sort?: Record<string, unknown>; is_default?: boolean }) =>
call<{ item: SavedView }>('view_upsert', p);
export const viewDelete = (id: number) => call<{ ok: true }>('view_delete', { id });
// ---- 管理端(is_admin) ----
export async function adminListInvites(baseUrl = '') {
const res = await fetch(baseUrl + '/admin/invites', { headers: { Authorization: 'Bearer ' + getToken() } });
return res.json();
}
export async function adminCreateInvites(count: number, baseUrl = '') {
const res = await fetch(baseUrl + '/admin/invites/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ count })
});
return res.json();
}
export async function adminRevokeInvite(id: number, baseUrl = '') {
const res = await fetch(baseUrl + '/admin/invites/revoke', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ id })
});
return res.json();
}
export async function adminListUsers(baseUrl = '') {
const res = await fetch(baseUrl + '/admin/users', { headers: { Authorization: 'Bearer ' + getToken() } });
return res.json();
}
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { Plus, Trash2, Users2, TicketCheck } from 'lucide-react';
import { adminCreateInvites, adminListInvites, adminListUsers, adminRevokeInvite, getToken, isTauriRef } from '@/lib/api';
import { Badge, Button, Card, CardBody, CardHeader, Empty } from '@/components/ui';
import { fmtDateTime, fmtSize } from '@/lib/format';
function baseUrl() {
if (!isTauriRef()) return '';
return localStorage.getItem('fp-server-url') || '';
}
export function AdminPage() {
const qc = useQueryClient();
const [busy, setBusy] = useState(false);
const meQ = useQuery({ queryKey: ['me'], queryFn: () => serverMeSafe(), enabled: !!getToken() });
const usersQ = useQuery({ queryKey: ['admin-users'], queryFn: () => adminListUsers(baseUrl()), enabled: !!getToken() });
const invitesQ = useQuery({ queryKey: ['admin-invites'], queryFn: () => adminListInvites(baseUrl()), enabled: !!getToken() });
const genMut = useMutation({
mutationFn: (count: number) => adminCreateInvites(count, baseUrl()),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['admin-invites'] }); setBusy(false); },
onError: () => setBusy(false)
});
const revokeMut = useMutation({
mutationFn: (id: number) => adminRevokeInvite(id, baseUrl()),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin-invites'] })
});
if (meQ.data && !meQ.data.is_admin) {
return <Empty title="需要管理员账号" description="当前登录账号不是管理员。" />;
}
const invites = invitesQ.data?.data?.invites ?? [];
const users = usersQ.data?.data?.users ?? [];
const totalInvites = invitesQ.data?.data?.total ?? 0;
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-[15px] font-semibold text-ink-1">管理</h2>
<Button variant="secondary" onClick={() => { void qc.invalidateQueries(); }}>刷新</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader title={'邀请码(' + totalInvites + ')'} extra={
<Button size="sm" variant="primary" disabled={busy} onClick={() => { setBusy(true); genMut.mutate(5); }}>
<Plus size={13} /> 批量生成 5 个
</Button>
} />
<CardBody className="p-0">
{invites.length ? (
<div className="divide-y divide-line max-h-[420px] overflow-y-auto">
{invites.map((i: any) => (
<div key={i.id} className="flex items-center gap-2 px-4 py-2 text-[12.5px]">
<code className={'num rounded-sm px-1.5 py-0.5 ' + (i.valid ? 'bg-success-subtle text-success' : 'bg-surface-3 text-ink-3')}>{i.code}</code>
{i.valid ? <Badge color="var(--success)">可用</Badge> : <Badge color="var(--text-3)">已使用</Badge>}
{i.used_by_name && <span className="text-ink-3 truncate">被 {i.used_by_name} 使用于 {fmtDateTime(i.used_at)}</span>}
{!i.used_by && (
<span className="ml-auto flex gap-1">
<Button size="sm" variant="secondary" onClick={() => { void navigator.clipboard.writeText(i.code); }}>
复制
</Button>
{!i.valid && <Button size="icon-sm" variant="ghost" aria-label="作废" className="text-ink-3" onClick={() => revokeMut.mutate(i.id)}>
<Trash2 size={13} />
</Button>}
</span>
)}
</div>
))}
</div>
) : (
<Empty icon={<TicketCheck size={32} strokeWidth={1.5} />} title="暂无邀请码" description="生成后发给需要注册的用户。" />
)}
</CardBody>
</Card>
<Card>
<CardHeader title={'用户(' + (usersQ.data?.data?.total ?? 0) + ')'} />
<CardBody className="p-0">
{users.length ? (
<div className="divide-y divide-line max-h-[420px] overflow-y-auto">
{users.map((u: any) => (
<div key={u.id} className="flex items-center gap-2 px-4 py-2 text-[12.5px]">
<span className="font-medium text-ink-1">{u.username}</span>
{u.is_admin && <Badge color="var(--brand-text)">管理员</Badge>}
<span className="num ml-auto text-ink-3">{u.devices} 设备 · {u.records} 记录 · {fmtSize(u.attachment_bytes)}</span>
</div>
))}
</div>
) : (
<Empty icon={<Users2 size={32} strokeWidth={1.5} />} title="暂无用户" />
)}
</CardBody>
</Card>
</div>
{busy && <div className="mt-3 text-[12px] text-ink-3">生成中…</div>}
</div>
);
}
async function serverMeSafe() {
const { serverMe } = await import('@/lib/api');
try { return await serverMe(baseUrl()); } catch { return null; }
}
@@ -0,0 +1,12 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-CAYqMwJw.js","assets/index-BthM1Vey.css"])))=>i.map(i=>d[i]);
import{c as M,u as E,r as I,e as d,Y as c,f as N,j as e,E as l,B as r,P as R,A as o,i as S,T as B,Z as F,_ as K,$ as P,a0 as L,a1 as U,a2 as A,a3 as D}from"./index-CAYqMwJw.js";import{C,a as q,b as z}from"./card-DipFCG5F.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 H=[["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"}]],V=M("TicketCheck",H);/**
* @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 W=[["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"}]],Z=M("UsersRound",W);function i(){return L()&&localStorage.getItem("fp-server-url")||""}function G(){var h,p,j,f,k,g,_,b;const a=E(),[n,t]=I.useState(!1),m=d({queryKey:["me"],queryFn:()=>$(),enabled:!!c()}),x=d({queryKey:["admin-users"],queryFn:()=>U(i()),enabled:!!c()}),u=d({queryKey:["admin-invites"],queryFn:()=>A(i()),enabled:!!c()}),T=N({mutationFn:s=>K(s,i()),onSuccess:()=>{a.invalidateQueries({queryKey:["admin-invites"]}),t(!1)},onError:()=>t(!1)}),w=N({mutationFn:s=>P(s,i()),onSuccess:()=>void a.invalidateQueries({queryKey:["admin-invites"]})});if(m.data&&!m.data.is_admin)return e.jsx(l,{title:"需要管理员账号",description:"当前登录账号不是管理员。"});const v=((p=(h=u.data)==null?void 0:h.data)==null?void 0:p.invites)??[],y=((f=(j=x.data)==null?void 0:j.data)==null?void 0:f.users)??[],Q=((g=(k=u.data)==null?void 0:k.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(r,{variant:"secondary",onClick:()=>{a.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:"邀请码("+Q+")",extra:e.jsxs(r,{size:"sm",variant:"primary",disabled:n,onClick:()=>{t(!0),T.mutate(5)},children:[e.jsx(R,{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(o,{color:"var(--success)",children:"可用"}):e.jsx(o,{color:"var(--text-3)",children:"已使用"}),s.used_by_name&&e.jsxs("span",{className:"text-ink-3 truncate",children:["被 ",s.used_by_name," 使用于 ",S(s.used_at)]}),!s.used_by&&e.jsxs("span",{className:"ml-auto flex gap-1",children:[e.jsx(r,{size:"sm",variant:"secondary",onClick:()=>{navigator.clipboard.writeText(s.code)},children:"复制"}),!s.valid&&e.jsx(r,{size:"icon-sm",variant:"ghost","aria-label":"作废",className:"text-ink-3",onClick:()=>w.mutate(s.id),children:e.jsx(B,{size:13})})]})]},s.id))}):e.jsx(l,{icon:e.jsx(V,{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:y.length?e.jsx("div",{className:"divide-y divide-line max-h-[420px] overflow-y-auto",children:y.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(o,{color:"var(--brand-text)",children:"管理员"}),e.jsxs("span",{className:"num ml-auto text-ink-3",children:[s.devices," 设备 · ",s.records," 记录 · ",F(s.attachment_bytes)]})]},s.id))}):e.jsx(l,{icon:e.jsx(Z,{size:32,strokeWidth:1.5}),title:"暂无用户"})})]})]}),n&&e.jsx("div",{className:"mt-3 text-[12px] text-ink-3",children:"生成中…"})]})}async function $(){const{serverMe:a}=await D(async()=>{const{serverMe:n}=await import("./index-CAYqMwJw.js").then(t=>t.a4);return{serverMe:n}},__vite__mapDeps([0,1]));try{return await a(i())}catch{return null}}export{G as AdminPage};
@@ -1 +1 @@
import{r as g,e as v,j as t,S as j,s as S}from"./index-BunBoxtn.js";import{C as c,a as d,b as x}from"./card-CgBa5fs5.js";import{u as N,i as u,a as m}from"./useChart-CwGNJ-9_.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-CAYqMwJw.js";import{C as c,a as d,b as x}from"./card-DipFCG5F.js";import{u as N,i as u,a as m}from"./useChart-sd-ke-zx.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-BunBoxtn.js";import{C as f,a as z,b as _}from"./card-CgBa5fs5.js";import{u as X,i as M,a as L}from"./useChart-CwGNJ-9_.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-CAYqMwJw.js";import{C as f,a as z,b as _}from"./card-DipFCG5F.js";import{u as X,i as M,a as L}from"./useChart-sd-ke-zx.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-BunBoxtn.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-CAYqMwJw.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-BunBoxtn.js";import{C as U}from"./card-CgBa5fs5.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-CAYqMwJw.js";import{C as U}from"./card-DipFCG5F.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-BunBoxtn.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-CAYqMwJw.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-BunBoxtn.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-CAYqMwJw.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-BunBoxtn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BMVMeOX_.css">
<script type="module" crossorigin src="/assets/index-CAYqMwJw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BthM1Vey.css">
</head>
<body>
<div id="root"></div>