feat: 足迹 Footprints 个人留痕系统

桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA

同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署

多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
root
2026-10-16 13:53:15 +08:00
commit 42ca57abfd
107 changed files with 234676 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.data/
src-tauri/target/
src-tauri/gen/
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
Binary file not shown.
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
# 足迹 Footprints
个人留痕系统 · 桌面应用(Tauri 2 + React 19 + SQLite)+ 移动端 PWA。
设计文档见仓库根目录 `DESIGN.md`;同步服务端见 `sync-server/`。
## 目录结构
app/
src/ React 前端(时间线 / 记录 / 统计 / 类型 / 字典)
src-tauri/ Tauri 2 桌面壳 + Rust 后端(rusqlite,26 个 IPC 命令 + 同步地基)
public/ PWA 清单 / Service Worker / 图标
server/dev-bridge.mjs 开发桥接:HTTP 暴露与 Tauri IPC 相同的命令契约
server/db.mjs 桥接与种子脚本共用的数据层(node:sqlite)
server/seed.mjs 演示数据(仅当库为空时写入)
schema.sql 数据库 schema(Rust 与 Node 共用同一份,v2 含同步元数据)
tools/pnpm.cjs 自包含 pnpm(本机无全局 npm/pnpm 时使用)
sync-server/ 自建同步服务(Docker 部署,协议已冻结)
## 浏览器预览(无需 Rust)
node ../tools/pnpm.cjs bridge # 终端 1:数据桥接(含 dist 静态托管)
node ../tools/pnpm.cjs dev # 终端 2:前端热更(http://localhost:5173)
或构建后直接访问桥接托管页:
node ../tools/pnpm.cjs build # tsc 类型检查 + vite 打包(含 PWA 资源)
node server/seed.mjs # 可选:写入演示数据
## 桌面应用打包(Windows)
前置:Rust stable-msvc(scripts/setup-rust.ps1 一键安装,MSVC Build Tools 需已具备)。
node ../tools/pnpm.cjs build # 先产出 dist
node ../tools/pnpm.cjs tauri build
产物:`src-tauri/target/release/bundle/nsis/*-setup.exe`(NSIS 安装包)。
开发模式:`node ../tools/pnpm.cjs tauri dev`。
## 移动端(PWA)
前端已做响应式与 PWA 改造(manifest + Service Worker + 图标):
- 桌面壳内运行时自动跳过 SW 注册
- 浏览器/手机访问托管页时自动注册 SW,可"添加到主屏幕"离线壳启动
- 同步服务部署后,手机 PWA 直连同一账号数据
原生 Android 壳(Tauri mobile)为可选后续项,需要 Android SDK/NDK;iOS 构建需 macOS。
## 命令契约
前端统一经 `src/lib/api.ts` 调用:浏览器模式走 `POST /api/<command>`(开发桥接),
桌面模式走 Tauri `invoke(command, { payload })`。两端实现同一组命令,
含多端同步地基:每行携带 uuid/hlc/origin,删除为墓碑,写入进 sync_oplog。
数据与附件位置:桌面版在系统应用数据目录(`%APPDATA%/com.footprints.app`),
开发预览在 `app/.data/`。备份 = 复制该目录。
+16
View File
@@ -0,0 +1,16 @@
<!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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "footprints-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"bridge": "node server/dev-bridge.mjs",
"seed": "node server/seed.mjs",
"tauri": "tauri"
},
"dependencies": {
"@tanstack/react-query": "^5.80.0",
"@tauri-apps/api": "^2.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"echarts": "^5.6.0",
"lucide-react": "^0.475.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.5",
"@tauri-apps/cli": "^2.5.0"
}
}
+1741
View File
File diff suppressed because it is too large Load Diff
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

+30
View File
@@ -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"
}
]
}
+45
View File
@@ -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;
}))
);
});
+215
View File
@@ -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);
+20
View File
@@ -0,0 +1,20 @@
# 安装 Rust 工具链(MSVC 目标)
# 前置:VS Build Tools 已安装(本机已具备 17.14 + VC.Tools.x86.x64)
$ErrorActionPreference = 'Stop'
if (Get-Command cargo -ErrorAction SilentlyContinue) {
Write-Host "cargo 已安装:$(cargo --version)"
exit 0
}
Write-Host '下载 rustup-init ...'
$exe = Join-Path $env:TEMP 'rustup-init.exe'
Invoke-WebRequest -Uri 'https://win.rustup.rs/x86_64' -OutFile $exe
Write-Host '安装 Rust(stable-msvc,用户目录,不改系统设置)...'
& $exe -y --default-toolchain stable-msvc
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
cargo --version
rustc --version
Write-Host '完成。新开的终端将自动使用 %USERPROFILE%\.cargo\bin。'
+910
View File
@@ -0,0 +1,910 @@
// 数据层:dev-bridge 与 seed 共用
// v2:多端同步地基 —— uuid v7 / 软删除墓碑 / HLC / 变更日志(自建服务方案)
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', '.data');
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;
}
function setMeta(d, key, value) {
d.prepare("INSERT INTO sync_state (key, value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
.run(key, String(value));
}
function getDeviceId(d) {
let id = getMeta(d, 'device_id');
if (!id) {
id = crypto.randomBytes(6).toString('hex');
setMeta(d, 'device_id', id);
}
return id;
}
// UUID v7(RFC 9562):48bit 毫秒时间戳 + 随机位,时间有序,索引友好
export function uuidv7() {
const ts = Date.now();
const b = Buffer.alloc(16);
b.writeUIntBE(ts % 281474976710656, 0, 6);
crypto.randomBytes(10).copy(b, 6);
b[6] = (b[6] & 0x0f) | 0x70; // version 7
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
const h = b.toString('hex');
return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20, 32);
}
// 混合逻辑时钟:毫秒:计数:设备 —— 时钟回拨免疫,同设备单调,跨设备可比较
export function hlcNext(d) {
const ms = Date.now();
const lastMs = Number(getMeta(d, 'hlc_ms') || 0);
const lastCnt = Number(getMeta(d, 'hlc_counter') || 0);
let newMs, newCnt;
if (ms > lastMs) { newMs = ms; newCnt = 0; }
else { newMs = lastMs; newCnt = lastCnt + 1; }
setMeta(d, 'hlc_ms', String(newMs));
setMeta(d, 'hlc_counter', String(newCnt));
return newMs + ':' + newCnt + ':' + getDeviceId(d);
}
function touchOplog(d, table, rowUuid, op, hlc) {
d.prepare('INSERT INTO sync_oplog (table_name, row_uuid, op, hlc) VALUES (?,?,?,?)')
.run(table, rowUuid, op, hlc);
}
function withTx(d, fn) {
d.exec('BEGIN');
try { const r = fn(); d.exec('COMMIT'); return r; }
catch (e) { d.exec('ROLLBACK'); throw e; }
}
function getUuid(d, table, id) {
const r = d.prepare('SELECT uuid FROM ' + table + ' WHERE id=?').get(id);
return r ? r.uuid : null;
}
// 老库迁移:补列 + 回填 uuid/hlc/origin(幂等)
const SYNC_TABLES = ['footprint', 'footprint_type', 'taxonomy', 'dimension', 'attachment',
'semester', 'course_grade', 'metric_def', 'saved_view', 'grade_band'];
const SYNC_COLS = [['uuid', 'TEXT'], ['deleted_at', 'TEXT'], ['origin', 'TEXT'], ['hlc', 'TEXT']];
function preMigrateColumns(d) {
const addCols = (t, cols) => {
const exists = d.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(t);
if (!exists) return; // 全新库:表由 schema.sql 创建,自带同步列
const have = new Set(d.prepare('PRAGMA table_info(' + t + ')').all().map(c => c.name));
for (const [name, ddl] of cols) {
if (!have.has(name)) d.exec('ALTER TABLE ' + t + ' ADD COLUMN ' + name + ' ' + ddl);
}
};
for (const t of SYNC_TABLES) addCols(t, SYNC_COLS);
addCols('attachment', [['sha256', 'TEXT']]);
}
export function migrateSync(d) {
const addCols = (t, cols) => {
const have = new Set(d.prepare('PRAGMA table_info(' + t + ')').all().map(c => c.name));
for (const [name, ddl] of cols) {
if (!have.has(name)) d.exec('ALTER TABLE ' + t + ' ADD COLUMN ' + name + ' ' + ddl);
}
};
for (const t of SYNC_TABLES) {
addCols(t, [['uuid', 'TEXT'], ['deleted_at', 'TEXT'], ['origin', 'TEXT'], ['hlc', 'TEXT']]);
}
addCols('attachment', [['sha256', 'TEXT']]);
for (const t of SYNC_TABLES) {
const rows = d.prepare('SELECT id FROM ' + t + ' WHERE uuid IS NULL').all();
for (const row of rows) {
d.prepare('UPDATE ' + t + ' SET uuid=?, hlc=?, origin=? WHERE id=?')
.run(uuidv7(), hlcNext(d), 'migrate', row.id);
}
}
}
function softDelete(d, table, id) {
const uuid = getUuid(d, table, id);
if (!uuid) return;
const h = hlcNext(d);
d.prepare('UPDATE ' + table + " SET deleted_at=datetime('now','localtime'), hlc=? WHERE id=?").run(h, id);
touchOplog(d, table, uuid, 'delete', h);
}
const BUILTIN_TYPES = [
['学业', 'graduation-cap', '#3056d3', 10, JSON.stringify([
{ key: 'semester_label', label: '学期', widget: 'text', placeholder: '如:2024-2025-1' }
])],
['竞赛', 'trophy', '#7c3aed', 20, JSON.stringify([
{ key: 'my_work', label: '我的分工', widget: 'textarea', required: true },
{ key: 'deliverable', label: '最终产品', widget: 'text', placeholder: '作品 / 论文 / 系统名称' },
{ key: 'team_members', label: '队伍成员', widget: 'text' }
])],
['科研', 'flask-conical', '#0891b2', 30, JSON.stringify([
{ key: 'authors', label: '作者排序', widget: 'text', placeholder: '如:第 2 作者' },
{ key: 'outcome', label: '成果形式', widget: 'select', options: ['论文', '专利', '软件著作权', '研究报告'] }
])],
['实习实践', 'briefcase', '#0f766e', 40, JSON.stringify([
{ key: 'position', label: '岗位', widget: 'text' },
{ key: 'mentor', label: '带教人', widget: 'text' }
])],
['学生工作', 'network', '#d97706', 50, JSON.stringify([
{ key: 'duties', label: '职责', widget: 'textarea' },
{ key: 'term', label: '任期', widget: 'text', placeholder: '2024.09 - 2025.06' }
])],
['志愿公益', 'heart-handshake', '#16a34a', 60, JSON.stringify([
{ key: 'hours', label: '志愿时长', widget: 'number', unit: '小时', metric: 'volunteer_hours' },
{ key: 'contact', label: '组织负责人', widget: 'text' }
])],
['文体活动', 'palette', '#db2777', 70, JSON.stringify([
{ key: 'participation', label: '参与形式', widget: 'text', placeholder: '如:方阵队员 / 主唱' }
])],
['证书技能', 'file-badge', '#64748b', 80, JSON.stringify([
{ key: 'cert_no', label: '证书编号', widget: 'text' },
{ key: 'score', label: '成绩', widget: 'text', placeholder: '如:580 / 良好' }
])],
['荣誉惩处', 'award', '#b45309', 90, JSON.stringify([
{ key: 'issued_by', label: '授予 / 决定单位', widget: 'text' }
])]
];
const BUILTIN_LEVELS = [
['国际级', '#7c3aed', 10], ['国家级', '#dc2626', 20], ['省市级', '#ea580c', 30],
['区县级', '#2563eb', 40], ['校级', '#16a34a', 50], ['院级', '#0891b2', 60]
];
const BUILTIN_ROLES = [
['主导', '#3056d3', 10], ['核心', '#0891b2', 20], ['参加', '#64748b', 30]
];
const BUILTIN_DIMENSIONS = [
['学业深造', '#3056d3', 10], ['职业发展', '#0891b2', 20],
['综合素养', '#16a34a', 30], ['兴趣生活', '#db2777', 40]
];
function ensureBuiltin(db) {
const cnt = db.prepare('SELECT COUNT(*) AS c FROM footprint_type WHERE deleted_at IS NULL').get().c;
if (cnt === 0) {
const ins = db.prepare(
'INSERT INTO footprint_type (name, icon, color, sort, form_template, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,?,1,?,?,?)');
for (const [name, icon, color, sort, tpl] of BUILTIN_TYPES) {
const h = hlcNext(db);
ins.run(name, icon, color, sort, tpl, uuidv7(), h, getDeviceId(db));
}
}
const tcnt = db.prepare('SELECT COUNT(*) AS c FROM taxonomy WHERE deleted_at IS NULL').get().c;
if (tcnt === 0) {
const ins = db.prepare('INSERT INTO taxonomy (kind, value, color, sort, is_builtin, uuid, hlc, origin) VALUES (?,?,?,?,1,?,?,?)');
for (const [v, c, s] of BUILTIN_LEVELS) {
const h = hlcNext(db);
ins.run('level', v, c, s, uuidv7(), h, getDeviceId(db));
}
for (const [v, c, s] of BUILTIN_ROLES) {
const h = hlcNext(db);
ins.run('role', v, c, s, uuidv7(), h, getDeviceId(db));
}
}
const dcnt = db.prepare('SELECT COUNT(*) AS c FROM dimension WHERE deleted_at IS NULL').get().c;
if (dcnt === 0) {
const ins = db.prepare('INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)');
for (const [v, c, s] of BUILTIN_DIMENSIONS) {
const h = hlcNext(db);
ins.run(v, c, s, uuidv7(), h, getDeviceId(db));
}
}
const mcnt = db.prepare('SELECT COUNT(*) AS c FROM metric_def WHERE deleted_at IS NULL').get().c;
if (mcnt === 0) {
const h = hlcNext(db);
db.prepare('INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
.run('volunteer_hours', '志愿时长', '小时', 'sum', '志愿服务', uuidv7(), h, getDeviceId(db));
}
const bcnt = db.prepare('SELECT COUNT(*) AS c FROM grade_band WHERE deleted_at IS NULL').get().c;
if (bcnt === 0) {
const bands = [
['优秀', '#16803c', 90, 100, 10],
['良好', '#0891b2', 80, 90, 20],
['中等', '#d97706', 70, 80, 30],
['及格', '#ea580c', 60, 70, 40],
['不及格', '#dc2626', 0, 60, 50]
];
const ins = db.prepare('INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)');
for (const [label, color, min, max, sort] of bands) {
const h = hlcNext(db);
ins.run(label, color, min, max, sort, uuidv7(), h, getDeviceId(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",
updated_desc: 'fp.updated_at DESC, fp.id DESC',
weight_desc: "fp.weight DESC, COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10)) DESC"
};
function buildWhere(query) {
const conds = [];
const params = [];
const joins = [];
if (query.q) {
// 中文检索策略:长度>=3 的词走 FTS5 trigram(子串匹配),更短的词回退 LIKE
const ftsTerms = [];
for (const raw of String(query.q).trim().split(/\s+/).filter(Boolean)) {
const clean = raw.replace(/["\\]/g, ' ');
if (!clean.trim()) continue;
if ([...clean].length >= 3) {
ftsTerms.push('"' + clean + '"');
} else {
conds.push('fp.search_text LIKE ?');
params.push('%' + clean + '%');
}
}
if (ftsTerms.length) {
joins.push('JOIN footprint_fts ON footprint_fts.rowid = fp.id');
conds.push('footprint_fts MATCH ?');
params.push(ftsTerms.join(' AND '));
}
}
if (query.types?.length) {
conds.push('fp.type_id IN (' + query.types.map(() => '?').join(',') + ')');
params.push(...query.types);
}
if (query.levels?.length) {
conds.push('fp.level IN (' + query.levels.map(() => '?').join(',') + ')');
params.push(...query.levels);
}
if (query.roles?.length) {
conds.push('fp.role IN (' + query.roles.map(() => '?').join(',') + ')');
params.push(...query.roles);
}
if (query.statuses?.length) {
conds.push('fp.status IN (' + query.statuses.map(() => '?').join(',') + ')');
params.push(...query.statuses);
}
if (query.dims?.length) {
conds.push('fp.id IN (SELECT footprint_id FROM footprint_dimension WHERE dimension_id IN ('
+ query.dims.map(() => '?').join(',') + '))');
params.push(...query.dims);
}
if (query.year) {
conds.push("strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?");
params.push(String(query.year));
}
if (query.polarity) {
conds.push('fp.polarity = ?');
params.push(query.polarity);
}
if (query.has_attachment) {
conds.push("EXISTS (SELECT 1 FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id AND a.deleted_at IS NULL)");
}
if (query.tag) {
conds.push('fp.tags LIKE ?');
params.push('%"' + query.tag + '"%');
}
return { joins, conds, params };
}
const BASE_SELECT = `SELECT fp.*, ft.name AS type_name, ft.color AS type_color, ft.icon AS type_icon,
(SELECT COUNT(*) FROM attachment a WHERE a.owner_type='footprint' AND a.owner_id=fp.id AND a.deleted_at IS NULL) AS attachment_count,
(SELECT GROUP_CONCAT(dimension_id) FROM footprint_dimension fd WHERE fd.footprint_id=fp.id) AS dim_ids
FROM footprint fp JOIN footprint_type ft ON ft.id = fp.type_id`;
function mapRow(r) {
let metrics = {}, extras = {}, tags = [];
try { metrics = JSON.parse(r.metrics || '{}'); } catch {}
try { extras = JSON.parse(r.extras || '{}'); } catch {}
try { tags = JSON.parse(r.tags || '[]'); } catch {}
return {
...r,
metrics, extras, tags,
dimensions: r.dim_ids ? String(r.dim_ids).split(',').map(Number) : [],
dim_ids: undefined
};
}
function listFootprints(query) {
const d = getDb();
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);
const base = ' WHERE fp.deleted_at IS NULL';
const where = conds.length ? base + ' AND ' + conds.join(' AND ') : base;
const order = SORTS[query.sort] || SORTS.date_desc;
const total = d.prepare(
'SELECT COUNT(*) AS c FROM footprint fp ' + joins.join(' ') + where
).get(...params).c;
const rows = d.prepare(
BASE_SELECT + ' ' + joins.join(' ') + where + ' ORDER BY ' + order +
' LIMIT ' + pageSize + ' OFFSET ' + (page - 1) * pageSize
).all(...params).map(mapRow);
return { items: rows, total, page, page_size: pageSize };
}
// ---------- 命令处理 ----------
export function handleCommand(cmd, payload) {
const d = getDb();
switch (cmd) {
case 'app_info': {
const pending = d.prepare('SELECT COUNT(*) AS c FROM sync_oplog WHERE synced_at IS NULL').get().c;
return {
app_name: 'Footprints 足迹',
version: '0.2.0',
db_path: DB_PATH,
attachment_dir: ATT_DIR,
schema_ok: true,
device_id: getDeviceId(d),
pending_ops: pending,
last_sync_at: getMeta(d, 'last_sync_at')
};
}
case 'sync_status':
return {
mode: 'local',
device_id: getDeviceId(d),
pending_ops: d.prepare('SELECT COUNT(*) AS c FROM sync_oplog WHERE synced_at IS NULL').get().c,
last_sync_at: getMeta(d, 'last_sync_at')
};
case 'type_list': {
const items = d.prepare(`
SELECT t.*, (SELECT COUNT(*) FROM footprint f WHERE f.type_id = t.id AND f.deleted_at IS NULL) AS usage_count
FROM footprint_type t WHERE t.deleted_at IS NULL ORDER BY t.sort, t.id`).all()
.map(t => ({ ...t, form_template: JSON.parse(t.form_template || '[]'), is_builtin: !!t.is_builtin, closed: !!t.closed }));
return { items };
}
case 'type_upsert': {
const p = payload;
const tpl = JSON.stringify(p.form_template || []);
let id = p.id;
if (id) {
withTx(d, () => {
const h = hlcNext(d);
d.prepare('UPDATE footprint_type SET name=?, icon=?, color=?, sort=?, form_template=?, closed=?, hlc=? WHERE id=?')
.run(p.name, p.icon, p.color, p.sort, tpl, p.closed ? 1 : 0, h, id);
touchOplog(d, 'footprint_type', getUuid(d, 'footprint_type', id), 'upsert', h);
});
} else {
withTx(d, () => {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare(
'INSERT INTO footprint_type (name, icon, color, sort, form_template, closed, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)')
.run(p.name, p.icon, p.color, p.sort, tpl, p.closed ? 1 : 0, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'footprint_type', uuid, 'upsert', h);
});
}
return { item: { id } };
}
case 'type_delete': {
const used = d.prepare('SELECT COUNT(*) AS c FROM footprint WHERE type_id=? AND deleted_at IS NULL').get(payload.id).c;
if (used > 0) throw new Error('该类型下仍有 ' + used + ' 条记录,请先迁移后再删除');
const row = d.prepare('SELECT uuid, is_builtin FROM footprint_type WHERE id=?').get(payload.id);
if (!row) return { ok: true };
if (row.is_builtin) throw new Error('内置类型不可删除');
withTx(d, () => softDelete(d, 'footprint_type', payload.id));
return { ok: true };
}
case 'taxonomy_list': {
const sql = payload.kind
? "SELECT * FROM taxonomy WHERE kind=? AND deleted_at IS NULL ORDER BY sort, id"
: "SELECT * FROM taxonomy WHERE deleted_at IS NULL ORDER BY kind, sort, id";
const rows = payload.kind ? d.prepare(sql).all(payload.kind) : d.prepare(sql).all();
return { items: rows.map(r => ({ ...r, is_builtin: !!r.is_builtin })) };
}
case 'taxonomy_upsert': {
const p = payload;
let id = p.id;
if (id) {
withTx(d, () => {
const h = hlcNext(d);
d.prepare('UPDATE taxonomy SET kind=?, value=?, color=?, sort=?, hlc=? WHERE id=?')
.run(p.kind, p.value, p.color ?? null, p.sort ?? 100, h, id);
touchOplog(d, 'taxonomy', getUuid(d, 'taxonomy', id), 'upsert', h);
});
} else {
withTx(d, () => {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO taxonomy (kind, value, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)')
.run(p.kind, p.value, p.color ?? null, p.sort ?? 100, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'taxonomy', uuid, 'upsert', h);
});
}
return { item: { id } };
}
case 'taxonomy_delete': {
const row = d.prepare('SELECT is_builtin FROM taxonomy WHERE id=?').get(payload.id);
if (!row) return { ok: true };
if (row.is_builtin) throw new Error('内置条目不可删除');
withTx(d, () => softDelete(d, 'taxonomy', payload.id));
return { ok: true };
}
case 'dimension_list':
return { items: d.prepare('SELECT * FROM dimension WHERE deleted_at IS NULL ORDER BY sort, id').all() };
case 'dimension_upsert': {
const p = payload;
let id = p.id;
if (id) {
withTx(d, () => {
const h = hlcNext(d);
d.prepare('UPDATE dimension SET name=?, color=?, sort=?, hlc=? WHERE id=?')
.run(p.name, p.color ?? null, p.sort ?? 100, h, id);
touchOplog(d, 'dimension', getUuid(d, 'dimension', id), 'upsert', h);
});
} else {
withTx(d, () => {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO dimension (name, color, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?)')
.run(p.name, p.color ?? null, p.sort ?? 100, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'dimension', uuid, 'upsert', h);
});
}
return { item: { id } };
}
case 'dimension_delete': {
withTx(d, () => softDelete(d, 'dimension', payload.id));
return { ok: true };
}
case 'footprint_list':
return listFootprints(payload.query || {});
case 'footprint_get': {
const row = d.prepare(BASE_SELECT + ' AND fp.id=?').get(payload.id);
if (!row) throw new Error('记录不存在');
const attachments = d.prepare(
"SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=? AND deleted_at IS NULL ORDER BY id"
).all(payload.id);
return { item: { ...mapRow(row), attachments } };
}
case 'footprint_upsert': {
const it = payload.item;
if (!it.type_id) throw new Error('缺少记录类型');
if (!it.title || !it.title.trim()) throw new Error('缺少标题');
const search = buildSearchText(it.title.trim(), it.description, it.result, it.tags, it.extras);
const metrics = JSON.stringify(it.metrics || {});
const extras = JSON.stringify(it.extras || {});
const tags = JSON.stringify(it.tags || []);
const nowLocal = "datetime('now','localtime')";
let id = it.id;
withTx(d, () => {
if (id) {
const h = hlcNext(d);
d.prepare(`UPDATE footprint SET type_id=?, title=?, role=?, level=?, polarity=?, award_grade=?, rank=?,
organizer=?, start_date=?, end_date=?, status=?, description=?, result=?, metrics=?, extras=?,
group_id=?, parent_id=?, tags=?, weight=?, search_text=?, hlc=?, updated_at=${nowLocal} WHERE id=?`)
.run(it.type_id, it.title.trim(), it.role ?? null, it.level ?? null, it.polarity || 'positive',
it.award_grade ?? null, it.rank ?? null, it.organizer ?? null, it.start_date ?? null, it.end_date ?? null,
it.status || 'draft', it.description ?? '', it.result ?? '', metrics, extras,
it.group_id ?? null, it.parent_id ?? null, tags, it.weight ?? 3, search, h, id);
touchOplog(d, 'footprint', getUuid(d, 'footprint', id), 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare(`INSERT INTO footprint (type_id, title, role, level, polarity, award_grade, rank,
organizer, start_date, end_date, status, description, result, metrics, extras, group_id, parent_id,
tags, weight, search_text, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(it.type_id, it.title.trim(), it.role ?? null, it.level ?? null, it.polarity || 'positive',
it.award_grade ?? null, it.rank ?? null, it.organizer ?? null, it.start_date ?? null, it.end_date ?? null,
it.status || 'draft', it.description ?? '', it.result ?? '', metrics, extras,
it.group_id ?? null, it.parent_id ?? null, tags, it.weight ?? 3, search, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'footprint', uuid, 'upsert', h);
}
if (Array.isArray(it.dimension_ids)) {
d.prepare('DELETE FROM footprint_dimension WHERE footprint_id=?').run(id);
const ins = d.prepare('INSERT OR IGNORE INTO footprint_dimension (footprint_id, dimension_id) VALUES (?,?)');
for (const dim of it.dimension_ids) ins.run(id, dim);
}
});
return { id };
}
case 'footprint_delete': {
const row = d.prepare('SELECT uuid FROM footprint WHERE id=?').get(payload.id);
if (!row) return { ok: true };
withTx(d, () => {
// FTS 内容表需同步移除:先物理删除 FTS 行,墓碑保留在主表
d.prepare("INSERT INTO footprint_fts(footprint_fts, rowid, search_text) SELECT 'delete', id, search_text FROM footprint WHERE id=?")
.run(payload.id);
softDelete(d, 'footprint', payload.id);
});
return { ok: true };
}
case 'timeline': {
const res = listFootprints({ ...(payload.query || {}), page_size: 2000, sort: 'date_desc' });
const groups = [];
const byYear = new Map();
for (const row of res.items) {
const date = (row.start_date && row.start_date.slice(0, 10)) || row.created_at.slice(0, 10);
const year = Number(date.slice(0, 4));
const month = Number(date.slice(5, 7));
if (!byYear.has(year)) {
const g = { year, count: 0, months: [] };
byYear.set(year, g);
groups.push(g);
}
const g = byYear.get(year);
g.count++;
let m = g.months.find(x => x.month === month);
if (!m) { m = { month, items: [] }; g.months.push(m); }
m.items.push(row);
}
groups.sort((a, b) => b.year - a.year);
for (const g of groups) g.months.sort((a, b) => a.month - b.month);
return { groups, total: res.total };
}
case 'stats_overview': {
const year = payload.year || null;
const yearCond = year
? " AND strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = '" + String(year).replace(/[^0-9]/g, '') + "'"
: '';
const yearCondRaw = year
? " AND strftime('%Y', COALESCE(NULLIF(start_date,''), substr(created_at,1,10))) = '" + String(year).replace(/[^0-9]/g, '') + "'"
: '';
const DEL = 'fp.deleted_at IS NULL';
const DEL_RAW = 'deleted_at IS NULL';
const total = d.prepare('SELECT COUNT(*) AS c FROM footprint fp WHERE ' + DEL + yearCond).get().c;
const curYear = year || new Date().getFullYear();
const totalThisYear = d.prepare(
"SELECT COUNT(*) AS c FROM footprint fp WHERE strftime('%Y', COALESCE(NULLIF(fp.start_date,''), substr(fp.created_at,1,10))) = ?"
).get(String(curYear)).c;
const attTotal = d.prepare(
"SELECT COUNT(*) AS c FROM attachment a JOIN footprint fp ON fp.id = a.owner_id AND a.owner_type='footprint' WHERE a.deleted_at IS NULL AND " + DEL + yearCond
).get().c;
const byType = d.prepare(`SELECT ft.name AS label, ft.color, COUNT(*) AS count
FROM footprint fp JOIN footprint_type ft ON ft.id=fp.type_id WHERE ${DEL}${yearCond}
GROUP BY fp.type_id ORDER BY count DESC`).all();
const byLevel = d.prepare(`SELECT COALESCE(fp.level,'未设置') AS label, COUNT(*) AS count
FROM footprint fp WHERE ${DEL}${yearCond} GROUP BY fp.level ORDER BY count DESC`).all();
const byRole = d.prepare(`SELECT COALESCE(fp.role,'未设置') AS label, COUNT(*) AS count
FROM footprint fp WHERE ${DEL}${yearCond} GROUP BY fp.role ORDER BY count DESC`).all();
const byStatus = d.prepare(`SELECT fp.status AS label, COUNT(*) AS count
FROM footprint fp WHERE ${DEL}${yearCond} GROUP BY fp.status`).all();
const monthlyRows = d.prepare(`SELECT substr(COALESCE(NULLIF(start_date,''), substr(created_at,1,10)),1,7) AS ym, COUNT(*) AS count
FROM footprint WHERE ${DEL_RAW}${yearCondRaw} GROUP BY ym ORDER BY ym`).all();
const monthlyMap = new Map(monthlyRows.map(r => [r.ym, r.count]));
const monthly = [];
const now = new Date();
for (let i = 11; i >= 0; i--) {
const dt = new Date(now.getFullYear(), now.getMonth() - i, 1);
const ym = dt.getFullYear() + '-' + String(dt.getMonth() + 1).padStart(2, '0');
monthly.push({ ym, count: monthlyMap.get(ym) ?? 0 });
}
const metrics = [];
for (const m of d.prepare('SELECT * FROM metric_def WHERE deleted_at IS NULL').all()) {
const path = '$."' + m.key + '"';
let value = 0;
if (m.agg === 'sum') {
const r = d.prepare(`SELECT SUM(CAST(json_extract(fp.metrics, ?) AS REAL)) AS v
FROM footprint fp WHERE json_extract(fp.metrics, ?) IS NOT NULL AND ${DEL}${yearCond}`).get(path, path);
value = r.v ?? 0;
} else if (m.agg === 'count') {
value = d.prepare(`SELECT COUNT(*) AS c FROM footprint fp WHERE json_extract(fp.metrics, ?) IS NOT NULL AND ${DEL}${yearCond}`).get(path).c;
} else if (m.agg === 'distinct') {
value = d.prepare(`SELECT COUNT(DISTINCT json_extract(fp.metrics, ?)) AS c FROM footprint fp WHERE json_extract(fp.metrics, ?) IS NOT NULL AND ${DEL}${yearCond}`).get(path, path).c;
}
metrics.push({ key: m.key, label: m.label, unit: m.unit, agg: m.agg, value });
}
const years = d.prepare(`SELECT DISTINCT CAST(strftime('%Y', COALESCE(NULLIF(start_date,''), substr(created_at,1,10))) AS INTEGER) AS y
FROM footprint WHERE ${DEL_RAW} ORDER BY y DESC`).all().map(r => r.y);
return {
total, total_this_year: totalThisYear, attachment_total: attTotal,
by_type: byType, by_level: byLevel, by_role: byRole, by_status: byStatus,
monthly, metrics, years
};
}
case 'metric_list':
return { items: d.prepare('SELECT * FROM metric_def WHERE deleted_at IS NULL ORDER BY id').all() };
case 'metric_upsert': {
const p = payload;
const exist = d.prepare('SELECT id, uuid FROM metric_def WHERE key=? AND deleted_at IS NULL').get(p.key);
withTx(d, () => {
if (exist) {
const h = hlcNext(d);
d.prepare('UPDATE metric_def SET label=?, unit=?, agg=?, section=?, hlc=? WHERE id=?')
.run(p.label, p.unit ?? null, p.agg || 'sum', p.section ?? null, h, exist.id);
touchOplog(d, 'metric_def', exist.uuid, 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
d.prepare('INSERT INTO metric_def (key, label, unit, agg, section, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
.run(p.key, p.label, p.unit ?? null, p.agg || 'sum', p.section ?? null, uuid, h, getDeviceId(d));
touchOplog(d, 'metric_def', uuid, 'upsert', h);
}
});
return { item: p };
}
case 'metric_delete': {
const row = d.prepare('SELECT id FROM metric_def WHERE key=?').get(payload.key);
if (row) withTx(d, () => softDelete(d, 'metric_def', row.id));
return { ok: true };
}
// ---------- 学年学科成绩 ----------
case 'grades_overview': {
const DEL = 'deleted_at IS NULL';
const bands = d.prepare('SELECT * FROM grade_band WHERE ' + DEL + ' ORDER BY sort, id').all();
const bandOf = (score) => {
if (score == null) return null;
let hit = bands.find(b => score >= b.min && score < b.max);
if (!hit) hit = bands.find(b => score >= b.min && score <= b.max);
return hit ? { id: hit.id, label: hit.label, color: hit.color } : null;
};
const semesters = d.prepare('SELECT * FROM semester WHERE ' + DEL + ' ORDER BY term DESC').all();
const activeSemIds = new Set(semesters.map(s => s.id));
const courses = d.prepare('SELECT * FROM course_grade WHERE ' + DEL + ' ORDER BY id').all()
.filter(c => activeSemIds.has(c.semester_id));
for (const s of semesters) {
s.courses = courses.filter(c => c.semester_id === s.id).map(c => ({ ...c, band: bandOf(c.score) }));
const scored = s.courses.filter(c => c.score != null);
const creditsAll = s.courses.reduce((a, c) => a + (c.credit || 0), 0);
const creditsScored = scored.reduce((a, c) => a + (c.credit || 0), 0);
s.stats = {
credits: Math.round(creditsAll * 10) / 10,
courses: s.courses.length,
avg: creditsScored > 0 ? Math.round(scored.reduce((a, c) => a + c.score * c.credit, 0) / creditsScored * 100) / 100 : null
};
}
const allScored = courses.filter(c => c.score != null);
const creditsAll = allScored.reduce((a, c) => a + (c.credit || 0), 0);
const overallAvg = creditsAll > 0
? Math.round(allScored.reduce((a, c) => a + c.score * c.credit, 0) / creditsAll * 100) / 100
: null;
const tierDist = bands.map(b => ({
label: b.label, color: b.color,
count: allScored.filter(c => { const x = bandOf(c.score); return x && x.id === b.id; }).length
}));
const gpaTrend = [...semesters]
.filter(s => s.gpa != null)
.sort((a, b) => a.term.localeCompare(b.term))
.map(s => ({ term: s.term, gpa: s.gpa, avg: s.stats.avg }));
return {
semesters,
bands,
overall: {
avg: overallAvg,
credits: Math.round(courses.reduce((a, c) => a + (c.credit || 0), 0) * 10) / 10,
courses: courses.length,
gpa_trend: gpaTrend,
tier_dist: tierDist
}
};
}
case 'semester_upsert': {
const p = payload;
if (!p.term || !String(p.term).trim()) throw new Error('缺少学期名称');
const term = String(p.term).trim();
let id = p.id;
withTx(d, () => {
if (id) {
const dup = d.prepare('SELECT id FROM semester WHERE term=? AND deleted_at IS NULL AND id != ?').get(term, id);
if (dup) throw new Error('学期已存在:' + term);
const h = hlcNext(d);
d.prepare('UPDATE semester SET term=?, gpa=?, rank=?, total_students=?, hlc=? WHERE id=?')
.run(term, p.gpa ?? null, p.rank ?? null, p.total_students ?? null, h, id);
touchOplog(d, 'semester', getUuid(d, 'semester', id), 'upsert', h);
} else {
const dup = d.prepare('SELECT 1 FROM semester WHERE term=? AND deleted_at IS NULL').get(term);
if (dup) throw new Error('学期已存在:' + term);
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO semester (term, gpa, rank, total_students, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)')
.run(term, p.gpa ?? null, p.rank ?? null, p.total_students ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'semester', uuid, 'upsert', h);
}
});
return { item: { id } };
}
case 'semester_delete': {
withTx(d, () => {
softDelete(d, 'semester', payload.id);
// 级联墓碑该学期下全部课程,避免孤儿课程污染总览
const courses = d.prepare('SELECT id FROM course_grade WHERE semester_id=? AND deleted_at IS NULL').all(payload.id);
for (const c of courses) softDelete(d, 'course_grade', c.id);
});
return { ok: true };
}
case 'course_upsert': {
const p = payload;
if (!p.semester_id) throw new Error('缺少学期');
if (!p.course_name || !String(p.course_name).trim()) throw new Error('缺少课程名');
if (p.score != null && (p.score < 0 || p.score > 100)) throw new Error('分数需在 0-100 之间');
if (p.grade_point != null && p.grade_point < 0) throw new Error('绩点不能为负');
const name = String(p.course_name).trim();
let id = p.id;
withTx(d, () => {
if (id) {
const h = hlcNext(d);
d.prepare('UPDATE course_grade SET semester_id=?, course_name=?, credit=?, score=?, grade_point=?, hlc=? WHERE id=?')
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, h, id);
touchOplog(d, 'course_grade', getUuid(d, 'course_grade', id), 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'course_grade', uuid, 'upsert', h);
}
});
return { item: { id } };
}
case 'course_delete': {
withTx(d, () => softDelete(d, 'course_grade', payload.id));
return { ok: true };
}
case 'band_upsert': {
const p = payload;
if (!p.label || !String(p.label).trim()) throw new Error('缺少等级名称');
if (p.min == null || p.max == null || p.min >= p.max) throw new Error('分数区间不合法(需 min < max)');
const label = String(p.label).trim();
let id = p.id;
withTx(d, () => {
if (id) {
const h = hlcNext(d);
d.prepare('UPDATE grade_band SET label=?, color=?, min=?, max=?, sort=?, hlc=? WHERE id=?')
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, h, id);
touchOplog(d, 'grade_band', getUuid(d, 'grade_band', id), 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO grade_band (label, color, min, max, sort, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'grade_band', uuid, 'upsert', h);
}
});
return { item: { id } };
}
case 'band_delete': {
withTx(d, () => softDelete(d, 'grade_band', payload.id));
return { ok: true };
}
case 'attachment_add': {
const p = payload;
const buf = Buffer.from(p.data_b64 || '', 'base64');
const sha = crypto.createHash('sha256').update(buf).digest('hex');
const safe = p.file_name.replace(/[\\/:*?"<>|]/g, '_');
const rel = path.join('attachments', String(p.owner_id), crypto.randomUUID() + '_' + safe);
const abs = path.join(DATA_DIR, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, buf);
const uuid = uuidv7();
let item = null;
withTx(d, () => {
const h = hlcNext(d);
const r = d.prepare(
"INSERT INTO attachment (owner_type, owner_id, kind, file_name, mime, size, rel_path, sha256, uuid, hlc, origin) VALUES ('footprint',?,?,?,?,?,?,?,?,?,?)"
).run(p.owner_id, null, p.file_name, p.mime ?? null, buf.length, rel.split(path.sep).join('/'), sha, uuid, h, getDeviceId(d));
item = d.prepare('SELECT * FROM attachment WHERE id=?').get(Number(r.lastInsertRowid));
touchOplog(d, 'attachment', uuid, 'upsert', h);
});
return { item };
}
case 'attachment_list':
return {
items: d.prepare(
"SELECT * FROM attachment WHERE owner_type='footprint' AND owner_id=? AND deleted_at IS NULL ORDER BY id"
).all(payload.owner_id)
};
case 'attachment_delete': {
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (row) {
withTx(d, () => softDelete(d, 'attachment', payload.id));
const abs = path.join(DATA_DIR, row.rel_path);
try { fs.unlinkSync(abs); } catch {}
}
return { ok: true };
}
case 'attachment_read': {
const row = d.prepare('SELECT * FROM attachment WHERE id=?').get(payload.id);
if (!row) throw new Error('附件不存在');
const data = fs.readFileSync(path.join(DATA_DIR, row.rel_path));
return { data_b64: data.toString('base64'), mime: row.mime, file_name: row.file_name };
}
case 'view_list':
return { items: d.prepare('SELECT * FROM saved_view WHERE deleted_at IS NULL ORDER BY id').all().map(v => ({
...v, filter: JSON.parse(v.filter || '{}'), sort: JSON.parse(v.sort || '{}'), is_default: !!v.is_default
})) };
case 'view_upsert': {
const p = payload;
let id = p.id;
withTx(d, () => {
if (id) {
const h = hlcNext(d);
d.prepare("UPDATE saved_view SET name=?, filter=?, sort=?, is_default=?, hlc=?, updated_at=datetime('now','localtime') WHERE id=?")
.run(p.name, JSON.stringify(p.filter || {}), JSON.stringify(p.sort || {}), p.is_default ? 1 : 0, h, id);
touchOplog(d, 'saved_view', getUuid(d, 'saved_view', id), 'upsert', h);
} else {
const h = hlcNext(d);
const uuid = uuidv7();
id = Number(d.prepare('INSERT INTO saved_view (name, filter, sort, is_default, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)')
.run(p.name, JSON.stringify(p.filter || {}), JSON.stringify(p.sort || {}), p.is_default ? 1 : 0, uuid, h, getDeviceId(d)).lastInsertRowid);
touchOplog(d, 'saved_view', uuid, 'upsert', h);
}
});
return { item: { id } };
}
case 'view_delete': {
const row = d.prepare('SELECT id FROM saved_view WHERE id=?').get(payload.id);
if (row) withTx(d, () => softDelete(d, 'saved_view', row.id));
return { ok: true };
}
default:
throw new Error('未知命令:' + cmd);
}
}
+74
View File
@@ -0,0 +1,74 @@
// 开发桥接服务:以 HTTP 形式暴露与 Tauri IPC 相同的命令契约
// 浏览器预览模式使用;打包为桌面应用后由 Rust 后端接管同一契约
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { handleCommand, DATA_DIR } from './db.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DIST = path.join(__dirname, '..', 'dist');
const PORT = Number(process.env.FP_PORT || process.env.PORT || 8787);
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json'
};
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
if (req.method === 'POST' && url.pathname.startsWith('/api/')) {
const cmd = url.pathname.slice(5);
let body = '';
let size = 0;
req.on('data', chunk => {
size += chunk.length;
if (size > 160 * 1024 * 1024) { req.destroy(); } // 与 sync-server 对齐
else body += chunk;
});
req.on('end', () => {
let payload = {};
try { payload = body ? JSON.parse(body) : {}; } catch {}
try {
const data = handleCommand(cmd, payload);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: true, data }));
} catch (e) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: e.message || String(e) }));
}
});
return;
}
// 静态托管 dist(若已构建),便于纯浏览器验收
if (req.method === 'GET' && fs.existsSync(DIST)) {
let p = url.pathname === '/' ? '/index.html' : url.pathname;
const abs = path.join(DIST, p);
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
res.writeHead(200, { 'Content-Type': MIME[path.extname(abs)] ?? 'application/octet-stream' });
res.end(fs.readFileSync(abs));
return;
}
const indexHtml = path.join(DIST, 'index.html');
if (fs.existsSync(indexHtml)) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(indexHtml));
return;
}
}
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: 'not found' }));
});
server.listen(PORT, '127.0.0.1', () => {
console.log('[footprints-bridge] listening on http://127.0.0.1:' + PORT);
console.log('[footprints-bridge] data dir:', DATA_DIR);
});
+120
View File
@@ -0,0 +1,120 @@
// 演示数据:仅当足迹表为空时写入,用于界面验收
import { getDb, buildSearchText, migrateSync, uuidv7, hlcNext } from './db.mjs';
const db = getDb();
const count = db.prepare('SELECT COUNT(*) AS c FROM footprint').get().c;
if (count > 0) {
console.log('已存在 ' + count + ' 条记录,跳过演示数据(如需重建请删除 app/.data 目录)');
process.exit(0);
}
const types = Object.fromEntries(
db.prepare('SELECT id, name FROM footprint_type').all().map(r => [r.name, r.id])
);
const dims = Object.fromEntries(
db.prepare('SELECT id, name FROM dimension').all().map(r => [r.name, r.id])
);
// [type, title, role, level, grade, organizer, start, end, status, desc, result, tags, dims, extras, metrics]
const rows = [
['竞赛', '2025 全国大学生数学建模竞赛', '主导', '国家级', '省市级二等奖', '中国工业与应用数学学会', '2025-09-05', '2025-09-08', 'finished',
'担任队长,负责模型建立与论文主笔;协调三人分工与时间安排', '重庆赛区二等奖', ['建模', '团队协作'], ['学业深造'],
{ my_work: '模型建立与论文主笔', deliverable: '参赛论文《基于优化模型的共享单车调度研究》', team_members: '张三、李四、王五' }, {}],
['竞赛', '第十六届蓝桥杯程序设计大赛', '参加', '省市级', '省市级二等奖', '工业和信息化部人才交流中心', '2025-04-12', '2025-06-20', 'finished',
'备战算法与数据结构,完成省赛', '重庆赛区 C/C++ B 组二等奖', ['算法'], ['职业发展', '学业深造'],
{ my_work: '独立参赛', deliverable: '省赛获奖证书' }, {}],
['竞赛', '全国大学生信息安全竞赛', '核心', '国家级', null, '教育部高等学校网络空间安全专业教学指导委员会', '2025-05-01', '2025-08-15', 'submitted',
'负责 Web 漏洞挖掘与利用脚本编写,参与作品报告撰写', '已提交作品,等待国赛评审', ['安全', 'CTF'], ['职业发展'],
{ my_work: 'Web 漏洞挖掘与利用脚本编写', deliverable: '参赛作品与报告' }, {}],
['志愿公益', '校图书馆迎新志愿服务', '参加', '校级', null, '校图书馆', '2025-09-01', '2025-09-05', 'finished',
'新生入馆引导与借阅咨询,共服务 5 天', '获馆方感谢信', ['志愿'], ['综合素养'],
{ hours: 24, contact: '李老师' }, { volunteer_hours: 24 }],
['志愿公益', '社区敬老院慰问活动', '参加', '区县级', null, '所在社区居委会', '2025-03-15', '2025-03-15', 'finished',
'陪伴老人、协助整理活动室', '', ['志愿', '公益'], ['综合素养'],
{ hours: 6 }, { volunteer_hours: 6 }],
['学生工作', '院学生会宣传部干事', '参加', '院级', null, '院学生会', '2024-09-01', '2025-06-30', 'finished',
'负责活动推文撰写与排版,全年产出推文 20 篇', '获优秀干事', ['宣传'], ['综合素养'],
{ duties: '推文撰写与排版、活动现场摄影', term: '2024.09 - 2025.06' }, {}],
['学生工作', '院学生会宣传部副部长', '主导', '院级', null, '院学生会', '2025-09-01', null, 'ongoing',
'统筹部门工作,管理 8 名干事,主导院级活动宣传方案', '', ['管理'], ['综合素养', '职业发展'],
{ duties: '部门统筹、活动宣传方案设计', term: '2025.09 - 至今' }, {}],
['荣誉惩处', '校级二等奖学金', '参加', '校级', null, '重庆城市科技学院', '2025-10-15', '2025-10-15', 'finished',
'2024-2025 学年综合测评前列', '二等奖学金', ['奖学金'], ['学业深造'],
{ issued_by: '重庆城市科技学院' }, {}],
['荣誉惩处', '优秀共青团员', '参加', '校级', null, '校团委', '2025-05-04', '2025-05-04', 'finished',
'五四表彰', '优秀共青团员', ['表彰'], ['综合素养'],
{ issued_by: '共青团重庆城市科技学院委员会' }, {}],
['证书技能', '大学英语六级', '参加', '国家级', null, '教育部教育考试院', '2025-06-14', '2025-08-20', 'finished',
'笔试 528 分', 'CET-6 528 分', ['英语'], ['学业深造', '职业发展'],
{ cert_no: '510001251200123', score: '528' }, {}],
['证书技能', '计算机技术与软件专业技术资格(中级)', '参加', '国家级', null, '工业和信息化部', '2025-11-08', null, 'ongoing',
'备考软件设计师', '', ['软考'], ['职业发展'],
{ score: '' }, {}],
['学业', '2024-2025 学年秋季学期', '参加', null, null, null, '2024-09-01', '2025-01-15', 'finished',
'学期绩点 3.72,专业排名 12/156', '', ['绩点'], ['学业深造'],
{ semester_label: '2024-2025-1' }, {}],
['学业', '2024-2025 学年春季学期', '参加', null, null, null, '2025-02-24', '2025-07-05', 'finished',
'学期绩点 3.85,专业排名 9/156', '', ['绩点'], ['学业深造'],
{ semester_label: '2024-2025-2' }, {}],
['文体活动', '校运动会开幕式方阵', '参加', '校级', null, '校体育运动委员会', '2025-04-18', '2025-04-20', 'finished',
'学院方阵队员,参与排练与开幕式表演', '', ['文体'], ['兴趣生活'],
{ participation: '方阵队员' }, {}],
['科研', '大学生创新创业训练计划项目', '主导', '校级', null, '校创新创业学院', '2025-03-01', null, 'ongoing',
'主持校级大创项目「基于流量特征的入侵检测研究」,带 2 名成员', '中期检查通过', ['科研', '安全'], ['学业深造', '职业发展'],
{ authors: '主持人', outcome: '研究报告' }, {}],
['实习实践', '某安全公司渗透测试实习生', '参加', null, null, '某网络安全科技有限公司', '2025-07-01', '2025-08-25', 'finished',
'参与企业授权渗透测试项目 3 个,编写测试报告', '获实习优秀评价', ['实习', '安全'], ['职业发展'],
{ position: '渗透测试实习生', mentor: '陈工' }, {}]
];
const ins = db.prepare(`INSERT INTO footprint (type_id, title, role, level, award_grade, organizer,
start_date, end_date, status, description, result, tags, search_text, extras, metrics)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
const setDim = db.prepare('INSERT OR IGNORE INTO footprint_dimension (footprint_id, dimension_id) VALUES (?,?)');
for (const [t, title, role, level, grade, organizer, start, end, status, desc, result, tags, dimNames, extras, metrics] of rows) {
const search = buildSearchText(title, desc, result, tags, extras);
const r = ins.run(types[t], title, role, level, grade, organizer, start, end, status, desc, result,
JSON.stringify(tags), search, JSON.stringify(extras), JSON.stringify(metrics));
const id = Number(r.lastInsertRowid);
for (const name of dimNames) {
if (dims[name]) setDim.run(id, dims[name]);
}
}
console.log('已写入 ' + rows.length + ' 条演示记录');
// ---------- 学年学科成绩演示 ----------
const semIns = db.prepare('INSERT INTO semester (term, gpa, rank, total_students, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)');
const couIns = db.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)');
const semData = [
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
['高等数学(上)', 5, 78, 3.0], ['大学英语(一)', 3, 85, 3.7], ['程序设计基础', 4, 91, 4.0],
['思想道德与法治', 2, 88, 3.9], ['体育(一)', 1, 82, 3.5]
]},
{ term: '2023-2024-2', gpa: 3.61, rank: 29, total: 156, courses: [
['高等数学(下)', 5, 84, 3.7], ['线性代数', 3, 76, 2.9], ['数据结构', 4, 90, 4.0],
['大学英语(二)', 3, 87, 3.8], ['中国近现代史纲要', 2, 84, 3.5], ['体育(二)', 1, 86, 3.8]
]},
{ term: '2024-2025-1', gpa: 3.78, rank: 18, total: 156, courses: [
['概率论与数理统计', 3, 88, 3.9], ['操作系统', 4, 92, 4.0], ['计算机网络', 4, 86, 3.8],
['数据库原理', 3, 94, 4.0], ['马克思主义基本原理', 2, 81, 3.3]
]},
{ term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [
['软件工程', 3, 90, 4.0], ['信息安全导论', 3, 95, 4.0], ['密码学基础', 3, 89, 3.9],
['Web 应用开发', 2, 96, 4.0], ['数字逻辑', 3, 79, 3.1], ['体育(四)', 1, 90, 4.0]
]}
];
for (const s of semData) {
const h1 = hlcNext(db);
const sid = Number(semIns.run(s.term, s.gpa, s.rank, s.total, uuidv7(), h1, 'seed').lastInsertRowid);
for (const [name, credit, score, gp] of s.courses) {
couIns.run(sid, name, credit, score, gp, uuidv7(), hlcNext(db), 'seed');
}
}
console.log('已写入 ' + semData.length + ' 个学期、' + semData.reduce((a, s) => a + s.courses.length, 0) + ' 门课程成绩');
migrateSync(getDb());
console.log('同步元数据(uuid/hlc/origin)已回填');
+4413
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "footprints"
version = "0.1.0"
description = "个人留痕系统 Footprints"
edition = "2021"
[lib]
name = "footprints_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
base64 = "0.22"
uuid = { version = "1", features = ["v7"] }
sha2 = "0.10"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
footprints_lib::run();
}
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Footprints",
"version": "0.1.0",
"identifier": "com.footprints.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "",
"beforeBuildCommand": ""
},
"app": {
"windows": [
{
"title": "足迹 Footprints",
"width": 1280,
"height": 800,
"minWidth": 960,
"minHeight": 640,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": [
"nsis"
],
"icon": [
"icons/icon.ico",
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png"
]
}
}
+32
View File
@@ -0,0 +1,32 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from '@/components/layout/AppShell';
import { TimelinePage } from '@/pages/timeline/TimelinePage';
// 非首屏页面懒加载:echarts 等重依赖仅在进入对应路由时拉取
const RecordsPage = lazy(() => import('@/pages/records/RecordsPage').then(m => ({ default: m.RecordsPage })));
const DashboardPage = lazy(() => import('@/pages/dashboard/DashboardPage').then(m => ({ default: m.DashboardPage })));
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 })));
function PageLoading() {
return <div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>;
}
export default function App() {
return (
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/timeline" replace />} />
<Route path="/timeline" element={<TimelinePage />} />
<Route path="/records" element={<Suspense fallback={<PageLoading />}><RecordsPage /></Suspense>} />
<Route path="/grades" element={<Suspense fallback={<PageLoading />}><GradesPage /></Suspense>} />
<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="*" element={<Navigate to="/timeline" replace />} />
</Route>
</Routes>
);
}
@@ -0,0 +1,103 @@
import { useRef } from 'react';
import { Paperclip, FileText, Image, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui';
import { fmtSize } from '@/lib/format';
import type { Attachment } from '@/lib/types';
export interface PendingAtt { file_name: string; mime?: string; size: number; data_b64: string; preview?: string; }
export function AttachmentPicker({ items, pending, onPick, onDeleteSaved, onDeletePending, onOpenSaved, uploading }: {
items: Attachment[];
pending: PendingAtt[];
onPick: (files: FileList) => void;
onDeleteSaved: (id: number) => void;
onDeletePending: (index: number) => void;
onOpenSaved?: (a: Attachment) => void;
uploading?: boolean;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const total = items.length + pending.length;
return (
<div className="rounded-md border border-line bg-surface-2 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[12px] font-medium text-ink-2">
<Paperclip size={13} />
附件({total})
</div>
<Button size="sm" variant="secondary" disabled={uploading} onClick={() => inputRef.current?.click()}>
{uploading ? '上传中…' : '添加文件'}
</Button>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={e => {
if (e.target.files?.length) onPick(e.target.files);
e.target.value = '';
}}
/>
</div>
<div className="flex flex-col gap-1">
{items.map(a => (
<div key={'s' + a.id} className="flex items-center gap-2 rounded-sm bg-surface px-2 py-1.5 text-[12px]">
<FileText size={13} className="text-ink-3 shrink-0" />
<span
className={'truncate ' + (onOpenSaved ? 'cursor-pointer text-ink-1 hover:text-brand-text hover:underline' : 'text-ink-1')}
onClick={() => onOpenSaved?.(a)}
title={a.file_name}
>
{a.file_name}
</span>
<span className="num ml-auto shrink-0 text-ink-3">{fmtSize(a.size)}</span>
<button className="text-ink-3 hover:text-danger shrink-0" onClick={() => onDeleteSaved(a.id)} aria-label="删除附件">
<Trash2 size={13} />
</button>
</div>
))}
{pending.map((p, i) => (
<div key={'p' + i} className="flex items-center gap-2 rounded-sm bg-surface px-2 py-1.5 text-[12px]">
{p.mime?.startsWith('image/') && p.preview ? (
<img
src={p.preview}
alt={p.file_name}
className="size-7 shrink-0 rounded object-cover"
/>
) : (
<Image size={13} className="text-ink-3 shrink-0" />
)}
<span className="truncate text-ink-1">{p.file_name}</span>
<span className="text-[11px] text-brand-text">待上传</span>
<span className="num ml-auto shrink-0 text-ink-3">{fmtSize(p.size)}</span>
<button className="text-ink-3 hover:text-danger shrink-0" onClick={() => onDeletePending(i)} aria-label="移除">
<Trash2 size={13} />
</button>
</div>
))}
{!total && <div className="py-1 text-[12px] text-ink-3">证书扫描件、照片、文档等佐证材料</div>}
</div>
</div>
);
}
export function fileToPending(file: File): Promise<PendingAtt> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || '');
const comma = result.indexOf(',');
// 图片用 objectURL 做轻量预览(不上 Base64 进 DOM)
const preview = file.type?.startsWith('image/') ? URL.createObjectURL(file) : undefined;
resolve({
file_name: file.name,
mime: file.type || undefined,
size: file.size,
data_b64: comma >= 0 ? result.slice(comma + 1) : result,
preview
});
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsDataURL(file);
});
}
+118
View File
@@ -0,0 +1,118 @@
import type { FormField } from '@/lib/types';
import { Input, Textarea, Select, Field, type Option } from '@/components/ui';
export type TaxonomyOptionsMap = Record<string, Option[]>;
function resolveOptions(f: FormField, taxonomies: TaxonomyOptionsMap): Option[] {
if (!f.options) return [];
if (Array.isArray(f.options)) return f.options.map(v => ({ value: v, label: v }));
return taxonomies[f.options.taxonomy] ?? [];
}
export function DynamicForm({ fields, value, onChange, taxonomies }: {
fields: FormField[];
value: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
taxonomies: TaxonomyOptionsMap;
}) {
if (!fields.length) return null;
const set = (key: string, v: unknown) => onChange({ ...value, [key]: v });
const renderField = (f: FormField) => {
const v = value[f.key];
const commonError = f.required && (v === undefined || v === null || v === '') ? '该项必填' : '';
const withUnit = (node: React.ReactNode) =>
f.unit ? (
<div className="flex items-center gap-2">
{node}
<span className="text-[12px] text-ink-3 shrink-0">{f.unit}</span>
</div>
) : node;
switch (f.widget) {
case 'textarea':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<Textarea value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
</Field>
);
case 'number':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
{withUnit(
<Input type="number" step="any" className="num" value={v === undefined || v === null ? '' : String(v)}
onChange={e => set(f.key, e.target.value === '' ? undefined : Number(e.target.value))} />
)}
</Field>
);
case 'date':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<Input type="date" value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} />
</Field>
);
case 'select':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<Select
value={String(v ?? '')}
onChange={e => set(f.key, e.target.value || undefined)}
options={[{ value: '', label: '请选择' }, ...resolveOptions(f, taxonomies)]}
/>
</Field>
);
case 'multiselect': {
const opts = resolveOptions(f, taxonomies);
const arr = Array.isArray(v) ? (v as string[]) : [];
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<div className="flex flex-wrap gap-1.5">
{opts.map(o => {
const active = arr.includes(o.value);
return (
<button
key={o.value}
type="button"
className={
'rounded-sm border px-2 py-0.5 text-[12px] transition-colors ' +
(active
? 'border-brand bg-brand-subtle text-brand-text'
: 'border-line bg-surface text-ink-2 hover:border-line-strong')
}
onClick={() => set(f.key, active ? arr.filter(x => x !== o.value) : [...arr, o.value])}
>
{o.label}
</button>
);
})}
{!opts.length && <span className="text-[12px] text-ink-3">未配置选项</span>}
</div>
</Field>
);
}
case 'link':
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError || '关联记录功能规划中,暂以文本记录'}>
<Input value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
</Field>
);
default:
return (
<Field key={f.key} label={f.label} required={f.required} hint={commonError}>
<Input value={String(v ?? '')} onChange={e => set(f.key, e.target.value)} placeholder={f.placeholder} />
</Field>
);
}
};
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
{fields.map(f => (
<div key={f.key} className={f.widget === 'textarea' ? 'col-span-2' : ''}>
{renderField(f)}
</div>
))}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useState, type KeyboardEvent } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
export function TagInput({ value, onChange, placeholder }: {
value: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
const commit = () => {
const t = draft.trim();
if (t && !value.includes(t)) onChange([...value, t]);
setDraft('');
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commit();
} else if (e.key === 'Backspace' && !draft && value.length) {
onChange(value.slice(0, -1));
}
};
return (
<div
className={cn(
'flex min-h-8 w-full flex-wrap items-center gap-1 rounded-md border border-line bg-surface px-1.5 py-1',
'transition-colors hover:border-line-strong focus-within:border-brand'
)}
>
{value.map(tag => (
<span key={tag} className="inline-flex items-center gap-0.5 rounded-sm bg-surface-3 px-1.5 text-[12px] text-ink-2">
{tag}
<button
type="button"
className="text-ink-3 hover:text-ink-1"
onClick={() => onChange(value.filter(t => t !== tag))}
>
<X size={11} />
</button>
</span>
))}
<input
className="min-w-[80px] flex-1 border-0 bg-transparent text-[13px] outline-none placeholder:text-ink-3"
value={draft}
placeholder={value.length ? '' : (placeholder ?? '输入后回车添加')}
onChange={e => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={commit}
/>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { Badge, NeutralBadge } from '@/components/ui';
import type { FootprintRow } from '@/lib/types';
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
draft: { label: '草稿', color: 'var(--text-3)', bg: 'var(--surface-3)' },
ongoing: { label: '进行中', color: 'var(--brand-text)', bg: 'var(--brand-subtle)' },
submitted: { label: '已提交待果', color: 'var(--warning)', bg: 'var(--warning-subtle)' },
finished: { label: '已完结', color: 'var(--success)', bg: 'var(--success-subtle)' }
};
export function StatusBadge({ status }: { status: string }) {
const s = STATUS_MAP[status] ?? STATUS_MAP.draft;
return (
<span
className="inline-flex items-center rounded-sm px-1.5 py-px text-[11px] leading-[18px] font-medium whitespace-nowrap"
style={{ color: s.color, backgroundColor: s.bg }}
>
{s.label}
</span>
);
}
const LEVEL_COLORS: Record<string, string> = {
'国际级': '#7c3aed',
'国家级': '#dc2626',
'省市级': '#ea580c',
'区县级': '#2563eb',
'校级': '#16a34a',
'院级': '#0891b2'
};
export function LevelBadge({ level }: { level?: string | null }) {
if (!level) return null;
return <Badge color={LEVEL_COLORS[level] ?? 'var(--text-3)'}>{level}</Badge>;
}
const GRADE_COLORS: Record<string, string> = {
'特等奖': '#b45309',
'一等奖': '#b45309',
'二等奖': '#ea580c',
'三等奖': '#2563eb',
'优秀奖': '#64748b'
};
export function GradeBadge({ grade }: { grade?: string | null }) {
if (!grade) return null;
return <Badge color={GRADE_COLORS[grade] ?? 'var(--text-3)'}>{grade}</Badge>;
}
export function RoleBadge({ role }: { role?: string | null }) {
if (!role) return null;
return <NeutralBadge>{role}</NeutralBadge>;
}
export function NegativeBadge() {
return <Badge color="var(--danger)">负面</Badge>;
}
export function TypeBadge({ row }: { row: FootprintRow }) {
return (
<Badge color={row.type_color}>
{row.type_name}
</Badge>
);
}
+58
View File
@@ -0,0 +1,58 @@
import {
Tag, GraduationCap, Trophy, FlaskConical, Briefcase, Network, HeartHandshake,
Palette, Award, FileBadge, Star, ShieldAlert, BookOpen, Code2, Languages,
Mic2, PenLine, Rocket, Users, Building2, Dumbbell, type LucideIcon
} from 'lucide-react';
export const ICON_CHOICES: { name: string; label: string }[] = [
{ name: 'tag', label: '通用' },
{ name: 'graduation-cap', label: '学业' },
{ name: 'trophy', label: '竞赛' },
{ name: 'flask-conical', label: '科研' },
{ name: 'briefcase', label: '实习' },
{ name: 'network', label: '学生工作' },
{ name: 'heart-handshake', label: '志愿' },
{ name: 'palette', label: '文体' },
{ name: 'award', label: '荣誉' },
{ name: 'file-badge', label: '证书' },
{ name: 'star', label: '收藏' },
{ name: 'shield-alert', label: '惩处' },
{ name: 'book-open', label: '阅读' },
{ name: 'code-2', label: '技术' },
{ name: 'languages', label: '语言' },
{ name: 'mic-2', label: '文艺' },
{ name: 'pen-line', label: '发表' },
{ name: 'rocket', label: '项目' },
{ name: 'users', label: '团队' },
{ name: 'building-2', label: '组织' },
{ name: 'dumbbell', label: '体育' }
];
const MAP: Record<string, LucideIcon> = {
'tag': Tag,
'graduation-cap': GraduationCap,
'trophy': Trophy,
'flask-conical': FlaskConical,
'briefcase': Briefcase,
'network': Network,
'heart-handshake': HeartHandshake,
'palette': Palette,
'award': Award,
'file-badge': FileBadge,
'star': Star,
'shield-alert': ShieldAlert,
'book-open': BookOpen,
'code-2': Code2,
'languages': Languages,
'mic-2': Mic2,
'pen-line': PenLine,
'rocket': Rocket,
'users': Users,
'building-2': Building2,
'dumbbell': Dumbbell
};
export function TypeIcon({ name, color, size = 14, className }: { name: string; color?: string; size?: number; className?: string }) {
const Icon = MAP[name] ?? Tag;
return <Icon size={size} color={color} className={className} strokeWidth={2} />;
}
+26
View File
@@ -0,0 +1,26 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { ToastProvider } from '@/components/ui';
export function AppShell() {
const [navOpen, setNavOpen] = useState(false);
return (
<ToastProvider>
<div className="flex h-full">
{navOpen && (
<div className="fixed inset-0 z-30 bg-black/45 lg:hidden" onClick={() => setNavOpen(false)} aria-hidden />
)}
<Sidebar open={navOpen} onClose={() => setNavOpen(false)} />
<div className="flex min-w-0 flex-1 flex-col">
<Header onMenu={() => setNavOpen(true)} />
<main className="min-h-0 flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
</ToastProvider>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Search, Sun, Moon, Plus, Menu } from 'lucide-react';
import { Button, Input } from '@/components/ui';
const TITLES: Record<string, { title: string; sub?: string }> = {
'/timeline': { title: '时间线', sub: '按学年与月份回顾全部足迹' },
'/records': { title: '记录', sub: '全部足迹的检索与管理' },
'/grades': { title: '成绩', sub: '学年学科成绩 · 梯度与总览' },
'/dashboard': { title: '统计', sub: '量化个人成长收获' },
'/types': { title: '记录类型', sub: '类型与字段模板管理' },
'/taxonomy': { title: '字典与维度', sub: '级别、角色与成长维度' }
};
export function Header({ onMenu }: { onMenu?: () => void }) {
const { pathname } = useLocation();
const navigate = useNavigate();
const [q, setQ] = useState('');
const [dark, setDark] = useState(document.documentElement.classList.contains('dark'));
const meta = TITLES[pathname] ?? { title: '' };
const toggleTheme = () => {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle('dark', next);
localStorage.setItem('fp-theme', next ? 'dark' : 'light');
window.dispatchEvent(new CustomEvent('fp-theme-change'));
};
return (
<header className="flex h-14 shrink-0 items-center gap-4 border-b border-line bg-surface px-5">
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenu} aria-label="打开导航">
<Menu size={17} />
</Button>
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink-1">{meta.title}</h1>
{meta.sub && <p className="truncate text-[11px] leading-tight text-ink-3">{meta.sub}</p>}
</div>
<div className="relative ml-auto w-[280px]">
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3" />
<Input
className="pl-8"
placeholder="搜索标题、内容、结果、标签"
value={q}
onChange={e => setQ(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && q.trim()) {
navigate('/records?q=' + encodeURIComponent(q.trim()));
}
}}
/>
</div>
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label="切换主题">
{dark ? <Sun size={15} /> : <Moon size={15} />}
</Button>
<Button variant="primary" onClick={() => navigate('/records?new=1')}>
<Plus size={14} />
新建记录
</Button>
</header>
);
}
+99
View File
@@ -0,0 +1,99 @@
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 { cn } from '@/lib/utils';
const NAV = [
{
section: '工作区',
items: [
{ to: '/timeline', label: '时间线', icon: CalendarDays },
{ to: '/records', label: '记录', icon: Rows3 },
{ to: '/grades', label: '成绩', icon: GraduationCap },
{ to: '/dashboard', label: '统计', icon: ChartColumn }
]
},
{
section: '配置',
items: [
{ to: '/types', label: '记录类型', icon: Shapes },
{ to: '/taxonomy', label: '字典与维度', icon: SlidersHorizontal }
]
}
];
function LogoMark() {
return (
<svg width="26" height="26" 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 Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
const info = useQuery({ queryKey: ['app-info'], queryFn: appInfo, staleTime: Infinity });
const sync = useQuery({ queryKey: ['sync-status'], queryFn: syncStatus, refetchInterval: 15000 });
return (
<aside
className={
'flex w-[224px] shrink-0 flex-col border-r border-line bg-surface' +
' max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:z-40 max-lg:transition-transform' +
(open ? ' max-lg:translate-x-0' : ' max-lg:-translate-x-full')
}
>
<div className="flex h-14 items-center gap-2.5 border-b border-line px-4">
<LogoMark />
<div className="leading-tight">
<div className="text-[14px] font-semibold text-ink-1">足迹</div>
<div className="text-[10px] font-medium tracking-[0.18em] text-ink-3">FOOTPRINTS</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-2 py-3">
{NAV.map(group => (
<div key={group.section} className="mb-4">
<div className="px-2 pb-1.5 text-[11px] font-medium text-ink-3">{group.section}</div>
<div className="flex flex-col gap-0.5">
{group.items.map(item => (
<NavLink
key={item.to}
to={item.to}
onClick={onClose}
className={({ isActive }) =>
cn(
'flex h-8 items-center gap-2.5 rounded-md px-2 text-[13px] transition-colors',
isActive
? 'bg-brand-subtle font-medium text-brand-text'
: 'text-ink-2 hover:bg-surface-3 hover:text-ink-1'
)
}
>
<item.icon size={15} strokeWidth={2} />
{item.label}
</NavLink>
))}
</div>
</div>
))}
</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>
</div>
</aside>
);
}
@@ -0,0 +1,273 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Trash2, Paperclip, FileText } from 'lucide-react';
import { attachmentRead, footprintGet, listDimensions, listTypes } from '@/lib/api';
import type { Attachment, FormField } from '@/lib/types';
import { Badge, Button, Confirm, Dialog, useToast } from '@/components/ui';
import { StatusBadge, LevelBadge, GradeBadge, RoleBadge, NegativeBadge } from '@/components/common/badges';
import { openOrDownload } from '@/lib/file';
import { makeThumb } from '@/lib/img';
import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format';
export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
open: boolean;
onClose: () => void;
footprintId?: number | null;
onEdit?: (id: number) => void;
}) {
const qc = useQueryClient();
const toast = useToast();
const detailQ = useQuery({
queryKey: ['footprint', footprintId],
queryFn: () => footprintGet(footprintId!),
enabled: open && !!footprintId
});
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open });
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions, enabled: open });
const d = detailQ.data?.item;
const type = typesQ.data?.items.find(t => t.id === d?.type_id);
const dimNames = (d?.dimensions ?? [])
.map(id => dimsQ.data?.items.find(x => x.id === id)?.name)
.filter(Boolean) as string[];
const [confirmOpen, setConfirmOpen] = useState(false);
const delMut = useMutation({
mutationFn: () => import('@/lib/api').then(m => m.footprintDelete(footprintId!)),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['footprints'] });
void qc.invalidateQueries({ queryKey: ['timeline'] });
void qc.invalidateQueries({ queryKey: ['stats'] });
toast('success', '记录已删除');
setConfirmOpen(false);
onClose();
}
});
const openAtt = async (a: Attachment) => {
try {
const r = await attachmentRead(a.id);
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
} catch (e) {
toast('error', (e as Error).message);
}
};
// 图片直出:逐张读取为 data URL(渐进渲染)
const [imgMap, setImgMap] = useState<Record<number, string>>({});
const atts = d?.attachments ?? [];
const images = atts.filter(a => a.mime?.startsWith('image/'));
const docs = atts.filter(a => !a.mime?.startsWith('image/'));
const attsKey = atts.map(a => a.id).join(',');
useEffect(() => {
setImgMap({});
if (!open || !attsKey) return;
let cancelled = false;
(async () => {
const map: Record<number, string> = {};
const list = atts.filter(a => a.mime?.startsWith('image/'));
for (const a of list) {
try {
const r = await attachmentRead(a.id);
if (cancelled) return;
// 网格展示用缩略图(降采样至 480px WebP),原图仅在点击查看时读取
map[a.id] = await makeThumb(r.data_b64, r.mime || 'image/png');
setImgMap({ ...map });
} catch {
// 解码失败则退回原图
try {
const r = await attachmentRead(a.id);
map[a.id] = 'data:' + (r.mime || 'image/png') + ';base64,' + r.data_b64;
setImgMap({ ...map });
} catch { /* 单张失败不影响其余 */ }
}
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, footprintId, attsKey]);
// 动态字段渲染
const fields: { label: string; value: string }[] = [];
if (d && type) {
for (const f of (type.form_template ?? []) as FormField[]) {
const v = (d.extras ?? {})[f.key];
if (v === undefined || v === null || v === '') continue;
const text = Array.isArray(v) ? v.join('、') : String(v);
fields.push({ label: f.label, value: f.unit ? text + ' ' + f.unit : text });
}
}
return (
<Dialog
open={open}
onClose={onClose}
width={680}
title={d ? d.title : '记录详情'}
footer={
d ? (
<>
<Button variant="danger-ghost" className="mr-auto" onClick={() => setConfirmOpen(true)} disabled={delMut.isPending}>
<Trash2 size={13} /> 删除
</Button>
<Button variant="secondary" onClick={onClose}>关闭</Button>
<Button variant="primary" onClick={() => footprintId && onEdit?.(footprintId)}>
<Pencil size={13} /> 编辑
</Button>
</>
) : null
}
>
{detailQ.isLoading || !d ? (
<div className="py-10 text-center text-[13px] text-ink-3">加载中…</div>
) : (
<div className="flex flex-col gap-4">
{/* 徽章行 */}
<div className="flex flex-wrap items-center gap-1.5">
<Badge color={d.type_color}>{d.type_name}</Badge>
<LevelBadge level={d.level} />
<GradeBadge grade={d.award_grade} />
<RoleBadge role={d.role} />
<StatusBadge status={d.status} />
{d.polarity === 'negative' && <NegativeBadge />}
{d.rank != null && <Badge color="var(--text-3)">第 {d.rank} 名</Badge>}
</div>
{/* 基本信息网格 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border border-line bg-surface-2 px-3 py-2.5 text-[12.5px]">
<MetaItem label="时间" value={fmtRange(d.start_date, d.end_date) || '—'} />
<MetaItem label="组织方" value={d.organizer || '—'} />
{d.group_id && <MetaItem label="事件组" value={d.group_id} />}
{d.parent_id != null && <MetaItem label="上级记录" value={'#' + d.parent_id} />}
<MetaItem label="创建于" value={fmtDateTime(d.created_at)} />
<MetaItem label="更新于" value={fmtDateTime(d.updated_at)} />
</div>
{d.description && (
<Section title="工作内容 / 描述">
<p className="whitespace-pre-wrap text-[13px] leading-relaxed text-ink-2">{d.description}</p>
</Section>
)}
{d.result && (
<Section title="结果 / 产出">
<p className="whitespace-pre-wrap rounded-md bg-success-subtle px-3 py-2 text-[13px] leading-relaxed text-success">{d.result}</p>
</Section>
)}
{fields.length > 0 && (
<Section title={type ? '类型字段 · ' + type.name : '类型字段'}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
{fields.map(f => <MetaItem key={f.label} label={f.label} value={f.value} />)}
</div>
</Section>
)}
{(d.metrics && Object.keys(d.metrics).length > 0) && (
<Section title="量化指标">
<div className="flex flex-wrap gap-1.5">
{Object.entries(d.metrics).map(([k, v]) => (
<span key={k} className="num rounded-sm border border-line bg-surface-2 px-2 py-0.5 text-[12px] text-ink-2">
{k}: <span className="font-medium text-ink-1">{v}</span>
</span>
))}
</div>
</Section>
)}
{((d.tags?.length ?? 0) > 0 || dimNames.length > 0) && (
<Section title="标签与维度">
<div className="flex flex-wrap gap-1.5">
{d.tags.map(t => (
<span key={t} className="rounded-sm bg-surface-3 px-1.5 py-0.5 text-[12px] text-ink-2">{t}</span>
))}
{dimNames.map(n => (
<span key={n} className="rounded-sm border border-brand/30 bg-brand-subtle px-1.5 py-0.5 text-[12px] text-brand-text">{n}</span>
))}
</div>
</Section>
)}
<Section title={'附件(' + (d.attachments?.length ?? 0) + ')'}>
{atts.length ? (
<div className="flex flex-col gap-2.5">
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{images.map(a => (
<button
key={a.id}
className="group relative aspect-square overflow-hidden rounded-md border border-line bg-surface-2"
onClick={() => openAtt(a)}
title={'点击查看原图:' + a.file_name}
>
{imgMap[a.id] ? (
<img
src={imgMap[a.id]}
alt={a.file_name}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.04]"
/>
) : (
<div className="h-full w-full animate-pulse bg-surface-3" />
)}
<span className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/60 to-transparent px-1.5 pb-1 pt-3 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100">
{a.file_name}
</span>
</button>
))}
</div>
)}
{docs.length > 0 && (
<div className="flex flex-col gap-1">
{docs.map(a => (
<button
key={a.id}
className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-2 text-left text-[12.5px] transition-colors hover:border-line-strong"
onClick={() => openAtt(a)}
>
<FileText size={14} className="shrink-0 text-ink-3" />
<span className="truncate text-ink-1">{a.file_name}</span>
<span className="num ml-auto shrink-0 text-ink-3">{fmtSize(a.size)}</span>
<Paperclip size={12} className="shrink-0 text-ink-3" />
</button>
))}
</div>
)}
<div className="text-[11px] text-ink-3">点击图片查看原图(原始比例)</div>
</div>
) : (
<p className="text-[12px] text-ink-3">暂无佐证附件</p>
)}
</Section>
</div>
)}
<Confirm
open={confirmOpen}
title="删除记录"
content={d ? '确定删除「' + d.title + '」吗?该操作不可撤销。' : ''}
onCancel={() => setConfirmOpen(false)}
onOk={() => delMut.mutate()}
loading={delMut.isPending}
/>
</Dialog>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="flex min-w-0 gap-2">
<span className="shrink-0 text-ink-3">{label}</span>
<span className="min-w-0 truncate font-medium text-ink-1" title={value}>{value}</span>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-1.5 text-[12px] font-semibold text-ink-2">{title}</div>
{children}
</div>
);
}
@@ -0,0 +1,405 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
attachmentAdd, attachmentDelete, attachmentList, attachmentRead,
footprintGet, footprintList, footprintUpsert, listDimensions, listTaxonomy, listTypes
} from '@/lib/api';
import type { Attachment } from '@/lib/types';
import type { FootprintInput } from '@/lib/types';
import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/components/ui';
import { TagInput } from '@/components/common/TagInput';
import { DynamicForm, type TaxonomyOptionsMap } from '@/components/common/DynamicForm';
import { AttachmentPicker, fileToPending, type PendingAtt } from '@/components/common/AttachmentPicker';
interface FormState {
type_id: number | null;
title: string;
organizer: string;
start_date: string;
end_date: string;
status: string;
role: string;
level: string;
award_grade: string;
rank: string;
polarity: string;
weight: string;
description: string;
result: string;
tags: string[];
dimension_ids: number[];
group_id: string;
parent_id: string;
extras: Record<string, unknown>;
}
const BLANK: FormState = {
type_id: null, title: '', organizer: '', start_date: '', end_date: '',
status: 'draft', role: '', level: '', award_grade: '', rank: '', polarity: 'positive',
weight: '3', description: '', result: '', tags: [], dimension_ids: [],
group_id: '', parent_id: '', extras: {}
};
const GRADES = ['特等奖', '一等奖', '二等奖', '三等奖', '优秀奖', '无'];
const MAX_ATT_BYTES = 80 * 1024 * 1024; // 80MB(Base64 膨胀后约 107MB,低于桥接/服务端 160MB 上限)
const STATUS_OPTS = [
{ value: 'draft', label: '草稿' },
{ value: 'ongoing', label: '进行中' },
{ value: 'submitted', label: '已提交待果' },
{ value: 'finished', label: '已完结' }
];
import { openOrDownload } from '@/lib/file';
export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
open: boolean;
onClose: () => void;
footprintId?: number | null;
onSaved?: (id: number) => void;
}) {
const qc = useQueryClient();
const toast = useToast();
const [form, setForm] = useState<FormState>(BLANK);
const [pendingAtts, setPendingAtts] = useState<PendingAtt[]>([]);
const [uploadingAtt, setUploadingAtt] = useState(false);
const [error, setError] = useState('');
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open });
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy(), enabled: open });
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions, enabled: open });
const detailQ = useQuery({
queryKey: ['footprint', footprintId],
queryFn: () => footprintGet(footprintId!),
enabled: open && !!footprintId
});
const attsQ = useQuery({
queryKey: ['attachments', footprintId],
queryFn: () => attachmentList(footprintId!),
enabled: open && !!footprintId
});
const selectedType = useMemo(
() => typesQ.data?.items.find(t => t.id === form.type_id),
[typesQ.data, form.type_id]
);
const parentQ = useQuery({
queryKey: ['parent-candidates', form.type_id],
queryFn: () => footprintList({ types: form.type_id ? [form.type_id] : undefined, page_size: 200, sort: 'date_desc' }),
enabled: open && !!form.type_id
});
useEffect(() => {
if (!open) return;
setError('');
setPendingAtts([]);
if (footprintId && detailQ.data) {
const d = detailQ.data.item;
setForm({
type_id: d.type_id, title: d.title, organizer: d.organizer ?? '',
start_date: (d.start_date ?? '').slice(0, 10), end_date: (d.end_date ?? '').slice(0, 10),
status: d.status, role: d.role ?? '', level: d.level ?? '',
award_grade: d.award_grade ?? '', rank: d.rank != null ? String(d.rank) : '',
polarity: d.polarity, weight: String(d.weight ?? 3),
description: d.description ?? '', result: d.result ?? '',
tags: d.tags ?? [], dimension_ids: d.dimensions ?? [],
group_id: d.group_id ?? '', parent_id: d.parent_id != null ? String(d.parent_id) : '',
extras: d.extras ?? {}
});
} else {
setForm(BLANK);
}
}, [open, footprintId, detailQ.data]);
const taxonomies: TaxonomyOptionsMap = useMemo(() => {
const map: TaxonomyOptionsMap = {};
for (const t of taxQ.data?.items ?? []) {
(map[t.kind] ??= []).push({ value: t.value, label: t.value });
}
return map;
}, [taxQ.data]);
const saveMut = useMutation({
mutationFn: async () => {
if (!form.type_id) throw new Error('请选择记录类型');
if (!form.title.trim()) throw new Error('请填写标题');
const metrics: Record<string, number> = {};
for (const f of selectedType?.form_template ?? []) {
if (f.metric && f.widget === 'number') {
const v = form.extras[f.key];
if (typeof v === 'number' && !Number.isNaN(v)) metrics[f.metric] = v;
}
}
const input: FootprintInput = {
id: footprintId ?? undefined,
type_id: form.type_id,
title: form.title.trim(),
organizer: form.organizer || null,
start_date: form.start_date || null,
end_date: form.end_date || null,
status: form.status,
role: form.role || null,
level: form.level || null,
award_grade: form.award_grade && form.award_grade !== '无' ? form.award_grade : null,
rank: form.rank ? Number(form.rank) : null,
polarity: form.polarity,
description: form.description,
result: form.result,
tags: form.tags,
dimension_ids: form.dimension_ids,
group_id: form.group_id || null,
parent_id: form.parent_id ? Number(form.parent_id) : null,
weight: Number(form.weight) || 3,
extras: form.extras,
metrics
};
const saved = await footprintUpsert(input);
for (const p of pendingAtts) {
await attachmentAdd({ owner_id: saved.id, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 });
}
return saved.id;
},
onSuccess: (id) => {
void qc.invalidateQueries({ queryKey: ['footprints'] });
void qc.invalidateQueries({ queryKey: ['timeline'] });
void qc.invalidateQueries({ queryKey: ['stats'] });
void qc.invalidateQueries({ queryKey: ['footprint'] });
void qc.invalidateQueries({ queryKey: ['attachments', id] });
void qc.invalidateQueries({ queryKey: ['types'] });
toast('success', footprintId ? '记录已更新' : '记录已创建');
onSaved?.(id);
onClose();
},
onError: (e: Error) => setError(e.message)
});
const set = <K extends keyof FormState>(key: K, v: FormState[K]) => setForm(f => ({ ...f, [key]: v }));
const delAtt = useMutation({
mutationFn: (id: number) => attachmentDelete(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['attachments', footprintId] })
});
return (
<Dialog
open={open}
onClose={onClose}
width={760}
title={footprintId ? '编辑记录' : '新建记录'}
footer={
<>
{error && <span className="mr-auto text-[12px] text-danger">{error}</span>}
<Button variant="secondary" onClick={onClose}>取消</Button>
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
{saveMut.isPending ? '保存中…' : '保存'}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-x-3 gap-y-3.5">
<Field label="类型" required>
<Select
value={form.type_id != null ? String(form.type_id) : ''}
onChange={e => set('type_id', e.target.value ? Number(e.target.value) : null)}
options={[
{ value: '', label: '请选择' },
...(typesQ.data?.items ?? []).map(t => ({ value: String(t.id), label: t.name }))
]}
/>
</Field>
<Field label="标题" required>
<Input value={form.title} onChange={e => set('title', e.target.value)} placeholder="事项名称,如:2025 全国大学生数学建模竞赛" />
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-x-3 gap-y-3.5">
<Field label="组织方 / 所在组织">
<Input value={form.organizer} onChange={e => set('organizer', e.target.value)} placeholder="如:中国工业与应用数学学会" />
</Field>
<Field label="开始日期">
<Input type="date" value={form.start_date} onChange={e => set('start_date', e.target.value)} />
</Field>
<Field label="结束日期">
<Input type="date" value={form.end_date} onChange={e => set('end_date', e.target.value)} />
</Field>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 gap-y-3.5">
<Field label="角色">
<Select value={form.role} onChange={e => set('role', e.target.value)}
options={[{ value: '', label: '未设置' }, ...(taxonomies['role'] ?? [])]} />
</Field>
<Field label="级别">
<Select value={form.level} onChange={e => set('level', e.target.value)}
options={[{ value: '', label: '未设置' }, ...(taxonomies['level'] ?? [])]} />
</Field>
<Field label="状态">
<Select value={form.status} onChange={e => set('status', e.target.value)} options={STATUS_OPTS} />
</Field>
<Field label="重要度">
<Select value={form.weight} onChange={e => set('weight', e.target.value)}
options={[1, 2, 3, 4, 5].map(n => ({ value: String(n), label: String(n) }))} />
</Field>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 gap-y-3.5">
<Field label="奖等">
<Select value={form.award_grade} onChange={e => set('award_grade', e.target.value)}
options={[{ value: '', label: '未设置' }, ...GRADES.map(g => ({ value: g, label: g }))]} />
</Field>
<Field label="名次">
<Input type="number" className="num" value={form.rank} onChange={e => set('rank', e.target.value)} placeholder="如 2" />
</Field>
<Field label="性质">
<Select value={form.polarity} onChange={e => set('polarity', e.target.value)}
options={[{ value: 'positive', label: '正面' }, { value: 'negative', label: '负面' }]} />
</Field>
<Field label="上级记录" hint="晋升链">
<Select value={form.parent_id} onChange={e => set('parent_id', e.target.value)}
options={[
{ value: '', label: '无' },
...(parentQ.data?.items ?? [])
.filter(p => p.id !== footprintId)
.map(p => ({ value: String(p.id), label: p.title.slice(0, 24) }))
]}
/>
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
<Field label="工作内容 / 描述">
<Textarea value={form.description} onChange={e => set('description', e.target.value)} placeholder="具体做了什么、承担什么分工" />
</Field>
<Field label="结果 / 产出">
<Textarea value={form.result} onChange={e => set('result', e.target.value)} placeholder="获奖等级、成果、最终产品等" />
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-3.5">
<Field label="标签">
<TagInput value={form.tags} onChange={v => set('tags', v)} />
</Field>
<Field label="成长维度">
<div className="flex flex-wrap gap-1.5 min-h-8 items-center">
{(dimsQ.data?.items ?? []).map(d => {
const active = form.dimension_ids.includes(d.id);
return (
<button
key={d.id}
type="button"
className={
'rounded-sm border px-2 py-0.5 text-[12px] transition-colors ' +
(active
? 'border-brand bg-brand-subtle text-brand-text'
: 'border-line bg-surface text-ink-2 hover:border-line-strong')
}
onClick={() =>
set('dimension_ids', active
? form.dimension_ids.filter(x => x !== d.id)
: [...form.dimension_ids, d.id])
}
>
{d.name}
</button>
);
})}
{!dimsQ.data?.items.length && <span className="text-[12px] text-ink-3">尚未配置维度(配置页可添加)</span>}
</div>
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-3">
<Field label="事件组 ID" hint="同一场事件的多条记录共享">
<Input value={form.group_id} onChange={e => set('group_id', e.target.value)} placeholder="如 g2025001(可选)" />
</Field>
</div>
{selectedType && selectedType.form_template.length > 0 && (
<div className="border-t border-line pt-4">
<div className="mb-3 text-[12px] font-semibold text-ink-2">
类型字段 · {selectedType.name}
</div>
<DynamicForm
fields={selectedType.form_template}
value={form.extras}
onChange={v => set('extras', v)}
taxonomies={taxonomies}
/>
</div>
)}
{footprintId ? (
<div className="border-t border-line pt-4">
<AttachmentPicker
items={attsQ.data?.items ?? []}
pending={pendingAtts}
uploading={uploadingAtt}
onPick={async files => {
if (!footprintId) return;
setUploadingAtt(true);
try {
let done = 0;
for (const f of Array.from(files)) {
if (f.size > MAX_ATT_BYTES) {
toast('error', '「' + f.name + '」超过 80MB,已跳过');
continue;
}
const p = await fileToPending(f);
await attachmentAdd({ owner_id: footprintId, file_name: p.file_name, mime: p.mime, size: p.size, data_b64: p.data_b64 });
done++;
}
if (done > 0) {
void qc.invalidateQueries({ queryKey: ['attachments', footprintId] });
toast('success', '已上传 ' + done + ' 个附件');
}
} catch (e) {
toast('error', (e as Error).message);
} finally {
setUploadingAtt(false);
}
}}
onDeleteSaved={id => delAtt.mutate(id)}
onDeletePending={() => {}}
onOpenSaved={async (a: Attachment) => {
try {
const r = await attachmentRead(a.id);
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
} catch (e) {
toast('error', (e as Error).message);
}
}}
/>
<div className="mt-1.5 text-[11px] text-ink-3">
点击文件名可预览(支持图片 / PDF,其余下载)· 单个文件不超过 80MB
</div>
</div>
) : (
<div className="border-t border-line pt-4">
<AttachmentPicker
items={[]}
pending={pendingAtts}
onPick={async files => {
const list: PendingAtt[] = [];
for (const f of Array.from(files)) {
if (f.size > MAX_ATT_BYTES) {
toast('error', '「' + f.name + '」超过 80MB,已跳过');
continue;
}
list.push(await fileToPending(f));
}
setPendingAtts(prev => [...prev, ...list]);
}}
onDeleteSaved={() => {}}
onDeletePending={idx => setPendingAtts(prev => prev.filter((_, i) => i !== idx))}
/>
<div className="mt-1.5 text-[11px] text-ink-3">
保存记录时将一并上传 · 单个文件不超过 80MB
</div>
</div>
)}
</div>
</Dialog>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export function Badge({ className, color, ...props }: HTMLAttributes<HTMLSpanElement> & { color?: string }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-sm border px-1.5 py-px text-[11px] leading-[18px] font-medium whitespace-nowrap',
className
)}
style={color ? {
color: color,
borderColor: color + '44',
backgroundColor: color + '14'
} : undefined}
{...props}
/>
);
}
export function NeutralBadge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-sm border border-line bg-surface-2 px-1.5 py-px text-[11px] leading-[18px] font-medium text-ink-2 whitespace-nowrap',
className
)}
{...props}
/>
);
}
export function Dot({ color, className }: { color: string; className?: string }) {
return <span className={cn('inline-block size-2 rounded-full shrink-0', className)} style={{ backgroundColor: color }} />;
}
+37
View File
@@ -0,0 +1,37 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors select-none disabled:pointer-events-none disabled:opacity-45',
{
variants: {
variant: {
primary: 'bg-brand text-white hover:bg-brand-hover shadow-card',
secondary: 'bg-surface text-ink-1 border border-line hover:bg-surface-3 hover:border-line-strong',
ghost: 'text-ink-2 hover:bg-surface-3 hover:text-ink-1',
subtle: 'bg-brand-subtle text-brand-text hover:brightness-[0.97]',
danger: 'bg-danger text-white hover:brightness-110',
'danger-ghost': 'text-danger hover:bg-danger-subtle'
},
size: {
sm: 'h-7 px-2.5 text-[12px]',
md: 'h-8 px-3 text-[13px]',
lg: 'h-9 px-4 text-sm',
icon: 'h-8 w-8 p-0',
'icon-sm': 'h-7 w-7 p-0'
}
},
defaultVariants: { variant: 'secondary', size: 'md' }
}
);
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant, size, ...props }, ref
) {
return <button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
});
export { buttonVariants };
+24
View File
@@ -0,0 +1,24 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-lg border border-line bg-surface shadow-card', className)}
{...props}
/>
);
}
export function CardHeader({ title, extra, className }: { title: string; extra?: React.ReactNode; className?: string }) {
return (
<div className={cn('flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5', className)}>
<h3 className="text-[13px] font-semibold text-ink-1">{title}</h3>
{extra && <div className="flex flex-wrap items-center gap-2">{extra}</div>}
</div>
);
}
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-4', className)} {...props} />;
}
+24
View File
@@ -0,0 +1,24 @@
import { Dialog } from './dialog';
import { Button } from './button';
export function Confirm({ open, title, content, onCancel, onOk, loading }: {
open: boolean;
title: string;
content?: string;
onCancel: () => void;
onOk: () => void;
loading?: boolean;
}) {
return (
<Dialog open={open} onClose={onCancel} title={title} width={420}
footer={
<>
<Button variant="secondary" onClick={onCancel}>取消</Button>
<Button variant="danger" onClick={onOk} disabled={loading}>确认</Button>
</>
}
>
<p className="text-[13px] text-ink-2">{content ?? '该操作不可撤销,请确认。'}</p>
</Dialog>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { Button } from './button';
export function Dialog({ open, onClose, title, width = 560, children, footer }: {
open: boolean;
onClose: () => void;
title: ReactNode;
width?: number;
children: ReactNode;
footer?: ReactNode;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-6 pt-[8vh]">
<div className="fixed inset-0 bg-black/45" onClick={onClose} />
<div
className="relative w-full rounded-lg border border-line bg-surface shadow-pop"
style={{ maxWidth: width }}
role="dialog"
>
<div className="flex h-12 items-center justify-between border-b border-line px-4">
<div className="text-sm font-semibold text-ink-1">{title}</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="关闭">
<X size={15} />
</Button>
</div>
<div className="px-4 py-4 max-h-[64vh] overflow-y-auto">{children}</div>
{footer && (
<div className="flex items-center justify-end gap-2 border-t border-line px-4 h-14">
{footer}
</div>
)}
</div>
</div>,
document.body
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
export function Dropdown({ trigger, children, align = 'end', className }: {
trigger: ReactNode;
children: ReactNode | ((close: () => void) => ReactNode);
align?: 'start' | 'end';
className?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="relative" ref={ref}>
<div onClick={() => setOpen(v => !v)}>{trigger}</div>
{open && (
<div
className={cn(
'absolute z-40 mt-1 min-w-[160px] rounded-md border border-line bg-surface py-1 shadow-pop',
align === 'end' ? 'right-0' : 'left-0',
className
)}
>
{typeof children === 'function' ? children(() => setOpen(false)) : children}
</div>
)}
</div>
);
}
export function DropdownItem({ className, danger, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { danger?: boolean }) {
return (
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition-colors',
danger ? 'text-danger hover:bg-danger-subtle' : 'text-ink-1 hover:bg-surface-3',
className
)}
{...props}
/>
);
}
export function DropdownSeparator() {
return <div className="my-1 border-t border-line" />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
import { Inbox } from 'lucide-react';
export function Empty({ icon, title, description, action }: {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<div className="text-ink-3">{icon ?? <Inbox size={36} strokeWidth={1.5} />}</div>
<div className="text-[13px] font-medium text-ink-2">{title}</div>
{description && <div className="max-w-[360px] text-[12px] text-ink-3">{description}</div>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export function Field({ label, required, hint, className, children }: {
label: string; required?: boolean; hint?: string; className?: string; children: ReactNode;
}) {
return (
<div className={cn('flex flex-col gap-1.5', className)}>
<label className="text-[12px] font-medium text-ink-2">
{label}
{required && <span className="text-danger ml-0.5">*</span>}
{hint && <span className="text-ink-3 font-normal ml-2">{hint}</span>}
</label>
{children}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export { Button, buttonVariants } from './button';
export { Input, Textarea } from './input';
export { Field } from './field';
export { Select } from './select';
export type { Option } from './select';
export { Badge, NeutralBadge, Dot } from './badge';
export { Card, CardHeader, CardBody } from './card';
export { Dialog } from './dialog';
export { TableWrap, Table, Th, Td, Tr } from './table';
export { Dropdown, DropdownItem, DropdownSeparator } from './dropdown';
export { Empty } from './empty';
export { ToastProvider, useToast } from './toast';
export { Confirm } from './confirm';
+16
View File
@@ -0,0 +1,16 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
const base = 'w-full rounded-md border border-line bg-surface px-2.5 text-[13px] text-ink-1 placeholder:text-ink-3 transition-colors hover:border-line-strong focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input(
{ className, ...props }, ref
) {
return <input ref={ref} className={cn(base, 'h-8', className)} {...props} />;
});
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(function Textarea(
{ className, ...props }, ref
) {
return <textarea ref={ref} className={cn(base, 'py-2 min-h-[72px] resize-y leading-relaxed', className)} {...props} />;
});
+28
View File
@@ -0,0 +1,28 @@
import { forwardRef, type SelectHTMLAttributes } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface Option { value: string; label: string; }
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement> & { options?: Option[] }>(
function Select({ className, options, children, ...props }, ref) {
return (
<div className={cn('relative', className ? className.split(' ').filter(c => /^w-|^min-w|^flex-/.test(c)).join(' ') : '')}>
<select
ref={ref}
className={cn(
'w-full h-8 appearance-none rounded-md border border-line bg-surface pl-2.5 pr-7 text-[13px] text-ink-1',
'transition-colors hover:border-line-strong focus:border-brand focus:outline-none disabled:opacity-50',
className
)}
{...props}
>
{options
? options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)
: children}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-ink-3" />
</div>
);
}
);
+36
View File
@@ -0,0 +1,36 @@
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export function TableWrap({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('overflow-x-auto', className)} {...props} />;
}
export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
return <table className={cn('w-full border-collapse text-[13px]', className)} {...props} />;
}
export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn(
'sticky top-0 z-10 border-b border-line bg-surface-2 px-3 py-2 text-left text-[12px] font-semibold text-ink-3 whitespace-nowrap',
className
)}
{...props}
/>
);
}
export function Td({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn('border-b border-line px-3 py-2 align-middle text-ink-1', className)} {...props} />;
}
export function Tr({ className, onClick, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={cn('group transition-colors', onClick && 'cursor-pointer hover:bg-surface-2', className)}
onClick={onClick}
{...props}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { CheckCircle2, AlertCircle, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
type ToastKind = 'success' | 'error' | 'info';
interface ToastItem { id: number; kind: ToastKind; text: string; }
const ToastCtx = createContext<(kind: ToastKind, text: string) => void>(() => {});
export function useToast() {
return useContext(ToastCtx);
}
const icons: Record<ToastKind, ReactNode> = {
success: <CheckCircle2 size={15} className="text-success" />,
error: <AlertCircle size={15} className="text-danger" />,
info: <Info size={15} className="text-brand" />
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const seq = useRef(0);
const push = useCallback((kind: ToastKind, text: string) => {
const id = ++seq.current;
setItems(prev => [...prev, { id, kind, text }]);
setTimeout(() => setItems(prev => prev.filter(t => t.id !== id)), 3200);
}, []);
return (
<ToastCtx.Provider value={push}>
{children}
<div className="pointer-events-none fixed bottom-5 right-5 z-[60] flex flex-col gap-2">
{items.map(t => (
<div
key={t.id}
className={cn(
'pointer-events-auto flex items-center gap-2 rounded-md border border-line bg-surface px-3 py-2 text-[13px] text-ink-1 shadow-pop',
'animate-in'
)}
>
{icons[t.kind]}
{t.text}
</div>
))}
</div>
</ToastCtx.Provider>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from 'react';
import * as echarts from 'echarts/core';
import type { EChartsType } from 'echarts/core';
export function useThemeTick() {
const [tick, setTick] = useState(0);
useEffect(() => {
const handler = () => setTick((t) => t + 1);
window.addEventListener('fp-theme-change', handler);
return () => window.removeEventListener('fp-theme-change', handler);
}, []);
return tick;
}
export function inkColor(name: '1' | '2' | '3'): string {
return getComputedStyle(document.documentElement).getPropertyValue('--text-' + name).trim() || '#454e5c';
}
// 图表 hook:回调式 ref —— 容器挂载(含条件渲染后出现)即初始化并立即绘制当前数据;
// deps 变化时增量更新;容器卸载时销毁实例。
export function useChart(build: () => echarts.EChartsCoreOption, deps: unknown[]) {
const chartRef = useRef<EChartsType | null>(null);
const [el, setEl] = useState<HTMLDivElement | null>(null);
const buildRef = useRef(build);
buildRef.current = build;
// 挂载/重挂载:初始化实例 + 首次绘制
useEffect(() => {
if (!el) return;
const chart = echarts.init(el);
chartRef.current = chart;
chart.setOption(buildRef.current(), { notMerge: true });
const ro = new ResizeObserver(() => chart.resize());
ro.observe(el);
return () => {
ro.disconnect();
chart.dispose();
chartRef.current = null;
};
}, [el]);
// 数据/主题变化:增量更新
useEffect(() => {
chartRef.current?.setOption(buildRef.current(), { notMerge: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [el, ...deps]);
const setRef = (node: HTMLDivElement | null) => setEl(node);
return setRef;
}
+85
View File
@@ -0,0 +1,85 @@
import type {
AppInfo, Attachment, Dimension, FootprintDetail, FootprintInput, FootprintQuery,
FootprintRow, FootprintType, FormField, GradeBand, GradesOverview, MetricValue,
StatsOverview, SyncStatus, TaxonomyItem, TimelineGroup
} from './types';
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
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 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');
// ---- footprint type ----
export const listTypes = () => call<{ items: FootprintType[] }>('type_list');
export const upsertType = (p: { id?: number; name: string; icon: string; color: string; sort: number; form_template: FormField[]; closed?: boolean }) =>
call<{ item: FootprintType }>('type_upsert', p);
export const deleteType = (id: number) => call<{ ok: true }>('type_delete', { id });
// ---- taxonomy ----
export const listTaxonomy = (kind?: string) => call<{ items: TaxonomyItem[] }>('taxonomy_list', { kind });
export const upsertTaxonomy = (p: { id?: number; kind: string; value: string; color?: string | null; sort?: number }) =>
call<{ item: TaxonomyItem }>('taxonomy_upsert', p);
export const deleteTaxonomy = (id: number) => call<{ ok: true }>('taxonomy_delete', { id });
// ---- dimension ----
export const listDimensions = () => call<{ items: Dimension[] }>('dimension_list');
export const upsertDimension = (p: { id?: number; name: string; color?: string | null; sort?: number }) =>
call<{ item: Dimension }>('dimension_upsert', p);
export const deleteDimension = (id: number) => call<{ ok: true }>('dimension_delete', { id });
// ---- footprint ----
export const footprintList = (query: FootprintQuery = {}) =>
call<{ items: FootprintRow[]; total: number; page: number; page_size: number }>('footprint_list', { query });
export const footprintGet = (id: number) => call<{ item: FootprintDetail }>('footprint_get', { id });
export const footprintUpsert = (item: FootprintInput) => call<{ id: number }>('footprint_upsert', { item });
export const footprintDelete = (id: number) => call<{ ok: true }>('footprint_delete', { id });
export const timeline = (query: FootprintQuery = {}) => call<{ groups: TimelineGroup[]; total: number }>('timeline', { query });
// ---- stats ----
export const statsOverview = (year?: number) => call<StatsOverview>('stats_overview', { year });
export const metricList = () => call<{ items: MetricValue[] }>('metric_list');
export const metricUpsert = (p: { key: string; label: string; unit?: string; agg?: string; section?: string }) =>
call<{ item: MetricValue }>('metric_upsert', p);
export const metricDelete = (key: string) => call<{ ok: true }>('metric_delete', { key });
// ---- grades ----
export const gradesOverview = () => call<GradesOverview>('grades_overview');
export const semesterUpsert = (p: { id?: number; term: string; gpa?: number | null; rank?: number | null; total_students?: number | null }) =>
call<{ item: { id: number } }>('semester_upsert', p);
export const semesterDelete = (id: number) => call<{ ok: true }>('semester_delete', { id });
export const courseUpsert = (p: { id?: number; semester_id: number; course_name: string; credit: number; score?: number | null; grade_point?: number | null }) =>
call<{ item: { id: number } }>('course_upsert', p);
export const courseDelete = (id: number) => call<{ ok: true }>('course_delete', { id });
export const bandUpsert = (p: GradeBand) => call<{ item: { id: number } }>('band_upsert', p);
export const bandDelete = (id: number) => call<{ ok: true }>('band_delete', { id });
// ---- attachment ----
export const attachmentAdd = (p: { owner_id: number; file_name: string; mime?: string; size: number; data_b64: string }) =>
call<{ item: Attachment }>('attachment_add', p);
export const attachmentList = (owner_id: number) => call<{ items: Attachment[] }>('attachment_list', { owner_id });
export const attachmentDelete = (id: number) => call<{ ok: true }>('attachment_delete', { id });
export const attachmentRead = (id: number) =>
call<{ data_b64: string; mime?: string; file_name: string }>('attachment_read', { id });
// ---- saved view ----
export interface SavedView { id: number; name: string; filter: Record<string, unknown>; sort?: Record<string, unknown>; is_default: boolean; }
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 });
+16
View File
@@ -0,0 +1,16 @@
// 附件打开/下载:图片与 PDF 新窗口预览,其余触发下载
export function openOrDownload(fileName: string, mime?: string, b64?: string) {
if (!b64) return;
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
if (mime?.startsWith('image/') || mime === 'application/pdf') {
window.open(url, '_blank');
} else {
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
}
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
+29
View File
@@ -0,0 +1,29 @@
export function fmtDate(s?: string | null): string {
if (!s) return '';
return s.slice(0, 10);
}
export function fmtDateTime(s?: string | null): string {
if (!s) return '';
return s.slice(0, 16).replace('T', ' ');
}
export function fmtRange(start?: string | null, end?: string | null): string {
const a = start ? start.slice(0, 10) : '';
const b = end ? end.slice(0, 10) : '';
if (a && b) return a + ' ~ ' + b;
if (a) return a + (end === '' ? '' : ' 起');
return b ? '至 ' + b : '';
}
export function fmtSize(n: number): string {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
export function monthLabel(ym: string): string {
// ym: YYYY-MM
const [y, m] = ym.split('-');
return y + ' 年 ' + Number(m) + ' 月';
}
+25
View File
@@ -0,0 +1,25 @@
// 图片缩略图:canvas 降采样,输出 WebP(WebView2/Chromium 支持)
// 用于列表网格展示;原图仅在点击查看时按需读取
export function makeThumb(dataB64: string, mime: string, max = 480, quality = 0.82): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
try {
const scale = Math.min(1, max / Math.max(img.naturalWidth, img.naturalHeight));
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('canvas 不可用'));
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/webp', quality));
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
};
img.onerror = () => reject(new Error('图片解码失败'));
img.src = 'data:' + mime + ';base64,' + dataB64;
});
}
+228
View File
@@ -0,0 +1,228 @@
// 共享类型定义(与 dev-bridge / Rust 后端契约一致)
export type WidgetType = 'text' | 'textarea' | 'number' | 'date' | 'select' | 'multiselect' | 'link';
export interface FormField {
key: string;
label: string;
widget: WidgetType;
options?: string[] | { taxonomy: string };
required?: boolean;
unit?: string;
metric?: string;
placeholder?: string;
}
export interface FootprintType {
id: number;
name: string;
icon: string;
color: string;
sort: number;
form_template: FormField[];
is_builtin: boolean;
closed: boolean;
usage_count?: number;
}
export interface TaxonomyItem {
id: number;
kind: string;
value: string;
color?: string | null;
sort: number;
is_builtin: boolean;
}
export interface Dimension {
id: number;
name: string;
color?: string | null;
sort: number;
}
export interface Attachment {
id: number;
owner_type: string;
owner_id: number;
kind?: string | null;
file_name: string;
mime?: string | null;
size: number;
rel_path: string;
created_at: string;
}
export interface FootprintRow {
id: number;
type_id: number;
type_name: string;
type_color: string;
type_icon: string;
title: string;
role?: string | null;
level?: string | null;
polarity: string;
award_grade?: string | null;
rank?: number | null;
organizer?: string | null;
start_date?: string | null;
end_date?: string | null;
status: string;
description?: string | null;
result?: string | null;
metrics: Record<string, number>;
extras: Record<string, unknown>;
group_id?: string | null;
parent_id?: number | null;
tags: string[];
weight: number;
attachment_count: number;
dimensions: number[];
created_at: string;
updated_at: string;
}
export interface FootprintDetail extends FootprintRow {
attachments: Attachment[];
}
export interface FootprintQuery {
q?: string;
types?: number[];
levels?: string[];
roles?: string[];
statuses?: string[];
dims?: number[];
year?: number;
polarity?: string;
has_attachment?: boolean;
tag?: string;
sort?: string; // date_desc | date_asc | updated_desc | weight_desc
page?: number;
page_size?: number;
}
export interface FootprintInput {
id?: number;
type_id: number;
title: string;
role?: string | null;
level?: string | null;
polarity?: string;
award_grade?: string | null;
rank?: number | null;
organizer?: string | null;
start_date?: string | null;
end_date?: string | null;
status?: string;
description?: string;
result?: string;
metrics?: Record<string, number>;
extras?: Record<string, unknown>;
group_id?: string | null;
parent_id?: number | null;
tags?: string[];
dimension_ids?: number[];
weight?: number;
}
export interface TimelineGroup {
year: number;
count: number;
months: { month: number; items: FootprintRow[] }[];
}
export interface StatCount {
key: string;
label: string;
color?: string | null;
count: number;
}
export interface MonthlyPoint {
ym: string;
count: number;
}
export interface MetricValue {
key: string;
label: string;
unit?: string | null;
agg: string;
value: number;
}
export interface StatsOverview {
total: number;
total_this_year: number;
attachment_total: number;
by_type: StatCount[];
by_level: StatCount[];
by_role: StatCount[];
by_status: StatCount[];
monthly: MonthlyPoint[];
metrics: MetricValue[];
years: number[];
}
export interface AppInfo {
app_name: string;
version: string;
db_path: string;
attachment_dir: string;
schema_ok: boolean;
device_id?: string;
pending_ops?: number;
last_sync_at?: string | null;
}
export interface SyncStatus {
mode: string;
device_id: string;
pending_ops: number;
last_sync_at: string | null;
}
export interface GradeBand {
id: number;
label: string;
color: string;
min: number;
max: number;
sort: number;
}
export interface BandInfo { id: number; label: string; color: string; }
export interface CourseGrade {
id: number;
semester_id: number;
course_name: string;
credit: number;
score?: number | null;
grade_point?: number | null;
band?: BandInfo | null;
}
export interface SemesterGrades {
id: number;
term: string;
gpa?: number | null;
rank?: number | null;
total_students?: number | null;
courses: CourseGrade[];
stats: { credits: number; courses: number; avg: number | null };
}
export interface GradesOverview {
semesters: SemesterGrades[];
bands: GradeBand[];
overall: {
avg: number | null;
credits: number;
courses: number;
gpa_trend: { term: string; gpa: number; avg: number | null }[];
tier_dist: { label: string; color: string; count: number }[];
};
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './styles/theme.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 15_000 }
}
});
// theme bootstrap
const saved = localStorage.getItem('fp-theme');
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
// PWA:仅生产构建且非桌面壳时注册(桌面端走 Tauri,无需 SW)
if ('serviceWorker' in navigator && import.meta.env.PROD && !('__TAURI_INTERNALS__' in window)) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<HashRouter>
<App />
</HashRouter>
</QueryClientProvider>
</React.StrictMode>
);
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import * as echarts from 'echarts/core';
import { BarChart, PieChart, LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { statsOverview } from '@/lib/api';
import { Card, CardBody, CardHeader, Select } from '@/components/ui';
import { useChart, inkColor, useThemeTick } from '@/hooks/useChart';
echarts.use([BarChart, PieChart, LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
export function DashboardPage() {
const [year, setYear] = useState('');
const themeTick = useThemeTick();
const statsQ = useQuery({
queryKey: ['stats', year || undefined],
queryFn: () => statsOverview(year ? Number(year) : undefined)
});
const s = statsQ.data;
const ink2 = inkColor('2');
const ink3 = inkColor('3');
const monthlyRef = useChart(() => ({
grid: { left: 34, right: 12, top: 14, bottom: 24 },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: s?.monthly.map(m => m.ym.slice(2)) ?? [],
axisLine: { lineStyle: { color: ink3 } },
axisTick: { show: false },
axisLabel: { color: ink3, fontSize: 10 }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(128,138,155,0.15)' } },
axisLabel: { color: ink3, fontSize: 10 }
},
series: [{
type: 'bar',
data: s?.monthly.map(m => m.count) ?? [],
barMaxWidth: 18,
itemStyle: { color: '#3056d3', borderRadius: [3, 3, 0, 0] }
}]
}), [s, themeTick]);
const typeRef = useChart(() => {
const items = (s?.by_type ?? []).filter(t => t.count > 0);
return {
tooltip: { trigger: 'item' },
legend: { orient: 'vertical', right: 4, top: 'middle', textStyle: { color: ink2, fontSize: 11 }, itemWidth: 10, itemHeight: 10 },
series: [{
type: 'pie',
radius: ['48%', '72%'],
center: ['34%', '50%'],
label: { show: false },
data: items.map(t => ({ name: t.label, value: t.count, itemStyle: { color: t.color || '#64748b' } }))
}]
};
}, [s, themeTick]);
const levelRef = useChart(() => {
const items = (s?.by_level ?? []).filter(l => l.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: ink3, fontSize: 10 }
},
yAxis: {
type: 'category',
data: items.map(l => l.label),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: ink2, fontSize: 11 }
},
series: [{
type: 'bar',
data: items.map(l => ({
value: l.count,
itemStyle: { color: l.color || '#3056d3', borderRadius: [0, 3, 3, 0] }
})),
barMaxWidth: 14
}]
};
}, [s, themeTick]);
const metricCards = (s?.metrics ?? []).slice(0, 3);
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-3">
<MetricCard label="总记录" value={s?.total ?? 0} />
<MetricCard label={year ? year + ' 年新增' : '本年新增'} value={s?.total_this_year ?? 0} />
<MetricCard label="附件总数" value={s?.attachment_total ?? 0} />
{metricCards.map(m => (
<MetricCard key={m.key} label={m.label} value={m.value} unit={m.unit ?? ''} />
))}
</div>
<Select
className="w-[120px]"
value={year}
onChange={e => setYear(e.target.value)}
options={[{ value: '', label: '全部年份' }, ...(s?.years ?? []).map(y => ({ value: String(y), label: y + ' 年' }))]}
/>
</div>
{!s?.metrics.length && (
<div className="mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3">
提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader title="近 12 个月活跃度" />
<CardBody><div ref={monthlyRef} className="h-[220px]" /></CardBody>
</Card>
<Card>
<CardHeader title="类型分布" />
<CardBody><div ref={typeRef} className="h-[220px]" /></CardBody>
</Card>
<Card className="col-span-2">
<CardHeader title="级别分布" />
<CardBody><div ref={levelRef} className="h-[200px]" /></CardBody>
</Card>
</div>
</div>
);
}
function MetricCard({ label, value, unit }: { label: string; value: number; unit?: string }) {
return (
<div className="min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card">
<div className="text-[12px] text-ink-3">{label}</div>
<div className="num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1">
{value}
{unit && <span className="ml-1 text-[12px] font-normal text-ink-3">{unit}</span>}
</div>
</div>
);
}
+511
View File
@@ -0,0 +1,511 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as echarts from 'echarts/core';
import { BarChart, LineChart, PieChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { Plus, Trash2, Pencil, Calculator, GraduationCap } from 'lucide-react';
import { bandDelete, bandUpsert, courseDelete, courseUpsert, gradesOverview, semesterDelete, semesterUpsert } from '@/lib/api';
import type { CourseGrade, GradeBand, SemesterGrades } from '@/lib/types';
import { Badge, Button, Card, CardBody, CardHeader, Confirm, Dialog, Empty, Field, Input, useToast } from '@/components/ui';
import { useChart, inkColor, useThemeTick } from '@/hooks/useChart';
echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
function termYear(term: string): string {
const m = /^(\d{4})-(\d{4})/.exec(term);
return m ? m[1] + '~' + m[2] + ' 学年' : '其他学期';
}
function autoGpa(courses: CourseGrade[]): number | null {
const withGp = courses.filter(c => c.grade_point != null && c.credit > 0);
if (!withGp.length) return null;
const credits = withGp.reduce((a, c) => a + c.credit, 0);
const weighted = withGp.reduce((a, c) => a + (c.grade_point as number) * c.credit, 0);
return Math.round((weighted / credits) * 100) / 100;
}
export function GradesPage() {
const themeTick = useThemeTick();
const overviewQ = useQuery({ queryKey: ['grades'], queryFn: gradesOverview });
const s = overviewQ.data;
const ink2 = inkColor('2');
const ink3 = inkColor('3');
const tierRef = useChart(() => {
const items = (s?.overall.tier_dist ?? []).filter(t => t.count > 0);
return {
tooltip: { trigger: 'item' },
legend: { orient: 'vertical', right: 8, top: 'middle', itemGap: 10, textStyle: { color: ink2, fontSize: 11 }, itemWidth: 10, itemHeight: 10 },
series: [{
type: 'pie', radius: ['48%', '72%'], center: ['34%', '50%'], label: { show: false },
data: items.map(t => ({ name: t.label, value: t.count, itemStyle: { color: t.color } }))
}]
};
}, [s, themeTick]);
const trendRef = useChart(() => {
const t = s?.overall.gpa_trend ?? [];
return {
grid: { left: 8, right: 8, top: 36, bottom: 6, containLabel: true },
tooltip: { trigger: 'axis' },
legend: { top: 0, left: 'center', itemGap: 16, textStyle: { color: ink2, fontSize: 11 }, itemWidth: 14, itemHeight: 8 },
xAxis: {
type: 'category', data: t.map(x => x.term),
axisLine: { lineStyle: { color: ink3 } }, axisTick: { show: false },
axisLabel: { color: ink3, fontSize: 10 }
},
yAxis: [
{ type: 'value', min: 0, max: 100, splitLine: { lineStyle: { color: 'rgba(128,138,155,0.15)' } }, axisLabel: { color: ink3, fontSize: 10 } },
{ type: 'value', min: 0, max: 5, splitLine: { show: false }, axisLabel: { color: ink3, fontSize: 10 } }
],
series: [
{ name: '加权平均分', type: 'bar', barMaxWidth: 18, itemStyle: { color: '#3056d3', borderRadius: [3, 3, 0, 0] }, data: t.map(x => x.avg) },
{ name: '绩点', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, itemStyle: { color: '#d97706' }, lineStyle: { width: 2 }, data: t.map(x => x.gpa) }
]
};
}, [s, themeTick]);
if (overviewQ.isLoading) {
return <div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>;
}
const semesters = [...(s?.semesters ?? [])].sort((a, b) => b.term.localeCompare(a.term));
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
{/* 总览指标 */}
<div className="mb-4 flex flex-wrap gap-3">
<MetricCard label="总学分" value={s?.overall.credits ?? 0} />
<MetricCard label="加权平均分" value={s?.overall.avg != null ? s.overall.avg : '—'} />
<MetricCard label="已录课程" value={s?.overall.courses ?? 0} />
<MetricCard label="学期数" value={s?.semesters.length ?? 0} />
</div>
{/* 总览图表 */}
<div className="mb-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader title="单科成绩等级分布" />
<CardBody><div ref={tierRef} className="h-[220px]" /></CardBody>
</Card>
<Card>
<CardHeader title="学期趋势(加权平均分 / 绩点)" />
<CardBody><div ref={trendRef} className="h-[220px]" /></CardBody>
</Card>
</div>
{/* 成绩梯度设置 */}
<BandEditor bands={s?.bands ?? []} />
{/* 学年学科成绩 */}
<div className="mb-3 mt-5 flex items-center justify-between">
<h2 className="text-[14px] font-semibold text-ink-1">学年学科成绩</h2>
<AddSemesterButton />
</div>
{!semesters.length ? (
<Card><Empty
icon={<GraduationCap size={36} strokeWidth={1.5} />}
title="暂无学期成绩"
description="新增一个学期(如 2024-2025-1),再录入各科成绩。"
/></Card>
) : (
<div className="flex flex-col gap-5">
{groupByYear(semesters).map(([year, list]) => (
<section key={year}>
<div className="mb-2 text-[12px] font-medium text-ink-3">{year}</div>
<div className="flex flex-col gap-3">
{list.map(sem => <SemesterCard key={sem.id} sem={sem} />)}
</div>
</section>
))}
</div>
)}
</div>
);
}
function groupByYear(semesters: SemesterGrades[]): [string, SemesterGrades[]][] {
const map = new Map<string, SemesterGrades[]>();
for (const s of semesters) {
const y = termYear(s.term);
if (!map.has(y)) map.set(y, []);
map.get(y)!.push(s);
}
return [...map.entries()];
}
function MetricCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="min-w-[128px] flex-1 max-w-[220px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card">
<div className="text-[12px] text-ink-3">{label}</div>
<div className="num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1">{value}</div>
</div>
);
}
// ---------- 梯度设置 ----------
function BandEditor({ bands }: { bands: GradeBand[] }) {
const qc = useQueryClient();
const toast = useToast();
const [adding, setAdding] = useState<GradeBand | null>(null);
const [deleting, setDeleting] = useState<GradeBand | null>(null);
const upMut = useMutation({
mutationFn: bandUpsert,
onSuccess: () => {
toast('success', '梯度已保存');
void qc.invalidateQueries({ queryKey: ['grades'] });
},
onError: (e: Error) => toast('error', e.message)
});
const delMut = useMutation({
mutationFn: bandDelete,
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); setDeleting(null); }
});
return (
<Card>
<CardHeader
title="成绩梯度"
extra={<span className="text-[11px] text-ink-3">按 分数 ≥ min 且 &lt; max 定级,满分档含上限</span>}
/>
<CardBody className="p-0">
<div className="divide-y divide-line">
{bands.map(b => (
<BandRow key={b.id} band={b} onSave={(next) => upMut.mutate(next)} onDelete={() => setDeleting(b)} />
))}
</div>
<div className="border-t border-line px-4 py-2.5">
{adding ? (
<BandRow
band={adding}
isNew
onChange={setAdding}
onSave={() => { upMut.mutate(adding); setAdding(null); }}
onCancel={() => setAdding(null)}
/>
) : (
<Button size="sm" variant="secondary" onClick={() => setAdding({ id: 0, label: '', color: '#3056d3', min: 0, max: 60, sort: (bands.at(-1)?.sort ?? 0) + 10 })}>
<Plus size={13} /> 添加等级
</Button>
)}
</div>
</CardBody>
<Confirm
open={!!deleting}
title="删除等级"
content={deleting ? '确定删除「' + deleting.label + '」吗?已录成绩将不再显示该等级。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}
function BandRow({ band, onSave, onDelete, onChange, onCancel, isNew }: {
band: GradeBand;
isNew?: boolean;
onSave?: (b: GradeBand) => void;
onDelete?: () => void;
onChange?: (b: GradeBand) => void;
onCancel?: () => void;
}) {
const [draft, setDraft] = useState(band);
const value = isNew && onChange ? draft : band;
const set = (patch: Partial<GradeBand>) => {
const next = { ...value, ...patch };
if (onChange) onChange(next); else setDraft(next);
};
const dirty = isNew || JSON.stringify({ ...value, id: 0 }) !== JSON.stringify({ ...band, id: 0 });
return (
<div className="flex flex-wrap items-center gap-2 px-4 py-2">
<input type="color" value={value.color} onChange={e => set({ color: e.target.value })}
className="size-5 shrink-0 cursor-pointer rounded border border-line bg-surface" aria-label="颜色" />
<Input className="w-[96px]" value={value.label} onChange={e => set({ label: e.target.value })} placeholder="等级名" />
<span className="text-[12px] text-ink-3">分数</span>
<Input type="number" className="num w-[72px]" value={String(value.min)} onChange={e => set({ min: Number(e.target.value) })} />
<span className="text-[12px] text-ink-3">至</span>
<Input type="number" className="num w-[72px]" value={String(value.max)} onChange={e => set({ max: Number(e.target.value) })} />
{isNew ? (
<>
<Button size="sm" variant="primary" onClick={() => onSave?.(value)}>添加</Button>
<Button size="sm" variant="ghost" onClick={() => onCancel?.()}>取消</Button>
</>
) : (
<div className="ml-auto flex gap-1.5">
<Button size="sm" variant={dirty ? 'primary' : 'secondary'} disabled={!dirty} onClick={() => onSave?.(value)}>保存</Button>
<Button size="icon-sm" variant="ghost" aria-label="删除等级" onClick={() => onDelete?.()}>
<Trash2 size={13} />
</Button>
</div>
)}
</div>
);
}
// ---------- 学期卡片 ----------
function SemesterCard({ sem }: { sem: SemesterGrades }) {
const qc = useQueryClient();
const toast = useToast();
const [editingId, setEditingId] = useState<number | null>(null);
const [deleting, setDeleting] = useState<CourseGrade | null>(null);
const [semDialog, setSemDialog] = useState(false);
const invalidate = () => void qc.invalidateQueries({ queryKey: ['grades'] });
const delCourse = useMutation({
mutationFn: courseDelete,
onSuccess: () => { invalidate(); setDeleting(null); }
});
const autoGpaMut = useMutation({
mutationFn: () => {
const gpa = autoGpa(sem.courses);
if (gpa == null) throw new Error('请先为课程填写绩点');
return semesterUpsert({ id: sem.id, term: sem.term, gpa, rank: sem.rank ?? null, total_students: sem.total_students ?? null });
},
onSuccess: () => { toast('success', '绩点已按课程自动计算'); invalidate(); },
onError: (e: Error) => toast('error', e.message)
});
const delSem = useMutation({
mutationFn: () => semesterDelete(sem.id),
onSuccess: () => { invalidate(); setSemDialog(false); toast('success', '学期已删除'); }
});
return (
<Card>
<CardHeader
title={sem.term}
extra={
<div className="flex items-center gap-2">
<Badge color="var(--brand-text)">绩点 {sem.gpa ?? '—'}</Badge>
{sem.rank != null && <Badge color="var(--success)">排名 {sem.rank}/{sem.total_students ?? '—'}</Badge>}
<Badge color="var(--text-3)">均分 {sem.stats.avg ?? '—'}</Badge>
<Badge color="var(--text-3)">{sem.stats.credits} 学分</Badge>
<Button size="sm" variant="ghost" onClick={() => autoGpaMut.mutate()} disabled={autoGpaMut.isPending}>
<Calculator size={13} /> 自动绩点
</Button>
<Button size="icon-sm" variant="ghost" aria-label="编辑学期" onClick={() => setSemDialog(true)}>
<Pencil size={13} />
</Button>
</div>
}
/>
<CardBody className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{['课程', '学分', '分数', '等级', '绩点', ''].map(h => (
<th key={h} className="border-b border-line bg-surface-2 px-3 py-2 text-left text-[12px] font-semibold text-ink-3">{h}</th>
))}
</tr>
</thead>
<tbody>
{sem.courses.map(c => (
editingId === c.id
? <CourseEditRow key={c.id} sem={sem} course={c} onDone={() => setEditingId(null)} />
: (
<tr key={c.id} className="transition-colors hover:bg-surface-2">
<td className="border-b border-line px-3 py-2 font-medium text-ink-1">{c.course_name}</td>
<td className="num border-b border-line px-3 py-2 text-ink-2">{c.credit}</td>
<td className="num border-b border-line px-3 py-2">
{c.score != null ? <span className="font-medium">{c.score}</span> : <span className="text-ink-3">未录入</span>}
</td>
<td className="border-b border-line px-3 py-2">
{c.band ? <Badge color={c.band.color}>{c.band.label}</Badge> : <span className="text-ink-3">—</span>}
</td>
<td className="num border-b border-line px-3 py-2 text-ink-2">{c.grade_point ?? '—'}</td>
<td className="border-b border-line px-3 py-2">
<div className="flex justify-end gap-1">
<Button size="icon-sm" variant="ghost" aria-label="编辑课程" onClick={() => setEditingId(c.id)}>
<Pencil size={13} />
</Button>
<Button size="icon-sm" variant="ghost" aria-label="删除课程" className="text-ink-3 hover:text-danger" onClick={() => setDeleting(c)}>
<Trash2 size={13} />
</Button>
</div>
</td>
</tr>
)
))}
<CourseAddRow sem={sem} />
</tbody>
</table>
</div>
</CardBody>
<Confirm
open={!!deleting}
title="删除课程"
content={deleting ? '确定删除「' + deleting.course_name + '」吗?' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delCourse.mutate(deleting.id)}
/>
<SemesterDialog
open={semDialog}
sem={sem}
onClose={() => setSemDialog(false)}
deleteMut={delSem}
/>
</Card>
);
}
function CourseAddRow({ sem }: { sem: SemesterGrades }) {
const qc = useQueryClient();
const toast = useToast();
const [name, setName] = useState('');
const [credit, setCredit] = useState('');
const [score, setScore] = useState('');
const [gp, setGp] = useState('');
const addMut = useMutation({
mutationFn: () => courseUpsert({
semester_id: sem.id, course_name: name, credit: Number(credit) || 0,
score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp)
}),
onSuccess: () => {
setName(''); setCredit(''); setScore(''); setGp('');
void qc.invalidateQueries({ queryKey: ['grades'] });
},
onError: (e: Error) => toast('error', e.message)
});
return (
<tr className="bg-surface-2">
<td className="px-3 py-2"><Input className="h-7 w-[160px]" placeholder="课程名称" value={name} onChange={e => setName(e.target.value)} /></td>
<td className="px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" placeholder="0" value={credit} onChange={e => setCredit(e.target.value)} /></td>
<td className="px-3 py-2"><Input type="number" className="num h-7 w-[72px]" placeholder="0-100" value={score} onChange={e => setScore(e.target.value)} /></td>
<td className="px-3 py-2 text-ink-3">—</td>
<td className="px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" placeholder="选填" value={gp} onChange={e => setGp(e.target.value)} /></td>
<td className="px-3 py-2">
<Button size="sm" variant="secondary" disabled={!name.trim() || addMut.isPending} onClick={() => addMut.mutate()}>
<Plus size={13} /> 添加
</Button>
</td>
</tr>
);
}
function CourseEditRow({ sem, course, onDone }: { sem: SemesterGrades; course: CourseGrade; onDone: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const [name, setName] = useState(course.course_name);
const [credit, setCredit] = useState(String(course.credit));
const [score, setScore] = useState(course.score != null ? String(course.score) : '');
const [gp, setGp] = useState(course.grade_point != null ? String(course.grade_point) : '');
const saveMut = useMutation({
mutationFn: () => courseUpsert({
id: course.id, semester_id: sem.id, course_name: name, credit: Number(credit) || 0,
score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp)
}),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); onDone(); },
onError: (e: Error) => toast('error', e.message)
});
return (
<tr className="bg-brand-subtle/40">
<td className="border-b border-line px-3 py-2"><Input className="h-7 w-[160px]" value={name} onChange={e => setName(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" value={credit} onChange={e => setCredit(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Input type="number" className="num h-7 w-[72px]" value={score} onChange={e => setScore(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2 text-[12px] text-ink-3">保存后按梯度定级</td>
<td className="border-b border-line px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" value={gp} onChange={e => setGp(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2">
<div className="flex justify-end gap-1.5">
<Button size="sm" variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
<Button size="sm" variant="ghost" onClick={onDone}>取消</Button>
</div>
</td>
</tr>
);
}
// ---------- 学期编辑 ----------
function AddSemesterButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="primary" size="md" onClick={() => setOpen(true)}>
<Plus size={14} /> 新增学期
</Button>
<SemesterDialog open={open} sem={null} onClose={() => setOpen(false)} />
</>
);
}
function SemesterDialog({ open, sem, onClose, deleteMut }: {
open: boolean;
sem: SemesterGrades | null;
onClose: () => void;
deleteMut?: { mutate: () => void; isPending: boolean };
}) {
const qc = useQueryClient();
const toast = useToast();
const [term, setTerm] = useState(sem?.term ?? '');
const [gpa, setGpa] = useState(sem?.gpa != null ? String(sem.gpa) : '');
const [rank, setRank] = useState(sem?.rank != null ? String(sem.rank) : '');
const [total, setTotal] = useState(sem?.total_students != null ? String(sem.total_students) : '');
const [error, setError] = useState('');
const saveMut = useMutation({
mutationFn: () => semesterUpsert({
id: sem?.id, term, gpa: gpa === '' ? null : Number(gpa),
rank: rank === '' ? null : Number(rank), total_students: total === '' ? null : Number(total)
}),
onSuccess: () => {
toast('success', sem ? '学期已更新' : '学期已新增');
void qc.invalidateQueries({ queryKey: ['grades'] });
onClose();
},
onError: (e: Error) => setError(e.message)
});
return (
<Dialog
open={open}
onClose={onClose}
width={480}
title={sem ? '编辑学期 · ' + sem.term : '新增学期'}
footer={
<>
{error && <span className="mr-auto text-[12px] text-danger">{error}</span>}
{sem && (
<Button variant="danger-ghost" className="mr-auto" onClick={() => deleteMut?.mutate()} disabled={deleteMut?.isPending}>
<Trash2 size={13} /> 删除学期
</Button>
)}
<Button variant="secondary" onClick={onClose}>取消</Button>
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
</>
}
>
<div className="flex flex-col gap-3.5">
<Field label="学期名称" required hint="如 2024-2025-1">
<Input value={term} onChange={e => setTerm(e.target.value)} placeholder="2024-2025-1" />
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label="绩点">
<Input type="number" step="0.01" className="num" value={gpa} onChange={e => setGpa(e.target.value)} placeholder="选填" />
</Field>
<Field label="排名">
<Input type="number" className="num" value={rank} onChange={e => setRank(e.target.value)} placeholder="选填" />
</Field>
<Field label="年级人数">
<Input type="number" className="num" value={total} onChange={e => setTotal(e.target.value)} placeholder="选填" />
</Field>
</div>
<p className="flex items-start gap-1.5 rounded-md bg-surface-2 px-3 py-2 text-[12px] text-ink-3">
<Calculator size={13} className="mt-0.5 shrink-0" />
绩点可手动填写,也可在卡片上点「自动绩点」按课程学分加权计算。
</p>
</div>
</Dialog>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react';
import {
footprintDelete, footprintList, listTaxonomy, listTypes, statsOverview
} from '@/lib/api';
import type { FootprintRow } from '@/lib/types';
import {
Button, Confirm, Dropdown, DropdownItem, Empty, Input, Select, Table, TableWrap, Td, Th, Tr, useToast
} from '@/components/ui';
import { StatusBadge, LevelBadge, RoleBadge, GradeBadge } from '@/components/common/badges';
import { RecordEditorDialog } from '@/components/records/RecordEditorDialog';
import { RecordDetailDialog } from '@/components/records/RecordDetailDialog';
import { fmtRange, fmtDateTime } from '@/lib/format';
const PAGE_SIZE = 20;
export function RecordsPage() {
const qc = useQueryClient();
const toast = useToast();
const [params, setParams] = useSearchParams();
const [q, setQ] = useState(params.get('q') ?? '');
const [typeId, setTypeId] = useState('');
const [year, setYear] = useState('');
const [level, setLevel] = useState('');
const [role, setRole] = useState('');
const [status, setStatus] = useState('');
const [sort, setSort] = useState('date_desc');
const [page, setPage] = useState(1);
const [editingId, setEditingId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleting, setDeleting] = useState<FootprintRow | null>(null);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
// 新建入口:/records?new=1
useEffect(() => {
if (params.get('new') === '1') {
setEditingId(null);
setEditorOpen(true);
params.delete('new');
setParams(params, { replace: true });
}
}, [params, setParams]);
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes });
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy() });
const yearsQ = useQuery({ queryKey: ['stats', undefined], queryFn: () => statsOverview(undefined) });
const query = useMemo(() => ({
q: q || undefined,
types: typeId ? [Number(typeId)] : undefined,
levels: level ? [level] : undefined,
roles: role ? [role] : undefined,
statuses: status ? [status] : undefined,
year: year ? Number(year) : undefined,
sort,
page,
page_size: PAGE_SIZE
}), [q, typeId, year, level, role, status, sort, page]);
const listQ = useQuery({ queryKey: ['footprints', query], queryFn: () => footprintList(query) });
useEffect(() => { setPage(1); }, [q, typeId, year, level, role, status]);
const delMut = useMutation({
mutationFn: (id: number) => footprintDelete(id),
onSuccess: () => {
toast('success', '记录已删除');
void qc.invalidateQueries({ queryKey: ['footprints'] });
void qc.invalidateQueries({ queryKey: ['timeline'] });
void qc.invalidateQueries({ queryKey: ['stats'] });
setDeleting(null);
}
});
const total = listQ.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const openNew = () => { setEditingId(null); setEditorOpen(true); };
const openEdit = (id: number) => { setEditingId(id); setEditorOpen(true); };
const openDetail = (id: number) => { setDetailId(id); setDetailOpen(true); };
return (
<div className="px-6 py-5">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Input
className="w-[220px]"
placeholder="搜索标题、内容、结果、标签…"
value={q}
onChange={e => setQ(e.target.value)}
/>
<Select className="w-[130px]" value={typeId} onChange={e => setTypeId(e.target.value)}
options={[{ value: '', label: '全部类型' }, ...(typesQ.data?.items ?? []).map(t => ({ value: String(t.id), label: t.name }))]} />
<Select className="w-[110px]" value={year} onChange={e => setYear(e.target.value)}
options={[{ value: '', label: '全部年份' }, ...(yearsQ.data?.years ?? []).map(y => ({ value: String(y), label: y + ' 年' }))]} />
<Select className="w-[110px]" value={level} onChange={e => setLevel(e.target.value)}
options={[{ value: '', label: '全部级别' }, ...(taxQ.data?.items ?? []).filter(t => t.kind === 'level').map(t => ({ value: t.value, label: t.value }))]} />
<Select className="w-[100px]" value={role} onChange={e => setRole(e.target.value)}
options={[{ value: '', label: '全部角色' }, ...(taxQ.data?.items ?? []).filter(t => t.kind === 'role').map(t => ({ value: t.value, label: t.value }))]} />
<Select className="w-[110px]" value={status} onChange={e => setStatus(e.target.value)}
options={[
{ value: '', label: '全部状态' },
{ value: 'draft', label: '草稿' },
{ value: 'ongoing', label: '进行中' },
{ value: 'submitted', label: '已提交待果' },
{ value: 'finished', label: '已完结' }
]} />
<Select className="w-[130px]" value={sort} onChange={e => setSort(e.target.value)}
options={[
{ value: 'date_desc', label: '时间从新到旧' },
{ value: 'date_asc', label: '时间从旧到新' },
{ value: 'updated_desc', label: '最近修改' },
{ value: 'weight_desc', label: '重要度优先' }
]} />
<span className="num ml-auto text-[12px] text-ink-3">共 {total} 条</span>
<Button variant="primary" size="md" onClick={openNew}>
<Plus size={14} />
新建
</Button>
</div>
<div className="rounded-lg border border-line bg-surface shadow-card">
<TableWrap className="max-h-[calc(100vh-260px)] overflow-y-auto">
<Table>
<thead>
<tr>
<Th className="w-[32%]">标题</Th>
<Th>类型</Th>
<Th>时间</Th>
<Th>级别</Th>
<Th>奖等</Th>
<Th>角色</Th>
<Th>状态</Th>
<Th className="text-right">附件</Th>
<Th>更新时间</Th>
<Th className="w-[52px]" />
</tr>
</thead>
<tbody>
{(listQ.data?.items ?? []).map(row => (
<Tr key={row.id} onClick={() => openDetail(row.id)}>
<Td>
<div className="flex items-center gap-2">
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: row.type_color }} />
<span className="truncate font-medium" title={row.title}>{row.title}</span>
</div>
</Td>
<Td className="whitespace-nowrap text-ink-2">{row.type_name}</Td>
<Td className="num whitespace-nowrap text-ink-2">{fmtRange(row.start_date, row.end_date)}</Td>
<Td><LevelBadge level={row.level} /></Td>
<Td><GradeBadge grade={row.award_grade} /></Td>
<Td><RoleBadge role={row.role} /></Td>
<Td><StatusBadge status={row.status} /></Td>
<Td className="num text-right text-ink-2">{row.attachment_count > 0 ? row.attachment_count : ''}</Td>
<Td className="num whitespace-nowrap text-ink-3">{fmtDateTime(row.updated_at)}</Td>
<Td onClick={e => e.stopPropagation()}>
<Dropdown
trigger={
<Button variant="ghost" size="icon-sm" aria-label="更多操作">
<MoreHorizontal size={15} />
</Button>
}
>
{close => (
<>
<DropdownItem onClick={() => { close(); openEdit(row.id); }}>
<Pencil size={13} /> 编辑
</DropdownItem>
<DropdownItem danger onClick={() => { close(); setDeleting(row); }}>
<Trash2 size={13} /> 删除
</DropdownItem>
</>
)}
</Dropdown>
</Td>
</Tr>
))}
</tbody>
</Table>
{!listQ.isLoading && !listQ.data?.items.length && (
<Empty
title="没有匹配的记录"
description="调整筛选条件,或新建一条记录。"
action={
<Button variant="primary" onClick={openNew}>
<Plus size={14} /> 新建记录
</Button>
}
/>
)}
</TableWrap>
{total > 0 && (
<div className="flex h-11 items-center justify-end gap-3 border-t border-line px-4 text-[12px] text-ink-3">
<span className="num">第 {page} / {totalPages} 页</span>
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
<Button variant="secondary" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</Button>
</div>
)}
</div>
<RecordDetailDialog
open={detailOpen}
onClose={() => setDetailOpen(false)}
footprintId={detailId}
onEdit={id => {
setDetailOpen(false);
openEdit(id);
}}
/>
<RecordEditorDialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
footprintId={editingId}
/>
<Confirm
open={!!deleting}
title="删除记录"
content={deleting ? '确定删除「' + deleting.title + '」吗?该操作不可撤销。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
loading={delMut.isPending}
/>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { deleteDimension, deleteTaxonomy, listDimensions, listTaxonomy, upsertDimension, upsertTaxonomy } from '@/lib/api';
import { Button, Card, CardHeader, Confirm, Input, useToast } from '@/components/ui';
const PALETTE = ['#3056d3', '#7c3aed', '#0891b2', '#16a34a', '#d97706', '#dc2626', '#db2777', '#64748b'];
export function TaxonomyPage() {
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
<p className="mb-4 text-[12px] text-ink-3">
字典驱动录入表单中的下拉选项;维度是自定义的成长分类轴,一条记录可挂多个维度。
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<TaxonomyCard kind="level" title="级别字典" description="国际级、国家级、省市级等,可按学校口径增改" />
<TaxonomyCard kind="role" title="角色字典" description="主导、核心、参加等参与角色" />
<DimensionCard />
</div>
</div>
);
}
function TaxonomyCard({ kind, title, description }: { kind: string; title: string; description: string }) {
const qc = useQueryClient();
const toast = useToast();
const taxQ = useQuery({ queryKey: ['taxonomy-all'], queryFn: () => listTaxonomy() });
const items = (taxQ.data?.items ?? []).filter(t => t.kind === kind).sort((a, b) => a.sort - b.sort);
const [draft, setDraft] = useState('');
const [deleting, setDeleting] = useState<{ id: number; value: string } | null>(null);
const upMut = useMutation({
mutationFn: upsertTaxonomy,
onSuccess: () => void qc.invalidateQueries({ queryKey: ['taxonomy-all'] })
});
const delMut = useMutation({
mutationFn: (id: number) => deleteTaxonomy(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['taxonomy-all'] });
toast('success', '已删除');
setDeleting(null);
},
onError: (e: Error) => { toast('error', e.message); setDeleting(null); }
});
return (
<Card>
<CardHeader title={title} extra={<span className="text-[11px] text-ink-3">{description}</span>} />
<div className="flex flex-col gap-1 p-3">
{items.map((t, i) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-surface-2">
<input
type="color"
value={t.color ?? PALETTE[i % PALETTE.length]}
className="size-5 cursor-pointer rounded border border-line bg-surface"
onChange={e => upMut.mutate({ id: t.id, kind, value: t.value, color: e.target.value, sort: t.sort })}
/>
<input
className="h-7 flex-1 rounded border border-transparent bg-transparent px-1.5 text-[13px] text-ink-1 hover:border-line focus:border-brand focus:outline-none"
value={t.value}
onChange={() => {}}
onBlur={e => {
const v = e.target.value.trim();
if (v && v !== t.value) upMut.mutate({ id: t.id, kind, value: v, color: t.color, sort: t.sort });
}}
key={t.value}
defaultValue={t.value}
/>
<span className="num w-14 text-right text-[11px] text-ink-3">#{t.sort}</span>
{!t.is_builtin ? (
<Button variant="ghost" size="icon-sm" aria-label="删除" onClick={() => setDeleting({ id: t.id, value: t.value })}>
<Trash2 size={13} />
</Button>
) : (
<span className="w-7 text-center text-[11px] text-ink-3">内置</span>
)}
</div>
))}
<div className="mt-1 flex items-center gap-2 border-t border-line pt-2">
<Input
className="flex-1"
placeholder="新增条目,回车或点击按钮"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && draft.trim()) {
upMut.mutate({ kind, value: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}
}}
/>
<Button
size="sm"
variant="secondary"
disabled={!draft.trim()}
onClick={() => {
upMut.mutate({ kind, value: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}}
>
<Plus size={13} /> 添加
</Button>
</div>
</div>
<Confirm
open={!!deleting}
title="删除字典条目"
content={deleting ? '确定删除「' + deleting.value + '」吗?' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}
function DimensionCard() {
const qc = useQueryClient();
const toast = useToast();
const dimsQ = useQuery({ queryKey: ['dimensions'], queryFn: listDimensions });
const items = [...(dimsQ.data?.items ?? [])].sort((a, b) => a.sort - b.sort);
const [draft, setDraft] = useState('');
const [deleting, setDeleting] = useState<{ id: number; name: string } | null>(null);
const upMut = useMutation({
mutationFn: upsertDimension,
onSuccess: () => void qc.invalidateQueries({ queryKey: ['dimensions'] })
});
const delMut = useMutation({
mutationFn: (id: number) => deleteDimension(id),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dimensions'] }); toast('success', '已删除'); setDeleting(null); },
onError: (e: Error) => { toast('error', e.message); setDeleting(null); }
});
return (
<Card>
<CardHeader title="成长维度" extra={<span className="text-[11px] text-ink-3">如:学业深造 / 职业发展 / 兴趣生活</span>} />
<div className="flex flex-col gap-1 p-3">
{items.map((d, i) => (
<div key={d.id} className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-surface-2">
<span className="size-3 rounded-full" style={{ backgroundColor: d.color ?? PALETTE[i % PALETTE.length] }} />
<input
className="h-7 flex-1 rounded border border-transparent bg-transparent px-1.5 text-[13px] text-ink-1 hover:border-line focus:border-brand focus:outline-none"
key={d.name}
defaultValue={d.name}
onBlur={e => {
const v = e.target.value.trim();
if (v && v !== d.name) upMut.mutate({ id: d.id, name: v, color: d.color, sort: d.sort });
}}
/>
<Button variant="ghost" size="icon-sm" aria-label="删除" onClick={() => setDeleting({ id: d.id, name: d.name })}>
<Trash2 size={13} />
</Button>
</div>
))}
{!items.length && (
<div className="py-2 text-center text-[12px] text-ink-3">尚未配置维度</div>
)}
<div className="mt-1 flex items-center gap-2 border-t border-line pt-2">
<Input
className="flex-1"
placeholder="新增维度名称"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && draft.trim()) {
upMut.mutate({ name: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 });
setDraft('');
}
}}
/>
<Button
size="sm"
variant="secondary"
disabled={!draft.trim()}
onClick={() => { upMut.mutate({ name: draft.trim(), sort: (items.at(-1)?.sort ?? 0) + 10 }); setDraft(''); }}
>
<Plus size={13} /> 添加
</Button>
</div>
</div>
<Confirm
open={!!deleting}
title="删除维度"
content={deleting ? '确定删除「' + deleting.name + '」吗?记录与该维度的关联将一并移除。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Paperclip, CalendarDays } from 'lucide-react';
import { listTypes, timeline } from '@/lib/api';
import type { FootprintRow } from '@/lib/types';
import { Empty, Input, Select, Badge } from '@/components/ui';
import { StatusBadge, LevelBadge, RoleBadge, GradeBadge, NegativeBadge } from '@/components/common/badges';
import { TypeIcon } from '@/components/common/typeIcons';
import { RecordEditorDialog } from '@/components/records/RecordEditorDialog';
import { RecordDetailDialog } from '@/components/records/RecordDetailDialog';
export function TimelinePage() {
const [q, setQ] = useState('');
const [typeId, setTypeId] = useState('');
const [year, setYear] = useState('');
const [polarity, setPolarity] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes });
const query = useMemo(() => ({
q: q || undefined,
types: typeId ? [Number(typeId)] : undefined,
year: year ? Number(year) : undefined,
polarity: polarity || undefined,
sort: 'date_desc',
page_size: 2000
}), [q, typeId, year, polarity]);
const tlQ = useQuery({ queryKey: ['timeline', query], queryFn: () => timeline(query) });
const years = useMemo(() => {
const ys = new Set<number>();
for (const g of tlQ.data?.groups ?? []) ys.add(g.year);
return Array.from(ys).sort((a, b) => b - a);
}, [tlQ.data]);
const openDetail = (id: number) => {
setDetailId(id);
setDetailOpen(true);
};
return (
<div className="mx-auto max-w-[880px] px-6 py-5">
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
className="w-[220px]"
placeholder="搜索足迹…"
value={q}
onChange={e => setQ(e.target.value)}
/>
<Select
className="w-[130px]"
value={typeId}
onChange={e => setTypeId(e.target.value)}
options={[{ value: '', label: '全部类型' }, ...(typesQ.data?.items ?? []).map(t => ({ value: String(t.id), label: t.name }))]}
/>
<Select
className="w-[120px]"
value={year}
onChange={e => setYear(e.target.value)}
options={[{ value: '', label: '全部年份' }, ...years.map(y => ({ value: String(y), label: y + ' 年' }))]}
/>
<Select
className="w-[110px]"
value={polarity}
onChange={e => setPolarity(e.target.value)}
options={[
{ value: '', label: '全部性质' },
{ value: 'positive', label: '正面' },
{ value: 'negative', label: '负面' }
]}
/>
<span className="num ml-auto text-[12px] text-ink-3">共 {tlQ.data?.total ?? 0} 条</span>
</div>
{tlQ.isLoading ? (
<div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>
) : !tlQ.data?.groups.length ? (
<Empty
icon={<CalendarDays size={36} strokeWidth={1.5} />}
title="暂无足迹"
description="点击右上角「新建记录」,开始记录你的第一段足迹。"
/>
) : (
<div className="relative">
<div className="absolute bottom-0 left-[7px] top-0 w-px bg-line" aria-hidden />
{tlQ.data.groups.map(group => (
<section key={group.year} className="mb-2">
<div className="sticky top-0 z-10 -mx-6 flex items-center gap-2 bg-bg/90 px-6 py-2 backdrop-blur">
<span className="num text-[16px] font-semibold text-ink-1">{group.year}</span>
<span className="text-[12px] text-ink-3">{group.count} 条记录</span>
</div>
{group.months.map(m => (
<div key={m.month} className="mb-3">
<div className="mb-2 pl-6 text-[12px] font-medium text-ink-3">{m.month} 月</div>
<div className="flex flex-col gap-2">
{m.items.map(item => (
<TimelineItem key={item.id} row={item} onClick={() => openDetail(item.id)} />
))}
</div>
</div>
))}
</section>
))}
</div>
)}
<RecordDetailDialog
open={detailOpen}
onClose={() => setDetailOpen(false)}
footprintId={detailId}
onEdit={id => {
setDetailOpen(false);
setEditingId(id);
setEditorOpen(true);
}}
/>
<RecordEditorDialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
footprintId={editingId}
/>
</div>
);
}
function TimelineItem({ row, onClick }: { row: FootprintRow; onClick: () => void }) {
const date = (row.start_date ?? row.created_at).slice(0, 10);
return (
<button
onClick={onClick}
className="group relative w-full rounded-lg border border-line bg-surface px-4 py-3 text-left shadow-card transition-all hover:border-line-strong hover:shadow-pop"
>
<span
className="absolute left-0 top-3 bottom-3 w-[3px] rounded-full"
style={{ backgroundColor: row.type_color }}
aria-hidden
/>
<div className="flex items-start gap-3">
<div className="num w-[52px] shrink-0 pt-0.5 text-[12px] leading-relaxed text-ink-3">
{date.slice(5, 7)}-{date.slice(8, 10)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[13.5px] font-medium text-ink-1 group-hover:text-brand-text">
{row.title}
</span>
<Badge color={row.type_color}>{row.type_name}</Badge>
<LevelBadge level={row.level} />
<GradeBadge grade={row.award_grade} />
<RoleBadge role={row.role} />
<StatusBadge status={row.status} />
{row.polarity === 'negative' && <NegativeBadge />}
</div>
{row.organizer && (
<div className="mt-0.5 text-[12px] text-ink-3">{row.organizer}</div>
)}
{row.description && (
<p className="mt-1 line-clamp-2 text-[12.5px] leading-relaxed text-ink-2">{row.description}</p>
)}
{(row.result || row.tags.length > 0 || row.attachment_count > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-ink-3">
{row.result && <span className="text-success">结果:{row.result}</span>}
{row.tags.map(t => (
<span key={t} className="rounded-sm bg-surface-3 px-1.5 py-px">{t}</span>
))}
{row.attachment_count > 0 && (
<span className="inline-flex items-center gap-1">
<Paperclip size={11} />
{row.attachment_count}
</span>
)}
</div>
)}
</div>
<div className="shrink-0 pt-1 text-ink-3">
<TypeIcon name={row.type_icon} color={row.type_color} size={16} />
</div>
</div>
</button>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Plus, Trash2, EyeOff } from 'lucide-react';
import { deleteType, listTypes, upsertType } from '@/lib/api';
import type { FootprintType, FormField, WidgetType } from '@/lib/types';
import {
Badge, Button, Card, Confirm, Dialog, Field, Input, Select, useToast
} from '@/components/ui';
import { TypeIcon, ICON_CHOICES } from '@/components/common/typeIcons';
const PALETTE = ['#3056d3', '#7c3aed', '#0891b2', '#0f766e', '#16a34a', '#65a30d', '#d97706', '#dc2626', '#db2777', '#64748b'];
const WIDGETS: { value: WidgetType; label: string }[] = [
{ value: 'text', label: '单行文本' },
{ value: 'textarea', label: '多行文本' },
{ value: 'number', label: '数值' },
{ value: 'date', label: '日期' },
{ value: 'select', label: '单选' },
{ value: 'multiselect', label: '多选' },
{ value: 'link', label: '关联记录' }
];
export function TypesPage() {
const qc = useQueryClient();
const toast = useToast();
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes });
const [editing, setEditing] = useState<FootprintType | 'new' | null>(null);
const [deleting, setDeleting] = useState<FootprintType | null>(null);
const delMut = useMutation({
mutationFn: (t: FootprintType) => deleteType(t.id),
onSuccess: () => {
toast('success', '类型已删除');
void qc.invalidateQueries({ queryKey: ['types'] });
setDeleting(null);
},
onError: (e: Error) => {
toast('error', e.message);
setDeleting(null);
}
});
const items = typesQ.data?.items ?? [];
return (
<div className="mx-auto max-w-[1080px] px-6 py-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-[12px] text-ink-3">
每个类型可配置专属字段模板,录入表单、详情页与检索列随之联动。内置类型不可删除,可改名或停用。
</p>
<Button variant="primary" onClick={() => setEditing('new')}>
<Plus size={14} /> 新建类型
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{items.map(t => (
<Card key={t.id} className="flex items-center gap-3 px-4 py-3.5">
<div
className="flex size-9 shrink-0 items-center justify-center rounded-md"
style={{ backgroundColor: t.color + '18', color: t.color }}
>
<TypeIcon name={t.icon} color={t.color} size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[13.5px] font-medium text-ink-1">{t.name}</span>
{t.is_builtin && <Badge color="var(--text-3)">内置</Badge>}
{t.closed && <Badge color="var(--warning)">已停用</Badge>}
</div>
<div className="num text-[12px] text-ink-3">
{t.usage_count ?? 0} 条记录 · {t.form_template.length} 个类型字段
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon-sm" aria-label="编辑" onClick={() => setEditing(t)}>
<Pencil size={14} />
</Button>
{!t.is_builtin && (
<Button variant="ghost" size="icon-sm" aria-label="删除" onClick={() => setDeleting(t)}>
<Trash2 size={14} />
</Button>
)}
</div>
</Card>
))}
</div>
{editing && (
<TypeEditor
source={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void qc.invalidateQueries({ queryKey: ['types'] });
}}
/>
)}
<Confirm
open={!!deleting}
title="删除类型"
content={deleting ? '确定删除类型「' + deleting.name + '」吗?已有该类型的记录需先迁移。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting)}
/>
</div>
);
}
function TypeEditor({ source, onClose, onSaved }: { source: FootprintType | null; onClose: () => void; onSaved: () => void }) {
const toast = useToast();
const [name, setName] = useState(source?.name ?? '');
const [icon, setIcon] = useState(source?.icon ?? 'tag');
const [color, setColor] = useState(source?.color ?? PALETTE[0]);
const [sort, setSort] = useState(String(source?.sort ?? 100));
const [closed, setClosed] = useState(source?.closed ?? false);
const [fields, setFields] = useState<FormField[]>(source?.form_template ?? []);
const [error, setError] = useState('');
const saveMut = useMutation({
mutationFn: () => {
if (!name.trim()) throw new Error('请填写类型名称');
const keys = new Set<string>();
for (const f of fields) {
if (!f.label.trim() || !f.key.trim()) throw new Error('字段标签与键名均不能为空');
if (keys.has(f.key)) throw new Error('字段键名重复:' + f.key);
keys.add(f.key);
if ((f.widget === 'select' || f.widget === 'multiselect') && !(Array.isArray(f.options) && f.options.length)) {
throw new Error('字段「' + f.label + '」需要配置选项');
}
}
return upsertType({
id: source?.id,
name: name.trim(),
icon, color,
sort: Number(sort) || 100,
form_template: fields,
closed
});
},
onSuccess: () => {
toast('success', source ? '类型已更新' : '类型已创建');
onSaved();
},
onError: (e: Error) => setError(e.message)
});
const updateField = (idx: number, patch: Partial<FormField>) =>
setFields(fs => fs.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
return (
<Dialog
open
onClose={onClose}
width={720}
title={source ? '编辑类型 · ' + source.name : '新建类型'}
footer={
<>
{error && <span className="mr-auto text-[12px] text-danger">{error}</span>}
<Button variant="secondary" onClick={onClose}>取消</Button>
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
</>
}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_120px_100px] gap-3">
<Field label="名称" required>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:学科竞赛" />
</Field>
<Field label="排序">
<Input type="number" className="num" value={sort} onChange={e => setSort(e.target.value)} />
</Field>
<Field label="停用">
<button
type="button"
onClick={() => setClosed(v => !v)}
className={
'flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-[12px] transition-colors ' +
(closed ? 'border-warning bg-warning-subtle text-warning' : 'border-line bg-surface text-ink-2')
}
>
<EyeOff size={13} />
{closed ? '已停用' : '启用中'}
</button>
</Field>
</div>
<Field label="图标">
<div className="flex flex-wrap gap-1">
{ICON_CHOICES.map(ic => (
<button
key={ic.name}
type="button"
title={ic.label}
className={
'flex size-8 items-center justify-center rounded-md border transition-colors ' +
(icon === ic.name ? 'border-brand bg-brand-subtle text-brand-text' : 'border-line bg-surface text-ink-3 hover:border-line-strong')
}
onClick={() => setIcon(ic.name)}
>
<TypeIcon name={ic.name} size={15} color={icon === ic.name ? 'var(--brand-text)' : undefined} />
</button>
))}
</div>
</Field>
<Field label="颜色">
<div className="flex gap-1.5">
{PALETTE.map(c => (
<button
key={c}
type="button"
className={
'size-6 rounded-md border-2 transition-transform ' +
(color === c ? 'scale-110 border-ink-1' : 'border-transparent hover:scale-105')
}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
aria-label={c}
/>
))}
</div>
</Field>
<div className="border-t border-line pt-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-semibold text-ink-2">字段模板({fields.length})</span>
<Button
size="sm"
variant="secondary"
onClick={() => setFields(fs => [...fs, {
key: 'field_' + (fs.length + 1), label: '', widget: 'text'
}])}
>
<Plus size={13} /> 添加字段
</Button>
</div>
<div className="flex flex-col gap-2">
{fields.map((f, i) => (
<div key={i} className="rounded-md border border-line bg-surface-2 p-2.5">
<div className="grid grid-cols-1 sm:grid-cols-[1fr_130px_120px_1fr_28px] items-end gap-2">
<Field label="标签">
<Input value={f.label} onChange={e => updateField(i, { label: e.target.value })} placeholder="显示名" />
</Field>
<Field label="键名">
<Input value={f.key} onChange={e => updateField(i, { key: e.target.value })} />
</Field>
<Field label="控件">
<Select value={f.widget} onChange={e => updateField(i, { widget: e.target.value as WidgetType })} options={WIDGETS} />
</Field>
<Field label={f.widget === 'number' ? '单位' : (f.widget === 'select' || f.widget === 'multiselect') ? '选项(逗号分隔)' : '占位提示'}>
<Input
value={
f.widget === 'number' ? (f.unit ?? '')
: (f.widget === 'select' || f.widget === 'multiselect')
? (Array.isArray(f.options) ? f.options.join(',') : '')
: (f.placeholder ?? '')
}
onChange={e => {
const v = e.target.value;
if (f.widget === 'number') updateField(i, { unit: v });
else if (f.widget === 'select' || f.widget === 'multiselect')
updateField(i, { options: v ? v.split(/[,,]/).map(s => s.trim()).filter(Boolean) : [] });
else updateField(i, { placeholder: v });
}}
/>
</Field>
<Button variant="ghost" size="icon-sm" aria-label="删除字段" className="mb-0.5"
onClick={() => setFields(fs => fs.filter((_, x) => x !== i))}>
<Trash2 size={13} />
</Button>
</div>
<label className="mt-1.5 flex cursor-pointer items-center gap-1.5 text-[12px] text-ink-2">
<input type="checkbox" checked={!!f.required} onChange={e => updateField(i, { required: e.target.checked })} />
必填
{f.widget === 'number' && (
<>
<input type="checkbox" className="ml-2" checked={!!f.metric} onChange={e => updateField(i, { metric: e.target.checked ? f.key : undefined })} />
计入统计指标
</>
)}
</label>
</div>
))}
{!fields.length && (
<div className="rounded-md border border-dashed border-line px-3 py-4 text-center text-[12px] text-ink-3">
该类型暂无专属字段,仅使用通用字段(标题、时间、级别、角色等)
</div>
)}
</div>
</div>
</div>
</Dialog>
);
}
+135
View File
@@ -0,0 +1,135 @@
@import 'tailwindcss';
/* ============================================
Footprints 设计系统 v2 —— 企业级视觉规范
栅格 4px · 控件高 28px · 正文 14px · 圆角 6/8
============================================ */
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-line: var(--border);
--color-line-strong: var(--border-strong);
--color-ink-1: var(--text-1);
--color-ink-2: var(--text-2);
--color-ink-3: var(--text-3);
--color-brand: var(--brand);
--color-brand-hover: var(--brand-hover);
--color-brand-subtle: var(--brand-subtle);
--color-brand-text: var(--brand-text);
--color-success: var(--success);
--color-success-subtle: var(--success-subtle);
--color-warning: var(--warning);
--color-warning-subtle: var(--warning-subtle);
--color-danger: var(--danger);
--color-danger-subtle: var(--danger-subtle);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.05);
--shadow-pop: 0 12px 32px rgba(16, 24, 40, 0.16), 0 2px 8px rgba(16, 24, 40, 0.08);
}
/* ---------- 亮色 ---------- */
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--surface-2: #f9fafb;
--surface-3: #eff1f4;
--border: #e5e8ec;
--border-strong: #ccd3dc;
--text-1: #15181d;
--text-2: #454e5c;
--text-3: #7e8798;
--brand: #3056d3;
--brand-hover: #2748b8;
--brand-subtle: #eef2ff;
--brand-text: #2748b8;
--success: #15803d;
--success-subtle: #ecfdf3;
--warning: #b45309;
--warning-subtle: #fffaeb;
--danger: #dc2626;
--danger-subtle: #fef3f2;
}
/* ---------- 暗色 ---------- */
.dark {
--bg: #0e1013;
--surface: #15181e;
--surface-2: #1a1e25;
--surface-3: #22272f;
--border: #262c35;
--border-strong: #3a424f;
--text-1: #e8ebf1;
--text-2: #a2abb9;
--text-3: #6d7684;
--brand: #7c93f5;
--brand-hover: #94a8f8;
--brand-subtle: #1c2340;
--brand-text: #a3b4f8;
--success: #4ade80;
--success-subtle: #12251a;
--warning: #fbbf24;
--warning-subtle: #2a2213;
--danger: #f87171;
--danger-subtle: #2c1618;
}
html, body, #root { height: 100%; }
body {
background: var(--bg);
color: var(--text-1);
font-family: system-ui, 'Segoe UI Variable Text', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
font-size: 14px;
line-height: 1.55;
letter-spacing: 0.005em;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* 标题排版:紧凑字距 */
h1, h2, h3 {
letter-spacing: -0.01em;
text-wrap: balance;
}
/* 数字统一等宽,对齐敏感场景(表格/指标卡) */
.num {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
::selection { background: color-mix(in srgb, var(--brand) 22%, transparent); }
/* 滚动条:更纤细,悬停加深 */
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
*::-webkit-scrollbar { width: 6px; height: 6px; }
*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
*::-webkit-scrollbar-track { background: transparent; }
:focus-visible {
outline: 2px solid color-mix(in srgb, var(--brand) 55%, transparent);
outline-offset: 1px;
}
.animate-in { animation: fp-fade-slide-in 0.18s ease-out; }
@keyframes fp-fade-slide-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
/* 数字输入框去掉浏览器步进按钮的视觉噪音 */
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"useDefineForClassFields": true,
"paths": {
"@/*": [
"./src/*"
]
},
"baseUrl": "."
},
"include": [
"src"
]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
server: {
port: 5173,
proxy: { '/api': { target: 'http://127.0.0.1:8787', changeOrigin: true } }
},
build: {
outDir: 'dist',
chunkSizeWarningLimit: 1600,
rollupOptions: {
output: {
manualChunks: { echarts: ['echarts'] }
}
}
}
});