feat: Web 版上线 footpr.linyux.cn —— 登录页 + 按用户隔离的命令 API + 静态托管
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
**/node_modules
|
||||
**/.data
|
||||
**/data
|
||||
app/dist
|
||||
app/src-tauri
|
||||
tools/
|
||||
.git
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 基础镜像可通过 build-arg 替换为镜像源,例如:
|
||||
# docker compose build --build-arg NODE_IMAGE=docker.m.daocloud.io/library/node:24-alpine
|
||||
# 构建上下文 = 仓库根(docker-compose 已配置)
|
||||
# 基础镜像可 build-arg 切换镜像源
|
||||
ARG NODE_IMAGE=node:24-alpine
|
||||
FROM ${NODE_IMAGE}
|
||||
|
||||
@@ -9,8 +9,13 @@ ENV NODE_ENV=production
|
||||
ENV PORT=8788
|
||||
ENV DATA_DIR=/data
|
||||
|
||||
COPY package.json ./
|
||||
COPY src ./src
|
||||
COPY sync-server/package.json ./package.json
|
||||
COPY sync-server/src ./src
|
||||
COPY sync-server/schema.sql ./schema.sql
|
||||
# 命令层同源:以仓库内数据层覆盖薄适配文件
|
||||
COPY app/server/db.mjs ./src/app-commands.mjs
|
||||
# Web 前端(构建产物随仓库分发)
|
||||
COPY sync-server/www ./www
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
# 5. 注册自己的账号后,把 REGISTER_ENABLED 改为 false 并重新 up -d
|
||||
services:
|
||||
footprints-sync:
|
||||
build: .
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: sync-server/Dockerfile
|
||||
container_name: footprints-sync
|
||||
# 无需 ports 映射:Caddy 通过容器网络按服务名直连(footprints-sync:8788)
|
||||
volumes:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
# 若你的服务器上已有 Caddy 反代架构,请改用 docker-compose.caddy.yml
|
||||
services:
|
||||
footprints-sync:
|
||||
build: .
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: sync-server/Dockerfile
|
||||
container_name: footprints-sync
|
||||
ports:
|
||||
- "127.0.0.1:8788:8788" # 只绑定本机回环,交给反代;确认无反代时可改为 "8788:8788"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
-- Footprints 数据库 schema(开发桥接与 Rust 后端共用同一份)
|
||||
-- v2:加入多端同步地基(uuid v7 / 软删除墓碑 / HLC / 变更日志)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS footprint_type (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
icon TEXT NOT NULL DEFAULT 'tag',
|
||||
color TEXT NOT NULL DEFAULT '#64748b',
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
form_template TEXT NOT NULL DEFAULT '[]',
|
||||
is_builtin INTEGER NOT NULL DEFAULT 0,
|
||||
closed INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS taxonomy (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
color TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
is_builtin INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
UNIQUE(kind, value)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dimension (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS footprint (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
type_id INTEGER NOT NULL REFERENCES footprint_type(id),
|
||||
title TEXT NOT NULL,
|
||||
role TEXT,
|
||||
level TEXT,
|
||||
polarity TEXT NOT NULL DEFAULT 'positive',
|
||||
award_grade TEXT,
|
||||
rank INTEGER,
|
||||
organizer TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
result TEXT NOT NULL DEFAULT '',
|
||||
metrics TEXT NOT NULL DEFAULT '{}',
|
||||
extras TEXT NOT NULL DEFAULT '{}',
|
||||
group_id TEXT,
|
||||
parent_id INTEGER,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
weight INTEGER NOT NULL DEFAULT 3,
|
||||
search_text TEXT NOT NULL DEFAULT '',
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS footprint_dimension (
|
||||
footprint_id INTEGER NOT NULL REFERENCES footprint(id) ON DELETE CASCADE,
|
||||
dimension_id INTEGER NOT NULL REFERENCES dimension(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (footprint_id, dimension_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
owner_type TEXT NOT NULL DEFAULT 'footprint',
|
||||
owner_id INTEGER NOT NULL,
|
||||
kind TEXT,
|
||||
file_name TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
rel_path TEXT NOT NULL,
|
||||
sha256 TEXT,
|
||||
taken_at TEXT,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semester (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
term TEXT NOT NULL UNIQUE,
|
||||
gpa REAL,
|
||||
rank INTEGER,
|
||||
total_students INTEGER,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS course_grade (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
semester_id INTEGER NOT NULL REFERENCES semester(id) ON DELETE CASCADE,
|
||||
course_name TEXT NOT NULL,
|
||||
credit REAL NOT NULL DEFAULT 0,
|
||||
score REAL,
|
||||
grade_point REAL,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
-- 成绩梯度(等级带):用户可配置;单科成绩按 min <= 分数 < max 定级(满分档闭区间)
|
||||
CREATE TABLE IF NOT EXISTS grade_band (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
label TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#64748b',
|
||||
min REAL NOT NULL DEFAULT 0,
|
||||
max REAL NOT NULL DEFAULT 100,
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metric_def (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
agg TEXT NOT NULL DEFAULT 'sum',
|
||||
section TEXT,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_view (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
filter TEXT NOT NULL DEFAULT '{}',
|
||||
sort TEXT NOT NULL DEFAULT '{}',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
-- ===== 同步基础设施 =====
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_oplog (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
row_uuid TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
hlc TEXT NOT NULL,
|
||||
synced_at TEXT
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS footprint_fts USING fts5(
|
||||
search_text,
|
||||
content='footprint',
|
||||
content_rowid='id',
|
||||
tokenize='trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS footprint_ai AFTER INSERT ON footprint BEGIN
|
||||
INSERT INTO footprint_fts(rowid, search_text) VALUES (new.id, new.search_text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS footprint_ad AFTER DELETE ON footprint BEGIN
|
||||
INSERT INTO footprint_fts(footprint_fts, rowid, search_text)
|
||||
VALUES ('delete', old.id, old.search_text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS footprint_au AFTER UPDATE OF search_text ON footprint BEGIN
|
||||
INSERT INTO footprint_fts(footprint_fts, rowid, search_text)
|
||||
VALUES ('delete', old.id, old.search_text);
|
||||
INSERT INTO footprint_fts(rowid, search_text) VALUES (new.id, new.search_text);
|
||||
END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_footprint_start ON footprint(start_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_footprint_type ON footprint(type_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_footprint_group ON footprint(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_footprint_hlc ON footprint(hlc);
|
||||
CREATE INDEX IF NOT EXISTS idx_att_owner ON attachment(owner_type, owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oplog_pending ON sync_oplog(synced_at);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_footprint_uuid ON footprint(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_footprint_type_uuid ON footprint_type(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_taxonomy_uuid ON taxonomy(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dimension_uuid ON dimension(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_attachment_uuid ON attachment(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_semester_uuid ON semester(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_course_grade_uuid ON course_grade(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_metric_def_uuid ON metric_def(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_view_uuid ON saved_view(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_grade_band_uuid ON grade_band(uuid);
|
||||
@@ -0,0 +1,3 @@
|
||||
// 本地开发:直接引用仓库内的命令层(与桌面端单一数据源)
|
||||
// Docker 构建:此文件被 app/server/db.mjs 覆盖(见 Dockerfile),保证同源
|
||||
export { createAppCommands } from '../../app/server/db.mjs';
|
||||
+71
-16
@@ -1,13 +1,39 @@
|
||||
// Footprints 自建同步服务(零依赖:node:http + node:sqlite)
|
||||
// Footprints 自建同步 + Web 服务(零依赖:node:http + node:sqlite)
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getDb } from './db.mjs';
|
||||
import { createAppCommands } from './app-commands.mjs';
|
||||
import { register, login, authUser } from './auth.mjs';
|
||||
import { push, pull } from './sync.mjs';
|
||||
import { upload, download } from './attachments.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT || 8788);
|
||||
const WWW_DIR = process.env.WWW_DIR || path.join(__dirname, '..', 'www');
|
||||
const USERS_DIR = path.join(process.env.DATA_DIR || path.join(__dirname, '..', 'data'), 'users');
|
||||
const REGISTER_ENABLED = (process.env.REGISTER_ENABLED ?? 'true') === 'true';
|
||||
const MAX_BODY = 160 * 1024 * 1024; // 160MB(附件 Base64 余量)
|
||||
const MAX_BODY = 160 * 1024 * 1024; // 160MB
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png',
|
||||
'.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp',
|
||||
'.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8'
|
||||
};
|
||||
|
||||
// 按用户惰性创建命令集(每个用户一个独立 SQLite 档案库)
|
||||
const userCommands = new Map();
|
||||
function commandsFor(userId) {
|
||||
let c = userCommands.get(userId);
|
||||
if (!c) {
|
||||
fs.mkdirSync(USERS_DIR, { recursive: true });
|
||||
c = createAppCommands(path.join(USERS_DIR, userId + '.db'));
|
||||
userCommands.set(userId, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
@@ -38,7 +64,7 @@ const server = http.createServer(async (req, res) => {
|
||||
return send(200, true, login(body));
|
||||
}
|
||||
|
||||
// ---- 管理(X-Admin-Token,设置 ADMIN_TOKEN 环境变量后启用)----
|
||||
// ---- 管理(X-Admin-Token)----
|
||||
if (req.method === 'GET' && route === '/admin/users') {
|
||||
const expect = process.env.ADMIN_TOKEN;
|
||||
if (!expect || req.headers['x-admin-token'] !== expect) {
|
||||
@@ -55,12 +81,17 @@ const server = http.createServer(async (req, res) => {
|
||||
return send(200, true, { total: users.length, users });
|
||||
}
|
||||
|
||||
// ---- 同步(需鉴权)----
|
||||
if (route.startsWith('/sync/') || route.startsWith('/auth/')) {
|
||||
// ---- 同步与 Web API(鉴权区) ----
|
||||
if (route.startsWith('/sync/') || route.startsWith('/api/') || route.startsWith('/auth/')) {
|
||||
let auth;
|
||||
try { auth = authUser(req); }
|
||||
catch (e) { return send(e.code || 401, false, e.message); }
|
||||
|
||||
if (req.method === 'GET' && route === '/auth/me') {
|
||||
const row = getDb().prepare('SELECT id, username FROM users WHERE id=?').get(auth.user_id);
|
||||
return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', device_id: auth.device_id });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/sync/push') {
|
||||
const body = await readJson(req);
|
||||
return send(200, true, push(auth.user_id, body.changes));
|
||||
@@ -78,8 +109,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!file) return send(404, false, '附件不存在');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': file.mime || 'application/octet-stream',
|
||||
'Content-Length': file.buf.length,
|
||||
'Content-Disposition': 'attachment; filename="blob"'
|
||||
'Content-Length': file.buf.length
|
||||
});
|
||||
return res.end(file.buf);
|
||||
}
|
||||
@@ -88,13 +118,37 @@ const server = http.createServer(async (req, res) => {
|
||||
const changes = db.prepare('SELECT COUNT(*) AS c FROM changes WHERE user_id=?').get(auth.user_id).c;
|
||||
const att = db.prepare('SELECT COUNT(*) AS c, COALESCE(SUM(size),0) AS bytes FROM attachments WHERE user_id=?').get(auth.user_id);
|
||||
return send(200, true, {
|
||||
user_id: auth.user_id,
|
||||
device_id: auth.device_id,
|
||||
total_changes: changes,
|
||||
attachments: att.c,
|
||||
attachment_bytes: att.bytes
|
||||
user_id: auth.user_id, device_id: auth.device_id,
|
||||
total_changes: changes, attachments: att.c, attachment_bytes: att.bytes
|
||||
});
|
||||
}
|
||||
|
||||
// Web 应用命令层:/api/<command>(与桌面端同一契约,按用户隔离的档案库)
|
||||
if (req.method === 'POST' && route.startsWith('/api/')) {
|
||||
const cmd = route.slice(5);
|
||||
if (!cmd || cmd.includes('/')) return send(404, false, 'not found');
|
||||
const body = await readJson(req);
|
||||
const data = commandsFor(auth.user_id).handleCommand(cmd, body);
|
||||
return send(200, true, data);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 静态前端(www/,指纹资源长缓存,index 禁缓存)----
|
||||
if (req.method === 'GET' && fs.existsSync(WWW_DIR)) {
|
||||
let p = route === '/' ? '/index.html' : decodeURIComponent(route);
|
||||
const abs = path.join(WWW_DIR, path.normalize(p).replace(/^([.][.][\\/])+/, ''));
|
||||
if (abs.startsWith(WWW_DIR) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream',
|
||||
'Cache-Control': p.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'
|
||||
});
|
||||
return res.end(fs.readFileSync(abs));
|
||||
}
|
||||
const indexHtml = path.join(WWW_DIR, 'index.html');
|
||||
if (fs.existsSync(indexHtml)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
||||
return res.end(fs.readFileSync(indexHtml));
|
||||
}
|
||||
}
|
||||
|
||||
return send(404, false, 'not found');
|
||||
@@ -107,10 +161,10 @@ function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
req.on('data', c => {
|
||||
size += c.length;
|
||||
req.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) { reject(new Error('请求体过大')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
|
||||
@@ -123,5 +177,6 @@ function readJson(req) {
|
||||
getDb();
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log('[footprints-sync] listening on 0.0.0.0:' + PORT);
|
||||
console.log('[footprints-sync] data dir:', process.env.DATA_DIR || 'data');
|
||||
console.log('[footprints-sync] data dir:', process.env.DATA_DIR || path.join(__dirname, '..', 'data'));
|
||||
console.log('[footprints-sync] web dir:', WWW_DIR, fs.existsSync(WWW_DIR) ? '(已托管)' : '(未部署前端)');
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import{r as g,e as v,j as t,S as j,s as S}from"./index-BunBoxtn.js";import{C as c,a as d,b as x}from"./card-CgBa5fs5.js";import{u as N,i as u,a as m}from"./useChart-CwGNJ-9_.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};
|
||||
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
@@ -0,0 +1 @@
|
||||
import{j as s,a}from"./index-BunBoxtn.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};
|
||||
@@ -0,0 +1 @@
|
||||
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
@@ -0,0 +1 @@
|
||||
import{r}from"./index-BunBoxtn.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};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#3056d3" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<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-BunBoxtn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BMVMeOX_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "足迹 Footprints",
|
||||
"short_name": "足迹",
|
||||
"description": "个人成长足迹档案:记录、检索、量化、导出",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#101216",
|
||||
"theme_color": "#3056d3",
|
||||
"lang": "zh-CN",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/pwa-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/pwa-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// PWA Service Worker v2
|
||||
// - 页面导航:网络优先(构建更新后强制拿到新 index.html),离线才回落缓存
|
||||
// - 静态资源:缓存优先
|
||||
// - /api:直连不缓存
|
||||
const CACHE = 'fp-static-v2';
|
||||
const CORE = ['/', '/manifest.webmanifest', '/icons/pwa-192.png', '/icons/pwa-512.png'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
self.skipWaiting();
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(CORE)));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || url.pathname.startsWith('/api')) return;
|
||||
|
||||
if (e.request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(e.request).then((res) => {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put('/index.html', copy));
|
||||
return res;
|
||||
}).catch(() => caches.match('/index.html').then((h) => h || caches.match('/')))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
e.respondWith(
|
||||
caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => {
|
||||
if (res.ok && url.origin === location.origin) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(e.request, copy));
|
||||
}
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user