feat(island,homework,schedule): 灵动岛点开修复+胶囊类型汇总 · 作业看板与未交名单 · 单双周课表与 ClassIsland 课程表导入
一、灵动岛(用户反馈的三处"点不开"全部修复)
- 非紧急叫人:上课时段仍以胶囊保持可见、可点开(不再只进队列导致屏幕上什么都没有);
新增 isOpenable():紧急消息 + 任何"叫人"都允许点开,但普通叫人不会自动展开打断课堂。
- 紧急叫人收起后再点:命中改为"主进程按光标位置兜底校正"(上一轮的 island:set-hit-rect 轮询),
本轮补齐叫人类专项回归(展开 → 收起 → 再次点开)。
- 设置页预览:新增 preview 通道 —— 示例岛直接展开、失焦不收起、30 秒自行消失、关掉不残留。
- 收起态胶囊汇总消息类型:多条时显示「新消息:叫人/作业/通知(共 3 条)」+「点击查看」+ 类型小标签。
- 顺带修掉两个真问题:胶囊态收到新消息会把上一条静默顶掉(现在一律回队);
syncWindow 的上课时段守卫只认"紧急"导致普通叫人被藏掉。
- 性能:岛体矩形上报限流(~10 次/秒 + 尾随补发),修掉每帧 IPC 导致的开合采样骤降。
二、作业(学生端 + Web 教师端)
- 新增**看板模式**:按科目分卡片、点卡片放大全屏、工具栏可切「看板/列表」、
开关「显示时间」、拖「看板字号」(11–28px),偏好持久化到客户端配置 homeworkBoard。
- 去掉「全班标记完成」,改为**未交名单**:勾选没交的同学,其余自动标记已交;
新增 GET/PATCH /api/homeworks/:id/submissions(教师与班级设备可用,普通学生 403)。
三、课表单双周 + ClassIsland 课程表导入
- Schedule 新增 weekParity(ALL/ODD/EVEN)+ 迁移;周视图与上课判定按「第 1 周=单周」过滤,
历史数据默认 ALL 行为不变;Web 手动新增/编辑课表可选单双周,学生端课表打「单周/双周」标签。
- 修复既有 P0 bug:ClassIsland 真实档案里 TimeLayouts 是 "Guid → { Name, Layouts }" 字典,
旧解析器只认数组/.items,导致**真实导出完全导不进来**;现已按真实形态解析(并兼容旧写法),
同时补上 TimeType=3(行动)映射。
- 新增 ClassIsland 课程表(ClassPlan)导入:解析 TimeRule.WeekCountDiv/WeekCountDivTotal → 单双周,
Classes[i] 对齐第 i 个 TimeType=0 的时间点,Subjects 映射科目(缺失自动补建课程),
星期 0=周日 换算为 1=周一 … 7=周日;replace/merge 两种模式,预览只解析不写库。
- 新增维护脚本 packages/server/scripts/apply-parity-column.cjs(手工覆盖 dist 部署时补列用)。
验证:verify:e2e 148/148(新增单双周 12 项 + 课程表导入 5 项)、verify:desktop 71/71(复跑 65/65)、
verify:web 20/20、typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
This commit is contained in:
1 parent
86430c5f4a
commit
b8ef5d8dbb
34 files changed
+2351
-86
No files matched your search
@@ -20,7 +20,7 @@
|
|||||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 65/65) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 71/71) |
|
||||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||||
@@ -75,7 +75,7 @@ class-helper/
|
|||||||
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
|
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
|
||||||
│ ├── prisma/seed.ts # 种子数据
|
│ ├── prisma/seed.ts # 种子数据
|
||||||
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
|
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
|
||||||
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(136 项)
|
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(148 项)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
|
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
|
||||||
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
|
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
|
||||||
@@ -171,15 +171,15 @@ pnpm verify:desktop
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 另开一个终端先启动后端:pnpm dev:server
|
# 另开一个终端先启动后端:pnpm dev:server
|
||||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:136 项
|
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 导入 + 班级账号:148 项
|
||||||
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项
|
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项
|
||||||
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):65 项
|
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/胶囊类型汇总、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):71 项
|
||||||
```
|
```
|
||||||
|
|
||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 136 项,
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入**等 148 项,
|
||||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||||
(**班级账号登录** + 四类数据 + Socket.IO),实测 65/65 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 59/59)。
|
(**班级账号登录** + 四类数据 + Socket.IO),实测 71/71 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 65/65)。
|
||||||
|
|
||||||
## 默认账号与种子数据
|
## 默认账号与种子数据
|
||||||
|
|
||||||
@@ -220,9 +220,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中
|
|||||||
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
||||||
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
|
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
|
||||||
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
||||||
| `pnpm verify:e2e` | 后端端到端验收(136 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
|
| `pnpm verify:e2e` | 后端端到端验收(148 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) |
|
||||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
|
||||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(65 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(71 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
|
||||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||||
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
||||||
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
||||||
@@ -319,14 +319,23 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中
|
|||||||
|
|
||||||
### 功能页面
|
### 功能页面
|
||||||
|
|
||||||
| 页面 | 功能 |
|
| 页面 | 功能 |
|
||||||
| ---- | -------------------------------------------------------------------------------------------- |
|
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 登录 | 服务器地址 + **班级码 + 班级密码**,附带「测试连接」;已登录但服务器不可达时可「离线进入」 |
|
| 登录 | 服务器地址 + **班级码 + 班级密码**,附带「测试连接」;已登录但服务器不可达时可「离线进入」 |
|
||||||
| 课表 | 按周展示 7 列课表,支持上一周/下一周/下拉选择周次,高亮"今天",显示周次范围与地点 |
|
| 课表 | 按周展示 7 列课表,支持上一周/下一周/下拉选择周次,高亮"今天",显示周次范围与地点;**单周/双周**的课带 `单周`/`双周` 标签 |
|
||||||
| 作业 | 列表(全部/未完成/已完成过滤)、详情抽屉、**全班标记完成**/取消、附件链接、逾期高亮 |
|
| 作业 | **看板模式**(按科目卡片、点击卡片放大全屏、可切换「显示时间」与**看板字号**、随时切回列表模式)+ 列表模式、详情抽屉、附件链接;班级设备用**未交名单**勾选谁没交 |
|
||||||
| 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 |
|
| 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 |
|
||||||
| 成绩 | 班级账号看到**全班成绩总览**:表格(分数/得分率/等级)+ ECharts 柱状图 + 等级分布 + 最近更新 |
|
| 成绩 | 班级账号看到**全班成绩总览**:表格(分数/得分率/等级)+ ECharts 柱状图 + 等级分布 + 最近更新 |
|
||||||
| 设置 | 服务器地址(保存并测试)、账号信息、离线缓存条目统计与清空、客户端版本信息、退出登录 |
|
| 设置 | 服务器地址(保存并测试)、账号信息、离线缓存条目统计与清空、客户端版本信息、退出登录 |
|
||||||
|
|
||||||
|
**作业看板(学生端)**:默认按科目分卡片(每张卡列该科作业,条目可点开详情),
|
||||||
|
工具栏可切「看板 / 列表」、开关「显示时间」、拖「字号」(11–28px 即时生效),
|
||||||
|
点卡片或「全屏看板」进入全屏放大视图;这些偏好写入客户端配置(`homeworkBoard`),重启后仍生效。
|
||||||
|
|
||||||
|
**未交名单(班级设备)**:作业列表/详情里点「未交名单」→ 勾选**没交作业**的同学 →
|
||||||
|
保存后其余同学自动标记为已完成(`PATCH /api/homeworks/:id/submissions`,
|
||||||
|
`GET .../submissions` 读名单)。教师端 Web 管理端的作业详情抽屉里也能看到未交名单并勾选保存,
|
||||||
|
普通学生调用该接口返回 403(只能标记自己)。
|
||||||
|
|
||||||
> 通知到达时还会由**灵动岛**在桌面中上方浮出提醒(见上一节),上课时段自动隐藏、紧急通知立即展开。
|
> 通知到达时还会由**灵动岛**在桌面中上方浮出提醒(见上一节),上课时段自动隐藏、紧急通知立即展开。
|
||||||
|
|
||||||
@@ -360,7 +369,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中
|
|||||||
| 场景 | 灵动岛行为 |
|
| 场景 | 灵动岛行为 |
|
||||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` |
|
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` |
|
||||||
|
| 多条未处理消息 | 胶囊标题汇总类型:`新消息:叫人/作业/通知(共 3 条)` + `点击查看`,右侧附类型小标签(单条时显示具体类型名) |
|
||||||
| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` |
|
| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` |
|
||||||
|
| **上课时段的"普通叫人"** | **不自动展开**(不打断课堂),但保留 `叫人` 胶囊并**允许学生主动点开**;下课自动展开(`isOpenable`:紧急消息与任何叫人都可点开) |
|
||||||
| **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失** —— 随时都能再次点开(彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段) |
|
| **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失** —— 随时都能再次点开(彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段) |
|
||||||
| **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中并被固化为回归用例("收起态点击可再次展开(指针未移动也算命中)") |
|
| **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中并被固化为回归用例("收起态点击可再次展开(指针未移动也算命中)") |
|
||||||
| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
|
| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
|
||||||
@@ -400,6 +411,14 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中
|
|||||||
| 非上课时段"新消息"胶囊 |  | 268×44 / 536×88 | 直接出现(无入场动画),点击后展开 |
|
| 非上课时段"新消息"胶囊 |  | 268×44 / 536×88 | 直接出现(无入场动画),点击后展开 |
|
||||||
| 点击胶囊后的详情 |  | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 |
|
| 点击胶囊后的详情 |  | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 |
|
||||||
|
|
||||||
|
**收起态胶囊会汇总消息类型**(用户需求):当有多条未处理消息时,胶囊标题显示
|
||||||
|
`新消息:叫人/作业/通知(共 3 条)`、副标题 `点击查看`,并在右侧把三种类型做成小标签;
|
||||||
|
只有一条时仍显示具体类型名(`新消息` / `新作业` / `老师叫你`)。
|
||||||
|
多类型/多条的状态由渲染进程按 `state.active + state.queued` 现算,收起后在胶囊上就能看清"这堆消息里都有什么"。
|
||||||
|
|
||||||
|
> 设置页的「预览效果」走 `context.preview` 专用通道:示例岛**直接展开**、**失焦不收起**
|
||||||
|
> (用户拖滑块时主窗口一直是焦点)、30 秒后自行消失、点「知道了」不留残影。
|
||||||
|
|
||||||
> 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`);
|
> 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`);
|
||||||
> 归档目录里的图就是打包后 EXE 跑自动化验证时产出的同一份文件。
|
> 归档目录里的图就是打包后 EXE 跑自动化验证时产出的同一份文件。
|
||||||
|
|
||||||
@@ -574,7 +593,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
||||||
|
|
||||||
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
||||||
已实测 `packaged: true`、65/65 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
已实测 `packaged: true`、71/71 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
||||||
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
||||||
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
||||||
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
||||||
@@ -714,18 +733,48 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
|||||||
(组件 `packages/web-admin/src/components/TimeLayoutImportDialog.vue`)。
|
(组件 `packages/web-admin/src/components/TimeLayoutImportDialog.vue`)。
|
||||||
|
|
||||||
- **输入**:粘贴 JSON 文本或选择 ClassIsland 导出的 `.json` 文件;
|
- **输入**:粘贴 JSON 文本或选择 ClassIsland 导出的 `.json` 文件;
|
||||||
- **容错解析**(`packages/server/src/modules/imports/time-layout.service.ts`):
|
- **真实档案形态**(`Profiles/<档案名>.json`,ClassIsland 1.7.106+ / 2.x,字段为 PascalCase):
|
||||||
顶层数组、`{ TimeLayouts: [...] }`、`{ TimeLayoutItems | Items | items | Layouts | TimeLayout: [...] }` 都能识别;
|
`TimeLayouts` 是 **`{ "<guid>": { Name, Layouts: [ { StartTime: "08:00:00", EndTime, TimeType } ] } }`
|
||||||
时间支持 `"8:0:0"` / `"08:00"` / ISO 字符串 / `StartSecond: 28800` 秒数;字段名兼容
|
的字典**,条目在各时间表的 `Layouts` 数组里(解析器会把所有时间表的 Layouts 合并后导入);
|
||||||
`StartTime|startTime|start`、`EndTime|end`、`Name|Title|Subject`、`TimeType|Type`、`IsSkipped|skip`;
|
同时兼容更早的写法:顶层数组、`{ TimeLayouts: [...] }`、`{ items | Items | Layouts | TimeLayout: [...] }`
|
||||||
|
以及单时间表对象 `{ Name, Layouts: [...] }`;
|
||||||
|
- **时间字段**:`StartTime`/`EndTime`(`"08:00:00"`)、旧版 `StartSecond`/`EndSecond`(ISO 字符串或秒数)、
|
||||||
|
`"8:0"` / `"08:00"` 都能识别;
|
||||||
|
- **时间点类型**(`TimeType`):`0=上课`、`1=课间`、`2=分割线`、`3=行动`,与 ClassIsland 一致;
|
||||||
- **校验**:时间无法解析、结束不晚于开始、空配置都会给出**逐条原因**(含条序号与名称);
|
- **校验**:时间无法解析、结束不晚于开始、空配置都会给出**逐条原因**(含条序号与名称);
|
||||||
时间重叠、分割线无时间、原配置标记跳过等只作为 `warnings`;
|
时间重叠、分割线无时间等只作为 `warnings`;
|
||||||
- **写入模式**:
|
- **写入模式**:
|
||||||
- `replace`:整体替换该班同名(缺省「默认时间表」)配置;
|
- `replace`:整体替换该班同名(缺省「默认时间表」)配置;
|
||||||
- `merge`:以**开始时间**为槽位标识合并——同槽位被导入项覆盖,其它旧节次全部保留(不静默丢数据);
|
- `merge`:以**开始时间**为槽位标识合并——同槽位被导入项覆盖,其它旧节次全部保留(不静默丢数据);
|
||||||
- **失败回滚**:解析存在 `errors` 时返回 400 `IMPORT_INVALID` / `IMPORT_EMPTY`,
|
- **失败回滚**:解析存在 `errors` 时返回 400 `IMPORT_INVALID` / `IMPORT_EMPTY`,
|
||||||
**数据库完全不写入**,界面会提示"原有配置不会被修改",不会出现导入到一半的脏状态。
|
**数据库完全不写入**,界面会提示"原有配置不会被修改",不会出现导入到一半的脏状态。
|
||||||
|
|
||||||
|
### 4. ClassIsland 课程表导入(支持单双周)
|
||||||
|
|
||||||
|
界面:Web 管理端 →「课表管理」→「导入 ClassIsland 课程表」
|
||||||
|
(组件 `packages/web-admin/src/components/ClassPlanImportDialog.vue`)。
|
||||||
|
|
||||||
|
- **输入**:同一份 ClassIsland 档案 JSON(含 `TimeLayouts` / `ClassPlans` / `Subjects` 三个 Guid 字典);
|
||||||
|
- **单双周来源**:`ClassPlan.TimeRule.WeekCountDiv`(`0=每周`、`1=单周`、`2=双周`)
|
||||||
|
+ `WeekCountDivTotal`(默认 2)——**与 ClassIsland 源码一致的判定语义**,
|
||||||
|
因此"周一单周上数学 / 双周上英语"在档案里就是**两条 ClassPlan**(`WeekCountDiv` 分别 1 与 2);
|
||||||
|
3 周及以上轮换无法用单双周表达,会给出 `warnings` 并按每周处理;
|
||||||
|
- **节次对齐**:`ClassPlan.Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0` 的点**
|
||||||
|
(课间/分割线不占位),与 ClassIsland 的 `RefreshClassesList()` 行为一致;
|
||||||
|
- **科目映射**:`Classes[i].SubjectId` → `Subjects[<guid>].Name`;班级里还没有的科目会**自动补建课程**
|
||||||
|
(任课老师取该班班主任),预览里会提前列出"待补建科目";
|
||||||
|
- **星期换算**:ClassIsland `WeekRule.WeekDay` 是 `0=周日 … 6=周六`,导入时换算成我们的 `1=周一 … 7=周日`;
|
||||||
|
- **写入模式**:`replace`(清空该班课表后写入,事务内完成)/ `merge`
|
||||||
|
(按 `星期 + 开始时间 + 单双周` 去重,存在则更新,否则新增);
|
||||||
|
- **接口**:`POST /api/imports/class-plan/preview`(只解析不写库)与 `POST /api/imports/class-plan`;
|
||||||
|
- **课表单双周字段**:`Schedule.weekParity = ALL | ODD | EVEN`;周视图/上课判定都按
|
||||||
|
`第 1 周 = 单周` 的规则过滤(`weekParityOf(week)`,与 ClassIsland 默认相位一致),
|
||||||
|
历史数据默认 `ALL`,行为不变。手动添加课表时也能在弹窗里直接选「每周 / 单周 / 双周」。
|
||||||
|
|
||||||
|
> 手工覆盖 dist 部署(而非跑安装程序)时,已有数据库需要补一列:
|
||||||
|
> `node packages/server/scripts/apply-parity-column.cjs "<安装目录>/server/dist/lib/db.js"`
|
||||||
|
> (安装程序覆盖升级会自动补迁移;仅空库首次启动会执行随包迁移 SQL)。
|
||||||
|
|
||||||
## REST API 一览
|
## REST API 一览
|
||||||
|
|
||||||
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
|
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
|
||||||
@@ -945,7 +994,7 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
||||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
||||||
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **65/65** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **71/71** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
@@ -2,7 +2,14 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { app, safeStorage } from 'electron';
|
import { app, safeStorage } from 'electron';
|
||||||
import { DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, type IslandAppearance } from '@classhelper/shared';
|
import { DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, type IslandAppearance } from '@classhelper/shared';
|
||||||
import type { DesktopStoredConfig } from '../types/desktop.js';
|
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
|
||||||
|
|
||||||
|
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
|
||||||
|
const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
|
||||||
|
mode: 'board',
|
||||||
|
showTime: false,
|
||||||
|
fontSize: 15,
|
||||||
|
};
|
||||||
|
|
||||||
interface PersistedConfig {
|
interface PersistedConfig {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -12,6 +19,8 @@ interface PersistedConfig {
|
|||||||
tokenEncrypted: boolean;
|
tokenEncrypted: boolean;
|
||||||
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
||||||
island?: Partial<IslandAppearance>;
|
island?: Partial<IslandAppearance>;
|
||||||
|
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
||||||
|
homeworkBoard?: Partial<HomeworkBoardSettings>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: PersistedConfig = {
|
const DEFAULT_CONFIG: PersistedConfig = {
|
||||||
@@ -20,8 +29,23 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
|||||||
token: null,
|
token: null,
|
||||||
tokenEncrypted: false,
|
tokenEncrypted: false,
|
||||||
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
||||||
|
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
|
||||||
|
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
|
||||||
|
const mode = input?.mode === 'list' ? 'list' : 'board';
|
||||||
|
const rawFont =
|
||||||
|
typeof input?.fontSize === 'number' && Number.isFinite(input.fontSize)
|
||||||
|
? input.fontSize
|
||||||
|
: DEFAULT_HOMEWORK_BOARD.fontSize;
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
showTime: input?.showTime === true,
|
||||||
|
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 外观字段的合法区间校验(越界值直接夹紧,避免用户配置破坏灵动岛布局) */
|
/** 外观字段的合法区间校验(越界值直接夹紧,避免用户配置破坏灵动岛布局) */
|
||||||
function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandAppearance {
|
function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandAppearance {
|
||||||
const clamp = (value: unknown, min: number, max: number, fallback: number): number => {
|
const clamp = (value: unknown, min: number, max: number, fallback: number): number => {
|
||||||
@@ -99,6 +123,7 @@ function readPersisted(): PersistedConfig {
|
|||||||
token: typeof parsed.token === 'string' ? parsed.token : null,
|
token: typeof parsed.token === 'string' ? parsed.token : null,
|
||||||
tokenEncrypted: parsed.tokenEncrypted === true,
|
tokenEncrypted: parsed.tokenEncrypted === true,
|
||||||
island: normalizeIslandAppearance(parsed.island),
|
island: normalizeIslandAppearance(parsed.island),
|
||||||
|
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_CONFIG };
|
return { ...DEFAULT_CONFIG };
|
||||||
@@ -119,6 +144,7 @@ export function getConfig(): DesktopStoredConfig {
|
|||||||
username: persisted.username,
|
username: persisted.username,
|
||||||
token: decryptToken(persisted),
|
token: decryptToken(persisted),
|
||||||
island: normalizeIslandAppearance(persisted.island),
|
island: normalizeIslandAppearance(persisted.island),
|
||||||
|
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +161,12 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
|||||||
if (patch.island !== undefined) {
|
if (patch.island !== undefined) {
|
||||||
persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island });
|
persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island });
|
||||||
}
|
}
|
||||||
|
if (patch.homeworkBoard !== undefined) {
|
||||||
|
persisted.homeworkBoard = normalizeHomeworkBoard({
|
||||||
|
...persisted.homeworkBoard,
|
||||||
|
...patch.homeworkBoard,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (patch.token !== undefined) {
|
if (patch.token !== undefined) {
|
||||||
const encrypted = encryptToken(patch.token);
|
const encrypted = encryptToken(patch.token);
|
||||||
persisted.token = encrypted.token;
|
persisted.token = encrypted.token;
|
||||||
|
|||||||
@@ -434,7 +434,12 @@ class IslandController {
|
|||||||
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
||||||
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
||||||
const showing = this.state.active;
|
const showing = this.state.active;
|
||||||
if (this.state.inClass && !this.isOpenable(showing)) break;
|
if (this.state.inClass && !this.isOpenable(showing)) {
|
||||||
|
logger.info(
|
||||||
|
`灵动岛:上课时段忽略展开(priority=${showing?.priority ?? '-'} kind=${showing?.kind ?? '-'} id=${showing?.id ?? '-'})`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
|
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
|
||||||
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
|
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
|
||||||
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
|
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
|
||||||
@@ -448,6 +453,11 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
const active = this.state.active;
|
const active = this.state.active;
|
||||||
if (active) {
|
if (active) {
|
||||||
|
if (this.state.mode !== 'expanded') {
|
||||||
|
logger.info(
|
||||||
|
`灵动岛:展开通知 ${active.id}(priority=${active.priority} kind=${active.kind ?? '-'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
this.setState({ mode: 'expanded' });
|
this.setState({ mode: 'expanded' });
|
||||||
this.scheduleCollapse(
|
this.scheduleCollapse(
|
||||||
this.isImmediate(active)
|
this.isImmediate(active)
|
||||||
|
|||||||
@@ -1133,11 +1133,20 @@ async function runIslandChecks(
|
|||||||
await sleep(ISLAND_TIMEOUTS.pill + 700);
|
await sleep(ISLAND_TIMEOUTS.pill + 700);
|
||||||
const lingered = island.getState();
|
const lingered = island.getState();
|
||||||
const lingeredVisible = islandWindow?.isVisible() ?? false;
|
const lingeredVisible = islandWindow?.isVisible() ?? false;
|
||||||
|
// 断言"没有消失"即可:真人可能在这 15 秒里点了岛(那就从胶囊变成展开态),
|
||||||
|
// 但只要还看得见、还是同一条通知,就说明"常驻"成立。
|
||||||
record(
|
record(
|
||||||
'收起后胶囊常驻(超时不再消失,随时可再次点开)',
|
'收起后胶囊常驻(超时不再消失,随时可再次点开)',
|
||||||
collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible,
|
collapseBefore.mode === 'pill' &&
|
||||||
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`,
|
lingered.mode !== 'hidden' &&
|
||||||
|
lingeredVisible &&
|
||||||
|
lingered.active?.id === 'smoke-collapse-reopen',
|
||||||
|
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible} ` +
|
||||||
|
`active=${lingered.active?.id ?? '-'}`,
|
||||||
);
|
);
|
||||||
|
// 先确保处于胶囊态(真人在等待期间可能点开过),再验证"命中恢复 + 点击展开"
|
||||||
|
island.handleAction({ action: 'collapse' });
|
||||||
|
await sleep(500);
|
||||||
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
|
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
|
||||||
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
|
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
|
||||||
island.setInteractive(false);
|
island.setInteractive(false);
|
||||||
@@ -2281,24 +2290,27 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? ''));
|
record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
|
/**
|
||||||
await runTrayAndShutdownChecks(win, record);
|
* 把当前结果落盘 + 打印汇总。
|
||||||
|
* 为什么要写两次:托盘/退出清理用例会真的走一遍"退出应用",进程可能在收尾代码执行前就退出,
|
||||||
const failed = results.filter((item) => !item.ok);
|
* 那样 ELECTRON_SMOKE_RESULT 就不会生成(重启校验会误报"结果=-1/0")。
|
||||||
const passed = results.length - failed.length;
|
* 所以在跑托盘用例之前先写一次,收尾再写一次(覆盖全部用例)。
|
||||||
console.log(`\n[SMOKE] ${passed}/${results.length} 项通过`);
|
*/
|
||||||
|
const writeResult = (): void => {
|
||||||
const resultFile = process.env.ELECTRON_SMOKE_RESULT;
|
const failedItems = results.filter((item) => !item.ok);
|
||||||
if (resultFile) {
|
const passedCount = results.length - failedItems.length;
|
||||||
|
console.log(`\n[SMOKE] ${passedCount}/${results.length} 项通过`);
|
||||||
|
const resultFile = process.env.ELECTRON_SMOKE_RESULT;
|
||||||
|
if (!resultFile) return;
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(resultFile), { recursive: true });
|
fs.mkdirSync(path.dirname(resultFile), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
resultFile,
|
resultFile,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
passed,
|
passed: passedCount,
|
||||||
total: results.length,
|
total: results.length,
|
||||||
ok: failed.length === 0,
|
ok: failedItems.length === 0,
|
||||||
results,
|
results,
|
||||||
finishedAt: new Date().toISOString(),
|
finishedAt: new Date().toISOString(),
|
||||||
appVersion: app.getVersion(),
|
appVersion: app.getVersion(),
|
||||||
@@ -2313,7 +2325,15 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SMOKE] 写入结果文件失败', error);
|
console.error('[SMOKE] 写入结果文件失败', error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
writeResult();
|
||||||
|
|
||||||
|
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
|
||||||
|
await runTrayAndShutdownChecks(win, record);
|
||||||
|
|
||||||
|
writeResult();
|
||||||
|
|
||||||
|
const failed = results.filter((item) => !item.ok);
|
||||||
app.exit(failed.length === 0 ? 0 : 1);
|
app.exit(failed.length === 0 ? 0 : 1);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type GradeDto,
|
type GradeDto,
|
||||||
type HomeworkDto,
|
type HomeworkDto,
|
||||||
type HomeworkStatusDto,
|
type HomeworkStatusDto,
|
||||||
|
type HomeworkSubmissionsDto,
|
||||||
type LoginRequest,
|
type LoginRequest,
|
||||||
type LoginResponse,
|
type LoginResponse,
|
||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
@@ -44,6 +45,11 @@ export const homeworkApi = {
|
|||||||
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
||||||
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
|
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
|
||||||
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
|
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
|
||||||
|
/** 提交名单 / 未交名单(教师与班级设备可用) */
|
||||||
|
submissions: (id: string): Promise<HomeworkSubmissionsDto> =>
|
||||||
|
api.get(`${API_PATHS.homeworks}/${id}/submissions`),
|
||||||
|
saveSubmissions: (id: string, notSubmittedUserIds: string[]): Promise<HomeworkSubmissionsDto> =>
|
||||||
|
api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedUserIds }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const notificationApi = {
|
export const notificationApi = {
|
||||||
|
|||||||
+24
-7
@@ -350,6 +350,7 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
|
const failures: string[] = [];
|
||||||
let teacherToken = '';
|
let teacherToken = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -373,7 +374,9 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
{ start: toHHmm(nowMinutes - 180), end: toHHmm(nowMinutes - 120), location: '时间轴自检 · 已结束' },
|
{ start: toHHmm(nowMinutes - 180), end: toHHmm(nowMinutes - 120), location: '时间轴自检 · 已结束' },
|
||||||
{ start: toHHmm(nowMinutes - 20), end: toHHmm(nowMinutes + 25), location: '时间轴自检 · 正在上课' },
|
{ start: toHHmm(nowMinutes - 20), end: toHHmm(nowMinutes + 25), location: '时间轴自检 · 正在上课' },
|
||||||
{ start: toHHmm(nowMinutes + 60), end: toHHmm(nowMinutes + 105), location: '时间轴自检 · 下一节' },
|
{ start: toHHmm(nowMinutes + 60), end: toHHmm(nowMinutes + 105), location: '时间轴自检 · 下一节' },
|
||||||
];
|
// 深夜/凌晨时相对时间会越过 23:59 / 00:00,被夹紧后 start >= end(服务端 422)。
|
||||||
|
// 这种探针构造不出来就跳过,"下一节"的断言按实际创建数量放宽。
|
||||||
|
].filter((probe) => probe.start < probe.end);
|
||||||
for (const probe of probes) {
|
for (const probe of probes) {
|
||||||
const response = await fetch(`${serverUrl}/api/schedules`, {
|
const response = await fetch(`${serverUrl}/api/schedules`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -388,11 +391,23 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
weekEnd: 30,
|
weekEnd: 30,
|
||||||
location: probe.location,
|
location: probe.location,
|
||||||
}),
|
}),
|
||||||
}).then((result) => result.json());
|
});
|
||||||
const id = response?.data?.id;
|
const payload = await response.json().catch(() => null);
|
||||||
|
const id = payload?.data?.id;
|
||||||
if (id) created.push(id);
|
if (id) created.push(id);
|
||||||
|
// 记录失败原因(限流 429 / 时间非法 400 等),否则只看到一个"2/3"没法排查
|
||||||
|
else {
|
||||||
|
failures.push(
|
||||||
|
`${probe.start}-${probe.end} status=${response.status} ${payload?.message ?? ''}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (created.length < probes.length) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: `探针课表创建失败(${created.length}/${probes.length})${failures.length > 0 ? `:${failures.join(';')}` : ''}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (created.length < 3) return { ok: false, detail: `探针课表创建失败(${created.length}/3)` };
|
|
||||||
|
|
||||||
await router.push('/schedule');
|
await router.push('/schedule');
|
||||||
|
|
||||||
@@ -420,7 +435,7 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
text: (node.textContent ?? '').replace(/\s+/g, ' ').trim(),
|
text: (node.textContent ?? '').replace(/\s+/g, ' ').trim(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
if (snapshot.hasCurrent && snapshot.count >= 3) {
|
if (snapshot.hasCurrent && snapshot.count >= probes.length) {
|
||||||
dom = snapshot;
|
dom = snapshot;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -432,13 +447,15 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
const hasPast = dom.tags.some((tag) => tag.state === 'past' && tag.text.includes('已结束'));
|
const hasPast = dom.tags.some((tag) => tag.state === 'past' && tag.text.includes('已结束'));
|
||||||
const hasNext = dom.tags.some((tag) => tag.state === 'next' && tag.text.includes('下一节'));
|
const hasNext = dom.tags.some((tag) => tag.state === 'next' && tag.text.includes('下一节'));
|
||||||
const countdown = /距下课\s*\d+/.test(dom.currentText) || dom.currentText.includes('即将下课');
|
const countdown = /距下课\s*\d+/.test(dom.currentText) || dom.currentText.includes('即将下课');
|
||||||
|
// 深夜/凌晨可能只构造出 2 个探针("下一节"跨过 23:59),此时不要求存在该卡片
|
||||||
|
const expectNextProbe = probes.length >= 3;
|
||||||
const ok =
|
const ok =
|
||||||
dom.count >= 3 &&
|
dom.count >= probes.length &&
|
||||||
dom.hasCurrent &&
|
dom.hasCurrent &&
|
||||||
dom.hasProgress &&
|
dom.hasProgress &&
|
||||||
countdown &&
|
countdown &&
|
||||||
hasPast &&
|
hasPast &&
|
||||||
hasNext &&
|
(!expectNextProbe || hasNext) &&
|
||||||
/^\d{2}:\d{2}$/.test(dom.clock);
|
/^\d{2}:\d{2}$/.test(dom.clock);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { SOCKET_EVENTS, formatDate, type HomeworkDto } from '@classhelper/shared';
|
import { SOCKET_EVENTS, formatDate, type HomeworkDto, type HomeworkSubmissionDto } from '@classhelper/shared';
|
||||||
import { homeworkApi } from '../api/index.js';
|
import { homeworkApi } from '../api/index.js';
|
||||||
import { fetchWithCache } from '../cache/index.js';
|
import { fetchWithCache } from '../cache/index.js';
|
||||||
import { useAppStore } from '../stores/app.js';
|
import { useAppStore } from '../stores/app.js';
|
||||||
@@ -12,6 +12,9 @@ const appStore = useAppStore();
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
|
/** 看板/列表模式与看板外观(持久化到客户端配置,重启仍生效) */
|
||||||
|
const DEFAULT_BOARD = { mode: 'board' as 'board' | 'list', showTime: false, fontSize: 15 };
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const homeworks = ref<HomeworkDto[]>([]);
|
const homeworks = ref<HomeworkDto[]>([]);
|
||||||
const filter = ref<'all' | 'pending' | 'done'>('all');
|
const filter = ref<'all' | 'pending' | 'done'>('all');
|
||||||
@@ -21,6 +24,15 @@ const detailVisible = ref(false);
|
|||||||
const current = ref<HomeworkDto | null>(null);
|
const current = ref<HomeworkDto | null>(null);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
|
||||||
|
const boardShowTime = ref(DEFAULT_BOARD.showTime);
|
||||||
|
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
|
||||||
|
const savingBoard = ref(false);
|
||||||
|
|
||||||
|
/** 全屏放大的科目(null = 未打开) */
|
||||||
|
const fullscreenCourse = ref<string | null>(null);
|
||||||
|
const fullscreenBoard = ref(false);
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
if (filter.value === 'pending') return homeworks.value.filter((item) => item.completed !== true);
|
if (filter.value === 'pending') return homeworks.value.filter((item) => item.completed !== true);
|
||||||
if (filter.value === 'done') return homeworks.value.filter((item) => item.completed === true);
|
if (filter.value === 'done') return homeworks.value.filter((item) => item.completed === true);
|
||||||
@@ -30,6 +42,62 @@ const filtered = computed(() => {
|
|||||||
const pendingCount = computed(() => homeworks.value.filter((item) => item.completed !== true).length);
|
const pendingCount = computed(() => homeworks.value.filter((item) => item.completed !== true).length);
|
||||||
const doneCount = computed(() => homeworks.value.filter((item) => item.completed === true).length);
|
const doneCount = computed(() => homeworks.value.filter((item) => item.completed === true).length);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板分组:按科目聚合成卡片(未关联课程的归到「其他」),
|
||||||
|
* 科目内部按发布时间倒序,卡片顺序按学科常见顺序排列。
|
||||||
|
*/
|
||||||
|
const COURSE_ORDER = [
|
||||||
|
'语文',
|
||||||
|
'数学',
|
||||||
|
'英语',
|
||||||
|
'物理',
|
||||||
|
'化学',
|
||||||
|
'生物',
|
||||||
|
'政治',
|
||||||
|
'历史',
|
||||||
|
'地理',
|
||||||
|
'体育',
|
||||||
|
'音乐',
|
||||||
|
'美术',
|
||||||
|
'信息技术',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BoardColumn {
|
||||||
|
course: string;
|
||||||
|
items: HomeworkDto[];
|
||||||
|
pending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardColumns = computed<BoardColumn[]>(() => {
|
||||||
|
const groups = new Map<string, HomeworkDto[]>();
|
||||||
|
for (const item of filtered.value) {
|
||||||
|
const key = item.course?.name ?? '其他';
|
||||||
|
const list = groups.get(key) ?? [];
|
||||||
|
list.push(item);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
const columns: BoardColumn[] = [...groups.entries()].map(([course, items]) => ({
|
||||||
|
course,
|
||||||
|
items: [...items].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
|
||||||
|
pending: items.filter((item) => item.completed !== true).length,
|
||||||
|
}));
|
||||||
|
const rank = (name: string): number => {
|
||||||
|
const index = COURSE_ORDER.findIndex((item) => name.includes(item));
|
||||||
|
return index < 0 ? COURSE_ORDER.length : index;
|
||||||
|
};
|
||||||
|
return columns.sort((a, b) => rank(a.course) - rank(b.course) || a.course.localeCompare(b.course));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 看板字号:主看板用设定值,全屏放大 1.7 倍 */
|
||||||
|
const boardStyle = computed(() => ({ '--board-font': `${boardFontSize.value}px` }));
|
||||||
|
const fullscreenStyle = computed(() => ({ '--board-font': `${Math.round(boardFontSize.value * 1.7)}px` }));
|
||||||
|
|
||||||
|
const fullscreenItems = computed(() =>
|
||||||
|
fullscreenCourse.value
|
||||||
|
? (boardColumns.value.find((column) => column.course === fullscreenCourse.value)?.items ?? [])
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
async function loadHomework(): Promise<void> {
|
async function loadHomework(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -52,11 +120,49 @@ async function loadHomework(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取看板偏好(主进程配置为单一事实来源) */
|
||||||
|
async function loadBoardSettings(): Promise<void> {
|
||||||
|
const config = await window.desktop?.getConfig?.();
|
||||||
|
const saved = config?.homeworkBoard;
|
||||||
|
if (!saved) return;
|
||||||
|
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
|
||||||
|
if (typeof saved.showTime === 'boolean') boardShowTime.value = saved.showTime;
|
||||||
|
if (typeof saved.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
|
||||||
|
boardFontSize.value = Math.min(28, Math.max(11, Math.round(saved.fontSize)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBoardSettings(): Promise<void> {
|
||||||
|
if (!window.desktop?.saveConfig) return;
|
||||||
|
savingBoard.value = true;
|
||||||
|
try {
|
||||||
|
await window.desktop.saveConfig({
|
||||||
|
homeworkBoard: {
|
||||||
|
mode: viewMode.value,
|
||||||
|
showTime: boardShowTime.value,
|
||||||
|
fontSize: boardFontSize.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
savingBoard.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openDetail(item: HomeworkDto): void {
|
function openDetail(item: HomeworkDto): void {
|
||||||
current.value = item;
|
current.value = item;
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCourseFullscreen(column: BoardColumn): void {
|
||||||
|
fullscreenCourse.value = column.course;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 覆盖全屏:整块看板放大(点击标题栏「全屏」按钮) */
|
||||||
|
function openBoardFullscreen(): void {
|
||||||
|
fullscreenCourse.value = null;
|
||||||
|
fullscreenBoard.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
if (appStore.offline) {
|
if (appStore.offline) {
|
||||||
@@ -77,15 +183,7 @@ async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
|||||||
completed: next,
|
completed: next,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
ElMessage.success(
|
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
|
||||||
auth.isClassSession
|
|
||||||
? next
|
|
||||||
? '已按全班标记为完成'
|
|
||||||
: '已取消全班的完成标记'
|
|
||||||
: next
|
|
||||||
? '已标记为完成'
|
|
||||||
: '已取消完成标记',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '提交失败');
|
ElMessage.error(error instanceof Error ? error.message : '提交失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -93,6 +191,66 @@ async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 未交名单(班级设备) */
|
||||||
|
|
||||||
|
const submissionsVisible = ref(false);
|
||||||
|
const submissionsLoading = ref(false);
|
||||||
|
const submissionsSaving = ref(false);
|
||||||
|
const submissionsTarget = ref<HomeworkDto | null>(null);
|
||||||
|
const submissions = ref<HomeworkSubmissionDto[]>([]);
|
||||||
|
/** 勾选 = 未交(保存后其余学生一律标记为已交) */
|
||||||
|
const notSubmittedIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
const notSubmittedCount = computed(() => notSubmittedIds.value.length);
|
||||||
|
|
||||||
|
async function openSubmissions(item: HomeworkDto): Promise<void> {
|
||||||
|
submissionsTarget.value = item;
|
||||||
|
submissionsVisible.value = true;
|
||||||
|
submissionsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await homeworkApi.submissions(item.id);
|
||||||
|
submissions.value = result.students;
|
||||||
|
// 默认沿用服务端已有的未交数据(老师只需改动的部分)
|
||||||
|
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '读取未交名单失败');
|
||||||
|
submissions.value = [];
|
||||||
|
notSubmittedIds.value = [];
|
||||||
|
} finally {
|
||||||
|
submissionsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllSubmitted(): void {
|
||||||
|
notSubmittedIds.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllNotSubmitted(): void {
|
||||||
|
notSubmittedIds.value = submissions.value.map((student) => student.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSubmissions(): Promise<void> {
|
||||||
|
const target = submissionsTarget.value;
|
||||||
|
if (!target) return;
|
||||||
|
if (appStore.offline) {
|
||||||
|
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submissionsSaving.value = true;
|
||||||
|
try {
|
||||||
|
const result = await homeworkApi.saveSubmissions(target.id, notSubmittedIds.value);
|
||||||
|
submissions.value = result.students;
|
||||||
|
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
|
||||||
|
target.completedCount = result.completedCount;
|
||||||
|
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
|
||||||
|
submissionsVisible.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存未交名单失败');
|
||||||
|
} finally {
|
||||||
|
submissionsSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onHomeworkEvent(): void {
|
function onHomeworkEvent(): void {
|
||||||
void loadHomework();
|
void loadHomework();
|
||||||
}
|
}
|
||||||
@@ -109,6 +267,7 @@ function onRecovered(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadBoardSettings();
|
||||||
await loadHomework();
|
await loadHomework();
|
||||||
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
||||||
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
||||||
@@ -119,6 +278,7 @@ onUnmounted(() => {
|
|||||||
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
||||||
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
||||||
appStore.offServerRecovered(onRecovered);
|
appStore.offServerRecovered(onRecovered);
|
||||||
|
void saveBoardSettings();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -138,12 +298,77 @@ onUnmounted(() => {
|
|||||||
<el-radio-button value="pending">未完成</el-radio-button>
|
<el-radio-button value="pending">未完成</el-radio-button>
|
||||||
<el-radio-button value="done">已完成</el-radio-button>
|
<el-radio-button value="done">已完成</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
<el-radio-group v-model="viewMode" @change="saveBoardSettings">
|
||||||
|
<el-radio-button value="board">看板</el-radio-button>
|
||||||
|
<el-radio-button value="list">列表</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
|
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 看板外观:显示时间 / 字号(即时生效并持久化) -->
|
||||||
|
<el-card v-if="viewMode === 'board'" shadow="never" class="board-config">
|
||||||
|
<div class="board-config-row">
|
||||||
|
<span class="board-config-label">看板设置</span>
|
||||||
|
<el-switch
|
||||||
|
v-model="boardShowTime"
|
||||||
|
active-text="显示时间"
|
||||||
|
inactive-text="隐藏时间"
|
||||||
|
@change="saveBoardSettings"
|
||||||
|
/>
|
||||||
|
<span class="board-config-label">字号</span>
|
||||||
|
<el-slider
|
||||||
|
v-model="boardFontSize"
|
||||||
|
:min="11"
|
||||||
|
:max="28"
|
||||||
|
:step="1"
|
||||||
|
style="width: 200px"
|
||||||
|
@change="saveBoardSettings"
|
||||||
|
/>
|
||||||
|
<span class="text-muted">{{ boardFontSize }}px</span>
|
||||||
|
<el-button :icon="'FullScreen'" @click="openBoardFullscreen">全屏看板</el-button>
|
||||||
|
<span v-if="savingBoard" class="text-muted">保存中…</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card v-loading="loading" shadow="never">
|
<el-card v-loading="loading" shadow="never">
|
||||||
<el-empty v-if="filtered.length === 0" description="没有符合条件的作业" />
|
<el-empty v-if="filtered.length === 0" description="没有符合条件的作业" />
|
||||||
|
|
||||||
|
<!-- 看板模式:按科目分卡片,点击卡片放大全屏、点击条目看详情 -->
|
||||||
|
<div v-else-if="viewMode === 'board'" class="board" :style="boardStyle">
|
||||||
|
<section
|
||||||
|
v-for="column in boardColumns"
|
||||||
|
:key="column.course"
|
||||||
|
class="board-card"
|
||||||
|
:class="{ 'has-pending': column.pending > 0 }"
|
||||||
|
@click="openCourseFullscreen(column)"
|
||||||
|
>
|
||||||
|
<header class="board-card-head">
|
||||||
|
<span class="board-course">{{ column.course }}</span>
|
||||||
|
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
|
||||||
|
</header>
|
||||||
|
<ol class="board-list">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in column.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="board-item"
|
||||||
|
:class="{ done: item.completed }"
|
||||||
|
@click.stop="openDetail(item)"
|
||||||
|
>
|
||||||
|
<span class="board-index">{{ index + 1 }}.</span>
|
||||||
|
<span class="board-title">{{ item.title }}</span>
|
||||||
|
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||||
|
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<footer class="board-card-foot">
|
||||||
|
<span class="text-muted">共 {{ column.items.length }} 条</span>
|
||||||
|
<span class="board-zoom">点击放大</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表模式(默认表格视图) -->
|
||||||
<el-table v-else :data="filtered" @row-click="openDetail">
|
<el-table v-else :data="filtered" @row-click="openDetail">
|
||||||
<el-table-column label="状态" width="90">
|
<el-table-column label="状态" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -156,29 +381,103 @@ onUnmounted(() => {
|
|||||||
<el-table-column label="课程" width="110">
|
<el-table-column label="课程" width="110">
|
||||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="130" fixed="right">
|
<el-table-column label="发布时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="auth.isClassSession" link type="primary" @click.stop="openSubmissions(row)">
|
||||||
|
未交名单
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-else
|
||||||
link
|
link
|
||||||
:type="row.completed ? 'warning' : 'primary'"
|
:type="row.completed ? 'warning' : 'primary'"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
@click.stop="toggleComplete(row)"
|
@click.stop="toggleComplete(row)"
|
||||||
>
|
>
|
||||||
{{
|
{{ row.completed ? '取消完成' : '标记完成' }}
|
||||||
row.completed
|
|
||||||
? auth.isClassSession
|
|
||||||
? '取消全班完成'
|
|
||||||
: '取消完成'
|
|
||||||
: auth.isClassSession
|
|
||||||
? '全班标记完成'
|
|
||||||
: '标记完成'
|
|
||||||
}}
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 全屏:单个科目放大 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="fullscreenCourse"
|
||||||
|
fullscreen
|
||||||
|
:title="`${fullscreenCourse ?? ''} · 作业`"
|
||||||
|
class="board-fullscreen"
|
||||||
|
>
|
||||||
|
<ol class="board-list board-list-lg" :style="fullscreenStyle">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in fullscreenItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="board-item"
|
||||||
|
:class="{ done: item.completed }"
|
||||||
|
@click="openDetail(item)"
|
||||||
|
>
|
||||||
|
<span class="board-index">{{ index + 1 }}.</span>
|
||||||
|
<span class="board-title">{{ item.title }}</span>
|
||||||
|
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||||
|
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<el-empty v-if="fullscreenItems.length === 0" description="该科目暂无作业" />
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 全屏:整块看板放大 -->
|
||||||
|
<el-dialog v-model="fullscreenBoard" fullscreen title="今日作业看板" class="board-fullscreen">
|
||||||
|
<div class="board board-lg" :style="fullscreenStyle">
|
||||||
|
<section v-for="column in boardColumns" :key="column.course" class="board-card">
|
||||||
|
<header class="board-card-head">
|
||||||
|
<span class="board-course">{{ column.course }}</span>
|
||||||
|
<span v-if="column.pending > 0" class="board-badge">{{ column.pending }}</span>
|
||||||
|
</header>
|
||||||
|
<ol class="board-list">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in column.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="board-item"
|
||||||
|
:class="{ done: item.completed }"
|
||||||
|
@click="openDetail(item)"
|
||||||
|
>
|
||||||
|
<span class="board-index">{{ index + 1 }}.</span>
|
||||||
|
<span class="board-title">{{ item.title }}</span>
|
||||||
|
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 未交名单:勾选谁没交(其余学生一律视为已交) -->
|
||||||
|
<el-dialog v-model="submissionsVisible" title="未交名单" width="520px">
|
||||||
|
<p class="text-muted" style="margin-top: 0">
|
||||||
|
勾选
|
||||||
|
<strong>没交作业</strong>
|
||||||
|
的同学,保存后其余同学会自动标记为已完成。
|
||||||
|
</p>
|
||||||
|
<div class="submission-toolbar">
|
||||||
|
<el-button size="small" @click="markAllSubmitted">全部已交</el-button>
|
||||||
|
<el-button size="small" @click="markAllNotSubmitted">全部未交</el-button>
|
||||||
|
<span class="text-muted">未交 {{ notSubmittedCount }} / {{ submissions.length }} 人</span>
|
||||||
|
</div>
|
||||||
|
<div v-loading="submissionsLoading" class="submission-list">
|
||||||
|
<el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" />
|
||||||
|
<el-checkbox-group v-model="notSubmittedIds">
|
||||||
|
<el-checkbox v-for="student in submissions" :key="student.userId" :value="student.userId">
|
||||||
|
{{ student.name }}({{ student.username }})
|
||||||
|
</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="submissionsVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submissionsSaving" @click="saveSubmissions">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
|
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
|
||||||
<template v-if="current">
|
<template v-if="current">
|
||||||
<el-descriptions :column="2" border size="small">
|
<el-descriptions :column="2" border size="small">
|
||||||
@@ -207,14 +506,21 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div class="mt-16">
|
<div class="mt-16">
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="auth.isClassSession"
|
||||||
|
type="primary"
|
||||||
|
:disabled="appStore.offline"
|
||||||
|
@click="openSubmissions(current)"
|
||||||
|
>
|
||||||
|
维护未交名单
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
:disabled="appStore.offline"
|
:disabled="appStore.offline"
|
||||||
@click="toggleComplete(current)"
|
@click="toggleComplete(current)"
|
||||||
>
|
>
|
||||||
{{
|
{{ current.completed ? '取消完成标记' : '标记为已完成' }}
|
||||||
current.completed ? '取消完成标记' : auth.isClassSession ? '全班标记为已完成' : '标记为已完成'
|
|
||||||
}}
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
|
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,8 +538,177 @@ onUnmounted(() => {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 看板 */
|
||||||
|
|
||||||
|
.board-config {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-config-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-config-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-lg {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #1c1c1e;
|
||||||
|
color: #f5f5f7;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: calc(var(--board-font, 15px) * 0.9) calc(var(--board-font, 15px) * 1.1);
|
||||||
|
min-height: 120px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
transition:
|
||||||
|
transform 0.16s ease,
|
||||||
|
box-shadow 0.16s ease;
|
||||||
|
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: calc(var(--board-font, 15px) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-course {
|
||||||
|
font-size: calc(var(--board-font, 15px) * 0.95);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-badge {
|
||||||
|
min-width: calc(var(--board-font, 15px) * 1.4);
|
||||||
|
height: calc(var(--board-font, 15px) * 1.4);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ff9f0a;
|
||||||
|
color: #1c1c1e;
|
||||||
|
font-size: calc(var(--board-font, 15px) * 0.72);
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(var(--board-font, 15px) * 0.35);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--board-font, 15px);
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-index {
|
||||||
|
color: #8e8e93;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-item.done .board-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #8e8e93;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: calc(var(--board-font, 15px) * 0.72);
|
||||||
|
color: #8e8e93;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-done-tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: calc(var(--board-font, 15px) * 0.66);
|
||||||
|
color: #32d74b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-card-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: calc(var(--board-font, 15px) * 0.5);
|
||||||
|
font-size: calc(var(--board-font, 15px) * 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-card-foot .text-muted {
|
||||||
|
color: #8e8e93;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-zoom {
|
||||||
|
color: #0a84ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-list-lg .board-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-list {
|
||||||
|
max-height: 46vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-list :deep(.el-checkbox-group) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
WEEKDAY_LABELS,
|
WEEKDAY_LABELS,
|
||||||
|
WEEK_PARITY_LABELS,
|
||||||
buildWeekOptions,
|
buildWeekOptions,
|
||||||
formatWeekRange,
|
formatWeekRange,
|
||||||
resolveClassStatus,
|
resolveClassStatus,
|
||||||
@@ -73,6 +74,8 @@ interface TimelineLesson {
|
|||||||
course: string;
|
course: string;
|
||||||
location: string;
|
location: string;
|
||||||
weekRange: string;
|
weekRange: string;
|
||||||
|
/** 单双周标签('单周' / '双周';每周的课为空串) */
|
||||||
|
parityLabel: string;
|
||||||
state: LessonState;
|
state: LessonState;
|
||||||
/** 进行中:距下课剩余;下一节:距上课剩余(分钟) */
|
/** 进行中:距下课剩余;下一节:距上课剩余(分钟) */
|
||||||
remainMinutes: number;
|
remainMinutes: number;
|
||||||
@@ -102,6 +105,8 @@ const timeline = computed<TimelineLesson[]>(() =>
|
|||||||
course: item.course?.name ?? '课程',
|
course: item.course?.name ?? '课程',
|
||||||
location: item.location ?? '未填地点',
|
location: item.location ?? '未填地点',
|
||||||
weekRange: formatWeekRange(item.weekStart, item.weekEnd),
|
weekRange: formatWeekRange(item.weekStart, item.weekEnd),
|
||||||
|
/** 单双周标签(每周的课不显示) */
|
||||||
|
parityLabel: (item.weekParity ?? 'ALL') === 'ALL' ? '' : WEEK_PARITY_LABELS[item.weekParity],
|
||||||
state,
|
state,
|
||||||
remainMinutes: state === 'current' ? end - nowMinutes : start - nowMinutes,
|
remainMinutes: state === 'current' ? end - nowMinutes : start - nowMinutes,
|
||||||
progress: state === 'current' ? Math.round(((nowMinutes - start) / Math.max(1, end - start)) * 100) : 0,
|
progress: state === 'current' ? Math.round(((nowMinutes - start) / Math.max(1, end - start)) * 100) : 0,
|
||||||
@@ -324,6 +329,13 @@ onUnmounted(() => {
|
|||||||
<span>{{ lesson.location }}</span>
|
<span>{{ lesson.location }}</span>
|
||||||
<span class="lesson-sep">·</span>
|
<span class="lesson-sep">·</span>
|
||||||
<span>{{ lesson.weekRange }}</span>
|
<span>{{ lesson.weekRange }}</span>
|
||||||
|
<!-- 单双周:只有单周/双周上的课才打标签,避免"每周"的课被噪音刷屏 -->
|
||||||
|
<template v-if="lesson.parityLabel">
|
||||||
|
<span class="lesson-sep">·</span>
|
||||||
|
<el-tag size="small" effect="plain" class="lesson-parity">
|
||||||
|
{{ lesson.parityLabel }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
<template v-if="lesson.state === 'current'">
|
<template v-if="lesson.state === 'current'">
|
||||||
<span class="lesson-sep">·</span>
|
<span class="lesson-sep">·</span>
|
||||||
<span class="lesson-remain">距下课 {{ remainText(lesson.remainMinutes) }}</span>
|
<span class="lesson-remain">距下课 {{ remainText(lesson.remainMinutes) }}</span>
|
||||||
@@ -623,6 +635,15 @@ onUnmounted(() => {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 单双周标签:比正文小一号,颜色跟随主题色,避免抢标题 */
|
||||||
|
.lesson-parity {
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ch-accent, #409eff);
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.lesson-remain {
|
.lesson-remain {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -4,6 +4,16 @@
|
|||||||
*/
|
*/
|
||||||
import type { IslandAppearance, IslandNotification, IslandState } from '@classhelper/shared';
|
import type { IslandAppearance, IslandNotification, IslandState } from '@classhelper/shared';
|
||||||
|
|
||||||
|
/** 作业页展示偏好(看板 / 列表,看板外观) */
|
||||||
|
export interface HomeworkBoardSettings {
|
||||||
|
/** 展示模式:board = 按科目卡片看板,list = 默认表格 */
|
||||||
|
mode: 'board' | 'list';
|
||||||
|
/** 看板条目是否显示时间 */
|
||||||
|
showTime: boolean;
|
||||||
|
/** 看板字号(px) */
|
||||||
|
fontSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopStoredConfig {
|
export interface DesktopStoredConfig {
|
||||||
/** 后端服务地址,例如 http://127.0.0.1:4000 */
|
/** 后端服务地址,例如 http://127.0.0.1:4000 */
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -13,6 +23,8 @@ export interface DesktopStoredConfig {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
/** 个性化设置:灵动岛外观(高度/宽度/圆角/透明度/主题色/字号/动画/位置/置顶) */
|
/** 个性化设置:灵动岛外观(高度/宽度/圆角/透明度/主题色/字号/动画/位置/置顶) */
|
||||||
island: IslandAppearance;
|
island: IslandAppearance;
|
||||||
|
/** 作业页展示偏好(看板/列表、显示时间、看板字号) */
|
||||||
|
homeworkBoard: HomeworkBoardSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopAppInfo {
|
export interface DesktopAppInfo {
|
||||||
|
|||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
-- 课表支持单双周:新增 weekParity(ALL=每周 / ODD=单周 / EVEN=双周)
|
||||||
|
-- 旧数据默认为 'ALL',行为与升级前完全一致(向后兼容)。
|
||||||
|
|
||||||
|
ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL';
|
||||||
@@ -137,6 +137,8 @@ model Schedule {
|
|||||||
weekStart Int @default(1)
|
weekStart Int @default(1)
|
||||||
/// 学期结束周次(含)
|
/// 学期结束周次(含)
|
||||||
weekEnd Int @default(20)
|
weekEnd Int @default(20)
|
||||||
|
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
|
||||||
|
weekParity String @default("ALL")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ async function main(): Promise<void> {
|
|||||||
if (!course) continue;
|
if (!course) continue;
|
||||||
// 前 2 节限制在第 1 周(演示按周筛选)
|
// 前 2 节限制在第 1 周(演示按周筛选)
|
||||||
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
|
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
|
||||||
|
// 周二第 3 节演示"单双周":单周上次课表里的科目、双周上一门别的科目
|
||||||
|
const paritySlot = dayOfWeek === 2 && periodIndex === 2;
|
||||||
await prisma.schedule.create({
|
await prisma.schedule.create({
|
||||||
data: {
|
data: {
|
||||||
classId: targetClass.id,
|
classId: targetClass.id,
|
||||||
@@ -179,8 +181,28 @@ async function main(): Promise<void> {
|
|||||||
location: targetClass.room,
|
location: targetClass.room,
|
||||||
weekStart: 1,
|
weekStart: 1,
|
||||||
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
|
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
|
||||||
|
weekParity: paritySlot ? 'ODD' : 'ALL',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (paritySlot) {
|
||||||
|
// 双周同一节换另一门课,用于演示"单双周课表"(第 1 周单周、第 2 周双周)
|
||||||
|
const evenCourse = classCourses[(dayOfWeek + periodIndex + 2) % classCourses.length];
|
||||||
|
if (evenCourse) {
|
||||||
|
await prisma.schedule.create({
|
||||||
|
data: {
|
||||||
|
classId: targetClass.id,
|
||||||
|
courseId: evenCourse.id,
|
||||||
|
dayOfWeek,
|
||||||
|
startTime: period.startTime,
|
||||||
|
endTime: period.endTime,
|
||||||
|
location: targetClass.room,
|
||||||
|
weekStart: 1,
|
||||||
|
weekEnd: MAX_TERM_WEEK,
|
||||||
|
weekParity: 'EVEN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 维护脚本:给"已有数据库"补上 Schedule.weekParity 列(单双周课表)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表);
|
||||||
|
* 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist**
|
||||||
|
* 部署,就需要显式跑一次本脚本。
|
||||||
|
*
|
||||||
|
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
|
||||||
|
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
|
||||||
|
*
|
||||||
|
* node apply-parity-column.cjs "<安装目录>/server/dist/lib/db.js"
|
||||||
|
*
|
||||||
|
* 环境变量(与服务端一致):
|
||||||
|
* DATABASE_PROVIDER=sqlite
|
||||||
|
* DATABASE_URL="file:<绝对路径>/classhelper.db"
|
||||||
|
*/
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
|
|
||||||
|
const dbEntry = process.argv[2];
|
||||||
|
if (!dbEntry) {
|
||||||
|
console.error('用法:node apply-parity-column.cjs <已编译的 lib/db.js 绝对路径>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let prisma;
|
||||||
|
try {
|
||||||
|
const module = await import(pathToFileURL(dbEntry).href);
|
||||||
|
prisma = module.prisma ?? module.default?.prisma;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[parity] 无法加载服务端的 Prisma 客户端:',
|
||||||
|
error && error.message ? error.message : error,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!prisma) {
|
||||||
|
console.error('[parity] lib/db.js 里没有导出 prisma');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columns = await prisma.$queryRawUnsafe('PRAGMA table_info("Schedule")');
|
||||||
|
if (columns.some((column) => column.name === 'weekParity')) {
|
||||||
|
console.log('[parity] Schedule.weekParity 已存在,无需处理');
|
||||||
|
} else {
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL'`,
|
||||||
|
);
|
||||||
|
console.log('[parity] 已新增 Schedule.weekParity(默认 ALL,旧数据行为不变)');
|
||||||
|
}
|
||||||
|
const after = await prisma.$queryRawUnsafe('PRAGMA table_info("Schedule")');
|
||||||
|
console.log(`[parity] 当前列:${after.map((column) => column.name).join(', ')}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[parity] 失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect?.();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -143,6 +143,199 @@ async function main() {
|
|||||||
`第 1 周 ${totalSlots} 节课`,
|
`第 1 周 ${totalSlots} 节课`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 2.1 单双周课表 */
|
||||||
|
// 第 1 周是单周、第 2 周是双周(与 ClassIsland 的 WeekCountDiv 语义一致)
|
||||||
|
const parityCourseId = courseId ?? courses.payload?.data?.[0]?.id;
|
||||||
|
const parityCreate = await api('/schedules', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId,
|
||||||
|
courseId: parityCourseId,
|
||||||
|
dayOfWeek: 6,
|
||||||
|
startTime: '07:00',
|
||||||
|
endTime: '07:45',
|
||||||
|
weekStart: 1,
|
||||||
|
weekEnd: 20,
|
||||||
|
weekParity: 'ODD',
|
||||||
|
location: '单双周校验 · 单周',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'创建单周课表(weekParity=ODD)',
|
||||||
|
parityCreate.status === 201 && parityCreate.payload?.data?.weekParity === 'ODD',
|
||||||
|
`status=${parityCreate.status} weekParity=${parityCreate.payload?.data?.weekParity ?? '-'}`,
|
||||||
|
);
|
||||||
|
const parityId = parityCreate.payload?.data?.id;
|
||||||
|
const oddGrid = await api(`/schedules/grid?classId=${classId}&week=1`, { token: teacherToken });
|
||||||
|
const evenGrid = await api(`/schedules/grid?classId=${classId}&week=2`, { token: teacherToken });
|
||||||
|
const inGrid = (payload) =>
|
||||||
|
(payload?.data?.columns ?? []).some((col) => (col.items ?? []).some((item) => item.id === parityId));
|
||||||
|
record(
|
||||||
|
'单周课表只出现在单周(week=1 有、week=2 无)',
|
||||||
|
oddGrid.status === 200 && evenGrid.status === 200 && inGrid(oddGrid.payload) && !inGrid(evenGrid.payload),
|
||||||
|
`week1=${inGrid(oddGrid.payload)} week2=${inGrid(evenGrid.payload)}`,
|
||||||
|
);
|
||||||
|
// 补一条双周课(同一时段),验证两周各出现一节、互不冲突
|
||||||
|
const evenCreate = await api('/schedules', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId,
|
||||||
|
courseId: parityCourseId,
|
||||||
|
dayOfWeek: 6,
|
||||||
|
startTime: '07:00',
|
||||||
|
endTime: '07:45',
|
||||||
|
weekStart: 1,
|
||||||
|
weekEnd: 20,
|
||||||
|
weekParity: 'EVEN',
|
||||||
|
location: '单双周校验 · 双周',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const evenId = evenCreate.payload?.data?.id;
|
||||||
|
const oddGrid2 = await api(`/schedules/grid?classId=${classId}&week=1`, { token: teacherToken });
|
||||||
|
const evenGrid2 = await api(`/schedules/grid?classId=${classId}&week=2`, { token: teacherToken });
|
||||||
|
const countAt = (payload, id) =>
|
||||||
|
(payload?.data?.columns ?? []).reduce(
|
||||||
|
(sum, col) => sum + (col.items ?? []).filter((item) => item.id === id).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'同一时段的单周/双周课各归各周(互不串周)',
|
||||||
|
evenCreate.status === 201 &&
|
||||||
|
countAt(oddGrid2.payload, parityId) === 1 &&
|
||||||
|
countAt(oddGrid2.payload, evenId) === 0 &&
|
||||||
|
countAt(evenGrid2.payload, parityId) === 0 &&
|
||||||
|
countAt(evenGrid2.payload, evenId) === 1,
|
||||||
|
`week1 单周=${countAt(oddGrid2.payload, parityId)} 双周=${countAt(oddGrid2.payload, evenId)};` +
|
||||||
|
`week2 单周=${countAt(evenGrid2.payload, parityId)} 双周=${countAt(evenGrid2.payload, evenId)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 2.2 ClassIsland 课程表导入(含单双周) */
|
||||||
|
// 真实档案结构:TimeLayouts / ClassPlans / Subjects 都是 Guid 字典,单双周=两条 ClassPlan
|
||||||
|
const classIslandProfile = {
|
||||||
|
Name: '导入校验档案',
|
||||||
|
TimeLayouts: {
|
||||||
|
'11111111-1111-4111-8111-111111111111': {
|
||||||
|
Name: '周一作息',
|
||||||
|
Layouts: [
|
||||||
|
{ StartTime: '08:00:00', EndTime: '08:45:00', TimeType: 0, BreakName: '' },
|
||||||
|
{ StartTime: '08:45:00', EndTime: '08:55:00', TimeType: 1, BreakName: '课间' },
|
||||||
|
{ StartTime: '08:55:00', EndTime: '09:40:00', TimeType: 0, BreakName: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ClassPlans: {
|
||||||
|
'aaaaaaaa-0001-4000-8000-000000000001': {
|
||||||
|
Name: '周一-单周',
|
||||||
|
TimeLayoutId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
TimeRule: { WeekDay: 1, WeekCountDiv: 1, WeekCountDivTotal: 2 },
|
||||||
|
Classes: [
|
||||||
|
{ SubjectId: 'bbbbbbbb-0001-4000-8000-000000000001', IsEnabled: true },
|
||||||
|
{ SubjectId: 'bbbbbbbb-0002-4000-8000-000000000002', IsEnabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'aaaaaaaa-0002-4000-8000-000000000002': {
|
||||||
|
Name: '周一-双周',
|
||||||
|
TimeLayoutId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
TimeRule: { WeekDay: 1, WeekCountDiv: 2, WeekCountDivTotal: 2 },
|
||||||
|
Classes: [
|
||||||
|
{ SubjectId: 'bbbbbbbb-0001-4000-8000-000000000001', IsEnabled: true },
|
||||||
|
{ SubjectId: 'bbbbbbbb-0003-4000-8000-000000000003', IsEnabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Subjects: {
|
||||||
|
'bbbbbbbb-0001-4000-8000-000000000001': { Name: '导入校验语文', TeacherName: '王芳' },
|
||||||
|
'bbbbbbbb-0002-4000-8000-000000000002': { Name: '导入校验数学', TeacherName: '李梅' },
|
||||||
|
'bbbbbbbb-0003-4000-8000-000000000003': { Name: '导入校验英语', TeacherName: '张伟' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const classPlanPreview = await api('/imports/class-plan/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { classId, mode: 'merge', payload: classIslandProfile },
|
||||||
|
});
|
||||||
|
const previewEntries = classPlanPreview.payload?.data?.entries ?? [];
|
||||||
|
record(
|
||||||
|
'ClassIsland 课程表预览:解析出 4 节(单周 2 / 双周 2)',
|
||||||
|
classPlanPreview.status === 200 &&
|
||||||
|
previewEntries.length === 4 &&
|
||||||
|
previewEntries.filter((item) => item.weekParity === 'ODD').length === 2 &&
|
||||||
|
previewEntries.filter((item) => item.weekParity === 'EVEN').length === 2 &&
|
||||||
|
previewEntries.every((item) => item.dayOfWeek === 1),
|
||||||
|
`status=${classPlanPreview.status} 共 ${previewEntries.length} 节 · ` +
|
||||||
|
`单周 ${previewEntries.filter((item) => item.weekParity === 'ODD').length} / ` +
|
||||||
|
`双周 ${previewEntries.filter((item) => item.weekParity === 'EVEN').length}` +
|
||||||
|
`${classPlanPreview.payload?.data?.errors?.length ? ` 错误=${classPlanPreview.payload.data.errors.join(';')}` : ''}`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'课程表预览会列出需要自动补建的科目',
|
||||||
|
(classPlanPreview.payload?.data?.missingSubjects ?? []).length === 3,
|
||||||
|
`待建科目=[${(classPlanPreview.payload?.data?.missingSubjects ?? []).join('、')}]`,
|
||||||
|
);
|
||||||
|
const classPlanImport = await api('/imports/class-plan', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { classId, mode: 'merge', payload: classIslandProfile },
|
||||||
|
});
|
||||||
|
const importResult = classPlanImport.payload?.data;
|
||||||
|
record(
|
||||||
|
'导入 ClassIsland 课程表:4 节写入并自动补建 3 门课程',
|
||||||
|
classPlanImport.status === 200 &&
|
||||||
|
(importResult?.created ?? 0) + (importResult?.updated ?? 0) === 4 &&
|
||||||
|
(importResult?.createdCourses ?? []).length === 3,
|
||||||
|
`新增=${importResult?.created ?? '-'} 更新=${importResult?.updated ?? '-'} ` +
|
||||||
|
`补建=[${(importResult?.createdCourses ?? []).join('、')}]`,
|
||||||
|
);
|
||||||
|
// 导入结果里单双周必须落库:第 1 周只有单周两节、第 2 周只有双周两节
|
||||||
|
const oddAfterImport = await api(`/schedules/grid?classId=${classId}&week=1`, { token: teacherToken });
|
||||||
|
const evenAfterImport = await api(`/schedules/grid?classId=${classId}&week=2`, { token: teacherToken });
|
||||||
|
const importedAt = (payload, parity) =>
|
||||||
|
(payload?.data?.columns ?? []).reduce(
|
||||||
|
(sum, col) =>
|
||||||
|
sum +
|
||||||
|
(col.items ?? []).filter(
|
||||||
|
(item) =>
|
||||||
|
item.location === null &&
|
||||||
|
item.weekParity === parity &&
|
||||||
|
item.dayOfWeek === 1 &&
|
||||||
|
item.startTime < '09:00',
|
||||||
|
).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'导入后的单双周课表按周正确落位(第 1 周 2 节单周、第 2 周 2 节双周)',
|
||||||
|
importedAt(oddAfterImport.payload, 'ODD') >= 2 &&
|
||||||
|
importedAt(evenAfterImport.payload, 'ODD') === 0 &&
|
||||||
|
importedAt(evenAfterImport.payload, 'EVEN') >= 2 &&
|
||||||
|
importedAt(oddAfterImport.payload, 'EVEN') === 0,
|
||||||
|
`week1 单周=${importedAt(oddAfterImport.payload, 'ODD')} 双周=${importedAt(oddAfterImport.payload, 'EVEN')};` +
|
||||||
|
`week2 单周=${importedAt(evenAfterImport.payload, 'ODD')} 双周=${importedAt(evenAfterImport.payload, 'EVEN')}`,
|
||||||
|
);
|
||||||
|
// 清理:按"位置/科目名前缀"识别本次写入的数据,避免误删演示课表
|
||||||
|
const allSchedules = await api(`/schedules?classId=${classId}`, { token: teacherToken });
|
||||||
|
let removedSchedules = 0;
|
||||||
|
for (const item of allSchedules.payload?.data ?? []) {
|
||||||
|
const isParityProbe = String(item.location ?? '').startsWith('单双周校验');
|
||||||
|
const isImported = String(item.course?.name ?? '').startsWith('导入校验');
|
||||||
|
if (!isParityProbe && !isImported) continue;
|
||||||
|
const removed = await api(`/schedules/${item.id}`, { method: 'DELETE', token: teacherToken });
|
||||||
|
if (removed.status === 200) removedSchedules += 1;
|
||||||
|
}
|
||||||
|
const allCourses = await api(`/courses?classId=${classId}`, { token: teacherToken });
|
||||||
|
let removedImportedCourses = 0;
|
||||||
|
for (const course of allCourses.payload?.data ?? []) {
|
||||||
|
if (!String(course.name).startsWith('导入校验')) continue;
|
||||||
|
const removed = await api(`/courses/${course.id}`, { method: 'DELETE', token: teacherToken });
|
||||||
|
if (removed.status === 200) removedImportedCourses += 1;
|
||||||
|
}
|
||||||
|
record(
|
||||||
|
'清理单双周校验与导入数据',
|
||||||
|
removedSchedules >= 6,
|
||||||
|
`删除课表 ${removedSchedules} 条、课程 ${removedImportedCourses} 门`,
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 3. WebSocket 连接
|
// ---------------------------------------------------------------- 3. WebSocket 连接
|
||||||
const socket = io(BASE_URL, {
|
const socket = io(BASE_URL, {
|
||||||
auth: { token: studentToken, clientType: 'desktop' },
|
auth: { token: studentToken, clientType: 'desktop' },
|
||||||
@@ -227,6 +420,59 @@ async function main() {
|
|||||||
`status=${statusUpdate.status}`,
|
`status=${statusUpdate.status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 5.1 未交名单 */
|
||||||
|
const submissions = await api(`/homeworks/${homeworkId}/submissions`, { token: teacherToken });
|
||||||
|
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.userId);
|
||||||
|
record(
|
||||||
|
'教师可读取作业提交名单(全班学生 + 完成状态)',
|
||||||
|
submissions.status === 200 &&
|
||||||
|
studentIds.length > 0 &&
|
||||||
|
(submissions.payload?.data?.total ?? 0) === studentIds.length,
|
||||||
|
`status=${submissions.status} 全班 ${submissions.payload?.data?.total ?? '-'} 人 · ` +
|
||||||
|
`已完成 ${submissions.payload?.data?.completedCount ?? '-'} 人`,
|
||||||
|
);
|
||||||
|
// 勾选"未交名单":只留第一位学生未交,其余一律视为已交
|
||||||
|
const firstStudentId = studentIds[0];
|
||||||
|
const savedSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { notSubmittedUserIds: [firstStudentId] },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'保存未交名单:勾选者标记未交、其余学生自动标记已交',
|
||||||
|
savedSubmissions.status === 200 &&
|
||||||
|
(savedSubmissions.payload?.data?.notSubmitted ?? []).length === 1 &&
|
||||||
|
savedSubmissions.payload?.data?.notSubmitted?.[0]?.userId === firstStudentId &&
|
||||||
|
savedSubmissions.payload?.data?.completedCount === studentIds.length - 1,
|
||||||
|
`status=${savedSubmissions.status} 未交=${(savedSubmissions.payload?.data?.notSubmitted ?? []).length} ` +
|
||||||
|
`已完成=${savedSubmissions.payload?.data?.completedCount ?? '-'}/${savedSubmissions.payload?.data?.total ?? '-'}`,
|
||||||
|
);
|
||||||
|
// 全部已交:未交名单清空
|
||||||
|
const allSubmitted = await api(`/homeworks/${homeworkId}/submissions`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { notSubmittedUserIds: [] },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'未交名单可一键清空(全部已交)',
|
||||||
|
allSubmitted.status === 200 &&
|
||||||
|
(allSubmitted.payload?.data?.notSubmitted ?? []).length === 0 &&
|
||||||
|
allSubmitted.payload?.data?.completedCount === studentIds.length,
|
||||||
|
`未交=${(allSubmitted.payload?.data?.notSubmitted ?? []).length} ` +
|
||||||
|
`已完成=${allSubmitted.payload?.data?.completedCount ?? '-'}/${allSubmitted.payload?.data?.total ?? '-'}`,
|
||||||
|
);
|
||||||
|
// 越权:普通学生不能维护未交名单(只能标记自己)
|
||||||
|
const studentSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: studentToken,
|
||||||
|
body: { notSubmittedUserIds: [] },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'普通学生不能维护未交名单(403)',
|
||||||
|
studentSubmissions.status === 403,
|
||||||
|
`status=${studentSubmissions.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
const myHomeworks = await api(`/homeworks?classId=${classId}`, { token: studentToken });
|
const myHomeworks = await api(`/homeworks?classId=${classId}`, { token: studentToken });
|
||||||
const markedHomework = (myHomeworks.payload?.data ?? []).find((item) => item.id === homeworkId);
|
const markedHomework = (myHomeworks.payload?.data ?? []).find((item) => item.id === homeworkId);
|
||||||
record(
|
record(
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export interface ScheduleLike {
|
|||||||
location: string | null;
|
location: string | null;
|
||||||
weekStart: number;
|
weekStart: number;
|
||||||
weekEnd: number;
|
weekEnd: number;
|
||||||
|
weekParity: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
course?: CourseBriefLike | null;
|
course?: CourseBriefLike | null;
|
||||||
}
|
}
|
||||||
@@ -168,6 +169,7 @@ export function toScheduleDto(item: ScheduleLike): ScheduleDto {
|
|||||||
location: item.location,
|
location: item.location,
|
||||||
weekStart: item.weekStart,
|
weekStart: item.weekStart,
|
||||||
weekEnd: item.weekEnd,
|
weekEnd: item.weekEnd,
|
||||||
|
weekParity: (item.weekParity ?? 'ALL') as ScheduleDto['weekParity'],
|
||||||
createdAt: toIso(item.createdAt),
|
createdAt: toIso(item.createdAt),
|
||||||
course: item.course ? toCourseBrief(item.course) : null,
|
course: item.course ? toCourseBrief(item.course) : null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
listHomeworksQuerySchema,
|
listHomeworksQuerySchema,
|
||||||
updateHomeworkSchema,
|
updateHomeworkSchema,
|
||||||
updateHomeworkStatusSchema,
|
updateHomeworkStatusSchema,
|
||||||
|
updateHomeworkSubmissionsSchema,
|
||||||
type CreateHomeworkInput,
|
type CreateHomeworkInput,
|
||||||
type UpdateHomeworkInput,
|
type UpdateHomeworkInput,
|
||||||
type UpdateHomeworkStatusInput,
|
type UpdateHomeworkStatusInput,
|
||||||
|
type UpdateHomeworkSubmissionsInput,
|
||||||
} from './homeworks.schemas.js';
|
} from './homeworks.schemas.js';
|
||||||
import * as homeworkService from './homeworks.service.js';
|
import * as homeworkService from './homeworks.service.js';
|
||||||
|
|
||||||
@@ -97,6 +99,30 @@ router.patch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
|
||||||
|
router.get('/:id/submissions', validate({ params: idParamSchema }), async (req, res) => {
|
||||||
|
const user = getAuthUser(req);
|
||||||
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
|
const result = await homeworkService.listHomeworkSubmissions(user, id);
|
||||||
|
sendOk(res, result, `未交 ${result.notSubmitted.length} / ${result.total} 人`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
|
||||||
|
router.patch(
|
||||||
|
'/:id/submissions',
|
||||||
|
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
|
||||||
|
async (req, res) => {
|
||||||
|
const user = getAuthUser(req);
|
||||||
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
|
const result = await homeworkService.updateHomeworkSubmissions(
|
||||||
|
user,
|
||||||
|
id,
|
||||||
|
req.body as UpdateHomeworkSubmissionsInput,
|
||||||
|
);
|
||||||
|
sendOk(res, result, `已保存未交名单(未交 ${result.notSubmitted.length} 人)`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const homeworksModule = defineModule({
|
export const homeworksModule = defineModule({
|
||||||
name: 'homeworks',
|
name: 'homeworks',
|
||||||
basePath: '/homeworks',
|
basePath: '/homeworks',
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export const updateHomeworkStatusSchema = z.object({
|
|||||||
userId: z.string().min(1).optional(),
|
userId: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 保存"未交名单":列出未交的学生 id(其余学生一律标记为已交) */
|
||||||
|
export const updateHomeworkSubmissionsSchema = z.object({
|
||||||
|
notSubmittedUserIds: z.array(z.string().min(1)).max(200).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
|
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
|
||||||
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
|
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
|
||||||
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
|
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
|
||||||
|
export type UpdateHomeworkSubmissionsInput = z.infer<typeof updateHomeworkSubmissionsSchema>;
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
|
import {
|
||||||
|
SOCKET_EVENTS,
|
||||||
|
type HomeworkDto,
|
||||||
|
type HomeworkStatusDto,
|
||||||
|
type HomeworkSubmissionDto,
|
||||||
|
type HomeworkSubmissionsDto,
|
||||||
|
} from '@classhelper/shared';
|
||||||
import {
|
import {
|
||||||
assertCanPublishContent,
|
assertCanPublishContent,
|
||||||
assertClassAccess,
|
assertClassAccess,
|
||||||
@@ -10,12 +16,13 @@ import { prisma } from '../../lib/db.js';
|
|||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
|
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
|
||||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
import { isClassSession, personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||||
import { emitToClass } from '../../realtime/bus.js';
|
import { emitToClass } from '../../realtime/bus.js';
|
||||||
import type {
|
import type {
|
||||||
CreateHomeworkInput,
|
CreateHomeworkInput,
|
||||||
UpdateHomeworkInput,
|
UpdateHomeworkInput,
|
||||||
UpdateHomeworkStatusInput,
|
UpdateHomeworkStatusInput,
|
||||||
|
UpdateHomeworkSubmissionsInput,
|
||||||
} from './homeworks.schemas.js';
|
} from './homeworks.schemas.js';
|
||||||
|
|
||||||
const courseSelect = { select: { id: true, name: true } } as const;
|
const courseSelect = { select: { id: true, name: true } } as const;
|
||||||
@@ -213,6 +220,106 @@ export async function updateHomeworkStatus(
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ 未交名单 */
|
||||||
|
|
||||||
|
/** 组装某次作业的全班提交名单(未交名单 = completed=false 的那些) */
|
||||||
|
async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> {
|
||||||
|
const [students, statuses] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { classId, role: 'STUDENT' },
|
||||||
|
select: { id: true, name: true, username: true },
|
||||||
|
orderBy: { username: 'asc' },
|
||||||
|
}),
|
||||||
|
prisma.homeworkStatus.findMany({ where: { homeworkId }, select: { userId: true, completed: true } }),
|
||||||
|
]);
|
||||||
|
const completedBy = new Map(statuses.map((item) => [item.userId, item.completed]));
|
||||||
|
const list: HomeworkSubmissionDto[] = students.map((item) => ({
|
||||||
|
userId: item.id,
|
||||||
|
name: item.name || item.username,
|
||||||
|
username: item.username,
|
||||||
|
completed: completedBy.get(item.id) === true,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
homeworkId,
|
||||||
|
classId,
|
||||||
|
total: list.length,
|
||||||
|
completedCount: list.filter((item) => item.completed).length,
|
||||||
|
notSubmitted: list.filter((item) => !item.completed),
|
||||||
|
students: list,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有教师 / 管理员 / 班级设备可以维护"未交名单"(普通学生只能标记自己) */
|
||||||
|
function assertCanManageSubmissions(user: TokenPayload): void {
|
||||||
|
if (user.role === 'ADMIN' || user.role === 'TEACHER') return;
|
||||||
|
if (isClassSession(user)) return;
|
||||||
|
throw ApiError.forbidden('只有教师或班级设备可以维护未交名单');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取作业提交名单(教师 / 班级设备) */
|
||||||
|
export async function listHomeworkSubmissions(
|
||||||
|
user: TokenPayload,
|
||||||
|
homeworkId: string,
|
||||||
|
): Promise<HomeworkSubmissionsDto> {
|
||||||
|
const homework = await prisma.homework.findUnique({
|
||||||
|
where: { id: homeworkId },
|
||||||
|
select: { id: true, classId: true },
|
||||||
|
});
|
||||||
|
if (!homework) throw ApiError.notFound('作业不存在');
|
||||||
|
await assertClassAccess(user, homework.classId);
|
||||||
|
assertCanManageSubmissions(user);
|
||||||
|
return buildSubmissions(homework.classId, homeworkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存"未交名单":勾选的学生标记未完成,其余学生一律标记完成。
|
||||||
|
* 这样班级设备的操作语义是"点名谁没交",比逐个勾"谁交了"更省事。
|
||||||
|
*/
|
||||||
|
export async function updateHomeworkSubmissions(
|
||||||
|
user: TokenPayload,
|
||||||
|
homeworkId: string,
|
||||||
|
input: UpdateHomeworkSubmissionsInput,
|
||||||
|
): Promise<HomeworkSubmissionsDto> {
|
||||||
|
const homework = await prisma.homework.findUnique({
|
||||||
|
where: { id: homeworkId },
|
||||||
|
select: { id: true, classId: true },
|
||||||
|
});
|
||||||
|
if (!homework) throw ApiError.notFound('作业不存在');
|
||||||
|
await assertClassAccess(user, homework.classId);
|
||||||
|
assertCanManageSubmissions(user);
|
||||||
|
|
||||||
|
const students = await prisma.user.findMany({
|
||||||
|
where: { classId: homework.classId, role: 'STUDENT' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生账号');
|
||||||
|
|
||||||
|
const notSubmitted = new Set(input.notSubmittedUserIds);
|
||||||
|
const studentIds = new Set(students.map((item) => item.id));
|
||||||
|
const outsiders = [...notSubmitted].filter((id) => !studentIds.has(id));
|
||||||
|
if (outsiders.length > 0) throw ApiError.badRequest('未交名单里包含非本班学生');
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
students.map((item) =>
|
||||||
|
prisma.homeworkStatus.upsert({
|
||||||
|
where: { homeworkId_userId: { homeworkId, userId: item.id } },
|
||||||
|
create: { homeworkId, userId: item.id, completed: !notSubmitted.has(item.id) },
|
||||||
|
update: { completed: !notSubmitted.has(item.id) },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await buildSubmissions(homework.classId, homeworkId);
|
||||||
|
// 实时刷新教师端/学生端的完成情况
|
||||||
|
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, {
|
||||||
|
homeworkId,
|
||||||
|
classId: homework.classId,
|
||||||
|
completedCount: result.completedCount,
|
||||||
|
total: result.total,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
|
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
|
||||||
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
|
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
|
||||||
if (!course) throw ApiError.badRequest('课程不存在');
|
if (!course) throw ApiError.badRequest('课程不存在');
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
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 { ClassPlanImportInput } from './imports.schemas.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClassIsland「课程表(ClassPlan)」导入 —— 支持单双周。
|
||||||
|
*
|
||||||
|
* 真实档案 JSON(`Profiles/<档案名>.json`)的结构(PascalCase,均为 "Guid → 对象" 字典):
|
||||||
|
*
|
||||||
|
* ```jsonc
|
||||||
|
* {
|
||||||
|
* "TimeLayouts": { "<guid>": { "Name": "周一作息", "Layouts": [ { "StartTime": "08:00:00", "EndTime": "08:45:00", "TimeType": 0 } ] } },
|
||||||
|
* "ClassPlans": { "<guid>": { "TimeLayoutId": "<guid>", "TimeRule": { "WeekDay": 1, "WeekCountDiv": 1, "WeekCountDivTotal": 2 }, "Classes": [ { "SubjectId": "<guid>" } ] } },
|
||||||
|
* "Subjects": { "<guid>": { "Name": "语文", "TeacherName": "王老师" } }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 关键语义(与 ClassIsland 源码一致):
|
||||||
|
* - `TimeRule.WeekDay`:0=周日、1=周一 … 6=周六;
|
||||||
|
* - `TimeRule.WeekCountDiv`:0=每周;1=单周;2=双周(`WeekCountDivTotal=2` 时);
|
||||||
|
* 一般规则:`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based);
|
||||||
|
* - `Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0`(上课)的点**,课间/分割线不占位;
|
||||||
|
* - 判周相位("第 1 周是单周还是双周")不在 JSON 里,由我们自己的学期起始周决定。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 解析出来的单条课程(= 一个 Schedule 行) */
|
||||||
|
export interface ParsedClassPlanEntry {
|
||||||
|
/** 1=周一 … 7=周日(已从 ClassIsland 的 0=周日 转换) */
|
||||||
|
dayOfWeek: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
/** 科目名(Subjects 字典里的 Name) */
|
||||||
|
subject: string;
|
||||||
|
/** 教师名(可选,仅预览展示) */
|
||||||
|
teacherName: string;
|
||||||
|
/** 单双周 */
|
||||||
|
weekParity: 'ALL' | 'ODD' | 'EVEN';
|
||||||
|
/** 原始 WeekCountDiv / WeekCountDivTotal,便于预览里解释来源 */
|
||||||
|
weekCountDiv: number;
|
||||||
|
weekCountDivTotal: number;
|
||||||
|
/** 来源课表名(ClassPlan.Name),方便老师核对 */
|
||||||
|
planName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassPlanParseResult {
|
||||||
|
entries: ParsedClassPlanEntry[];
|
||||||
|
/** 解析到的科目名(去重) */
|
||||||
|
subjects: string[];
|
||||||
|
/** 需要新建的科目(班级里还没有对应课程) */
|
||||||
|
missingSubjects: string[];
|
||||||
|
/** 时间表名(多个时间表时列出) */
|
||||||
|
layoutNames: string[];
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "08:00:00" / "8:0" / 数字秒 → HH:mm */
|
||||||
|
function normalizeTime(value: unknown): string | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
const seconds = value > 100 ? value : value * 60;
|
||||||
|
const hour = Math.floor(seconds / 3600) % 24;
|
||||||
|
const minute = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `WeekCountDiv` / `WeekCountDivTotal` → 我们课表的 weekParity。
|
||||||
|
* 只支持 2 周循环(=单双周);3 周及以上循环无法用单双周表达,记 warning 并按"每周"处理。
|
||||||
|
*/
|
||||||
|
function resolveParity(div: number, total: number): 'ALL' | 'ODD' | 'EVEN' {
|
||||||
|
if (div <= 0 || total <= 1) return 'ALL';
|
||||||
|
const position = ((div - 1) % total) + 1;
|
||||||
|
if (total === 2) return position === 1 ? 'ODD' : 'EVEN';
|
||||||
|
return 'ALL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 ClassIsland 档案 JSON(或只含 ClassPlans 的片段)里的课程表 */
|
||||||
|
export function parseClassIslandClassPlan(input: unknown): ClassPlanParseResult {
|
||||||
|
const result: ClassPlanParseResult = {
|
||||||
|
entries: [],
|
||||||
|
subjects: [],
|
||||||
|
missingSubjects: [],
|
||||||
|
layoutNames: [],
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload: unknown = input;
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(payload);
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`JSON 解析失败:${(error as Error).message}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const root = asRecord(payload);
|
||||||
|
if (!root) {
|
||||||
|
result.errors.push(
|
||||||
|
'无法识别的 JSON 结构:期望 ClassIsland 档案对象(含 TimeLayouts / ClassPlans / Subjects)',
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 科目字典:Guid → { Name, TeacherName }
|
||||||
|
const subjectDict = asRecord(root.Subjects) ?? {};
|
||||||
|
const subjectName = (id: string): string => {
|
||||||
|
const record = asRecord(subjectDict[id]);
|
||||||
|
return String(record?.Name ?? '').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间表字典:Guid → { Name, Layouts: [...] }
|
||||||
|
const layoutDict = asRecord(root.TimeLayouts) ?? {};
|
||||||
|
const layouts = new Map<string, { name: string; items: { start: string; end: string }[] }>();
|
||||||
|
for (const [id, value] of Object.entries(layoutDict)) {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (!record) continue;
|
||||||
|
const name = String(record.Name ?? '时间表').trim() || '时间表';
|
||||||
|
// 只取"上课"时间点(TimeType 0 / 缺省),与 ClassIsland 的 Classes[i] 对齐规则一致
|
||||||
|
const items = asArray(record.Layouts)
|
||||||
|
.map((item) => asRecord(item))
|
||||||
|
.filter((item): item is Record<string, unknown> => item !== null)
|
||||||
|
.filter((item) => {
|
||||||
|
const raw = item.TimeType ?? item.timeType ?? 0;
|
||||||
|
const numeric = typeof raw === 'number' ? raw : Number(raw);
|
||||||
|
return !Number.isFinite(numeric) || numeric === 0;
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
start: normalizeTime(item.StartTime ?? item.StartSecond ?? item.startTime) ?? '',
|
||||||
|
end: normalizeTime(item.EndTime ?? item.EndSecond ?? item.endTime) ?? '',
|
||||||
|
}));
|
||||||
|
layouts.set(id, { name, items });
|
||||||
|
result.layoutNames.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容"只给了 ClassPlans 片段"的情况:此时从 Classes 里直接读时间(可选字段 StartTime/EndTime)
|
||||||
|
const planDict = asRecord(root.ClassPlans) ?? asRecord(root.classPlans) ?? root;
|
||||||
|
const plans = Object.entries(planDict).filter(([, value]) => {
|
||||||
|
const record = asRecord(value);
|
||||||
|
return Boolean(record && (record.TimeRule || record.timeRule || record.Classes || record.classes));
|
||||||
|
});
|
||||||
|
if (plans.length === 0) {
|
||||||
|
result.errors.push(
|
||||||
|
'没找到课表(ClassPlans):请确认选择的是 ClassIsland 档案 JSON,或包含 ClassPlans 的片段',
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [planId, value] of plans) {
|
||||||
|
const plan = asRecord(value);
|
||||||
|
if (!plan) continue;
|
||||||
|
const planName = String(plan.Name ?? plan.name ?? planId).trim() || '课表';
|
||||||
|
if (plan.IsEnabled === false || plan.isEnabled === false) continue;
|
||||||
|
if (plan.IsOverlay === true || plan.isOverlay === true) continue;
|
||||||
|
|
||||||
|
const rule = asRecord(plan.TimeRule ?? plan.timeRule) ?? {};
|
||||||
|
const rawWeekDay = Number(rule.WeekDay ?? rule.weekDay ?? 0);
|
||||||
|
// ClassIsland:0=周日 … 6=周六 → 我们:1=周一 … 7=周日
|
||||||
|
const dayOfWeek = Number.isFinite(rawWeekDay)
|
||||||
|
? rawWeekDay === 0
|
||||||
|
? 7
|
||||||
|
: Math.min(7, Math.max(1, rawWeekDay))
|
||||||
|
: 1;
|
||||||
|
const weekCountDiv = Number(rule.WeekCountDiv ?? rule.weekCountDiv ?? 0) || 0;
|
||||||
|
const weekCountDivTotal = Number(rule.WeekCountDivTotal ?? rule.weekCountDivTotal ?? 2) || 2;
|
||||||
|
if (weekCountDivTotal > 2 && weekCountDiv > 0) {
|
||||||
|
result.warnings.push(
|
||||||
|
`${planName}:${weekCountDivTotal} 周轮换无法用"单双周"表达,已按每周处理(第 ${weekCountDiv} 周)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const weekParity = resolveParity(weekCountDiv, weekCountDivTotal);
|
||||||
|
|
||||||
|
const layoutId = String(plan.TimeLayoutId ?? plan.timeLayoutId ?? '');
|
||||||
|
const layout = layouts.get(layoutId) ?? (layouts.size > 0 ? [...layouts.values()][0] : undefined);
|
||||||
|
const timeItems = layout?.items ?? [];
|
||||||
|
if (timeItems.length === 0) {
|
||||||
|
result.warnings.push(`${planName}:没有可用的时间表节次,已跳过(请先导入该班的时间配置)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = asArray(plan.Classes ?? plan.classes);
|
||||||
|
classes.forEach((item, index) => {
|
||||||
|
const info = asRecord(item);
|
||||||
|
if (!info) return;
|
||||||
|
if (info.IsEnabled === false || info.isEnabled === false) return;
|
||||||
|
const slot = timeItems[index];
|
||||||
|
if (!slot) return;
|
||||||
|
const subjectId = String(info.SubjectId ?? info.subjectId ?? '');
|
||||||
|
const subject = subjectName(subjectId);
|
||||||
|
if (!subject) {
|
||||||
|
result.warnings.push(`${planName} 第 ${index + 1} 节:科目未在 Subjects 里找到,已跳过`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!slot.start || !slot.end || slot.start >= slot.end) {
|
||||||
|
result.warnings.push(`${planName} 第 ${index + 1} 节:时间非法(${slot.start}-${slot.end}),已跳过`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const teacherRecord = asRecord(subjectDict[subjectId]);
|
||||||
|
result.entries.push({
|
||||||
|
dayOfWeek,
|
||||||
|
startTime: slot.start,
|
||||||
|
endTime: slot.end,
|
||||||
|
subject,
|
||||||
|
teacherName: String(teacherRecord?.TeacherName ?? ''),
|
||||||
|
weekParity,
|
||||||
|
weekCountDiv,
|
||||||
|
weekCountDivTotal,
|
||||||
|
planName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.entries.length === 0 && result.errors.length === 0) {
|
||||||
|
result.errors.push(
|
||||||
|
'没有解析出任何课程:请检查 TimeLayouts 与 ClassPlans 是否完整(Classes 与上课时间点一一对应)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.subjects = [...new Set(result.entries.map((item) => item.subject))];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览:解析 + 与现有课程比对(列出需要新建的科目) */
|
||||||
|
export async function previewClassPlan(
|
||||||
|
user: TokenPayload,
|
||||||
|
input: ClassPlanImportInput,
|
||||||
|
): Promise<ClassPlanParseResult & { entries: (ParsedClassPlanEntry & { weekdayLabel: string })[] }> {
|
||||||
|
await assertCanManageSchedule(user, input.classId);
|
||||||
|
const parsed = parseClassIslandClassPlan(input.payload);
|
||||||
|
const courses = await prisma.course.findMany({
|
||||||
|
where: { classId: input.classId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
const existing = new Set(courses.map((item) => item.name));
|
||||||
|
parsed.missingSubjects = parsed.subjects.filter((name) => !existing.has(name));
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
entries: parsed.entries.map((item) => ({
|
||||||
|
...item,
|
||||||
|
weekdayLabel: WEEKDAY_LABELS[item.dayOfWeek % 7] ?? `周${item.dayOfWeek}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入:把解析出的课程写成课表(Schedule),必要时自动补建缺失的课程。
|
||||||
|
* - replace:先清空该班课表再写入(失败回滚:整个过程在一个事务里);
|
||||||
|
* - merge:按 `星期 + 开始时间 + 单双周` 去重,存在则更新时间/课程,否则新增。
|
||||||
|
*/
|
||||||
|
export async function importClassPlan(
|
||||||
|
user: TokenPayload,
|
||||||
|
input: ClassPlanImportInput,
|
||||||
|
): Promise<{
|
||||||
|
imported: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
createdCourses: string[];
|
||||||
|
weekStart: number;
|
||||||
|
weekEnd: number;
|
||||||
|
}> {
|
||||||
|
await assertCanManageSchedule(user, input.classId);
|
||||||
|
const parsed = parseClassIslandClassPlan(input.payload);
|
||||||
|
if (parsed.entries.length === 0) {
|
||||||
|
throw new ApiError(400, 'IMPORT_INVALID', parsed.errors[0] ?? '没有可导入的课程');
|
||||||
|
}
|
||||||
|
|
||||||
|
const classRecord = await prisma.class.findUnique({
|
||||||
|
where: { id: input.classId },
|
||||||
|
select: { id: true, teacherId: true },
|
||||||
|
});
|
||||||
|
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||||
|
|
||||||
|
// 时间配置(导入的时间表)里如果只有部分节次,weekEnd 用默认 20 周
|
||||||
|
const weekStart = input.termStartWeek ?? 1;
|
||||||
|
const weekEnd = 20;
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// 1) 补建缺失课程(用班主任作为课程任课老师)
|
||||||
|
const existingCourses = await tx.course.findMany({
|
||||||
|
where: { classId: input.classId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const courseIdByName = new Map(existingCourses.map((item) => [item.name, item.id]));
|
||||||
|
const createdCourses: string[] = [];
|
||||||
|
for (const name of parsed.subjects) {
|
||||||
|
if (courseIdByName.has(name)) continue;
|
||||||
|
const created = await tx.course.create({
|
||||||
|
data: { classId: input.classId, name, teacherId: classRecord.teacherId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
courseIdByName.set(created.name, created.id);
|
||||||
|
createdCourses.push(created.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) replace 模式:先清空该班课表
|
||||||
|
if (input.mode === 'replace') {
|
||||||
|
await tx.schedule.deleteMany({ where: { classId: input.classId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 写入课程(merge 时按 星期+开始时间+单双周 去重)
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
for (const entry of parsed.entries) {
|
||||||
|
const courseId = courseIdByName.get(entry.subject);
|
||||||
|
if (!courseId) continue;
|
||||||
|
const existing = await tx.schedule.findFirst({
|
||||||
|
where: {
|
||||||
|
classId: input.classId,
|
||||||
|
dayOfWeek: entry.dayOfWeek,
|
||||||
|
startTime: entry.startTime,
|
||||||
|
weekParity: entry.weekParity,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await tx.schedule.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { courseId, endTime: entry.endTime, weekStart, weekEnd },
|
||||||
|
});
|
||||||
|
updated += 1;
|
||||||
|
} else {
|
||||||
|
await tx.schedule.create({
|
||||||
|
data: {
|
||||||
|
classId: input.classId,
|
||||||
|
courseId,
|
||||||
|
dayOfWeek: entry.dayOfWeek,
|
||||||
|
startTime: entry.startTime,
|
||||||
|
endTime: entry.endTime,
|
||||||
|
location: null,
|
||||||
|
weekStart,
|
||||||
|
weekEnd,
|
||||||
|
weekParity: entry.weekParity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { created, updated, createdCourses };
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = result.created + result.updated;
|
||||||
|
logger.info(
|
||||||
|
`ClassIsland 课程表导入:班级=${input.classId} 模式=${input.mode} 新增=${result.created} 更新=${result.updated} ` +
|
||||||
|
`补建科目=[${result.createdCourses.join(',')}]`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
imported: total,
|
||||||
|
created: result.created,
|
||||||
|
updated: result.updated,
|
||||||
|
createdCourses: result.createdCourses,
|
||||||
|
weekStart,
|
||||||
|
weekEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ import {
|
|||||||
tableCommitSchema,
|
tableCommitSchema,
|
||||||
tableFileSchema,
|
tableFileSchema,
|
||||||
timeLayoutImportSchema,
|
timeLayoutImportSchema,
|
||||||
|
classPlanImportSchema,
|
||||||
timeLayoutQuerySchema,
|
timeLayoutQuerySchema,
|
||||||
type TableCommitInput,
|
type TableCommitInput,
|
||||||
type TableFileInput,
|
type TableFileInput,
|
||||||
type TimeLayoutImportInput,
|
type TimeLayoutImportInput,
|
||||||
|
type ClassPlanImportInput,
|
||||||
} from './imports.schemas.js';
|
} from './imports.schemas.js';
|
||||||
import { buildTemplateCsv, buildTemplateXlsx, commitTable, previewTable } from './table-import.service.js';
|
import { buildTemplateCsv, buildTemplateXlsx, commitTable, previewTable } from './table-import.service.js';
|
||||||
|
import { importClassPlan, previewClassPlan } from './class-plan.service.js';
|
||||||
import {
|
import {
|
||||||
deleteTimeLayout,
|
deleteTimeLayout,
|
||||||
importTimeLayout,
|
importTimeLayout,
|
||||||
@@ -123,6 +126,33 @@ router.delete('/time-layout/:id', async (req, res) => {
|
|||||||
sendOk(res, { id: req.params.id }, '时间配置已删除');
|
sendOk(res, { id: req.params.id }, '时间配置已删除');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
|
||||||
|
* 真实档案里单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
|
||||||
|
*/
|
||||||
|
router.post('/class-plan/preview', validate({ body: classPlanImportSchema }), async (req, res) => {
|
||||||
|
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
|
||||||
|
sendOk(
|
||||||
|
res,
|
||||||
|
result,
|
||||||
|
`解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/imports/class-plan - 导入 ClassIsland 课程表(支持单双周)
|
||||||
|
* replace 清空后写入 / merge 按 星期+开始时间+单双周 去重;解析失败返回 400 且不改动现有课表。
|
||||||
|
*/
|
||||||
|
router.post('/class-plan', validate({ body: classPlanImportSchema }), async (req, res) => {
|
||||||
|
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
|
||||||
|
sendOk(
|
||||||
|
res,
|
||||||
|
result,
|
||||||
|
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
|
||||||
|
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const importsModule = defineModule({
|
export const importsModule = defineModule({
|
||||||
name: 'imports',
|
name: 'imports',
|
||||||
basePath: '/imports',
|
basePath: '/imports',
|
||||||
|
|||||||
@@ -46,6 +46,24 @@ export const timeLayoutQuerySchema = z.object({
|
|||||||
classId: z.string().min(1, '请选择班级'),
|
classId: z.string().min(1, '请选择班级'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClassIsland 课程表(ClassPlan)导入:先预览再提交。
|
||||||
|
* 真实档案 JSON 里 TimeLayouts / ClassPlans / Subjects 都是 "Guid → 对象" 的字典,
|
||||||
|
* 单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
|
||||||
|
*/
|
||||||
|
export const classPlanImportSchema = z.object({
|
||||||
|
classId: z.string().min(1, '请选择班级'),
|
||||||
|
/** replace = 清空该班现有课表后写入;merge = 保留现有条目,只新增(同 星期+节次+单双周 视为重复则更新) */
|
||||||
|
mode: z.enum(['replace', 'merge']).default('replace'),
|
||||||
|
/** 学期起始周(用于计算单双周;不传则用服务端配置的开学日期推算) */
|
||||||
|
termStartWeek: z.coerce.number().int().min(1).max(60).optional(),
|
||||||
|
payload: z.union([
|
||||||
|
z.string().min(1, '请粘贴或选择 ClassIsland 导出的 JSON'),
|
||||||
|
z.record(z.string(), z.unknown()),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
export type TableFileInput = z.infer<typeof tableFileSchema>;
|
export type TableFileInput = z.infer<typeof tableFileSchema>;
|
||||||
export type TableCommitInput = z.infer<typeof tableCommitSchema>;
|
export type TableCommitInput = z.infer<typeof tableCommitSchema>;
|
||||||
export type TimeLayoutImportInput = z.infer<typeof timeLayoutImportSchema>;
|
export type TimeLayoutImportInput = z.infer<typeof timeLayoutImportSchema>;
|
||||||
|
export type ClassPlanImportInput = z.infer<typeof classPlanImportSchema>;
|
||||||
@@ -17,7 +17,7 @@ import type { TimeLayoutImportInput } from './imports.schemas.js';
|
|||||||
* 因此这里做**容错解析**:先把可能的数组抠出来,再逐条按候选字段名解析时间,
|
* 因此这里做**容错解析**:先把可能的数组抠出来,再逐条按候选字段名解析时间,
|
||||||
* 解析失败的行进入 errors(不写库),成功解析但可疑的进入 warnings(可继续导入)。
|
* 解析失败的行进入 errors(不写库),成功解析但可疑的进入 warnings(可继续导入)。
|
||||||
*/
|
*/
|
||||||
export type TimeLayoutItemType = 'class' | 'break' | 'divider';
|
export type TimeLayoutItemType = 'class' | 'break' | 'divider' | 'action';
|
||||||
|
|
||||||
export interface ParsedTimeLayoutItem {
|
export interface ParsedTimeLayoutItem {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -84,19 +84,47 @@ function secondsToHHmm(seconds: number): string {
|
|||||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从任意形态里抠出"可能是时间表条目"的数组 */
|
/**
|
||||||
|
* 从任意形态里抠出"可能是时间表条目"的数组。
|
||||||
|
*
|
||||||
|
* ClassIsland 真实档案(`Profiles/<档案名>.json`,1.7.106+ / 2.x)里是:
|
||||||
|
* `{ "TimeLayouts": { "<guid>": { "Name": "默认", "Layouts": [ { StartTime, EndTime, TimeType } ] } } }`
|
||||||
|
* —— 即**按 Guid 索引的字典**,条目在各自的 `Layouts` 里。早期版本还可能直接是数组或 `{ items: [...] }`。
|
||||||
|
* 之前只认数组 / `.items`,导致真实导出文件完全导不进来(本次修复)。
|
||||||
|
*/
|
||||||
function extractItems(input: unknown): { items: unknown[]; shape: string } {
|
function extractItems(input: unknown): { items: unknown[]; shape: string } {
|
||||||
if (Array.isArray(input)) return { items: input, shape: 'array' };
|
if (Array.isArray(input)) return { items: input, shape: 'array' };
|
||||||
if (input && typeof input === 'object') {
|
if (input && typeof input === 'object') {
|
||||||
const record = input as Record<string, unknown>;
|
const record = input as Record<string, unknown>;
|
||||||
for (const key of ['TimeLayouts', 'TimeLayoutItems', 'Items', 'items', 'Layouts', 'TimeLayout']) {
|
for (const key of ['TimeLayouts', 'TimeLayoutItems', 'Items', 'items', 'Layouts']) {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
if (Array.isArray(value)) return { items: value, shape: key };
|
if (Array.isArray(value)) return { items: value, shape: key };
|
||||||
// 有些导出是 { TimeLayouts: { items: [...] } }
|
// 有些导出是 { TimeLayouts: { items: [...] } }
|
||||||
if (value && typeof value === 'object' && Array.isArray((value as Record<string, unknown>).items)) {
|
const nestedItems =
|
||||||
return { items: (value as Record<string, unknown>).items as unknown[], shape: `${key}.items` };
|
value && typeof value === 'object' ? (value as Record<string, unknown>).items : null;
|
||||||
|
if (Array.isArray(nestedItems)) {
|
||||||
|
return { items: nestedItems as unknown[], shape: `${key}.items` };
|
||||||
|
}
|
||||||
|
// ClassIsland 真实形态:Guid → { Name, Layouts: [...] };合并所有时间表的 Layouts
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
const merged: unknown[] = [];
|
||||||
|
let layoutCount = 0;
|
||||||
|
for (const entry of Object.values(value as Record<string, unknown>)) {
|
||||||
|
const layoutRecord = entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : null;
|
||||||
|
const layouts = layoutRecord?.Layouts ?? layoutRecord?.layouts;
|
||||||
|
if (Array.isArray(layouts)) {
|
||||||
|
merged.push(...layouts);
|
||||||
|
layoutCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (merged.length > 0) {
|
||||||
|
return { items: merged, shape: `${key}{guid}×${layoutCount}` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 单个时间表对象:{ Name, Layouts: [...] }
|
||||||
|
const single = record.Layouts ?? record.layouts;
|
||||||
|
if (Array.isArray(single)) return { items: single, shape: 'Layouts' };
|
||||||
}
|
}
|
||||||
return { items: [], shape: 'unknown' };
|
return { items: [], shape: 'unknown' };
|
||||||
}
|
}
|
||||||
@@ -105,6 +133,7 @@ function normalizeType(record: Record<string, unknown>): TimeLayoutItemType {
|
|||||||
const raw = pick(record, TYPE_KEYS);
|
const raw = pick(record, TYPE_KEYS);
|
||||||
const numeric = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
|
const numeric = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
|
||||||
if (numeric === 1) return 'break';
|
if (numeric === 1) return 'break';
|
||||||
|
if (numeric === 3) return 'action'; // ClassIsland TimeType=3:行动(非上课/课间)
|
||||||
if (numeric === 2) return 'divider';
|
if (numeric === 2) return 'divider';
|
||||||
return 'class';
|
return 'class';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { timeSchema, weekNumberSchema } from '../../lib/schemas.js';
|
import { timeSchema, weekNumberSchema } from '../../lib/schemas.js';
|
||||||
|
|
||||||
|
/** 单双周:ALL=每周 / ODD=单周 / EVEN=双周(与 ClassIsland 的 WeekCountDiv 语义一致) */
|
||||||
|
const weekParitySchema = z.enum(['ALL', 'ODD', 'EVEN']);
|
||||||
|
|
||||||
export const listSchedulesQuerySchema = z.object({
|
export const listSchedulesQuerySchema = z.object({
|
||||||
classId: z.string().min(1).optional(),
|
classId: z.string().min(1).optional(),
|
||||||
/** 不传则按"当前教学周"过滤 */
|
/** 不传则按"当前教学周"过滤 */
|
||||||
@@ -37,6 +40,8 @@ export const createScheduleSchema = z
|
|||||||
location: z.string().trim().max(64).nullish(),
|
location: z.string().trim().max(64).nullish(),
|
||||||
weekStart: weekNumberSchema.optional(),
|
weekStart: weekNumberSchema.optional(),
|
||||||
weekEnd: weekNumberSchema.optional(),
|
weekEnd: weekNumberSchema.optional(),
|
||||||
|
/** 单双周(默认 ALL=每周) */
|
||||||
|
weekParity: weekParitySchema.optional(),
|
||||||
})
|
})
|
||||||
.refine((data) => data.startTime < data.endTime, {
|
.refine((data) => data.startTime < data.endTime, {
|
||||||
message: '结束时间必须晚于开始时间',
|
message: '结束时间必须晚于开始时间',
|
||||||
@@ -56,6 +61,8 @@ export const updateScheduleSchema = z
|
|||||||
location: z.string().trim().max(64).nullish(),
|
location: z.string().trim().max(64).nullish(),
|
||||||
weekStart: weekNumberSchema.optional(),
|
weekStart: weekNumberSchema.optional(),
|
||||||
weekEnd: weekNumberSchema.optional(),
|
weekEnd: weekNumberSchema.optional(),
|
||||||
|
/** 单双周(默认 ALL=每周) */
|
||||||
|
weekParity: weekParitySchema.optional(),
|
||||||
})
|
})
|
||||||
.refine((data) => !data.startTime || !data.endTime || data.startTime < data.endTime, {
|
.refine((data) => !data.startTime || !data.endTime || data.startTime < data.endTime, {
|
||||||
message: '结束时间必须晚于开始时间',
|
message: '结束时间必须晚于开始时间',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
buildScheduleWeekView,
|
buildScheduleWeekView,
|
||||||
resolveClassStatus,
|
resolveClassStatus,
|
||||||
resolveCurrentWeek,
|
resolveCurrentWeek,
|
||||||
|
weekParityOf,
|
||||||
type ClassStatusDto,
|
type ClassStatusDto,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
type ScheduleWeekView,
|
type ScheduleWeekView,
|
||||||
@@ -43,6 +44,8 @@ export async function listSchedules(
|
|||||||
where: {
|
where: {
|
||||||
...classScopeWhere(scope),
|
...classScopeWhere(scope),
|
||||||
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
|
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
|
||||||
|
// 单双周:ALL 永远命中;ODD / EVEN 只在对应周次命中(第 1 周为单周)
|
||||||
|
...(week && week > 0 ? { weekParity: { in: ['ALL', weekParityOf(week)] } } : {}),
|
||||||
...(options.dayOfWeek ? { dayOfWeek: options.dayOfWeek } : {}),
|
...(options.dayOfWeek ? { dayOfWeek: options.dayOfWeek } : {}),
|
||||||
},
|
},
|
||||||
include: { course: courseSelect },
|
include: { course: courseSelect },
|
||||||
@@ -115,6 +118,8 @@ export async function createSchedule(user: TokenPayload, input: CreateScheduleIn
|
|||||||
location: input.location ?? null,
|
location: input.location ?? null,
|
||||||
weekStart: input.weekStart ?? 1,
|
weekStart: input.weekStart ?? 1,
|
||||||
weekEnd: input.weekEnd ?? 20,
|
weekEnd: input.weekEnd ?? 20,
|
||||||
|
// 单双周:默认 ALL(每周),与旧数据行为一致
|
||||||
|
weekParity: input.weekParity ?? 'ALL',
|
||||||
},
|
},
|
||||||
include: { course: courseSelect },
|
include: { course: courseSelect },
|
||||||
});
|
});
|
||||||
@@ -157,6 +162,7 @@ export async function updateSchedule(
|
|||||||
...(input.location !== undefined ? { location: input.location ?? null } : {}),
|
...(input.location !== undefined ? { location: input.location ?? null } : {}),
|
||||||
weekStart,
|
weekStart,
|
||||||
weekEnd,
|
weekEnd,
|
||||||
|
...(input.weekParity !== undefined ? { weekParity: input.weekParity } : {}),
|
||||||
},
|
},
|
||||||
include: { course: courseSelect },
|
include: { course: courseSelect },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,6 +66,21 @@ export const WEEKDAY_LABELS: Record<number, string> = {
|
|||||||
|
|
||||||
export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
|
export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单双周标签与取值(课表用)。
|
||||||
|
* 语义与 ClassIsland 课表一致:WeekCountDivTotal=1 → 每周;=2 时 WeekCountDiv=1 → 单周、=2 → 双周。
|
||||||
|
*/
|
||||||
|
export const WEEK_PARITY_LABELS: Record<'ALL' | 'ODD' | 'EVEN', string> = {
|
||||||
|
ALL: '每周',
|
||||||
|
ODD: '单周',
|
||||||
|
EVEN: '双周',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEEK_PARITY_VALUES: readonly ('ALL' | 'ODD' | 'EVEN')[] = ['ALL', 'ODD', 'EVEN'];
|
||||||
|
|
||||||
|
/** 一个学期按多少周为一个单双周循环(ClassIsland WeekCountDivTotal 的默认值) */
|
||||||
|
export const WEEK_PARITY_CYCLE = 2;
|
||||||
|
|
||||||
/** 灵动岛默认外观(设置页个性化项的初始值,也是区间校验的单一来源) */
|
/** 灵动岛默认外观(设置页个性化项的初始值,也是区间校验的单一来源) */
|
||||||
export const DEFAULT_ISLAND_APPEARANCE = {
|
export const DEFAULT_ISLAND_APPEARANCE = {
|
||||||
height: 44,
|
height: 44,
|
||||||
|
|||||||
@@ -172,10 +172,18 @@ export interface ScheduleDto {
|
|||||||
location: string | null;
|
location: string | null;
|
||||||
weekStart: number;
|
weekStart: number;
|
||||||
weekEnd: number;
|
weekEnd: number;
|
||||||
|
/**
|
||||||
|
* 单双周:ALL = 每周都上(默认);ODD = 只在单周;EVEN = 只在双周。
|
||||||
|
* 与 ClassIsland 课表的 WeekCountDiv / WeekCountDivTotal 语义对应(见导入模块)。
|
||||||
|
*/
|
||||||
|
weekParity: WeekParity;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
course?: CourseBrief | null;
|
course?: CourseBrief | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单双周(ClassIsland:WeekCountDivTotal=2 时按 WeekCountDiv 分单/双周) */
|
||||||
|
export type WeekParity = 'ALL' | 'ODD' | 'EVEN';
|
||||||
|
|
||||||
export interface CourseBrief {
|
export interface CourseBrief {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -190,6 +198,8 @@ export interface CreateScheduleRequest {
|
|||||||
location?: string | null;
|
location?: string | null;
|
||||||
weekStart?: number;
|
weekStart?: number;
|
||||||
weekEnd?: number;
|
weekEnd?: number;
|
||||||
|
/** 单双周:ALL(默认)/ ODD(单周)/ EVEN(双周) */
|
||||||
|
weekParity?: WeekParity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateScheduleRequest = Partial<CreateScheduleRequest>;
|
export type UpdateScheduleRequest = Partial<CreateScheduleRequest>;
|
||||||
@@ -206,6 +216,50 @@ export interface ScheduleWeekView {
|
|||||||
columns: ScheduleDayColumn[];
|
columns: ScheduleDayColumn[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ ClassIsland 课程表导入(单双周) */
|
||||||
|
|
||||||
|
/** 解析出来的单节课(预览用) */
|
||||||
|
export interface ClassPlanEntryDto {
|
||||||
|
/** 1=周一 … 7=周日(已从 ClassIsland 的 0=周日 转换) */
|
||||||
|
dayOfWeek: number;
|
||||||
|
/** 展示用:周一 / 周二 … */
|
||||||
|
weekdayLabel?: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
subject: string;
|
||||||
|
teacherName?: string;
|
||||||
|
/** 单双周:ALL / ODD(单周)/ EVEN(双周) */
|
||||||
|
weekParity: WeekParity;
|
||||||
|
/** 原始 WeekCountDiv / WeekCountDivTotal,便于核对来源 */
|
||||||
|
weekCountDiv: number;
|
||||||
|
weekCountDivTotal: number;
|
||||||
|
planName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课程表导入预览结果 */
|
||||||
|
export interface ClassPlanPreviewDto {
|
||||||
|
entries: ClassPlanEntryDto[];
|
||||||
|
/** 解析到的科目名(去重) */
|
||||||
|
subjects: string[];
|
||||||
|
/** 班级里还没有、导入时会自动补建的科目 */
|
||||||
|
missingSubjects: string[];
|
||||||
|
/** 解析到的时间表名 */
|
||||||
|
layoutNames: string[];
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课程表导入结果 */
|
||||||
|
export interface ClassPlanImportResultDto {
|
||||||
|
imported: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
/** 自动补建的课程名 */
|
||||||
|
createdCourses: string[];
|
||||||
|
weekStart: number;
|
||||||
|
weekEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 作业 */
|
/* ------------------------------------------------------------------ 作业 */
|
||||||
|
|
||||||
export interface HomeworkDto {
|
export interface HomeworkDto {
|
||||||
@@ -246,6 +300,35 @@ export interface UpdateHomeworkStatusRequest {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 作业提交名单里的一位学生 */
|
||||||
|
export interface HomeworkSubmissionDto {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作业提交名单("未交名单"功能的载体):
|
||||||
|
* 教师端 / 班级设备用它勾选谁没交作业,其余学生一律视为已交。
|
||||||
|
*/
|
||||||
|
export interface HomeworkSubmissionsDto {
|
||||||
|
homeworkId: string;
|
||||||
|
classId: string;
|
||||||
|
/** 全班学生数 */
|
||||||
|
total: number;
|
||||||
|
completedCount: number;
|
||||||
|
/** 只包含未交的学生(completed=false),便于直接渲染"未交名单" */
|
||||||
|
notSubmitted: HomeworkSubmissionDto[];
|
||||||
|
/** 全班学生及其完成状态 */
|
||||||
|
students: HomeworkSubmissionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateHomeworkSubmissionsRequest {
|
||||||
|
/** 未交作业的学生 id(其余学生一律标记为已交) */
|
||||||
|
notSubmittedUserIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface HomeworkQueryParams {
|
export interface HomeworkQueryParams {
|
||||||
classId?: string;
|
classId?: string;
|
||||||
courseId?: string;
|
courseId?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { WEEKDAY_LABELS, WEEKDAYS } from './constants.js';
|
import { WEEKDAY_LABELS, WEEKDAYS, WEEK_PARITY_LABELS } from './constants.js';
|
||||||
import type { ClassPeriod, GradeLevel, ScheduleDto, ScheduleWeekView } from './types.js';
|
import type { ClassPeriod, GradeLevel, ScheduleDto, ScheduleWeekView, WeekParity } from './types.js';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 日期时间 */
|
/* ------------------------------------------------------------------ 日期时间 */
|
||||||
|
|
||||||
@@ -60,12 +60,25 @@ export function relativeTime(value: string | number | Date, now: Date = new Date
|
|||||||
|
|
||||||
/* ------------------------------------------------------------------ 课表 */
|
/* ------------------------------------------------------------------ 课表 */
|
||||||
|
|
||||||
/** 判断某个课表条目在第 week 周是否上课 */
|
/** 第 week 周是单周还是双周(第 1 周记为单周,与 ClassIsland 的 WeekCountDiv 一致) */
|
||||||
|
export function weekParityOf(week: number): Exclude<WeekParity, 'ALL'> {
|
||||||
|
return week % 2 === 0 ? 'EVEN' : 'ODD';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断某个课表条目在第 week 周是否上课(周次区间 + 单双周) */
|
||||||
export function isScheduleActiveInWeek(
|
export function isScheduleActiveInWeek(
|
||||||
schedule: Pick<ScheduleDto, 'weekStart' | 'weekEnd'>,
|
schedule: Pick<ScheduleDto, 'weekStart' | 'weekEnd'> & { weekParity?: WeekParity | null },
|
||||||
week: number,
|
week: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
return week >= schedule.weekStart && week <= schedule.weekEnd;
|
if (week < schedule.weekStart || week > schedule.weekEnd) return false;
|
||||||
|
const parity = schedule.weekParity ?? 'ALL';
|
||||||
|
if (parity === 'ALL') return true;
|
||||||
|
return parity === weekParityOf(week);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化单双周标签(课表卡片用) */
|
||||||
|
export function formatWeekParity(parity: WeekParity | null | undefined): string {
|
||||||
|
return WEEK_PARITY_LABELS[(parity ?? 'ALL') as WeekParity] ?? '每周';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化周次范围,例如"1-20 周" / "单周" */
|
/** 格式化周次范围,例如"1-20 周" / "单周" */
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
API_PATHS,
|
API_PATHS,
|
||||||
type ClassDetailDto,
|
type ClassDetailDto,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
|
type ClassPlanImportResultDto,
|
||||||
|
type ClassPlanPreviewDto,
|
||||||
type ClassStatusDto,
|
type ClassStatusDto,
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
type CreateClassRequest,
|
type CreateClassRequest,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
type GradeDto,
|
type GradeDto,
|
||||||
type GradeStats,
|
type GradeStats,
|
||||||
type HomeworkDto,
|
type HomeworkDto,
|
||||||
|
type HomeworkSubmissionsDto,
|
||||||
type LoginRequest,
|
type LoginRequest,
|
||||||
type LoginResponse,
|
type LoginResponse,
|
||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
@@ -110,6 +113,11 @@ export const homeworkApi = {
|
|||||||
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
|
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
|
||||||
api.patch(`${API_PATHS.homeworks}/${id}`, payload),
|
api.patch(`${API_PATHS.homeworks}/${id}`, payload),
|
||||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.homeworks}/${id}`),
|
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.homeworks}/${id}`),
|
||||||
|
/** 提交名单 / 未交名单(教师与班级设备可用) */
|
||||||
|
submissions: (id: string): Promise<HomeworkSubmissionsDto> =>
|
||||||
|
api.get(`${API_PATHS.homeworks}/${id}/submissions`),
|
||||||
|
saveSubmissions: (id: string, notSubmittedUserIds: string[]): Promise<HomeworkSubmissionsDto> =>
|
||||||
|
api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedUserIds }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 通知 */
|
/* ------------------------------------------------------------------ 通知 */
|
||||||
@@ -202,7 +210,7 @@ export const teacherApi = {
|
|||||||
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
|
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置) */
|
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置 / ClassIsland 课程表) */
|
||||||
|
|
||||||
export const importApi = {
|
export const importApi = {
|
||||||
/** 模板下载地址(CSV 走 JSON,XLSX 走二进制) */
|
/** 模板下载地址(CSV 走 JSON,XLSX 走二进制) */
|
||||||
@@ -251,4 +259,21 @@ export const importApi = {
|
|||||||
|
|
||||||
removeTimeLayout: (id: string): Promise<{ id: string }> =>
|
removeTimeLayout: (id: string): Promise<{ id: string }> =>
|
||||||
api.delete(`${API_PATHS.imports}/time-layout/${id}`),
|
api.delete(`${API_PATHS.imports}/time-layout/${id}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClassIsland 课程表(ClassPlan)解析预览(不写库)。
|
||||||
|
* 注意:请求体里的 JSON 字段名就叫 payload(与 ClassIsland 档案 JSON 同名)。
|
||||||
|
*/
|
||||||
|
previewClassPlan: (payload: {
|
||||||
|
classId: string;
|
||||||
|
mode?: 'replace' | 'merge';
|
||||||
|
payload: unknown;
|
||||||
|
}): Promise<ClassPlanPreviewDto> => api.post(`${API_PATHS.imports}/class-plan/preview`, payload),
|
||||||
|
|
||||||
|
/** 导入 ClassIsland 课程表(支持单双周:replace 覆盖 / merge 合并;失败会抛错且不改动原课表) */
|
||||||
|
importClassPlan: (payload: {
|
||||||
|
classId: string;
|
||||||
|
mode?: 'replace' | 'merge';
|
||||||
|
payload: unknown;
|
||||||
|
}): Promise<ClassPlanImportResultDto> => api.post(`${API_PATHS.imports}/class-plan`, payload),
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ClassIsland 课程表(ClassPlan)导入弹窗,支持单双周。
|
||||||
|
*
|
||||||
|
* 流程:粘贴 JSON / 选择 .json 文件(整份 ClassIsland 档案)
|
||||||
|
* → 解析预览(后端解析 TimeLayouts + ClassPlans + Subjects,单双周取 WeekCountDiv/WeekCountDivTotal)
|
||||||
|
* → 查看解析出的课程、缺失科目与校验结果 → 选择覆盖(replace) / 合并(merge) → 导入。
|
||||||
|
*
|
||||||
|
* 安全与一致性:
|
||||||
|
* - JSON 语法错误、结构无法识别、没有解析出任何课程时后端返回 errors(或 400),
|
||||||
|
* 导入按钮会被禁用/阻止,原课表不会被改成"导入一半"的脏状态;
|
||||||
|
* - 缺失的科目在导入时由后端自动补建(预览里提前列出,避免意外建课);
|
||||||
|
* - 权限与课表管理一致(管理员或本班班主任),科任老师看不到入口,后端亦会二次校验。
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { WEEK_PARITY_LABELS, type ClassPlanEntryDto, type ClassPlanPreviewDto } 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),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实 ClassIsland 档案结构示例:Guid 字典 + PascalCase。
|
||||||
|
* 单双周 = 同一天两条 ClassPlan(WeekCountDiv 1/2,WeekCountDivTotal 2);
|
||||||
|
* 注意 TimeLayouts 必须在 ClassPlans 之前(Classes 按下标与"上课"时间点一一对应)。
|
||||||
|
*/
|
||||||
|
const CLASS_PLAN_SAMPLE = `{
|
||||||
|
"Name": "示例班级",
|
||||||
|
"TimeLayouts": {
|
||||||
|
"11111111-1111-4111-8111-111111111111": {
|
||||||
|
"Name": "周一作息",
|
||||||
|
"Layouts": [
|
||||||
|
{ "StartTime": "08:00:00", "EndTime": "08:45:00", "TimeType": 0, "BreakName": "" },
|
||||||
|
{ "StartTime": "08:45:00", "EndTime": "08:55:00", "TimeType": 1, "BreakName": "课间" },
|
||||||
|
{ "StartTime": "08:55:00", "EndTime": "09:40:00", "TimeType": 0, "BreakName": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ClassPlans": {
|
||||||
|
"aaaaaaaa-0001-4000-8000-000000000001": {
|
||||||
|
"Name": "周一-单周",
|
||||||
|
"TimeLayoutId": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"TimeRule": { "WeekDay": 1, "WeekCountDiv": 1, "WeekCountDivTotal": 2 },
|
||||||
|
"Classes": [
|
||||||
|
{ "SubjectId": "bbbbbbbb-0001-4000-8000-000000000001", "IsEnabled": true },
|
||||||
|
{ "SubjectId": "bbbbbbbb-0002-4000-8000-000000000002", "IsEnabled": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"aaaaaaaa-0002-4000-8000-000000000002": {
|
||||||
|
"Name": "周一-双周",
|
||||||
|
"TimeLayoutId": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"TimeRule": { "WeekDay": 1, "WeekCountDiv": 2, "WeekCountDivTotal": 2 },
|
||||||
|
"Classes": [
|
||||||
|
{ "SubjectId": "bbbbbbbb-0001-4000-8000-000000000001", "IsEnabled": true },
|
||||||
|
{ "SubjectId": "bbbbbbbb-0003-4000-8000-000000000003", "IsEnabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Subjects": {
|
||||||
|
"bbbbbbbb-0001-4000-8000-000000000001": { "Name": "语文", "TeacherName": "王芳" },
|
||||||
|
"bbbbbbbb-0002-4000-8000-000000000002": { "Name": "数学", "TeacherName": "李梅" },
|
||||||
|
"bbbbbbbb-0003-4000-8000-000000000003": { "Name": "英语", "TeacherName": "张伟" }
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** 输入框提示(用常量避免模板里的引号破坏 lint 规则) */
|
||||||
|
const TEXTAREA_PLACEHOLDER =
|
||||||
|
'粘贴 ClassIsland 导出的档案 JSON(含 TimeLayouts / ClassPlans / Subjects),或点击「选择 .json 文件」';
|
||||||
|
|
||||||
|
const jsonText = ref('');
|
||||||
|
const mode = ref<'replace' | 'merge'>('replace');
|
||||||
|
const preview = ref<ClassPlanPreviewDto | null>(null);
|
||||||
|
const parsing = ref(false);
|
||||||
|
const importing = ref(false);
|
||||||
|
const fileInputRef = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
watch(visible, (value) => {
|
||||||
|
if (value) {
|
||||||
|
jsonText.value = '';
|
||||||
|
preview.value = null;
|
||||||
|
mode.value = 'replace';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 语法错误由后端统一报错,这里保留原文让后端给出精确原因 */
|
||||||
|
function parseJsonOrText(text: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析出的课程数(单双周会各算一节,便于核对) */
|
||||||
|
const parityCount = computed(() => {
|
||||||
|
const counts = { ODD: 0, EVEN: 0, ALL: 0 };
|
||||||
|
for (const item of preview.value?.entries ?? []) counts[item.weekParity] += 1;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (counts.ODD > 0) parts.push(`单周 ${counts.ODD} 节`);
|
||||||
|
if (counts.EVEN > 0) parts.push(`双周 ${counts.EVEN} 节`);
|
||||||
|
if (counts.ALL > 0) parts.push(`每周 ${counts.ALL} 节`);
|
||||||
|
return parts.join(' / ');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 解析预览:把 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 {
|
||||||
|
preview.value = await importApi.previewClassPlan({
|
||||||
|
classId: props.classId,
|
||||||
|
mode: mode.value,
|
||||||
|
payload: parseJsonOrText(jsonText.value),
|
||||||
|
});
|
||||||
|
if (preview.value.errors.length > 0) {
|
||||||
|
ElMessage.warning(`解析发现 ${preview.value.errors.length} 个问题,请修正后再导入`);
|
||||||
|
} else {
|
||||||
|
ElMessage.success(
|
||||||
|
`解析成功:共 ${preview.value.entries.length} 节课${parityCount.value ? `(${parityCount.value})` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
preview.value = null;
|
||||||
|
} finally {
|
||||||
|
parsing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport(): Promise<void> {
|
||||||
|
if (!preview.value || preview.value.entries.length === 0) {
|
||||||
|
ElMessage.warning('请先解析预览并确认课程');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preview.value.errors.length > 0) {
|
||||||
|
ElMessage.warning('存在解析错误,已阻止导入(原课表不会被修改)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
importing.value = true;
|
||||||
|
try {
|
||||||
|
const result = await importApi.importClassPlan({
|
||||||
|
classId: props.classId,
|
||||||
|
mode: mode.value,
|
||||||
|
payload: parseJsonOrText(jsonText.value),
|
||||||
|
});
|
||||||
|
ElMessage.success(`${result.created} 新增 / ${result.updated} 更新`);
|
||||||
|
emit('imported');
|
||||||
|
visible.value = false;
|
||||||
|
} finally {
|
||||||
|
importing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSample(): void {
|
||||||
|
jsonText.value = CLASS_PLAN_SAMPLE;
|
||||||
|
preview.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parityLabel(item: ClassPlanEntryDto): string {
|
||||||
|
return WEEK_PARITY_LABELS[item.weekParity] ?? item.weekParity;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="导入 ClassIsland 课程表(支持单双周)" width="860px" top="5vh">
|
||||||
|
<el-alert type="info" :closable="false" show-icon class="mb-12">
|
||||||
|
<template #title>
|
||||||
|
支持 ClassIsland 档案 JSON(含 TimeLayouts / ClassPlans / Subjects 的 Guid 字典)。单双周由 ClassPlan
|
||||||
|
的 WeekCountDiv / WeekCountDivTotal 决定(两条 ClassPlan = 单周 +
|
||||||
|
双周各一节);课间(TimeType=1)会自动跳过。解析失败不会修改原有课表。
|
||||||
|
</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-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.entries.length }} 节</span>
|
||||||
|
<span v-if="parityCount">单双周分布:{{ parityCount }}</span>
|
||||||
|
<span>识别科目:{{ preview.subjects.length }} 个</span>
|
||||||
|
<span v-if="preview.layoutNames.length">时间表:{{ preview.layoutNames.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-alert
|
||||||
|
v-if="preview.missingSubjects.length"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-8"
|
||||||
|
:title="`导入时会自动补建这些课程:${preview.missingSubjects.join('、')}`"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-if="preview.entries.length"
|
||||||
|
:data="preview.entries"
|
||||||
|
size="small"
|
||||||
|
max-height="260"
|
||||||
|
class="mt-8"
|
||||||
|
>
|
||||||
|
<el-table-column label="星期" width="80">
|
||||||
|
<template #default="{ row }">{{ row.weekdayLabel ?? `周${row.dayOfWeek}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" width="130">
|
||||||
|
<template #default="{ row }">{{ row.startTime }}-{{ row.endTime }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="subject" label="科目" min-width="110" />
|
||||||
|
<el-table-column label="单双周" width="90">
|
||||||
|
<template #default="{ row }">{{ parityLabel(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="planName" label="来源" min-width="120" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!preview || preview.entries.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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ClassDto,
|
type ClassDto,
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
type HomeworkDto,
|
type HomeworkDto,
|
||||||
|
type HomeworkSubmissionsDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { classApi, courseApi, homeworkApi } from '@/api';
|
import { classApi, courseApi, homeworkApi } from '@/api';
|
||||||
import { useRealtimeStore } from '@/stores/realtime';
|
import { useRealtimeStore } from '@/stores/realtime';
|
||||||
@@ -34,6 +35,65 @@ const completedMap = computed(() => {
|
|||||||
return { total, done, rate: total === 0 ? 0 : Math.round((done / total) * 100) };
|
return { total, done, rate: total === 0 ? 0 : Math.round((done / total) * 100) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 未交名单(勾选谁没交) */
|
||||||
|
|
||||||
|
const submissionsVisible = ref(false);
|
||||||
|
const submissionsLoading = ref(false);
|
||||||
|
const submissionsSaving = ref(false);
|
||||||
|
const submissionStudents = ref<HomeworkSubmissionsDto['students']>([]);
|
||||||
|
/** 勾选 = 未交(保存后其余学生一律标记为已交) */
|
||||||
|
const notSubmittedIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
const notSubmittedNames = computed(() =>
|
||||||
|
submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
|
||||||
|
async function loadSubmissions(homeworkId: string): Promise<void> {
|
||||||
|
submissionsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await homeworkApi.submissions(homeworkId);
|
||||||
|
submissionStudents.value = result.students;
|
||||||
|
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
|
||||||
|
} catch {
|
||||||
|
submissionStudents.value = [];
|
||||||
|
notSubmittedIds.value = [];
|
||||||
|
} finally {
|
||||||
|
submissionsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSubmissions(): Promise<void> {
|
||||||
|
if (!current.value) return;
|
||||||
|
if (submissionStudents.value.length === 0) await loadSubmissions(current.value.id);
|
||||||
|
submissionsVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSubmissions(): Promise<void> {
|
||||||
|
if (!current.value) return;
|
||||||
|
submissionsSaving.value = true;
|
||||||
|
try {
|
||||||
|
const result = await homeworkApi.saveSubmissions(current.value.id, notSubmittedIds.value);
|
||||||
|
submissionStudents.value = result.students;
|
||||||
|
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
|
||||||
|
if (current.value) current.value.completedCount = result.completedCount;
|
||||||
|
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
|
||||||
|
submissionsVisible.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存未交名单失败');
|
||||||
|
} finally {
|
||||||
|
submissionsSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllSubmitted(): void {
|
||||||
|
notSubmittedIds.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllNotSubmitted(): void {
|
||||||
|
notSubmittedIds.value = submissionStudents.value.map((item) => item.userId);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadClasses(): Promise<void> {
|
async function loadClasses(): Promise<void> {
|
||||||
classes.value = await classApi.list();
|
classes.value = await classApi.list();
|
||||||
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
|
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
|
||||||
@@ -136,6 +196,7 @@ async function openDetail(row: HomeworkDto): Promise<void> {
|
|||||||
current.value = await homeworkApi.detail(row.id);
|
current.value = await homeworkApi.detail(row.id);
|
||||||
const students = await classApi.students(row.classId);
|
const students = await classApi.students(row.classId);
|
||||||
classStudents.value = students.map((item) => ({ id: item.id, name: item.name }));
|
classStudents.value = students.map((item) => ({ id: item.id, name: item.name }));
|
||||||
|
void loadSubmissions(row.id);
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +318,27 @@ onUnmounted(() => {
|
|||||||
已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%)
|
已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%)
|
||||||
<el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" />
|
<el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" />
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="未交名单" :span="2">
|
||||||
|
<div v-loading="submissionsLoading" class="submission-summary">
|
||||||
|
<template v-if="submissionStudents.length === 0">该班还没有学生账号</template>
|
||||||
|
<template v-else-if="notSubmittedNames.length === 0">
|
||||||
|
<el-tag type="success" size="small" effect="light">全部已交</el-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-tag
|
||||||
|
v-for="student in notSubmittedNames"
|
||||||
|
:key="student.userId"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
effect="light"
|
||||||
|
class="submission-tag"
|
||||||
|
>
|
||||||
|
{{ student.name }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<el-button size="small" type="primary" plain @click="openSubmissions">勾选未交</el-button>
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<div class="mt-16">
|
<div class="mt-16">
|
||||||
@@ -277,6 +359,35 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 未交名单:勾选谁没交,其余学生自动标记为已交 -->
|
||||||
|
<el-dialog v-model="submissionsVisible" title="未交名单" width="520px">
|
||||||
|
<p class="text-muted" style="margin-top: 0">
|
||||||
|
勾选
|
||||||
|
<strong>没交作业</strong>
|
||||||
|
的同学,保存后其余同学会自动标记为已完成。
|
||||||
|
</p>
|
||||||
|
<div class="submission-toolbar">
|
||||||
|
<el-button size="small" @click="markAllSubmitted">全部已交</el-button>
|
||||||
|
<el-button size="small" @click="markAllNotSubmitted">全部未交</el-button>
|
||||||
|
<span class="text-muted">未交 {{ notSubmittedIds.length }} / {{ submissionStudents.length }} 人</span>
|
||||||
|
</div>
|
||||||
|
<div v-loading="submissionsLoading" class="submission-list">
|
||||||
|
<el-empty
|
||||||
|
v-if="submissionStudents.length === 0 && !submissionsLoading"
|
||||||
|
description="该班还没有学生账号"
|
||||||
|
/>
|
||||||
|
<el-checkbox-group v-model="notSubmittedIds">
|
||||||
|
<el-checkbox v-for="student in submissionStudents" :key="student.userId" :value="student.userId">
|
||||||
|
{{ student.name }}({{ student.username }})
|
||||||
|
</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="submissionsVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submissionsSaving" @click="saveSubmissions">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -289,8 +400,42 @@ onUnmounted(() => {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 未交名单 */
|
||||||
|
|
||||||
|
.submission-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-tag {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-list {
|
||||||
|
max-height: 46vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #eef0f5;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-list :deep(.el-checkbox-group) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -5,15 +5,20 @@ import {
|
|||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
WEEKDAY_LABELS,
|
WEEKDAY_LABELS,
|
||||||
|
WEEK_PARITY_LABELS,
|
||||||
|
WEEK_PARITY_VALUES,
|
||||||
buildWeekOptions,
|
buildWeekOptions,
|
||||||
formatWeekRange,
|
formatWeekRange,
|
||||||
|
weekParityOf,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
type CreateScheduleRequest,
|
type CreateScheduleRequest,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
type ScheduleWeekView,
|
type ScheduleWeekView,
|
||||||
|
type WeekParity,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
|
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
|
||||||
|
import ClassPlanImportDialog from '@/components/ClassPlanImportDialog.vue';
|
||||||
import TimeLayoutImportDialog from '@/components/TimeLayoutImportDialog.vue';
|
import TimeLayoutImportDialog from '@/components/TimeLayoutImportDialog.vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useRealtimeStore } from '@/stores/realtime';
|
import { useRealtimeStore } from '@/stores/realtime';
|
||||||
@@ -41,6 +46,12 @@ const rawSchedules = ref<ScheduleDto[]>([]);
|
|||||||
const query = reactive({ classId: '', week: 1 });
|
const query = reactive({ classId: '', week: 1 });
|
||||||
const weekOptions = ref<number[]>(buildWeekOptions(20));
|
const weekOptions = ref<number[]>(buildWeekOptions(20));
|
||||||
|
|
||||||
|
/** 当前周次的单双周(第 1 周记为单周),用于工具栏提示与课表校验 */
|
||||||
|
const currentWeekParity = computed(() => weekParityOf(query.week));
|
||||||
|
const currentWeekParityLabel = computed(
|
||||||
|
() => `第 ${query.week} 周 · ${WEEK_PARITY_LABELS[currentWeekParity.value]}`,
|
||||||
|
);
|
||||||
|
|
||||||
/** 把同一时间段的课表行聚合成"节次"行,形成 7 列课表 */
|
/** 把同一时间段的课表行聚合成"节次"行,形成 7 列课表 */
|
||||||
const periods = computed(() => {
|
const periods = computed(() => {
|
||||||
const buckets = new Map<string, ScheduleDto[]>();
|
const buckets = new Map<string, ScheduleDto[]>();
|
||||||
@@ -112,6 +123,8 @@ const form = reactive({
|
|||||||
location: '',
|
location: '',
|
||||||
weekStart: 1,
|
weekStart: 1,
|
||||||
weekEnd: 20,
|
weekEnd: 20,
|
||||||
|
/** 单双周:ALL 每周(默认)/ ODD 单周 / EVEN 双周 */
|
||||||
|
weekParity: 'ALL' as WeekParity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
@@ -133,6 +146,7 @@ function openCreate(): void {
|
|||||||
form.location = '';
|
form.location = '';
|
||||||
form.weekStart = 1;
|
form.weekStart = 1;
|
||||||
form.weekEnd = weekOptions.value.at(-1) ?? 20;
|
form.weekEnd = weekOptions.value.at(-1) ?? 20;
|
||||||
|
form.weekParity = 'ALL';
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +159,7 @@ function openEdit(item: ScheduleDto): void {
|
|||||||
form.location = item.location ?? '';
|
form.location = item.location ?? '';
|
||||||
form.weekStart = item.weekStart;
|
form.weekStart = item.weekStart;
|
||||||
form.weekEnd = item.weekEnd;
|
form.weekEnd = item.weekEnd;
|
||||||
|
form.weekParity = item.weekParity ?? 'ALL';
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +180,7 @@ async function submitForm(): Promise<void> {
|
|||||||
location: form.location || null,
|
location: form.location || null,
|
||||||
weekStart: Number(form.weekStart),
|
weekStart: Number(form.weekStart),
|
||||||
weekEnd: Number(form.weekEnd),
|
weekEnd: Number(form.weekEnd),
|
||||||
|
weekParity: form.weekParity,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
@@ -207,6 +223,24 @@ function openTimeLayoutImport(): void {
|
|||||||
timeLayoutVisible.value = true;
|
timeLayoutVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ ClassIsland 课程表导入(支持单双周) */
|
||||||
|
|
||||||
|
const classPlanVisible = ref(false);
|
||||||
|
|
||||||
|
function openClassPlanImport(): void {
|
||||||
|
if (!query.classId) {
|
||||||
|
ElMessage.warning('请先选择班级');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
classPlanVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导入成功后刷新周视图与列表(grid + list 都在 loadSchedules 里) */
|
||||||
|
async function onClassPlanImported(): Promise<void> {
|
||||||
|
await loadCourses();
|
||||||
|
await loadSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadBase();
|
await loadBase();
|
||||||
await loadSchedules();
|
await loadSchedules();
|
||||||
@@ -237,6 +271,7 @@ onUnmounted(() => {
|
|||||||
<el-select v-model="query.week" placeholder="周次" style="width: 130px" @change="loadSchedules">
|
<el-select v-model="query.week" placeholder="周次" style="width: 130px" @change="loadSchedules">
|
||||||
<el-option v-for="week in weekOptions" :key="week" :label="`第 ${week} 周`" :value="week" />
|
<el-option v-for="week in weekOptions" :key="week" :label="`第 ${week} 周`" :value="week" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-tag type="info" effect="plain">{{ currentWeekParityLabel }}</el-tag>
|
||||||
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
|
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
|
||||||
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
|
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
|
||||||
新增课表
|
新增课表
|
||||||
@@ -244,6 +279,15 @@ onUnmounted(() => {
|
|||||||
<el-button v-if="canManageSchedule" type="warning" :icon="'Upload'" @click="openTimeLayoutImport">
|
<el-button v-if="canManageSchedule" type="warning" :icon="'Upload'" @click="openTimeLayoutImport">
|
||||||
导入时间配置
|
导入时间配置
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canManageSchedule"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
:icon="'Upload'"
|
||||||
|
@click="openClassPlanImport"
|
||||||
|
>
|
||||||
|
导入 ClassIsland 课程表
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -268,7 +312,12 @@ onUnmounted(() => {
|
|||||||
<el-table-column v-for="day in WEEKDAYS" :key="day" :label="WEEKDAY_LABELS[day]" min-width="130">
|
<el-table-column v-for="day in WEEKDAYS" :key="day" :label="WEEKDAY_LABELS[day]" min-width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
|
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
|
||||||
<div class="timetable-course">{{ item.course?.name ?? '-' }}</div>
|
<div class="timetable-course">
|
||||||
|
{{ item.course?.name ?? '-' }}
|
||||||
|
<el-tag v-if="item.weekParity !== 'ALL'" size="small" effect="plain">
|
||||||
|
{{ WEEK_PARITY_LABELS[item.weekParity] }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
|
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
|
||||||
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
|
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -323,6 +372,13 @@ onUnmounted(() => {
|
|||||||
<span style="margin: 0 8px">至</span>
|
<span style="margin: 0 8px">至</span>
|
||||||
<el-input-number v-model="form.weekEnd" :min="1" :max="30" />
|
<el-input-number v-model="form.weekEnd" :min="1" :max="30" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="单双周" prop="weekParity">
|
||||||
|
<el-radio-group v-model="form.weekParity">
|
||||||
|
<el-radio v-for="parity in WEEK_PARITY_VALUES" :key="parity" :value="parity">
|
||||||
|
{{ WEEK_PARITY_LABELS[parity] }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="formVisible = false">取消</el-button>
|
<el-button @click="formVisible = false">取消</el-button>
|
||||||
@@ -336,5 +392,13 @@ onUnmounted(() => {
|
|||||||
:class-id="query.classId"
|
:class-id="query.classId"
|
||||||
:class-name="classes.find((item) => item.id === query.classId)?.name"
|
:class-name="classes.find((item) => item.id === query.classId)?.name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- ClassIsland 课程表导入(支持单双周;导入成功后刷新周视图与列表) -->
|
||||||
|
<ClassPlanImportDialog
|
||||||
|
v-model="classPlanVisible"
|
||||||
|
:class-id="query.classId"
|
||||||
|
:class-name="classes.find((item) => item.id === query.classId)?.name"
|
||||||
|
@imported="onClassPlanImported"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
Reference in new issue
Block a user