feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.data/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
@@ -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/`。备份 = 复制该目录。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "足迹 Footprints",
|
||||
"short_name": "足迹",
|
||||
"description": "个人成长足迹档案:记录、检索、量化、导出",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#101216",
|
||||
"theme_color": "#3056d3",
|
||||
"lang": "zh-CN",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/pwa-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/pwa-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// PWA Service Worker v2
|
||||
// - 页面导航:网络优先(构建更新后强制拿到新 index.html),离线才回落缓存
|
||||
// - 静态资源:缓存优先
|
||||
// - /api:直连不缓存
|
||||
const CACHE = 'fp-static-v2';
|
||||
const CORE = ['/', '/manifest.webmanifest', '/icons/pwa-192.png', '/icons/pwa-512.png'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
self.skipWaiting();
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(CORE)));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || url.pathname.startsWith('/api')) return;
|
||||
|
||||
if (e.request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(e.request).then((res) => {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put('/index.html', copy));
|
||||
return res;
|
||||
}).catch(() => caches.match('/index.html').then((h) => h || caches.match('/')))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
e.respondWith(
|
||||
caches.match(e.request).then((hit) => hit || fetch(e.request).then((res) => {
|
||||
if (res.ok && url.origin === location.origin) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(e.request, copy));
|
||||
}
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
-- Footprints 数据库 schema(开发桥接与 Rust 后端共用同一份)
|
||||
-- v2:加入多端同步地基(uuid v7 / 软删除墓碑 / HLC / 变更日志)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS footprint_type (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
icon TEXT NOT NULL DEFAULT 'tag',
|
||||
color TEXT NOT NULL DEFAULT '#64748b',
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
form_template TEXT NOT NULL DEFAULT '[]',
|
||||
is_builtin INTEGER NOT NULL DEFAULT 0,
|
||||
closed INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS taxonomy (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
color TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
is_builtin INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
UNIQUE(kind, value)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dimension (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS footprint (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
type_id INTEGER NOT NULL REFERENCES footprint_type(id),
|
||||
title TEXT NOT NULL,
|
||||
role TEXT,
|
||||
level TEXT,
|
||||
polarity TEXT NOT NULL DEFAULT 'positive',
|
||||
award_grade TEXT,
|
||||
rank INTEGER,
|
||||
organizer TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
result TEXT NOT NULL DEFAULT '',
|
||||
metrics TEXT NOT NULL DEFAULT '{}',
|
||||
extras TEXT NOT NULL DEFAULT '{}',
|
||||
group_id TEXT,
|
||||
parent_id INTEGER,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
weight INTEGER NOT NULL DEFAULT 3,
|
||||
search_text TEXT NOT NULL DEFAULT '',
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS footprint_dimension (
|
||||
footprint_id INTEGER NOT NULL REFERENCES footprint(id) ON DELETE CASCADE,
|
||||
dimension_id INTEGER NOT NULL REFERENCES dimension(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (footprint_id, dimension_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
owner_type TEXT NOT NULL DEFAULT 'footprint',
|
||||
owner_id INTEGER NOT NULL,
|
||||
kind TEXT,
|
||||
file_name TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
rel_path TEXT NOT NULL,
|
||||
sha256 TEXT,
|
||||
taken_at TEXT,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semester (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
term TEXT NOT NULL UNIQUE,
|
||||
gpa REAL,
|
||||
rank INTEGER,
|
||||
total_students INTEGER,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS course_grade (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
semester_id INTEGER NOT NULL REFERENCES semester(id) ON DELETE CASCADE,
|
||||
course_name TEXT NOT NULL,
|
||||
credit REAL NOT NULL DEFAULT 0,
|
||||
score REAL,
|
||||
grade_point REAL,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
-- 成绩梯度(等级带):用户可配置;单科成绩按 min <= 分数 < max 定级(满分档闭区间)
|
||||
CREATE TABLE IF NOT EXISTS grade_band (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
label TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#64748b',
|
||||
min REAL NOT NULL DEFAULT 0,
|
||||
max REAL NOT NULL DEFAULT 100,
|
||||
sort INTEGER NOT NULL DEFAULT 100,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metric_def (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
agg TEXT NOT NULL DEFAULT 'sum',
|
||||
section TEXT,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_view (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
filter TEXT NOT NULL DEFAULT '{}',
|
||||
sort TEXT NOT NULL DEFAULT '{}',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
origin TEXT,
|
||||
hlc TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
-- ===== 同步基础设施 =====
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_oplog (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
row_uuid TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
hlc TEXT NOT NULL,
|
||||
synced_at TEXT
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS footprint_fts USING fts5(
|
||||
search_text,
|
||||
content='footprint',
|
||||
content_rowid='id',
|
||||
tokenize='trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS footprint_ai AFTER INSERT ON footprint BEGIN
|
||||
INSERT INTO footprint_fts(rowid, search_text) VALUES (new.id, new.search_text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS footprint_ad AFTER DELETE ON footprint BEGIN
|
||||
INSERT INTO footprint_fts(footprint_fts, rowid, search_text)
|
||||
VALUES ('delete', old.id, old.search_text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS footprint_au AFTER UPDATE OF search_text ON footprint BEGIN
|
||||
INSERT INTO footprint_fts(footprint_fts, rowid, search_text)
|
||||
VALUES ('delete', old.id, old.search_text);
|
||||
INSERT INTO footprint_fts(rowid, search_text) VALUES (new.id, new.search_text);
|
||||
END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_footprint_start ON footprint(start_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_footprint_type ON footprint(type_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_footprint_group ON footprint(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_footprint_hlc ON footprint(hlc);
|
||||
CREATE INDEX IF NOT EXISTS idx_att_owner ON attachment(owner_type, owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oplog_pending ON sync_oplog(synced_at);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_footprint_uuid ON footprint(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_footprint_type_uuid ON footprint_type(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_taxonomy_uuid ON taxonomy(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dimension_uuid ON dimension(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_attachment_uuid ON attachment(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_semester_uuid ON semester(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_course_grade_uuid ON course_grade(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_metric_def_uuid ON metric_def(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_view_uuid ON saved_view(uuid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_grade_band_uuid ON grade_band(uuid);
|
||||
@@ -0,0 +1,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。'
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)已回填');
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
footprints_lib::run();
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) + ' 月';
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 }[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 且 < 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 施行)"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
data/
|
||||
.data/
|
||||
node_modules/
|
||||
*.md
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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 增量片段
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||