From 8b85079d81abcb7201b937cf5e6441e65943750e Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 12 Sep 2026 15:35:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(imports):=20=E6=88=90=E7=BB=A9/=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E8=A1=A8=E6=A0=BC=E5=AF=BC=E5=85=A5=20+=20ClassIsland?= =?UTF-8?q?=20=E6=97=B6=E9=97=B4=E9=85=8D=E7=BD=AE=E5=AF=BC=E5=85=A5=20+?= =?UTF-8?q?=20=E5=AE=89=E8=A3=85=E7=89=88=E5=A2=9E=E9=87=8F=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端: - 新增 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 表与验收对照更新 --- README.md | 265 ++++++++---- packages/server/package.json | 1 + .../migration.sql | 14 + packages/server/prisma/schema.prisma | 19 + packages/server/scripts/verify-e2e.mjs | 387 +++++++++++++++++ packages/server/src/lib/db-bootstrap.ts | 151 ++++++- packages/server/src/lib/http.ts | 6 + packages/server/src/middleware/validate.ts | 6 + .../src/modules/imports/imports.module.ts | 113 +++++ .../src/modules/imports/imports.schemas.ts | 51 +++ .../modules/imports/table-import.service.ts | 409 ++++++++++++++++++ .../modules/imports/time-layout.service.ts | 358 +++++++++++++++ packages/server/src/modules/registry.ts | 2 + packages/shared/src/constants.ts | 2 + packages/shared/src/types.ts | 71 +++ packages/web-admin/src/api/index.ts | 56 +++ .../src/components/TableImportDialog.vue | 407 +++++++++++++++++ .../src/components/TimeLayoutImportDialog.vue | 311 +++++++++++++ packages/web-admin/src/views/GradesView.vue | 26 ++ .../web-admin/src/views/SchedulesView.vue | 23 + packages/web-admin/src/views/StudentsView.vue | 22 +- pnpm-lock.yaml | 72 +++ scripts/ui-smoke/main.cjs | 71 +++ 23 files changed, 2735 insertions(+), 108 deletions(-) create mode 100644 packages/server/prisma/migrations/20260912120000_add_time_layout/migration.sql create mode 100644 packages/server/src/modules/imports/imports.module.ts create mode 100644 packages/server/src/modules/imports/imports.schemas.ts create mode 100644 packages/server/src/modules/imports/table-import.service.ts create mode 100644 packages/server/src/modules/imports/time-layout.service.ts create mode 100644 packages/web-admin/src/components/TableImportDialog.vue create mode 100644 packages/web-admin/src/components/TimeLayoutImportDialog.vue diff --git a/README.md b/README.md index f300a02..f4b97b7 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ class-helper/ │ ├── prisma/migrations/ # 迁移历史(已生成并应用) │ ├── prisma/seed.ts # 种子数据 │ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串) - │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(62 项) + │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(107 项) │ └── src/ │ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载) │ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出) @@ -171,12 +171,12 @@ pnpm verify:desktop ```bash # 另开一个终端先启动后端:pnpm dev:server -pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:82 项 -pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知):17 项 +pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入:107 项 +pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 导入弹窗):18 项 pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项 ``` -后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, +后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚**等 107 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 (登录 + 四类数据 + Socket.IO),实测 26/26 通过。 @@ -202,30 +202,30 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 ## 常用命令 -| 命令 | 说明 | -| ------------------------------------------- | ------------------------------------------------------------------------- | -| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | -| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | -| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | -| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | -| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | -| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | -| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | -| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | -| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | -| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | -| `pnpm verify:e2e` | 后端端到端验收(82 项,含角色权限矩阵,需服务端已启动) | -| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,17 项,含权限入口隐藏) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(34 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) | -| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | -| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | -| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | -| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | -| `pnpm db:migrate` | 创建并应用迁移 | -| `pnpm db:seed` | 写入种子数据 | -| `pnpm db:reset` | 重置数据库并重新执行 seed | -| `pnpm db:studio` | 打开 Prisma Studio | -| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | +| 命令 | 说明 | +| ------------------------------------------- | ---------------------------------------------------------------------------- | +| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | +| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | +| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | +| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | +| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | +| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | +| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | +| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | +| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | +| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | +| `pnpm verify:e2e` | 后端端到端验收(107 项,含角色权限矩阵与导入,需服务端已启动) | +| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,18 项,含权限入口隐藏与导入弹窗) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(40 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) | +| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | +| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | +| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | +| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | +| `pnpm db:migrate` | 创建并应用迁移 | +| `pnpm db:seed` | 写入种子数据 | +| `pnpm db:reset` | 重置数据库并重新执行 seed | +| `pnpm db:studio` | 打开 Prisma Studio | +| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | ## Web 管理端(含手机小屏适配) @@ -261,7 +261,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 实现位置:`src/composables/useResponsive.ts`(断点 + resize 监听)、`src/layouts/AdminLayout.vue`(抽屉/顶栏)、 `src/styles/index.css`(全局响应式规则)、各视图表格卡片加 `.table-card`。 -`pnpm verify:web` 的 17 项里,最后 9 项专门验证权限入口隐藏、手机端与叫人入口(Electron 真实点击): +`pnpm verify:web` 的 18 项里,最后 10 项专门验证权限入口隐藏、手机端、叫人入口与 ClassIsland 时间配置导入弹窗(Electron 真实点击): | 校验项 | 实测结果 | | -------------------------------------- | -------------------------------------------------------------------- | @@ -531,49 +531,102 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) 回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现); `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 一览 统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。 所有接口前缀 `/api`,除登录外均需 `Authorization: Bearer `。 -| 方法 | 路径 | 权限 | 说明 | -| --------------------------- | -------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------- | -| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 | -| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | -| PATCH | `/auth/password` | 登录 | 修改自己的密码 | -| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | -| GET | `/classes` | 登录 | 班级列表(按权限收敛) | -| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | -| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 | -| GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) | -| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 | -| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 | -| GET / POST / PATCH / DELETE | `/courses` | 登录 / 教师 | 课程管理 | -| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) | -| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | -| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | -| POST / PATCH / DELETE | `/schedules` | 教师/管理员 | 课表增删改(广播 `schedule:updated`) | -| GET | `/homeworks?classId=&courseId=&pendingOnly=&keyword=` | 登录 | 作业列表(学生带完成状态,教师带完成人数) | -| GET | `/homeworks/:id` | 班级可见 | 作业详情 | -| POST / PATCH / DELETE | `/homeworks` | 教师/管理员 | 发布/修改/删除(广播 `homework:new` / `homework:updated`) | -| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) | -| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | -| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | -| POST | `/notifications` | 教师/管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` | -| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读 | -| DELETE | `/notifications/:id` | 教师/管理员 | 删除通知 | -| GET | `/grades/my` | 登录 | 个人成绩 | -| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师/管理员 | 班级成绩 | -| GET | `/grades/stats?classId=&courseId=&examName=` | 教师/管理员 | 等级分布 + 各课程平均得分率 | -| POST | `/grades`、`/grades/bulk` | 教师/管理员 | 单条 / 批量录入(广播 `grade:updated`) | -| PATCH / DELETE | `/grades/:id` | 教师/管理员 | 修改 / 删除成绩 | -| GET | `/students?classId=&keyword=` | 教师/管理员 | 学生名单 | -| POST / PATCH / DELETE | `/students` | 教师/管理员 | 学生账号增删改 | -| POST | `/students/:id/reset-password` | 教师/管理员 | 重置密码 | -| GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) | -| POST | `/teachers` | 管理员 | 新建教师账号 | -| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | -| GET | `/health` | 公开 | 健康检查(含已挂载模块列表) | +| 方法 | 路径 | 权限 | 说明 | +| --------------------------- | -------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------- | +| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 | +| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | +| PATCH | `/auth/password` | 登录 | 修改自己的密码 | +| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | +| GET | `/classes` | 登录 | 班级列表(按权限收敛) | +| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | +| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 | +| GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) | +| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 | +| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 | +| GET / POST / PATCH / DELETE | `/courses` | 登录 / 教师 | 课程管理 | +| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) | +| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | +| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | +| POST / PATCH / DELETE | `/schedules` | 教师/管理员 | 课表增删改(广播 `schedule:updated`) | +| GET | `/homeworks?classId=&courseId=&pendingOnly=&keyword=` | 登录 | 作业列表(学生带完成状态,教师带完成人数) | +| GET | `/homeworks/:id` | 班级可见 | 作业详情 | +| POST / PATCH / DELETE | `/homeworks` | 教师/管理员 | 发布/修改/删除(广播 `homework:new` / `homework:updated`) | +| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) | +| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | +| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | +| POST | `/notifications` | 教师/管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` | +| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读 | +| DELETE | `/notifications/:id` | 教师/管理员 | 删除通知 | +| GET | `/grades/my` | 登录 | 个人成绩 | +| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师/管理员 | 班级成绩 | +| GET | `/grades/stats?classId=&courseId=&examName=` | 教师/管理员 | 等级分布 + 各课程平均得分率 | +| POST | `/grades`、`/grades/bulk` | 教师/管理员 | 单条 / 批量录入(广播 `grade:updated`) | +| PATCH / DELETE | `/grades/:id` | 教师/管理员 | 修改 / 删除成绩 | +| GET | `/students?classId=&keyword=` | 教师/管理员 | 学生名单 | +| POST / PATCH / DELETE | `/students` | 教师/管理员 | 学生账号增删改 | +| POST | `/students/:id/reset-password` | 教师/管理员 | 重置密码 | +| GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) | +| POST | `/teachers` | 管理员 | 新建教师账号 | +| 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` | 公开 | 健康检查(含已挂载模块列表) | ## WebSocket 事件 @@ -678,26 +731,41 @@ pnpm db:migrate && pnpm db:seed ``` 数据库表:`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** | -| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | -| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | -| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | -| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | -| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | -| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | -| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | -| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 | -| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` | -| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" | -| 叫人(老师点名 → 学生灵动岛立即跳出) | ✅ | `verify:e2e` 6 项 + `verify:desktop` 上课中也立即展开(徽标"叫人"、按钮"收到") | -| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) | -| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | +| 验收项 | 结果 | 证据 | +| ------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------- | +| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | +| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | +| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | +| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | +| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | +| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | +| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | +| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | +| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 | +| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` | +| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" | +| 叫人(老师点名 → 学生灵动岛立即跳出) | ✅ | `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`(见下表) | +| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | 打包产物验证(对最终 EXE 实测,非仅开发产物): @@ -710,14 +778,14 @@ pnpm db:migrate && pnpm db:seed 当前实测: -| 验证 | 结果 | -| --------------------------------------------------------------------------------- | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **82/82** ✅ | -| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + PWA) | **17/17** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ | -| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | -| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | -| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | +| 验证 | 结果 | +| ----------------------------------------------------------------------------------------- | ----------------------------------- | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **107/107** ✅ | +| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | +| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | +| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | ## 常见问题(本机环境已知坑) @@ -816,6 +884,19 @@ pnpm db:migrate && pnpm db:seed | `scripts/lib/{electron-env,node-runtime}.mjs` | Electron/Node 运行时定位与宿主环境兼容处理 | | `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 提示)。 diff --git a/packages/server/package.json b/packages/server/package.json index 1b41ade..880184c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -34,6 +34,7 @@ "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "socket.io": "^4.8.3", + "xlsx": "0.18.5", "zod": "^4.6.2" }, "devDependencies": { diff --git a/packages/server/prisma/migrations/20260912120000_add_time_layout/migration.sql b/packages/server/prisma/migrations/20260912120000_add_time_layout/migration.sql new file mode 100644 index 0000000..bfa0cac --- /dev/null +++ b/packages/server/prisma/migrations/20260912120000_add_time_layout/migration.sql @@ -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"); diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 1e5919e..a3b6ebd 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -66,6 +66,7 @@ model Class { notifications Notification[] grades Grade[] enrollments Enrollment[] + timeLayouts TimeLayout[] @@index([teacherId]) } @@ -212,6 +213,24 @@ model NotificationRead { @@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 { id String @id @default(cuid()) diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index ae0b431..7fdf7c9 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -847,6 +847,393 @@ async function main() { `涉及 ${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(); // ---------------------------------------------------------------- 汇总 diff --git a/packages/server/src/lib/db-bootstrap.ts b/packages/server/src/lib/db-bootstrap.ts index 1700cb8..da36998 100644 --- a/packages/server/src/lib/db-bootstrap.ts +++ b/packages/server/src/lib/db-bootstrap.ts @@ -9,8 +9,9 @@ import { hashPassword } from './password.js'; /** * 首次启动时的数据库初始化(SQLite/libSQL)。 * - * 场景:安装程序首次运行、Docker 挂载了空数据卷、用户手动删除了数据库文件。 - * 做法:若目标库中还没有业务表,就把随包发布的 Prisma 迁移 SQL 依次执行一遍。 + * 场景:安装程序首次运行、覆盖安装升级、Docker 挂载了空数据卷、用户手动删除了数据库文件。 + * 做法:以 `_ch_migrations` 账本表记录已执行的迁移;启动时按时间戳顺序补跑未记录的迁移。 + * 全新库会跑全部迁移,旧库只补跑新增迁移(已存在的表/索引自动跳过)。 * * 说明: * - 只处理 SQLite(本项目打包安装的默认形态); @@ -58,6 +59,103 @@ async function schemaExists(): Promise { } } +/** + * 迁移账本表名。 + * + * 老版本安装(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; +}): Promise> { + 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>( + `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 }, + file: string, +): Promise { + 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 { + 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; + }, + ); + return files.filter((file) => !applied.has(path.basename(path.dirname(file)))); + } catch (error) { + logger.warn('读取迁移账本失败,按全部待执行处理', error); + return files; + } finally { + client.close(); + } +} + export interface BootstrapResult { applied: number; skipped: boolean; @@ -95,8 +193,12 @@ export async function ensureInitialAdmin(): Promise<{ created: boolean; username } /** - * 确保数据库结构存在。 + * 确保数据库结构存在(增量迁移)。 * 仅在 AUTO_MIGRATE=true 时由启动流程调用。 + * + * - 全新数据库:依次执行全部迁移; + * - 旧版本升级:只补跑账本里没有记录的迁移(已存在的对象自动跳过); + * - 已是最新:直接返回,不做任何写操作。 */ export async function ensureSchema(): Promise { if (env.databaseProvider !== 'sqlite') { @@ -107,27 +209,46 @@ export async function ensureSchema(): Promise { }; } - if (await schemaExists()) { - return { applied: 0, skipped: true, detail: '数据库结构已存在,跳过初始化' }; - } - const files = listMigrationFiles(); if (files.length === 0) { return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' }; } + const freshInstall = !(await schemaExists()); const client = createClient({ url: env.databaseUrl }); - let applied = 0; + try { - for (const file of files) { - const sql = fs.readFileSync(file, 'utf8'); - await client.executeMultiple(sql); - applied += 1; - logger.info(`已应用迁移:${path.basename(path.dirname(file))}`); + const applied = await loadAppliedMigrations( + client as unknown as { + execute: (sql: string) => Promise; + }, + ); + 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 }, + 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 { client.close(); } - - return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` }; } diff --git a/packages/server/src/lib/http.ts b/packages/server/src/lib/http.ts index 10c5d5a..eaf640b 100644 --- a/packages/server/src/lib/http.ts +++ b/packages/server/src/lib/http.ts @@ -22,6 +22,12 @@ export type ApiErrorCode = | 'TOO_MANY_REQUESTS' /** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */ | 'URGENT_DURING_CLASS' + /** 导入:空文件 / 文件过大 / 格式错误 / 内容不合法 / 没有有效数据 */ + | 'IMPORT_EMPTY_FILE' + | 'IMPORT_TOO_LARGE' + | 'IMPORT_FORMAT_INVALID' + | 'IMPORT_INVALID' + | 'IMPORT_EMPTY' | 'INTERNAL_ERROR'; /** 业务异常:中间件会把它转换成统一错误响应体 */ diff --git a/packages/server/src/middleware/validate.ts b/packages/server/src/middleware/validate.ts index 728a3e2..e25e37d 100644 --- a/packages/server/src/middleware/validate.ts +++ b/packages/server/src/middleware/validate.ts @@ -28,6 +28,12 @@ export function validate( } /** 读取已校验的 query(类型由调用方用 zod schema 保证) */ +/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */ +export function validatedBody(req: Request): T { + // validate() 会把解析结果写回 req.body + return req.body as T; +} + export function validatedQuery(req: Request): T { return (req.validatedQuery ?? {}) as T; } diff --git a/packages/server/src/modules/imports/imports.module.ts b/packages/server/src/modules/imports/imports.module.ts new file mode 100644 index 0000000..836cfb9 --- /dev/null +++ b/packages/server/src/modules/imports/imports.module.ts @@ -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(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(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(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(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, +}); diff --git a/packages/server/src/modules/imports/imports.schemas.ts b/packages/server/src/modules/imports/imports.schemas.ts new file mode 100644 index 0000000..a60e864 --- /dev/null +++ b/packages/server/src/modules/imports/imports.schemas.ts @@ -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; +export type TableCommitInput = z.infer; +export type TimeLayoutImportInput = z.infer; diff --git a/packages/server/src/modules/imports/table-import.service.ts b/packages/server/src/modules/imports/table-import.service.ts new file mode 100644 index 0000000..156a87e --- /dev/null +++ b/packages/server/src/modules/imports/table-import.service.ts @@ -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; + 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(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 { + const mapping: Record = {}; + 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, 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 { + 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 { + 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 { + return input.kind === 'grades' ? commitGrades(user, input) : commitStudents(user, input); +} diff --git a/packages/server/src/modules/imports/time-layout.service.ts b/packages/server/src/modules/imports/time-layout.service.ts new file mode 100644 index 0000000..6bec38e --- /dev/null +++ b/packages/server/src/modules/imports/time-layout.service.ts @@ -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, 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; + 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).items)) { + return { items: (value as Record).items as unknown[], shape: `${key}.items` }; + } + } + } + return { items: [], shape: 'unknown' }; +} + +function normalizeType(record: Record): 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; + + 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 { + 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 { + 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 } }); +} diff --git a/packages/server/src/modules/registry.ts b/packages/server/src/modules/registry.ts index 2586f33..cabec1d 100644 --- a/packages/server/src/modules/registry.ts +++ b/packages/server/src/modules/registry.ts @@ -5,6 +5,7 @@ import { coursesModule } from './courses/courses.module.js'; import { dashboardModule } from './dashboard/dashboard.module.js'; import { gradesModule } from './grades/grades.module.js'; import { homeworksModule } from './homeworks/homeworks.module.js'; +import { importsModule } from './imports/imports.module.js'; import { notificationsModule } from './notifications/notifications.module.js'; import { schedulesModule } from './schedules/schedules.module.js'; import { studentsModule } from './students/students.module.js'; @@ -26,6 +27,7 @@ export const apiModules: ApiModule[] = [ homeworksModule, notificationsModule, callsModule, + importsModule, gradesModule, studentsModule, teachersModule, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 50c0278..8effa45 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -21,6 +21,8 @@ export const API_PATHS = { grades: '/grades', students: '/students', teachers: '/teachers', + /** 导入:模板下载 / 表格导入 / ClassIsland 课表时间配置 */ + imports: '/imports', dashboard: '/dashboard/summary', } as const; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 545a546..dd88542 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -458,6 +458,77 @@ export interface ServerToClientEvents { connected: (payload: { userId: string; role: UserRole; rooms: string[] }) => void; } +/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置) */ + +/** 表格导入预览:列名、前若干行、建议字段映射与校验问题 */ +export interface TableImportPreview { + kind: 'grades' | 'students'; + columns: string[]; + rows: string[][]; + totalRows: number; + suggestedMapping: Record; + 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 { 'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void; diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 416e8e7..97c04a6 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -21,6 +21,11 @@ import { type ScheduleDto, type ScheduleWeekView, type StudentDto, + type TableImportPreview, + type TableImportResult, + type TimeLayoutDto, + type TimeLayoutImportResult, + type TimeLayoutParsePreview, type UpdateClassRequest, type UserDto, } from '@classhelper/shared'; @@ -192,3 +197,54 @@ export const teacherApi = { /** 班级详情中的 teachers 字段即为已分配的协作教师 */ fromClass: (classId: string): Promise => 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 => api.post(`${API_PATHS.imports}/table/preview`, payload), + + /** 确认字段映射与写入模式后执行导入 */ + commitTable: (payload: { + kind: 'grades' | 'students'; + classId: string; + fileName: string; + contentBase64: string; + mapping: Record; + mode: 'append' | 'upsert'; + }): Promise => api.post(`${API_PATHS.imports}/table/commit`, payload), + + /** ClassIsland 时间配置解析预览(不写库) */ + previewTimeLayout: (payload: { + classId: string; + name?: string; + mode?: 'replace' | 'merge'; + payload: unknown; + }): Promise => api.post(`${API_PATHS.imports}/time-layout/preview`, payload), + + /** 某班已导入的时间配置 */ + listTimeLayouts: (classId: string): Promise => + api.get(`${API_PATHS.imports}/time-layout`, { classId }), + + /** 导入(覆盖或合并;解析失败返回 400 且不改动原配置) */ + importTimeLayout: (payload: { + classId: string; + name?: string; + mode: 'replace' | 'merge'; + payload: unknown; + }): Promise => api.post(`${API_PATHS.imports}/time-layout`, payload), + + removeTimeLayout: (id: string): Promise<{ id: string }> => + api.delete(`${API_PATHS.imports}/time-layout/${id}`), +}; diff --git a/packages/web-admin/src/components/TableImportDialog.vue b/packages/web-admin/src/components/TableImportDialog.vue new file mode 100644 index 0000000..c55ac50 --- /dev/null +++ b/packages/web-admin/src/components/TableImportDialog.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/packages/web-admin/src/components/TimeLayoutImportDialog.vue b/packages/web-admin/src/components/TimeLayoutImportDialog.vue new file mode 100644 index 0000000..923cc3f --- /dev/null +++ b/packages/web-admin/src/components/TimeLayoutImportDialog.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/packages/web-admin/src/views/GradesView.vue b/packages/web-admin/src/views/GradesView.vue index c346898..a4cefbe 100644 --- a/packages/web-admin/src/views/GradesView.vue +++ b/packages/web-admin/src/views/GradesView.vue @@ -14,6 +14,7 @@ import { type StudentDto, } from '@classhelper/shared'; import { classApi, courseApi, gradeApi } from '@/api'; +import TableImportDialog from '@/components/TableImportDialog.vue'; import { useAuthStore } from '@/stores/auth'; import { useRealtimeStore } from '@/stores/realtime'; @@ -214,6 +215,22 @@ async function submitBulk(): Promise { await loadGrades(); } +/* ------------------------------------------------------------ 表格导入 */ + +const importVisible = ref(false); + +function openImport(): void { + if (!filter.classId) { + ElMessage.warning('请先选择班级'); + return; + } + importVisible.value = true; +} + +async function onImported(): Promise { + await loadGrades(); +} + async function removeGrade(row: GradeDto): Promise { await ElMessageBox.confirm(`删除 ${row.student?.name ?? ''} 的「${row.examName}」成绩?`, '确认', { type: 'warning', @@ -280,6 +297,7 @@ onUnmounted(() => { 单条录入 批量录入 + 导入表格 @@ -415,6 +433,14 @@ onUnmounted(() => { 提交并广播 + + + diff --git a/packages/web-admin/src/views/SchedulesView.vue b/packages/web-admin/src/views/SchedulesView.vue index 26da999..80fa2f4 100644 --- a/packages/web-admin/src/views/SchedulesView.vue +++ b/packages/web-admin/src/views/SchedulesView.vue @@ -14,6 +14,7 @@ import { type ScheduleWeekView, } from '@classhelper/shared'; import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api'; +import TimeLayoutImportDialog from '@/components/TimeLayoutImportDialog.vue'; import { useAuthStore } from '@/stores/auth'; import { useRealtimeStore } from '@/stores/realtime'; @@ -194,6 +195,18 @@ function onScheduleEvent(): void { void loadSchedules(); } +/* ------------------------------------------------------------ ClassIsland 时间配置导入 */ + +const timeLayoutVisible = ref(false); + +function openTimeLayoutImport(): void { + if (!query.classId) { + ElMessage.warning('请先选择班级'); + return; + } + timeLayoutVisible.value = true; +} + onMounted(async () => { await loadBase(); await loadSchedules(); @@ -228,6 +241,9 @@ onUnmounted(() => { 新增课表 + + 导入时间配置 + @@ -313,5 +329,12 @@ onUnmounted(() => { 保存 + + + diff --git a/packages/web-admin/src/views/StudentsView.vue b/packages/web-admin/src/views/StudentsView.vue index 3c1d235..de4ba77 100644 --- a/packages/web-admin/src/views/StudentsView.vue +++ b/packages/web-admin/src/views/StudentsView.vue @@ -1,4 +1,4 @@ -