feat: Web 版上线 footpr.linyux.cn —— 登录页 + 按用户隔离的命令 API + 静态托管

This commit is contained in:
root
2026-10-16 15:00:58 +08:00
parent 99358b834a
commit 547157aaa1
31 changed files with 967 additions and 60 deletions
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { serverLogin, serverRegister } from '@/lib/api';
import { Button, Field, Input } from '@/components/ui';
function LogoMark() {
return (
<svg width="44" height="44" 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 LoginPage({ onAuthed }: { onAuthed: () => void }) {
const [mode, setMode] = useState<'login' | 'register'>('login');
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; }
setBusy(true);
setError('');
try {
if (mode === 'login') {
await serverLogin(username.trim(), password);
} else {
if (!invite.trim()) throw new Error('请填写邀请码');
await serverRegister(username.trim(), password, invite.trim());
}
onAuthed();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="flex h-full items-center justify-center bg-bg px-4">
<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-[18px] font-semibold tracking-tight text-ink-1">足迹</div>
<div className="text-[11px] 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="用户名" required>
<Input value={username} onChange={e => setUsername(e.target.value)} placeholder="3-32 位字母数字-_" onKeyDown={e => { if (e.key === 'Enter') void submit(); }} />
</Field>
<Field label="密码" required>
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="至少 8 位" onKeyDown={e => { if (e.key === 'Enter') void submit(); }} />
</Field>
{mode === 'register' && (
<Field label="邀请码" required hint="向管理员索取">
<Input value={invite} onChange={e => setInvite(e.target.value)} placeholder="邀请码" onKeyDown={e => { if (e.key === 'Enter') void submit(); }} />
</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>
</div>
);
}
+14 -1
View File
@@ -1,12 +1,25 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { ToastProvider } from '@/components/ui';
import { LoginPage } from '@/components/auth/LoginPage';
import { isLocalMode } from '@/lib/api';
export function AppShell() {
const [authed, setAuthed] = useState(() => isLocalMode());
const [navOpen, setNavOpen] = useState(false);
useEffect(() => {
const h = () => setAuthed(false);
window.addEventListener('fp-auth-required', h);
return () => window.removeEventListener('fp-auth-required', h);
}, []);
if (!authed) {
return <LoginPage onAuthed={() => setAuthed(true)} />;
}
return (
<ToastProvider>
<div className="flex h-full">
+47 -5
View File
@@ -6,16 +6,58 @@ import type {
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
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): Promise<ServerSession> {
const res = await fetch('/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, device_name: 'Web 浏览器' })
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || '登录失败');
setToken(json.data.token);
return json.data;
}
export async function serverRegister(username: string, password: string, invite_code: string): Promise<ServerSession> {
const res = await fetch('/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, invite_code, device_name: 'Web 浏览器' })
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || '注册失败');
setToken(json.data.token);
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() } });
const json = await res.json();
if (!json.ok) throw new Error(json.error || '会话失效');
return json.data;
}
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 res = await fetch('/api/' + cmd, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(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 (res.status === 401) {
clearToken();
window.dispatchEvent(new CustomEvent('fp-auth-required'));
throw new Error('登录已过期,请重新登录');
}
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.error || ('HTTP ' + res.status));
return json.data as T;