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
+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)已回填');