feat: 云端优先架构 — 桌面/Web/PWA 全走服务器 API

This commit is contained in:
root
2026-10-16 17:34:26 +08:00
parent 629c7e4073
commit aea94de9e5
22 changed files with 472 additions and 445 deletions
+3
View File
@@ -17,6 +17,9 @@ build-log.txt
.factory-test/
.seed-dbg/
.seed-*/
.regression/
.regression-s/
.dbg-x/
.grade-test/
.regression-test/
sync-server/.data-*/
+61 -31
View File
@@ -1,12 +1,24 @@
// Deprecated: 统一使用 LoginPage(桌面/Web 均走全屏登录页)
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { serverLogin, serverRegister } from '@/lib/api';
import { Button, Dialog, Field, Input } from '@/components/ui';
import { Button, Field, Input, useToast } from '@/components/ui';
function LogoMark({ size = 44 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 26 26" fill="none" aria-hidden>
<rect x="1" y="1" width="24" height="24" rx="6" fill="var(--brand)" />
<ellipse cx="11" cy="10" rx="4.2" ry="5.2" fill="#fff" opacity="0.95" transform="rotate(-18 11 10)" />
<circle cx="17.5" cy="17.5" r="3.1" fill="#fff" opacity="0.7" />
</svg>
);
}
export function LoginDialog({ open, onClose, defaultUrl }: { open: boolean; onClose: () => void; defaultUrl: string }) {
const qc = useQueryClient();
const toast = useToast();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [serverUrl, setServerUrl] = useState(defaultUrl);
const [serverUrl, setServerUrl] = useState(defaultUrl || 'https://footpr.linyux.cn');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [invite, setInvite] = useState('');
@@ -26,44 +38,62 @@ export function LoginDialog({ open, onClose, defaultUrl }: { open: boolean; onCl
await serverRegister(username.trim(), password, invite.trim(), base);
}
void qc.invalidateQueries();
toast('success', mode === 'login' ? '已登录 · 同步已开启' : '已注册登录 · 同步已开启');
onClose();
window.location.reload(); // 触发侧边栏与同步引擎重启
setTimeout(() => window.location.reload(), 300);
} catch (e) {
setError((e as Error).message);
} finally { setBusy(false); }
};
if (!open) return null;
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' ? '没有账号?注册' : '已有账号?登录'}
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-bg/95">
<div className="w-full max-w-[380px] rounded-lg border border-line bg-surface p-6 shadow-pop">
<div className="flex flex-col items-center gap-1.5 pb-5">
<LogoMark />
<div className="text-[17px] font-semibold tracking-tight text-ink-1">足迹</div>
<div className="text-[10px] font-medium tracking-[0.2em] text-ink-3">FOOTPRINTS</div>
</div>
<div className="mb-4 flex rounded-md border border-line bg-surface-2 p-0.5 text-[12.5px]">
<button
className={'flex-1 rounded-sm px-2 py-1 transition-colors ' + (mode === 'login' ? 'bg-surface font-medium text-ink-1 shadow-card' : 'text-ink-3')}
onClick={() => { setMode('login'); setError(''); }}
>
登录
</button>
<button
className={'flex-1 rounded-sm px-2 py-1 transition-colors ' + (mode === 'register' ? 'bg-surface font-medium text-ink-1 shadow-card' : 'text-ink-3')}
onClick={() => { setMode('register'); setError(''); }}
>
注册
</button>
</div>
<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>}
<Button variant="primary" size="lg" onClick={() => void submit()} disabled={busy}>
{busy ? "请稍候…" : (mode === "login" ? "登录并同步" : "注册并同步")}
</Button>
<p className="text-center text-[11px] text-ink-3">登录后本机数据将多端同步</p>
</div>
</div>
</Dialog>
</div>
);
}
+9 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { serverLogin, serverRegister } from '@/lib/api';
import { serverLogin, serverRegister, getServerUrl, setServerUrl } from '@/lib/api';
import { Button, Field, Input } from '@/components/ui';
function LogoMark() {
@@ -19,6 +19,9 @@ export function LoginPage({ onAuthed }: { onAuthed: () => void }) {
const [invite, setInvite] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const isDesktop = '__TAURI_INTERNALS__' in window;
const [serverUrl, setServerUrlState] = useState(getServerUrl());
const setUrl = (v: string) => { setServerUrlState(v); setServerUrl(v); }
const submit = async () => {
if (!username.trim() || !password) { setError('请填写账号与密码'); return; }
@@ -64,6 +67,11 @@ export function LoginPage({ onAuthed }: { onAuthed: () => void }) {
</div>
<div className="flex flex-col gap-3">
{isDesktop && (
<Field label="服务器地址" hint="默认即可">
<Input value={serverUrl} onChange={e => setUrl(e.target.value)} placeholder="https://footpr.linyux.cn" />
</Field>
)}
<Field label="用户名" required>
<Input value={username} onChange={e => setUsername(e.target.value)} placeholder="3-32 位字母数字-_" onKeyDown={e => { if (e.key === 'Enter') void submit(); }} />
</Field>
+1 -2
View File
@@ -5,10 +5,9 @@ import { Header } from './Header';
import { ToastProvider } from '@/components/ui';
import { LoginPage } from '@/components/auth/LoginPage';
import { getToken } from '@/lib/api';
import { isTauriRef } from '@/lib/api';
export function AppShell() {
const [authed, setAuthed] = useState(() => isTauriRef() || !!getToken());
const [authed, setAuthed] = useState(() => !!getToken());
const [navOpen, setNavOpen] = useState(false);
useEffect(() => {
+25 -53
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, ShieldCheck } from 'lucide-react';
import { syncStatus, serverMe, clearToken, isTauriRef, getToken } from '@/lib/api';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, ShieldCheck, Cloud } from 'lucide-react';
import { serverMe, clearToken, getToken } from '@/lib/api';
import { LoginDialog } from '@/components/auth/LoginDialog';
import { cn } from '@/lib/utils';
@@ -36,21 +36,16 @@ function LogoMark() {
);
}
function baseUrl() {
return isTauriRef() ? (localStorage.getItem('fp-server-url') || '') : '';
}
export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
const isWeb = !isTauriRef();
const sync = useQuery({ queryKey: ['sync-status'], queryFn: syncStatus, refetchInterval: 15000 });
const qc = useQueryClient();
const meQ = useQuery({
queryKey: ['me'],
queryFn: () => serverMe(baseUrl()),
queryFn: () => serverMe(),
enabled: !!getToken(),
retry: 0
});
const me = meQ.data ?? null;
const [loginOpen, setLoginOpen] = useState(false);
const me = meQ.data ?? null;
return (
<aside
@@ -67,7 +62,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'} />
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} defaultUrl={'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">
@@ -97,41 +92,28 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
</nav>
<div className="border-t border-line px-4 py-3">
{/* 左下角:账号区 */}
{/* 左下角账号区 */}{getToken() ? (
<div className="mb-2">
{me && (
<div className="flex items-center justify-between gap-2">
{/* 左下角:账号区(云端模式) */}
{getToken() && me ? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<Cloud size={13} className="shrink-0 text-success" />
<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.is_admin && <span className="mr-0.5 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)}
className="shrink-0 text-[11px] text-ink-3 underline transition-colors hover:text-danger"
onClick={() => { clearToken(); qc.clear(); window.location.reload(); }}
>
<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 items-center gap-1 text-[10px] text-success">
<Cloud size={10} />
云端同步已开启
</div>
</div>
) : (
<div className="flex flex-col gap-1.5">
@@ -140,22 +122,12 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
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 className="text-[10px] text-ink-3">登录后数据云端互通</div>
</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>
}
}
+43 -27
View File
@@ -4,21 +4,21 @@ import type {
StatsOverview, SyncStatus, TaxonomyItem, TimelineGroup
} 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); }
export function setToken(t: string) { localStorage.setItem(TOKEN_KEY, t); }
export function clearToken() { localStorage.removeItem(TOKEN_KEY); }
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, baseUrl = ''): Promise<ServerSession> {
const res = await fetch(baseUrl + '/auth/login', {
export async function serverLogin(username: string, password: string, baseUrl?: string): Promise<ServerSession> {
const base = baseUrl || getServerUrl();
const res = await fetch(base + '/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, device_name: isTauri ? '桌面应用' : 'Web 浏览器' })
body: JSON.stringify({ username, password, device_name: '桌面应用' })
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || '登录失败');
@@ -26,10 +26,11 @@ export async function serverLogin(username: string, password: string, baseUrl =
return json.data;
}
export async function serverRegister(username: string, password: string, invite_code: string, baseUrl = ''): Promise<ServerSession> {
const res = await fetch(baseUrl + '/auth/register', {
export async function serverRegister(username: string, password: string, invite_code: string, baseUrl?: string): Promise<ServerSession> {
const base = baseUrl || getServerUrl();
const res = await fetch(base + '/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, invite_code, device_name: isTauri ? '桌面应用' : 'Web 浏览器' })
body: JSON.stringify({ username, password, invite_code, device_name: '桌面应用' })
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || '注册失败');
@@ -38,32 +39,47 @@ export async function serverRegister(username: string, password: string, invite_
}
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() } });
export async function serverMe(baseUrl?: string): Promise<MeInfo> {
const base = baseUrl || getServerUrl();
const res = await fetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + getToken() } });
const json = await res.json();
if (!json.ok) throw new Error(json.error || '会话失效');
return json.data;
}
// ---------- 云端传输层:所有数据存服务器 ----------
const SERVER_KEY = 'fp-server-url';
export function getServerUrl() { return localStorage.getItem(SERVER_KEY) || 'https://footpr.linyux.cn'; }
export function setServerUrl(url: string) { localStorage.setItem(SERVER_KEY, url.replace(/\/+$/, '')); }
export class AuthError extends Error {
constructor(msg: string) { super(msg); this.name = 'AuthError'; }
}
async function call<T>(cmd: string, payload?: unknown): Promise<T> {
if (isTauri) {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<T>(cmd, { payload: payload ?? {} });
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getToken();
if (token) headers.Authorization = 'Bearer ' + token;
const res = await fetch('/api/' + cmd, { method: 'POST', headers, body: JSON.stringify(payload ?? {}) });
if (!token) throw new AuthError('请先登录');
const base = getServerUrl();
const res = await fetch(base + '/api/' + cmd, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token
},
body: JSON.stringify(payload ?? {})
});
if (res.status === 401) {
clearToken();
window.dispatchEvent(new CustomEvent('fp-auth-required'));
throw new Error('登录已过期,请重新登录');
throw new AuthError('登录已过期,请重新登录');
}
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.error || ('HTTP ' + res.status));
return json.data as T;
}
// ---- app ----
export const appInfo = () => call<AppInfo>('app_info');
export const syncStatus = () => call<SyncStatus>('sync_status');
@@ -128,25 +144,25 @@ 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 = '') {
const res = await fetch(baseUrl + '/admin/invites', { headers: { Authorization: 'Bearer ' + getToken() } });
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 = '') {
const res = await fetch(baseUrl + '/admin/invites/generate', {
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 })
});
return res.json();
}
export async function adminRevokeInvite(id: number, baseUrl = '') {
const res = await fetch(baseUrl + '/admin/invites/revoke', {
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();
}
export async function adminListUsers(baseUrl = '') {
const res = await fetch(baseUrl + '/admin/users', { headers: { Authorization: 'Bearer ' + getToken() } });
export async function adminListUsers(baseUrl?: string) {
const res = await fetch((baseUrl || getServerUrl()) + '/admin/users', { headers: { Authorization: 'Bearer ' + getToken() } });
return res.json();
}
+2 -7
View File
@@ -1,19 +1,18 @@
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 { adminCreateInvites, adminListInvites, adminListUsers, adminRevokeInvite, getToken } 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 meQ = useQuery({ queryKey: ['me'], queryFn: () => import('@/lib/api').then(m => m.serverMe(baseUrl())), enabled: !!getToken() });
const usersQ = useQuery({ queryKey: ['admin-users'], queryFn: () => adminListUsers(baseUrl()), enabled: !!getToken() });
const invitesQ = useQuery({ queryKey: ['admin-invites'], queryFn: () => adminListInvites(baseUrl()), enabled: !!getToken() });
@@ -102,7 +101,3 @@ export function AdminPage() {
);
}
async function serverMeSafe() {
const { serverMe } = await import('@/lib/api');
try { return await serverMe(baseUrl()); } catch { return null; }
}
@@ -1,12 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-DdiJYOyq.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-DdiJYOyq.js";import{C,a as q,b as z}from"./card-DPuXHBpL.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-DdiJYOyq.js").then(t=>t.a4);return{serverMe:n}},__vite__mapDeps([0,1]));try{return await a(i())}catch{return null}}export{G as AdminPage};
@@ -0,0 +1,12 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-BPypTgG9.js","assets/index-D8_RHt64.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-BPypTgG9.js";import{C,a as q,b as z}from"./card-DtBTl1yb.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-BPypTgG9.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};
@@ -1 +1 @@
import{r as g,e as v,j as t,S as j,s as S}from"./index-DdiJYOyq.js";import{C as c,a as d,b as x}from"./card-DPuXHBpL.js";import{u as N,i as u,a as m}from"./useChart-Bui3ZDic.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-BPypTgG9.js";import{C as c,a as d,b as x}from"./card-DtBTl1yb.js";import{u as N,i as u,a as m}from"./useChart-ODBxrUQE.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-DdiJYOyq.js";import{C as f,a as z,b as _}from"./card-DPuXHBpL.js";import{u as X,i as M,a as L}from"./useChart-Bui3ZDic.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-BPypTgG9.js";import{C as f,a as z,b as _}from"./card-DtBTl1yb.js";import{u as X,i as M,a as L}from"./useChart-ODBxrUQE.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-DdiJYOyq.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-BPypTgG9.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-DdiJYOyq.js";import{C as U}from"./card-DPuXHBpL.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-BPypTgG9.js";import{C as U}from"./card-DtBTl1yb.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-DdiJYOyq.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-BPypTgG9.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};
-1
View File
@@ -1 +0,0 @@
async function o(r,e={},n){return window.__TAURI_INTERNALS__.invoke(r,e,n)}export{o as invoke};
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-DdiJYOyq.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-BPypTgG9.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-DdiJYOyq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BthM1Vey.css">
<script type="module" crossorigin src="/assets/index-BPypTgG9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D8_RHt64.css">
</head>
<body>
<div id="root"></div>