feat: Web 版上线 footpr.linyux.cn —— 登录页 + 按用户隔离的命令 API + 静态托管
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+57
-32
@@ -11,33 +11,6 @@ export const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', '.dat
|
||||
export const ATT_DIR = path.join(DATA_DIR, 'attachments');
|
||||
export const DB_PATH = path.join(DATA_DIR, 'footprints.db');
|
||||
|
||||
let db = null;
|
||||
|
||||
export function getDb() {
|
||||
if (db) return db;
|
||||
fs.mkdirSync(ATT_DIR, { recursive: true });
|
||||
db = new DatabaseSync(DB_PATH);
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
// 先补列(老库无 uuid/hlc 等列时,schema 中的索引会引用失败),再执行 schema,最后回填
|
||||
preMigrateColumns(db);
|
||||
db.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
|
||||
migrateSync(db);
|
||||
ensureBuiltin(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function buildSearchText(title, description, result, tags, extras) {
|
||||
const extraFlat = Object.entries(extras || {}).map(([k, v]) => {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'object') return Object.values(v).filter(Boolean).join(' ');
|
||||
return String(v);
|
||||
}).filter(Boolean);
|
||||
return [title, description || '', result || '', (tags || []).join(' '), ...extraFlat]
|
||||
.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// ---------- 同步基础设施 ----------
|
||||
|
||||
function getMeta(d, key) {
|
||||
const r = d.prepare('SELECT value FROM sync_state WHERE key=?').get(key);
|
||||
return r ? r.value : null;
|
||||
@@ -98,6 +71,15 @@ function getUuid(d, table, id) {
|
||||
return r ? r.uuid : null;
|
||||
}
|
||||
|
||||
export function buildSearchText(title, description, result, tags, extras) {
|
||||
const extraFlat = Object.entries(extras || {}).map(([k, v]) => {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'object') return Object.values(v).filter(Boolean).join(' ');
|
||||
return String(v);
|
||||
}).filter(Boolean);
|
||||
return [title, description || '', result || '', (tags || []).join(' '), ...extraFlat]
|
||||
.filter(Boolean).join('\n');
|
||||
}
|
||||
// 老库迁移:补列 + 回填 uuid/hlc/origin(幂等)
|
||||
const SYNC_TABLES = ['footprint', 'footprint_type', 'taxonomy', 'dimension', 'attachment',
|
||||
'semester', 'course_grade', 'metric_def', 'saved_view', 'grade_band'];
|
||||
@@ -250,6 +232,31 @@ function ensureBuiltin(db) {
|
||||
}
|
||||
|
||||
// ---------- 查询构建 ----------
|
||||
|
||||
function openDb(dbPath) {
|
||||
fs.mkdirSync(path.join(path.dirname(dbPath), 'attachments'), { recursive: true });
|
||||
const d = new DatabaseSync(dbPath);
|
||||
d.exec('PRAGMA foreign_keys = ON;');
|
||||
// 先补列(老库无 uuid/hlc 等列时,schema 中的索引会引用失败),再执行 schema,最后回填
|
||||
preMigrateColumns(d);
|
||||
d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
|
||||
migrateSync(d);
|
||||
ensureBuiltin(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
// 按数据库路径创建一套应用命令(多用户/服务器模式:每个用户一个独立档案库)
|
||||
export function createAppCommands(dbPath) {
|
||||
const db = openDb(dbPath);
|
||||
const DATA_DIR = path.dirname(dbPath); // 工厂内遮蔽顶层默认值:附件按用户目录
|
||||
function getDb() {
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
// ---------- 同步基础设施 ----------
|
||||
|
||||
|
||||
const SORTS = {
|
||||
date_desc: "COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) DESC, fp.id DESC",
|
||||
date_asc: "COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) ASC, fp.id ASC",
|
||||
@@ -337,8 +344,7 @@ function mapRow(r) {
|
||||
};
|
||||
}
|
||||
|
||||
function listFootprints(query) {
|
||||
const d = getDb();
|
||||
function listFootprints(d, query) {
|
||||
const page = Math.max(1, query.page || 1);
|
||||
const pageSize = Math.min(2000, Math.max(1, query.page_size || 20));
|
||||
const { joins, conds, params } = buildWhere(query);
|
||||
@@ -359,7 +365,7 @@ function listFootprints(query) {
|
||||
}
|
||||
|
||||
// ---------- 命令处理 ----------
|
||||
export function handleCommand(cmd, payload) {
|
||||
function handleCommand(cmd, payload) {
|
||||
const d = getDb();
|
||||
|
||||
switch (cmd) {
|
||||
@@ -496,7 +502,7 @@ export function handleCommand(cmd, payload) {
|
||||
}
|
||||
|
||||
case 'footprint_list':
|
||||
return listFootprints(payload.query || {});
|
||||
return listFootprints(d, payload.query || {});
|
||||
|
||||
case 'footprint_get': {
|
||||
const row = d.prepare(BASE_SELECT + ' AND fp.id=?').get(payload.id);
|
||||
@@ -563,7 +569,7 @@ export function handleCommand(cmd, payload) {
|
||||
}
|
||||
|
||||
case 'timeline': {
|
||||
const res = listFootprints({ ...(payload.query || {}), page_size: 2000, sort: 'date_desc' });
|
||||
const res = listFootprints(d, { ...(payload.query || {}), page_size: 2000, sort: 'date_desc' });
|
||||
const groups = [];
|
||||
const byYear = new Map();
|
||||
for (const row of res.items) {
|
||||
@@ -908,3 +914,22 @@ export function handleCommand(cmd, payload) {
|
||||
throw new Error('未知命令:' + cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// 工厂闭合
|
||||
return { handleCommand, getDb };
|
||||
}
|
||||
|
||||
// ---------- 默认单例(开发桥接 / seed 兼容) ----------
|
||||
let defaultCommands = null;
|
||||
function getDefault() {
|
||||
if (!defaultCommands) defaultCommands = createAppCommands(DB_PATH);
|
||||
return defaultCommands;
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
return getDefault().getDb();
|
||||
}
|
||||
|
||||
export function handleCommand(cmd, payload) {
|
||||
return getDefault().handleCommand(cmd, payload);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user