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
+21
View File
@@ -0,0 +1,21 @@
# ---- 依赖与构建产物 ----
node_modules/
dist/
# ---- 运行时数据(本地库 / 附件 / 服务端数据)----
.data/
data/
# ---- Rust ----
src-tauri/target/
src-tauri/gen/
# ---- 自包含 pnpm:保留 pnpm.cjs,忽略解包目录 ----
tools/pnpm-tgz/
# ---- 临时脚本与产物 ----
gen_*.py
parse_docx.py
check_yaml.py
docx_content.txt
*.log
+283
View File
@@ -0,0 +1,283 @@
# 个人留痕系统(Footprints)设计方案
> 版本 v0.5 · 足迹本体是核心;分类、表单、统计、导出、评分全部用户可自定义
> 形态:单人使用 · Windows 桌面应用(MVP 已实现)· 预留多端同步
## 0. 设计原则
1. 足迹本体覆盖一个学生全部值得留痕的事,不局限于任何评分公式
2. **配置即数据**:所有枚举、分类轴、表单字段、统计口径、导出格式都是数据库里的配置,
程序只提供渲染与计算引擎;换学校、换习惯、换关注点 = 改配置,不改代码
3. 时间线完整:正面/负面记录、进行中、草稿都留痕
4. 单机桌面、数据全本地、附件与描述分离存储
## 1. 产品定位
个人成长档案馆:平时随手留痕,需要时按任意维度检索、聚合、量化、导出。
- 检索盘点:"大二上学期做过什么""拿过哪些省级奖""在宣传部做过哪些事" —— 秒级检索
- 材料输出:评奖评优/综测/简历/入党材料 → 选条目、汇总表、佐证包
- 成长量化:自定义维度与指标统计
- 时间线复盘:完整回顾
## 2. 足迹本体模型
```sql
footprint (
id INTEGER PRIMARY KEY,
type_id INTEGER NOT NULL, -- → footprint_type(含自定义类型)
title TEXT NOT NULL,
role TEXT, -- 来自 taxonomy(kind='role'),可自定义
level TEXT, -- 来自 taxonomy(kind='level'),可自定义
polarity TEXT, -- positive/negative
award_grade TEXT, rank INTEGER, -- 可选
organizer TEXT,
start_date DATE, end_date DATE,
status TEXT, -- draft/ongoing/submitted/finished
description TEXT, result TEXT,
metrics TEXT, -- JSON {"志愿时长":8} 任意键 → metric_def 注册后可统计
extras TEXT, -- JSON 按类型表单模板的动态字段
group_id TEXT, -- 事件归组
parent_id INTEGER, -- 晋升链/隶属
tags TEXT, -- JSON 自由标签
weight INTEGER,
created_at DATETIME, updated_at DATETIME
);
footprint_type (
id, name, icon, color, sort,
form_template JSON, -- 动态字段模板(见 §3.2)
is_builtin BOOL, closed BOOL -- 内置类型可改名/停用,不可删除
);
dimension (id, name, color, sort); -- 成长维度(自定义分类轴)
footprint_dimension (footprint_id, dimension_id);
attachment (id, owner_type, owner_id, kind, file_path, file_name, mime, size, taken_at);
semester (id, term UNIQUE, gpa, rank, total_students);
course_grade (id, semester_id, course_name, credit, score, grade_point);
footprint_fts: FTS5(title, description, result, tags, extras_text);
```
## 3. 用户自定义体系(本版重点)
### 3.1 十个可自定义层(从记录到输出)
| # | 对象 | 自定义内容 | 存储 |
|---|---|---|---|
| 1 | 足迹类型 | 新建类型(名称/图标/颜色);内置类型改名、停用 | footprint_type |
| 2 | 表单字段 | 每个类型的动态字段:增删改、必填、默认值、单位 | form_template JSON |
| 3 | 分类轴(维度) | 自定义正交分类(如 学业深造/职业发展/兴趣生活),一条足迹可挂多轴 | dimension + 多对多 |
| 4 | 标签 | 自由打标 + 标签管理(重命名/合并/用量统计) | tags JSON |
| 5 | 枚举字典 | 级别、角色等枚举可增改排序(如加"企业级""国际级") | taxonomy |
| 6 | 统计指标 | 把 metrics 的键注册为指标:求和/计数/去重计数 + 单位 | metric_def |
| 7 | 视图 | 保存筛选条件为一键视图(评优视图/学年视图/任一组合),可设默认 | saved_view |
| 8 | 导出模板 | 列选择、分组方式、文件命名规则 | export_template |
| 9 | 评分适配器 | 学校综测/加分类规则:预设 JSON 导入 + 界面微调分值 | scoring_preset |
| 10 | 外观 | 主题色/深浅模式/首页默认视图/时间线密度 | app_config |
### 3.2 动态表单模板(form_template)
每个类型一个字段数组,驱动录入表单、详情页、检索列三处渲染:
```json
[
{ "key": "team_members", "label": "队伍成员", "widget": "multiselect", "options": "从联系人选" },
{ "key": "my_work", "label": "我的分工", "widget": "text", "required": true },
{ "key": "deliverable", "label": "最终产品", "widget": "text" },
{ "key": "cert_level", "label": "证书等级", "widget": "select",
"options": ["从业资格", "初级", "中级", "高级"] },
{ "key": "hours", "label": "志愿时长", "widget": "number", "unit": "小时",
"metric": "volunteer_hours" }
]
```
- widget 类型:text / textarea / number / date / select / multiselect / link(关联其他足迹) / contact(联系人)
- 数据存 footprint.extras JSON;schema 永不因新字段而改
- widget=number 且声明 metric 时,录入即累计进统计
### 3.3 枚举字典(taxonomy)
```sql
taxonomy (id, kind TEXT, value TEXT, color TEXT, sort INT, is_builtin BOOL, UNIQUE(kind, value));
-- kind: level(级别) / role(角色) / attachment_kind / polarity ...
-- 预置: level = 国家级/省市级/区县级/校级/院级/none;
-- role = 主导/核心/参加 —— 均可增改,例如加"企业实习级"
```
### 3.4 统计指标与视图
```sql
metric_def (id, key TEXT UNIQUE, label TEXT, unit TEXT,
agg TEXT /*sum|count|distinct*/, section TEXT);
saved_view (id, name, filter JSON, sort JSON, is_default BOOL, updated_at);
export_template (id, name, columns JSON, group_by TEXT, filename_rule TEXT);
```
- 指标卡:看板上的"自定义指标卡"由 metric_def 驱动(例:volunteer_hours 求和=志愿总时长;
证书类型 distinct 计数;省级以上获奖 count)
- 视图:筛选器(类型×级别×角色×维度×学年×状态×标签×关键词)任意组合另存,
例:"综测佐证视图" = level∈{国家级,省市级} + polarity=positive + 有附件
### 3.5 评分适配器(第 9 层,V3)
预设 JSON 定义分类映射 + 查表/矩阵/公式 + 去重 + 封顶,对足迹投影综测得分单。
示例:rules/重庆城市科技学院-计网学院-2026-2027.json(参考实现,非系统依赖)。
## 4. 技术选型
Tauri 2 + React 18 + TypeScript + Vite + Tailwind + shadcn/ui + SQLite(rusqlite, FTS5) + ECharts
- 配置驱动 UI:动态表单/动态筛选器/动态看板全部由上述配置表渲染
- 备选:Electron + better-sqlite3(数据层一致可互换)
## 5. 输出
- 任意视图/筛选结果 → xlsx 汇总表(列与分组来自 export_template)
- 勾选条目 → 佐证材料包 zip(编号命名规则可配)
- 适配器激活时 → 综测得分单 + 加分明细对账单
- V3:评优事迹 Word、简历段生成
## 6. 迭代路线
- MVP:类型管理 + 动态表单 + footprint CRUD + 时间线 + FTS 检索 + 附件 + taxonomy + 标签
- V2:维度/指标/视图/导出模板、事件归组聚合、成绩导入、xlsx/佐证包
- V3:评分适配器、AI 字段抽取、评优 Word、同步备份
## 9. 账号系统与多端互通(v0.5 新增)
### 9.1 定位
单人系统中账号 = 同步身份(多端识别 + 云备份 + 未来协作扩展点),不是数据前提。
未登录 = 纯本地模式(现状完全不变),登录后多端打通。
### 9.2 路线选型
| | A 整库文件同步 | B 云优先 | C 本地优先+同步引擎(选定) |
|---|---|---|---|
| 离线 | 可用 | 差 | 完全可用 |
| 冲突 | 整库覆盖丢数据 | 服务端事务 | 行级合并可控 |
| 改造 | 极低 | 高 | 中(数据层+同步模块) |
| 附件 | 易碎 | 对象存储 | 内容寻址+对象存储 |
### 9.3 数据层改造(P1)
```sql
-- 业务表统一增加同步元数据
uuid TEXT; -- uuid v7(时间有序),替代自增 ID 的跨端身份
deleted_at TEXT; -- 软删除墓碑,删除也要同步
hlc TEXT; -- 混合逻辑时钟 "毫秒:计数:设备号",冲突仲裁依据
origin TEXT; -- 创建设备
-- 本地变更日志(推送队列)
CREATE TABLE sync_oplog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
row_uuid TEXT NOT NULL,
op TEXT NOT NULL, -- upsert / delete
hlc TEXT NOT NULL,
synced_at TEXT -- NULL = 待推送
);
```
- 自增 ID 换 UUID(v7):两台设备各自自增必然冲突
- HLC 而非本地时间:时钟回拨免疫,跨设备可比较、同设备单调
- 删除走墓碑:硬删除无法向其他设备传播
### 9.4 同步协议(P2)
- push:本地 oplog 未同步变更 → 服务端按 hlc LWW 仲裁 upsert
- pull:GET /sync?cursor=服务端hlc → 应用比自己新的行 → 推进游标
- 附件:SHA-256 内容寻址;上传前查重;本地按需缓存(同一佐证多处引用只存一份)
### 9.4.1 附件与图片存储布局
本地(桌面端):
```
应用数据目录/
├─ footprints.db ← 元数据(rel_path/sha256/size/mime + 同步元数据)
└─ attachments/{记录id}/{uuid}_{原文件名} ← 图片实体原样保存(EXIF 保留)
```
服务器(sync-server):
```
data/
├─ sync.db ← attachments 表 (user_id, sha256) 主键
└─ blobs/{user_id}/{sha256} ← 内容寻址,跨记录/跨设备去重
```
生命周期:添加 → 本地落盘+元数据入 oplog → push 元数据 → 服务器缺 blob 则补传(哈希校验)
→ 其他端 pull 元数据 → 按需下载。删除 → 元数据墓碑同步,文件延迟 GC(全端确认后回收)。
演进方向:缩略图(256px WebP,网格用缩略图)、大文件分片、可选上传压缩、对象存储适配、E2E 加密。
- 触发:启动 + 写操作后 debounce 3s + 手动同步
- 冲突:按行 LWW(单人多端最优);不做 CRDT
### 9.5 身份层
- 登录:邮箱魔法链接或手机验证码;本地设备 ID 注册
- 凭据:Tauri keyring(Windows Credential Manager)
- 设备管理:device_id ↔ 设备名,可远程下线
- 未登录:同步模块整体旁路,界面显示"本地模式"
### 9.6 服务端选型
| | BaaS(火山引擎 Supabase 版) | 自建轻量服务 |
|---|---|---|
| Auth/DB/存储/Realtime | 全部现成 + RLS 行级权限 | 自管 Postgres + MinIO/OSS |
| 同步实现 | 客户端直连表 + hlc 游标 | 单 /sync 模块 |
| 运维 | 免运维,起步免费 | 一台服务器 |
| 适用 | 快速落地(推荐) | 数据完全自持 |
### 9.7 安全分层
1. 基线:TLS + 服务端账号隔离(RLS)
2. 进阶:本地库 SQLCipher
3. 可选端到端:客户端加密后再上云(代价是服务端不可检索)
### 9.8 分阶段落地
| 阶段 | 内容 | 产出 | 状态 |
|---|---|---|---|
| P1 | UUID/软删除/HLC/oplog + 存量迁移(纯本地) | 同步地基,功能不变 | **已实现**(Node 与 Rust 双端一致) |
| P1.5 | 导出加密同步包手动导入合并 | 无后端的手动多端 | 待做(可选) |
| P2 | 自建同步服务:登录 + push/pull + 附件 hash 上传 | 真正多端互通 | **服务端已实现**(sync-server/,Docker 部署);客户端引擎待接 |
| P3 | Web 端复用 React 前端;移动端 PWA | 随时随地 | **前端响应式 + PWA 改造完成**(manifest/SW/抽屉导航),待服务部署后直连 |
| P4 | 端到端加密;多人协作(复用审核流设计) | 班级/学院版 | 待做 |
### 9.9 自建同步服务实现(sync-server/,v0.2 新增)
- 零 npm 依赖:node:http + node:sqlite,Docker 镜像 node:24-alpine,构建无供应链风险
- 存储模型:`current`(每行最新态,user+table+row_uuid 唯一)+ `changes`(全量历史,seq 全局单调即拉取游标)
- push:按 HLC 三元组(毫秒:计数:设备)逐级比较 LWW;旧推送计入 stale;服务端不解释业务字段(data 为不透明 JSON,业务表间引用由客户端以 uuid 表达)
- pull:`updated_seq > cursor` 增量,游标由服务端 seq 保证单调
- 附件:(user, sha256) 内容寻址去重,上传校验实际哈希,跨设备仅存一份
- 认证:scrypt 口令散列 + Bearer token(365 天)+ 设备登记(同名设备复用 device_id);REGISTER_ENABLED 环境变量控制注册开关
- 多用户:天然按 user_id 隔离(变更/附件/设备);INVITE_CODE 邀请码注册 + MAX_USERS 名额上限 + ADMIN_TOKEN 保护的 /admin/users 管理视图(仅元数据,档案内容管理员不可见)
- 客户端多账号(P2):登录/切换账号,本地档案按账号建独立 SQLite profile
- 部署:`cd sync-server && docker compose up -d --build`,数据落 ./data/;必须经 HTTPS 反代(Caddy 示例见 sync-server/README.md)
### 9.10 验证状态(诚实边界)
已验证(自动化测试):
- 服务端协议:注册/登录/多用户/邀请码/名额/管理视图/push(LWW)/pull(游标)/附件去重/账号隔离 —— 全链路通过
- Node 数据层:53 项回归(CRUD/守卫/FTS/墓碑/级联/oplog)—— 通过
- Rust 后端:9 项运行时测试(uuid v7/HLC 单调/时钟回拨/老库迁移/命令层往返/墓碑/级联/重名守卫/加权计算)—— 通过
测试曾抓出并已修复的关键缺陷:
- Rust exec() 返回"受影响行数"而非 lastInsertRowid,导致桌面端所有创建操作拿到 id=1(关键级)
- Rust footprint_list 的 BASE_SELECT 与 build_where 双 WHERE 拼接导致检索 SQL 报错
- 学期删除未级联墓碑课程,孤儿课程污染总览
尚未闭环(诚实声明):
- 客户端同步引擎(P2)未实现:桌面/手机目前均为纯本地,尚无数据真正流向服务器
- FK 的 uuid 映射约定已定稿(载荷中业务表间引用以 uuid 表达),代码随 P2 实现
- 真实双设备并发、附件 GC、备份恢复等长尾场景待真机验证
P1 实现说明(v0.2):
- schema v2:9 张业务表统一 uuid/deleted_at/origin/hlc 列 + sync_state + sync_oplog + uuid 唯一索引
- 老库迁移幂等:pre_migrate(补列,先于 schema 执行)→ schema → backfill(uuid/hlc/origin)
- 所有写路径接 oplog(Node 与 Rust 26 命令一致):变更先进本地队列,P2 推送直接消费
- 删除统一软删除(墓碑);FTS 内容表在删除时同步清理
- 附件新增 sha256 内容寻址列,为 P2 去重上传就绪
- 新增 sync_status 命令与侧边栏同步状态显示(本地模式 · 待同步 N)
+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'] }
}
}
}
});
@@ -0,0 +1,682 @@
{
"schema_version": 1,
"preset_id": "cqccst-csn-2026-2027",
"name": "学生综合素质测评评分细则",
"school": "重庆城市科技学院",
"college": "计算机与网络安全学院",
"academic_year": "2026-2027",
"effective_from": "2026-09-09",
"period_note": "所有加减分只针对上一学年度,截止当年8月31日",
"total_formula": "品行素质 + 学业成绩 + 文体素质 + 拓展性素质 + 奖惩分",
"grade_order": [
"特等奖",
"一等奖",
"二等奖",
"三等奖",
"优秀奖"
],
"rank_to_grade": [
{
"match": {
"rank": [
1,
1
]
},
"grade": "一等奖"
},
{
"match": {
"rank": [
2,
3
]
},
"grade": "二等奖"
},
{
"match": {
"rank": [
4,
6
]
},
"grade": "三等奖"
},
{
"match": {
"rank_min": 7
},
"grade": "优秀奖"
}
],
"special_grade": {
"grade": "特等奖",
"base": "一等奖",
"extra": 1
},
"sections": [
{
"id": "pinxing",
"name": "品行素质",
"max": 13,
"items": [
{
"id": "politics",
"name": "思想政治基础分",
"max": 3,
"type": "single_select",
"match": {
"footprint_type": "identity"
},
"options": [
{
"label": "中共党员",
"points": 3
},
{
"label": "中共预备党员",
"points": 2.5
},
{
"label": "入党积极分子(考试合格)",
"points": 2
},
{
"label": "共青团员",
"points": 1.5
},
{
"label": "群众",
"points": 1
}
]
},
{
"id": "conduct",
"name": "操行考评成绩",
"max": 8,
"type": "deduction_log",
"cap_note": "累计扣分不超过8分,辅导员制定规则打分",
"rules": [
{
"label": "上课迟到",
"points": 0.5,
"unit": "次",
"is_deduction": true
},
{
"label": "早退",
"points": 0.5,
"unit": "次",
"is_deduction": true
},
{
"label": "旷课",
"points": 1,
"unit": "次",
"is_deduction": true
},
{
"label": "带早餐进课堂",
"points": 0.5,
"unit": "次",
"is_deduction": true
},
{
"label": "会议/活动迟到",
"points": 0.5,
"unit": "次",
"is_deduction": true
},
{
"label": "会议/活动早退",
"points": 0.5,
"unit": "次",
"is_deduction": true
},
{
"label": "会议/活动旷会",
"points": 1,
"unit": "次",
"is_deduction": true
},
{
"label": "不按时请销假",
"points": 1,
"unit": "次",
"is_deduction": true
},
{
"label": "未请假离校",
"points": 1,
"unit": "天",
"is_deduction": true
},
{
"label": "晚归",
"points": 0.5,
"unit": "次",
"is_deduction": true
},
{
"label": "夜不归宿",
"points": 1,
"unit": "次",
"is_deduction": true
},
{
"label": "私自校外住宿",
"points": 3,
"unit": "次",
"is_deduction": true
},
{
"label": "未经院方同意男女互访",
"points": 2,
"unit": "次",
"is_deduction": true
},
{
"label": "私自换宿舍",
"points": 3,
"unit": "次",
"is_deduction": true
},
{
"label": "酗酒",
"points": 1.5,
"unit": "次",
"is_deduction": true
},
{
"label": "赌博",
"points": 2,
"unit": "次",
"is_deduction": true
},
{
"label": "破坏校园公物",
"points": 1,
"unit": "次",
"is_deduction": true
},
{
"label": "影响校风建设",
"points": [
1,
3
],
"unit": "次",
"is_deduction": true,
"manual": true
},
{
"label": "不服从教职工管理",
"points": [
1,
2
],
"unit": "次",
"is_deduction": true,
"manual": true
},
{
"label": "辱骂、殴打教职工",
"points": [
3,
5
],
"unit": "次",
"is_deduction": true,
"manual": true
}
]
},
{
"id": "integrity",
"name": "诚信分",
"max": 2,
"type": "default_deduct",
"default": 2,
"rules": [
{
"label": "评选/考试不诚信(作弊、作弊未遂)",
"points": 2,
"is_deduction": true
},
{
"label": "请假条理由与实际不符等(酌情)",
"manual": true
}
]
}
]
},
{
"id": "academic",
"name": "学业成绩",
"max": 60,
"items": [
{
"id": "gpa",
"name": "平均学分绩点",
"max": 60,
"type": "formula",
"formula": "avg_gpa * 10",
"source": "semester 表(教务系统导出)"
}
]
},
{
"id": "wenti",
"name": "文体素质",
"max": 10,
"items": [
{
"id": "pe",
"name": "体育成绩",
"max": 5,
"type": "formula",
"formula": "sum(两学期体育课成绩) * 0.025",
"note": "大三大四按满分5分计"
},
{
"id": "wenti_bonus",
"name": "文体类加分",
"max": 5,
"type": "award_matrix",
"match": {
"footprint_type": [
"competition",
"publish"
],
"category": "文体类"
},
"matrix_grades": [
"一等奖",
"二等奖",
"三等奖",
"优秀奖"
],
"matrix": {
"国家级": [
3,
2,
1,
0.5
],
"省市级": [
2,
1,
0.5,
0.2
],
"区县级": [
1,
0.5,
0.2,
0.1
],
"校级": [
0.5,
0.2,
0.1,
0.05
],
"二级学院": [
0.2,
0.1,
0.05,
0
]
},
"publish_matrix": {
"国家级": 3,
"省市级": 2,
"区级": 1,
"校级": 0.5,
"二级学院级": 0.2
},
"participation_flat_bonus": [
{
"label": "参加校级比赛方阵/健美操",
"points": 0.5,
"per": "人"
},
{
"label": "参加院级篮球赛/方阵",
"points": 0.1,
"per": "人"
}
],
"dedup": "同一项目(同 group_id)不同类别获奖按最高分值加一次;不同项目可累计",
"excluded": [
"网络答题获奖及类似网上捐款证书一律不加分",
"活动组织过程中颁发的优秀工作者等不适用本量表"
],
"publish_note": "发表作品需提供复印件/录用通知/网页依据且含本人信息;发表同时获奖可叠加计分"
}
]
},
{
"id": "tuozhan",
"name": "拓展性素质",
"max": 17,
"items": [
{
"id": "student_work",
"name": "担任学生工作",
"max": 4,
"type": "role_table",
"match": {
"footprint_type": "student_work"
},
"rules": [
{
"role": "干部",
"校级": 4,
"院级": 3,
"note": "各级团委/学生会/社团联合会/青年志愿者协会成员;校级团体按院级标准"
},
{
"role": "干事",
"校级": 2,
"院级": 1.5
},
{
"role": "支部委员",
"院级": 1.5,
"note": "需届满一年"
},
{
"role": "支部组长",
"院级": 1,
"note": "需届满一年"
},
{
"role": "班长/团支书",
"points": 1
},
{
"role": "其他班委/团支部委员",
"points": 0.5,
"note": "每班不超过4名班委、3名团支部委员"
},
{
"role": "社团干部",
"points": 1,
"note": "社团干事不加分"
},
{
"role": "学工助理",
"points": 2,
"note": "学院设置"
}
],
"constraints": [
"任职需满一年及以上",
"担任多种职务只取最高一项,不累计",
"以学生处/校团委/二级学院/机关部处签章的加分凭条为准"
]
},
{
"id": "contest_award",
"name": "竞赛获奖",
"max": 10,
"type": "award_matrix",
"match": {
"footprint_type": "competition",
"category": "学科/拓展类"
},
"matrix_grades": [
"一等奖",
"二等奖",
"三等奖",
"优秀奖"
],
"matrix": {
"国家级": [
3,
2,
1,
0.5
],
"省市级": [
2,
1,
0.5,
0.2
],
"区县级": [
1,
0.5,
0.2,
0.1
],
"校级": [
0.5,
0.2,
0.1,
0.05
],
"二级学院": [
0.2,
0.1,
0.05,
0
]
},
"dedup": "同一项目(同 group_id)不同类别获奖按最高分值加一次;不同项目、不同类别/不同项目同一类别均可累计",
"member_rule": "集体项目每位成员均按相应等级加分",
"excluded": [
"网络答题获奖及类似网上捐款证书一律不加分",
"活动组织过程中颁发的优秀工作者等不适用本量表"
]
},
{
"id": "public_welfare",
"name": "公益活动",
"max": 3,
"type": "sum_rules",
"match": {
"footprint_type": "volunteer"
},
"rules": [
{
"label": "无偿献血",
"points": 0.5,
"once": true,
"note": "多次献血不叠加,只计一次"
},
{
"label": "捐献骨髓",
"points": 3
},
{
"label": "公益活动(按级别)",
"level_points": {
"国家级": 1.5,
"省市级": 1,
"区县级": 0.5,
"校级": 0.2,
"二级学院级": 0.1
}
},
{
"label": "校区搬迁",
"points": 1
},
{
"label": "迎新活动",
"points": 1
}
],
"dedup": "同一活动同一等级不可累计;同类型不同等级/不同类型可累计",
"cap_note": "累计不超过3分;凭组织部门盖章证明加分"
}
]
},
{
"id": "reward_punish",
"name": "奖惩分",
"max": 5,
"min": -5,
"items": [
{
"id": "reward",
"name": "综测加分",
"max": 5,
"type": "sum_rules",
"rules": [
{
"label": "通报表扬",
"points": 0.5,
"scope": "校级",
"unit": "次"
},
{
"label": "荣誉称号、奖学金",
"level_points": {
"院级": 0.3,
"校级": 0.5,
"区级": 1,
"市级": 1.5,
"国家级": 2
},
"note": "校级/院级优秀团员、优秀志愿者等比骨干类(如优秀团干)少0.1分,区级及以上不区分;奖学金指单项荣誉奖学金(校级)、特等奖学金、国家奖学金、励志奖学金(市级)可加;综合测评奖学金不加分;荣誉称号含校外各级荣誉、五四表彰、争先创优、七一表彰等"
},
{
"label": "英语等级考试",
"sub_rules": [
{
"label": "英语三级",
"points": 0.5
},
{
"label": "四级(专四)",
"points": 1
},
{
"label": "六级(专八)",
"points": 2
}
]
},
{
"label": "计算机等级考试",
"sub_rules": [
{
"label": "国家一级",
"points": 0.5
},
{
"label": "国家二级",
"points": 1
},
{
"label": "国家三级",
"points": 2
},
{
"label": "省市一级",
"points": 0.2
},
{
"label": "省市二级",
"points": 0.5
},
{
"label": "省市三级",
"points": 1
}
]
},
{
"label": "职业资格证书",
"sub_rules": [
{
"label": "从业资格",
"points": 0.5
},
{
"label": "初级",
"points": 1
},
{
"label": "中级",
"points": 2
},
{
"label": "高级",
"points": 3
}
],
"note": "从业资格含教师资格证、计算机软考、普通话、机动车驾驶证等;只通过某证书的某一门课程不加分"
},
{
"label": "本硕直通",
"points": 2
},
{
"label": "云计算",
"points": 1
},
{
"label": "二学历(报名)",
"points": 0.5
},
{
"label": "校级交流",
"points": 0.5
},
{
"label": "卓越班",
"note": "详见卓越班评奖评优实施方案(另文)",
"manual": true
}
],
"dedup": "同项目不同级别只计一项最高分;不同项目不同级别/不同项目同级别可累计;累计加分不超过5分"
},
{
"id": "punish",
"name": "处分扣分",
"type": "deduction_log",
"rules": [
{
"label": "通报批评",
"points": 1,
"is_deduction": true
},
{
"label": "警告",
"points": 2,
"is_deduction": true
},
{
"label": "严重警告",
"points": 3,
"is_deduction": true
},
{
"label": "记过",
"points": 4,
"is_deduction": true
},
{
"label": "留校察看",
"points": 5,
"is_deduction": true
}
]
}
]
}
],
"references": [
"《重庆城市科技学院关于进行2025-2026学年度学生综合素质测评奖学金评选的通知》",
"《重庆城市科技学院学生综合素质测评奖学金评选办法》",
"《重庆城市科技学院学生综合素质测评评分细则》",
"计算机与网络安全学院评分细则(2026-09-09 施行)"
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
data/
.data/
node_modules/
*.md
+7
View File
@@ -0,0 +1,7 @@
# 追加到 /opt/caddy 的 Caddyfile(与 gitea.linyux.cn 等块并列)
# 前置:为 FootPR.linyux.cn 添加 A 记录指向服务器 IP,Caddy 自动签发证书
# 注:域名不区分大小写,配置统一使用小写 footpr.linyux.cn
footpr.linyux.cn {
reverse_proxy footprints-sync:8788
}
+19
View File
@@ -0,0 +1,19 @@
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8788
ENV DATA_DIR=/data
COPY package.json ./
COPY src ./src
RUN mkdir -p /data
EXPOSE 8788
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD wget -qO- http://127.0.0.1:8788/health || exit 1
CMD ["node", "src/index.mjs"]
+82
View File
@@ -0,0 +1,82 @@
# Footprints 自建同步服务
个人留痕系统的多端同步服务端。零 npm 依赖(Node 内置 node:sqlite),SQLite 单文件存储,
专为单用户/小规模多设备设计。协议:行级 LWW(HLC 仲裁)+ seq 游标增量拉取。
## 部署(Docker)
### 方式一:已有 Caddy 容器网络(推荐,与你现有 gitea/halo 等部署同构)
# 1. 仓库上传到服务器(如 /opt/footprints),进入 sync-server 目录
# 2. 网络名已预设为 mynet(Caddy 共享网络,无需再改)
docker compose -f docker-compose.caddy.yml up -d --build
容器不映射宿主端口,Caddy 直接按服务名反代。Caddyfile 增加一块(与现有写法并列):
footpr.linyux.cn {
reverse_proxy footprints-sync:8788
}
前置:为 footpr.linyux.cn 添加 A 记录指向服务器 IP,Caddy 自动签发证书;重载 Caddy(`caddy reload` 或重启容器)。
### 方式二:独立部署(无 Caddy 网络时)
docker compose up -d --build
# 默认只绑定 127.0.0.1:8788,交给宿主上的反代;需要直连时改为 "8788:8788"
数据全部落在 `./data/`(SQLite 库 + 附件 Blob),备份 = 复制该目录。
- 首次部署 `REGISTER_ENABLED=true`,注册自己的账号后改为 `false` 再 `docker compose up -d` 关闭注册
- 验证:curl https://footpr.linyux.cn/health 应返回 {"ok":true,...}
## 多用户
不止一人使用时,服务端天然按账号隔离数据(变更/附件/设备均挂 user_id),无需分库:
- 邀请码:`INVITE_CODE` 设置后,注册请求必须携带 `invite_code` 字段——把邀请码发给你信任的人即可
- 名额:`MAX_USERS` 限制用户总数(0 = 不限),防滥用
- 管理:设置 `ADMIN_TOKEN` 后,`GET /admin/users`(请求头 `X-Admin-Token`)可查看全部用户、设备数、记录数与附件占用
- 客户端:各用户安装桌面应用或 PWA,登录自己的账号即得到独立档案;同一台电脑可切换多个账号(本地档案按账号隔离)
- 数据安全:账号间完全隔离;管理员也只能看到元数据(用户名/设备数/存储占用),看不到档案内容
## 安全要点
- 必须经 HTTPS 访问(Caddy 自动证书);裸 HTTP 暴露公网不可接受
- 数据(SQLite + 附件 Blob)全在 `./data/`,定期备份该目录即可
- 服务端不解释业务字段;表间引用由客户端以 uuid 表达
## API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /health | 健康检查(Docker healthcheck 使用) |
| POST | /auth/register | 注册 {username, password, device_name},返回 {token, device_id} |
| POST | /auth/login | 登录,同上;同名设备复用 device_id |
| POST | /sync/push | 推送变更 {changes:[{table,row_uuid,op,hlc,data}]},返回 {accepted, stale, invalid} |
| GET | /sync/pull?cursor=N | 增量拉取,返回 {changes, cursor, has_more} |
| POST | /sync/attachment | 上传附件 {sha256,size,mime,file_name,data_b64},返回 {dedup} |
| GET | /sync/attachment/:sha256 | 下载附件(内容寻址,跨设备去重) |
| GET | /sync/status | 账号统计 |
鉴权:`Authorization: Bearer <token>`(/sync/* 全部需要)。
### 同步语义
- 服务端不解释业务字段,data 为不透明 JSON;业务表间引用由客户端以 uuid 表达
- 冲突:按 HLC(毫秒:计数:设备)三元组逐级比较,新者胜;旧推送计入 stale 并丢弃
- current 表存每行最新态,changes 表保留完整历史(seq 全局单调,即拉取游标)
- 附件按 (user, sha256) 去重,同一佐证多端引用仅存一份
## 目录
src/index.mjs HTTP 路由
src/db.mjs SQLite schema 与连接
src/auth.mjs 注册/登录/token(scrypt)
src/sync.mjs push(LWW)/ pull(seq 游标)
src/attachments.mjs 附件内容寻址存储
## 相关文件
- `docker-compose.caddy.yml`:与既有 Caddy 容器网络配合(推荐)
- `docker-compose.yml`:独立部署(本机回环端口)
- `Caddyfile.snippet`:Caddyfile 增量片段
+26
View File
@@ -0,0 +1,26 @@
# 与既有 Caddy 反代架构配合使用(推荐):
# 1. 把整个仓库上传到服务器(如 /opt/footprints)
# 2. Caddy 所在的 Docker 网络名已确认为 mynet(docker network ls)
# docker compose -f docker-compose.caddy.yml up -d --build
# 4. Caddyfile 增加反代块(见 README),重载 Caddy
# 5. 注册自己的账号后,把 REGISTER_ENABLED 改为 false 并重新 up -d
services:
footprints-sync:
build: .
container_name: footprints-sync
# 无需 ports 映射:Caddy 通过容器网络按服务名直连(footprints-sync:8788)
volumes:
- ./data:/data
environment:
- REGISTER_ENABLED=true
- INVITE_CODE=please-change-me # 多用户:注册须携带此邀请码(留空则不校验)
- MAX_USERS=20 # 用户数上限,0=不限
# - ADMIN_TOKEN=please-change-me # 设置后可用 GET /admin/users(请求头 X-Admin-Token)
restart: unless-stopped
networks:
- proxy
networks:
proxy:
name: mynet # 服务器上已确认的 Caddy 共享网络
external: true
+16
View File
@@ -0,0 +1,16 @@
# 通用独立部署:直接暴露宿主端口 8788(建议置于反代/内网之后)
# 若你的服务器上已有 Caddy 反代架构,请改用 docker-compose.caddy.yml
services:
footprints-sync:
build: .
container_name: footprints-sync
ports:
- "127.0.0.1:8788:8788" # 只绑定本机回环,交给反代;确认无反代时可改为 "8788:8788"
volumes:
- ./data:/data
environment:
- REGISTER_ENABLED=true # 注册自己的账号后改为 false,再 docker compose up -d
- INVITE_CODE=please-change-me # 多用户:注册须携带此邀请码(留空则不校验)
- MAX_USERS=20 # 用户数上限,0=不限
# - ADMIN_TOKEN=please-change-me # 设置后可用 GET /admin/users(请求头 X-Admin-Token)
restart: unless-stopped
+12
View File
@@ -0,0 +1,12 @@
{
"name": "footprints-sync-server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.mjs"
},
"engines": {
"node": ">=24"
}
}
+37
View File
@@ -0,0 +1,37 @@
// 附件:SHA-256 内容寻址去重存储
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { getDb, BLOB_DIR } from './db.mjs';
export function upload(user_id, { sha256, size, mime, file_name, data_b64 }) {
const d = getDb();
if (!/^[a-f0-9]{64}$/.test(sha256 || '')) throw new Error('sha256 格式不合法');
const buf = Buffer.from(data_b64 || '', 'base64');
const actual = crypto.createHash('sha256').update(buf).digest('hex');
if (actual !== sha256) throw new Error('内容哈希与声明不符');
if (size != null && buf.length !== Number(size)) throw new Error('大小与声明不符');
if (buf.length > 100 * 1024 * 1024) throw new Error('单文件不得超过 100MB');
const dir = path.join(BLOB_DIR, user_id);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, sha256);
if (!fs.existsSync(file)) fs.writeFileSync(file, buf);
const existing = d.prepare('SELECT 1 FROM attachments WHERE user_id=? AND sha256=?').get(user_id, sha256);
if (!existing) {
d.prepare('INSERT INTO attachments (user_id, sha256, size, mime, file_name) VALUES (?,?,?,?,?)')
.run(user_id, sha256, buf.length, mime ?? null, (file_name || '').slice(0, 255));
return { dedup: false, size: buf.length };
}
return { dedup: true, size: buf.length };
}
export function download(user_id, sha256) {
const d = getDb();
const row = d.prepare('SELECT * FROM attachments WHERE user_id=? AND sha256=?').get(user_id, sha256);
if (!row) return null;
const file = path.join(BLOB_DIR, user_id, sha256);
if (!fs.existsSync(file)) return null;
return { buf: fs.readFileSync(file), mime: row.mime, file_name: row.file_name, size: row.size };
}
+72
View File
@@ -0,0 +1,72 @@
// 认证:scrypt 口令散列 + Bearer token + 设备登记
import crypto from 'node:crypto';
import { getDb } from './db.mjs';
export function register({ username, password, device_name, invite_code }) {
const d = getDb();
// 多用户管控:邀请码(设置后必填)+ 用户数上限(0=不限)
const invite = process.env.INVITE_CODE;
if (invite && invite_code !== invite) throw new Error('邀请码无效');
const maxUsers = Number(process.env.MAX_USERS || 0);
if (maxUsers > 0) {
const cnt = d.prepare('SELECT COUNT(*) AS c FROM users').get().c;
if (cnt >= maxUsers) throw new Error('注册名额已满,请联系管理员');
}
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(username || '')) throw new Error('用户名需为 3-32 位字母数字-_');
if (!password || password.length < 8) throw new Error('密码至少 8 位');
const exists = d.prepare('SELECT 1 FROM users WHERE username=?').get(username);
if (exists) throw new Error('用户名已存在');
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
const user_id = crypto.randomUUID();
d.prepare('INSERT INTO users (id, username, password_hash, salt) VALUES (?,?,?,?)')
.run(user_id, username, hash, salt);
return issueToken(user_id, device_name);
}
export function login({ username, password, device_name }) {
const d = getDb();
const row = d.prepare('SELECT * FROM users WHERE username=?').get(username || '');
if (!row) throw new Error('用户名或密码错误');
const hash = crypto.scryptSync(password || '', row.salt, 64);
const expect = Buffer.from(row.password_hash, 'hex');
if (hash.length !== expect.length || !crypto.timingSafeEqual(hash, expect)) {
throw new Error('用户名或密码错误');
}
return issueToken(row.id, device_name);
}
function issueToken(user_id, device_name) {
const d = getDb();
const name = (device_name || '未知设备').slice(0, 64);
let device = d.prepare('SELECT id FROM devices WHERE user_id=? AND name=?').get(user_id, name);
let device_id;
if (device) {
device_id = device.id;
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=?").run(device_id);
} else {
device_id = crypto.randomUUID();
d.prepare('INSERT INTO devices (id, user_id, name) VALUES (?,?,?)').run(device_id, user_id, name);
}
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 365 * 86400_000).toISOString().slice(0, 19).replace('T', ' ');
d.prepare('INSERT INTO tokens (token, user_id, device_id, expires_at) VALUES (?,?,?,?)')
.run(token, user_id, device_id, expires);
return { token, user_id, device_id, device_name: name, expires_at: expires };
}
export function authUser(req) {
const d = getDb();
const header = req.headers['authorization'] || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
if (!token) throw Object.assign(new Error('未提供凭据'), { code: 401 });
const row = d.prepare(`
SELECT t.user_id, t.device_id, t.expires_at FROM tokens t WHERE t.token=?`).get(token);
if (!row) throw Object.assign(new Error('凭据无效'), { code: 401 });
if (row.expires_at < new Date().toISOString().slice(0, 19).replace('T', ' ')) {
throw Object.assign(new Error('凭据已过期,请重新登录'), { code: 401 });
}
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=?").run(row.device_id);
return { user_id: row.user_id, device_id: row.device_id };
}

Some files were not shown because too many files have changed in this diff Show More