Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e976fee6 | ||
|
|
8cdf8b44fa | ||
|
|
88525a3f9f | ||
|
|
17ab5f0390 | ||
|
|
3613b9f5e1 | ||
|
|
a668647f35 | ||
|
|
ba448946c9 | ||
|
|
11cfeeb3a6 | ||
|
|
c72474d123 | ||
|
|
d534cae0e1 | ||
|
|
265edc9009 | ||
|
|
0fdba1944e | ||
|
|
b7ad7ef5dc | ||
|
|
48aa5dc07f |
@@ -41,3 +41,7 @@ build-log.txt
|
||||
# 本地工具误生成的运行时与临时文件
|
||||
Python/
|
||||
.tmp-*
|
||||
|
||||
# UI 布局审计产物
|
||||
.ui-shots/
|
||||
.ui-audit-results.json
|
||||
|
||||
@@ -28,3 +28,24 @@
|
||||
### Security
|
||||
- scrypt 口令散列 + timingSafeEqual 校验
|
||||
- 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` 末段误解析导致该学年学业分缺失)
|
||||
- 综测学业绩点与成绩页学期卡口径不一致(现同源优先)
|
||||
- 档案识别学年错误(按入学时间推算,缺省回退最早学期)
|
||||
- 证据画廊「计分/未计分」徽章在聚合计分项下恒错
|
||||
|
||||
@@ -11,27 +11,41 @@
|
||||
## 开发流程
|
||||
|
||||
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. 本地跑绿再推:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks # 每个克隆只需一次
|
||||
node scripts/check.mjs
|
||||
node scripts/test-integration.mjs
|
||||
node scripts/test-integration.mjs # 61 条集成断言
|
||||
```
|
||||
|
||||
启用钩子后,push 会自动跑同一套检查;工作区有未提交变更或测试失败时 push 会被拒绝。
|
||||
|
||||
## 约定
|
||||
|
||||
- 服务端保持**零 npm 依赖**(只用 Node 内置模块);确需引入依赖请先开 Issue 讨论
|
||||
- 改动 `app/server/db.mjs`(命令层)时注意:Web / 桌面 / 播种三处共用它,测试必须全绿
|
||||
- 涉及 schema 变更:保证老库能平滑迁移(参考 `preMigrateColumns` 的加列模式),并在
|
||||
`scripts/test-integration.mjs` 补断言
|
||||
- **服务端保持零 npm 依赖**(只用 Node 内置模块);确需引入依赖请先开 Issue 讨论
|
||||
- 改动 `app/server/db.mjs`(命令层)时注意:Web / 桌面 / 播种三处共用它,测试必须全绿;新增命令顺手在 `docs/api.md` 补一行
|
||||
- 涉及 schema 变更:保证老库能平滑迁移(参考 `schema.sql` IF NOT EXISTS / `preMigrateColumns` 的加列模式),并在 `scripts/test-integration.mjs` 补断言
|
||||
- 涉及 Service Worker:接口类请求(/api /auth /admin /sync)永远不得缓存
|
||||
- UI 改动请同时适配亮/暗两套主题(令牌见 `app/src/styles/theme.css`)
|
||||
- UI 改动请同时适配亮/暗两套主题(令牌见 `app/src/styles/theme.css`),并兼顾移动端(390px 宽)与浏览器打印样式
|
||||
- **安全敏感路径**(认证/上传/管理/导出)请说明改动思路;过滤、白名单优先于黑名单;欢迎补 PoC 级测试(参考 `docs/SECURITY.md` 的字段表)
|
||||
|
||||
## Issue
|
||||
|
||||
- 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,6 +1,6 @@
|
||||
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
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -12,20 +12,22 @@
|
||||
|
||||
- **留痕本体** —— 自定义记录类型 + 动态表单模板;竞赛、志愿、学生工作、荣誉、证书、科研、实习皆可配;正面/负面、进行中、草稿全留痕
|
||||
- **秒级检索** —— FTS5 trigram 中文全文搜索 + 多条件筛选 + 保存视图(同一查询一键复用)
|
||||
- **时间线** —— 按学年/月分组的完整回顾
|
||||
- **学业成绩** —— 学期 GPA/排名、课程成绩、可自定义绩点梯度(grade band)与趋势图
|
||||
- **时间线** —— 按学年/月分组的完整回顾,玻璃质感粘性年份头
|
||||
- **学业成绩** —— 学期 GPA/排名、课程成绩、可自定义绩点梯度(grade band)与趋势图;综测学业分直接取学期卡同源口径
|
||||
- **成长量化** —— 自定义维度与指标(如志愿时长),统计总览与年度对比
|
||||
- **附件佐证** —— 图片/文档上传,SHA-256 内容寻址去重
|
||||
- **简历** —— 可视化编辑模板(基本信息/学业/经历区块增删排序)+ 一键导出规范排版的 Word 文档
|
||||
- **综合测评** —— 五大素质自动计分(活动/竞赛/学生工作/奖惩,逐项证据画廊)、中文 FTS 三角分词检索佐证、可编辑评分细则与手动修正、学年九格信息表、导出带页码页脚与附件矩阵附录的 Word 档案
|
||||
- **附件佐证** —— 图片/文档/PDF 上传,SHA-256 内容寻址去重,客户端缩略图 + 灯箱预览,docx/xlsx 在线预览(已 HTML 加固)
|
||||
- **多用户云优先** —— 所有数据存服务器,按用户独立 SQLite 档案库;浏览器 / PWA / Windows 桌面三端同源同步
|
||||
- **管理后台** —— 邀请码全生命周期管理(自定义码、备注、启用/停用、删除、批量生成)、用户列表、注册配额
|
||||
- **演示模式** —— 内置演示账号 `demo / demo1234`,自动播种 16 条足迹 + 4 学期成绩,开箱即看
|
||||
- **管理后台** —— 邀请码全生命周期管理(自定义码、备注、启用/停用、删除、批量生成)、用户列表、注册配额、账号删除级联清理(图床/档案库/附件/会话)
|
||||
- **演示模式** —— 内置演示账号 `demo / demo1234`,自动播种 25 条足迹 + 4 学期成绩 + 7 份佐证附件,开箱即看
|
||||
- **PWA + 桌面** —— 可安装到桌面/手机;Windows Tauri 安装包;亮/暗双主题
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
浏览器 PWA ─┐
|
||||
Windows 桌面 ─┤ HTTPS + Bearer Token(365 天)
|
||||
Windows 桌面 ─┤ HTTPS + Bearer Token(365 天,可服务端吊销)
|
||||
移动端 PWA ─┘
|
||||
│
|
||||
▼
|
||||
@@ -33,17 +35,19 @@
|
||||
│ sync-server(零依赖 Node ≥ 22.5)│
|
||||
│ node:http + node:sqlite │
|
||||
│ scrypt 认证 · 邀请码 · LWW 同步 │
|
||||
│ 登录限速 · 会话吊销 · 附件加固 │
|
||||
└───────────────┬────────────────┘
|
||||
▼
|
||||
data/users/<uid>.db 每用户独立 SQLite 档案库
|
||||
data/blobs/<uid>/ 内容寻址附件存储
|
||||
data/users/<uid>.db 每用户独立 SQLite 档案库(WAL + 索引齐全)
|
||||
data/attachments/ 按用户隔离的内容寻址附件
|
||||
```
|
||||
|
||||
设计取向:
|
||||
|
||||
- **服务器零 npm 依赖**:仅用 Node 内置模块(`node:http` / `node:sqlite` / `node:crypto`),攻击面与供应链风险极小
|
||||
- **命令层同源**:Web、桌面、数据播种共用同一份 `app/server/db.mjs` 命令层,同一业务逻辑多处不漂移
|
||||
- **命令层同源**:Web、桌面、数据播种共用同一份 `app/server/db.mjs` 命令层(44 个命令),同一业务逻辑多处不漂移
|
||||
- **同步元数据内建**:所有表带 uuid/hlc/origin 列 + oplog,为多端增量同步而生
|
||||
- **性能基线**(3000 条记录实测):列表/时间线 ≤23ms、单条写 ≈0.3ms(WAL + synchronous=NORMAL + 8MB 页缓存)
|
||||
|
||||
## 快速开始(Docker 部署)
|
||||
|
||||
@@ -69,12 +73,13 @@ footprints.example.com {
|
||||
```
|
||||
|
||||
> compose 默认加入名为 `mynet` 的外部网络;没有就先 `docker network create mynet`,或把 compose 里的 networks 段改成你自己的。
|
||||
> 详细部署与拓扑说明见 [`sync-server/README.md`](sync-server/README.md)。
|
||||
|
||||
首次使用:
|
||||
|
||||
1. 打开站点 → 注册 —— **第一个注册的账号自动成为管理员**
|
||||
1. 打开站点 → 注册 —— **第一个注册的账号自动成为管理员**(内置 demo 账号不占管理员席位)
|
||||
2. 管理员登录 → 侧边栏「管理」→ 新建/批量生成邀请码,发给需要注册的用户
|
||||
3. 人齐之后建议收紧注册:compose 里 `REGISTER_ENABLED=false`(或改掉 `INVITE_CODE` 默认值)再 `up -d`
|
||||
3. 人齐之后建议收紧注册:compose 里 `REGISTER_ENABLED=false`,并把 `INVITE_CODE` 从示例默认值 `please-change-me` 换成强随机值或留空(改用管理页动态码)
|
||||
4. 想先看看界面?用演示账号 **demo / demo1234** 登录,数据管够
|
||||
|
||||
## 本地开发
|
||||
@@ -99,29 +104,32 @@ pnpm -C app install && pnpm -C app tauri build
|
||||
|
||||
```bash
|
||||
node scripts/check.mjs # 语法 + 类型 + 构建 + 关键标记 tripwire
|
||||
node scripts/test-integration.mjs # 35 条集成断言(播种/登录/邀请码全矩阵/角色规则/幂等)
|
||||
node scripts/test-integration.mjs # 61 条集成断言(播种/登录/邀请码全矩阵/角色规则/幂等/级联删除)
|
||||
node scripts/smoke.mjs <url> <user> <pass> # 部署后冒烟
|
||||
git config core.hooksPath .githooks # 启用 pre-push 钩子:工作区不干净或测试红,push 直接拒绝
|
||||
```
|
||||
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├─ app/ # 前端 + 桌面端
|
||||
│ ├─ src/ # React 19 + Vite + Tailwind v4 UI(三端共用)
|
||||
│ ├─ public/ # PWA:sw.js v3(接口绝不缓存)/ manifest / icons
|
||||
│ ├─ server/ # ★ 命令层 db.mjs:零依赖数据层(37 个命令)
|
||||
│ │ ├─ pages/ # 时间线/记录/成绩/统计/类型/字典/个人信息/简历/综合测评/管理
|
||||
│ │ ├─ 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 桌面壳
|
||||
├─ sync-server/ # 自建同步服务器
|
||||
│ ├─ src/ # index/auth/db/sync/attachments(零依赖)
|
||||
│ ├─ www/ # 构建产物(部署时以卷挂载,git pull 即更新)
|
||||
│ └─ 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
|
||||
├─ tools/ # pnpm.cjs + 图标生成器
|
||||
├─ .gitea/ # Issue/PR 模板
|
||||
├─ .githooks/ # pre-push 质量门禁(check + 35 条集成断言)
|
||||
├─ .githooks/ # pre-push 质量门禁(check + 61 条集成断言)
|
||||
└─ .editorconfig
|
||||
```
|
||||
|
||||
@@ -131,20 +139,26 @@ git config core.hooksPath .githooks # 启用 pre-push 钩子:工作区不干
|
||||
|---|---|---|
|
||||
| `PORT` | `8788` | 监听端口 |
|
||||
| `DATA_DIR` | `./data` | 数据目录(用户库/附件/同步账本) |
|
||||
| `REGISTER_ENABLED` | `true` | 是否开放注册 |
|
||||
| `INVITE_CODE` | 空 | 静态兜底邀请码(动态码由管理员在管理页生成) |
|
||||
| `REGISTER_ENABLED` | `true` | 是否开放注册(注册完毕后建议关闭) |
|
||||
| `INVITE_CODE` | 空 | 静态兜底邀请码(动态码由管理员在管理页生成;**务必改默认值**) |
|
||||
| `MAX_USERS` | `0` | 用户数上限,0 = 不限 |
|
||||
| `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
|
||||
|
||||
- [ ] 附件缩略图 / 上传前压缩 / 大文件分片
|
||||
- [ ] 评奖评优材料包一键导出
|
||||
- [ ] 大文件分片上传
|
||||
- [x] 附件缩略图 / 上传前压缩(客户端 canvas 生成 480px WebP)
|
||||
- [x] 评奖评优材料包一键导出(简历 / 综测 Word 档案)
|
||||
- [ ] 对象存储(S3)适配
|
||||
- [ ] 端到端加密存储
|
||||
- [ ] 移动端体验优化(手势、离线编辑队列)
|
||||
|
||||
@@ -5,12 +5,13 @@ React 19 前端 + Tauri 2 桌面壳 + 共用 Node 命令层。
|
||||
|
||||
## 结构
|
||||
|
||||
src/ React 前端(时间线 / 记录 / 成绩 / 统计 / 类型 / 字典 / 管理)
|
||||
public/ PWA:sw.js(v3,接口类请求绝不缓存)/ manifest / 图标
|
||||
src/ React 前端(时间线/记录/成绩/统计/类型/字典/个人信息/简历/综合测评/管理)
|
||||
public/ PWA:sw.js(接口类请求绝不缓存)/ manifest / 图标
|
||||
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 含同步元数据)
|
||||
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)。
|
||||
|
||||
文档:[`../docs/DESIGN.md`](../docs/DESIGN.md)(设计底稿)· [`../docs/api.md`](../docs/api.md)(HTTP API)· [`../sync-server/`](../sync-server/)(服务器)。
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadCert, CERT_BY_TITLE } from './demo-certs.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', '.data');
|
||||
@@ -334,6 +335,11 @@ function openDb(dbPath) {
|
||||
fs.mkdirSync(path.join(path.dirname(dbPath), 'attachments'), { recursive: true });
|
||||
const d = new DatabaseSync(dbPath);
|
||||
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);
|
||||
d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
|
||||
// 个人信息表兜底(确保任何挂载形态下都存在)
|
||||
@@ -412,6 +418,27 @@ export const DEMO_ROWS = [
|
||||
['证书技能', '计算机技术与软件专业技术资格(软件设计师)', '参加', '国家级', null, '工业和信息化部', '2025-11-08', null, 'ongoing',
|
||||
'备考软件设计师(中级)', '', ['软考'], ['职业发展'],
|
||||
{}, {}],
|
||||
['竞赛', 'ACM-ICPC 国际大学生程序设计竞赛', '核心', '国家级', '银奖', '中国大学生程序设计竞赛组委会', '2025-10-18', '2025-10-19', 'finished',
|
||||
'赛区内团队排名前 5%,与队友分工负责图论与动态规划、数据结构与搜索方向', '亚洲区域赛银奖', ['算法', '团队协作'], ['学业深造'],
|
||||
{ my_work: '图论建模与状态转移设计', team_members: '王五、赵六' }, {}],
|
||||
['竞赛', '中国大学生计算机设计大赛', '主导', '国家级', '二等奖', '中国大学生计算机设计大赛组织委员会', '2025-05-10', '2025-07-30', 'finished',
|
||||
'作品《基于流量特征的入侵检测系统》,负责整体方案设计与检测算法实现', '全国总决赛二等奖', ['计算机', '安全'], ['学业深造'],
|
||||
{ my_work: '方案设计、检测算法实现', team_members: '张三、李四' }, {}],
|
||||
['竞赛', '第三届"强网杯"全国大学生网络安全挑战赛', '核心', '国家级', '二等奖', '战略支援部队信息工程大学', '2024-11-09', '2024-11-11', 'finished',
|
||||
'CTF 攻防模式,负责 Web 渗透与 RSA 共模攻击类密码题', '半决赛二等奖', ['CTF', '密码', '安全'], ['职业发展'],
|
||||
{ my_work: 'Web 渗透与密码工具编写', team_members: '张三、王五' }, {}],
|
||||
['竞赛', '"挑战杯"全国大学生课外学术科技作品竞赛(省赛)', '核心', '省市级', '三等奖', '共青团重庆市委员会', '2025-03-15', '2025-06-20', 'finished',
|
||||
'作品「基于流量特征的入侵检测研究」,负责研究报告撰写与答辩', '省赛三等奖', ['科研', '学术'], ['学业深造'],
|
||||
{ my_work: '研究报告撰写' }, {}],
|
||||
['竞赛', '全国大学生英语竞赛', '参加', '省市级', '三等奖', '高等学校大学外语教学研究会', '2024-04-14', '2024-04-14', 'finished',
|
||||
'C 类笔试,词汇与阅读优势明显', 'C类省级三等奖', ['英语'], ['学业深造'],
|
||||
{ score: '88' }, {}],
|
||||
['竞赛', '院"新生杯"程序设计竞赛', '参加', '二级学院', '一等奖', '计算机网络学院', '2023-11-18', '2023-11-18', 'finished',
|
||||
'大一入门竞赛,C 语言实现图书管理系统', '院级一等奖', ['编程'], ['学业深造'],
|
||||
{}, {}],
|
||||
['竞赛', '全国大学生密码技术竞赛', '参加', '国家级', null, '中国密码学会', '2025-11-20', null, 'ongoing',
|
||||
'组队备战国赛(SM9 聚群签名方向)', '', ['密码', '学术'], ['学业深造'],
|
||||
{}, {}],
|
||||
['实习实践', '某安全公司渗透测试实习生', '参加', null, null, '某网络安全科技有限公司', '2025-07-01', '2025-08-25', 'finished',
|
||||
'参与企业授权渗透测试项目 3 个,编写测试报告', '获实习优秀评价', ['实习', '安全'], ['职业发展'],
|
||||
{ position: '渗透测试实习生', mentor: '陈工' }, {}]
|
||||
@@ -446,27 +473,27 @@ export const DEMO_PROFILE = {
|
||||
resume: DEMO_RESUME
|
||||
};
|
||||
|
||||
// 演示附件用真实 PNG(1x1,可在预览查看并进入综测证明材料)
|
||||
const DEMO_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
|
||||
|
||||
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点, 类别][] }
|
||||
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点, 类别][] }
|
||||
// 演示成绩与成绩梯度公式一致:分段线性 gp(90→4.0 至 100→5.0,80→3.0 至 89→3.9 …),
|
||||
// 学期 GPA 为分类加权(必修 1.2 / 专业选修 1.1),与 grades_overview 的综合算法结果吻合。
|
||||
export const DEMO_SEMESTERS = [
|
||||
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
|
||||
['高等数学(上)', 5, 78, 3.0, '必修课'], ['大学英语(一)', 3, 85, 3.7, '必修课'], ['程序设计基础', 4, 91, 4.0, '必修课'],
|
||||
['思想道德与法治', 2, 88, 3.9, '必修课'], ['体育(一)', 1, 82, 3.5, '必修课']
|
||||
{ term: '2023-2024-1', gpa: 4.13, rank: 48, total: 156, courses: [
|
||||
['高等数学(上)', 5, 78, 2.8, '必修课'], ['大学英语(一)', 3, 85, 3.5, '必修课'], ['程序设计基础', 4, 91, 4.1, '必修课'],
|
||||
['思想道德与法治', 2, 88, 3.8, '必修课'], ['体育(一)', 1, 82, 3.2, '必修课']
|
||||
]},
|
||||
{ term: '2023-2024-2', gpa: 3.61, rank: 29, total: 156, courses: [
|
||||
['高等数学(下)', 5, 84, 3.7, '必修课'], ['线性代数', 3, 76, 2.9, '必修课'], ['数据结构', 4, 90, 4.0, '必修课'],
|
||||
['大学英语(二)', 3, 87, 3.8, '必修课'], ['中国近现代史纲要', 2, 84, 3.5, '必修课'], ['体育(二)', 1, 86, 3.8, '必修课']
|
||||
{ term: '2023-2024-2', gpa: 4.15, rank: 36, total: 156, courses: [
|
||||
['高等数学(下)', 5, 84, 3.4, '必修课'], ['线性代数', 3, 76, 2.6, '必修课'], ['数据结构', 4, 90, 4.0, '必修课'],
|
||||
['大学英语(二)', 3, 87, 3.7, '必修课'], ['中国近现代史纲要', 2, 84, 3.4, '必修课'], ['体育(二)', 1, 86, 3.6, '必修课']
|
||||
]},
|
||||
{ term: '2024-2025-1', gpa: 3.78, rank: 18, total: 156, courses: [
|
||||
['概率论与数理统计', 3, 88, 3.9, '必修课'], ['操作系统', 4, 92, 4.0, '必修课'], ['计算机网络', 4, 86, 3.8, '必修课'],
|
||||
['数据库原理', 3, 94, 4.0, '必修课'], ['马克思主义基本原理', 2, 81, 3.3, '必修课'], ['体育(三)', 1, 88, 3.9, '必修课']
|
||||
{ term: '2024-2025-1', gpa: 4.63, rank: 11, total: 156, courses: [
|
||||
['概率论与数理统计', 3, 88, 3.8, '必修课'], ['操作系统', 4, 92, 4.18, '必修课'], ['计算机网络', 4, 86, 3.6, '必修课'],
|
||||
['数据库原理', 3, 94, 4.36, '必修课'], ['马克思主义基本原理', 2, 81, 3.1, '必修课'], ['体育(三)', 1, 88, 3.8, '必修课']
|
||||
]},
|
||||
{ term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [
|
||||
['软件工程', 3, 90, 4.0, '必修课'], ['信息安全导论', 3, 95, 4.0, '必修课'], ['密码学基础', 3, 89, 3.9, '必修课'],
|
||||
['Web 应用开发', 2, 96, 4.0, '专业选修课'], ['数字逻辑', 3, 79, 3.1, '必修课'], ['体育(四)', 1, 90, 4.0, '必修课']
|
||||
{ term: '2024-2025-2', gpa: 4.65, rank: 7, total: 156, courses: [
|
||||
['软件工程', 3, 90, 4.0, '必修课'], ['信息安全导论', 3, 95, 4.45, '必修课'], ['密码学基础', 3, 89, 3.9, '必修课'],
|
||||
['Web 应用开发', 2, 96, 4.55, '专业选修课'], ['数字逻辑', 3, 79, 2.9, '必修课'], ['体育(四)', 1, 90, 4.0, '必修课']
|
||||
]}
|
||||
];
|
||||
|
||||
@@ -1193,7 +1220,14 @@ function listFootprints(d, query) {
|
||||
case 'profile_upsert': {
|
||||
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();
|
||||
withTx(d, () => {
|
||||
const h = hlcNext(d);
|
||||
@@ -1261,11 +1295,16 @@ function listFootprints(d, query) {
|
||||
dimension_ids: (dimNames || []).map((nm) => dimIds[nm]).filter(Boolean),
|
||||
}});
|
||||
n++;
|
||||
// 演示附件:竞赛/荣誉记录附带证书图片(可预览、可进综测证明材料)
|
||||
if (/竞赛|优秀共青团员/.test(String(title)) && status === 'finished') {
|
||||
// 演示附件:按标题匹配对应证书图片(可预览、可进综测证明材料)
|
||||
const certKey = CERT_BY_TITLE.find((p2) => p2[0].test(String(title)))?.[1];
|
||||
if (certKey && status === 'finished') {
|
||||
const cert = loadCert(certKey);
|
||||
if (cert) {
|
||||
try {
|
||||
handleCommand('attachment_add', { owner_id: fr.item.id, file_name: '获奖证书.png', mime: 'image/png', size: DEMO_PNG.length, data_b64: DEMO_PNG, thumb_b64: DEMO_PNG });
|
||||
} catch {}
|
||||
const att = handleCommand('attachment_add', { owner_id: fr.id, ...cert });
|
||||
console.log('[demo-seed] 证书附件已挂到「' + title + '」:', att.item?.id || att.item?.uuid || '?');
|
||||
} catch (e) { console.log('[demo-seed] 证书附件失败 ' + title + ':', e.message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
let sems = 0;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// 演示证书图片索引:demo 播种时按记录标题匹配,附加对应证书 WebP 附件。
|
||||
// 图片为 Pillow 离线绘制的获奖证书(720x508 WebP + 480px 缩略图),仅作演示数据。
|
||||
// 如需替换:直接覆盖 demo-certs/*.webp 即可,无需改代码。
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DIR = path.join(__dirname, 'demo-certs');
|
||||
|
||||
/**
|
||||
* 主名(如 cert-ctf)→ 证书附件负载(attachment_add 所需格式);文件缺失返回 undefined
|
||||
*/
|
||||
export function loadCert(assetName) {
|
||||
try {
|
||||
const data = fs.readFileSync(path.join(DIR, assetName + '.webp'));
|
||||
let thumbB64 = '';
|
||||
try { thumbB64 = fs.readFileSync(path.join(DIR, assetName + '.thumb.webp')).toString('base64'); } catch {}
|
||||
return {
|
||||
file_name: '获奖证书.webp', mime: 'image/webp',
|
||||
data_b64: data.toString('base64'), thumb_b64: thumbB64, size: data.length,
|
||||
};
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
/** 记录标题 → 证书资源名(demo 播种时逐条匹配) */
|
||||
export const CERT_BY_TITLE = [
|
||||
[/信息安全竞赛/, 'cert-ctf'],
|
||||
[/数学建模/, 'cert-math'],
|
||||
[/蓝桥杯/, 'cert-lanqiao'],
|
||||
[/计算机设计大赛/, 'cert-cd'],
|
||||
[/英语竞赛/, 'cert-eng'],
|
||||
[/ACM-ICPC/, 'cert-acm'],
|
||||
[/优秀共青团员/, 'cert-wusi'],
|
||||
];
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -1,54 +1,12 @@
|
||||
// 演示数据:仅当足迹表为空时写入,用于界面验收
|
||||
import { getDb, buildSearchText, migrateSync, uuidv7, hlcNext, DEMO_ROWS, DEMO_SEMESTERS } from './db.mjs';
|
||||
// 演示数据播种(本地开发兼容入口)
|
||||
// 与云端 demo 账号共用同一份 demo_seed 命令层逻辑(app/server/db.mjs),
|
||||
// 演示内容统一维护于 DEMO_ROWS / DEMO_SEMESTERS,竞赛/荣誉记录附带证书演示附件。
|
||||
import { handleCommand, getDb, migrateSync } from './db.mjs';
|
||||
|
||||
const db = getDb();
|
||||
const count = db.prepare('SELECT COUNT(*) AS c FROM footprint').get().c;
|
||||
if (count > 0) {
|
||||
console.log('已存在 ' + count + ' 条记录,跳过演示数据(如需重建请删除 app/.data 目录)');
|
||||
const r = handleCommand('demo_seed', {});
|
||||
if (!r.seeded) {
|
||||
console.log('已存在 ' + r.footprints + ' 条记录 / ' + r.semesters + ' 个学期,跳过演示数据(如需重建请删除 app/.data 目录)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const types = Object.fromEntries(
|
||||
db.prepare('SELECT id, name FROM footprint_type').all().map(r => [r.name, r.id])
|
||||
);
|
||||
const dims = Object.fromEntries(
|
||||
db.prepare('SELECT id, name FROM dimension').all().map(r => [r.name, r.id])
|
||||
);
|
||||
|
||||
// [type, title, role, level, grade, organizer, start, end, status, desc, result, tags, dims, extras, metrics]
|
||||
// 数据统一维护在 ./db.mjs(DEMO_ROWS / DEMO_SEMESTERS)
|
||||
|
||||
const ins = db.prepare(`INSERT INTO footprint (type_id, title, role, level, award_grade, organizer,
|
||||
start_date, end_date, status, description, result, tags, search_text, extras, metrics)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
|
||||
const setDim = db.prepare('INSERT OR IGNORE INTO footprint_dimension (footprint_id, dimension_id) VALUES (?,?)');
|
||||
|
||||
for (const [t, title, role, level, grade, organizer, start, end, status, desc, result, tags, dimNames, extras, metrics] of DEMO_ROWS) {
|
||||
const search = buildSearchText(title, desc, result, tags, extras);
|
||||
const r = ins.run(types[t], title, role, level, grade, organizer, start, end, status, desc, result,
|
||||
JSON.stringify(tags), search, JSON.stringify(extras), JSON.stringify(metrics));
|
||||
const id = Number(r.lastInsertRowid);
|
||||
for (const name of dimNames) {
|
||||
if (dims[name]) setDim.run(id, dims[name]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('已写入 ' + DEMO_ROWS.length + ' 条演示记录');
|
||||
|
||||
// ---------- 学年学科成绩演示 ----------
|
||||
const semIns = db.prepare('INSERT INTO semester (term, gpa, rank, total_students, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?)');
|
||||
const couIns = db.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)');
|
||||
|
||||
|
||||
|
||||
for (const s of DEMO_SEMESTERS) {
|
||||
const h1 = hlcNext(db);
|
||||
const sid = Number(semIns.run(s.term, s.gpa, s.rank, s.total, uuidv7(), h1, 'seed').lastInsertRowid);
|
||||
for (const [name, credit, score, gp] of s.courses) {
|
||||
couIns.run(sid, name, credit, score, gp, uuidv7(), hlcNext(db), 'seed');
|
||||
}
|
||||
}
|
||||
console.log('已写入 ' + DEMO_SEMESTERS.length + ' 个学期、' + DEMO_SEMESTERS.reduce((a, s) => a + s.courses.length, 0) + ' 门课程成绩');
|
||||
|
||||
migrateSync(getDb());
|
||||
console.log('同步元数据(uuid/hlc/origin)已回填');
|
||||
console.log('演示数据已写入:足迹 ' + r.footprints + ' 条、学期成绩 ' + r.semesters + ' 个(竞赛/荣誉记录附带证书演示附件)');
|
||||
|
||||
@@ -7,6 +7,30 @@ import type { Attachment } from '@/lib/types';
|
||||
|
||||
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 {
|
||||
const mime = (a.mime || '').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 html = (await mammoth.convertToHtml({ arrayBuffer: bytes.buffer })).value as string;
|
||||
if (cancelled) return;
|
||||
setContent({ kind, html });
|
||||
setContent({ kind, html: sanitizePreviewHtml(html) });
|
||||
} else if (kind === 'xlsx') {
|
||||
const XLSX = await import('xlsx');
|
||||
const wb = XLSX.read(bytes, { type: 'array' });
|
||||
const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]);
|
||||
if (cancelled) return;
|
||||
setContent({ kind, html });
|
||||
setContent({ kind, html: sanitizePreviewHtml(html) });
|
||||
} else {
|
||||
// 本地回退的图片/PDF/音视频
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: mime || 'application/octet-stream' }));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
const TITLES: Record<string, { title: string; sub?: string }> = {
|
||||
@@ -9,7 +10,11 @@ const TITLES: Record<string, { title: string; sub?: string }> = {
|
||||
'/grades': { title: '成绩', sub: '学年学科成绩 · 梯度与总览' },
|
||||
'/dashboard': { 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 }) {
|
||||
@@ -20,6 +25,20 @@ export function Header({ onMenu }: { onMenu?: () => void }) {
|
||||
|
||||
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 next = !dark;
|
||||
setDark(next);
|
||||
@@ -29,14 +48,21 @@ export function Header({ onMenu }: { onMenu?: () => void }) {
|
||||
};
|
||||
|
||||
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="打开导航">
|
||||
<Menu size={17} />
|
||||
</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">
|
||||
<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 className="relative ml-auto hidden w-[240px] md:block lg:w-[280px]">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
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 { serverMe, clearToken, getToken } from '@/lib/api';
|
||||
import { serverLogout, serverMe, clearToken, getToken } from '@/lib/api';
|
||||
import { LoginDialog } from '@/components/auth/LoginDialog';
|
||||
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">
|
||||
{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 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()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -123,7 +123,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
|
||||
<button
|
||||
className="shrink-0 rounded p-1 text-ink-3 transition-colors hover:bg-surface-3 hover:text-danger"
|
||||
title="退出登录"
|
||||
onClick={() => { clearToken(); qc.clear(); window.location.reload(); }}
|
||||
onClick={() => { void serverLogout(); clearToken(); qc.clear(); window.location.reload(); }}
|
||||
>
|
||||
<LogOut size={13} />
|
||||
</button>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors select-none disabled:pointer-events-none disabled:opacity-45',
|
||||
'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: {
|
||||
variant: {
|
||||
primary: 'bg-brand text-white hover:bg-brand-hover shadow-card',
|
||||
secondary: 'bg-surface text-ink-1 border border-line hover:bg-surface-3 hover:border-line-strong',
|
||||
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 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',
|
||||
subtle: 'bg-brand-subtle text-brand-text hover:brightness-[0.97]',
|
||||
danger: 'bg-danger text-white hover:brightness-110',
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Dialog({ open, onClose, title, width = 560, children, footer }:
|
||||
role="dialog"
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between border-b border-line px-4">
|
||||
<div className="text-sm font-semibold text-ink-1">{title}</div>
|
||||
<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="关闭">
|
||||
<X size={15} />
|
||||
</Button>
|
||||
|
||||
@@ -8,9 +8,12 @@ export function Empty({ icon, title, description, action }: {
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<div className="text-ink-3">{icon ?? <Inbox size={36} strokeWidth={1.5} />}</div>
|
||||
<div className="text-[13px] font-medium text-ink-2">{title}</div>
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<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="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>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const base = 'w-full rounded-md border border-line bg-surface px-2.5 text-[13px] text-ink-1 placeholder:text-ink-3 transition-colors hover:border-line-strong focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
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(
|
||||
{ className, ...props }, ref
|
||||
|
||||
@@ -12,7 +12,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full h-8 appearance-none rounded-md border border-line bg-surface pl-2.5 pr-7 text-[13px] text-ink-1',
|
||||
'transition-colors hover:border-line-strong focus:border-brand focus:outline-none disabled:opacity-50',
|
||||
'transition-colors hover:border-line-strong focus:border-brand focus:ring-[1.5px] focus:ring-brand/15 focus:outline-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -55,6 +55,16 @@ const SERVER_KEY = 'fp-server-url';
|
||||
export function getServerUrl() { return localStorage.getItem(SERVER_KEY) || 'https://footpr.linyux.cn'; }
|
||||
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 {
|
||||
constructor(msg: string) { super(msg); this.name = 'AuthError'; }
|
||||
}
|
||||
@@ -195,6 +205,8 @@ async function adminGet<T>(path: string, baseUrl?: string): Promise<T> {
|
||||
export interface InviteRow {
|
||||
id: number; code: string; remark: string | null; created_at: string;
|
||||
used_by: string | null; used_at: string | null; valid: boolean;
|
||||
/** 已使用邀请码对应的账号已被注销(状态固定展示为「已销户」) */
|
||||
holder_gone?: boolean;
|
||||
used_by_name?: string | null; created_by_name?: string | null;
|
||||
}
|
||||
export interface AdminUserRow {
|
||||
|
||||
@@ -38,6 +38,12 @@ export interface ZcOverrides {
|
||||
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)
|
||||
export function yearRange(year: string): [string, string] | null {
|
||||
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'];
|
||||
}
|
||||
|
||||
// 学年 → 年级名(按个人信息入学年份推算:大一~大四)
|
||||
// 学年 → 年级名(按个人信息入学年份推算:大一~大四);无入学年份时退回学年名
|
||||
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 ?? '');
|
||||
if (!m || !e) return year + ' 学年';
|
||||
if (!m || !e) return y + ' 学年';
|
||||
const n = Number(m[1]) - Number(e[1]) + 1;
|
||||
const names = ['大一', '大二', '大三', '大四', '大五'];
|
||||
return (names[n - 1] ?? n + '年级') + '(' + year + ' 学年)';
|
||||
const names = ['大一', '大二', '大三', '大四', '大五', '大六', '大七'];
|
||||
if (n < 1) return y + ' 学年';
|
||||
return (names[n - 1] ?? n + '年级') + '(' + y + ' 学年)';
|
||||
}
|
||||
|
||||
function inRange(d: string | null | undefined, range: [string, string] | null): boolean {
|
||||
@@ -75,9 +83,9 @@ function normLevel(level?: string | null): keyof typeof ZC_DEFAULT_RULES.table2
|
||||
function normGrade(grade?: string | null): keyof typeof ZC_DEFAULT_RULES.table2['国家级'] | null {
|
||||
const v = (grade ?? '').trim();
|
||||
if (!v) return null;
|
||||
if (/特等|一等|冠军|第1名|第一名/.test(v)) return '一等奖';
|
||||
if (/二等|亚军|第2名|第3名|第二名|第三名/.test(v)) return '二等奖';
|
||||
if (/三等|第[4-6]名|第四名|第五名|第六名/.test(v)) return '三等奖';
|
||||
if (/特等|一等|金(色)?奖|冠军|第1名|第一名/.test(v)) return '一等奖';
|
||||
if (/二等|银(色)?奖|亚军|第2名|第3名|第二名|第三名/.test(v)) return '二等奖';
|
||||
if (/三等|铜(色)?奖|第[4-6]名|第四名|第五名|第六名/.test(v)) return '三等奖';
|
||||
if (/优秀|优胜|第[7-9]名|第七名/.test(v)) return '优秀奖';
|
||||
return null;
|
||||
}
|
||||
@@ -90,6 +98,8 @@ export interface ZcItem {
|
||||
overridden?: boolean;
|
||||
note?: string;
|
||||
fromRecord?: number;
|
||||
/** 聚合条款(ccap/kcap/…)对应的记录 id 列表,用于标注哪些记录参与计分 */
|
||||
recordIds?: number[];
|
||||
}
|
||||
export interface ZcSection { key: string; label: string; cap: number; items: ZcItem[]; subtotal: number }
|
||||
export interface ZcResult { sections: ZcSection[]; total: number; gpa: number | null }
|
||||
@@ -141,14 +151,8 @@ export function computeZongce(opts: {
|
||||
]));
|
||||
|
||||
// ---- 学业成绩(60):学年平均学分绩点 × 10 ----
|
||||
const scopedCourses: CourseGrade[] = [];
|
||||
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;
|
||||
// 口径与「成绩」页统一:优先取学期卡展示的 semester.gpa;未存 gpa 的学期回退到
|
||||
// 与「自动绩点」按钮相同的推算公式(档内线性折算 × 分类权重),保证两处数字一致。
|
||||
const gpOf = (c: CourseGrade): number | null => {
|
||||
if (c.grade_point != null) return c.grade_point;
|
||||
if (bands && c.score != null) {
|
||||
@@ -165,14 +169,25 @@ export function computeZongce(opts: {
|
||||
return null;
|
||||
};
|
||||
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);
|
||||
if (gpCourses.length) {
|
||||
const credits = gpCourses.reduce((a, c) => a + c.credit, 0);
|
||||
gpa = round2(gpCourses.reduce((a, c) => a + (c.grade_point as number) * wOf(c) * c.credit, 0) / credits);
|
||||
const yearKey = termYearOf(year);
|
||||
const scopedSemesters = semesters.filter(x => !yearKey || termYearOf(x.term) === yearKey);
|
||||
const scopedCourses: CourseGrade[] = [];
|
||||
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, [
|
||||
{ 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) ----
|
||||
@@ -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));
|
||||
}
|
||||
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) ----
|
||||
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));
|
||||
sections.push(sec('expand', '拓展性素质', 17, [
|
||||
{ itemKey: 'ccap', title: '竞赛获奖(上限 10 分)', source: compItems.length + ' 条记录', score: compCap, note: compItems.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(';') || '无匹配记录' },
|
||||
{ itemKey: 'gcap', title: '公益活动(上限 3 分)', source: pubItems.length + ' 条记录', score: pubCap, note: pubItems.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(';') || '无匹配记录', 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(';') || '无匹配记录', recordIds: pubItems.map(x => x.fromRecord).filter(Boolean) as number[] },
|
||||
]));
|
||||
|
||||
// ---- 奖惩分(±5) ----
|
||||
@@ -255,7 +270,7 @@ export function computeZongce(opts: {
|
||||
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));
|
||||
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 ?? []) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { ProfileInfo } from './types';
|
||||
import type { ZcSection } from './zongce';
|
||||
|
||||
const esc = (t: unknown) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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>';
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './styles/theme.css';
|
||||
|
||||
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: true, staleTime: 0, gcTime: 0 }
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
import { Badge, Button, Card, CardBody, CardHeader, Dialog, Empty, Input, useToast } from '@/components/ui';
|
||||
import { fmtDateTime, fmtSize } from '@/lib/format';
|
||||
|
||||
type Filter = 'all' | 'valid' | 'used' | 'disabled';
|
||||
type Filter = 'all' | 'valid' | 'used' | 'gone' | 'disabled';
|
||||
|
||||
function InviteStatus({ i }: { i: InviteRow }) {
|
||||
if (i.used_by && i.holder_gone) return <Badge color="var(--danger)">已销户</Badge>;
|
||||
if (i.used_by) return <Badge color="var(--text-3)">已使用</Badge>;
|
||||
return i.valid ? <Badge color="var(--success)">可用</Badge> : <Badge color="var(--warning)">已停用</Badge>;
|
||||
}
|
||||
@@ -86,10 +87,10 @@ export function AdminPage() {
|
||||
});
|
||||
|
||||
const invites = useMemo(() => invitesQ.data?.invites ?? [], [invitesQ.data]);
|
||||
const users = usersQ.data?.users ?? [];
|
||||
const filtered = useMemo(() => invites.filter((i) => {
|
||||
if (filter === 'valid') return i.valid && !i.used_by;
|
||||
if (filter === 'used') return !!i.used_by;
|
||||
if (filter === 'used') return !!i.used_by && !i.holder_gone;
|
||||
if (filter === 'gone') return !!i.used_by && !!i.holder_gone;
|
||||
if (filter === 'disabled') return !i.valid && !i.used_by;
|
||||
return true;
|
||||
}), [invites, filter]);
|
||||
@@ -158,7 +159,7 @@ export function AdminPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
|
||||
<Card>
|
||||
<CardHeader title={'邀请码(' + (invitesQ.data?.total ?? 0) + ')'} extra={
|
||||
<div className="flex gap-1.5">
|
||||
@@ -193,7 +194,7 @@ export function AdminPage() {
|
||||
)}
|
||||
|
||||
<div className="flex gap-1 border-b border-line px-4 py-2 text-[12px]">
|
||||
{([['all', '全部'], ['valid', '可用'], ['used', '已使用'], ['disabled', '已停用']] as [Filter, string][]).map(([k, label]) => (
|
||||
{([['all', '全部'], ['valid', '可用'], ['used', '已使用'], ['gone', '已销户'], ['disabled', '已停用']] as [Filter, string][]).map(([k, label]) => (
|
||||
<button key={k}
|
||||
className={'rounded-md px-2.5 py-1 transition-colors ' + (filter === k ? 'bg-brand-subtle font-medium text-brand-text' : 'text-ink-3 hover:bg-surface-3')}
|
||||
onClick={() => setFilter(k)}>{label}</button>
|
||||
@@ -275,26 +276,6 @@ export function AdminPage() {
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title={'用户(' + (usersQ.data?.total ?? 0) + ')'} />
|
||||
<CardBody className="p-0">
|
||||
{users.length ? (
|
||||
<div className="divide-y divide-line max-h-[520px] overflow-y-auto">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-[12.5px]">
|
||||
<span className="font-medium text-ink-1">{u.username}</span>
|
||||
{u.is_admin && <Badge color="var(--brand-text)">管理员</Badge>}
|
||||
<span className="num ml-auto text-ink-3">{u.devices} 设备 · {u.records} 记录 · {fmtSize(u.attachment_bytes)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty icon={<Users2 size={32} strokeWidth={1.5} />} title="暂无用户" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<Dialog
|
||||
open={!!delUser}
|
||||
onClose={() => setDelUser(null)}
|
||||
@@ -339,7 +320,7 @@ export function AdminPage() {
|
||||
</>}>
|
||||
<div className="text-[12.5px] leading-relaxed text-ink-2">
|
||||
将<b>删除现有 demo 账号及其全部数据</b>,然后重新创建并播种全套虚拟演示数据
|
||||
(个人信息档案、16 条足迹、4 学期成绩、简历内容)。
|
||||
(个人信息档案、25 条足迹(含 10 项竞赛获奖与证书附件)、4 学期学科成绩、简历内容)。
|
||||
<div className="mt-2 text-ink-3">demo / demo1234 · 仅影响演示账号,其他用户不受影响。</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,65 +1,206 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Download, FileText, Images, Printer, RotateCcw } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download, Printer, RotateCcw } from 'lucide-react';
|
||||
import { attachmentList, attachmentThumb, footprintList, gradesOverview, profileGet, profileUpsert } from '@/lib/api';
|
||||
import type { FootprintRow, ProfileInfo, ZcOverrides } from '@/lib/types';
|
||||
import { computeZongce, gradeLabel, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce';
|
||||
import { Button, Dialog, Select, useToast } from '@/components/ui';
|
||||
import { computeZongce, gradeLabel, termYearOf, yearRange, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce';
|
||||
import { Badge, Button, Dialog, Select, useToast } from '@/components/ui';
|
||||
|
||||
interface Evidence { recordId: number; title: string; images: string[] }
|
||||
interface Evidence { recordId: number; title: string; meta: string; images: string[] }
|
||||
|
||||
function esc(t: unknown) { return String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
// MHTML:把图片内嵌进 Word(.doc)的可靠方式
|
||||
// ---------------- Word 导出(MHTML 单文件 .doc,图片内嵌) ----------------
|
||||
function buildMhtml(html: string, images: { name: string; dataUri: string }[]): Blob {
|
||||
const boundary = '----=_fp_zongce';
|
||||
const parts: string[] = [];
|
||||
parts.push('--' + boundary + '\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n' + html + '\r\n');
|
||||
parts.push(
|
||||
'MIME-Version: 1.0\r\n' +
|
||||
'Content-Type: multipart/related; type="text/html"; boundary="' + boundary + '"\r\n\r\n' +
|
||||
'--' + boundary + '\r\n' +
|
||||
'Content-Location: document.html\r\n' +
|
||||
'Content-Type: text/html; charset="utf-8"\r\n' +
|
||||
'Content-Transfer-Encoding: 8bit\r\n\r\n' + html + '\r\n',
|
||||
);
|
||||
for (const img of images) {
|
||||
const idx = img.dataUri.indexOf(',');
|
||||
const meta = img.dataUri.slice(0, idx);
|
||||
const b64 = img.dataUri.slice(idx + 1);
|
||||
const mime = /data:([^;]+)/.exec(meta)?.[1] ?? 'image/png';
|
||||
parts.push('--' + boundary + '\r\nContent-Type: ' + mime + '\r\nContent-Transfer-Encoding: base64\r\nContent-Location: ' + img.name + '\r\n\r\n' + b64 + '\r\n');
|
||||
parts.push(
|
||||
'--' + boundary + '\r\n' +
|
||||
'Content-Location: ' + img.name + '\r\n' +
|
||||
'Content-Type: ' + mime + '\r\n' +
|
||||
'Content-Transfer-Encoding: base64\r\n\r\n' + b64 + '\r\n',
|
||||
);
|
||||
}
|
||||
parts.push('--' + boundary + '--\r\n');
|
||||
return new Blob([parts.join('')], { type: 'multipart/related; type="text/html"; boundary="' + boundary + '"' });
|
||||
}
|
||||
|
||||
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[]): Blob {
|
||||
const body = result.sections.map(sec => {
|
||||
const W_TD = 'border:0.75pt solid #3a3a3a;padding:5pt 8pt;font-size:10.5pt;vertical-align:middle';
|
||||
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]-->';
|
||||
|
||||
/** 评分细则参考(附在 Word 末尾,与页面「评分细则参考」同一数据源) */
|
||||
function rulesAnnexHtml(r: ZcRules): string {
|
||||
const basics =
|
||||
'<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
|
||||
'<tr><td style="' + W_TH + ';width:30%">基础项</td><td style="' + W_TH + '">分值 / 规则</td></tr>' +
|
||||
'<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>' +
|
||||
'<tr><td style="' + W_TD + '">体育加分</td><td style="' + W_TD + '">体育课成绩之和 × ' + esc(r.sportsFactor) + '(上限 5 分)</td></tr>' +
|
||||
'<tr><td style="' + W_TD + '">公益服务</td><td style="' + W_TD + '">' + esc(Object.entries(r.publicGood).map(([k, v]) => k + ' ' + v).join(';')) + '</td></tr>' +
|
||||
'</table>';
|
||||
const t2 = (r.table2 as unknown as Record<string, Record<string, number>>);
|
||||
const t4 = (r.table4 as unknown as Record<string, Record<string, number>>);
|
||||
const rw = (r.rewards as unknown as Record<string, Record<string, number>>);
|
||||
const levels = Object.keys(t2);
|
||||
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 (
|
||||
'<div style="font-family:SimHei;font-size:12pt;margin:14pt 0 4pt">附 2:评分细则参考(默认值,可自行调整)</div>' +
|
||||
basics + matrix + work + others
|
||||
);
|
||||
}
|
||||
|
||||
/** 综合素质测评表: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);
|
||||
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">' + esc(sec.label) + '(上限 ' + esc(sec.cap) + ' 分,小计 ' + esc(subtotal) + ' 分)</h2>' +
|
||||
'<table style="width:100%;border-collapse:collapse;font-size:11pt">' + rows + '</table>';
|
||||
}).join('');
|
||||
const images: { name: string; dataUri: string }[] = [];
|
||||
const annex = evidence.map(ev => {
|
||||
let imgs = '';
|
||||
ev.images.forEach((uri, i) => {
|
||||
const name = 'ev' + ev.recordId + '_' + i;
|
||||
images.push({ name, dataUri: uri });
|
||||
imgs += '<div style="font-size:10pt;color:#555">图 ' + (i + 1) + '</div><img src="' + name + '" style="width:300pt">';
|
||||
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>',
|
||||
);
|
||||
});
|
||||
return '<div style="margin-bottom:10pt"><b style="font-size:11pt">' + esc(ev.title) + '</b>' + imgs + '</div>';
|
||||
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 }[] = [];
|
||||
let figNo = 0;
|
||||
const grids = evidence.map(ev => {
|
||||
const imgs = ev.images.map(uri => {
|
||||
figNo++;
|
||||
const name = 'ev' + ev.recordId + '_' + (images.length);
|
||||
images.push({ name, dataUri: uri });
|
||||
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:#333;font-family:"宋体",SimSun">图 ' + figNo + ' ' + f(ev.title) + '</span></div>';
|
||||
}).join('');
|
||||
const html = '<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>' +
|
||||
'<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>' +
|
||||
'<div style="text-align:center;color:#555;font-size:11pt">' + esc(profile.name ?? '') + ' · ' + esc(profile.className ?? '') + ' · 统计周期:' + esc(termLabel) + '</div>' +
|
||||
'<p style="text-align:center;font-size:13pt;background:#f2f2f2;padding:6pt"><b>综合素质总分:' + esc(result.total) + ' 分</b></p>' +
|
||||
body + (annex ? '<h2 style="font-size:13pt;margin:16pt 0 6pt">附:证明材料</h2>' + annex : '') +
|
||||
'<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>';
|
||||
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:#555">' + f(ev.meta) + '</span></td>' +
|
||||
'<td style="' + W_TD + '">' + imgs + '</td></tr>';
|
||||
}).join('');
|
||||
const annex = grids
|
||||
? '<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>'
|
||||
: '';
|
||||
|
||||
// 信息栏 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 =
|
||||
'<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:Zoom>100</w:Zoom><w:TrackChanges>0</w:TrackChanges></w:WordDocument></xml><![endif]-->' +
|
||||
'<style>' +
|
||||
'@page WordSection1 { size:595.3pt 841.9pt; margin:72pt 78pt 66pt; mso-footer: f1; }' +
|
||||
'div.WordSection1 { page:WordSection1; }' +
|
||||
'body { font-family:"宋体",SimSun,serif; font-size:10.5pt; color:#1a1a1a; line-height:1.5; }' +
|
||||
'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 0 2pt; }' +
|
||||
'table { border-collapse:collapse; }' +
|
||||
'.num { font-family:"Times New Roman",SimSun; }' +
|
||||
'</style></head>' +
|
||||
'<body><div class="WordSection1">' +
|
||||
schoolLine +
|
||||
'<h1 class="title">学生综合素质测评表</h1>' +
|
||||
'<p class="sub">测评学年:' + f(termLabel) + '</p>' +
|
||||
info +
|
||||
specTable +
|
||||
annex +
|
||||
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>日期:______</td></tr></table>' +
|
||||
'<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>';
|
||||
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() {
|
||||
const toast = useToast();
|
||||
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
||||
@@ -68,20 +209,29 @@ export function AssessmentPage() {
|
||||
|
||||
const stored = profileQ.data?.content;
|
||||
const profile: Partial<ProfileInfo> = { ...(stored?.info ?? {}) };
|
||||
const rules: ZcRules = stored?.zongce?.rules ?? ZC_DEFAULT_RULES;
|
||||
const [year, setYear] = useState('');
|
||||
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
|
||||
const [evidence, setEvidence] = useState<Evidence[]>([]);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stored) return;
|
||||
setOvMap(stored.zongce?.overrides ?? {});
|
||||
const years = Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.slice(0, 7))));
|
||||
if (!year && years.length) setYear(years[0]);
|
||||
}, [stored, gradesQ.data]);
|
||||
if (stored.zongce?.rules) setRules(stored.zongce.rules);
|
||||
}, [stored]);
|
||||
|
||||
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 result = useMemo(
|
||||
() => computeZongce({
|
||||
@@ -90,90 +240,253 @@ export function AssessmentPage() {
|
||||
bands: gradesQ.data?.bands, weights: gradesQ.data?.weights,
|
||||
profile, rules, ov,
|
||||
}),
|
||||
[records, gradesQ.data, profile, rules, ov, year]
|
||||
[records, gradesQ.data, profile, rules, ov, year],
|
||||
);
|
||||
|
||||
// 证明材料:匹配条目对应记录的附件缩略图
|
||||
const matchedIds = useMemo(() => {
|
||||
const ids: number[] = [];
|
||||
result.sections.forEach(sec => sec.items.forEach(it => { if (it.fromRecord) ids.push(it.fromRecord); }));
|
||||
return ids;
|
||||
// 学年 → 大几:优先用个人信息「入学时间」,缺省时以最早学期推算
|
||||
const eduStartEff = profile.eduStart || (termYears.length ? termYears[0] + '-09' : '');
|
||||
const termLabel = year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff);
|
||||
|
||||
// 本学年范围内全部记录(证据画廊不再只看计分记录——有附件就展示)
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const out: Evidence[] = [];
|
||||
for (const rid of matchedIds) {
|
||||
for (const rid of scopeIds) {
|
||||
const rec = records.find(r => r.id === rid);
|
||||
try {
|
||||
const attList = await attachmentList(rid);
|
||||
const images: string[] = [];
|
||||
for (const att of attList.items.filter(x => x.id).slice(0, 3)) {
|
||||
for (const att of attList.items.filter(x => x.id && (x.mime ?? '').startsWith('image/'))) {
|
||||
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 (images.length) out.push({ recordId: rid, title: rec?.title ?? '记录 ' + rid, images });
|
||||
if (images.length) {
|
||||
const meta = [rec?.type_name, rec?.level, rec?.award_grade].filter(Boolean).join(' · ');
|
||||
out.push({ recordId: rid, title: rec?.title ?? '记录 ' + rid, meta, images });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (alive) setEvidence(out);
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [matchedIds.join(',')]);
|
||||
}, [scopeIds.join(',')]);
|
||||
|
||||
// 全部放平的图序列(灯箱上一张/下一张用)
|
||||
const flatImages = useMemo(() => {
|
||||
const arr: { src: string; title: string }[] = [];
|
||||
for (const ev of evidence) for (const src of ev.images) arr.push({ src, title: ev.title });
|
||||
return arr;
|
||||
}, [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) => {
|
||||
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 blob = zongceWordMhtml(termLabel, result, profile, evidence);
|
||||
const blob = zongceWordMhtml(termLabel, result, profile, evidence, rules);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = '综合素质测评_' + termLabel + '.doc'; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
toast('success', '综测材料已导出' + (evidence.length ? '(含 ' + evidence.length + ' 条证明材料图片)' : ''));
|
||||
toast('success', '综测表已导出(.doc,附证明材料图 ' + flatImages.length + ' 张 + 细则参考)');
|
||||
};
|
||||
|
||||
const zoomIdx = zoom ? flatImages.findIndex(x => x.src === zoom.src) : -1;
|
||||
const gotoZoom = (delta: number) => {
|
||||
const next = zoomIdx + delta;
|
||||
if (next >= 0 && next < flatImages.length) {
|
||||
setZoom({ src: flatImages[next].src, title: flatImages[next].title, index: next });
|
||||
}
|
||||
};
|
||||
|
||||
const totalImgs = flatImages.length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-[15px] font-semibold text-ink-1">综合测评(按学年自动计算)</h2>
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-ink-1">综合测评</h2>
|
||||
<p className="mt-0.5 text-[11.5px] text-ink-3">按学年依据评分细则自动计算,可手动修正分值,支持导出 Word 测评表</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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: '全部学年' }]} />
|
||||
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出材料</Button>
|
||||
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={() => setInitOpen(true)}><RotateCcw size={13} /> 初始化</Button>
|
||||
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white p-5 shadow-card sm:p-8">
|
||||
<h1 className="text-center text-[18px] font-bold text-neutral-900">学生综合素质测评</h1>
|
||||
<div className="mt-1 text-center text-[12px] text-neutral-500">{profile.name ?? ''} · {profile.className ?? ''} · 统计周期:{termLabel}</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="mt-5 rounded-md bg-neutral-100 px-4 py-2 text-center text-[15px] font-bold text-neutral-900">
|
||||
综合素质总分:<span className="num">{result.total}</span> 分
|
||||
<div className="print-sheet mx-auto w-full max-w-[820px] rounded-xl border border-line bg-white shadow-card sm:p-8">
|
||||
{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>
|
||||
<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">
|
||||
{[
|
||||
['学 号', profile.studentId], ['姓 名', profile.name], ['性 别', profile.gender],
|
||||
['学 院', profile.college], ['专 业', profile.major], ['班 级', profile.className],
|
||||
['政治面貌', profile.political], ['统计周期', termLabel], ['电话号码', profile.phone],
|
||||
].map(([label, val]) => (
|
||||
<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="min-w-0 flex-1 truncate px-2 py-1.5 text-neutral-900">{val || '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center rounded-lg bg-gradient-to-r from-neutral-900 to-neutral-700 px-4 py-2.5 text-white">
|
||||
<span className="text-[12px] opacity-80">综合素质测评总分</span>
|
||||
<span className="num ml-3 text-[26px] font-bold leading-none">{result.total}</span>
|
||||
<span className="ml-1.5 text-[12px] opacity-80">/ 100 分</span>
|
||||
</div>
|
||||
|
||||
{result.sections.map(sec => (
|
||||
<div key={sec.key} className="mt-5">
|
||||
<div className="flex items-baseline justify-between border-b-2 border-neutral-800 pb-1">
|
||||
<span className="text-[13.5px] font-bold text-neutral-900">{sec.label}(上限 {sec.cap} 分)</span>
|
||||
<span className="num text-[12.5px] text-neutral-600">
|
||||
小计 {sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap)} 分
|
||||
<div key={sec.key} className="mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-4 w-1 rounded-full bg-brand"></span>
|
||||
<span className="text-[13.5px] font-bold text-neutral-900">{sec.label}</span>
|
||||
<Badge className="no-print" color="var(--text-3)">上限 {sec.cap} 分</Badge>
|
||||
</div>
|
||||
<span className="num rounded-md bg-neutral-100 px-2 py-0.5 text-[12px] font-semibold text-neutral-700">
|
||||
小计 {sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-neutral-200 bg-neutral-50/60 p-3">
|
||||
{sec.items.map(it => (
|
||||
<div key={it.itemKey} className="flex items-center gap-2 text-[12.5px]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-neutral-900">{it.title}</span>
|
||||
<span className="ml-2 text-[11px] text-neutral-400">{it.source}</span>
|
||||
{it.note && <div className="text-[11px] text-neutral-400">{it.note}</div>}
|
||||
{it.note && <div className="text-[11px] leading-relaxed text-neutral-400">{it.note}</div>}
|
||||
</span>
|
||||
<input type="number" step="0.1" className="num w-[72px] rounded-sm border border-neutral-200 px-1 py-0.5 text-right text-[12px]"
|
||||
<input type="number" step="0.1" className="num w-[72px] rounded-md border border-neutral-200 bg-white px-1.5 py-0.5 text-right text-[12px] focus:border-brand focus:outline-none"
|
||||
value={it.score} onChange={e => {
|
||||
if (it.itemKey === 'conduct' || it.itemKey === 'integrity') {
|
||||
persistOv({ ...ov, [it.itemKey]: Number(e.target.value) });
|
||||
@@ -189,27 +502,64 @@ export function AssessmentPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{evidence.length > 0 && (
|
||||
<div className="mt-6 border-t border-neutral-300 pt-4">
|
||||
<div className="mb-3 text-[13.5px] font-bold text-neutral-900">附:证明材料</div>
|
||||
{/* 附:证明材料——本学年范围内每条有附件的活动 / 竞赛记录都展示 */}
|
||||
{(evidence.length > 0 || totalImgs > 0) && (
|
||||
<div className="mt-7">
|
||||
<div className="flex items-center justify-between border-t border-neutral-200 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Images 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">共 {evidence.length} 项记录 · {totalImgs} 张图片(点击可放大)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{evidence.map(ev => (
|
||||
<div key={ev.recordId} className="mb-4">
|
||||
<div className="text-[12px] font-semibold text-neutral-800">{ev.title}</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-3">
|
||||
<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 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>
|
||||
<div className="shrink-0 text-[10.5px] text-neutral-400">{ev.meta}</div>
|
||||
</div>
|
||||
<div className={'mt-2 gap-2 ' + (ev.images.length > 1 ? 'grid grid-cols-2' : 'flex')}>
|
||||
{ev.images.map((src, i) => (
|
||||
<img key={i} src={src} alt={'证明 ' + (i + 1)} className="max-h-[180px] rounded border border-neutral-200" />
|
||||
<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">
|
||||
<img src={src} alt={'证明 ' + (i + 1)} className="aspect-[4/3] w-full object-cover transition-transform group-hover:scale-[1.03]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
|
||||
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值;
|
||||
「恢复自动」清除本学年全部手动修正。导出材料前请核对每条加分记录与证明材料一致。
|
||||
计分依据:《学生综合素质测评评分细则》(默认值可在上方「评分细则参考」查看与修改)。
|
||||
自动匹配条目均可手动修正分值;「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片灯箱 */}
|
||||
<Dialog
|
||||
open={!!zoom}
|
||||
onClose={() => setZoom(null)}
|
||||
width={920}
|
||||
title={zoom ? zoom.title + '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : ''}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button variant="secondary" disabled={zoomIdx <= 0} onClick={() => gotoZoom(-1)}><ChevronLeft size={13} /> 上一张</Button>
|
||||
<span className="num text-[12px] text-ink-3">{zoom ? (zoomIdx + 1) + ' / ' + totalImgs : ''}</span>
|
||||
<Button variant="primary" disabled={zoomIdx >= totalImgs - 1} onClick={() => gotoZoom(1)}>下一张 <ChevronRight size={13} /></Button>
|
||||
</div>
|
||||
}>
|
||||
{zoom && <img src={zoom.src} alt={zoom.title} className="mx-auto max-h-[74vh] rounded-md" />}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={initOpen}
|
||||
onClose={() => setInitOpen(false)}
|
||||
@@ -221,13 +571,15 @@ export function AssessmentPage() {
|
||||
const next = { ...ovMap };
|
||||
delete next[year];
|
||||
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>
|
||||
</>}>
|
||||
<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>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
--border-strong: #363b47;
|
||||
--text-1: #e9ebf0;
|
||||
--text-2: #a0a8b8;
|
||||
--text-3: #68707e;
|
||||
--text-3: #747c8a;
|
||||
--brand: #6d8bfa;
|
||||
--brand-hover: #8aa2fb;
|
||||
--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'] { -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: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 {
|
||||
content: '';
|
||||
|
||||
@@ -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)
|
||||
@@ -1,7 +1,7 @@
|
||||
# HTTP API 参考
|
||||
|
||||
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/logout` | 吊销当前 Bearer Token(服务端删除 token 行) |
|
||||
| GET | `/auth/me` | 当前用户信息(`user_id` / `username` / `is_admin` / `device_id`) |
|
||||
|
||||
```bash
|
||||
curl -s https://footpr.example.com/auth/login \
|
||||
TOKEN=$(curl -s https://footpr.example.com/auth/login \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"demo","password":"demo1234","device_name":"cli"}'
|
||||
# => {"ok":true,"data":{"token":"...","user_id":"...","device_id":"...","expires_at":"..."}}
|
||||
-d '{"username":"demo","password":"demo1234","device_name":"cli"}' | jq -r .data.token)
|
||||
```
|
||||
|
||||
## 管理(仅 is_admin 会话)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/admin/users` | 用户列表(设备数/记录数/附件用量) |
|
||||
| GET | `/admin/invites` | 邀请码列表(含 remark、创建者、使用者) |
|
||||
| GET | `/admin/users` | 用户列表(设备数/记录数/附件用量),元数据不含档案内容 |
|
||||
| POST | `/admin/users/delete` | 删除用户 `{"user_id":"..."}`,级联清理图床/档案库/附件/会话;不能删自己、不能删最后一位管理员 |
|
||||
| GET | `/admin/invites` | 邀请码列表(remark、创建者/使用者、valid、holder_gone) |
|
||||
| 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/delete` | 删除 `{"id":1}` |
|
||||
| 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` | 当前用户同步账本统计 |
|
||||
| POST | `/sync/push` | 推送 wire 变更(LWW 上/删除,立即生效) |
|
||||
| GET | `/sync/pull?cursor=...&limit=...` | 按 seq 游标增量拉取 |
|
||||
| POST | `/sync/attachment` | 上传附件 `{"sha256","size","mime","file_name","data_b64"}`(内容寻址去重) |
|
||||
| GET | `/sync/attachment/<sha256>` | 下载附件 |
|
||||
| POST | `/sync/attachment` | 上传附件 `{"sha256","size","mime","file_name","data_b64"}`(内容寻址去重,哈希校验) |
|
||||
| GET | `/sync/attachment/<sha256>` | 下载附件(`Cache-Control: private, immutable`) |
|
||||
|
||||
附件另有两个业务命令通道:`POST /api/attachment_read`(base64 全文,含缩略图通道 `attachment_thumb`)
|
||||
与 `POST /api/attachment_download`(直接落盘,`Content-Disposition` 带原始文件名)。
|
||||
|
||||
## 业务命令层 `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` |
|
||||
| 足迹 | `footprint_list` · `footprint_get` · `footprint_upsert` · `footprint_delete` · `timeline` |
|
||||
| 统计 | `stats_overview` · `metric_list` · `metric_upsert` · `metric_delete` |
|
||||
| 成绩 | `grades_overview` · `semester_upsert` · `semester_delete` · `course_upsert` · `course_delete` · `band_upsert` · `band_delete` |
|
||||
| 附件 | `attachment_add` · `attachment_list` · `attachment_delete` · `attachment_read` |
|
||||
| 成绩 | `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_thumb` · `attachment_set_remote` · `attachment_set_thumb` |
|
||||
| 档案 | `profile_get` · `profile_upsert` |
|
||||
| 保存视图 | `view_list` · `view_upsert` · `view_delete` |
|
||||
| 播种/同步内核 | `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
|
||||
@@ -77,6 +111,3 @@ curl -s https://footpr.example.com/api/footprint_list \
|
||||
-H 'content-type: application/json' -H "authorization: Bearer $TOKEN" \
|
||||
-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`。
|
||||
|
||||
@@ -57,7 +57,7 @@ const rootMe = await api(A, 'GET', '/auth/me', undefined, T);
|
||||
ok(rootMe.body?.data?.username === 'root' && rootMe.body?.data?.is_admin === true, 'root 是管理员', rootMe.body);
|
||||
|
||||
const fp = await api(A, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, D);
|
||||
ok(fp.body?.data?.total === 18, '演示足迹 18 条(播种成功)', fp.body?.data?.total);
|
||||
ok(fp.body?.data?.total === 25, '演示足迹 25 条(播种成功,含 10 项竞赛+证书附件)', fp.body?.data?.total);
|
||||
const grades = await api(A, 'POST', '/api/grades_overview', {}, D);
|
||||
ok(grades.body?.data?.semesters?.length === 4, '演示学期 4 个', grades.body?.data?.semesters?.length);
|
||||
const tl = await api(A, 'POST', '/api/timeline', { query: {} }, D);
|
||||
@@ -167,7 +167,7 @@ ok(reseed.body?.data?.seeded === false, '重复播种幂等跳过', reseed.body)
|
||||
|
||||
// demo 重建路由(管理员):重置 demo 数据为初始播种态
|
||||
const re2 = await api(A, 'POST', '/admin/demo-rebuild', {}, T);
|
||||
ok(re2.status === 200 && re2.body?.data?.seeded?.footprints === 18, 'demo 重建路由可达', re2.body);
|
||||
ok(re2.status === 200 && re2.body?.data?.seeded?.footprints === 25, 'demo 重建路由可达', re2.body);
|
||||
|
||||
// ---------- Phase B:同一数据目录「重启」(新模块实例 + 新端口) ----------
|
||||
console.log('\n== Phase B:重启后(持久化 + 不重复播种) ==');
|
||||
@@ -180,7 +180,7 @@ const rootLogin2 = await api(B, 'POST', '/auth/login', { username: 'root', passw
|
||||
ok(rootLogin2.status === 200, '重启后 root 可登录', rootLogin2.body);
|
||||
const T2 = rootLogin2.body.data.token;
|
||||
const fp2 = await api(B, 'POST', '/api/footprint_list', { query: { page: 1, page_size: 50 } }, D2);
|
||||
ok(fp2.body?.data?.total === 18, '重启后仍是 18 条(测试足迹已被级联删除)', fp2.body?.data?.total);
|
||||
ok(fp2.body?.data?.total === 25, '重启后仍是 25 条(测试足迹已被级联删除)', fp2.body?.data?.total);
|
||||
const list2 = (await api(B, 'GET', '/admin/invites', undefined, T2)).body.data.invites;
|
||||
ok(list2.some((i) => i.code === 'XIONG-FRIEND-2025'), '邀请码跨重启持久化');
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Footprints 自建同步服务
|
||||
|
||||
个人留痕系统的多端同步服务端。零 npm 依赖(Node 内置 node:sqlite),SQLite 单文件存储,
|
||||
专为单用户/小规模多设备设计。协议:行级 LWW(HLC 仲裁)+ seq 游标增量拉取。
|
||||
个人留痕系统的多端同步服务端。零 npm 依赖(Node 内置 node:sqlite),SQLite 单文件存储。
|
||||
协议:行级 LWW(HLC 仲裁)+ seq 游标增量拉取;每个用户一个独立 SQLite 档案库。
|
||||
|
||||
## 部署(Docker)
|
||||
|
||||
@@ -24,41 +24,51 @@
|
||||
docker compose up -d --build
|
||||
# 默认只绑定 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 = 不限),防滥用
|
||||
- 管理:设置 `ADMIN_TOKEN` 后,`GET /admin/users`(请求头 `X-Admin-Token`)可查看全部用户、设备数、记录数与附件占用
|
||||
- 管理:首位真实注册账号自动成为管理员(内置 demo 账号不占席位且永不为管理员);管理员登录后「管理」页可查看全部用户(设备数/记录数/附件占用)、删除用户(级联清理其图床文件/档案库/附件/会话)、重建演示账号
|
||||
- 客户端:各用户安装桌面应用或 PWA,登录自己的账号即得到独立档案;同一台电脑可切换多个账号(本地档案按账号隔离)
|
||||
- 数据安全:账号间完全隔离;管理员也只能看到元数据(用户名/设备数/存储占用),看不到档案内容
|
||||
|
||||
## 安全要点
|
||||
## 安全
|
||||
|
||||
- 必须经 HTTPS 访问(Caddy 自动证书);裸 HTTP 暴露公网不可接受
|
||||
- 数据(SQLite + 附件 Blob)全在 `./data/`,定期备份该目录即可
|
||||
- 服务端不解释业务字段;表间引用由客户端以 uuid 表达
|
||||
- 口令 scrypt 散列 + `timingSafeEqual` 校验;Bearer Token 365 天有效期,支持服务端吊销(`POST /auth/logout`)
|
||||
- 登录限速:同 IP + 同用户名连续失败 5 次冷却 10 分钟
|
||||
- 用户档案库仅用参数化 SQL;附件以 SHA-256 内容寻址命名(无路径可穿越)、哈希声明校验、100MB 单文件上限
|
||||
- 详见 [`../docs/SECURITY.md`](../docs/SECURITY.md)
|
||||
|
||||
## API
|
||||
|
||||
完整参考见 [`../docs/api.md`](../docs/api.md)。同步面速览:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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/logout | 吊销当前 Bearer Token |
|
||||
| GET | /auth/me | 当前用户信息 |
|
||||
| POST | /sync/push | 推送变更 {changes:[{table,row_uuid,op,hlc,data}]},返回 {accepted, stale, invalid} |
|
||||
| GET | /sync/pull?cursor=N | 增量拉取,返回 {changes, cursor, has_more} |
|
||||
| POST | /sync/attachment | 上传附件 {sha256,size,mime,file_name,data_b64},返回 {dedup} |
|
||||
| GET | /sync/attachment/:sha256 | 下载附件(内容寻址,跨设备去重) |
|
||||
| GET | /sync/status | 账号统计 |
|
||||
| POST | /api/footprint_list 等 | 业务命令层 `POST /api/<command>`(44 个命令,按用户隔离) |
|
||||
| POST | /api/attachment_download | 附件落盘下载(Content-Disposition 带原始文件名) |
|
||||
|
||||
鉴权:`Authorization: Bearer <token>`(/sync/* 全部需要)。
|
||||
鉴权:`Authorization: Bearer <token>`(除 /health /auth/register /auth/login 外全部需要)。
|
||||
|
||||
### 同步语义
|
||||
|
||||
@@ -69,10 +79,11 @@
|
||||
|
||||
## 目录
|
||||
|
||||
src/index.mjs HTTP 路由
|
||||
src/db.mjs SQLite schema 与连接
|
||||
src/auth.mjs 注册/登录/token(scrypt)
|
||||
src/index.mjs HTTP 路由(静态/认证/管理/同步/命令层)
|
||||
src/db.mjs Service 主库 schema(users/devices/tokens/invites)
|
||||
src/auth.mjs 注册/登录/token/登出(scrypt + 限速)
|
||||
src/sync.mjs push(LWW)/ pull(seq 游标)
|
||||
src/app-commands.mjs 桥接共用命令层(app/server/db.mjs)
|
||||
src/attachments.mjs 附件内容寻址存储
|
||||
|
||||
## 相关文件
|
||||
|
||||
@@ -95,6 +95,7 @@ export function authUser(req) {
|
||||
if (row.expires_at < new Date().toISOString().slice(0, 19).replace('T', ' ')) {
|
||||
throw Object.assign(new Error('凭据已过期,请重新登录'), { code: 401 });
|
||||
}
|
||||
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=?").run(row.device_id);
|
||||
// 节流:仅当 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 };
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ CREATE TABLE IF NOT EXISTS attachments (
|
||||
);`);
|
||||
const hasRemark = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('invite_codes') WHERE name='remark'").get().c;
|
||||
if (Number(hasRemark) === 0) db.exec("ALTER TABLE invite_codes ADD COLUMN remark TEXT;");
|
||||
const hasHolderGone = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('invite_codes') WHERE name='holder_gone'").get().c;
|
||||
if (Number(hasHolderGone) === 0) db.exec("ALTER TABLE invite_codes ADD COLUMN holder_gone INTEGER NOT NULL DEFAULT 0;");
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,52 @@ async function lskyUpload(buf, fileName, mime) {
|
||||
return { url, thumb_url: sanitizeImageUrl(j?.data?.thumbnail_url) || undefined, key: j.data.key };
|
||||
}
|
||||
|
||||
function remoteAttsOf(uid) {
|
||||
try { return commandsFor(uid).getDb().prepare('SELECT remote_key, remote_thumb_key, remote_url, remote_thumb FROM attachment').all(); }
|
||||
catch { return []; }
|
||||
}
|
||||
// blob 目录与 attachments.mjs/db.mjs 同源(DATA_DIR env 或 cwd/data);删除账号时整目录清空
|
||||
function userBlobDir(uid) {
|
||||
return path.join(process.env.DATA_DIR || path.join(process.cwd(), 'data'), 'blobs', String(uid));
|
||||
}
|
||||
// 递归删除目录并统计实际移除的文件个数
|
||||
function rmDirCounted(dir) {
|
||||
let n = 0;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
|
||||
for (const e of entries) {
|
||||
const fp = path.join(dir, e.name);
|
||||
if (e.isDirectory()) { n += rmDirCounted(fp); try { fs.rmdirSync(fp); } catch {} }
|
||||
else { try { fs.unlinkSync(fp); n++; } catch {} }
|
||||
}
|
||||
try { fs.rmdirSync(dir); } catch {}
|
||||
return n;
|
||||
}
|
||||
// 用户档案库附件清单(rel_path 相对 USERS_DIR;删除账号时物理清文件)
|
||||
function localAttachmentFiles(uid) {
|
||||
const out = new Set();
|
||||
try {
|
||||
for (const a of commandsFor(uid).getDb().prepare('SELECT rel_path, thumb_rel_path FROM attachment').all()) {
|
||||
for (const rel of [a.rel_path, a.thumb_rel_path]) if (rel) out.add(String(rel));
|
||||
}
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
function rmLocalAttachmentFiles(uid) {
|
||||
let n = 0;
|
||||
for (const rel of localAttachmentFiles(uid)) {
|
||||
try { fs.rmSync(path.join(USERS_DIR, rel), { force: true }); n++; } catch {}
|
||||
try { fs.rmdirSync(path.dirname(path.join(USERS_DIR, rel))); } catch {}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
async function deleteUserCascade(db, target) {
|
||||
// 图床远端:附件 remote keys + 用户资产(头像等)
|
||||
let remote = 0;
|
||||
let localFiles = 0;
|
||||
if (LSKY_API && LSKY_TOKEN) {
|
||||
try {
|
||||
const atts = commandsFor(target.id).getDb().prepare('SELECT remote_key, remote_thumb_key FROM attachment WHERE deleted_at IS NULL').all();
|
||||
const atts = remoteAttsOf(target.id);
|
||||
for (const a of atts) {
|
||||
for (const k of [a.remote_key, a.remote_thumb_key]) {
|
||||
if (k) { try { await lskyDelete(k); remote++; } catch {} }
|
||||
@@ -46,22 +86,30 @@ async function deleteUserCascade(db, target) {
|
||||
} catch {}
|
||||
}
|
||||
try { db.prepare('DELETE FROM user_assets WHERE user_id=?').run(target.id); } catch {}
|
||||
// 本地落盘数据:档案库 blob 目录、档案库附件文件、sqlite 三件套 —— 全部清空不占空间
|
||||
try { localFiles += rmLocalAttachmentFiles(target.id); } catch {}
|
||||
try { commandsFor(target.id).close(); } catch {}
|
||||
userCommands.delete(target.id);
|
||||
if (demoUserId === target.id) demoUserId = undefined;
|
||||
for (const f of [target.id + '.db', target.id + '.db-wal', target.id + '.db-shm']) {
|
||||
try { fs.rmSync(path.join(USERS_DIR, f), { force: true }); } catch {}
|
||||
}
|
||||
try { fs.rmSync(path.join(USERS_DIR, String(target.id)), { recursive: true, force: true }); } catch {}
|
||||
try { localFiles += rmDirCounted(path.join(USERS_DIR, String(target.id))); } catch {}
|
||||
try { localFiles += rmDirCounted(userBlobDir(target.id)); } catch {}
|
||||
console.log('[footprints-sync] 用户 ' + target.id + ' 数据已销毁: 图床' + remote + ' 个副本, 本地 ' + localFiles + ' 个文件');
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.prepare('DELETE FROM tokens WHERE user_id=?').run(target.id);
|
||||
db.prepare('DELETE FROM current WHERE user_id=?').run(target.id);
|
||||
db.prepare('DELETE FROM attachments WHERE user_id=?').run(target.id);
|
||||
db.prepare('DELETE FROM changes WHERE user_id=?').run(target.id);
|
||||
db.prepare('DELETE FROM devices WHERE user_id=?').run(target.id);
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(target.id);
|
||||
// 其 used 邀请码标记为「已销户」
|
||||
db.prepare('UPDATE invite_codes SET holder_gone=1 WHERE used_by=?').run(target.id);
|
||||
db.exec('COMMIT');
|
||||
} catch (e) { db.exec('ROLLBACK'); throw e; }
|
||||
try { db.exec('VACUUM'); } catch {} // 回收空间
|
||||
return remote;
|
||||
}
|
||||
async function lskyDelete(key) {
|
||||
@@ -135,6 +183,8 @@ const MIME = {
|
||||
|
||||
// 按用户惰性创建命令集(每个用户一个独立 SQLite 档案库)
|
||||
const userCommands = new Map();
|
||||
const loginFails = new Map(); // 登录防爆破计数:key = IP|username,连续失败进入冷却
|
||||
|
||||
const gzipCache = new Map(); // 指纹资产 gzip 预压缩缓存
|
||||
let demoUserId;
|
||||
function commandsFor(userId) {
|
||||
@@ -189,9 +239,24 @@ const server = http.createServer(async (req, res) => {
|
||||
const body = await readJson(req);
|
||||
if (route === '/auth/register') {
|
||||
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, 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 登录会话) ----
|
||||
@@ -242,13 +307,13 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
if (req.method === 'GET' && route === '/admin/invites') {
|
||||
const invites = db.prepare(`
|
||||
SELECT i.id, i.code, i.remark, i.created_at, i.used_by, i.used_at, i.valid,
|
||||
SELECT i.id, i.code, i.remark, i.created_at, i.used_by, i.used_at, i.valid, i.holder_gone,
|
||||
u.username AS used_by_name, cu.username AS created_by_name
|
||||
FROM invite_codes i
|
||||
LEFT JOIN users u ON u.id = i.used_by
|
||||
LEFT JOIN users cu ON cu.id = i.created_by
|
||||
ORDER BY i.id DESC`).all()
|
||||
.map(i => ({ ...i, valid: !!i.valid }));
|
||||
.map(i => ({ ...i, valid: !!i.valid, holder_gone: !!i.holder_gone }));
|
||||
return send(200, true, { total: invites.length, invites });
|
||||
}
|
||||
if (req.method === 'POST' && route === '/admin/invites/generate') {
|
||||
@@ -343,6 +408,13 @@ const server = http.createServer(async (req, res) => {
|
||||
try { auth = authUser(req); }
|
||||
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') {
|
||||
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 });
|
||||
|
||||
@@ -1 +1 @@
|
||||
import{r as g,f as v,j as t,S as j,t as S}from"./index-D-R037K4.js";import{C as c,a as d,b as x}from"./card-Br1zrGHT.js";import{u as N,i as u,a as m}from"./useChart-59NNyLSS.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-D-R037K4.js";import{C as w,a as G,b as F}from"./card-Br1zrGHT.js";import{u as ee,i as K,a as P}from"./useChart-59NNyLSS.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
|
||||
*
|
||||
* 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-D-R037K4.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
|
||||
*
|
||||
* 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-D-R037K4.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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -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-D-R037K4.js";import{C as Y}from"./card-Br1zrGHT.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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1 +1 @@
|
||||
import{j as s,a}from"./index-D-R037K4.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};
|
||||
@@ -0,0 +1,16 @@
|
||||
import{c as t}from"./index-DH5SEuAI.js";/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const e=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],c=t("ChevronRight",e);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const a=[["path",{d:"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2",key:"143wyd"}],["path",{d:"M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6",key:"1itne7"}],["rect",{x:"6",y:"14",width:"12",height:"8",rx:"1",key:"1ue0tg"}]],n=t("Printer",a);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const o=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],i=t("RotateCcw",o);export{c as C,n as P,i as R};
|
||||
@@ -1,11 +0,0 @@
|
||||
import{c as t}from"./index-D-R037K4.js";/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const e=[["path",{d:"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2",key:"143wyd"}],["path",{d:"M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6",key:"1itne7"}],["rect",{x:"6",y:"14",width:"12",height:"8",rx:"1",key:"1ue0tg"}]],c=t("Printer",e);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const a=[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]],h=t("RotateCcw",a);export{c as P,h as R};
|
||||
@@ -1 +1 @@
|
||||
import{r}from"./index-D-R037K4.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};
|
||||
@@ -9,8 +9,8 @@
|
||||
<link rel="icon" href="/icons/pwa-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/pwa-192.png" />
|
||||
<title>足迹 Footprints</title>
|
||||
<script type="module" crossorigin src="/assets/index-D-R037K4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9uCsgi6.css">
|
||||
<script type="module" crossorigin src="/assets/index-DH5SEuAI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-TvH70H3i.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||