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; }
}