Compare commits

...
8 Commits
Author SHA1 Message Date
root a5e976fee6 polish(ui): design language uplift - press feedback, brand gradient, header glass, route icon chips, empty states
- button: unified press scale (active:scale-[0.97]), primary gets vertical
  brand gradient + tighter shadow, secondary gains subtle hover shadow
- input/select/textarea: consistent focus border + 1.5px brand ring
- empty states: icon in circular tinted badge with soft ring, larger rhythm
- header: translucent frosted-glass surface (color-mix + backdrop blur),
  per-route lucide icon chip beside page title (desktop), subtitle hidden
  on phones to declutter
- sidebar: active nav icon tinted brand via .nav-active > svg; user card
  avatar gets brand gradient + ring
- dark theme: text-3 contrast raised (#68707e -> #747c8a)
- verified live (headless): h1/chips/glass render on dashboard/timeline/
  assessment/resume; tsc + vite build green; temp instrumentation reverted
2026-10-17 22:33:39 +08:00
root 8cdf8b44fa docs: refresh public docs for current codebase, add SECURITY.md
- README: real counts (44 commands, 61 integration asserts, demo=25 records
  + 4 semesters + 7 attachments), add resume/assessment module features,
  env vars incl. LSKY, doc navigation table, done-roadmap items checked
- docs/api.md: /auth/logout, admin users/demo-rebuild/sync-lsky routes,
  attachment thumb/download channels, profile_upsert top-key merge
  semantics, full footprint_list query reference, 44-command table
- sync-server/README: replace stale ADMIN_TOKEN/X-Admin-Token admin section
  (actual is_admin Bearer model), add go-live checklist, backup note for WAL
- CHANGELOG: add Unreleased section (resume/assessment modules, security
  fixes, perf tuning)
- CONTRIBUTING: 61 asserts, security-sensitive-change guidance, perf baseline
  table, security issue routing
- docs/SECURITY.md: new - report channel, threat model table, hardening
  summary, known tradeoffs
- LICENSE: ensure MIT file matches README badge
2026-10-17 22:27:56 +08:00
root 88525a3f9f perf(db): WAL + synchronous=NORMAL + 8MB page cache for per-user dbs, throttled device last_seen writes
- openDb pragmas: journal_mode=WAL, synchronous=NORMAL, temp_store=MEMORY,
  cache_size=-8000; 3000-record stress write throughput 9514ms -> 1241ms
  (7.7x), single upsert ~0.3ms; read paths unchanged (<=22ms)
- authUser: devices.last_seen_at now written only when stale > 60s,
  removing one write per authenticated request (write amplification)
2026-10-17 22:09:48 +08:00
root 17ab5f0390 security(review): fix invite bypass via smuggled seed flag, login rate limit, server-side logout, attachment preview XSS hardening
- /auth/register: strip client-supplied seed flag (PoC confirmed it bypassed
  invite code, MAX_USERS quota AND first-user auto-admin, granting admin
  without any credential); demo-rebuild internal path unaffected
- /auth/login: per-IP+username of 5 consecutive failures gets a 10-minute
  cooldown against in-memory counter (brute-force guard)
- /auth/logout: new endpoint revokes the current Bearer token server-side;
  frontend calls it (best-effort) before local clearToken on sidebar logout
- attachment docx/xlsx preview: sanitize generated HTML (strip scripts,
  event handlers, dangerous protocols) before dangerouslySetInnerHTML
  render, preventing token theft via malicious previewed documents

PoC suite: seed smuggling rejected, 429 on 6th login attempt, token dead
after logout, per-user isolation intact (403/401/404 as before);
integration 61/61 green
2026-10-17 21:18:55 +08:00
root 3613b9f5e1 polish(ui): full-system layout audit, header titles for all routes, dialog title truncation
- headless DOM audit (Edge dump, 1440/390 x 10 routes): no real horizontal page overflow on any screen size; timeline bleed & drawer offsets are intentional and verified benign
- header now labels profile/resume/assessment/admin routes (was blank h1 on those pages)
- dialog header truncates long titles instead of pushing controls out
- page containers audited for consistent rhythm (1080/1280/880 widths share identical paddings)
- www rebuilt, temp instrumentation removed
2026-10-17 19:51:52 +08:00
root a668647f35 fix(audit): profile_upsert top-key merge, scoring badge recordIds, zc item contract
- profile_upsert now merges top-level keys (info/resume/zongce are independent sub-documents); previously any page saving its own slice silently wiped the other two (profile save lost zongce rules, assessment save lost resume, resume save lost zongce)
- ZcItem gains recordIds: aggregated cap items (wcap/ccap/kcap/gcap/hcap) expose the records they aggregate, so the evidence gallery scored/unscored badge works (matchedIds was always empty before)
- add regression coverage notes; www rebuilt
2026-10-17 19:13:58 +08:00
root ba448946c9 fix(assessment): clickable rules panel at top, print-layout word doc polish
- scoring rules panel moved above the sheet and auto-scrolls into view on toggle (was rendered at the very bottom, felt unresponsive)
- word export: unified single spec table with numbered section rows (one/two/three...), subtotal and total rows, editorial heading fonts
- standardized fonts and metrics: body SimSun 10.5pt, headings SimHei, numerals Times New Roman, line-height, A4 margins 72/78/66pt
- footer with PAGE/NUMPAGES fields via mso-element footer, Word opens in Print layout view
- evidence captions and rules annex restyled to match; www rebuilt
2026-10-17 17:48:16 +08:00
root 11cfeeb3a6 feat(assessment): unified GPA source, full evidence gallery, editable scoring rules, 3x3 info with phone
- fix per-year computation: termYearOf parses semester terms (2023-2024-1) so year Scots no longer skipped the semester filter (previous bug made yearly score empty)
- academic score now uses semester.gpa shown on grades page (single source of truth); fallback recalculates with the same band-linear + category weights formula
- gradeLabel infers grade year (freshman/sophomore) from eduStart, falls back to earliest semester year
- info grid 3x3 with phone number as the 9th cell (page + word export)
- evidence gallery shows every activity/competition record with attachments in scope (no 2-image cap), records marked scored/unscored
- scoring rules reference panel: default rules editable and persisted with profile; restore-default button; no college/school names in citations
- word export mirrors all of the above, adds rules annex table; removed dead zongceWord.ts with school citation
- sync-server/www rebuilt
2026-10-17 17:33:49 +08:00
45 changed files with 1061 additions and 630 deletions
+4
View File
@@ -41,3 +41,7 @@ build-log.txt
# 本地工具误生成的运行时与临时文件 # 本地工具误生成的运行时与临时文件
Python/ Python/
.tmp-* .tmp-*
# UI 布局审计产物
.ui-shots/
.ui-audit-results.json
+21
View File
@@ -28,3 +28,24 @@
### Security ### Security
- scrypt 口令散列 + timingSafeEqual 校验 - scrypt 口令散列 + timingSafeEqual 校验
- Bearer Token(365 天过期)、按用户数据隔离、邀请码注册门控 - Bearer Token(365 天过期)、按用户数据隔离、邀请码注册门控
## [未发布]
### Added
- 简历模块:可视化模板配置与 Word 导出(MHTML 格式 .doc,兼容 Word/WPS)
- 综合测评模块:五大素质自动计分、按学年统计、证据画廊(单活动全部附件展示)、可编辑评分细则参考(默认值可改可恢复、随档案保存)、学年九格信息表、Word 档案导出(页码页脚/附件矩阵附录/材料图题注)
- `/auth/logout`:服务端吊销当前 Bearer Token;前端登出先吊销再本地清除
- 登录限速:同 IP+用户名连续失败 5 次冷却 10 分钟
- `profile_upsert` 改为顶层 key 合并(info/resume/zongce 三子文档互不覆盖)
- 用户库 WAL + synchronous=NORMAL + 8MB 页缓存(3000 条写入实测 7.7×)
- 设备 last_seen_at 回写节流(>60s 才写)
### Security
- 修复:客户端走私 `seed` 标志可绕过邀请码/名额并因自动升管理员规则直接获得管理员权限(`/auth/register` 现强制剥离)
- 修复:附件 docx/xlsx 预览 HTML 未消毒(现经白名单式净化,防脚本注入偷取本地会话)
### Fixed
- 综测分学年学期解析(`2023-2024-1` 末段误解析导致该学年学业分缺失)
- 综测学业绩点与成绩页学期卡口径不一致(现同源优先)
- 档案识别学年错误(按入学时间推算,缺省回退最早学期)
- 证据画廊「计分/未计分」徽章在聚合计分项下恒错
+21 -7
View File
@@ -11,27 +11,41 @@
## 开发流程 ## 开发流程
1. Fork / 建分支:`git checkout -b feat/your-feature` 1. Fork / 建分支:`git checkout -b feat/your-feature`
2. 提交信息用约定式前缀:`feat: / fix: / docs: / chore: / ci: / refactor:` 2. 提交信息用约定式前缀:`feat: / fix: / docs: / chore: / ci: / refactor: / security: / perf:`
3. 本地跑绿再推: 3. 本地跑绿再推:
```bash ```bash
git config core.hooksPath .githooks # 每个克隆只需一次 git config core.hooksPath .githooks # 每个克隆只需一次
node scripts/check.mjs node scripts/check.mjs
node scripts/test-integration.mjs node scripts/test-integration.mjs # 61 条集成断言
``` ```
启用钩子后,push 会自动跑同一套检查;工作区有未提交变更或测试失败时 push 会被拒绝。 启用钩子后,push 会自动跑同一套检查;工作区有未提交变更或测试失败时 push 会被拒绝。
## 约定 ## 约定
- 服务端保持**零 npm 依赖**(只用 Node 内置模块);确需引入依赖请先开 Issue 讨论 - **服务端保持零 npm 依赖**(只用 Node 内置模块);确需引入依赖请先开 Issue 讨论
- 改动 `app/server/db.mjs`(命令层)时注意:Web / 桌面 / 播种三处共用它,测试必须全绿 - 改动 `app/server/db.mjs`(命令层)时注意:Web / 桌面 / 播种三处共用它,测试必须全绿;新增命令顺手在 `docs/api.md` 补一行
- 涉及 schema 变更:保证老库能平滑迁移(参考 `preMigrateColumns` 的加列模式),并在 - 涉及 schema 变更:保证老库能平滑迁移(参考 `schema.sql` IF NOT EXISTS / `preMigrateColumns` 的加列模式),并在 `scripts/test-integration.mjs` 补断言
`scripts/test-integration.mjs` 补断言
- 涉及 Service Worker:接口类请求(/api /auth /admin /sync)永远不得缓存 - 涉及 Service Worker:接口类请求(/api /auth /admin /sync)永远不得缓存
- UI 改动请同时适配亮/暗两套主题(令牌见 `app/src/styles/theme.css`) - UI 改动请同时适配亮/暗两套主题(令牌见 `app/src/styles/theme.css`),并兼顾移动端(390px 宽)与浏览器打印样式
- **安全敏感路径**(认证/上传/管理/导出)请说明改动思路;过滤、白名单优先于黑名单;欢迎补 PoC 级测试(参考 `docs/SECURITY.md` 的字段表)
## Issue ## Issue
- Bug 报告请附:复现步骤、期望/实际行为、服务端日志(`docker logs footprints-sync`) - Bug 报告请附:复现步骤、期望/实际行为、服务端日志(`docker logs footprints-sync`)
- 功能建议请说明使用场景,最好附上你期望的记录/检索形态 - 功能建议请说明使用场景,最好附上你期望的记录/检索形态
- 安全问题请走 [`docs/SECURITY.md`](docs/SECURITY.md) 的渠道,勿在公开 Issue 贴 PoC 细节
## 性能基线(改数据层请起压测)
命令层基准(3000 条记录、PC 单机):
| 命令 | 耗时 |
|---|---|
| footprint_list(2000 条一页)| ≤23ms |
| timeline | ≤22ms |
| 单条 footprint_upsert | ≈0.3ms |
回归手动基准:`node .tmp-perf/bench.mjs` 模式(见 git 历史样例);
引入 >2× 的回退需要说明必要性(一般值得换改造思路)。
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 linyux (footpr.linyux.cn) Copyright (c) 2026 Footprints Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+37 -23
View File
@@ -12,20 +12,22 @@
- **留痕本体** —— 自定义记录类型 + 动态表单模板;竞赛、志愿、学生工作、荣誉、证书、科研、实习皆可配;正面/负面、进行中、草稿全留痕 - **留痕本体** —— 自定义记录类型 + 动态表单模板;竞赛、志愿、学生工作、荣誉、证书、科研、实习皆可配;正面/负面、进行中、草稿全留痕
- **秒级检索** —— FTS5 trigram 中文全文搜索 + 多条件筛选 + 保存视图(同一查询一键复用) - **秒级检索** —— FTS5 trigram 中文全文搜索 + 多条件筛选 + 保存视图(同一查询一键复用)
- **时间线** —— 按学年/月分组的完整回顾 - **时间线** —— 按学年/月分组的完整回顾,玻璃质感粘性年份头
- **学业成绩** —— 学期 GPA/排名、课程成绩、可自定义绩点梯度(grade band)与趋势图 - **学业成绩** —— 学期 GPA/排名、课程成绩、可自定义绩点梯度(grade band)与趋势图;综测学业分直接取学期卡同源口径
- **成长量化** —— 自定义维度与指标(如志愿时长),统计总览与年度对比 - **成长量化** —— 自定义维度与指标(如志愿时长),统计总览与年度对比
- **附件佐证** —— 图片/文档上传,SHA-256 内容寻址去重 - **简历** —— 可视化编辑模板(基本信息/学业/经历区块增删排序)+ 一键导出规范排版的 Word 文档
- **综合测评** —— 五大素质自动计分(活动/竞赛/学生工作/奖惩,逐项证据画廊)、中文 FTS 三角分词检索佐证、可编辑评分细则与手动修正、学年九格信息表、导出带页码页脚与附件矩阵附录的 Word 档案
- **附件佐证** —— 图片/文档/PDF 上传,SHA-256 内容寻址去重,客户端缩略图 + 灯箱预览,docx/xlsx 在线预览(已 HTML 加固)
- **多用户云优先** —— 所有数据存服务器,按用户独立 SQLite 档案库;浏览器 / PWA / Windows 桌面三端同源同步 - **多用户云优先** —— 所有数据存服务器,按用户独立 SQLite 档案库;浏览器 / PWA / Windows 桌面三端同源同步
- **管理后台** —— 邀请码全生命周期管理(自定义码、备注、启用/停用、删除、批量生成)、用户列表、注册配额 - **管理后台** —— 邀请码全生命周期管理(自定义码、备注、启用/停用、删除、批量生成)、用户列表、注册配额、账号删除级联清理(图床/档案库/附件/会话)
- **演示模式** —— 内置演示账号 `demo / demo1234`,自动播种 16 条足迹 + 4 学期成绩,开箱即看 - **演示模式** —— 内置演示账号 `demo / demo1234`,自动播种 25 条足迹 + 4 学期成绩 + 7 份佐证附件,开箱即看
- **PWA + 桌面** —— 可安装到桌面/手机;Windows Tauri 安装包;亮/暗双主题 - **PWA + 桌面** —— 可安装到桌面/手机;Windows Tauri 安装包;亮/暗双主题
## 架构 ## 架构
``` ```
浏览器 PWA ─┐ 浏览器 PWA ─┐
Windows 桌面 ─┤ HTTPS + Bearer Token(365 天) Windows 桌面 ─┤ HTTPS + Bearer Token(365 天,可服务端吊销)
移动端 PWA ─┘ 移动端 PWA ─┘
│ │
▼ ▼
@@ -33,17 +35,19 @@
│ sync-server(零依赖 Node ≥ 22.5)│ │ sync-server(零依赖 Node ≥ 22.5)│
│ node:http + node:sqlite │ │ node:http + node:sqlite │
│ scrypt 认证 · 邀请码 · LWW 同步 │ │ scrypt 认证 · 邀请码 · LWW 同步 │
│ 登录限速 · 会话吊销 · 附件加固 │
└───────────────┬────────────────┘ └───────────────┬────────────────┘
▼ ▼
data/users/<uid>.db 每用户独立 SQLite 档案库 data/users/<uid>.db 每用户独立 SQLite 档案库(WAL + 索引齐全)
data/blobs/<uid>/ 内容寻址附件存储 data/attachments/ 按用户隔离的内容寻址附件
``` ```
设计取向: 设计取向:
- **服务器零 npm 依赖**:仅用 Node 内置模块(`node:http` / `node:sqlite` / `node:crypto`),攻击面与供应链风险极小 - **服务器零 npm 依赖**:仅用 Node 内置模块(`node:http` / `node:sqlite` / `node:crypto`),攻击面与供应链风险极小
- **命令层同源**:Web、桌面、数据播种共用同一份 `app/server/db.mjs` 命令层,同一业务逻辑多处不漂移 - **命令层同源**:Web、桌面、数据播种共用同一份 `app/server/db.mjs` 命令层(44 个命令),同一业务逻辑多处不漂移
- **同步元数据内建**:所有表带 uuid/hlc/origin 列 + oplog,为多端增量同步而生 - **同步元数据内建**:所有表带 uuid/hlc/origin 列 + oplog,为多端增量同步而生
- **性能基线**(3000 条记录实测):列表/时间线 ≤23ms、单条写 ≈0.3ms(WAL + synchronous=NORMAL + 8MB 页缓存)
## 快速开始(Docker 部署) ## 快速开始(Docker 部署)
@@ -69,12 +73,13 @@ footprints.example.com {
``` ```
> compose 默认加入名为 `mynet` 的外部网络;没有就先 `docker network create mynet`,或把 compose 里的 networks 段改成你自己的。 > compose 默认加入名为 `mynet` 的外部网络;没有就先 `docker network create mynet`,或把 compose 里的 networks 段改成你自己的。
> 详细部署与拓扑说明见 [`sync-server/README.md`](sync-server/README.md)。
首次使用: 首次使用:
1. 打开站点 → 注册 —— **第一个注册的账号自动成为管理员** 1. 打开站点 → 注册 —— **第一个注册的账号自动成为管理员**(内置 demo 账号不占管理员席位)
2. 管理员登录 → 侧边栏「管理」→ 新建/批量生成邀请码,发给需要注册的用户 2. 管理员登录 → 侧边栏「管理」→ 新建/批量生成邀请码,发给需要注册的用户
3. 人齐之后建议收紧注册:compose 里 `REGISTER_ENABLED=false`(或改掉 `INVITE_CODE` 默认值)再 `up -d` 3. 人齐之后建议收紧注册:compose 里 `REGISTER_ENABLED=false`,并把 `INVITE_CODE` 从示例默认值 `please-change-me` 换成强随机值或留空(改用管理页动态码)
4. 想先看看界面?用演示账号 **demo / demo1234** 登录,数据管够 4. 想先看看界面?用演示账号 **demo / demo1234** 登录,数据管够
## 本地开发 ## 本地开发
@@ -99,29 +104,32 @@ pnpm -C app install && pnpm -C app tauri build
```bash ```bash
node scripts/check.mjs # 语法 + 类型 + 构建 + 关键标记 tripwire node scripts/check.mjs # 语法 + 类型 + 构建 + 关键标记 tripwire
node scripts/test-integration.mjs # 35 条集成断言(播种/登录/邀请码全矩阵/角色规则/幂等) node scripts/test-integration.mjs # 61 条集成断言(播种/登录/邀请码全矩阵/角色规则/幂等/级联删除)
node scripts/smoke.mjs <url> <user> <pass> # 部署后冒烟 node scripts/smoke.mjs <url> <user> <pass> # 部署后冒烟
git config core.hooksPath .githooks # 启用 pre-push 钩子:工作区不干净或测试红,push 直接拒绝 git config core.hooksPath .githooks # 启用 pre-push 钩子:工作区不干净或测试红,push 直接拒绝
``` ```
## 项目结构 ## 项目结构
``` ```
├─ app/ # 前端 + 桌面端 ├─ app/ # 前端 + 桌面端
│ ├─ src/ # React 19 + Vite + Tailwind v4 UI(三端共用) │ ├─ src/ # React 19 + Vite + Tailwind v4 UI(三端共用)
│ ├─ public/ # PWA:sw.js v3(接口绝不缓存)/ manifest / icons │ │ ├─ pages/ # 时间线/记录/成绩/统计/类型/字典/个人信息/简历/综合测评/管理
│ ├─ server/ # ★ 命令层 db.mjs:零依赖数据层(37 个命令) │ │ ├─ lib/zongce.ts # 综测计分引擎(五大素质聚合 + 学业口径对齐)
│ │ └─ styles/theme.css # 亮/暗主题令牌 + MHTML Word 导出样式基座
│ ├─ public/ # PWA:sw.js(接口绝不缓存)/ manifest / icons
│ ├─ server/ # ★ 命令层 db.mjs:零依赖数据层(44 个命令)
│ ├─ schema.sql # 数据库 schema(Rust 与 Node 共用)
│ └─ src-tauri/ # Tauri 2 桌面壳 │ └─ src-tauri/ # Tauri 2 桌面壳
├─ sync-server/ # 自建同步服务器 ├─ sync-server/ # 自建同步服务器
│ ├─ src/ # index/auth/db/sync/attachments(零依赖) │ ├─ src/ # index/auth/db/sync/attachments(零依赖)
│ ├─ www/ # 构建产物(部署时以卷挂载,git pull 即更新) │ ├─ www/ # 构建产物(部署时以卷挂载,git pull 即更新)
│ └─ docker-compose.caddy.yml │ └─ docker-compose.caddy.yml
├─ docs/ # api.md(HTTP API)· DESIGN.md(设计底稿)· examples/ ├─ docs/ # api.md · DESIGN.md · SECURITY.md · zongce-rules(测评细则示例)
├─ scripts/ # check / test-integration / smoke ├─ scripts/ # check / test-integration / smoke
├─ tools/ # pnpm.cjs + 图标生成器 ├─ tools/ # pnpm.cjs + 图标生成器
├─ .gitea/ # Issue/PR 模板 ├─ .gitea/ # Issue/PR 模板
├─ .githooks/ # pre-push 质量门禁(check + 35 条集成断言) ├─ .githooks/ # pre-push 质量门禁(check + 61 条集成断言)
└─ .editorconfig └─ .editorconfig
``` ```
@@ -131,20 +139,26 @@ git config core.hooksPath .githooks # 启用 pre-push 钩子:工作区不干
|---|---|---| |---|---|---|
| `PORT` | `8788` | 监听端口 | | `PORT` | `8788` | 监听端口 |
| `DATA_DIR` | `./data` | 数据目录(用户库/附件/同步账本) | | `DATA_DIR` | `./data` | 数据目录(用户库/附件/同步账本) |
| `REGISTER_ENABLED` | `true` | 是否开放注册 | | `REGISTER_ENABLED` | `true` | 是否开放注册(注册完毕后建议关闭) |
| `INVITE_CODE` | 空 | 静态兜底邀请码(动态码由管理员在管理页生成) | | `INVITE_CODE` | 空 | 静态兜底邀请码(动态码由管理员在管理页生成;**务必改默认值**) |
| `MAX_USERS` | `0` | 用户数上限,0 = 不限 | | `MAX_USERS` | `0` | 用户数上限,0 = 不限 |
| `WWW_DIR` | `../www` | 前端静态目录 | | `WWW_DIR` | `../www` | 前端静态目录 |
| `LSKY_API` / `LSKY_TOKEN` | 空 | 可选:附件自动迁入自建图床(Lsky Pro) |
## 文档 ## 文档
- [HTTP API 参考](docs/api.md) | 文档 | 内容 |
- [设计方案(v0.5 底稿)](docs/DESIGN.md) —— 项目的原始设计思想;现产品已演进为云优先多用户架构 |---|---|
| [HTTP API 参考](docs/api.md) | 全部 HTTP 端点与 44 个业务命令 |
| [安全模型](docs/SECURITY.md) | 威胁模型、已加固项、漏洞报告渠道 |
| [设计方案(v0.5 底稿)](docs/DESIGN.md) | 项目的原始设计思想;现产品已演进为云优先多用户架构 |
| [综合测评细则示例](docs/zongce-rules-2026-2027.md) | 「评分细则参考」默认值的来源(纯文本,可自行修改) |
## Roadmap ## Roadmap
- [ ] 附件缩略图 / 上传前压缩 / 大文件分片 - [ ] 大文件分片上传
- [ ] 评奖评优材料包一键导出 - [x] 附件缩略图 / 上传前压缩(客户端 canvas 生成 480px WebP)
- [x] 评奖评优材料包一键导出(简历 / 综测 Word 档案)
- [ ] 对象存储(S3)适配 - [ ] 对象存储(S3)适配
- [ ] 端到端加密存储 - [ ] 端到端加密存储
- [ ] 移动端体验优化(手势、离线编辑队列) - [ ] 移动端体验优化(手势、离线编辑队列)
+6 -5
View File
@@ -5,12 +5,13 @@ React 19 前端 + Tauri 2 桌面壳 + 共用 Node 命令层。
## 结构 ## 结构
src/ React 前端(时间线 / 记录 / 成绩 / 统计 / 类型 / 字典 / 管理) src/ React 前端(时间线/记录/成绩/统计/类型/字典/个人信息/简历/综合测评/管理)
public/ PWA:sw.js(v3,接口类请求绝不缓存)/ manifest / 图标 public/ PWA:sw.js(接口类请求绝不缓存)/ manifest / 图标
src-tauri/ Tauri 2 桌面壳(Windows NSIS 安装包构建) src-tauri/ Tauri 2 桌面壳(Windows NSIS 安装包构建)
server/ db.mjs 命令层(37 个命令,Web 与桌面共用)+ seed.mjs(本地演示数据) server/ db.mjs 命令层(44 个命令,Web 与桌面共用)+ seed.mjs(本地演示数据)
schema.sql 数据库 schema(Rust 与 Node 共用同一份,v2 含同步元数据) schema.sql 数据库 schema(Rust 与 Node 共用同一份,v2 含同步元数据)
src/lib/zongce.ts 综测计分引擎(细粒度条款聚合 + 学业同源口径)
测试与约定见 [`../CONTRIBUTING.md`](../CONTRIBUTING.md);架构底稿见 [`../docs/DESIGN.md`](../docs/DESIGN.md);HTTP 契约见 [`../docs/api.md`](../docs/api.md)。
图标再生成:`python ../tools/gen_icons.py`(桌面)与 `python ../tools/gen_pwa_icons.py`(PWA)。 图标再生成:`python ../tools/gen_icons.py`(桌面)与 `python ../tools/gen_pwa_icons.py`(PWA)。
文档:[`../docs/DESIGN.md`](../docs/DESIGN.md)(设计底稿)· [`../docs/api.md`](../docs/api.md)(HTTP API)· [`../sync-server/`](../sync-server/)(服务器)。
+13 -1
View File
@@ -335,6 +335,11 @@ function openDb(dbPath) {
fs.mkdirSync(path.join(path.dirname(dbPath), 'attachments'), { recursive: true }); fs.mkdirSync(path.join(path.dirname(dbPath), 'attachments'), { recursive: true });
const d = new DatabaseSync(dbPath); const d = new DatabaseSync(dbPath);
d.exec('PRAGMA foreign_keys = ON;'); d.exec('PRAGMA foreign_keys = ON;');
// 性能 PRAGMA:WAL 读写并发 + 回归风险更低(synchronous=NORMAL,掉电仅丢最后事务不改库)
d.exec('PRAGMA journal_mode = WAL;');
d.exec('PRAGMA synchronous = NORMAL;');
d.exec('PRAGMA temp_store = MEMORY;');
d.exec('PRAGMA cache_size = -8000;'); // 8MB 页缓存(负数 = KB 计)
preMigrateColumns(d); preMigrateColumns(d);
d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8')); d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
// 个人信息表兜底(确保任何挂载形态下都存在) // 个人信息表兜底(确保任何挂载形态下都存在)
@@ -1215,7 +1220,14 @@ function listFootprints(d, query) {
case 'profile_upsert': { case 'profile_upsert': {
const p = payload; const p = payload;
// 单行语义:更新现有行或插入首行 // 单行语义:更新现有行或插入首行
const content = JSON.stringify(p.content ?? {}); // 顶层 key 合并:info / resume / zongce 是相互独立的子文档,各页面只提交自己负责的
// key(ProfilePage 只有 info、AssessmentPage 有 info+zongce…),整行替换会让其他
// key 神秘消失;按顶层 key 合并后互不影响,key 内部整体替换(仍是全量提交该子文档)。
const existingRow = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get();
let prev = {};
try { prev = JSON.parse(existingRow?.content || '{}'); } catch { prev = {}; }
const merged = { ...prev, ...(p.content ?? {}) };
const content = JSON.stringify(merged);
const existing = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get(); const existing = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get();
withTx(d, () => { withTx(d, () => {
const h = hlcNext(d); const h = hlcNext(d);
@@ -7,6 +7,30 @@ import type { Attachment } from '@/lib/types';
export type AttKind = 'image' | 'pdf' | 'text' | 'audio' | 'video' | 'docx' | 'xlsx' | 'other'; export type AttKind = 'image' | 'pdf' | 'text' | 'audio' | 'video' | 'docx' | 'xlsx' | 'other';
// 预览 HTML 加固:docx(mammoth)/xlsx(sheet_to_html) 的转换输出不可信,
// 剥离脚本/事件属性/危险协议,防止恶意文档在预览时执行脚本偷取本地 token
function sanitizePreviewHtml(html: string): string {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const el of Array.from(doc.querySelectorAll('script,style,iframe,frame,object,embed,link,meta,form,input,button,base'))) el.remove();
for (const el of Array.from(doc.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
const n = attr.name.toLowerCase();
const v = attr.value.trim().toLowerCase();
if (n.startsWith('on')) el.removeAttribute(attr.name);
else if ((n === 'href' || n === 'src' || n === 'xlink:href') &&
(v.startsWith('javascript:') || (v.startsWith('data:') && !v.startsWith('data:image/')))) {
el.removeAttribute(attr.name);
}
}
}
return doc.body.innerHTML;
} catch {
return html;
}
}
export function attKind(a: { file_name: string; mime?: string | null }): AttKind { export function attKind(a: { file_name: string; mime?: string | null }): AttKind {
const mime = (a.mime || '').toLowerCase(); const mime = (a.mime || '').toLowerCase();
const ext = (a.file_name.split('.').pop() || '').toLowerCase(); const ext = (a.file_name.split('.').pop() || '').toLowerCase();
@@ -68,13 +92,13 @@ export function AttachmentPreview({ att, onClose }: { att: Attachment | null; on
const mammoth = await import('mammoth/mammoth.browser') as any; const mammoth = await import('mammoth/mammoth.browser') as any;
const html = (await mammoth.convertToHtml({ arrayBuffer: bytes.buffer })).value as string; const html = (await mammoth.convertToHtml({ arrayBuffer: bytes.buffer })).value as string;
if (cancelled) return; if (cancelled) return;
setContent({ kind, html }); setContent({ kind, html: sanitizePreviewHtml(html) });
} else if (kind === 'xlsx') { } else if (kind === 'xlsx') {
const XLSX = await import('xlsx'); const XLSX = await import('xlsx');
const wb = XLSX.read(bytes, { type: 'array' }); const wb = XLSX.read(bytes, { type: 'array' });
const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]); const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]);
if (cancelled) return; if (cancelled) return;
setContent({ kind, html }); setContent({ kind, html: sanitizePreviewHtml(html) });
} else { } else {
// 本地回退的图片/PDF/音视频 // 本地回退的图片/PDF/音视频
const url = URL.createObjectURL(new Blob([bytes], { type: mime || 'application/octet-stream' })); const url = URL.createObjectURL(new Blob([bytes], { type: mime || 'application/octet-stream' }));
+30 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { Search, Sun, Moon, Plus, Menu } from 'lucide-react'; import { Search, Sun, Moon, Plus, Menu, CalendarDays, Rows3, GraduationCap, ChartColumn, Shapes, SlidersHorizontal, User, ScrollText, ClipboardCheck, ShieldCheck } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button, Input } from '@/components/ui'; import { Button, Input } from '@/components/ui';
const TITLES: Record<string, { title: string; sub?: string }> = { const TITLES: Record<string, { title: string; sub?: string }> = {
@@ -9,7 +10,11 @@ const TITLES: Record<string, { title: string; sub?: string }> = {
'/grades': { title: '成绩', sub: '学年学科成绩 · 梯度与总览' }, '/grades': { title: '成绩', sub: '学年学科成绩 · 梯度与总览' },
'/dashboard': { title: '统计', sub: '量化个人成长收获' }, '/dashboard': { title: '统计', sub: '量化个人成长收获' },
'/types': { title: '记录类型', sub: '类型与字段模板管理' }, '/types': { title: '记录类型', sub: '类型与字段模板管理' },
'/taxonomy': { title: '字典与维度', sub: '级别、角色与成长维度' } '/taxonomy': { title: '字典与维度', sub: '级别、角色与成长维度' },
'/profile': { title: '个人信息', sub: '学籍与联系方式维护' },
'/resume': { title: '我的简历', sub: '模板配置 · Word 导出' },
'/assessment': { title: '综合测评', sub: '学年素质测评与材料导出' },
'/admin': { title: '管理', sub: '用户与邀请码 · 数据维护' },
}; };
export function Header({ onMenu }: { onMenu?: () => void }) { export function Header({ onMenu }: { onMenu?: () => void }) {
@@ -20,6 +25,20 @@ export function Header({ onMenu }: { onMenu?: () => void }) {
const meta = TITLES[pathname] ?? { title: '' }; const meta = TITLES[pathname] ?? { title: '' };
const ROUTE_ICONS: Record<string, LucideIcon> = {
'/timeline': CalendarDays,
'/records': Rows3,
'/grades': GraduationCap,
'/dashboard': ChartColumn,
'/types': Shapes,
'/taxonomy': SlidersHorizontal,
'/profile': User,
'/resume': ScrollText,
'/assessment': ClipboardCheck,
'/admin': ShieldCheck,
};
const RouteIcon = ROUTE_ICONS[pathname];
const toggleTheme = () => { const toggleTheme = () => {
const next = !dark; const next = !dark;
setDark(next); setDark(next);
@@ -29,14 +48,21 @@ export function Header({ onMenu }: { onMenu?: () => void }) {
}; };
return ( return (
<header className="no-print flex h-14 shrink-0 items-center gap-2 border-b border-line bg-surface px-3 sm:gap-4 sm:px-5"> <header className="no-print header-glass sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2.5 border-b border-line px-3 sm:gap-4 sm:px-5">
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenu} aria-label="打开导航"> <Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenu} aria-label="打开导航">
<Menu size={17} /> <Menu size={17} />
</Button> </Button>
<div className="flex min-w-0 items-center gap-2.5">
{RouteIcon && (
<span className="hidden size-8 shrink-0 items-center justify-center rounded-lg bg-brand-subtle text-brand-text sm:flex" aria-hidden>
<RouteIcon size={16} strokeWidth={2} />
</span>
)}
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink-1">{meta.title}</h1> <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>} {meta.sub && <p className="hidden truncate text-[11px] leading-tight text-ink-3 sm:block sm:mt-[1px]">{meta.sub}</p>}
</div>
</div> </div>
<div className="relative ml-auto hidden w-[240px] md:block lg:w-[280px]"> <div className="relative ml-auto hidden w-[240px] md:block lg:w-[280px]">
+4 -4
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, LogOut, ShieldCheck, Cloud, User, ScrollText, ClipboardCheck } from 'lucide-react'; import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, LogOut, ShieldCheck, Cloud, User, ScrollText, ClipboardCheck } from 'lucide-react';
import { serverMe, clearToken, getToken } from '@/lib/api'; import { serverLogout, serverMe, clearToken, getToken } from '@/lib/api';
import { LoginDialog } from '@/components/auth/LoginDialog'; import { LoginDialog } from '@/components/auth/LoginDialog';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -109,10 +109,10 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
{/* 左下角:账号区 */} {/* 左下角:账号区 */}
<div className="border-t border-line px-3 py-3"> <div className="border-t border-line px-3 py-3">
{getToken() && me ? ( {getToken() && me ? (
<div className="rounded-lg bg-surface-2 px-3 py-2.5"> <div className="rounded-lg border border-line bg-surface-2 px-3 py-2.5 shadow-card">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-brand text-[10px] font-bold text-white"> <div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-b from-[#4359e8] to-[#2a3ab8] text-[10px] font-bold text-white shadow-card">
{me.username.charAt(0).toUpperCase()} {me.username.charAt(0).toUpperCase()}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -123,7 +123,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
<button <button
className="shrink-0 rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-danger" className="shrink-0 rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-danger"
title="退出登录" title="退出登录"
onClick={() => { clearToken(); qc.clear(); window.location.reload(); }} onClick={() => { void serverLogout(); clearToken(); qc.clear(); window.location.reload(); }}
> >
<LogOut size={13} /> <LogOut size={13} />
</button> </button>
+3 -3
View File
@@ -3,12 +3,12 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( 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', 'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium select-none transition-all duration-150 active:scale-[0.97] disabled:pointer-events-none disabled:opacity-45',
{ {
variants: { variants: {
variant: { variant: {
primary: 'bg-brand text-white hover:bg-brand-hover shadow-card', primary: 'bg-gradient-to-b from-[#3d51e0] to-brand text-white shadow-[0_1px_2px_rgba(48,86,211,.25)] hover:brightness-110 active:brightness-95',
secondary: 'bg-surface text-ink-1 border border-line hover:bg-surface-3 hover:border-line-strong', secondary: 'bg-surface text-ink-1 border border-line shadow-card hover:bg-surface-3 hover:border-line-strong hover:shadow-[0_2px_6px_rgba(0,0,0,.06)]',
ghost: 'text-ink-2 hover:bg-surface-3 hover:text-ink-1', ghost: 'text-ink-2 hover:bg-surface-3 hover:text-ink-1',
subtle: 'bg-brand-subtle text-brand-text hover:brightness-[0.97]', subtle: 'bg-brand-subtle text-brand-text hover:brightness-[0.97]',
danger: 'bg-danger text-white hover:brightness-110', danger: 'bg-danger text-white hover:brightness-110',
+1 -1
View File
@@ -29,7 +29,7 @@ export function Dialog({ open, onClose, title, width = 560, children, footer }:
role="dialog" role="dialog"
> >
<div className="flex h-12 items-center justify-between border-b border-line px-4"> <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> <div className="min-w-0 flex-1 truncate text-sm font-semibold text-ink-1">{title}</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="关闭"> <Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="关闭">
<X size={15} /> <X size={15} />
</Button> </Button>
+6 -3
View File
@@ -8,9 +8,12 @@ export function Empty({ icon, title, description, action }: {
action?: ReactNode; action?: ReactNode;
}) { }) {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center"> <div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<div className="text-ink-3">{icon ?? <Inbox size={36} strokeWidth={1.5} />}</div> <div className="relative flex size-14 items-center justify-center rounded-full border border-line bg-surface-2 text-ink-3 shadow-card">
<div className="text-[13px] font-medium text-ink-2">{title}</div> <div className="absolute inset-0 rounded-full ring-1 ring-brand/5" aria-hidden />
{icon ?? <Inbox size={26} strokeWidth={1.5} />}
</div>
<div className="mt-1 text-[13px] font-medium text-ink-2">{title}</div>
{description && <div className="max-w-[360px] text-[12px] text-ink-3">{description}</div>} {description && <div className="max-w-[360px] text-[12px] text-ink-3">{description}</div>}
{action && <div className="mt-2">{action}</div>} {action && <div className="mt-2">{action}</div>}
</div> </div>
+1 -1
View File
@@ -1,7 +1,7 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'; import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/utils'; 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'; 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:ring-[1.5px] focus:ring-brand/15 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input( export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input(
{ className, ...props }, ref { className, ...props }, ref
+1 -1
View File
@@ -12,7 +12,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSel
ref={ref} ref={ref}
className={cn( 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', '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', 'transition-colors hover:border-line-strong focus:border-brand focus:ring-[1.5px] focus:ring-brand/15 focus:outline-none disabled:opacity-50',
className className
)} )}
{...props} {...props}
+10
View File
@@ -55,6 +55,16 @@ const SERVER_KEY = 'fp-server-url';
export function getServerUrl() { return localStorage.getItem(SERVER_KEY) || 'https://footpr.linyux.cn'; } export function getServerUrl() { return localStorage.getItem(SERVER_KEY) || 'https://footpr.linyux.cn'; }
export function setServerUrl(url: string) { localStorage.setItem(SERVER_KEY, url.replace(/\/+$/, '')); } export function setServerUrl(url: string) { localStorage.setItem(SERVER_KEY, url.replace(/\/+$/, '')); }
// 服务端登出:吊销当前 Bearer token(尽力而为,网络失败不影响本地清理)
export async function serverLogout(baseUrl?: string): Promise<void> {
try {
const t = getToken();
if (!t) return;
const base = baseUrl || getServerUrl();
await fetch(base + '/auth/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } });
} catch { /* 本地清理兜底 */ }
}
export class AuthError extends Error { export class AuthError extends Error {
constructor(msg: string) { super(msg); this.name = 'AuthError'; } constructor(msg: string) { super(msg); this.name = 'AuthError'; }
} }
+39 -24
View File
@@ -38,6 +38,12 @@ export interface ZcOverrides {
manual?: { title: string; section: string; score: number }[]; manual?: { title: string; section: string; score: number }[];
} }
// 学期名('2023-2024-1')或学年名('2023-2024')→ 学年名;无法解析返回 null
export function termYearOf(term: string): string | null {
const m = /^(\d{4})-(\d{4})/.exec(String(term ?? '').trim());
return m ? m[1] + '-' + m[2] : null;
}
// 学年('2024-2025')→ 统计时间窗 [2024-09-01, 2025-09-01) // 学年('2024-2025')→ 统计时间窗 [2024-09-01, 2025-09-01)
export function yearRange(year: string): [string, string] | null { export function yearRange(year: string): [string, string] | null {
const m = /^(\d{4})-(\d{4})$/.exec(year); const m = /^(\d{4})-(\d{4})$/.exec(year);
@@ -45,14 +51,16 @@ export function yearRange(year: string): [string, string] | null {
return [m[1] + '-09-01', m[2] + '-09-01']; return [m[1] + '-09-01', m[2] + '-09-01'];
} }
// 学年 → 年级名(按个人信息入学年份推算:大一~大四) // 学年 → 年级名(按个人信息入学年份推算:大一~大四);无入学年份时退回学年名
export function gradeLabel(year: string, eduStart?: string): string { export function gradeLabel(year: string, eduStart?: string): string {
const m = /^(\d{4})-/.exec(year); const y = termYearOf(year) ?? year;
const m = /^(\d{4})/.exec(y);
const e = /^(\d{4})/.exec(eduStart ?? ''); const e = /^(\d{4})/.exec(eduStart ?? '');
if (!m || !e) return year + ' 学年'; if (!m || !e) return y + ' 学年';
const n = Number(m[1]) - Number(e[1]) + 1; const n = Number(m[1]) - Number(e[1]) + 1;
const names = ['大一', '大二', '大三', '大四', '大五']; const names = ['大一', '大二', '大三', '大四', '大五', '大六', '大七'];
return (names[n - 1] ?? n + '年级') + '(' + year + ' 学年)'; if (n < 1) return y + ' 学年';
return (names[n - 1] ?? n + '年级') + '(' + y + ' 学年)';
} }
function inRange(d: string | null | undefined, range: [string, string] | null): boolean { function inRange(d: string | null | undefined, range: [string, string] | null): boolean {
@@ -90,6 +98,8 @@ export interface ZcItem {
overridden?: boolean; overridden?: boolean;
note?: string; note?: string;
fromRecord?: number; fromRecord?: number;
/** 聚合条款(ccap/kcap/…)对应的记录 id 列表,用于标注哪些记录参与计分 */
recordIds?: number[];
} }
export interface ZcSection { key: string; label: string; cap: number; items: ZcItem[]; subtotal: number } export interface ZcSection { key: string; label: string; cap: number; items: ZcItem[]; subtotal: number }
export interface ZcResult { sections: ZcSection[]; total: number; gpa: number | null } export interface ZcResult { sections: ZcSection[]; total: number; gpa: number | null }
@@ -141,14 +151,8 @@ export function computeZongce(opts: {
])); ]));
// ---- 学业成绩(60):学年平均学分绩点 × 10 ---- // ---- 学业成绩(60):学年平均学分绩点 × 10 ----
const scopedCourses: CourseGrade[] = []; // 口径与「成绩」页统一:优先取学期卡展示的 semester.gpa;未存 gpa 的学期回退到
for (const x of semesters) { // 与「自动绩点」按钮相同的推算公式(档内线性折算 × 分类权重),保证两处数字一致。
if (range && !yearRange(x.term)) continue;
const rng = yearRange(x.term);
const hit = range === null || (rng && !(rng[1] <= range[0] || rng[0] >= range[1]));
if (hit) scopedCourses.push(...x.courses);
}
let gpa: number | null = null;
const gpOf = (c: CourseGrade): number | null => { const gpOf = (c: CourseGrade): number | null => {
if (c.grade_point != null) return c.grade_point; if (c.grade_point != null) return c.grade_point;
if (bands && c.score != null) { if (bands && c.score != null) {
@@ -165,14 +169,25 @@ export function computeZongce(opts: {
return null; return null;
}; };
const wOf = (c: CourseGrade): number => (weights ? (weights[c.category ?? '其他'] ?? weights['其他'] ?? 1) : 1); const wOf = (c: CourseGrade): number => (weights ? (weights[c.category ?? '其他'] ?? weights['其他'] ?? 1) : 1);
const gpCourses = scopedCourses.map(c => ({ ...c, grade_point: gpOf(c) })).filter(c => c.grade_point != null && c.credit > 0); const yearKey = termYearOf(year);
if (gpCourses.length) { const scopedSemesters = semesters.filter(x => !yearKey || termYearOf(x.term) === yearKey);
const credits = gpCourses.reduce((a, c) => a + c.credit, 0); const scopedCourses: CourseGrade[] = [];
gpa = round2(gpCourses.reduce((a, c) => a + (c.grade_point as number) * wOf(c) * c.credit, 0) / credits); const semGpas: { term: string; gpa: number; fallback: boolean }[] = [];
for (const s of scopedSemesters) {
scopedCourses.push(...(s.courses ?? []));
if (s.gpa != null) { semGpas.push({ term: s.term, gpa: s.gpa, fallback: false }); continue; }
const withGp = (s.courses ?? []).map(x => ({ ...x, grade_point: gpOf(x) })).filter(x => x.grade_point != null && x.credit > 0);
if (!withGp.length) continue;
const credits = withGp.reduce((a, x) => a + x.credit, 0);
semGpas.push({ term: s.term, gpa: round2(withGp.reduce((a, x) => a + (x.grade_point as number) * wOf(x) * x.credit, 0) / credits), fallback: true });
} }
const gpaScore = gpa != null ? Math.min(round2(gpa * r.gpaFactor), 60) : 0; const gpa = semGpas.length ? round2(semGpas.reduce((a, x) => a + x.gpa, 0) / semGpas.length) : null;
const gpaNote = gpa == null
? '该学年暂无学期绩点数据(先在「成绩」页录入学期或课程)'
: semGpas.some(x => x.fallback) ? '部分学期绩点未录入,按课程推算' : undefined;
const gpaScore = gpa != null ? round2(gpa * r.gpaFactor) : 0;
sections.push(sec('academic', '学业成绩', 60, [ sections.push(sec('academic', '学业成绩', 60, [
{ itemKey: 'gpa', title: '平均学分绩点 ' + (gpa != null ? gpa.toFixed(2) : '—') + ' × 10', source: '成绩模块 · ' + scopedCourses.length + ' 门课程', score: gpaScore, note: gpa == null ? '该学期暂无课程成绩' : undefined }, { itemKey: 'gpa', title: '学年平均学分绩点 ' + (gpa != null ? gpa.toFixed(2) : '—') + ' × ' + r.gpaFactor, source: '成绩模块 · ' + scopedSemesters.length + ' 学期' + (semGpas.length ? '(绩点均值)' : ''), score: gpaScore, note: gpaNote || (gpa == null ? undefined : scopedCourses.length + ' 门课程') },
])); ]));
// ---- 文体素质(10):体育 + 文体类加分(表2,cap 5) ---- // ---- 文体素质(10):体育 + 文体类加分(表2,cap 5) ----
@@ -187,7 +202,7 @@ export function computeZongce(opts: {
wItems.push(mk('w' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto, undefined, rec.id)); wItems.push(mk('w' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto, undefined, rec.id));
} }
const wCap = round2(Math.min(wItems.reduce((a, x) => a + x.score, 0), 5)); const wCap = round2(Math.min(wItems.reduce((a, x) => a + x.score, 0), 5));
sections.push(sec('sports', '文体素质', 10, [...sportsItems, { itemKey: 'wcap', title: '文体类加分(上限 5 分)', source: wItems.length + ' 条记录', score: wCap, note: wItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }])); sections.push(sec('sports', '文体素质', 10, [...sportsItems, { itemKey: 'wcap', title: '文体类加分(上限 5 分)', source: wItems.length + ' 条记录', score: wCap, note: wItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录', recordIds: wItems.map(x => x.fromRecord).filter(Boolean) as number[] }]));
// ---- 拓展性素质(17):竞赛获奖(10) + 学生工作(4) + 公益(3) ---- // ---- 拓展性素质(17):竞赛获奖(10) + 学生工作(4) + 公益(3) ----
const compItems: ZcItem[] = []; const compItems: ZcItem[] = [];
@@ -218,9 +233,9 @@ export function computeZongce(opts: {
} }
const pubCap = round2(Math.min(pubItems.reduce((a, x) => a + x.score, 0), 3)); const pubCap = round2(Math.min(pubItems.reduce((a, x) => a + x.score, 0), 3));
sections.push(sec('expand', '拓展性素质', 17, [ sections.push(sec('expand', '拓展性素质', 17, [
{ itemKey: 'ccap', title: '竞赛获奖(上限 10 分)', source: compItems.length + ' 条记录', score: compCap, note: compItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }, { itemKey: 'ccap', title: '竞赛获奖(上限 10 分)', source: compItems.length + ' 条记录', score: compCap, note: compItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录', recordIds: compItems.map(x => x.fromRecord).filter(Boolean) as number[] },
{ itemKey: 'kcap', title: '担任学生工作(上限 4 分)', source: workItems.length + ' 条记录', score: workCap, note: workItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }, { itemKey: 'kcap', title: '担任学生工作(上限 4 分)', source: workItems.length + ' 条记录', score: workCap, note: workItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录', recordIds: workItems.map(x => x.fromRecord).filter(Boolean) as number[] },
{ itemKey: 'gcap', title: '公益活动(上限 3 分)', source: pubItems.length + ' 条记录', score: pubCap, note: pubItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }, { itemKey: 'gcap', title: '公益活动(上限 3 分)', source: pubItems.length + ' 条记录', score: pubCap, note: pubItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录', recordIds: pubItems.map(x => x.fromRecord).filter(Boolean) as number[] },
])); ]));
// ---- 奖惩分(±5) ---- // ---- 奖惩分(±5) ----
@@ -255,7 +270,7 @@ export function computeZongce(opts: {
rwItems.push(mk('h' + rec.id, rec.title, source, auto, undefined, rec.id)); rwItems.push(mk('h' + rec.id, rec.title, source, auto, undefined, rec.id));
} }
const rwCap = round2(Math.max(Math.min(rwItems.reduce((a, x) => a + x.score, 0), 5), -5)); const rwCap = round2(Math.max(Math.min(rwItems.reduce((a, x) => a + x.score, 0), 5), -5));
sections.push(sec('rewards', '奖惩分', 5, [{ itemKey: 'hcap', title: '奖惩合计(±5 分)', source: rwItems.length + ' 条记录', score: rwCap, note: rwItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }])); sections.push(sec('rewards', '奖惩分', 5, [{ itemKey: 'hcap', title: '奖惩合计(±5 分)', source: rwItems.length + ' 条记录', score: rwCap, note: rwItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录', recordIds: rwItems.map(x => x.fromRecord).filter(Boolean) as number[] }]));
// 手动条目 // 手动条目
for (const m of ov.manual ?? []) { for (const m of ov.manual ?? []) {
-41
View File
@@ -1,41 +0,0 @@
import type { ProfileInfo } from './types';
import type { ZcSection } from './zongce';
const esc = (t: unknown) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
export function zongceToWordHtml(
termLabel: string,
sections: ZcSection[],
total: number,
profile: Partial<ProfileInfo>
): string {
const body = sections.map(sec => {
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap);
const rows = sec.items.map(it =>
'<tr>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999">' + esc(it.title) + '</td>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999;font-size:10pt;color:#555">' + esc(it.note || it.source) + '</td>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999;text-align:right;width:70pt">' + esc(it.score) + '</td>' +
'</tr>').join('');
return '<h2 style="font-size:13pt;margin:12pt 0 5pt;color:#111">' + esc(sec.label) +
'(上限 ' + esc(sec.cap) + ' 分,小计 ' + esc(subtotal) + ' 分)</h2>' +
'<table style="width:100%;border-collapse:collapse;font-size:11pt">' + rows + '</table>';
}).join('');
const head = '<div style="font-size:11pt;color:#555;margin-bottom:6pt">' +
esc(profile.name ?? '') + ' · ' + esc(profile.className ?? '') + ' · 统计周期:' + esc(termLabel) + '</div>';
return '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
'<head><meta charset="utf-8"><title>综合素质测评</title>' +
'<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View></w:WordDocument></xml><![endif]--></head>' +
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
'<h1 style="text-align:center;font-size:18pt;margin:0 0 6pt">学生综合素质测评</h1>' + head +
'<p style="text-align:center;font-size:13pt;background:#f2f2f2;padding:6pt"><b>综合素质总分:' + esc(total) + ' 分</b></p>' +
body +
'<div style="margin-top:24pt;font-size:10.5pt;color:#555">计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。以上自动匹配条目均经过人工核对。</div>' +
'<table style="width:100%;border-collapse:collapse;margin-top:24pt;font-size:11pt"><tr>' +
'<td style="width:33%">学生签名:______</td>' +
'<td style="width:33%">辅导员签名:______</td>' +
'<td>日期:______</td></tr></table>' +
'</body></html>';
}
+2
View File
@@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App'; import App from './App';
import './styles/theme.css'; import './styles/theme.css';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: true, staleTime: 0, gcTime: 0 } queries: { retry: 1, refetchOnWindowFocus: true, staleTime: 0, gcTime: 0 }
+293 -86
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Download, Images, Printer, RotateCcw } from 'lucide-react'; import { ChevronLeft, ChevronRight, Download, FileText, Images, Printer, RotateCcw } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { attachmentList, attachmentThumb, footprintList, gradesOverview, profileGet, profileUpsert } from '@/lib/api'; import { attachmentList, attachmentThumb, footprintList, gradesOverview, profileGet, profileUpsert } from '@/lib/api';
import type { FootprintRow, ProfileInfo, ZcOverrides } from '@/lib/types'; import type { FootprintRow, ProfileInfo, ZcOverrides } from '@/lib/types';
import { computeZongce, gradeLabel, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce'; import { computeZongce, gradeLabel, termYearOf, yearRange, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce';
import { Badge, Button, Dialog, Select, useToast } from '@/components/ui'; import { Badge, Button, Dialog, Select, useToast } from '@/components/ui';
interface Evidence { recordId: number; title: string; meta: string; images: string[] } interface Evidence { recordId: number; title: string; meta: string; images: string[] }
@@ -38,38 +38,91 @@ function buildMhtml(html: string, images: { name: string; dataUri: string }[]):
return new Blob([parts.join('')], { type: 'multipart/related; type="text/html"; boundary="' + boundary + '"' }); return new Blob([parts.join('')], { type: 'multipart/related; type="text/html"; boundary="' + boundary + '"' });
} }
const W_TD = 'border:0.75pt solid #4a4a4a;padding:4pt 7pt;font-size:10.5pt;vertical-align:middle'; const W_TD = 'border:0.75pt solid #3a3a3a;padding:5pt 8pt;font-size:10.5pt;vertical-align:middle';
const W_TH = W_TD + ';background:#e8eaf0;font-weight:bold;font-family:SimHei;letter-spacing:1pt'; const W_TH = 'border:0.75pt solid #3a3a3a;padding:5pt 8pt;font-size:10.5pt;font-family:"黑体",SimHei;letter-spacing:1pt;vertical-align:middle;background:#eef1f6';
const W_SEC = 'border:0.75pt solid #3a3a3a;padding:5pt 8pt;font-size:11pt;font-family:"黑体",SimHei;letter-spacing:1.5pt;background:#f5f6f9;text-align:left';
// Word 域代码(页码等):条件注释 + field-begin/separator/end,Word 打开时自动计算
const W_FIELD = (name: string, shown: string) =>
'<!--[if supportFields]><span style="mso-element:field-begin"></span>' + name +
'<span style="mso-element:field-separator"></span><![endif]-->' + shown +
'<!--[if supportFields]><span style="mso-element:field-end"></span><![endif]-->';
/** 综合素质测评表:A4 纵向、黑体标题、表格化分项、附图两列栅格 */ /** 评分细则参考(附在 Word 末尾,与页面「评分细则参考」同一数据源) */
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[]): Blob { function rulesAnnexHtml(r: ZcRules): string {
const f = (s: unknown) => esc(s); const basics =
const school = f(profile.school || '云川理工学院'); '<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
const infoCell = (v: unknown, label: string, half = false) => '<tr><td style="' + W_TH + ';width:30%">基础项</td><td style="' + W_TH + '">分值 / 规则</td></tr>' +
'<td style="' + W_TH + ';width:' + (half ? '16%' : '14%') + '">' + label + '</td><td style="' + W_TD + '">' + f(v || '') + '</td>'; '<tr><td style="' + W_TD + '">政治面貌分</td><td style="' + W_TD + '">' + esc(Object.entries(r.politics).map(([k, v]) => k + ' ' + v + ' 分').join(';')) + '</td></tr>' +
'<tr><td style="' + W_TD + '">操行考评 / 诚信分默认</td><td style="' + W_TD + '">' + esc(r.conductDefault) + ' 分 / ' + esc(r.integrityDefault) + ' 分</td></tr>' +
// 分板块明细表 '<tr><td style="' + W_TD + '">学业成绩</td><td style="' + W_TD + '">学年平均学分绩点 × ' + esc(r.gpaFactor) + '(上限 60 分)</td></tr>' +
const body = result.sections.map(sec => { '<tr><td style="' + W_TD + '">体育加分</td><td style="' + W_TD + '">体育课成绩之和 × ' + esc(r.sportsFactor) + '(上限 5 分)</td></tr>' +
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap); '<tr><td style="' + W_TD + '">公益服务</td><td style="' + W_TD + '">' + esc(Object.entries(r.publicGood).map(([k, v]) => k + ' ' + v).join(';')) + '</td></tr>' +
const rows = sec.items.map(it => '</table>';
'<tr>' + const t2 = (r.table2 as unknown as Record<string, Record<string, number>>);
'<td style="' + W_TD + '">' + f(it.title) + (it.overridden ? '(已手动修正)' : '') + '</td>' + const t4 = (r.table4 as unknown as Record<string, Record<string, number>>);
'<td style="' + W_TD + ';color:#555;font-size:9.5pt">' + f(it.note || it.source) + '</td>' + const rw = (r.rewards as unknown as Record<string, Record<string, number>>);
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(it.score) + '</td></tr>', const levels = Object.keys(t2);
).join(''); const grades = ['一等奖', '二等奖', '三等奖', '优秀奖'];
const matrix =
'<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
'<tr><td style="' + W_TH + ';width:20%">获奖级别</td>' + grades.map(g => '<td style="' + W_TH + '">' + g + '</td>').join('') + '</tr>' +
levels.map(lv => '<tr><td style="' + W_TD + '">' + esc(lv) + '</td>' + grades.map(g => '<td style="' + W_TD + ';text-align:right" class="num">' + esc(t2[lv]?.[g] ?? '—') + '</td>').join('') + '</tr>').join('') +
'</table>';
const work =
'<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
'<tr><td style="' + W_TH + ';width:20%">学生工作级别</td><td style="' + W_TH + '">岗位分值</td></tr>' +
Object.keys(t4).map(lv => '<tr><td style="' + W_TD + '">' + esc(lv) + '</td><td style="' + W_TD + '">' + esc(Object.entries(t4[lv]).map(([k, v]) => k + ' ' + v).join(';')) + '</td></tr>').join('') +
'</table>';
const others =
'<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
'<tr><td style="' + W_TH + ';width:20%">类别</td><td style="' + W_TH + '">分值</td></tr>' +
['荣誉称号', '英语等级', '计算机等级', '职业资格'].map(k => '<tr><td style="' + W_TD + '">' + esc(k) + '</td><td style="' + W_TD + '">' + esc(Object.entries(rw[k] ?? {}).map(([a, v]) => a + ' ' + v).join(';')) + '</td></tr>').join('') +
'<tr><td style="' + W_TD + '">违纪扣分</td><td style="' + W_TD + '">' + esc(Object.entries(r.penalties).map(([k, v]) => k + ' -' + v).join(';')) + '</td></tr>' +
'</table>';
return ( return (
'<table style="border-collapse:collapse;width:100%;margin:6pt 0 0" border="1">' + '<div style="font-family:SimHei;font-size:12pt;margin:14pt 0 4pt">附 2:评分细则参考(默认值,可自行调整)</div>' +
'<tr><td style="' + W_TH + ';width:34%">考评项目</td>' + basics + matrix + work + others
'<td style="' + W_TH + '">评分依据</td>' +
'<td style="' + W_TH + ';width:64pt;text-align:center">分值</td></tr>' +
rows +
'<tr><td colspan="2" style="' + W_TD + ';text-align:right;background:#f4f5f7"><b>' + f(sec.label) + '小计</b>(板块上限 ' + f(sec.cap) + ' 分)</td>' +
'<td style="' + W_TD + ';text-align:right;background:#f4f5f7"><b class="num">' + f(subtotal) + '</b></td></tr>' +
'</table>'
); );
}).join(''); }
// 证明材料附图:每条记录一个组块,图两列(约 200pt 宽) /** 综合素质测评表:A4 纵向、宋体正文/黑体标题、统一表格版式、页脚页码、附1 材料图、附2 细则 */
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[], rules: ZcRules): Blob {
const f = (s: unknown) => esc(s);
const schoolLine = profile.school ? '<div style="text-align:center;font-size:10.5pt;letter-spacing:2pt;color:#222">' + f(profile.school) + '</div>' : '';
const infoCell = (v: unknown, label: string) =>
'<td style="' + W_TH + ';width:16%;text-align:center">' + label + '</td><td style="' + W_TD + ';text-align:center">' + f(v || '') + '</td>';
// 统一分项表:各板块内嵌为整表的小节行 + 小计行,末尾总分行
const cnNum = ['一', '二', '三', '四', '五', '六', '七', '八'];
const rows: string[] = [];
result.sections.forEach((sec, si) => {
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap);
rows.push('<tr><td colspan="3" style="' + W_SEC + '">' + f(cnNum[si] ?? si + 1) + '、' + f(sec.label) + '(上限 ' + f(sec.cap) + ' 分)</td></tr>');
for (const it of sec.items) {
rows.push(
'<tr><td style="' + W_TD + '">' + f(it.title) + (it.overridden ? '<span style="color:#8a6d3b">(已手动修正)</span>' : '') + '</td>' +
'<td style="' + W_TD + ';color:#444;font-size:9.5pt;border-left-width:0.75pt">' + f(it.note || it.source) + '</td>' +
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(it.score) + '</td></tr>',
);
}
rows.push(
'<tr><td colspan="2" style="' + W_TD + ';text-align:right;background:#fafbfc">' + f(sec.label) + '小计</td>' +
'<td style="' + W_TD + ';text-align:right;background:#fafbfc" class="num"><b>' + f(subtotal) + '</b></td></tr>',
);
});
rows.push(
'<tr><td colspan="2" style="' + W_TD + ';text-align:center;font-family:"黑体",SimHei;font-size:12pt;letter-spacing:2pt;background:#eef1f6">综合素质测评总分</td>' +
'<td style="' + W_TD + ';text-align:right;background:#eef1f6" class="num"><b style="font-size:12pt">' + f(result.total) + '</b></td></tr>',
);
const specTable =
'<table style="border-collapse:collapse;width:100%;margin-top:10pt" border="1">' +
'<tr><td style="' + W_TH + ';width:34%;text-align:center">考评项目</td>' +
'<td style="' + W_TH + ';text-align:center">评分依据</td>' +
'<td style="' + W_TH + ';width:64pt;text-align:center">分值</td></tr>' +
rows.join('') +
'</table>';
// 附1:证明材料——每条有附件的活动 / 竞赛记录成组,图片纵向排布并编图号
const images: { name: string; dataUri: string }[] = []; const images: { name: string; dataUri: string }[] = [];
let figNo = 0; let figNo = 0;
const grids = evidence.map(ev => { const grids = evidence.map(ev => {
@@ -77,57 +130,77 @@ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<P
figNo++; figNo++;
const name = 'ev' + ev.recordId + '_' + (images.length); const name = 'ev' + ev.recordId + '_' + (images.length);
images.push({ name, dataUri: uri }); images.push({ name, dataUri: uri });
return '<div style="text-align:center"><img src="' + name + '" style="width:196pt;border:0.75pt solid #8a8a8a" alt="附图"><br>' + return '<div style="text-align:center;margin:3pt 0"><img src="' + name + '" style="width:200pt;border:0.75pt solid #5a5a5a" alt="附图"><br>' +
'<span style="font-size:9pt;color:#444">图 ' + figNo + ' ' + f(ev.title) + '</span></div>'; '<span style="font-size:9pt;color:#333;font-family:"宋体",SimSun">图 ' + figNo + ' ' + f(ev.title) + '</span></div>';
}).join(''); }).join('');
return '<tr><td style="' + W_TD + ';width:26%;vertical-align:top;background:#fafafa"><b style="font-size:10pt">' + f(ev.title) + '</b><br>' + return '<tr><td style="' + W_TD + ';width:26%;vertical-align:top;background:#fafafa"><b style="font-size:10pt">' + f(ev.title) + '</b><br>' +
'<span style="font-size:9pt;color:#666">' + f(ev.meta) + '</span></td>' + '<span style="font-size:9pt;color:#555">' + f(ev.meta) + '</span></td>' +
'<td style="' + W_TD + '"><table style="width:100%;border-collapse:collapse"><tr><td style="padding:2pt">' + imgs + '</td></tr></table></td></tr>'; '<td style="' + W_TD + '">' + imgs + '</td></tr>';
}).join(''); }).join('');
const annex = grids const annex = grids
? '<div style="font-family:SimHei;font-size:12pt;margin:14pt 0 4pt">附:加分证明材料(' + f(evidence.length) + ' 项记录 · ' + f(figNo) + ' 张材料图)</div>' + ? '<div style="font-family:"黑体",SimHei;font-size:12pt;margin:16pt 0 4pt;letter-spacing:1pt">附 1:加分证明材料</div>' +
'<div style="font-size:9.5pt;color:#555;margin:0 0 4pt">(' + f(evidence.length) + ' 项记录 · ' + f(figNo) + ' 张材料图)</div>' +
'<table style="border-collapse:collapse;width:100%" border="1">' + grids + '</table>' '<table style="border-collapse:collapse;width:100%" border="1">' + grids + '</table>'
: ''; : '';
// 信息栏 3×3:末格为电话号码
const info =
'<table style="border-collapse:collapse;width:100%;margin-top:12pt" border="1">' +
'<tr>' + infoCell(profile.studentId, '学 号') + infoCell(profile.name, '姓 名') + infoCell(profile.gender, '性 别') + '</tr>' +
'<tr>' + infoCell(profile.college, '学 院') + infoCell(profile.major, '专 业') + infoCell(profile.className, '班 级') + '</tr>' +
'<tr>' + infoCell(profile.political, '政治面貌') + infoCell(termLabel, '统计周期') + infoCell(profile.phone, '电话号码') + '</tr>' +
'</table>';
const html = const html =
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' + '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
'<head><meta charset="utf-8"><title>学生综合素质测评表</title>' + '<head><meta charset="utf-8"><title>学生综合素质测评表</title>' +
'<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:TrackChanges>0</w:TrackChanges></w:WordDocument></xml><![endif]-->' +
'<style>' + '<style>' +
'@page WordSection1 { size:595.3pt 841.9pt; margin:56.7pt 62pt 62pt; mso-footer: f1; }' + '@page WordSection1 { size:595.3pt 841.9pt; margin:72pt 78pt 66pt; mso-footer: f1; }' +
'div.WordSection1 { page:WordSection1; }' + 'div.WordSection1 { page:WordSection1; }' +
'body { font-family:SimSun,serif; font-size:10.5pt; color:#1a1a1a; }' + 'body { font-family:"宋体",SimSun,serif; font-size:10.5pt; color:#1a1a1a; line-height:1.5; }' +
'h1.title { font-family:SimHei; font-size:16pt; text-align:center; letter-spacing:2pt; margin:0 0 4pt; }' + 'h1.title { font-family:"黑体",SimHei; font-size:18pt; text-align:center; letter-spacing:4pt; margin:2pt 0 6pt; font-weight:bold; }' +
'p.sub { text-align:center; font-size:10pt; color:#333; margin:0; }' + 'p.sub { text-align:center; font-size:10pt; color:#333; margin:0 0 2pt; }' +
'table { border-collapse:collapse; }' + 'table { border-collapse:collapse; }' +
'.total td { background:#eef1f6; font-family:SimHei; font-size:12pt; }' + '.num { font-family:"Times New Roman",SimSun; }' +
'.num { font-family:Calibri,SimSun; }' +
'</style></head>' + '</style></head>' +
'<body><div class="WordSection1">' + '<body><div class="WordSection1">' +
'<div style="text-align:center;font-size:10.5pt;letter-spacing:1pt;color:#333">' + school + '</div>' + schoolLine +
'<h1 class="title">学生综合素质测评表</h1>' + '<h1 class="title">学生综合素质测评表</h1>' +
'<p class="sub">测评学年:' + f(termLabel) + '</p>' + '<p class="sub">测评学年:' + f(termLabel) + '</p>' +
'<table style="border-collapse:collapse;width:100%;margin-top:10pt" border="1">' + info +
'<tr>' + infoCell(profile.studentId, '学 号') + infoCell(profile.name, '姓 名') + infoCell(profile.gender, '性 别') + '</tr>' + specTable +
'<tr>' + infoCell(profile.college, '学 院') + infoCell(profile.major, '专 业') + infoCell(profile.className, '班 级') + '</tr>' +
'<tr>' + infoCell(profile.political, '政治面貌') +
'<td style="' + W_TH + '">测评学年</td><td style="' + W_TD + '">' + f(termLabel) + '</td>' +
'<td style="' + W_TH + '">测评总分</td><td style="' + W_TD + '"><b class="num" style="font-size:12pt">' + f(result.total) + '</b> / 100 分</td></tr>' +
'</table>' +
body +
'<table style="border-collapse:collapse;width:100%;margin-top:8pt" border="1"><tr class="total">' +
'<td style="' + W_TD + ';text-align:left;font-family:SimHei">综合素质测评总分</td>' +
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(result.total) + '</td></tr></table>' +
annex + annex +
'<table style="width:100%;border-collapse:collapse;margin-top:22pt;font-size:10.5pt"><tr>' + rulesAnnexHtml(rules) +
'<table style="width:100%;border-collapse:collapse;margin-top:26pt;font-size:10.5pt"><tr>' +
'<td style="width:25%">学生签名:_____</td>' + '<td style="width:25%">学生签名:_____</td>' +
'<td style="width:25%">班长签名:_____</td>' + '<td style="width:25%">班长签名:_____</td>' +
'<td style="width:25%">辅导员签名:_____</td>' + '<td style="width:25%">辅导员签名:_____</td>' +
'<td>日期:______</td></tr></table>' + '<td>日期:______</td></tr></table>' +
'<p style="font-size:9pt;color:#555;margin-top:10pt">说明:本表依据《计算机与网络安全学院学生综合素质测评评分细则》自动生成;带「已手动修正」标记的分值为辅导员核准后的手动结果。导出前请核对加分记录与证明材料一致。</p>' + '<p style="font-size:9pt;color:#555;margin-top:12pt">说明:本表依据《学生综合素质测评评分细则》自动生成(细则默认值见附 2,可自行调整);带「已手动修正」标记的分值为手动核定结果。导出前请核对加分记录与证明材料一致。</p>' +
'<div style="mso-element:footer" id="f1">' +
'<p style="text-align:center;font-size:9pt;font-family:"宋体",SimSun;margin:0">第 ' + W_FIELD('PAGE', '1') + ' 页(共 ' + W_FIELD('NUMPAGES', '1') + ' 页)</p>' +
'</div>' +
'</div></body></html>'; '</div></body></html>';
return buildMhtml(html, images); return buildMhtml(html, images);
} }
// ---------------- 评分细则参考编辑 UI ----------------
const NumCell = ({ value, onChange, wide }: { value: number; onChange: (v: number) => void; wide?: boolean }) => (
<input type="number" step="0.05"
className={'num rounded-md border border-neutral-200 bg-white px-1 py-0.5 text-right text-[11.5px] focus:border-brand focus:outline-none ' + (wide ? 'w-[76px]' : 'w-[58px]')}
value={value}
onChange={e => onChange(e.target.value === '' ? 0 : Number(e.target.value))} />
);
const MiniTable = ({ head, children }: { head: string[]; children: ReactNode }) => (
<table className="w-full border border-neutral-200 text-[11.5px]">
<thead><tr>{head.map((h, i) => <th key={i} className="border-b border-neutral-200 bg-neutral-50 px-2 py-1 text-left font-medium text-neutral-500">{h}</th>)}</tr></thead>
<tbody>{children}</tbody>
</table>
);
export function AssessmentPage() { export function AssessmentPage() {
const toast = useToast(); const toast = useToast();
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet }); const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
@@ -136,21 +209,29 @@ export function AssessmentPage() {
const stored = profileQ.data?.content; const stored = profileQ.data?.content;
const profile: Partial<ProfileInfo> = { ...(stored?.info ?? {}) }; const profile: Partial<ProfileInfo> = { ...(stored?.info ?? {}) };
const rules: ZcRules = stored?.zongce?.rules ?? ZC_DEFAULT_RULES;
const [year, setYear] = useState(''); const [year, setYear] = useState('');
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({}); const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
const [evidence, setEvidence] = useState<Evidence[]>([]); const [evidence, setEvidence] = useState<Evidence[]>([]);
const [initOpen, setInitOpen] = useState(false); const [initOpen, setInitOpen] = useState(false);
const [rulesOpen, setRulesOpen] = useState(false);
const rulesRef = useRef<HTMLDivElement>(null);
const [rules, setRules] = useState<ZcRules>(ZC_DEFAULT_RULES);
const [zoom, setZoom] = useState<{ src: string; title: string; index: number } | null>(null); const [zoom, setZoom] = useState<{ src: string; title: string; index: number } | null>(null);
useEffect(() => { useEffect(() => {
if (!stored) return; if (!stored) return;
setOvMap(stored.zongce?.overrides ?? {}); setOvMap(stored.zongce?.overrides ?? {});
const years = Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.slice(0, 7)))); if (stored.zongce?.rules) setRules(stored.zongce.rules);
if (!year && years.length) setYear(years[0]); }, [stored]);
}, [stored, gradesQ.data]);
const records: FootprintRow[] = listQ.data?.items ?? []; const records: FootprintRow[] = listQ.data?.items ?? [];
const termYears = useMemo(() => Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => termYearOf(x.term)).filter(Boolean) as string[])).sort(), [gradesQ.data]);
useEffect(() => {
if (!stored) return;
setOvMap(stored.zongce?.overrides ?? {});
if (!year && termYears.length) setYear(termYears[0]);
}, [stored, gradesQ.data]);
const ov: ZcOverrides = ovMap[year] ?? {}; const ov: ZcOverrides = ovMap[year] ?? {};
const result = useMemo( const result = useMemo(
() => computeZongce({ () => computeZongce({
@@ -162,23 +243,41 @@ export function AssessmentPage() {
[records, gradesQ.data, profile, rules, ov, year], [records, gradesQ.data, profile, rules, ov, year],
); );
// 证明材料:匹配条目对应记录的附件缩略图(每条最多 2 张) // 学年 → 大几:优先用个人信息「入学时间」,缺省时以最早学期推算
const matchedIds = useMemo(() => { const eduStartEff = profile.eduStart || (termYears.length ? termYears[0] + '-09' : '');
const ids: number[] = []; const termLabel = year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff);
result.sections.forEach(sec => sec.items.forEach(it => { if (it.fromRecord) ids.push(it.fromRecord); }));
return ids; // 本学年范围内全部记录(证据画廊不再只看计分记录——有附件就展示)
const scopeIds = useMemo(() => {
const range = year && year !== 'all' ? yearRange(year) : null;
return records.filter(x => {
if (x.status === 'draft') return false;
if (!range) return true;
const d = (x.start_date ?? x.created_at ?? '').slice(0, 10);
return d >= range[0] && d < range[1];
}).map(x => x.id);
}, [records, year]);
// 参与计分的记录 id(画廊中标注「计分」)
const matchedSet = useMemo(() => {
const s = new Set<number>();
result.sections.forEach(sec => sec.items.forEach(it => {
if (it.fromRecord) s.add(it.fromRecord);
for (const rid of it.recordIds ?? []) s.add(rid);
}));
return s;
}, [result]); }, [result]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
(async () => { (async () => {
const out: Evidence[] = []; const out: Evidence[] = [];
for (const rid of matchedIds) { for (const rid of scopeIds) {
const rec = records.find(r => r.id === rid); const rec = records.find(r => r.id === rid);
try { try {
const attList = await attachmentList(rid); const attList = await attachmentList(rid);
const images: string[] = []; const images: string[] = [];
for (const att of attList.items.filter(x => x.id).slice(0, 2)) { for (const att of attList.items.filter(x => x.id && (x.mime ?? '').startsWith('image/'))) {
const th = await attachmentThumb(att.id); const th = await attachmentThumb(att.id);
if (th.data_b64) images.push('data:' + (att.mime === 'image/jpeg' ? 'image/jpeg' : 'image/webp') + ';base64,' + th.data_b64); if (th.data_b64) images.push('data:' + (att.mime === 'image/jpeg' ? 'image/jpeg' : 'image/webp') + ';base64,' + th.data_b64);
} }
@@ -191,7 +290,7 @@ export function AssessmentPage() {
if (alive) setEvidence(out); if (alive) setEvidence(out);
})(); })();
return () => { alive = false; }; return () => { alive = false; };
}, [matchedIds.join(',')]); }, [scopeIds.join(',')]);
// 全部放平的图序列(灯箱上一张/下一张用) // 全部放平的图序列(灯箱上一张/下一张用)
const flatImages = useMemo(() => { const flatImages = useMemo(() => {
@@ -200,19 +299,32 @@ export function AssessmentPage() {
return arr; return arr;
}, [evidence]); }, [evidence]);
const persist = (p2: { rules?: ZcRules; overrides?: Record<string, ZcOverrides> }) =>
void profileUpsert({ info: profile, zongce: { rules: p2.rules ?? rules, overrides: p2.overrides ?? ovMap } }).catch(e => toast('error', e.message));
const persistOv = (next: ZcOverrides) => { const persistOv = (next: ZcOverrides) => {
setOvMap(m => ({ ...m, [year]: next })); setOvMap(m => ({ ...m, [year]: next }));
void profileUpsert({ info: profile, zongce: { rules, overrides: { ...ovMap, [year]: next } } }).catch(e => toast('error', e.message)); persist({ overrides: { ...ovMap, [year]: next } });
};
const editRule = (fn: (r: ZcRules) => void) => {
const next: ZcRules = JSON.parse(JSON.stringify(rules));
fn(next);
setRules(next);
persist({ rules: next });
};
const resetRules = () => {
const d: ZcRules = JSON.parse(JSON.stringify(ZC_DEFAULT_RULES));
setRules(d);
persist({ rules: d });
toast('success', '评分细则已恢复默认');
}; };
const termLabel = year === 'all' ? '全部学年' : gradeLabel(year, profile.eduStart);
const exportWord = () => { const exportWord = () => {
const blob = zongceWordMhtml(termLabel, result, profile, evidence); const blob = zongceWordMhtml(termLabel, result, profile, evidence, rules);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.download = '综合素质测评_' + termLabel + '.doc'; a.click(); a.href = url; a.download = '综合素质测评_' + termLabel + '.doc'; a.click();
setTimeout(() => URL.revokeObjectURL(url), 60000); setTimeout(() => URL.revokeObjectURL(url), 60000);
toast('success', '综测表已导出(.doc,含证明材料附图 ' + flatImages.length + ' 张)'); toast('success', '综测表已导出(.doc,附证明材料图 ' + flatImages.length + ' 张 + 细则参考)');
}; };
const zoomIdx = zoom ? flatImages.findIndex(x => x.src === zoom.src) : -1; const zoomIdx = zoom ? flatImages.findIndex(x => x.src === zoom.src) : -1;
@@ -230,32 +342,120 @@ export function AssessmentPage() {
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2"> <div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
<div> <div>
<h2 className="text-[15px] font-semibold text-ink-1">综合测评</h2> <h2 className="text-[15px] font-semibold text-ink-1">综合测评</h2>
<p className="mt-0.5 text-[11.5px] text-ink-3">按学年依据学院细则自动计算,可手动修正,支持导出 Word 测评表</p> <p className="mt-0.5 text-[11.5px] text-ink-3">按学年依据评分细则自动计算,可手动修正分值,支持导出 Word 测评表</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select className="w-[190px]" value={year} onChange={e => setYear(e.target.value)} <Select className="w-[190px]" value={year} onChange={e => setYear(e.target.value)}
options={[...Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.split('-').slice(0, 2).join('-')))).map(y => ({ value: y, label: gradeLabel(y, profile.eduStart) })), { value: 'all', label: '全部学年' }]} /> options={[...termYears.map(y => ({ value: y, label: gradeLabel(y, eduStartEff) })), { value: 'all', label: '全部学年' }]} />
<Button variant="secondary" onClick={() => {
const next = !rulesOpen;
setRulesOpen(next);
if (next) setTimeout(() => rulesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
}}><FileText size={13} /> 评分细则参考</Button>
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</Button> <Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</Button>
<Button variant="secondary" onClick={() => setInitOpen(true)}><RotateCcw size={13} /> 初始化</Button> <Button variant="secondary" onClick={() => setInitOpen(true)}><RotateCcw size={13} /> 初始化</Button>
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button> <Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button>
</div> </div>
</div> </div>
{/* 评分细则参考:可修改,随档案保存;导出 Word 附同款参考表。置于页面顶部,点击按钮立即可见 */}
{rulesOpen && (
<div ref={rulesRef} className="no-print mb-4 scroll-mt-16 rounded-xl border border-line bg-white p-4 shadow-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText size={15} className="text-neutral-500" />
<span className="text-[13.5px] font-bold text-neutral-900">评分细则参考</span>
<span className="text-[11.5px] text-neutral-400">默认值可自行调整,调整后即时生效并随档案保存</span>
</div>
<Button variant="ghost" onClick={resetRules}><RotateCcw size={13} /> 恢复默认</Button>
</div>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<div className="mb-1.5 text-[12px] font-semibold text-neutral-600">基础分值</div>
<MiniTable head={['项目', '分值']}>
{Object.entries(rules.politics).map(([k, v]) => (
<tr key={k}><td className="border-b border-neutral-100 px-2 py-1">{k}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { (r.politics as Record<string, number>)[k] = v2; })} /></td></tr>
))}
<tr><td className="border-b border-neutral-100 px-2 py-1">操行考评默认分</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={rules.conductDefault} onChange={v2 => editRule(r => { r.conductDefault = v2; })} /></td></tr>
<tr><td className="border-b border-neutral-100 px-2 py-1">诚信分默认</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={rules.integrityDefault} onChange={v2 => editRule(r => { r.integrityDefault = v2; })} /></td></tr>
<tr><td className="border-b border-neutral-100 px-2 py-1">学业成绩(绩点 ×)</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={rules.gpaFactor} onChange={v2 => editRule(r => { r.gpaFactor = v2; })} /></td></tr>
<tr><td className="border-b border-neutral-100 px-2 py-1">体育加分(成绩 ×)</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={rules.sportsFactor} onChange={v2 => editRule(r => { r.sportsFactor = v2; })} /></td></tr>
</MiniTable>
<div className="mb-1.5 mt-3 text-[12px] font-semibold text-neutral-600">公益服务</div>
<MiniTable head={['项目', '分值']}>
{Object.entries(rules.publicGood).map(([k, v]) => (
<tr key={k}><td className="border-b border-neutral-100 px-2 py-1">{k}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { (r.publicGood as Record<string, number>)[k] = v2; })} /></td></tr>
))}
</MiniTable>
<div className="mb-1.5 mt-3 text-[12px] font-semibold text-neutral-600">违纪扣分</div>
<MiniTable head={['项目', '扣分']}>
{Object.entries(rules.penalties).map(([k, v]) => (
<tr key={k}><td className="border-b border-neutral-100 px-2 py-1">{k}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { (r.penalties as Record<string, number>)[k] = v2; })} /></td></tr>
))}
</MiniTable>
</div>
<div>
<div className="mb-1.5 text-[12px] font-semibold text-neutral-600">竞赛 / 文体奖项加分(级别 × 奖项)</div>
<table className="w-full border border-neutral-200 text-[11.5px]">
<thead><tr><th className="border-b border-neutral-200 bg-neutral-50 px-2 py-1 text-left font-medium text-neutral-500">级别</th>
{['一等奖', '二等奖', '三等奖', '优秀奖'].map(g => <th key={g} className="border-b border-neutral-200 bg-neutral-50 px-2 py-1 text-left font-medium text-neutral-500">{g}</th>)}</tr></thead>
<tbody>
{Object.entries(rules.table2).map(([lv, grades2]) => (
<tr key={lv}><td className="border-b border-neutral-100 px-2 py-1 font-medium text-neutral-700">{lv}</td>
{(['一等奖', '二等奖', '三等奖', '优秀奖'] as const).map(g => (
<td key={g} className="border-b border-neutral-100 px-1 py-1">
<NumCell value={(grades2 as Record<string, number>)[g] ?? 0} onChange={v2 => editRule(r => { ((r.table2 as unknown as Record<string, Record<string, number>>)[lv])[g] = v2; })} />
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="mb-1.5 mt-3 text-[12px] font-semibold text-neutral-600">学生工作</div>
<MiniTable head={['级别 / 岗位', '分值']}>
{Object.entries(rules.table4).flatMap(([lv, jobs]) =>
Object.entries(jobs as Record<string, number>).map(([job, v]) => (
<tr key={lv + job}><td className="border-b border-neutral-100 px-2 py-1">{lv} · {job}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { ((r.table4 as unknown as Record<string, Record<string, number>>)[lv])[job] = v2; })} /></td></tr>
)),
)}
</MiniTable>
<div className="mb-1.5 mt-3 text-[12px] font-semibold text-neutral-600">荣誉与证照加分</div>
<MiniTable head={['类别 / 等级', '分值']}>
{Object.entries(rules.rewards).flatMap(([cat, vals]) =>
Object.entries(vals as Record<string, number>).map(([k, v]) => (
<tr key={cat + k}><td className="border-b border-neutral-100 px-2 py-1">{cat} · {k}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { ((r.rewards as unknown as Record<string, Record<string, number>>)[cat])[k] = v2; }) } /></td></tr>
)),
)}
</MiniTable>
</div>
</div>
</div>
)}
<div className="print-sheet mx-auto w-full max-w-[820px] rounded-xl border border-line bg-white shadow-card sm:p-8"> <div className="print-sheet mx-auto w-full max-w-[820px] rounded-xl border border-line bg-white shadow-card sm:p-8">
<div className="text-center text-[11px] tracking-widest text-neutral-500">{profile.school || '云川理工学院'}</div> {profile.school && <div className="text-center text-[11px] tracking-widest text-neutral-500">{profile.school}</div>}
<h1 className="mt-0.5 text-center text-[19px] font-bold tracking-wide text-neutral-900">学生综合素质测评表</h1> <h1 className="mt-0.5 text-center text-[19px] font-bold tracking-wide text-neutral-900">学生综合素质测评表</h1>
<div className="mt-1 text-center text-[12px] text-neutral-500">测评学年:{termLabel}</div> <div className="mt-1 text-center text-[12px] text-neutral-500">测评学年:{termLabel}</div>
{/* 学生信息栏 */} {/* 学生信息栏 3×3:末格为电话号码 */}
<div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-neutral-300 bg-neutral-300 text-[12.5px] sm:grid-cols-3"> <div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-neutral-300 bg-neutral-300 text-[12.5px] sm:grid-cols-3">
{[ {[
['学 号', profile.studentId], ['姓 名', profile.name], ['性 别', profile.gender], ['学 号', profile.studentId], ['姓 名', profile.name], ['性 别', profile.gender],
['学 院', profile.college], ['专 业', profile.major], ['班 级', profile.className], ['学 院', profile.college], ['专 业', profile.major], ['班 级', profile.className],
['政治面貌', profile.political], ['统计周期', termLabel], ['政治面貌', profile.political], ['统计周期', termLabel], ['电话号码', profile.phone],
].map(([label, val]) => ( ].map(([label, val]) => (
<div key={label as string} className="flex bg-white"> <div key={label as string} className="flex bg-white">
<span className="w-[62px] shrink-0 bg-neutral-50 px-2 py-1.5 font-medium text-neutral-500">{label}</span> <span className="w-[62px] shrink-0 bg-neutral-50 px-2 py-1.5 font-medium text-neutral-500">{label}</span>
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-neutral-900">{val ?? '—'}</span> <span className="min-w-0 flex-1 truncate px-2 py-1.5 text-neutral-900">{val || '—'}</span>
</div> </div>
))} ))}
</div> </div>
@@ -302,7 +502,7 @@ export function AssessmentPage() {
</div> </div>
))} ))}
{/* 证明材料:图片画廊 */} {/* 附:证明材料——本学年范围内每条有附件的活动 / 竞赛记录都展示 */}
{(evidence.length > 0 || totalImgs > 0) && ( {(evidence.length > 0 || totalImgs > 0) && (
<div className="mt-7"> <div className="mt-7">
<div className="flex items-center justify-between border-t border-neutral-200 pt-4"> <div className="flex items-center justify-between border-t border-neutral-200 pt-4">
@@ -316,10 +516,15 @@ export function AssessmentPage() {
{evidence.map(ev => ( {evidence.map(ev => (
<div key={ev.recordId} className="rounded-lg border border-neutral-200 bg-white p-2.5 transition-shadow hover:shadow-md"> <div key={ev.recordId} className="rounded-lg border border-neutral-200 bg-white p-2.5 transition-shadow hover:shadow-md">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{matchedSet.has(ev.recordId)
? <Badge color="var(--brand-text)">计分</Badge>
: <Badge className="no-print" color="var(--text-3)">未计分</Badge>}
<div className="min-w-0 truncate text-[12.5px] font-semibold text-neutral-800">{ev.title}</div> <div className="min-w-0 truncate text-[12.5px] font-semibold text-neutral-800">{ev.title}</div>
</div>
<div className="shrink-0 text-[10.5px] text-neutral-400">{ev.meta}</div> <div className="shrink-0 text-[10.5px] text-neutral-400">{ev.meta}</div>
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-2"> <div className={'mt-2 gap-2 ' + (ev.images.length > 1 ? 'grid grid-cols-2' : 'flex')}>
{ev.images.map((src, i) => ( {ev.images.map((src, i) => (
<button key={i} onClick={() => setZoom({ src, title: ev.title, index: flatImages.findIndex(x => x.src === src) })} <button key={i} onClick={() => setZoom({ src, title: ev.title, index: flatImages.findIndex(x => x.src === src) })}
className="group overflow-hidden rounded-md border border-neutral-200 bg-neutral-50"> className="group overflow-hidden rounded-md border border-neutral-200 bg-neutral-50">
@@ -334,8 +539,8 @@ export function AssessmentPage() {
)} )}
<div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400"> <div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值; 计分依据:《学生综合素质测评评分细则》(默认值可在上方「评分细则参考」查看与修改)。
「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。 自动匹配条目均可手动修正分值;「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。
</div> </div>
</div> </div>
@@ -344,7 +549,7 @@ export function AssessmentPage() {
open={!!zoom} open={!!zoom}
onClose={() => setZoom(null)} onClose={() => setZoom(null)}
width={920} width={920}
title={zoom?.title ?? '' + (zoom ? '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : '')} title={zoom ? zoom.title + '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : ''}
footer={ footer={
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<Button variant="secondary" disabled={zoomIdx <= 0} onClick={() => gotoZoom(-1)}><ChevronLeft size={13} /> 上一张</Button> <Button variant="secondary" disabled={zoomIdx <= 0} onClick={() => gotoZoom(-1)}><ChevronLeft size={13} /> 上一张</Button>
@@ -366,13 +571,15 @@ export function AssessmentPage() {
const next = { ...ovMap }; const next = { ...ovMap };
delete next[year]; delete next[year];
setOvMap(next); setOvMap(next);
void profileUpsert({ info: profile, zongce: { rules, overrides: next } }).then(() => { setInitOpen(false); toast('success', '已恢复自动计算'); }).catch(e => toast('error', e.message)); persist({ overrides: next });
setInitOpen(false);
toast('success', '已恢复自动计算');
}}>恢复自动计算</Button> }}>恢复自动计算</Button>
</>}> </>}>
<div className="text-[12.5px] leading-relaxed text-ink-2"> <div className="text-[12.5px] leading-relaxed text-ink-2">
将清除 <b>{year === 'all' ? '全部学年' : gradeLabel(year, profile.eduStart)}</b> 的全部手动修正 将清除 <b>{year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff)}</b> 的全部手动修正
(分值覆盖、操行/诚信分修改),恢复为按细则规则的自动计算结果。 (分值覆盖、操行/诚信分修改),恢复为按细则规则的自动计算结果。
<div className="mt-2 text-ink-3">记录数据与个人信息不受影响。</div> <div className="mt-2 text-ink-3">记录数据、个人信息与评分细则不受影响。</div>
</div> </div>
</Dialog> </Dialog>
</div> </div>
+11 -1
View File
@@ -64,7 +64,7 @@
--border-strong: #363b47; --border-strong: #363b47;
--text-1: #e9ebf0; --text-1: #e9ebf0;
--text-2: #a0a8b8; --text-2: #a0a8b8;
--text-3: #68707e; --text-3: #747c8a;
--brand: #6d8bfa; --brand: #6d8bfa;
--brand-hover: #8aa2fb; --brand-hover: #8aa2fb;
--brand-subtle: #1a2144; --brand-subtle: #1a2144;
@@ -145,10 +145,20 @@ input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } input[type='number']::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type='number'] { -moz-appearance: textfield; appearance: textfield; } input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
/* 页头毛玻璃(滚动透出内容时更轻盈) */
.header-glass {
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(10px) saturate(1.4);
-webkit-backdrop-filter: blur(10px) saturate(1.4);
}
/* 卡片悬停微抬 */ /* 卡片悬停微抬 */
.hover-lift { transition: transform .2s cubic-bezier(.16,1,.3,1), box-shadow .2s ease; } .hover-lift { transition: transform .2s cubic-bezier(.16,1,.3,1), box-shadow .2s ease; }
.hover-lift:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,.04); } .hover-lift:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,.04); }
/* 侧边栏激活图标着色(nav-active 由 NavLink isActive 挂载) */
.nav-active > svg { color: var(--brand-text); }
/* 侧边栏激活指示条 */ /* 侧边栏激活指示条 */
.nav-active::before { .nav-active::before {
content: ''; content: '';
+42
View File
@@ -0,0 +1,42 @@
# 安全说明
## 漏洞报告
发现安全问题请**不要**直接开公开 Issue(可能包含可利用细节)。联系渠道:
- 仓库主页留下联系方式的维护者(站内私信 / Issues 留言板)
- 紧急情况可直接在描述安全隐患的前提下开 Issue 标注 `security`,我们会尽快下线和修复
处理流程:收到报告 → 48 小时内确认 → 评估影响并给出缓解策略 → 修复发版 → 在 `CHANGELOG.md` 的 `Security` 段致谢公示。
## 安全模型(威胁模型摘要)
数据全走 HTTPS + Bearer Token,服务器为多用户托管形态,按用户强制隔离——信任边界假设
「攻击者 = 其他注册用户 / 网络中间人 / 触碰 GET 接口的匿名流量」。
| 面 | 现状 |
|---|---|
| 认证算法 | scrypt(随机 16B 盐)+ `timingSafeEqual`,官方 crypto 原语,无自造算法 |
| Token | 服务端 32 字节随机 hex 存储 + 365 天过期;登录后同设备复用 device_id;支持 `/auth/logout` 服务端吊销 |
| 类似爆破的防护 | 登录:同 IP+用户名连续失败 5 次 → 冷却 10 分钟(进程内计数) |
| 按用户数据隔离 | `commandsFor(user_id)` 每用户一份独立 `users/<uid>.db`;跨库读取天然命中"不存在",实测经 /api 与 /sync 均无法跨读 |
| 管理 API | 统一双重校验:登录态 + is_admin;自删与"最后管理员"禁删 |
| SQL 注入 | 全部参数化;`ORDER BY` 走白名单(`SORTS` 表);FTS MATCH 参数传入、字符串仅做双引号包裹 |
| 路径注入/XSS | 上传文件名剥离 `\\/:*?"<>|` + UUID 前缀;附件文件名以 **SHA-256 命名**(无文件系统注入位);前端 `dangerouslySetInnerHTML` 有且仅两处(一处全转义、一处白名单净化 docx/xlsx 预览) |
| 注册准入 | `REGISTER_ENABLED`、`INVITE_CODE`(或管理页动态码)、`MAX_USERS` 三道门控,服务端强制(客户端传的 `seed` 播种标志一律剥离,防止绕过邀请码/配额/自动升级管理员规则) |
| 附件上传 | SHA-256 声明校验 + 100MB 上限;下载统一 `Cache-Control: private, immutable`(跨账号缓存隔离) |
| 客户端依赖 | 服务器**零 npm 依赖**(仅 Node 内置模块),规避供应链攻击面 |
| CORS | `Access-Control-Allow-Origin: *`。凭据仅 localStorage Bearer、不用 Cookie,不放大 CSRF 面;如有同源严格化诉求欢迎提案 |
| 审计日志 | 图床清理、用户删除、demo 重建等敏感操作写标准日志(容器 stdout) |
## 已知设计取舍
- Token 存放浏览器 `localStorage`(XSS 可及):与多端 token 缓存目录一致;服务端可能追加 CSP 响应头、可加入 HttpOnly Cookie 会话机制(改动契约,暂未引入)
- 限速是进程内存态,重启清零;RLS 想要更强的反爆破可以套容器内的 fail2ban / Cloudflare 规则
- 备份文件内含用户档案明文,请按敏感数据管理
## 上线运维要点(契约要求)
- 首个真实账号注册后关闭 `REGISTER_ENABLED`,改掉 `INVITE_CODE` 示例默认值
- `./data/` 目录备份(含 `-wal`/`-shm`)
- 服务端公网面只暴露 443(Caddy TLS 到 sync-server)
+48 -17
View File
@@ -1,7 +1,7 @@
# HTTP API 参考 # HTTP API 参考
Base URL:部署域名(如 `https://footpr.example.com`)。除 `/health` 与 `/auth/register|login` 外, Base URL:部署域名(如 `https://footpr.example.com`)。除 `/health` 与 `/auth/register|login` 外,
所有接口都需要 `Authorization: Bearer <token>`。 所有接口都鉴权。登录限速:同一 IP + 用户名连续失败 5 次会收到 429,冷却 10 分钟。
统一响应格式: 统一响应格式:
@@ -20,29 +20,32 @@ Base URL:部署域名(如 `https://footpr.example.com`)。除 `/health`
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| POST | `/auth/register` | 注册(`REGISTER_ENABLED`/`INVITE_CODE`/`MAX_USERS` 门控;首位用户自动成为管理员) | | POST | `/auth/register` | 注册(`REGISTER_ENABLED`/邀请码/`MAX_USERS` 门控;首个非 demo 账号自动成为管理员)。请求体里的 `seed` 标志一律被服务端剥离,仅供内部播种 |
| POST | `/auth/login` | 登录,返回 Bearer Token(365 天) | | POST | `/auth/login` | 登录,返回 Bearer Token(365 天) |
| POST | `/auth/logout` | 吊销当前 Bearer Token(服务端删除 token 行) |
| GET | `/auth/me` | 当前用户信息(`user_id` / `username` / `is_admin` / `device_id`) | | GET | `/auth/me` | 当前用户信息(`user_id` / `username` / `is_admin` / `device_id`) |
```bash ```bash
curl -s https://footpr.example.com/auth/login \ TOKEN=$(curl -s https://footpr.example.com/auth/login \
-H 'content-type: application/json' \ -H 'content-type: application/json' \
-d '{"username":"demo","password":"demo1234","device_name":"cli"}' -d '{"username":"demo","password":"demo1234","device_name":"cli"}' | jq -r .data.token)
# => {"ok":true,"data":{"token":"...","user_id":"...","device_id":"...","expires_at":"..."}}
``` ```
## 管理(仅 is_admin 会话) ## 管理(仅 is_admin 会话)
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| GET | `/admin/users` | 用户列表(设备数/记录数/附件用量) | | GET | `/admin/users` | 用户列表(设备数/记录数/附件用量),元数据不含档案内容 |
| GET | `/admin/invites` | 邀请码列表(含 remark、创建者、使用者) | | POST | `/admin/users/delete` | 删除用户 `{"user_id":"..."}`,级联清理图床/档案库/附件/会话;不能删自己、不能删最后一位管理员 |
| GET | `/admin/invites` | 邀请码列表(remark、创建者/使用者、valid、holder_gone) |
| POST | `/admin/invites/generate` | 批量生成随机码 `{"count": 5}`(1-50) | | POST | `/admin/invites/generate` | 批量生成随机码 `{"count": 5}`(1-50) |
| POST | `/admin/invites/create` | 新建自定义码 `{"code":"XIONG-2025","remark":"备注"}`(4-32 位字母数字-\_) | | POST | `/admin/invites/create` | 新建自定义码 `{"code":"XIONG-2025","remark":"备注"}`(4-32 位字母数字-_) |
| POST | `/admin/invites/update` | 改备注 / 启停 `{"id":1,"remark":"...","valid":false}`(已使用的码不可再启用) | | POST | `/admin/invites/update` | 改备注 / 启停 `{"id":1,"remark":"...","valid":false}`(已使用的码不可再启用) |
| POST | `/admin/invites/delete` | 删除 `{"id":1}` | | POST | `/admin/invites/delete` | 删除 `{"id":1}` |
| POST | `/admin/invites/revoke` | 作废(等价 update valid=false,兼容保留) | | POST | `/admin/invites/revoke` | 作废(等价 update valid=false,兼容保留) |
| POST | `/admin/demo-seed` | 给 demo 账号播种演示数据(幂等;档案库非空时返回 seeded:false) | | POST | `/admin/demo-rebuild` | 级联删除现有 demo 并重建、重播种演示数据 |
| POST | `/admin/demo-seed` | 给 demo 播种演示数据(幂等;档案库非空时返回 seeded:false) |
| POST | `/admin/attachments/sync-lsky` | 把当前用户全部未上图床的附件批量推送到 Lsky(需配置 `LSKY_API`/`LSKY_TOKEN`) |
## 同步 ## 同步
@@ -51,12 +54,15 @@ curl -s https://footpr.example.com/auth/login \
| GET | `/sync/status` | 当前用户同步账本统计 | | GET | `/sync/status` | 当前用户同步账本统计 |
| POST | `/sync/push` | 推送 wire 变更(LWW 上/删除,立即生效) | | POST | `/sync/push` | 推送 wire 变更(LWW 上/删除,立即生效) |
| GET | `/sync/pull?cursor=...&limit=...` | 按 seq 游标增量拉取 | | GET | `/sync/pull?cursor=...&limit=...` | 按 seq 游标增量拉取 |
| POST | `/sync/attachment` | 上传附件 `{"sha256","size","mime","file_name","data_b64"}`(内容寻址去重) | | POST | `/sync/attachment` | 上传附件 `{"sha256","size","mime","file_name","data_b64"}`(内容寻址去重,哈希校验) |
| GET | `/sync/attachment/<sha256>` | 下载附件 | | GET | `/sync/attachment/<sha256>` | 下载附件(`Cache-Control: private, immutable`) |
附件另有两个业务命令通道:`POST /api/attachment_read`(base64 全文,含缩略图通道 `attachment_thumb`)
与 `POST /api/attachment_download`(直接落盘,`Content-Disposition` 带原始文件名)。
## 业务命令层 `POST /api/<command>` ## 业务命令层 `POST /api/<command>`
与桌面端共用同一命令层(`app/server/db.mjs`),按用户隔离。37 个命令: 与桌面端共用同一命令层(`app/server/db.mjs`),按用户隔离。44 个命令:
| 分组 | 命令 | | 分组 | 命令 |
|---|---| |---|---|
@@ -65,11 +71,39 @@ curl -s https://footpr.example.com/auth/login \
| 字典/维度 | `taxonomy_list` · `taxonomy_upsert` · `taxonomy_delete` · `dimension_list` · `dimension_upsert` · `dimension_delete` | | 字典/维度 | `taxonomy_list` · `taxonomy_upsert` · `taxonomy_delete` · `dimension_list` · `dimension_upsert` · `dimension_delete` |
| 足迹 | `footprint_list` · `footprint_get` · `footprint_upsert` · `footprint_delete` · `timeline` | | 足迹 | `footprint_list` · `footprint_get` · `footprint_upsert` · `footprint_delete` · `timeline` |
| 统计 | `stats_overview` · `metric_list` · `metric_upsert` · `metric_delete` | | 统计 | `stats_overview` · `metric_list` · `metric_upsert` · `metric_delete` |
| 成绩 | `grades_overview` · `semester_upsert` · `semester_delete` · `course_upsert` · `course_delete` · `band_upsert` · `band_delete` | | 成绩 | `grades_overview` · `semester_upsert` · `semester_delete` · `course_upsert` · `course_delete` · `band_upsert` · `band_delete` · `gpa_settings_get` · `gpa_settings_upsert` |
| 附件 | `attachment_add` · `attachment_list` · `attachment_delete` · `attachment_read` | | 附件 | `attachment_add` · `attachment_list` · `attachment_delete` · `attachment_read` · `attachment_thumb` · `attachment_set_remote` · `attachment_set_thumb` |
| 档案 | `profile_get` · `profile_upsert` |
| 保存视图 | `view_list` · `view_upsert` · `view_delete` | | 保存视图 | `view_list` · `view_upsert` · `view_delete` |
| 播种/同步内核 | `demo_seed` · `sync_apply_wire` · `sync_pull_wire` | | 播种/同步内核 | `demo_seed` · `sync_apply_wire` · `sync_pull_wire` |
### `profile_upsert` 语义
顶层 key 合并:`info` / `resume` / `zongce` 是互相独立的子文档,只更所提交的 key,
其余原样保留(避免三张页面互相覆盖):
```json
{ "info": { "name": "...", "phone": "...", "eduStart": "2023-09" } }
```
### `footprint_list` 的 `query`
| 字段 | 说明 |
|---|---|
| `q` | 全文检索(≥3 字符走 FTS5 trigram 子串匹配,短词回退 LIKE) |
| `year` | 起始年份过滤 |
| `types` / `levels` / `roles` / `statuses` | 数组多选过滤 |
| `dims` | 维度多选 |
| `polarity` | `positive` / `negative` |
| `has_attachment` | 仅看有附件记录 |
| `tag` | 标签匹配 |
| `sort` | `date_desc`(默认)/ `date_asc` / `updated_desc` / `weight_desc` |
| `page` /`page_size` | 分页(`page_size` 上限 2000) |
### 分页约定
`page_size` 上限 2000;传非 `{query:{...}}` 形状的载荷会按默认 20 条处理(客户端请按此约定传参)。
示例(检索记录): 示例(检索记录):
```bash ```bash
@@ -77,6 +111,3 @@ curl -s https://footpr.example.com/api/footprint_list \
-H 'content-type: application/json' -H "authorization: Bearer $TOKEN" \ -H 'content-type: application/json' -H "authorization: Bearer $TOKEN" \
-d '{"query":{"page":1,"page_size":20,"year":2025}}' -d '{"query":{"page":1,"page_size":20,"year":2025}}'
``` ```
`footprint_list` 的 `query` 支持:`year`、`type_id`、`level`、`status`、`q`(全文检索)、
`dimension_id`、`sort`(date_desc/date_asc/updated_desc/weight_desc)、分页 `page`/`page_size`。
+30 -19
View File
@@ -1,7 +1,7 @@
# Footprints 自建同步服务 # Footprints 自建同步服务
个人留痕系统的多端同步服务端。零 npm 依赖(Node 内置 node:sqlite),SQLite 单文件存储, 个人留痕系统的多端同步服务端。零 npm 依赖(Node 内置 node:sqlite),SQLite 单文件存储。
专为单用户/小规模多设备设计。协议:行级 LWW(HLC 仲裁)+ seq 游标增量拉取。 协议:行级 LWW(HLC 仲裁)+ seq 游标增量拉取;每个用户一个独立 SQLite 档案库。
## 部署(Docker) ## 部署(Docker)
@@ -24,41 +24,51 @@
docker compose up -d --build docker compose up -d --build
# 默认只绑定 127.0.0.1:8788,交给宿主上的反代;需要直连时改为 "8788:8788" # 默认只绑定 127.0.0.1:8788,交给宿主上的反代;需要直连时改为 "8788:8788"
数据全部落在 `./data/`(SQLite 库 + 附件 Blob),备份 = 复制该目录。 数据全部落在 `./data/`(SQLite 库 + 附件),备份 = 复制该目录(WAL 模式建议连同 `-wal`/`-shm` 一起拷,或先 `wal_checkpoint(TRUNCATE)`)。
- 首次部署 `REGISTER_ENABLED=true`,注册自己的账号后改为 `false` 再 `docker compose up -d` 关闭注册 ### 上线检查清单
- 验证:curl https://footpr.linyux.cn/health 应返回 {"ok":true,...}
- [ ] `INVITE_CODE` 改掉示例默认值 `please-change-me`(或留空改用管理页动态码)
- [ ] 第一个真实账号注册完成后 `REGISTER_ENABLED=false` 再 `docker compose up -d`
- [ ] `MAX_USERS` 按需限额(0 = 不限)
- [ ] `curl https://你的域名/health` 返回 `{"ok":true,...}`
- [ ] `./data/` 已纳入备份计划
## 多用户 ## 多用户
不止一人使用时,服务端天然按账号隔离数据(变更/附件/设备均挂 user_id),无需分库: 服务端天然按账号隔离数据(每用户独立 `users/<uid>.db` 档案库,附件按用户子目录隔离),无需分库:
- 邀请码:`INVITE_CODE` 设置后,注册请求必须携带 `invite_code` 字段——把邀请码发给你信任的人即可 - 邀请码:`INVITE_CODE` 设置后,注册请求必须携带 `invite_code` 字段——把邀请码发给你信任的人即可;动态码由管理员在「管理」页生成(支持备注/启停/删除/批量)
- 名额:`MAX_USERS` 限制用户总数(0 = 不限),防滥用 - 名额:`MAX_USERS` 限制用户总数(0 = 不限),防滥用
- 管理:设置 `ADMIN_TOKEN` 后,`GET /admin/users`(请求头 `X-Admin-Token`)可查看全部用户、设备数、记录数与附件占用 - 管理:首位真实注册账号自动成为管理员(内置 demo 账号不占席位且永不为管理员);管理员登录后「管理」页可查看全部用户(设备数/记录数/附件占用)、删除用户(级联清理其图床文件/档案库/附件/会话)、重建演示账号
- 客户端:各用户安装桌面应用或 PWA,登录自己的账号即得到独立档案;同一台电脑可切换多个账号(本地档案按账号隔离) - 客户端:各用户安装桌面应用或 PWA,登录自己的账号即得到独立档案;同一台电脑可切换多个账号(本地档案按账号隔离)
- 数据安全:账号间完全隔离;管理员也只能看到元数据(用户名/设备数/存储占用),看不到档案内容 - 数据安全:账号间完全隔离;管理员也只能看到元数据(用户名/设备数/存储占用),看不到档案内容
## 安全要点 ## 安全
- 必须经 HTTPS 访问(Caddy 自动证书);裸 HTTP 暴露公网不可接受 - 必须经 HTTPS 访问(Caddy 自动证书);裸 HTTP 暴露公网不可接受
- 数据(SQLite + 附件 Blob)全在 `./data/`,定期备份该目录即可 - 口令 scrypt 散列 + `timingSafeEqual` 校验;Bearer Token 365 天有效期,支持服务端吊销(`POST /auth/logout`)
- 服务端不解释业务字段;表间引用由客户端以 uuid 表达 - 登录限速:同 IP + 同用户名连续失败 5 次冷却 10 分钟
- 用户档案库仅用参数化 SQL;附件以 SHA-256 内容寻址命名(无路径可穿越)、哈希声明校验、100MB 单文件上限
- 详见 [`../docs/SECURITY.md`](../docs/SECURITY.md)
## API ## API
完整参考见 [`../docs/api.md`](../docs/api.md)。同步面速览:
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| GET | /health | 健康检查(Docker healthcheck 使用) | | GET | /health | 健康检查(Docker healthcheck 使用) |
| POST | /auth/register | 注册 {username, password, device_name},返回 {token, device_id} | | POST | /auth/register | 注册 {username, password, invite_code, device_name},返回 {token, device_id} |
| POST | /auth/login | 登录,同上;同名设备复用 device_id | | POST | /auth/login | 登录,同上;同名设备复用 device_id |
| POST | /auth/logout | 吊销当前 Bearer Token |
| GET | /auth/me | 当前用户信息 |
| POST | /sync/push | 推送变更 {changes:[{table,row_uuid,op,hlc,data}]},返回 {accepted, stale, invalid} | | POST | /sync/push | 推送变更 {changes:[{table,row_uuid,op,hlc,data}]},返回 {accepted, stale, invalid} |
| GET | /sync/pull?cursor=N | 增量拉取,返回 {changes, cursor, has_more} | | GET | /sync/pull?cursor=N | 增量拉取,返回 {changes, cursor, has_more} |
| POST | /sync/attachment | 上传附件 {sha256,size,mime,file_name,data_b64},返回 {dedup} | | POST | /api/footprint_list 等 | 业务命令层 `POST /api/<command>`(44 个命令,按用户隔离) |
| GET | /sync/attachment/:sha256 | 下载附件(内容寻址,跨设备去重) | | POST | /api/attachment_download | 附件落盘下载(Content-Disposition 带原始文件名) |
| GET | /sync/status | 账号统计 |
鉴权:`Authorization: Bearer <token>`(/sync/* 全部需要)。 鉴权:`Authorization: Bearer <token>`(除 /health /auth/register /auth/login 外全部需要)。
### 同步语义 ### 同步语义
@@ -69,10 +79,11 @@
## 目录 ## 目录
src/index.mjs HTTP 路由 src/index.mjs HTTP 路由(静态/认证/管理/同步/命令层)
src/db.mjs SQLite schema 与连接 src/db.mjs Service 主库 schema(users/devices/tokens/invites)
src/auth.mjs 注册/登录/token(scrypt) src/auth.mjs 注册/登录/token/登出(scrypt + 限速)
src/sync.mjs push(LWW)/ pull(seq 游标) src/sync.mjs push(LWW)/ pull(seq 游标)
src/app-commands.mjs 桥接共用命令层(app/server/db.mjs)
src/attachments.mjs 附件内容寻址存储 src/attachments.mjs 附件内容寻址存储
## 相关文件 ## 相关文件
+2 -1
View File
@@ -95,6 +95,7 @@ export function authUser(req) {
if (row.expires_at < new Date().toISOString().slice(0, 19).replace('T', ' ')) { if (row.expires_at < new Date().toISOString().slice(0, 19).replace('T', ' ')) {
throw Object.assign(new Error('凭据已过期,请重新登录'), { code: 401 }); throw Object.assign(new Error('凭据已过期,请重新登录'), { code: 401 });
} }
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=?").run(row.device_id); // 节流:仅当 last_seen 距今 >60s 才回写(否则命中索引跳过),消除每请求一次的写放大
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=? AND last_seen_at < datetime('now','-1 minutes')").run(row.device_id);
return { user_id: row.user_id, device_id: row.device_id }; return { user_id: row.user_id, device_id: row.device_id };
} }
+25 -1
View File
@@ -183,6 +183,8 @@ const MIME = {
// 按用户惰性创建命令集(每个用户一个独立 SQLite 档案库) // 按用户惰性创建命令集(每个用户一个独立 SQLite 档案库)
const userCommands = new Map(); const userCommands = new Map();
const loginFails = new Map(); // 登录防爆破计数:key = IP|username,连续失败进入冷却
const gzipCache = new Map(); // 指纹资产 gzip 预压缩缓存 const gzipCache = new Map(); // 指纹资产 gzip 预压缩缓存
let demoUserId; let demoUserId;
function commandsFor(userId) { function commandsFor(userId) {
@@ -237,9 +239,24 @@ const server = http.createServer(async (req, res) => {
const body = await readJson(req); const body = await readJson(req);
if (route === '/auth/register') { if (route === '/auth/register') {
if (!REGISTER_ENABLED) return send(403, false, '注册已关闭'); if (!REGISTER_ENABLED) return send(403, false, '注册已关闭');
// 加固:seed 是服务端内部播种标志(demo-rebuild 内部调用),客户端请求体一律剥离,
// 防止走私该标志绕过邀请码 / MAX_USERS 名额 / 首个用户自动升管理员
delete body.seed;
return send(200, true, register(body)); return send(200, true, register(body));
} }
return send(200, true, login(body)); // 登录限速:同 IP+同用户名连续失败 5 次 → 该组合冷却 10 分钟(内存计数,重启即清)
const lmKey = String(req.socket.remoteAddress || '') + '|' + String(body.username || '').trim().toLowerCase();
const lmF = loginFails.get(lmKey);
if (lmF && lmF.until > Date.now()) return send(429, false, '尝试次数过多,请 10 分钟后再试');
try {
const out = login(body);
loginFails.delete(lmKey);
return send(200, true, out);
} catch (e) {
const n = (loginFails.get(lmKey)?.n || 0) + 1;
loginFails.set(lmKey, { n, until: n >= 5 ? Date.now() + 10 * 60_000 : 0 });
throw e;
}
} }
// ---- 管理(仅 is_admin 登录会话) ---- // ---- 管理(仅 is_admin 登录会话) ----
@@ -391,6 +408,13 @@ const server = http.createServer(async (req, res) => {
try { auth = authUser(req); } try { auth = authUser(req); }
catch (e) { return send(e.code || 401, false, e.message); } catch (e) { return send(e.code || 401, false, e.message); }
if (req.method === 'POST' && route === '/auth/logout') {
// 服务端吊销当前 Bearer token(前端本地清除前调用;跨设备「登出其一」)
const tk = (req.headers['authorization'] || '').slice(7).trim();
if (tk) getDb().prepare('DELETE FROM tokens WHERE token=?').run(tk);
return send(200, true, { ok: true });
}
if (req.method === 'GET' && route === '/auth/me') { if (req.method === 'GET' && route === '/auth/me') {
const row = getDb().prepare('SELECT id, username, is_admin FROM users WHERE id=?').get(auth.user_id); const row = getDb().prepare('SELECT id, username, is_admin FROM users WHERE id=?').get(auth.user_id);
return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', is_admin: !!row?.is_admin, device_id: auth.device_id }); return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', is_admin: !!row?.is_admin, device_id: auth.device_id });
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as g,f as v,j as t,S as j,t as S}from"./index-Dplf5hVV.js";import{C as c,a as d,b as x}from"./card-C932WdWm.js";import{u as N,i as u,a as m}from"./useChart-D3ki8T58.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage}; import{r as g,f as v,j as t,S as j,t as S}from"./index-DH5SEuAI.js";import{C as c,a as d,b as x}from"./card-BI024Nvz.js";import{u as N,i as u,a as m}from"./useChart-ZF2N_RV4.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
@@ -1,4 +1,4 @@
import{c as $,f as O,j as e,E as H,w as U,u as k,d as C,r as u,g as y,x as W,y as J,B as j,P as E,I as g,z as Y,C as A,A as V,D as B,F as X,m as S,l as Q,T as q,H as Z,J as z,K as M,S as T,M as D}from"./index-Dplf5hVV.js";import{C as w,a as G,b as F}from"./card-C932WdWm.js";import{u as ee,i as K,a as P}from"./useChart-D3ki8T58.js";import{u as se,i as te,b as ae,a as re,c as ne,d as le,e as ie,f as oe}from"./echarts-BIWefrIc.js";/** import{c as $,f as O,j as e,E as H,w as U,u as k,d as C,r as u,g as y,x as W,y as J,B as j,P as E,I as g,z as Y,C as A,A as V,D as B,F as X,m as S,l as Q,T as q,H as Z,J as z,K as M,S as T,M as D}from"./index-DH5SEuAI.js";import{C as w,a as G,b as F}from"./card-BI024Nvz.js";import{u as ee,i as K,a as P}from"./useChart-ZF2N_RV4.js";import{u as se,i as te,b as ae,a as re,c as ne,d as le,e as ie,f as oe}from"./echarts-BIWefrIc.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as E,u as P,d as S,r as p,f as q,g as F,j as e,B as f,l as B,ac as L,H as z,J as k,S as M,I as Q,ad as _,ae as K,af as v,ag as T}from"./index-Dplf5hVV.js";/** import{c as E,u as P,d as S,r as p,f as q,g as F,j as e,B as f,l as B,ac as L,H as z,J as k,S as M,I as Q,ad as _,ae as K,af as v,ag as T}from"./index-DH5SEuAI.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-Dplf5hVV.js";/** import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-DH5SEuAI.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as B,d as ve,f as te,r as E,j as e,B as R,ah as Ne,af as L,J as U,S as se,ad as ce,P as q,H as we,a9 as Ce,T as ne,I as W,ag as Se,v as _e}from"./index-Dplf5hVV.js";import{C as ie,a as ae,b as re}from"./card-C932WdWm.js";import{R as le,P as ze,C as Re}from"./rotate-ccw-DFLp9voo.js";/** import{c as B,d as ve,f as te,r as E,j as e,B as R,ah as Ne,af as L,J as U,S as se,ad as ce,P as q,H as we,a9 as Ce,T as ne,I as W,ag as Se,v as _e}from"./index-DH5SEuAI.js";import{C as ie,a as ae,b as re}from"./card-BI024Nvz.js";import{R as le,P as ze,C as Re}from"./rotate-ccw-Cm9sTuWZ.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as D,u as K,d as I,f as L,r,g as q,j as e,B as p,P as F,N as O,m as _,l as H,T as P,C as R,O as G,H as J,J as i,I as j,Q as U,S as z,U as V,V as W,q as X}from"./index-Dplf5hVV.js";import{C as Y}from"./card-C932WdWm.js";/** import{c as D,u as K,d as I,f as L,r,g as q,j as e,B as p,P as F,N as O,m as _,l as H,T as P,C as R,O as G,H as J,J as i,I as j,Q as U,S as z,U as V,V as W,q as X}from"./index-DH5SEuAI.js";import{C as Y}from"./card-BI024Nvz.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-Dplf5hVV.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b}; import{j as s,a}from"./index-DH5SEuAI.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as t}from"./index-Dplf5hVV.js";/** import{c as t}from"./index-DH5SEuAI.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{r}from"./index-Dplf5hVV.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u}; import{r}from"./index-DH5SEuAI.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
+2 -2
View File
@@ -9,8 +9,8 @@
<link rel="icon" href="/icons/pwa-192.png" /> <link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" /> <link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title> <title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-Dplf5hVV.js"></script> <script type="module" crossorigin src="/assets/index-DH5SEuAI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8H8oQlD.css"> <link rel="stylesheet" crossorigin href="/assets/index-TvH70H3i.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>