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:
LoveChengke committed 2026-09-12 23:48:59 +08:00
1 parent 86430c5f4a
commit b8ef5d8dbb
34 files changed
+2345 -80

No files matched your search

+67 -18
View File
@@ -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 格式化 / 检查 |
@@ -320,14 +320,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 客户端(含灵动岛收起常驻/命中
| 非上课时段"新消息"胶囊 | ![新消息胶囊](docs/screenshots/island/island-4-pill.png) | 268×44 / 536×88 | 直接出现(无入场动画),点击后展开 | | 非上课时段"新消息"胶囊 | ![新消息胶囊](docs/screenshots/island/island-4-pill.png) | 268×44 / 536×88 | 直接出现(无入场动画),点击后展开 |
| 点击胶囊后的详情 | ![点击后展开](docs/screenshots/island/island-5-clicked.png) | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 | | 点击胶囊后的详情 | ![点击后展开](docs/screenshots/island/island-5-clicked.png) | 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

+33 -1
View File
@@ -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;
+11 -1
View File
@@ -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)
+33 -13
View File
@@ -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 failedItems = results.filter((item) => !item.ok);
const passedCount = results.length - failedItems.length;
console.log(`\n[SMOKE] ${passedCount}/${results.length} 项通过`);
const resultFile = process.env.ELECTRON_SMOKE_RESULT; const resultFile = process.env.ELECTRON_SMOKE_RESULT;
if (resultFile) { 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
View File
@@ -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
View File
@@ -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 {
@@ -0,0 +1,4 @@
-- 课表支持单双周:新增 weekParity(ALL=每周 / ODD=单周 / EVEN=双周)
-- 旧数据默认为 'ALL',行为与升级前完全一致(向后兼容)。
ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL';
+2
View File
@@ -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
+22
View File
@@ -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?.();
}
})();
+246
View File
@@ -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(
+2
View File
@@ -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 },
}); });
+15
View File
@@ -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,
+83
View File
@@ -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;
+18 -5
View File
@@ -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 周" / "单周" */
+26 -1
View File
@@ -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>
+146 -1
View File
@@ -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>
+65 -1
View File
@@ -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>