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:
1 parent
0dcab6f369
commit
8b85079d81
23 files changed
+2658
-31
No files matched your search
@@ -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 通过。
|
||||
@@ -203,7 +203,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
|
||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||
@@ -214,9 +214,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
| `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 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 格式化 / 检查 |
|
||||
@@ -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,13 +531,61 @@ 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 <token>`。
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| --------------------------- | -------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------- |
|
||||
| --------------------------- | -------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------- |
|
||||
| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 |
|
||||
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) |
|
||||
| PATCH | `/auth/password` | 登录 | 修改自己的密码 |
|
||||
@@ -573,6 +621,11 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
||||
| 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,12 +731,24 @@ 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` 返回记录;批量录入与统计接口通过 |
|
||||
@@ -696,6 +761,9 @@ pnpm db:migrate && pnpm db:seed
|
||||
| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `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 切换、打包与常见问题 |
|
||||
|
||||
@@ -711,9 +779,9 @@ pnpm db:migrate && pnpm db:seed
|
||||
当前实测:
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **82/82** ✅ |
|
||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + PWA) | **17/17** ✅ |
|
||||
| ----------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **107/107** ✅ |
|
||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ |
|
||||
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ |
|
||||
| `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 运行时定位与宿主环境兼容处理 |
|
||||
| `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 提示)。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
@@ -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<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 {
|
||||
applied: number;
|
||||
skipped: boolean;
|
||||
@@ -95,8 +193,12 @@ export async function ensureInitialAdmin(): Promise<{ created: boolean; username
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保数据库结构存在。
|
||||
* 确保数据库结构存在(增量迁移)。
|
||||
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。
|
||||
*
|
||||
* - 全新数据库:依次执行全部迁移;
|
||||
* - 旧版本升级:只补跑账本里没有记录的迁移(已存在的对象自动跳过);
|
||||
* - 已是最新:直接返回,不做任何写操作。
|
||||
*/
|
||||
export async function ensureSchema(): Promise<BootstrapResult> {
|
||||
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();
|
||||
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<unknown>;
|
||||
},
|
||||
);
|
||||
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 {
|
||||
client.close();
|
||||
}
|
||||
|
||||
return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||
|
||||
@@ -28,6 +28,12 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
|
||||
}
|
||||
|
||||
/** 读取已校验的 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 {
|
||||
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 } });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -21,6 +21,8 @@ export const API_PATHS = {
|
||||
grades: '/grades',
|
||||
students: '/students',
|
||||
teachers: '/teachers',
|
||||
/** 导入:模板下载 / 表格导入 / ClassIsland 课表时间配置 */
|
||||
imports: '/imports',
|
||||
dashboard: '/dashboard/summary',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void;
|
||||
|
||||
@@ -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<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,
|
||||
} 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<void> {
|
||||
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> {
|
||||
await ElMessageBox.confirm(`删除 ${row.student?.name ?? ''} 的「${row.examName}」成绩?`, '确认', {
|
||||
type: 'warning',
|
||||
@@ -280,6 +297,7 @@ onUnmounted(() => {
|
||||
</el-select>
|
||||
<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="warning" :icon="'Document'" @click="openImport">导入表格</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -415,6 +433,14 @@ onUnmounted(() => {
|
||||
<el-button type="primary" @click="submitBulk">提交并广播</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 表格导入(xlsx/xls/csv:模板下载 + 预览 + 字段映射 + 结果统计) -->
|
||||
<TableImportDialog
|
||||
v-model="importVisible"
|
||||
kind="grades"
|
||||
:class-id="filter.classId"
|
||||
@imported="onImported"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
|
||||
新增课表
|
||||
</el-button>
|
||||
<el-button v-if="canManageSchedule" type="warning" :icon="'Upload'" @click="openTimeLayoutImport">
|
||||
导入时间配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,5 +329,12 @@ onUnmounted(() => {
|
||||
<el-button type="primary" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ClassIsland 时间配置导入(覆盖 / 合并,失败不改动原配置) -->
|
||||
<TimeLayoutImportDialog
|
||||
v-model="timeLayoutVisible"
|
||||
:class-id="query.classId"
|
||||
:class-name="classes.find((item) => item.id === query.classId)?.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { callApi, classApi, studentApi } from '@/api';
|
||||
import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
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 formRef = ref<FormInstance>();
|
||||
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 type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
|
||||
<el-button type="warning" :icon="'Document'" @click="openImport">导入名单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -284,6 +296,14 @@ async function submitCall(): Promise<void> {
|
||||
<el-button type="primary" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 学生名单导入(xlsx/xls/csv,仅管理员可见;后端同样强校验权限) -->
|
||||
<TableImportDialog
|
||||
v-model="importVisible"
|
||||
kind="students"
|
||||
:class-id="filter.classId"
|
||||
@imported="loadStudents"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Generated
+72
@@ -126,6 +126,9 @@ importers:
|
||||
socket.io:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
xlsx:
|
||||
specifier: 0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: ^4.6.2
|
||||
version: 4.6.2
|
||||
@@ -1213,6 +1216,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
@@ -1395,6 +1402,10 @@ packages:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1436,6 +1447,10 @@ packages:
|
||||
clone-response@1.0.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1511,6 +1526,11 @@ packages:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==}
|
||||
|
||||
@@ -1956,6 +1976,10 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2981,6 +3005,10 @@ packages:
|
||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -3298,10 +3326,18 @@ packages:
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
hasBin: true
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3325,6 +3361,11 @@ packages:
|
||||
utf-8-validate:
|
||||
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:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4446,6 +4487,8 @@ snapshots:
|
||||
|
||||
acorn@8.18.0: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -4702,6 +4745,11 @@ snapshots:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -4739,6 +4787,8 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-response: 1.0.1
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -4807,6 +4857,8 @@ snapshots:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
cross-dirname@0.1.0:
|
||||
optional: true
|
||||
|
||||
@@ -5364,6 +5416,8 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
@@ -6402,6 +6456,10 @@ snapshots:
|
||||
|
||||
sqlstring@2.3.3: {}
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
stat-mode@1.0.0: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
@@ -6680,8 +6738,12 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 4.0.0
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -6698,6 +6760,16 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
@@ -412,6 +412,77 @@ async function main() {
|
||||
return true;
|
||||
})()`);
|
||||
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 风格:侧边栏收进抽屉 + 卡片内横向滚动)
|
||||
await runMobileChecks(win);
|
||||
|
||||
|
||||
Reference in new issue
Block a user