feat(imports): 成绩/名单表格导入 + ClassIsland 时间配置导入 + 安装版增量迁移

服务端:
- 新增 imports 模块: 模板下载(csv/xlsx)、表格解析预览、字段映射导入(append/upsert)、
  错误行号与结果统计; ClassIsland 时间配置容错解析 + replace/merge 导入 + 失败回滚(400 不写库)
- TimeLayout 模型 + 迁移; 修复 contentBase64 空值被 422 拦截, 改由 service 返回 IMPORT_EMPTY_FILE
- merge 语义改为按开始时间槽位覆盖, 同开始时间的旧节次不再被静默丢弃
- db-bootstrap 增加 _ch_migrations 账本: 覆盖安装升级也能自动补跑新增迁移(旧库实测通过)

Web 管理端:
- TableImportDialog(成绩/名单): 模板下载、预览、字段映射、重复处理模式、结果统计与错误行
- TimeLayoutImportDialog: 粘贴/选 .json、解析预览、覆盖/合并、已导入列表与删除
- 成绩录入/学生管理/课表管理接入入口(按权限矩阵显示)

验证: verify:e2e 107/107(新增导入 22 项) verify:web 18/18(新增时间配置导入弹窗真实点击)
      verify:desktop 40/40
文档: README 新增导入章节、增量迁移说明、API 表与验收对照更新
This commit is contained in:
LoveChengke committed 2026-09-12 15:35:01 +08:00
1 parent 0dcab6f369
commit 8b85079d81
23 files changed
+2658 -31

No files matched your search

+96 -15
View File
@@ -75,7 +75,7 @@ class-helper/
│ ├── prisma/migrations/ # 迁移历史(已生成并应用) │ ├── prisma/migrations/ # 迁移历史(已生成并应用)
│ ├── prisma/seed.ts # 种子数据 │ ├── prisma/seed.ts # 种子数据
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串) │ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(62 项) │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(107 项)
│ └── src/ │ └── src/
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载) │ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出) │ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
@@ -171,12 +171,12 @@ pnpm verify:desktop
```bash ```bash
# 另开一个终端先启动后端:pnpm dev:server # 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:82 项 pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入:107 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知):17 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 导入弹窗):18 项
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项 pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项
``` ```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚**等 107 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
(登录 + 四类数据 + Socket.IO),实测 26/26 通过。 (登录 + 四类数据 + Socket.IO),实测 26/26 通过。
@@ -203,7 +203,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
## 常用命令 ## 常用命令
| 命令 | 说明 | | 命令 | 说明 |
| ------------------------------------------- | ------------------------------------------------------------------------- | | ------------------------------------------- | ---------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | | `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | | `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | | `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
@@ -214,9 +214,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(82 项,含角色权限矩阵,需服务端已启动) | | `pnpm verify:e2e` | 后端端到端验收(107 项,含角色权限矩阵与导入,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,17 项,含权限入口隐藏) | | `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,18 项,含权限入口隐藏与导入弹窗) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(34 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) | | `pnpm verify:desktop` | EXE 客户端冒烟验证(40 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
@@ -261,7 +261,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
实现位置:`src/composables/useResponsive.ts`(断点 + resize 监听)、`src/layouts/AdminLayout.vue`(抽屉/顶栏)、 实现位置:`src/composables/useResponsive.ts`(断点 + resize 监听)、`src/layouts/AdminLayout.vue`(抽屉/顶栏)、
`src/styles/index.css`(全局响应式规则)、各视图表格卡片加 `.table-card`。 `src/styles/index.css`(全局响应式规则)、各视图表格卡片加 `.table-card`。
`pnpm verify:web` 的 17 项里,最后 9 项专门验证权限入口隐藏、手机端与叫人入口(Electron 真实点击): `pnpm verify:web` 的 18 项里,最后 10 项专门验证权限入口隐藏、手机端、叫人入口与 ClassIsland 时间配置导入弹窗(Electron 真实点击):
| 校验项 | 实测结果 | | 校验项 | 实测结果 |
| -------------------------------------- | -------------------------------------------------------------------- | | -------------------------------------- | -------------------------------------------------------------------- |
@@ -531,13 +531,61 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现); 回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现);
`verify:web` 用真实点击构造"正在上课"场景并断言全屏警告文案、按钮禁用与倒计时时长(实测 ~2.96s)。 `verify:web` 用真实点击构造"正在上课"场景并断言全屏警告文案、按钮禁用与倒计时时长(实测 ~2.96s)。
## 导入(成绩 / 名单表格 + ClassIsland 时间配置)
三条导入链路都在服务端完成解析、校验与去重,前端只负责收集文件与展示结果,
因此 Web 端、客户端与后续接口调用者拿到的是**同一套规则与同一份结果统计**。
### 1. 成绩表格导入(管理员)
界面:Web 管理端 →「成绩录入」→「导入表格」(组件 `packages/web-admin/src/components/TableImportDialog.vue`)。
1. **下载模板**:`GET /imports/template?kind=grades&format=xlsx|csv`
模板列:`学生用户名 / 学生姓名 / 考试名称 / 分数 / 总分 / 课程`(CSV 带 BOM,Excel 直接双击不乱码)。
2. **上传预览**:`POST /imports/table/preview` → 列名、前 20 行、`totalRows`、**建议字段映射**与校验问题。
- 支持 `.xlsx / .xls / .csv / .tsv`(SheetJS,自动识别分隔符),上限 8MB;
- 列名按同义词自动映射(如「学号→学生用户名」「得分→分数」「满分→总分」),也可手动改;
- 缺必填列(考试名称、分数)不会静默通过,直接给出"缺少必填列「…」"提示。
3. **确认导入**:`POST /imports/table/commit`,参数 `mapping`(规范字段 → 列名)+ `mode`:
- `upsert`(默认)已存在则更新;`append` 已存在则跳过;
- 成绩重复判定:**同班级 + 同学生 + 同考试 + 同课程**;学生按「用户名」优先、「姓名」兜底匹配;
- 返回 `{ total, inserted, updated, skipped, failed, errors[], warnings[] }`,
`errors[].row` 是 **Excel 视角的行号**(含表头),便于老师改完再导。
### 2. 学生名单导入(管理员)
界面:Web 管理端 →「学生管理」→「导入名单」(复用同一组件,`kind=students`)。
- 模板列:`用户名 / 姓名 / 初始密码`;用户名必须匹配 `^[A-Za-z0-9_.-]{3,32}$`;
- 重复判定:同一用户名(`append` 跳过 / `upsert` 更新姓名与班级);
- 密码留空使用 `DEFAULT_STUDENT_PASSWORD`,服务端 bcrypt 加密后入库;
- 用户名被教师/管理员账号占用时该行进错误行,不会覆盖他人账号。
### 3. ClassIsland 课表时间配置导入(管理员 / 本班班主任)
界面:Web 管理端 →「课表管理」→「导入时间配置」
(组件 `packages/web-admin/src/components/TimeLayoutImportDialog.vue`)。
- **输入**:粘贴 JSON 文本或选择 ClassIsland 导出的 `.json` 文件;
- **容错解析**(`packages/server/src/modules/imports/time-layout.service.ts`):
顶层数组、`{ TimeLayouts: [...] }`、`{ TimeLayoutItems | Items | items | Layouts | TimeLayout: [...] }` 都能识别;
时间支持 `"8:0:0"` / `"08:00"` / ISO 字符串 / `StartSecond: 28800` 秒数;字段名兼容
`StartTime|startTime|start`、`EndTime|end`、`Name|Title|Subject`、`TimeType|Type`、`IsSkipped|skip`;
- **校验**:时间无法解析、结束不晚于开始、空配置都会给出**逐条原因**(含条序号与名称);
时间重叠、分割线无时间、原配置标记跳过等只作为 `warnings`;
- **写入模式**:
- `replace`:整体替换该班同名(缺省「默认时间表」)配置;
- `merge`:以**开始时间**为槽位标识合并——同槽位被导入项覆盖,其它旧节次全部保留(不静默丢数据);
- **失败回滚**:解析存在 `errors` 时返回 400 `IMPORT_INVALID` / `IMPORT_EMPTY`,
**数据库完全不写入**,界面会提示"原有配置不会被修改",不会出现导入到一半的脏状态。
## REST API 一览 ## REST API 一览
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。 统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
所有接口前缀 `/api`,除登录外均需 `Authorization: Bearer <token>`。 所有接口前缀 `/api`,除登录外均需 `Authorization: Bearer <token>`。
| 方法 | 路径 | 权限 | 说明 | | 方法 | 路径 | 权限 | 说明 |
| --------------------------- | -------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------- | | --------------------------- | -------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------- |
| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 | | POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 |
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码 | | PATCH | `/auth/password` | 登录 | 修改自己的密码 |
@@ -573,6 +621,11 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
| GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) | | GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) |
| POST | `/teachers` | 管理员 | 新建教师账号 | | POST | `/teachers` | 管理员 | 新建教师账号 |
| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | | GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 |
| GET | `/imports/template?kind=&format=` | 管理员 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) |
| POST | `/imports/table/preview` | 管理员 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 |
| POST | `/imports/table/commit` | 管理员 | 按字段映射与写入模式导入(成绩 / 学生名单),返回新增/更新/跳过/失败与错误行号 |
| POST | `/imports/time-layout/preview` | 管理员/本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) |
| GET / POST / DELETE | `/imports/time-layout` | 管理员/本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 |
| GET | `/health` | 公开 | 健康检查(含已挂载模块列表) | | GET | `/health` | 公开 | 健康检查(含已挂载模块列表) |
## WebSocket 事件 ## WebSocket 事件
@@ -678,12 +731,24 @@ pnpm db:migrate && pnpm db:seed
``` ```
数据库表:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` / 数据库表:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` /
`Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `Grade`(共 11 张)。 `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `Grade` /
`TimeLayout`(共 12 张)。
### 启动时迁移(安装版自动升级)
`packages/server/src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名:
- **全新安装**:库中无业务表 → 依次执行 `prisma/migrations/` 下全部迁移,并创建初始管理员;
- **覆盖安装升级**:只补跑账本里没有记录的迁移(已存在的表/索引自动跳过),
因此新增表(如 `TimeLayout`)无需用户手动执行 `prisma migrate deploy`;
- **已是最新**:直接跳过,不做任何写操作。
日志会明确写出 `全新安装` / `升级安装`、应用了几个迁移、跳过了几个已存在对象,便于排障。
## 验收标准对照 ## 验收标准对照
| 验收项 | 结果 | 证据 | | 验收项 | 结果 | 证据 |
| ------------------------------------------------ | ---- | -------------------------------------------------------------------------------------------- | | ------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------- |
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | | 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | | 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | | 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
@@ -696,6 +761,9 @@ pnpm db:migrate && pnpm db:seed
| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` | | 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` |
| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" | | 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" |
| 叫人(老师点名 → 学生灵动岛立即跳出) | ✅ | `verify:e2e` 6 项 + `verify:desktop` 上课中也立即展开(徽标"叫人"、按钮"收到") | | 叫人(老师点名 → 学生灵动岛立即跳出) | ✅ | `verify:e2e` 6 项 + `verify:desktop` 上课中也立即展开(徽标"叫人"、按钮"收到") |
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 |
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 |
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 |
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) | | 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) |
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | | 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
@@ -711,9 +779,9 @@ pnpm db:migrate && pnpm db:seed
当前实测: 当前实测:
| 验证 | 结果 | | 验证 | 结果 |
| --------------------------------------------------------------------------------- | ----------------------------------- | | ----------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **82/82** ✅ | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **107/107** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + PWA) | **17/17** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ | | `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
@@ -816,6 +884,19 @@ pnpm db:migrate && pnpm db:seed
| `scripts/lib/{electron-env,node-runtime}.mjs` | Electron/Node 运行时定位与宿主环境兼容处理 | | `scripts/lib/{electron-env,node-runtime}.mjs` | Electron/Node 运行时定位与宿主环境兼容处理 |
| `docs/screenshots/island/*.png` | 灵动岛四种形态的自动化留档截图(由冒烟验证生成,含像素级断言) | | `docs/screenshots/island/*.png` | 灵动岛四种形态的自动化留档截图(由冒烟验证生成,含像素级断言) |
阶段 9(导入能力:成绩 / 名单表格 + ClassIsland 时间配置)新增文件:
| 路径 | 说明 |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `packages/server/src/modules/imports/imports.module.ts` | 导入路由(模板下载 / 表格预览与提交 / 时间配置预览·导入·列表·删除) |
| `packages/server/src/modules/imports/imports.schemas.ts` | Zod 校验(base64 文件、字段映射、写入模式、时间配置 payload) |
| `packages/server/src/modules/imports/table-import.service.ts` | SheetJS 解析 + 模板生成 + 同义词映射 + 成绩/名单写入与结果统计 |
| `packages/server/src/modules/imports/time-layout.service.ts` | ClassIsland 时间配置容错解析(多形态/多字段名/秒与 HH:mm)+ 覆盖/合并 |
| `packages/server/prisma/migrations/20260912120000_add_time_layout/` | `TimeLayout` 表迁移 |
| `packages/web-admin/src/components/TableImportDialog.vue` | 成绩 / 名单导入弹窗(模板、预览、字段映射、模式、结果与错误行) |
| `packages/web-admin/src/components/TimeLayoutImportDialog.vue` | ClassIsland 时间配置导入弹窗(粘贴/选文件、解析预览、覆盖/合并、已存列表) |
| `packages/shared/src/types.ts` 的导入 DTO | `TableImportPreview` / `TableImportResult` / `TimeLayoutDto` 等前后端共用 |
## 后续可选增强 ## 后续可选增强
1. 代码签名证书(消除 SmartScreen 提示)。 1. 代码签名证书(消除 SmartScreen 提示)。
+1
View File
@@ -34,6 +34,7 @@
"helmet": "^8.3.0", "helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"xlsx": "0.18.5",
"zod": "^4.6.2" "zod": "^4.6.2"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "TimeLayout" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'classisland',
"items" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "TimeLayout_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "TimeLayout_classId_idx" ON "TimeLayout"("classId");
+19
View File
@@ -66,6 +66,7 @@ model Class {
notifications Notification[] notifications Notification[]
grades Grade[] grades Grade[]
enrollments Enrollment[] enrollments Enrollment[]
timeLayouts TimeLayout[]
@@index([teacherId]) @@index([teacherId])
} }
@@ -212,6 +213,24 @@ model NotificationRead {
@@index([userId]) @@index([userId])
} }
/// 课表时间配置(ClassIsland 同款 JSON 导入后的节次时间表)
model TimeLayout {
id String @id @default(cuid())
classId String
name String
/// 来源:classisland | manual
source String @default("classisland")
/// 节次数组(JSON 字符串):[{ index, name, startTime, endTime, type, skipped }]
items String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@index([classId])
}
/// 成绩 /// 成绩
model Grade { model Grade {
id String @id @default(cuid()) id String @id @default(cuid())
+387
View File
@@ -847,6 +847,393 @@ async function main() {
`涉及 ${homeworkClassIds.size} 个班级`, `涉及 ${homeworkClassIds.size} 个班级`,
); );
// ---------------------------------------------------------------- 8. 导入能力(课表时间配置 + 表格导入)
const NL = String.fromCharCode(10);
// 8.1 导入模板下载
const templateCsv = await api('/imports/template?kind=grades&format=csv', { token: adminToken });
const templateCsvText = templateCsv.payload?.data?.content ?? '';
record(
'成绩导入模板(CSV)可下载且含必填列',
templateCsv.status === 200 &&
templateCsvText.includes('考试名称') &&
templateCsvText.includes('分数') &&
templateCsvText.charCodeAt(0) === 0xfeff,
`status=${templateCsv.status} len=${templateCsvText.length}`,
);
const templateXlsxResponse = await fetch(`${BASE_URL}/api/imports/template?kind=students&format=xlsx`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const templateXlsxBuffer = Buffer.from(await templateXlsxResponse.arrayBuffer());
record(
'名单导入模板(XLSX)可下载',
templateXlsxResponse.status === 200 &&
templateXlsxBuffer.length > 500 &&
templateXlsxBuffer.subarray(0, 2).toString() === 'PK',
`status=${templateXlsxResponse.status} bytes=${templateXlsxBuffer.length}`,
);
const templateDenied = await api('/imports/template?kind=grades&format=csv', { token: teacherToken });
record('教师下载成绩模板被拒绝(403)', templateDenied.status === 403, `status=${templateDenied.status}`);
// 8.2 ClassIsland 课表时间配置:预览 -> 导入 -> 失败回滚 -> 合并 -> 越权 -> 清理
const sampleLayout = {
TimeLayouts: [
{ Name: '第 1 节', StartTime: '8:0:0', EndTime: '8:45:0', TimeType: 0 },
{ Name: '课间休息', StartTime: '8:45:0', EndTime: '8:55:0', TimeType: 1 },
{ Name: '第 2 节', StartSecond: 31500, EndSecond: 34200, TimeType: 0 },
],
};
const layoutPreview = await api('/imports/time-layout/preview', {
method: 'POST',
token: teacherToken,
body: { classId, payload: sampleLayout },
});
const previewItems = layoutPreview.payload?.data?.items ?? [];
record(
'ClassIsland 时间配置预览(时分与秒两种写法)',
layoutPreview.status === 200 && previewItems.length === 3 && previewItems[0]?.startTime === '08:00',
`status=${layoutPreview.status} 节数=${previewItems.length} shape=${layoutPreview.payload?.data?.shape}`,
);
const brokenPreview = await api('/imports/time-layout/preview', {
method: 'POST',
token: teacherToken,
body: { classId, payload: '[{"StartTime":"8:0:0","EndTime":"7:0:0"}]' },
});
record(
'时间配置校验可定位错误(预览返回 errors)',
brokenPreview.status === 200 && (brokenPreview.payload?.data?.errors ?? []).length > 0,
`errors=${(brokenPreview.payload?.data?.errors ?? []).length}`,
);
const layoutImport = await api('/imports/time-layout', {
method: 'POST',
token: teacherToken,
body: { classId, name: '默认时间表', mode: 'replace', payload: sampleLayout },
});
const layoutId = layoutImport.payload?.data?.layout?.id ?? null;
record(
'班主任导入本班时间配置(replace)',
layoutImport.status === 200 && layoutImport.payload?.data?.layout?.items?.length === 3,
`status=${layoutImport.status} 节数=${layoutImport.payload?.data?.layout?.items?.length}`,
);
const layoutList = await api(`/imports/time-layout?classId=${classId}`, { token: teacherToken });
record(
'时间配置已持久化可查询',
(layoutList.payload?.data ?? []).some((item) => item.id === layoutId),
`共 ${(layoutList.payload?.data ?? []).length} 份`,
);
const badImport = await api('/imports/time-layout', {
method: 'POST',
token: teacherToken,
body: {
classId,
name: '默认时间表',
mode: 'replace',
payload: '[{"StartTime":"9:0:0","EndTime":"8:0:0"}]',
},
});
const afterBadList = await api(`/imports/time-layout?classId=${classId}`, { token: teacherToken });
const afterBadItems = (afterBadList.payload?.data ?? []).find((item) => item.id === layoutId)?.items ?? [];
record(
'非法时间配置导入失败且原配置不变(失败回滚)',
badImport.status === 400 && badImport.payload?.code === 'IMPORT_INVALID' && afterBadItems.length === 3,
`status=${badImport.status} code=${badImport.payload?.code} 原配置=${afterBadItems.length} 节`,
);
const mergeImport = await api('/imports/time-layout', {
method: 'POST',
token: teacherToken,
body: {
classId,
name: '默认时间表',
mode: 'merge',
payload: [
{ StartTime: '8:0:0', EndTime: '8:50:0', Name: '第 1 节(调整)' },
{ StartTime: '10:0:0', EndTime: '10:45:0', Name: '第 3 节' },
],
},
});
const mergeData = mergeImport.payload?.data;
record(
'合并导入按开始时间去重(合并 1 节 / 新增 1 节)',
mergeImport.status === 200 &&
mergeData?.merged === 1 &&
mergeData?.replaced === 1 &&
mergeData?.layout?.items?.length === 4,
`合并=${mergeData?.merged} 新增=${mergeData?.replaced} 共=${mergeData?.layout?.items?.length}`,
);
if (foreignClass) {
const crossImport = await api('/imports/time-layout', {
method: 'POST',
token: teacherToken,
body: { classId: foreignClass.id, mode: 'replace', payload: sampleLayout },
});
record('向他人班级导入时间配置被拒绝(403)', crossImport.status === 403, `status=${crossImport.status}`);
}
const studentLayoutList = await api(`/imports/time-layout?classId=${classId}`, { token: studentToken });
record(
'学生访问时间配置管理接口被拒绝(403)',
studentLayoutList.status === 403,
`status=${studentLayoutList.status}`,
);
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
const gradesCsv = [
'用户名,姓名,考试名称,分数,总分,课程',
'student01,,期中导入考试,92,100,',
`,${targetStudent?.name ?? '张同学'},期中导入考试,不是数字,100,`,
'unknown-user,,期中导入考试,88,100,',
].join(NL);
const gradesCsvBase64 = Buffer.from(`\ufeff${gradesCsv}${NL}`, 'utf8').toString('base64');
const gradesPreview = await api('/imports/table/preview', {
method: 'POST',
token: adminToken,
body: { kind: 'grades', fileName: 'grades.csv', contentBase64: gradesCsvBase64 },
});
const previewData = gradesPreview.payload?.data;
record(
'CSV 成绩表预览(列名 + 行数 + 建议映射)',
gradesPreview.status === 200 &&
previewData?.columns?.length === 6 &&
previewData?.totalRows === 3 &&
previewData?.suggestedMapping?.score === '分数',
`status=${gradesPreview.status} 行数=${previewData?.totalRows} 映射=${JSON.stringify(previewData?.suggestedMapping ?? {})}`,
);
const missingColumn = await api('/imports/table/preview', {
method: 'POST',
token: adminToken,
body: {
kind: 'grades',
fileName: 'missing.csv',
contentBase64: Buffer.from(`\ufeff姓名${NL}王小明${NL}`, 'utf8').toString('base64'),
},
});
record(
'缺少必填列的表格给出明确提示',
missingColumn.status === 200 && (missingColumn.payload?.data?.errors ?? []).length >= 2,
`errors=${(missingColumn.payload?.data?.errors ?? []).length}`,
);
const emptyFile = await api('/imports/table/preview', {
method: 'POST',
token: adminToken,
body: { kind: 'grades', fileName: 'empty.csv', contentBase64: '' },
});
record(
'空文件被明确拒绝(IMPORT_EMPTY_FILE)',
emptyFile.status === 400 && emptyFile.payload?.code === 'IMPORT_EMPTY_FILE',
`status=${emptyFile.status} code=${emptyFile.payload?.code}`,
);
const xlsxNamespace = await import('xlsx');
const XLSX = xlsxNamespace.default ?? xlsxNamespace;
const xlsxSheet = XLSX.utils.aoa_to_sheet([
['学号', '姓名', '考试', '得分', '满分', '科目'],
['student02', '', '期中导入考试', 85, 100, ''],
['student01', '', '期中导入考试', 78, 100, ''],
]);
const xlsxBook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(xlsxBook, xlsxSheet, '成绩');
const xlsxBase64 = XLSX.write(xlsxBook, { type: 'base64', bookType: 'xlsx' });
const xlsxPreview = await api('/imports/table/preview', {
method: 'POST',
token: adminToken,
body: { kind: 'grades', fileName: 'grades.xlsx', contentBase64: xlsxBase64 },
});
record(
'XLSX 成绩表预览(同义词自动映射 学号/得分)',
xlsxPreview.status === 200 &&
xlsxPreview.payload?.data?.totalRows === 2 &&
xlsxPreview.payload?.data?.suggestedMapping?.username === '学号' &&
xlsxPreview.payload?.data?.suggestedMapping?.score === '得分',
`status=${xlsxPreview.status} 映射=${JSON.stringify(xlsxPreview.payload?.data?.suggestedMapping ?? {})}`,
);
const commitMapping = {
username: '学号',
name: '姓名',
examName: '考试',
score: '得分',
totalScore: '满分',
courseName: '科目',
};
const gradesCommitBody = {
kind: 'grades',
classId,
fileName: 'grades.xlsx',
contentBase64: xlsxBase64,
mapping: commitMapping,
};
const gradesCommit = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
body: { ...gradesCommitBody, mode: 'append' },
});
record(
'成绩导入新增成功且返回结果统计',
gradesCommit.status === 200 &&
gradesCommit.payload?.data?.inserted === 2 &&
gradesCommit.payload?.data?.failed === 0,
`新增=${gradesCommit.payload?.data?.inserted} 失败=${gradesCommit.payload?.data?.failed}`,
);
const gradesAgain = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
body: { ...gradesCommitBody, mode: 'append' },
});
record(
'重复成绩按 append 跳过(不重复写入)',
gradesAgain.status === 200 &&
gradesAgain.payload?.data?.skipped === 2 &&
gradesAgain.payload?.data?.inserted === 0,
`跳过=${gradesAgain.payload?.data?.skipped} 新增=${gradesAgain.payload?.data?.inserted}`,
);
const gradesUpsert = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
body: { ...gradesCommitBody, mode: 'upsert' },
});
record(
'重复成绩按 upsert 更新',
gradesUpsert.status === 200 && gradesUpsert.payload?.data?.updated === 2,
`更新=${gradesUpsert.payload?.data?.updated}`,
);
const gradesBroken = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
body: {
kind: 'grades',
classId,
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
},
mode: 'append',
},
});
const brokenErrors = gradesBroken.payload?.data?.errors ?? [];
record(
'非法数据行给出具体行号与原因',
gradesBroken.status === 200 &&
gradesBroken.payload?.data?.failed === 2 &&
brokenErrors.length === 2 &&
brokenErrors.every((item) => item.row >= 2 && item.message.length > 0),
`失败=${gradesBroken.payload?.data?.failed} 首条=第 ${brokenErrors[0]?.row} 行:${brokenErrors[0]?.message ?? ''}`,
);
const gradesByTeacher = await api('/imports/table/commit', {
method: 'POST',
token: teacherToken,
body: { ...gradesCommitBody, mode: 'append' },
});
record(
'教师(非管理员)导入成绩被拒绝(403)',
gradesByTeacher.status === 403,
`status=${gradesByTeacher.status}`,
);
// 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
const studentsCsv = [
'用户名,姓名,初始密码',
`${importedUsername},导入测试生,imp123456`,
'bad user,非法用户名,imp123456',
].join(NL);
const studentsCommit = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
body: {
kind: 'students',
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名', password: '初始密码' },
mode: 'append',
},
});
record(
'学生名单导入(新增 1 / 非法用户名进错误行 1)',
studentsCommit.status === 200 &&
studentsCommit.payload?.data?.inserted === 1 &&
studentsCommit.payload?.data?.failed === 1,
`新增=${studentsCommit.payload?.data?.inserted} 失败=${studentsCommit.payload?.data?.failed}`,
);
const studentsByTeacher = await api('/imports/table/commit', {
method: 'POST',
token: teacherToken,
body: {
kind: 'students',
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名', password: '初始密码' },
mode: 'append',
},
});
record(
'教师导入学生名单被拒绝(403)',
studentsByTeacher.status === 403,
`status=${studentsByTeacher.status}`,
);
// 清理:删除导入产生的成绩、时间配置与学生,保持环境可重复验证
const importedGrades = await api(
`/grades?classId=${classId}&examName=${encodeURIComponent('期中导入考试')}`,
{
token: adminToken,
},
);
let gradesCleaned = 0;
for (const grade of importedGrades.payload?.data ?? []) {
const removed = await api(`/grades/${grade.id}`, { method: 'DELETE', token: adminToken });
if (removed.status === 200) gradesCleaned += 1;
}
record('导入的成绩可正常删除(清理验证数据)', gradesCleaned >= 2, `已删除 ${gradesCleaned} 条`);
const roster = await api(`/students?classId=${classId}`, { token: adminToken });
const importedStudent = (roster.payload?.data ?? []).find((item) => item.username === importedUsername);
if (importedStudent) {
const removedStudent = await api(`/students/${importedStudent.id}`, {
method: 'DELETE',
token: adminToken,
});
record(
'导入的学生可正常删除(清理验证数据)',
removedStudent.status === 200,
`status=${removedStudent.status}`,
);
}
if (layoutId) {
const layoutDeleted = await api(`/imports/time-layout/${layoutId}`, {
method: 'DELETE',
token: teacherToken,
});
record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`);
}
socket.close(); socket.close();
// ---------------------------------------------------------------- 汇总 // ---------------------------------------------------------------- 汇总
+136 -15
View File
@@ -9,8 +9,9 @@ import { hashPassword } from './password.js';
/** /**
* 首次启动时的数据库初始化(SQLite/libSQL)。 * 首次启动时的数据库初始化(SQLite/libSQL)。
* *
* 场景:安装程序首次运行、Docker 挂载了空数据卷、用户手动删除了数据库文件。 * 场景:安装程序首次运行、覆盖安装升级、Docker 挂载了空数据卷、用户手动删除了数据库文件。
* 做法:若目标库中还没有业务表,就把随包发布的 Prisma 迁移 SQL 依次执行一遍。 * 做法:以 `_ch_migrations` 账本表记录已执行的迁移;启动时按时间戳顺序补跑未记录的迁移。
* 全新库会跑全部迁移,旧库只补跑新增迁移(已存在的表/索引自动跳过)。
* *
* 说明: * 说明:
* - 只处理 SQLite(本项目打包安装的默认形态); * - 只处理 SQLite(本项目打包安装的默认形态);
@@ -58,6 +59,103 @@ async function schemaExists(): Promise<boolean> {
} }
} }
/**
* 迁移账本表名。
*
* 老版本安装(1.0 之前)只区分「空库 / 非空库」,因此升级安装时不会补建新表。
* 现在用一张账本表记录已执行过的迁移目录名,启动时只补跑未记录的迁移,
* 这样「覆盖安装升级」也能自动获得新表/新索引。
*/
const MIGRATION_LEDGER = '_ch_migrations';
/** 这些错误表示对象已存在,属于「旧版本已经建过」的正常情况,可安全忽略 */
const IGNORABLE_SQL_ERROR = /already exists|duplicate column name|duplicate index/i;
/** 拆分 migration.sql 为独立语句(去掉注释行,过滤空语句) */
function splitStatements(sql: string): string[] {
return sql
.split(/\r?\n/)
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.split(';')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
/** 确保账本表存在,返回已记录的迁移名 */
async function loadAppliedMigrations(client: {
execute: (sql: string) => Promise<unknown>;
}): Promise<Set<string>> {
await client.execute(
`CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
);
const rows = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
`SELECT name FROM "${MIGRATION_LEDGER}"`,
);
return new Set(rows.map((row) => row.name));
}
interface MigrationOutcome {
file: string;
applied: number;
skipped: number;
}
/** 执行单个迁移文件:逐条语句执行,已存在的对象跳过,其余错误上抛 */
async function applyMigration(
client: { execute: (sql: string) => Promise<unknown> },
file: string,
): Promise<MigrationOutcome> {
const sql = fs.readFileSync(file, 'utf8');
const outcome: MigrationOutcome = { file, applied: 0, skipped: 0 };
for (const statement of splitStatements(sql)) {
try {
await client.execute(statement);
outcome.applied += 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (IGNORABLE_SQL_ERROR.test(message)) {
outcome.skipped += 1;
continue;
}
throw new Error(
`迁移 ${path.basename(path.dirname(file))} 执行失败:${message}\nSQL: ${statement.slice(0, 200)}`,
);
}
}
await client.execute({
// libsql 的 execute 支持参数化对象形式
sql: `INSERT OR REPLACE INTO "${MIGRATION_LEDGER}" ("name", "appliedAt") VALUES (?, CURRENT_TIMESTAMP)`,
args: [path.basename(path.dirname(file))],
} as never);
return outcome;
}
/** 计算待执行的迁移文件 */
export async function listPendingMigrations(): Promise<string[]> {
const files = listMigrationFiles();
if (files.length === 0) return [];
const client = createClient({ url: env.databaseUrl });
try {
const applied = await loadAppliedMigrations(
client as unknown as {
execute: (sql: string) => Promise<unknown>;
},
);
return files.filter((file) => !applied.has(path.basename(path.dirname(file))));
} catch (error) {
logger.warn('读取迁移账本失败,按全部待执行处理', error);
return files;
} finally {
client.close();
}
}
export interface BootstrapResult { export interface BootstrapResult {
applied: number; applied: number;
skipped: boolean; skipped: boolean;
@@ -95,8 +193,12 @@ export async function ensureInitialAdmin(): Promise<{ created: boolean; username
} }
/** /**
* 确保数据库结构存在。 * 确保数据库结构存在(增量迁移)。
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。 * 仅在 AUTO_MIGRATE=true 时由启动流程调用。
*
* - 全新数据库:依次执行全部迁移;
* - 旧版本升级:只补跑账本里没有记录的迁移(已存在的对象自动跳过);
* - 已是最新:直接返回,不做任何写操作。
*/ */
export async function ensureSchema(): Promise<BootstrapResult> { export async function ensureSchema(): Promise<BootstrapResult> {
if (env.databaseProvider !== 'sqlite') { if (env.databaseProvider !== 'sqlite') {
@@ -107,27 +209,46 @@ export async function ensureSchema(): Promise<BootstrapResult> {
}; };
} }
if (await schemaExists()) {
return { applied: 0, skipped: true, detail: '数据库结构已存在,跳过初始化' };
}
const files = listMigrationFiles(); const files = listMigrationFiles();
if (files.length === 0) { if (files.length === 0) {
return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' }; return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' };
} }
const freshInstall = !(await schemaExists());
const client = createClient({ url: env.databaseUrl }); const client = createClient({ url: env.databaseUrl });
let applied = 0;
try { try {
for (const file of files) { const applied = await loadAppliedMigrations(
const sql = fs.readFileSync(file, 'utf8'); client as unknown as {
await client.executeMultiple(sql); execute: (sql: string) => Promise<unknown>;
applied += 1; },
logger.info(`已应用迁移:${path.basename(path.dirname(file))}`); );
const pending = files.filter((file) => !applied.has(path.basename(path.dirname(file))));
if (pending.length === 0) {
return { applied: 0, skipped: true, detail: '数据库结构已是最新,无需迁移' };
} }
let reused = 0;
for (const file of pending) {
const outcome = await applyMigration(
client as unknown as { execute: (sql: string) => Promise<unknown> },
file,
);
reused += outcome.skipped;
logger.info(
`已应用迁移:${path.basename(path.dirname(file))}` +
(outcome.skipped > 0 ? `(跳过 ${outcome.skipped} 个已存在对象)` : ''),
);
}
const mode = freshInstall ? '全新安装' : '升级安装';
return {
applied: pending.length,
skipped: false,
detail: `${mode}:已应用 ${pending.length} 个迁移文件${reused > 0 ? `,跳过 ${reused} 个已存在对象` : ''}`,
};
} finally { } finally {
client.close(); client.close();
} }
return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` };
} }
+6
View File
@@ -22,6 +22,12 @@ export type ApiErrorCode =
| 'TOO_MANY_REQUESTS' | 'TOO_MANY_REQUESTS'
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */ /** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
| 'URGENT_DURING_CLASS' | 'URGENT_DURING_CLASS'
/** 导入:空文件 / 文件过大 / 格式错误 / 内容不合法 / 没有有效数据 */
| 'IMPORT_EMPTY_FILE'
| 'IMPORT_TOO_LARGE'
| 'IMPORT_FORMAT_INVALID'
| 'IMPORT_INVALID'
| 'IMPORT_EMPTY'
| 'INTERNAL_ERROR'; | 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */ /** 业务异常:中间件会把它转换成统一错误响应体 */
@@ -28,6 +28,12 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
} }
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */ /** 读取已校验的 query(类型由调用方用 zod schema 保证) */
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
export function validatedBody<T>(req: Request): T {
// validate() 会把解析结果写回 req.body
return req.body as T;
}
export function validatedQuery<T>(req: Request): T { export function validatedQuery<T>(req: Request): T {
return (req.validatedQuery ?? {}) as T; return (req.validatedQuery ?? {}) as T;
} }
@@ -0,0 +1,113 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
tableCommitSchema,
tableFileSchema,
timeLayoutImportSchema,
timeLayoutQuerySchema,
type TableCommitInput,
type TableFileInput,
type TimeLayoutImportInput,
} from './imports.schemas.js';
import { buildTemplateCsv, buildTemplateXlsx, commitTable, previewTable } from './table-import.service.js';
import {
deleteTimeLayout,
importTimeLayout,
listTimeLayouts,
previewClassIslandTimeLayout,
} from './time-layout.service.js';
const router = Router();
router.use(authenticate());
/**
* GET /api/imports/template?kind=grades|students&format=csv|xlsx - 下载导入模板
* 成绩模板=管理员;名单模板=管理员(与写入权限一致,不额外开入口)
*/
router.get('/template', requireRole('ADMIN'), (req, res) => {
const kind = req.query.kind === 'students' ? 'students' : 'grades';
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
if (format === 'xlsx') {
const base64 = buildTemplateXlsx(kind);
res.setHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('content-disposition', `attachment; filename="template-${kind}.xlsx"`);
res.send(Buffer.from(base64, 'base64'));
return;
}
sendOk(
res,
{ kind, format, fileName: `template-${kind}.csv`, content: buildTemplateCsv(kind) },
'模板生成成功',
);
});
/**
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
* 仅管理员:与"成绩录入 / 名单管理"权限一致
*/
router.post('/table/preview', requireRole('ADMIN'), validate({ body: tableFileSchema }), (req, res) => {
sendOk(res, previewTable(validatedBody<TableFileInput>(req)), '表格解析成功');
});
/** POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计) */
router.post(
'/table/commit',
requireRole('ADMIN'),
validate({ body: tableCommitSchema }),
async (req, res) => {
const result = await commitTable(getAuthUser(req), validatedBody<TableCommitInput>(req));
sendOk(
res,
result,
`导入完成:新增 ${result.inserted} / 更新 ${result.updated} / 跳过 ${result.skipped} / 失败 ${result.failed}`,
);
},
);
/**
* POST /api/imports/time-layout/preview - 预览 ClassIsland 课表时间 JSON(只解析不写库)
* 权限与"课表管理"一致:管理员或本班班主任
*/
router.post('/time-layout/preview', validate({ body: timeLayoutImportSchema }), async (req, res) => {
const input = validatedBody<TimeLayoutImportInput>(req);
// 预览也要校验班级权限(与导入一致),避免越权探测
const { assertCanManageSchedule } = await import('../../lib/access.js');
await assertCanManageSchedule(getAuthUser(req), input.classId);
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
});
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功');
});
/**
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
*/
router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
sendOk(
res,
result,
result.mode === 'merge'
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节`
: `覆盖导入成功:共 ${result.replaced} 节`,
);
});
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
router.delete('/time-layout/:id', async (req, res) => {
await deleteTimeLayout(getAuthUser(req), req.params.id ?? '');
sendOk(res, { id: req.params.id }, '时间配置已删除');
});
export const importsModule = defineModule({
name: 'imports',
basePath: '/imports',
router,
});
@@ -0,0 +1,51 @@
import { z } from 'zod';
/** 导入类型:成绩 / 学生名单 / 课表时间配置 */
export const IMPORT_KINDS = ['grades', 'students'] as const;
/** 表格导入:上传的文件(base64)+ 类型 */
export const tableFileSchema = z.object({
kind: z.enum(IMPORT_KINDS),
fileName: z.string().trim().min(1, '缺少文件名').max(200),
/**
* base64(不含 data: 前缀)。
* 这里刻意不限制最小长度:空内容交给 service 返回 IMPORT_EMPTY_FILE,
* 这样前端能拿到"文件为空"这一精确原因,而不是笼统的参数校验错误。
*/
contentBase64: z.string(),
});
/** 表格提交:在预览基础上带字段映射、目标班级与写入模式 */
export const tableCommitSchema = tableFileSchema.extend({
classId: z.string().min(1, '请选择班级'),
/** 列映射:规范字段 → 文件列名(或列索引字符串) */
mapping: z.record(z.string(), z.string().min(1)),
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按用户名) */
mode: z.enum(['append', 'upsert']).default('upsert'),
});
/** ClassIsland 课表时间导入:先校验/预览,再提交 */
export const timeLayoutImportSchema = z.object({
classId: z.string().min(1, '请选择班级'),
name: z.string().trim().max(64).optional(),
/** replace = 覆盖该班现有时间配置;merge = 按开始时间合并 */
mode: z.enum(['replace', 'merge']).default('replace'),
/**
* ClassIsland 导出的 JSON(对象或字符串均可)。
* 字符串是为了兼容"直接粘贴 JSON 文本"的场景。
*/
payload: z.union([
z.string().min(1, '请粘贴或选择 ClassIsland 导出的 JSON'),
z.record(z.string(), z.unknown()),
z.array(z.unknown()),
]),
});
/** 时间配置查询 */
export const timeLayoutQuerySchema = z.object({
classId: z.string().min(1, '请选择班级'),
});
export type TableFileInput = z.infer<typeof tableFileSchema>;
export type TableCommitInput = z.infer<typeof tableCommitSchema>;
export type TimeLayoutImportInput = z.infer<typeof timeLayoutImportSchema>;
@@ -0,0 +1,409 @@
import bcrypt from 'bcryptjs';
import * as XLSX from 'xlsx';
import { prisma } from '../../lib/db.js';
import { env } from '../../config/env.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
* 表格导入(xlsx / xls / csv)。
*
* 流程:上传(base64) → 解析首个工作表 → 预览(列名/前 N 行/校验问题/建议映射)
* → 前端确认字段映射与写入模式 → 提交(逐行校验,重复按模式处理)→ 结果统计。
*
* 异常处理:空文件、超大文件、格式错误、缺列、非法数值、重复数据、未知学生等
* 都会给出明确原因与行号,绝不静默失败。
*/
const MAX_IMPORT_BYTES = 8 * 1024 * 1024; // 8MB
const PREVIEW_ROWS = 20;
export type ImportKind = TableFileInput['kind'];
/** 规范字段定义(预览、模板、映射提示共用) */
export const IMPORT_FIELDS: Record<
ImportKind,
{ key: string; label: string; required: boolean; synonyms: string[] }[]
> = {
grades: [
{
key: 'username',
label: '学生用户名',
required: false,
synonyms: ['用户名', 'username', '学号', '账号'],
},
{ key: 'name', label: '学生姓名', required: false, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{
key: 'examName',
label: '考试名称',
required: true,
synonyms: ['考试', '考试名称', 'exam', 'examName', '测验'],
},
{ key: 'score', label: '分数', required: true, synonyms: ['分数', 'score', '成绩', '得分'] },
{ key: 'totalScore', label: '总分', required: false, synonyms: ['总分', 'totalScore', '满分'] },
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
],
students: [
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
],
};
export interface TablePreview {
kind: ImportKind;
columns: string[];
rows: string[][];
totalRows: number;
suggestedMapping: Record<string, string>;
errors: string[];
warnings: string[];
templateCsv: string;
}
interface ParsedTable {
columns: string[];
rows: string[][];
}
function decodeBase64(contentBase64: string): Buffer {
const cleaned = contentBase64.includes(',')
? contentBase64.slice(contentBase64.indexOf(',') + 1)
: contentBase64;
const buffer = Buffer.from(cleaned, 'base64');
if (buffer.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '文件内容为空,请确认导出后重新上传');
if (buffer.length > MAX_IMPORT_BYTES) {
throw new ApiError(
413,
'IMPORT_TOO_LARGE',
`文件过大(${(buffer.length / 1024 / 1024).toFixed(1)}MB),请拆分后分批导入(上限 ${MAX_IMPORT_BYTES / 1024 / 1024}MB)`,
);
}
return buffer;
}
/** 解析表格:xlsx/xls 走 SheetJS,csv/tsv 也走 SheetJS(自动识别分隔符) */
function parseTable(buffer: Buffer, fileName: string): ParsedTable {
let workbook: XLSX.WorkBook;
try {
workbook = XLSX.read(buffer, { type: 'buffer', raw: false, cellDates: false });
} catch (error) {
throw new ApiError(
400,
'IMPORT_FORMAT_INVALID',
`文件解析失败:${(error as Error).message}(支持 .xlsx/.xls/.csv)`,
{
fileName,
},
);
}
const sheetName = workbook.SheetNames[0];
const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
if (!sheet) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格里没有工作表,请确认文件内容');
const matrix = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
blankrows: false,
defval: '',
raw: false,
});
const cleaned = matrix
.map((row) => (Array.isArray(row) ? row.map((cell) => String(cell ?? '').trim()) : []))
.filter((row) => row.some((cell) => cell !== ''));
if (cleaned.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格是空的(没有任何数据行)');
const headerRow = cleaned[0] ?? [];
const columns = headerRow.map((cell, index) => (cell === '' ? `第 ${index + 1} 列` : cell));
const rows = cleaned.slice(1).map((row) => {
const values = [...row];
while (values.length < columns.length) values.push('');
return values.slice(0, columns.length);
});
if (rows.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格只有表头,没有数据行');
return { columns, rows };
}
/** 生成模板 CSV(同时用于预览弹窗的"下载模板") */
export function buildTemplateCsv(kind: ImportKind): string {
const fields = IMPORT_FIELDS[kind];
const header = fields.map((field) => field.label).join(',');
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', '92', '100', '数学']
: ['student01', '王小明', 'student123'];
const sampleLine = fields
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
.join(',');
return `\ufeff${header}\n${sampleLine}\n`;
}
/** 生成模板 XLSX(Base64),让老师可以直接下载后填写 */
export function buildTemplateXlsx(kind: ImportKind): string {
const fields = IMPORT_FIELDS[kind];
const header = fields.map((field) => field.label);
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', 92, 100, '数学']
: ['student01', '王小明', 'student123'];
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, kind === 'grades' ? '成绩' : '学生名单');
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
}
/** 按同义词自动建议列映射 */
function suggestMapping(kind: ImportKind, columns: string[]): Record<string, string> {
const mapping: Record<string, string> = {};
for (const field of IMPORT_FIELDS[kind]) {
const match = columns.find(
(column) =>
field.synonyms.some((synonym) => column.toLowerCase() === synonym.toLowerCase()) ||
field.synonyms.some((synonym) => column.toLowerCase().includes(synonym.toLowerCase())),
);
if (match) mapping[field.key] = match;
}
return mapping;
}
/** 预览:解析 + 必填列校验 + 建议映射(不写库) */
export function previewTable(input: TableFileInput): TablePreview {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const errors: string[] = [];
const warnings: string[] = [];
const suggestedMapping = suggestMapping(input.kind, parsed.columns);
for (const field of IMPORT_FIELDS[input.kind]) {
if (field.required && !suggestedMapping[field.key]) {
errors.push(`缺少必填列「${field.label}」:请在表格里补上该列,或在下一步手动映射到某一列`);
}
}
if (parsed.rows.length > 2000) warnings.push(`数据行较多(${parsed.rows.length} 行),导入可能需要几秒钟`);
return {
kind: input.kind,
columns: parsed.columns,
rows: parsed.rows.slice(0, PREVIEW_ROWS),
totalRows: parsed.rows.length,
suggestedMapping,
errors,
warnings,
templateCsv: buildTemplateCsv(input.kind),
};
}
export interface ImportRowError {
row: number;
message: string;
}
export interface ImportResult {
kind: ImportKind;
total: number;
inserted: number;
updated: number;
skipped: number;
failed: number;
errors: ImportRowError[];
warnings: string[];
}
function cellValue(row: string[], columns: string[], mapping: Record<string, string>, key: string): string {
const column = mapping[key];
if (!column) return '';
const index = columns.indexOf(column);
if (index < 0) return '';
return (row[index] ?? '').trim();
}
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageGrades(user);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const students = await prisma.user.findMany({
where: { classId: input.classId, role: 'STUDENT' },
select: { id: true, username: true, name: true },
});
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId: input.classId },
select: { id: true, name: true },
});
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2; // 含表头,从 Excel 视角的行号
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const examName = cellValue(row, parsed.columns, input.mapping, 'examName');
const scoreText = cellValue(row, parsed.columns, input.mapping, 'score');
const totalText = cellValue(row, parsed.columns, input.mapping, 'totalScore');
const courseName = cellValue(row, parsed.columns, input.mapping, 'courseName');
if (!username && !name) {
errors.push({ row: rowNumber, message: '缺少学生标识(用户名与姓名至少填写一个)' });
continue;
}
const student =
(username ? byUsername.get(username.toLowerCase()) : undefined) ??
(name ? byName.get(name.toLowerCase()) : undefined);
if (!student) {
errors.push({ row: rowNumber, message: `找不到该班学生:${username || name}` });
continue;
}
if (!examName) {
errors.push({ row: rowNumber, message: '缺少考试名称' });
continue;
}
const score = Number(scoreText);
if (!Number.isFinite(score)) {
errors.push({ row: rowNumber, message: `分数「${scoreText}」不是数字` });
continue;
}
const totalScore = totalText ? Number(totalText) : 100;
if (!Number.isFinite(totalScore) || totalScore <= 0) {
errors.push({ row: rowNumber, message: `总分「${totalText || '100'}」不合法` });
continue;
}
if (score < 0 || score > totalScore) {
errors.push({ row: rowNumber, message: `分数 ${score} 超出 0~${totalScore} 范围` });
continue;
}
const course = courseName ? courses.find((item) => item.name === courseName) : null;
if (courseName && !course)
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
const existing = await prisma.grade.findFirst({
where: { classId: input.classId, userId: student.id, examName, courseId: course?.id ?? null },
select: { id: true },
});
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
}
await prisma.grade.update({
where: { id: existing.id },
data: { score, totalScore, publishedAt: new Date() },
});
updated += 1;
} else {
await prisma.grade.create({
data: {
classId: input.classId,
userId: student.id,
courseId: course?.id ?? null,
examName,
score,
totalScore,
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'grades',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`成绩导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const cls = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
if (!cls) throw ApiError.notFound('班级不存在');
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (existing.role !== 'STUDENT') {
errors.push({ row: rowNumber, message: `用户名「${username}」已被教师/管理员账号占用` });
continue;
}
if (input.mode === 'append') {
skipped += 1;
continue;
}
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: input.classId } });
updated += 1;
} else {
const initialPassword = password || env.defaultStudentPassword;
await prisma.user.create({
data: {
username,
name,
role: 'STUDENT',
classId: input.classId,
passwordHash: await bcrypt.hash(initialPassword, 10),
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'students',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`学生名单导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
return input.kind === 'grades' ? commitGrades(user, input) : commitStudents(user, input);
}
@@ -0,0 +1,358 @@
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { assertCanManageSchedule } from '../../lib/access.js';
import type { TimeLayoutImportInput } from './imports.schemas.js';
/**
* ClassIsland 课表时间(TimeLayout)导入。
*
* ClassIsland 的导出结构在不同版本里略有差异,常见形态:
* 1. 顶层就是数组:`[{ StartTime: "8:0:0", EndTime: "8:45:0", TimeType: 0 }, ...]`
* 2. 包在对象里:`{ TimeLayouts: [...] }` / `{ TimeLayoutItems: [...] }` / `{ items: [...] }`
* 3. 时间用秒表示:`{ StartSecond: 28800, EndSecond: 31500 }`
* 4. 字段名大小写/命名不同:`StartTime` / `startTime` / `Start`;`Name` / `Title`
*
* 因此这里做**容错解析**:先把可能的数组抠出来,再逐条按候选字段名解析时间,
* 解析失败的行进入 errors(不写库),成功解析但可疑的进入 warnings(可继续导入)。
*/
export type TimeLayoutItemType = 'class' | 'break' | 'divider';
export interface ParsedTimeLayoutItem {
index: number;
name: string;
startTime: string;
endTime: string;
type: TimeLayoutItemType;
skipped: boolean;
}
export interface TimeLayoutParseResult {
items: ParsedTimeLayoutItem[];
errors: string[];
warnings: string[];
}
const TIME_KEYS = ['StartTime', 'startTime', 'start', 'Start'] as const;
const END_KEYS = ['EndTime', 'endTime', 'end', 'End'] as const;
const START_SECOND_KEYS = ['StartSecond', 'startSecond', 'StartSeconds'] as const;
const END_SECOND_KEYS = ['EndSecond', 'endSecond', 'EndSeconds'] as const;
const NAME_KEYS = ['Name', 'name', 'Title', 'title', 'Subject'] as const;
const TYPE_KEYS = ['TimeType', 'timeType', 'Type', 'type'] as const;
const SKIP_KEYS = ['IsSkipped', 'isSkipped', 'Skipped', 'skip'] as const;
function pick(record: Record<string, unknown>, keys: readonly string[]): unknown {
for (const key of keys) {
if (record[key] !== undefined && record[key] !== null) return record[key];
}
return undefined;
}
/** 把 ClassIsland 的时间写法统一成 HH:mm */
function normalizeTime(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
// 数字:优先按"秒"解释(ClassIsland 的 StartSecond),小于 100 时按"分钟"
const seconds = value > 100 ? value : value * 60;
return secondsToHHmm(seconds);
}
if (typeof value !== 'string') return null;
const text = value.trim();
if (!text) return null;
// HH:mm / H:mm / HH:mm:ss / H:mm:ss
const match = /^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/.exec(text);
if (match) {
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return null;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
// ISO 字符串(ClassIsland 某些版本导出 "2024-01-01T08:00:00")
const parsed = new Date(text);
if (!Number.isNaN(parsed.getTime())) {
return `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`;
}
return null;
}
function secondsToHHmm(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
const hour = Math.floor(total / 3600) % 24;
const minute = Math.floor((total % 3600) / 60);
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
/** 从任意形态里抠出"可能是时间表条目"的数组 */
function extractItems(input: unknown): { items: unknown[]; shape: string } {
if (Array.isArray(input)) return { items: input, shape: 'array' };
if (input && typeof input === 'object') {
const record = input as Record<string, unknown>;
for (const key of ['TimeLayouts', 'TimeLayoutItems', 'Items', 'items', 'Layouts', 'TimeLayout']) {
const value = record[key];
if (Array.isArray(value)) return { items: value, shape: key };
// 有些导出是 { TimeLayouts: { items: [...] } }
if (value && typeof value === 'object' && Array.isArray((value as Record<string, unknown>).items)) {
return { items: (value as Record<string, unknown>).items as unknown[], shape: `${key}.items` };
}
}
}
return { items: [], shape: 'unknown' };
}
function normalizeType(record: Record<string, unknown>): TimeLayoutItemType {
const raw = pick(record, TYPE_KEYS);
const numeric = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
if (numeric === 1) return 'break';
if (numeric === 2) return 'divider';
return 'class';
}
export function parseClassIslandTimeLayout(input: unknown): TimeLayoutParseResult {
let payload = input;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch (error) {
return { items: [], errors: [`JSON 解析失败:${(error as Error).message}`], warnings: [] };
}
}
const { items: rawItems, shape } = extractItems(payload);
const errors: string[] = [];
const warnings: string[] = [];
if (rawItems.length === 0) {
return {
items: [],
errors: [
'未识别到时间表条目:请确认是 ClassIsland 导出的课表时间 JSON(数组或含 TimeLayouts/TimeLayoutItems 字段)',
],
warnings: [],
};
}
if (shape === 'unknown') {
warnings.push('未识别外层结构,已尝试按数组解析');
}
const parsed: ParsedTimeLayoutItem[] = [];
rawItems.forEach((raw, index) => {
if (!raw || typeof raw !== 'object') {
errors.push(`第 ${index + 1} 条:不是对象,已跳过`);
return;
}
const record = raw as Record<string, unknown>;
const start =
normalizeTime(pick(record, TIME_KEYS)) ?? normalizeTime(pick(record, START_SECOND_KEYS)) ?? null;
const end = normalizeTime(pick(record, END_KEYS)) ?? normalizeTime(pick(record, END_SECOND_KEYS)) ?? null;
const skipped = pick(record, SKIP_KEYS) === true || pick(record, SKIP_KEYS) === 'true';
const type = normalizeType(record);
const nameRaw = pick(record, NAME_KEYS);
const name = typeof nameRaw === 'string' && nameRaw.trim() ? nameRaw.trim() : '';
if (!start || !end) {
if (type === 'divider') {
warnings.push(`第 ${index + 1} 条:分割线没有时间,已忽略`);
return;
}
errors.push(
`第 ${index + 1} 条(${name || '未命名'}):无法解析开始/结束时间` +
`(支持 StartTime: "8:0:0"、StartSecond: 28800 等写法)`,
);
return;
}
if (start >= end) {
errors.push(`第 ${index + 1} 条(${name || '未命名'}):结束时间 ${end} 不晚于开始时间 ${start}`);
return;
}
if (skipped) warnings.push(`第 ${index + 1} 条(${name || '未命名'}):原配置标记为跳过`);
parsed.push({
index: parsed.length + 1,
name: name || `第 ${parsed.length + 1} 节`,
startTime: start,
endTime: end,
type,
skipped,
});
});
// 排序 + 重叠检测(不阻断导入,仅提示)
parsed.sort((a, b) => a.startTime.localeCompare(b.startTime));
for (let index = 1; index < parsed.length; index += 1) {
const previous = parsed[index - 1];
const current = parsed[index];
if (
previous &&
current &&
previous.type === 'class' &&
current.type === 'class' &&
current.startTime < previous.endTime
) {
warnings.push(
`时间重叠:${previous.startTime}-${previous.endTime} 与 ${current.startTime}-${current.endTime}`,
);
}
}
return { items: parsed, errors, warnings };
}
export interface TimeLayoutDto {
id: string;
classId: string;
name: string;
source: string;
items: ParsedTimeLayoutItem[];
createdAt: string;
updatedAt: string;
}
function toDto(record: {
id: string;
classId: string;
name: string;
source: string;
items: string;
createdAt: Date;
updatedAt: Date;
}): TimeLayoutDto {
let items: ParsedTimeLayoutItem[] = [];
try {
items = JSON.parse(record.items) as ParsedTimeLayoutItem[];
} catch {
items = [];
}
return {
id: record.id,
classId: record.classId,
name: record.name,
source: record.source,
items,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
/** 预览:只解析不落库(失败时原配置不受影响) */
export function previewClassIslandTimeLayout(payload: unknown): TimeLayoutParseResult & { shape: string } {
return {
...parseClassIslandTimeLayout(payload),
shape: extractItems(typeof payload === 'string' ? safeJson(payload) : payload).shape,
};
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
export async function listTimeLayouts(user: TokenPayload, classId: string): Promise<TimeLayoutDto[]> {
await assertCanManageSchedule(user, classId);
const records = await prisma.timeLayout.findMany({ where: { classId }, orderBy: { updatedAt: 'desc' } });
return records.map(toDto);
}
/**
* 导入(写入):解析 → 有错误直接 400(不写库)→ 按 replace/merge 落库。
* 权限与"课表管理"一致:管理员或本班班主任。
*/
export async function importTimeLayout(
user: TokenPayload,
input: TimeLayoutImportInput,
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
await assertCanManageSchedule(user, input.classId);
const parsed = parseClassIslandTimeLayout(input.payload);
if (parsed.errors.length > 0) {
throw new ApiError(400, 'IMPORT_INVALID', '课表时间配置解析失败,已保持原有配置不变', {
errors: parsed.errors,
warnings: parsed.warnings,
});
}
if (parsed.items.length === 0) {
throw new ApiError(400, 'IMPORT_EMPTY', '没有解析到任何有效节次,已保持原有配置不变', {
warnings: parsed.warnings,
});
}
const name = input.name?.trim() || '默认时间表';
const existing = await prisma.timeLayout.findMany({
where: { classId: input.classId },
orderBy: { updatedAt: 'desc' },
});
const current = existing.find((item) => item.name === name) ?? existing[0] ?? null;
if (input.mode === 'merge' && current) {
let previousItems: ParsedTimeLayoutItem[] = [];
try {
previousItems = JSON.parse(current.items) as ParsedTimeLayoutItem[];
} catch {
previousItems = [];
}
// 以"开始时间"为槽位标识:
// - 导入项与旧配置的开始时间相同 → 视为同一节次被覆盖(计入 merged)
// - 开始时间不同 → 视为新增(计入 replaced),旧节次全部保留
// 注意:旧配置里可能存在开始时间相同的多条(例如分割线/跳过的节次),
// 只有被此次导入显式覆盖的那些才移除,避免合并时静默丢数据。
const incomingStarts = new Set(parsed.items.map((item) => item.startTime));
const previousStarts = new Set(previousItems.map((item) => item.startTime));
const merged = parsed.items.filter((item) => previousStarts.has(item.startTime)).length;
const replaced = parsed.items.length - merged;
const preserved = previousItems.filter((item) => !incomingStarts.has(item.startTime));
const combined = [...preserved, ...parsed.items].sort((a, b) => a.startTime.localeCompare(b.startTime));
const updated = await prisma.timeLayout.update({
where: { id: current.id },
data: {
items: JSON.stringify(combined.map((item, index) => ({ ...item, index: index + 1 }))),
source: 'classisland',
},
});
logger.info(
`课表时间配置合并导入:班级=${input.classId} 覆盖=${merged} 新增=${replaced} 共 ${combined.length} 节`,
);
return { layout: toDto(updated), mode: 'merge', warnings: parsed.warnings, replaced, merged };
}
// replace:同一班级同名配置整体替换;没有则新建
if (current) {
const updated = await prisma.timeLayout.update({
where: { id: current.id },
data: { items: JSON.stringify(parsed.items), name, source: 'classisland' },
});
logger.info(`课表时间配置覆盖导入:班级=${input.classId} 共 ${parsed.items.length} 节`);
return {
layout: toDto(updated),
mode: 'replace',
warnings: parsed.warnings,
replaced: parsed.items.length,
merged: 0,
};
}
const created = await prisma.timeLayout.create({
data: { classId: input.classId, name, source: 'classisland', items: JSON.stringify(parsed.items) },
});
logger.info(`课表时间配置新建:班级=${input.classId} 共 ${parsed.items.length} 节`);
return {
layout: toDto(created),
mode: 'replace',
warnings: parsed.warnings,
replaced: parsed.items.length,
merged: 0,
};
}
export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Promise<void> {
const record = await prisma.timeLayout.findUnique({ where: { id: layoutId } });
if (!record) throw ApiError.notFound('时间配置不存在');
await assertCanManageSchedule(user, record.classId);
await prisma.timeLayout.delete({ where: { id: layoutId } });
}
+2
View File
@@ -5,6 +5,7 @@ import { coursesModule } from './courses/courses.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js'; import { dashboardModule } from './dashboard/dashboard.module.js';
import { gradesModule } from './grades/grades.module.js'; import { gradesModule } from './grades/grades.module.js';
import { homeworksModule } from './homeworks/homeworks.module.js'; import { homeworksModule } from './homeworks/homeworks.module.js';
import { importsModule } from './imports/imports.module.js';
import { notificationsModule } from './notifications/notifications.module.js'; import { notificationsModule } from './notifications/notifications.module.js';
import { schedulesModule } from './schedules/schedules.module.js'; import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js'; import { studentsModule } from './students/students.module.js';
@@ -26,6 +27,7 @@ export const apiModules: ApiModule[] = [
homeworksModule, homeworksModule,
notificationsModule, notificationsModule,
callsModule, callsModule,
importsModule,
gradesModule, gradesModule,
studentsModule, studentsModule,
teachersModule, teachersModule,
+2
View File
@@ -21,6 +21,8 @@ export const API_PATHS = {
grades: '/grades', grades: '/grades',
students: '/students', students: '/students',
teachers: '/teachers', teachers: '/teachers',
/** 导入:模板下载 / 表格导入 / ClassIsland 课表时间配置 */
imports: '/imports',
dashboard: '/dashboard/summary', dashboard: '/dashboard/summary',
} as const; } as const;
+71
View File
@@ -458,6 +458,77 @@ export interface ServerToClientEvents {
connected: (payload: { userId: string; role: UserRole; rooms: string[] }) => void; connected: (payload: { userId: string; role: UserRole; rooms: string[] }) => void;
} }
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置) */
/** 表格导入预览:列名、前若干行、建议字段映射与校验问题 */
export interface TableImportPreview {
kind: 'grades' | 'students';
columns: string[];
rows: string[][];
totalRows: number;
suggestedMapping: Record<string, string>;
errors: string[];
warnings: string[];
templateCsv: string;
}
/** 导入时某一行的具体错误(row 为 Excel 视角的行号,含表头) */
export interface TableImportRowError {
row: number;
message: string;
}
/** 导入结果统计 */
export interface TableImportResult {
kind: 'grades' | 'students';
total: number;
inserted: number;
updated: number;
skipped: number;
failed: number;
errors: TableImportRowError[];
warnings: string[];
}
/** ClassIsland 时间配置条目 */
export interface TimeLayoutEntry {
index: number;
name: string;
startTime: string;
endTime: string;
type: 'class' | 'break' | 'divider';
skipped: boolean;
}
export interface TimeLayoutDto {
id: string;
classId: string;
name: string;
source: string;
items: TimeLayoutEntry[];
createdAt: string;
updatedAt: string;
}
/** ClassIsland JSON 解析结果(预览用:只解析不落库) */
export interface TimeLayoutParsePreview {
items: TimeLayoutEntry[];
errors: string[];
warnings: string[];
shape: string;
}
/** 课表时间配置导入结果 */
export interface TimeLayoutImportResult {
layout: TimeLayoutDto;
mode: 'replace' | 'merge';
warnings: string[];
/** merge 时:被此次导入覆盖的旧节次数 */
merged: number;
/** merge 时:新增节次数;replace 时为总节次数 */
replaced: number;
}
/** 客户端 -> 服务端事件名 */ /** 客户端 -> 服务端事件名 */
export interface ClientToServerEvents { export interface ClientToServerEvents {
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void; 'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void;
+56
View File
@@ -21,6 +21,11 @@ import {
type ScheduleDto, type ScheduleDto,
type ScheduleWeekView, type ScheduleWeekView,
type StudentDto, type StudentDto,
type TableImportPreview,
type TableImportResult,
type TimeLayoutDto,
type TimeLayoutImportResult,
type TimeLayoutParsePreview,
type UpdateClassRequest, type UpdateClassRequest,
type UserDto, type UserDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -192,3 +197,54 @@ export const teacherApi = {
/** 班级详情中的 teachers 字段即为已分配的协作教师 */ /** 班级详情中的 teachers 字段即为已分配的协作教师 */
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId), fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
}; };
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置) */
export const importApi = {
/** 模板下载地址(CSV 走 JSON,XLSX 走二进制) */
template: (
kind: 'grades' | 'students',
format: 'csv' | 'xlsx' = 'csv',
): Promise<{ kind: string; format: string; fileName: string; content: string }> =>
api.get(`${API_PATHS.imports}/template`, { kind, format }),
/** 上传表格并预览(解析 + 必填列校验 + 建议映射,不写库) */
previewTable: (payload: {
kind: 'grades' | 'students';
fileName: string;
contentBase64: string;
}): Promise<TableImportPreview> => api.post(`${API_PATHS.imports}/table/preview`, payload),
/** 确认字段映射与写入模式后执行导入 */
commitTable: (payload: {
kind: 'grades' | 'students';
classId: string;
fileName: string;
contentBase64: string;
mapping: Record<string, string>;
mode: 'append' | 'upsert';
}): Promise<TableImportResult> => api.post(`${API_PATHS.imports}/table/commit`, payload),
/** ClassIsland 时间配置解析预览(不写库) */
previewTimeLayout: (payload: {
classId: string;
name?: string;
mode?: 'replace' | 'merge';
payload: unknown;
}): Promise<TimeLayoutParsePreview> => api.post(`${API_PATHS.imports}/time-layout/preview`, payload),
/** 某班已导入的时间配置 */
listTimeLayouts: (classId: string): Promise<TimeLayoutDto[]> =>
api.get(`${API_PATHS.imports}/time-layout`, { classId }),
/** 导入(覆盖或合并;解析失败返回 400 且不改动原配置) */
importTimeLayout: (payload: {
classId: string;
name?: string;
mode: 'replace' | 'merge';
payload: unknown;
}): Promise<TimeLayoutImportResult> => api.post(`${API_PATHS.imports}/time-layout`, payload),
removeTimeLayout: (id: string): Promise<{ id: string }> =>
api.delete(`${API_PATHS.imports}/time-layout/${id}`),
};
@@ -0,0 +1,407 @@
<script setup lang="ts">
/**
* 表格导入弹窗(成绩 / 学生名单通用)。
*
* 流程:下载模板 → 选择文件(xlsx/xls/csv)→ 上传预览(列名 + 前 20 行 + 校验问题 + 建议映射)
* → 确认字段映射与写入模式 → 提交 → 展示结果统计与错误行(行号 + 原因)。
*
* 设计要点:
* - 解析、校验、去重全部由后端完成,前端只负责收集与展示,避免前后端规则漂移;
* - 预览失败(缺必填列 / 空文件 / 格式错误)会直接显示后端给出的原因;
* - 错误行保留行号,方便老师在 Excel 里定位修改后重新导入。
*/
import { computed, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import {
API_PATHS,
STORAGE_KEYS,
type TableImportPreview,
type TableImportResult,
} from '@classhelper/shared';
import { API_BASE_URL } from '@/config';
import { importApi } from '@/api';
type Kind = 'grades' | 'students';
interface FieldDef {
key: string;
label: string;
required: boolean;
}
/** 与后端 IMPORT_FIELDS 保持一致(仅用于展示标签与必填提示) */
const FIELDS: Record<Kind, FieldDef[]> = {
grades: [
{ key: 'username', label: '学生用户名', required: false },
{ key: 'name', label: '学生姓名', required: false },
{ key: 'examName', label: '考试名称', required: true },
{ key: 'score', label: '分数', required: true },
{ key: 'totalScore', label: '总分', required: false },
{ key: 'courseName', label: '课程', required: false },
],
students: [
{ key: 'username', label: '用户名', required: true },
{ key: 'name', label: '姓名', required: true },
{ key: 'password', label: '初始密码', required: false },
],
};
const props = defineProps<{
modelValue: boolean;
kind: Kind;
classId: string;
}>();
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void;
(event: 'imported', result: TableImportResult): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const loading = ref(false);
const committing = ref(false);
const fileName = ref('');
const contentBase64 = ref('');
const preview = ref<TableImportPreview | null>(null);
const mapping = ref<Record<string, string>>({});
const mode = ref<'append' | 'upsert'>('upsert');
const result = ref<TableImportResult | null>(null);
const fileInputRef = ref<HTMLInputElement>();
const fields = computed(() => FIELDS[props.kind]);
const title = computed(() => (props.kind === 'grades' ? '导入成绩表格' : '导入学生名单'));
watch(visible, (value) => {
if (value) reset();
});
function reset(): void {
preview.value = null;
result.value = null;
fileName.value = '';
contentBase64.value = '';
mapping.value = {};
mode.value = 'upsert';
if (fileInputRef.value) fileInputRef.value.value = '';
}
function close(): void {
visible.value = false;
}
/** 模板下载:带上 JWT 直接取二进制,避免暴露匿名下载入口 */
async function downloadTemplate(format: 'csv' | 'xlsx'): Promise<void> {
try {
const token = localStorage.getItem(STORAGE_KEYS.token) ?? '';
const response = await fetch(
`${API_BASE_URL}${API_PATHS.imports}/template?kind=${props.kind}&format=${format}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
);
if (!response.ok) {
ElMessage.error(`模板下载失败(${response.status})`);
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `template-${props.kind}.${format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
ElMessage.success('模板已开始下载');
} catch (error) {
ElMessage.error(`模板下载失败:${(error as Error).message}`);
}
}
function pickFile(): void {
fileInputRef.value?.click();
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const allowed = /\.(xlsx|xls|csv|tsv)$/i.test(file.name);
if (!allowed) {
ElMessage.warning('只支持 .xlsx / .xls / .csv 文件');
input.value = '';
return;
}
if (file.size > 8 * 1024 * 1024) {
ElMessage.warning('文件超过 8MB,请拆分后分批导入');
input.value = '';
return;
}
loading.value = true;
result.value = null;
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
const base64 = btoa(binary);
fileName.value = file.name;
contentBase64.value = base64;
const data = await importApi.previewTable({
kind: props.kind,
fileName: file.name,
contentBase64: base64,
});
preview.value = data;
mapping.value = { ...data.suggestedMapping };
if ((data.errors ?? []).length > 0) {
ElMessage.warning('表格已解析,但存在需要处理的问题(见下方提示)');
} else {
ElMessage.success(`解析成功:共 ${data.totalRows} 行数据`);
}
} catch {
preview.value = null;
contentBase64.value = '';
} finally {
loading.value = false;
}
}
const mappedRequired = computed(() =>
fields.value.filter((field) => field.required).every((field) => Boolean(mapping.value[field.key])),
);
async function submit(): Promise<void> {
if (!props.classId) {
ElMessage.warning('请先在页面上选择班级');
return;
}
if (!contentBase64.value) {
ElMessage.warning('请先选择要导入的文件');
return;
}
if (!mappedRequired.value) {
ElMessage.warning('请先完成必填字段的列映射');
return;
}
committing.value = true;
try {
const data = await importApi.commitTable({
kind: props.kind,
classId: props.classId,
fileName: fileName.value,
contentBase64: contentBase64.value,
mapping: mapping.value,
mode: mode.value,
});
result.value = data;
ElMessage.success(
`导入完成:新增 ${data.inserted} / 更新 ${data.updated} / 跳过 ${data.skipped} / 失败 ${data.failed}`,
);
emit('imported', data);
} finally {
committing.value = false;
}
}
</script>
<template>
<el-dialog v-model="visible" :title="title" width="760px" class="import-dialog" top="6vh">
<el-alert
type="info"
:closable="false"
show-icon
title="支持 .xlsx / .xls / .csv;先下载模板填写可以避免列名不符"
class="mb-12"
/>
<div class="import-toolbar">
<el-button size="small" @click="downloadTemplate('xlsx')">下载模板(Excel)</el-button>
<el-button size="small" @click="downloadTemplate('csv')">下载模板(CSV)</el-button>
<el-button size="small" type="primary" :loading="loading" @click="pickFile">
{{ fileName ? '重新选择文件' : '选择文件' }}
</el-button>
<input
ref="fileInputRef"
type="file"
accept=".xlsx,.xls,.csv,.tsv"
class="hidden-input"
@change="onFileChange"
/>
<span v-if="fileName" class="file-name">{{ fileName }}</span>
</div>
<template v-if="preview">
<div class="import-meta">
<span>解析行数:{{ preview.totalRows }}</span>
<span v-if="preview.columns.length">列:{{ preview.columns.join(' / ') }}</span>
</div>
<el-alert
v-for="(item, index) in preview.errors"
:key="`e-${index}`"
type="error"
:closable="false"
show-icon
:title="item"
class="mb-8"
/>
<el-alert
v-for="(item, index) in preview.warnings"
:key="`w-${index}`"
type="warning"
:closable="false"
show-icon
:title="item"
class="mb-8"
/>
<el-card shadow="never" class="mt-8">
<template #header><span>字段映射(规范字段 → 表格列)</span></template>
<el-form label-width="110px" size="small">
<el-form-item v-for="field in fields" :key="field.key" :label="field.label">
<el-select v-model="mapping[field.key]" clearable placeholder="不导入该字段" style="width: 260px">
<el-option v-for="column in preview.columns" :key="column" :label="column" :value="column" />
</el-select>
<span v-if="field.required" class="required-hint">必填</span>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="mt-8">
<template #header>
<span>数据预览(前 {{ preview.rows.length }} 行)</span>
</template>
<el-table :data="preview.rows" size="small" max-height="220">
<el-table-column type="index" label="#" width="50" />
<el-table-column
v-for="(column, index) in preview.columns"
:key="column"
:label="column"
min-width="110"
>
<template #default="{ row }">{{ row[index] }}</template>
</el-table-column>
</el-table>
</el-card>
<el-form label-width="110px" size="small" class="mt-12">
<el-form-item label="重复数据处理">
<el-radio-group v-model="mode">
<el-radio value="upsert">已存在则更新</el-radio>
<el-radio value="append">已存在则跳过</el-radio>
</el-radio-group>
<div class="mode-hint">
{{ kind === 'grades' ? '成绩重复判定:同一学生 + 考试 + 课程' : '名单重复判定:同一用户名' }}
</div>
</el-form-item>
</el-form>
<el-card v-if="result" shadow="never" class="mt-8 result-card">
<template #header><span>导入结果</span></template>
<div class="result-stats">
<span>总行数:{{ result.total }}</span>
<span class="ok">新增:{{ result.inserted }}</span>
<span class="warn">更新:{{ result.updated }}</span>
<span>跳过:{{ result.skipped }}</span>
<span class="bad">失败:{{ result.failed }}</span>
</div>
<el-table
v-if="result.errors.length"
:data="result.errors"
size="small"
max-height="180"
class="mt-8"
>
<el-table-column prop="row" label="行号" width="80" />
<el-table-column prop="message" label="原因" />
</el-table>
<el-alert
v-if="result.errors.length"
type="warning"
:closable="false"
show-icon
title="失败的行已跳过,其余数据已成功导入;修改后可用「已存在则更新」重新导入"
class="mt-8"
/>
</el-card>
</template>
<el-empty v-else-if="!loading" description="尚未选择文件" :image-size="70" />
<template #footer>
<el-button @click="close">关闭</el-button>
<el-button type="primary" :disabled="!preview || !contentBase64" :loading="committing" @click="submit">
开始导入
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.import-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.file-name {
color: #606266;
font-size: 13px;
}
.hidden-input {
display: none;
}
.import-meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin: 12px 0 4px;
color: #606266;
font-size: 13px;
}
.required-hint {
margin-left: 8px;
color: #f56c6c;
font-size: 12px;
}
.mode-hint {
width: 100%;
color: #909399;
font-size: 12px;
}
.result-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 13px;
}
.result-stats .ok {
color: #67c23a;
}
.result-stats .warn {
color: #e6a23c;
}
.result-stats .bad {
color: #f56c6c;
}
.mb-8 {
margin-bottom: 8px;
}
.mb-12 {
margin-bottom: 12px;
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
</style>
@@ -0,0 +1,311 @@
<script setup lang="ts">
/**
* ClassIsland 课表时间配置导入弹窗。
*
* 流程:粘贴 JSON / 选择 .json 文件 → 预览(后端容错解析,秒与 HH:mm 两种写法都支持)
* → 查看节次列表与校验结果 → 选择覆盖(replace) / 合并(merge) → 导入。
*
* 安全与一致性:
* - 解析失败(JSON 语法错误、时间无法识别、结束早于开始)后端返回 400,
* 教务端会提示"原有配置保持不变",不会出现"导入一半"的脏状态;
* - 权限与课表管理一致(管理员或本班班主任),科任老师看不到入口,后端亦会二次校验。
*/
import { computed, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate, type TimeLayoutDto, type TimeLayoutParsePreview } from '@classhelper/shared';
import { importApi } from '@/api';
const props = defineProps<{
modelValue: boolean;
classId: string;
className?: string;
}>();
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void;
(event: 'imported'): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const LAYOUT_SAMPLE = `{
"TimeLayouts": [
{ "Name": "第 1 节", "StartTime": "8:0:0", "EndTime": "8:45:0", "TimeType": 0 },
{ "Name": "课间休息", "StartTime": "8:45:0", "EndTime": "8:55:0", "TimeType": 1 }
]
}`;
/** 输入框提示(用常量避免模板里的引号破坏 lint 规则) */
const TEXTAREA_PLACEHOLDER =
'粘贴 ClassIsland 导出的 JSON,例如 {"TimeLayouts":[{"Name":"第 1 节","StartTime":"8:0:0","EndTime":"8:45:0"}]}';
const jsonText = ref('');
const layoutName = ref('默认时间表');
const mode = ref<'replace' | 'merge'>('replace');
const preview = ref<TimeLayoutParsePreview | null>(null);
const existing = ref<TimeLayoutDto[]>([]);
const parsing = ref(false);
const importing = ref(false);
const fileInputRef = ref<HTMLInputElement>();
watch(visible, async (value) => {
if (value) {
jsonText.value = '';
preview.value = null;
layoutName.value = '默认时间表';
mode.value = 'replace';
await loadExisting();
}
});
async function loadExisting(): Promise<void> {
if (!props.classId) {
existing.value = [];
return;
}
try {
existing.value = await importApi.listTimeLayouts(props.classId);
} catch {
existing.value = [];
}
}
function pickFile(): void {
fileInputRef.value?.click();
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!/\.json$/i.test(file.name)) {
ElMessage.warning('请选择 ClassIsland 导出的 .json 文件');
input.value = '';
return;
}
jsonText.value = await file.text();
preview.value = null;
ElMessage.success(`已读取 ${file.name}(${jsonText.value.length} 字符),请点击「解析预览」`);
}
/** 解析预览:把 JSON 文本或对象交给后端容错解析 */
async function doPreview(): Promise<void> {
if (!jsonText.value.trim()) {
ElMessage.warning('请先粘贴 JSON 或选择 .json 文件');
return;
}
if (!props.classId) {
ElMessage.warning('请先在页面上选择班级');
return;
}
parsing.value = true;
try {
const payload = parseJsonOrText(jsonText.value);
preview.value = await importApi.previewTimeLayout({
classId: props.classId,
name: layoutName.value,
mode: mode.value,
payload,
});
if (preview.value.errors.length > 0) {
ElMessage.warning(`解析发现 ${preview.value.errors.length} 个问题,请修正后再导入`);
} else {
ElMessage.success(`解析成功:共 ${preview.value.items.length} 节`);
}
} catch {
preview.value = null;
} finally {
parsing.value = false;
}
}
/** JSON 语法错误由后端统一报错,这里保留原文让后端给出精确原因 */
function parseJsonOrText(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function doImport(): Promise<void> {
if (!preview.value || preview.value.items.length === 0) {
ElMessage.warning('请先解析预览并确认节次');
return;
}
if (preview.value.errors.length > 0) {
ElMessage.warning('存在解析错误,已阻止导入(原配置不会被修改)');
return;
}
importing.value = true;
try {
const result = await importApi.importTimeLayout({
classId: props.classId,
name: layoutName.value,
mode: mode.value,
payload: parseJsonOrText(jsonText.value),
});
ElMessage.success(
mode.value === 'merge'
? `合并导入成功:覆盖 ${result.merged} 节 / 新增 ${result.replaced} 节(共 ${result.layout.items.length} 节)`
: `覆盖导入成功:共 ${result.layout.items.length} 节`,
);
await loadExisting();
emit('imported');
} finally {
importing.value = false;
}
}
/** 删除已导入的时间配置(同一班级可保留多份,按名称区分) */
async function removeLayout(item: TimeLayoutDto): Promise<void> {
await ElMessageBox.confirm(`删除「${item.name}」(${item.items.length} 节)?`, '确认', {
type: 'warning',
});
await importApi.removeTimeLayout(item.id);
ElMessage.success('已删除');
await loadExisting();
}
function fillSample(): void {
jsonText.value = LAYOUT_SAMPLE;
preview.value = null;
}
</script>
<template>
<el-dialog v-model="visible" title="导入 ClassIsland 课表时间配置" width="820px" top="5vh">
<el-alert type="info" :closable="false" show-icon class="mb-12">
<template #title>
支持 ClassIsland 导出的时间表 JSON(数组,或含 TimeLayouts / TimeLayoutItems 字段的对象); 时间可写成
HH:mm:ss,也可用 StartSecond 秒数。解析失败不会修改原有配置。
</template>
</el-alert>
<el-form label-width="110px" size="small">
<el-form-item label="目标班级">
<el-input :model-value="className || classId" disabled style="width: 260px" />
</el-form-item>
<el-form-item label="配置名称">
<el-input v-model="layoutName" placeholder="默认时间表" style="width: 260px" />
</el-form-item>
<el-form-item label="导入方式">
<el-radio-group v-model="mode">
<el-radio value="replace">覆盖:整体替换现有时间表</el-radio>
<el-radio value="merge">合并:按开始时间覆盖同节次并保留其它节次</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<div class="toolbar mb-8">
<el-button size="small" :loading="parsing" type="primary" @click="doPreview">解析预览</el-button>
<el-button size="small" @click="pickFile">选择 .json 文件</el-button>
<el-button size="small" @click="fillSample">填入示例</el-button>
<input ref="fileInputRef" type="file" accept=".json" class="hidden-input" @change="onFileChange" />
</div>
<el-input v-model="jsonText" type="textarea" :rows="7" :placeholder="TEXTAREA_PLACEHOLDER" />
<template v-if="preview">
<div class="preview-meta">
<span>识别结构:{{ preview.shape }}</span>
<span>解析节次:{{ preview.items.length }}</span>
</div>
<el-alert
v-for="(item, index) in preview.errors"
:key="`e-${index}`"
type="error"
:closable="false"
show-icon
:title="item"
class="mb-8"
/>
<el-alert
v-for="(item, index) in preview.warnings"
:key="`w-${index}`"
type="warning"
:closable="false"
show-icon
:title="item"
class="mb-8"
/>
<el-table v-if="preview.items.length" :data="preview.items" size="small" max-height="240" class="mt-8">
<el-table-column prop="index" label="#" width="50" />
<el-table-column prop="name" label="节次" min-width="140" />
<el-table-column prop="startTime" label="开始" width="90" />
<el-table-column prop="endTime" label="结束" width="90" />
<el-table-column label="类型" width="100">
<template #default="{ row }">
{{ row.type === 'break' ? '课间' : row.type === 'divider' ? '分割线' : '上课' }}
</template>
</el-table-column>
<el-table-column label="跳过" width="80">
<template #default="{ row }">{{ row.skipped ? '是' : '否' }}</template>
</el-table-column>
</el-table>
</template>
<el-card v-if="existing.length" shadow="never" class="mt-12">
<template #header><span>该班级已导入的时间配置</span></template>
<el-table :data="existing" size="small">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="节次" width="80">
<template #default="{ row }">{{ row.items.length }}</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="120" />
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatDate(row.updatedAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removeLayout(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button
type="primary"
:disabled="!preview || preview.items.length === 0 || preview.errors.length > 0"
:loading="importing"
@click="doImport"
>
确认导入
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.hidden-input {
display: none;
}
.preview-meta {
display: flex;
gap: 16px;
margin-top: 10px;
color: #606266;
font-size: 13px;
}
.mb-8 {
margin-bottom: 8px;
}
.mb-12 {
margin-bottom: 12px;
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
</style>
@@ -14,6 +14,7 @@ import {
type StudentDto, type StudentDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { classApi, courseApi, gradeApi } from '@/api'; import { classApi, courseApi, gradeApi } from '@/api';
import TableImportDialog from '@/components/TableImportDialog.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime'; import { useRealtimeStore } from '@/stores/realtime';
@@ -214,6 +215,22 @@ async function submitBulk(): Promise<void> {
await loadGrades(); await loadGrades();
} }
/* ------------------------------------------------------------ 表格导入 */
const importVisible = ref(false);
function openImport(): void {
if (!filter.classId) {
ElMessage.warning('请先选择班级');
return;
}
importVisible.value = true;
}
async function onImported(): Promise<void> {
await loadGrades();
}
async function removeGrade(row: GradeDto): Promise<void> { async function removeGrade(row: GradeDto): Promise<void> {
await ElMessageBox.confirm(`删除 ${row.student?.name ?? ''} 的「${row.examName}」成绩?`, '确认', { await ElMessageBox.confirm(`删除 ${row.student?.name ?? ''} 的「${row.examName}」成绩?`, '确认', {
type: 'warning', type: 'warning',
@@ -280,6 +297,7 @@ onUnmounted(() => {
</el-select> </el-select>
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button> <el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button>
<el-button v-if="isAdmin" type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button> <el-button v-if="isAdmin" type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button>
<el-button v-if="isAdmin" type="warning" :icon="'Document'" @click="openImport">导入表格</el-button>
</div> </div>
</div> </div>
@@ -415,6 +433,14 @@ onUnmounted(() => {
<el-button type="primary" @click="submitBulk">提交并广播</el-button> <el-button type="primary" @click="submitBulk">提交并广播</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 表格导入(xlsx/xls/csv:模板下载 + 预览 + 字段映射 + 结果统计) -->
<TableImportDialog
v-model="importVisible"
kind="grades"
:class-id="filter.classId"
@imported="onImported"
/>
</div> </div>
</template> </template>
@@ -14,6 +14,7 @@ import {
type ScheduleWeekView, type ScheduleWeekView,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api'; import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
import TimeLayoutImportDialog from '@/components/TimeLayoutImportDialog.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime'; import { useRealtimeStore } from '@/stores/realtime';
@@ -194,6 +195,18 @@ function onScheduleEvent(): void {
void loadSchedules(); void loadSchedules();
} }
/* ------------------------------------------------------------ ClassIsland 时间配置导入 */
const timeLayoutVisible = ref(false);
function openTimeLayoutImport(): void {
if (!query.classId) {
ElMessage.warning('请先选择班级');
return;
}
timeLayoutVisible.value = true;
}
onMounted(async () => { onMounted(async () => {
await loadBase(); await loadBase();
await loadSchedules(); await loadSchedules();
@@ -228,6 +241,9 @@ onUnmounted(() => {
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate"> <el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
新增课表 新增课表
</el-button> </el-button>
<el-button v-if="canManageSchedule" type="warning" :icon="'Upload'" @click="openTimeLayoutImport">
导入时间配置
</el-button>
</div> </div>
</div> </div>
@@ -313,5 +329,12 @@ onUnmounted(() => {
<el-button type="primary" @click="submitForm">保存</el-button> <el-button type="primary" @click="submitForm">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- ClassIsland 时间配置导入(覆盖 / 合并,失败不改动原配置) -->
<TimeLayoutImportDialog
v-model="timeLayoutVisible"
:class-id="query.classId"
:class-name="classes.find((item) => item.id === query.classId)?.name"
/>
</div> </div>
</template> </template>
+21 -1
View File
@@ -1,4 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { import {
@@ -9,6 +9,7 @@ import {
type StudentDto, type StudentDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { callApi, classApi, studentApi } from '@/api'; import { callApi, classApi, studentApi } from '@/api';
import TableImportDialog from '@/components/TableImportDialog.vue';
const loading = ref(false); const loading = ref(false);
const students = ref<StudentDto[]>([]); const students = ref<StudentDto[]>([]);
@@ -33,6 +34,16 @@ async function loadStudents(): Promise<void> {
/* ------------------------------------------------------------ 新建 / 编辑 */ /* ------------------------------------------------------------ 新建 / 编辑 */
/** 名单导入(表格):需要先选定班级,避免导入到错误的班级 */
const importVisible = ref(false);
function openImport(): void {
if (!filter.classId) {
ElMessage.warning('请先选择要导入的班级');
return;
}
importVisible.value = true;
}
const formVisible = ref(false); const formVisible = ref(false);
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null); const editingId = ref<string | null>(null);
@@ -180,6 +191,7 @@ async function submitCall(): Promise<void> {
/> />
<el-button :icon="'Search'" @click="loadStudents">查询</el-button> <el-button :icon="'Search'" @click="loadStudents">查询</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button> <el-button type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
<el-button type="warning" :icon="'Document'" @click="openImport">导入名单</el-button>
</div> </div>
</div> </div>
@@ -284,6 +296,14 @@ async function submitCall(): Promise<void> {
<el-button type="primary" @click="submitForm">保存</el-button> <el-button type="primary" @click="submitForm">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 学生名单导入(xlsx/xls/csv,仅管理员可见;后端同样强校验权限) -->
<TableImportDialog
v-model="importVisible"
kind="students"
:class-id="filter.classId"
@imported="loadStudents"
/>
</div> </div>
</template> </template>
+72
View File
@@ -126,6 +126,9 @@ importers:
socket.io: socket.io:
specifier: ^4.8.3 specifier: ^4.8.3
version: 4.8.3 version: 4.8.3
xlsx:
specifier: 0.18.5
version: 0.18.5
zod: zod:
specifier: ^4.6.2 specifier: ^4.6.2
version: 4.6.2 version: 4.6.2
@@ -1213,6 +1216,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agent-base@6.0.2: agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
@@ -1395,6 +1402,10 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1436,6 +1447,10 @@ packages:
clone-response@1.0.3: clone-response@1.0.3:
resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -1511,6 +1526,11 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
cross-dirname@0.1.0: cross-dirname@0.1.0:
resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==}
@@ -1956,6 +1976,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fresh@2.0.0: fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2981,6 +3005,10 @@ packages:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stat-mode@1.0.0: stat-mode@1.0.0:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3298,10 +3326,18 @@ packages:
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^20.17.0 || >=22.9.0}
hasBin: true hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3325,6 +3361,11 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@5.0.0: xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4446,6 +4487,8 @@ snapshots:
acorn@8.18.0: {} acorn@8.18.0: {}
adler-32@1.3.1: {}
agent-base@6.0.2: agent-base@6.0.2:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -4702,6 +4745,11 @@ snapshots:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -4739,6 +4787,8 @@ snapshots:
dependencies: dependencies:
mimic-response: 1.0.1 mimic-response: 1.0.1
codepage@1.15.0: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -4807,6 +4857,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
vary: 1.1.2 vary: 1.1.2
crc-32@1.2.2: {}
cross-dirname@0.1.0: cross-dirname@0.1.0:
optional: true optional: true
@@ -5364,6 +5416,8 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
frac@1.1.2: {}
fresh@2.0.0: {} fresh@2.0.0: {}
fs-extra@10.1.0: fs-extra@10.1.0:
@@ -6402,6 +6456,10 @@ snapshots:
sqlstring@2.3.3: {} sqlstring@2.3.3: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
stat-mode@1.0.0: {} stat-mode@1.0.0: {}
statuses@2.0.2: {} statuses@2.0.2: {}
@@ -6680,8 +6738,12 @@ snapshots:
dependencies: dependencies:
isexe: 4.0.0 isexe: 4.0.0
wmf@1.0.2: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
word@0.3.0: {}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -6698,6 +6760,16 @@ snapshots:
ws@8.21.3: {} ws@8.21.3: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-name-validator@5.0.0: {} xml-name-validator@5.0.0: {}
xmlbuilder@15.1.1: {} xmlbuilder@15.1.1: {}
+71
View File
@@ -412,6 +412,77 @@ async function main() {
return true; return true;
})()`); })()`);
await sleep(300); await sleep(300);
// 7.7 ClassIsland 时间配置导入弹窗(班主任可见:粘贴 JSON → 解析预览 → 确认导入)
const layoutDialog = await win.webContents.executeJavaScript(`(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
(node.textContent ?? '').trim().startsWith('课表管理'),
);
if (!menu) return { ok: false, reason: '未找到课表管理菜单' };
menu.click();
const deadline = Date.now() + 6000;
while (Date.now() < deadline && location.pathname !== '/schedules') await sleep(80);
await sleep(600);
const openButton = Array.from(document.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '导入时间配置',
);
if (!openButton) return { ok: false, reason: '课表页没有"导入时间配置"按钮(班主任权限未生效?)' };
openButton.click();
await sleep(700);
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('ClassIsland'),
);
if (!dialog) return { ok: false, reason: '时间配置导入弹窗未打开' };
const sampleButton = Array.from(dialog.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '填入示例',
);
if (!sampleButton) return { ok: false, reason: '缺少"填入示例"按钮' };
sampleButton.click();
await sleep(200);
const previewButton = Array.from(dialog.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '解析预览',
);
if (!previewButton) return { ok: false, reason: '缺少"解析预览"按钮' };
previewButton.click();
const previewDeadline = Date.now() + 8000;
let rows = 0;
let importEnabled = false;
let errors = 0;
while (Date.now() < previewDeadline) {
rows = dialog.querySelectorAll('.el-table__body tbody tr').length;
errors = dialog.querySelectorAll('.el-alert--error').length;
const confirm = Array.from(dialog.querySelectorAll('.el-dialog__footer button')).find((node) =>
(node.textContent ?? '').trim() === '确认导入',
);
importEnabled = Boolean(confirm) && !confirm.disabled;
if (rows >= 2) break;
await sleep(150);
}
return { ok: true, rows, importEnabled, errors, hasFileInput: Boolean(dialog.querySelector('input[type=file]')) };
})()`);
record(
'ClassIsland 时间配置导入弹窗(示例 → 解析预览 → 可导入)',
Boolean(layoutDialog?.ok) &&
(layoutDialog?.rows ?? 0) >= 2 &&
layoutDialog?.importEnabled === true &&
layoutDialog?.errors === 0,
`节次行=${layoutDialog?.rows ?? 0} 可导入=${layoutDialog?.importEnabled} 校验错误=${layoutDialog?.errors ?? '-'} 文件选择=${layoutDialog?.hasFileInput}` +
`${layoutDialog?.reason ? ` 原因=${layoutDialog.reason}` : ''}`,
);
await win.webContents.executeJavaScript(`(() => {
const buttons = Array.from(document.querySelectorAll('.el-dialog__footer button'));
const close = buttons.find((node) => (node.textContent ?? '').trim() === '关闭');
if (close) close.click();
return true;
})()`);
await sleep(300);
// 8. 手机小屏适配(390×844,1Panel 风格:侧边栏收进抽屉 + 卡片内横向滚动) // 8. 手机小屏适配(390×844,1Panel 风格:侧边栏收进抽屉 + 卡片内横向滚动)
await runMobileChecks(win); await runMobileChecks(win);