feat: 云端优先架构 — 桌面/Web/PWA 全走服务器 API
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user