fix(island,homework,schedule,web): 16 项反馈修复(联合通知点不开 / 设置不生效 / 红点位置 / 看板当天且铺满 / 单双周与周数可调 / Web 手机端与批量通知等)
一、灵动岛
- ①「紧急通知后来了新作业,收起再点开不了新作业」根因有二,都已修:
(a) active 仍是紧急那条,点击只会反复打开它 —— 现在点击胶囊优先打开**最新**那条未处理消息(队列里更晚的);
(b) 上课时段 syncWindow 会把普通通知立刻隐藏(表现为点了没反应)—— 新增 explicitShowId,
"用户主动点开"的那条守卫放行(岛完全隐藏时仍严格不打扰)。
冒烟新增「紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)」。
- ② 设置实时预览/保存:补「设置页真实链路」断言(window.desktop.islandSetAppearance → IPC → 主进程 →
渲染进程 CSS 变量 + 窗口尺寸),并断言"岛显示中改尺寸立即生效"(实测均通过)。
- ③ 未读红点移到「通知」图标右上角(此前在文字后面),并加 DOM 几何断言(dx>0 且 dy<0,
没有未读时自动造一条未读通知再断言、断言后删除)。
- 冒烟稳定性:新增 island.setBlurCollapseEnabled()(默认开),测试期间关掉"失焦收起",
避免系统抢焦点导致展开态被收回的随机失败;专门验证失焦的用例仍临时打开。
二、作业看板(学生端)
- ⑥ 只看当天:新增「只看今天」开关(默认开),按 createdAt 的本地日期过滤,昨天的过期作业不再混入。
- ⑧ 完整显示:卡片同时渲染标题 + 作业要求全文(列表模式也加「作业要求」列)。
- ⑨ 铺满不滚动:看板外层高度固定 + overflow hidden,按内容/可用高度算缩放系数用 transform: scale 适配
(工具栏显示当前缩放比例),页面本身不出现滚动条。
- ⑦ 全屏看板标题栏用 #header 插槽:标题左对齐、当天日期时间绝对居中(受「显示时间」开关控制,每分钟刷新)。
三、课表与周数
- ⑤ 学期周数可调:新增 Class.termWeeks(默认 20,迁移 + 维护脚本),班主任在班级管理里改;
GET /api/dashboard/term?classId= 返回该班 maxWeek,课表周次下拉与新建课表的默认 weekEnd 都按它来
(不再固定 30 周)。
- ⑩ 课表管理只保留「导入 ClassIsland 课程表」入口(时间表由课程表 JSON 的 TimeLayouts 一并带入),
「导入时间配置」按钮下线;ui-smoke 第 7.7 段改为断言新弹窗(解析 4 行 + 单双周标签 + 旧入口已下线)。
四、以班级为单位(⑬)与 Web 端
- 个人学生账号登录停用:POST /api/auth/login 对 role=STUDENT 返回 403(改用班级码 + 班级密码);
学生行仍作为个人数据记录键由班级会话代全班读写;e2e 全面切换到班级会话并新增该断言。
- ④ 作业科目过少:新增共享 SUBJECT_CATALOG(16 个常见科目)+ 种子课程扩到全科目;
Web 作业发布弹窗可选「其他科目(新建)」,提交前自动建课(同名复用)。
- ⑪ 通知支持多班批量发布(多选 + 逐班发布 + 汇总结果 + 二次确认覆盖所有选中班);
⑫ 单班只显示已读情况、跨班才显示已读率;⑭ 手机端课表可横向滚动看全 7 天;
⑮ 手机端已连接不再显示孤立绿点(仅未连接时显示文字标签);⑯ 手机端「叫人/发布通知」按钮同宽左对齐;
⑬ Web 学生管理去掉「用户名」列(新建自动生成学号式用户名,导入仍支持显式指定)。
验证:verify:e2e 149/149、verify:web 20/20、verify:desktop 75/75(复跑 68/68,打包便携版 75/75 packaged=true)、
typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
This commit is contained in:
1 parent
b8ef5d8dbb
commit
11008928a1
34 files changed
+1346
-265
No files matched your search
@@ -20,7 +20,7 @@
|
|||||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 71/71) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 75/75) |
|
||||||
| 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 # 后端端到端验收脚本(148 项)
|
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(149 项)
|
||||||
│ └── 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 + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 导入 + 班级账号:148 项
|
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 班级账号(个人学生登录停用):149 项
|
||||||
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项
|
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、多班批量发布、未交名单、课程表导入弹窗、手机端课表横向滚动):20 项
|
||||||
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/胶囊类型汇总、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):71 项
|
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/联合通知回归/胶囊类型汇总、未读红点位置、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化与设置页链路、托盘/退出无残留):75 项
|
||||||
```
|
```
|
||||||
|
|
||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入**等 148 项,
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入、个人学生登录停用**等 149 项,
|
||||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||||
(**班级账号登录** + 四类数据 + Socket.IO),实测 71/71 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 65/65)。
|
(**班级账号登录** + 四类数据 + Socket.IO),实测 75/75 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 68/68)。
|
||||||
|
|
||||||
## 默认账号与种子数据
|
## 默认账号与种子数据
|
||||||
|
|
||||||
@@ -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` | 后端端到端验收(148 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) |
|
| `pnpm verify:e2e` | 后端端到端验收(149 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) |
|
||||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
|
||||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(71 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(75 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
|
||||||
| `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 格式化 / 检查 |
|
||||||
@@ -332,10 +332,19 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中
|
|||||||
工具栏可切「看板 / 列表」、开关「显示时间」、拖「字号」(11–28px 即时生效),
|
工具栏可切「看板 / 列表」、开关「显示时间」、拖「字号」(11–28px 即时生效),
|
||||||
点卡片或「全屏看板」进入全屏放大视图;这些偏好写入客户端配置(`homeworkBoard`),重启后仍生效。
|
点卡片或「全屏看板」进入全屏放大视图;这些偏好写入客户端配置(`homeworkBoard`),重启后仍生效。
|
||||||
|
|
||||||
**未交名单(班级设备)**:作业列表/详情里点「未交名单」→ 勾选**没交作业**的同学 →
|
看板行为细节(按用户反馈逐条落地):
|
||||||
保存后其余同学自动标记为已完成(`PATCH /api/homeworks/:id/submissions`,
|
|
||||||
`GET .../submissions` 读名单)。教师端 Web 管理端的作业详情抽屉里也能看到未交名单并勾选保存,
|
| 需求 | 实现 |
|
||||||
普通学生调用该接口返回 403(只能标记自己)。
|
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 只看当天的作业 | 工具栏「只看今天」开关(默认开):按 `createdAt` 的**本地日期**过滤,昨天的过期作业不再混进看板 |
|
||||||
|
| 作业要完整显示 | 卡片里同时渲染**标题 + 作业要求全文**(`white-space: pre-wrap`,不截断),列表模式也新增「作业要求」列 |
|
||||||
|
| 全部铺满屏幕、不用上下翻页 | 看板外层高度固定 + `overflow: hidden`,按「内容高度 / 可用高度」算缩放系数用 `transform: scale()` 整体缩到刚好放下(字号仍由滑块决定,工具栏显示当前缩放比例) |
|
||||||
|
| 当天时间显示在标题栏正中 | 全屏看板标题栏用 `#header` 插槽:标题左对齐、当天日期时间**绝对居中**显示(受「显示时间」开关控制,每分钟刷新) |
|
||||||
|
| 未交名单 | 作业列表/详情里点「未交名单」→ 勾选**没交作业**的同学 → 保存后其余同学自动标记为已完成(`PATCH /api/homeworks/:id/submissions`,`GET .../submissions` 读名单);Web 管理端作业详情抽屉里同样可看可改 |
|
||||||
|
|
||||||
|
**学生端主体 = 班级(个人学生账号不再允许登录)**:`POST /api/auth/login` 对 `role=STUDENT`
|
||||||
|
一律返回 **403**(提示改用「班级码 + 班级密码」);学生用户行仍保留,作为作业完成 / 通知已读 / 成绩
|
||||||
|
等个人数据的记录键,由班级会话代全班读写。Web 端学生管理列表也不再展示「用户名」列。
|
||||||
|
|
||||||
> 通知到达时还会由**灵动岛**在桌面中上方浮出提醒(见上一节),上课时段自动隐藏、紧急通知立即展开。
|
> 通知到达时还会由**灵动岛**在桌面中上方浮出提醒(见上一节),上课时段自动隐藏、紧急通知立即展开。
|
||||||
|
|
||||||
@@ -343,6 +352,19 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中
|
|||||||
|
|
||||||
客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口。它是"消息到达"的第一现场。
|
客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口。它是"消息到达"的第一现场。
|
||||||
|
|
||||||
|
> **联合通知(紧急 + 作业)为什么以前"点不开新作业"**:上课时段里紧急通知会自动展开,
|
||||||
|
> 学生收起后再来一条作业时 `active` 仍是紧急那条 —— 点击只会反复打开它;而且就算把 active
|
||||||
|
> 换成新作业,`syncWindow` 的上课时段守卫又会立刻把普通通知隐藏(表现为"点了没反应")。
|
||||||
|
> 现在两处都修了:①点击胶囊优先打开**最新**那条未处理消息(队列里更晚的那条);
|
||||||
|
> ②上课时段里"用户主动点开"的那条会记入 `explicitShowId`,守卫对它放行(岛完全隐藏时仍严格不打扰)。
|
||||||
|
> 冒烟里对应断言「紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)」。
|
||||||
|
>
|
||||||
|
> **设置页实时生效链路有专项断言**:`window.desktop.islandSetAppearance` → IPC → 主进程 →
|
||||||
|
> 广播给灵动岛渲染进程(CSS 变量 + 窗口尺寸),岛显示中改尺寸同样立即生效。
|
||||||
|
>
|
||||||
|
> **未读红点位置**:客户端侧边栏的未读红点挂在「通知」图标的**右上方**(此前挂在文字后面,位置不对),
|
||||||
|
> 冒烟用 DOM 几何断言(`dx > 0 且 dy < 0`)守住。
|
||||||
|
|
||||||
> **视觉与排版参考 [WinIsland](https://github.com/WinIslandProject/WinIsland)(Rust/Direct2D 的 Windows 动态岛)重写**:
|
> **视觉与排版参考 [WinIsland](https://github.com/WinIslandProject/WinIsland)(Rust/Direct2D 的 Windows 动态岛)重写**:
|
||||||
> 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/亚克力毛玻璃/主题色三种底、
|
> 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/亚克力毛玻璃/主题色三种底、
|
||||||
> Apple 系统色强调色、白字 alpha 分级、**所有文本 = 基础字号 × 排版系数**、
|
> Apple 系统色强调色、白字 alpha 分级、**所有文本 = 基础字号 × 排版系数**、
|
||||||
@@ -593,7 +615,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
||||||
|
|
||||||
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
||||||
已实测 `packaged: true`、71/71 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
已实测 `packaged: true`、75/75 通过、退出码 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`),因此**用户开着客户端也能跑验证**。
|
||||||
@@ -771,9 +793,17 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
|||||||
`第 1 周 = 单周` 的规则过滤(`weekParityOf(week)`,与 ClassIsland 默认相位一致),
|
`第 1 周 = 单周` 的规则过滤(`weekParityOf(week)`,与 ClassIsland 默认相位一致),
|
||||||
历史数据默认 `ALL`,行为不变。手动添加课表时也能在弹窗里直接选「每周 / 单周 / 双周」。
|
历史数据默认 `ALL`,行为不变。手动添加课表时也能在弹窗里直接选「每周 / 单周 / 双周」。
|
||||||
|
|
||||||
> 手工覆盖 dist 部署(而非跑安装程序)时,已有数据库需要补一列:
|
> **学期周数(班主任可调)**:`Class.termWeeks`(默认 20)由班主任在「班级管理 → 编辑班级」里设置,
|
||||||
> `node packages/server/scripts/apply-parity-column.cjs "<安装目录>/server/dist/lib/db.js"`
|
> `GET /api/dashboard/term?classId=` 返回该班的 `maxWeek`,课表周次下拉与新建课表的默认结束周都按它来
|
||||||
> (安装程序覆盖升级会自动补迁移;仅空库首次启动会执行随包迁移 SQL)。
|
> (此前固定给到 30 周,与实际 20 周不符且无法调整)。
|
||||||
|
>
|
||||||
|
> **课表页入口只保留一个**:「导入 ClassIsland 课程表」——同一份档案 JSON 里的 `TimeLayouts`
|
||||||
|
> 已经把节次时间带进来了,因此「导入时间配置」按钮已下线(组件与接口仍保留,供旧脚本调用)。
|
||||||
|
|
||||||
|
> 手工覆盖 dist 部署(而非跑安装程序)时,已有数据库需要补列:
|
||||||
|
> `node packages/server/scripts/apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js"`
|
||||||
|
> (会幂等补齐 `Schedule.weekParity` 与 `Class.termWeeks`;安装程序覆盖升级会自动补迁移,
|
||||||
|
> 仅空库首次启动会执行随包迁移 SQL)。
|
||||||
|
|
||||||
## REST API 一览
|
## REST API 一览
|
||||||
|
|
||||||
@@ -994,7 +1024,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 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **71/71** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **75/75** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
|
|||||||
mode: 'board',
|
mode: 'board',
|
||||||
showTime: false,
|
showTime: false,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
todayOnly: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PersistedConfig {
|
interface PersistedConfig {
|
||||||
@@ -42,6 +43,7 @@ function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): Homewor
|
|||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
showTime: input?.showTime === true,
|
showTime: input?.showTime === true,
|
||||||
|
todayOnly: input?.todayOnly !== false,
|
||||||
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
|
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ class IslandController {
|
|||||||
/** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */
|
/** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */
|
||||||
private backgroundMaterial: string = 'none';
|
private backgroundMaterial: string = 'none';
|
||||||
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
|
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
|
||||||
|
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
|
||||||
|
private blurCollapseEnabled = true;
|
||||||
private interactive = false;
|
private interactive = false;
|
||||||
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
|
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
|
||||||
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
|
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
|
||||||
@@ -137,6 +139,8 @@ class IslandController {
|
|||||||
private lastHitSyncAt = 0;
|
private lastHitSyncAt = 0;
|
||||||
/** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */
|
/** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */
|
||||||
private previewId: string | null = null;
|
private previewId: string | null = null;
|
||||||
|
/** 上课时段里"用户主动点开"的通知 id(syncWindow 的上课守卫对它放行) */
|
||||||
|
private explicitShowId: string | null = null;
|
||||||
/** 预览示例岛的自动收尾定时器 */
|
/** 预览示例岛的自动收尾定时器 */
|
||||||
private previewTimer: ReturnType<typeof setTimeout> | null = null;
|
private previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -225,6 +229,8 @@ class IslandController {
|
|||||||
* 只在激活满 500ms 后才把 blur 当作"用户点击了别处"。
|
* 只在激活满 500ms 后才把 blur 当作"用户点击了别处"。
|
||||||
*/
|
*/
|
||||||
win.on('blur', () => {
|
win.on('blur', () => {
|
||||||
|
// 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启)
|
||||||
|
if (!this.blurCollapseEnabled) return;
|
||||||
if (this.state.mode !== 'expanded' || this.state.inClass) return;
|
if (this.state.mode !== 'expanded' || this.state.inClass) return;
|
||||||
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
|
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
|
||||||
if (this.previewId && this.state.active?.id === this.previewId) return;
|
if (this.previewId && this.state.active?.id === this.previewId) return;
|
||||||
@@ -388,6 +394,7 @@ class IslandController {
|
|||||||
|
|
||||||
if (payload.inClass) {
|
if (payload.inClass) {
|
||||||
if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏');
|
if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏');
|
||||||
|
this.explicitShowId = null;
|
||||||
if (this.state.mode !== 'hidden') {
|
if (this.state.mode !== 'hidden') {
|
||||||
// 上课期间自动隐藏(紧急通知也遵循,避免持续遮挡课堂投影)
|
// 上课期间自动隐藏(紧急通知也遵循,避免持续遮挡课堂投影)
|
||||||
this.setState({ mode: 'hidden', reason: null });
|
this.setState({ mode: 'hidden', reason: null });
|
||||||
@@ -434,21 +441,38 @@ class IslandController {
|
|||||||
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
||||||
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
||||||
const showing = this.state.active;
|
const showing = this.state.active;
|
||||||
if (this.state.inClass && !this.isOpenable(showing)) {
|
// 岛当前可见(胶囊态)时,用户是"对着屏幕上这条岛"点的:
|
||||||
|
// 只要队列里有更**新**的消息(例如紧急通知之后又来了作业),就应当打开新那条,
|
||||||
|
// 而不是反复打开上一条 —— 否则表现为"点不开新作业"(用户反馈的联合通知场景)。
|
||||||
|
// 岛完全隐藏时仍严格遵循上课不打扰。
|
||||||
|
const islandVisible = this.state.mode !== 'hidden';
|
||||||
|
if (this.state.inClass && !this.isOpenable(showing) && !islandVisible) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`灵动岛:上课时段忽略展开(priority=${showing?.priority ?? '-'} kind=${showing?.kind ?? '-'} id=${showing?.id ?? '-'})`,
|
`灵动岛:上课时段忽略展开(priority=${showing?.priority ?? '-'} kind=${showing?.kind ?? '-'} id=${showing?.id ?? '-'})`,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
|
const newestQueued = this.state.queued[this.state.queued.length - 1];
|
||||||
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
|
const shouldOpenNewest = Boolean(
|
||||||
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
|
newestQueued &&
|
||||||
if (!this.state.active) {
|
(!showing || String(newestQueued.createdAt ?? '') > String(showing.createdAt ?? '')),
|
||||||
const pending = this.state.queued[this.state.queued.length - 1];
|
);
|
||||||
|
if (!this.state.active || shouldOpenNewest) {
|
||||||
|
const pending = newestQueued;
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
// 当前展示的那条回队列,换成最新的一条
|
||||||
|
if (this.state.active && this.state.active.id !== pending.id) {
|
||||||
|
this.state.queued = this.state.queued.filter(
|
||||||
|
(item) => item.id !== pending.id && item.id !== this.state.active?.id,
|
||||||
|
);
|
||||||
|
this.state.queued.push(this.state.active);
|
||||||
|
} else {
|
||||||
this.state.queued = this.state.queued.filter((item) => item.id !== pending.id);
|
this.state.queued = this.state.queued.filter((item) => item.id !== pending.id);
|
||||||
|
}
|
||||||
this.state.active = pending;
|
this.state.active = pending;
|
||||||
logger.info(`灵动岛:收起态点击,重新打开队列中的通知 ${pending.id}`);
|
logger.info(
|
||||||
|
`灵动岛:收起态点击,打开最新通知 ${pending.id}(队列 ${this.state.queued.length} 条)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const active = this.state.active;
|
const active = this.state.active;
|
||||||
@@ -458,6 +482,10 @@ class IslandController {
|
|||||||
`灵动岛:展开通知 ${active.id}(priority=${active.priority} kind=${active.kind ?? '-'})`,
|
`灵动岛:展开通知 ${active.id}(priority=${active.priority} kind=${active.kind ?? '-'})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 上课时段里"用户主动点开"的消息要能真的留在屏幕上:
|
||||||
|
// 记下这条 id,syncWindow 的上课守卫对它是放行的(否则展开后会被立刻隐藏,
|
||||||
|
// 表现为"点不开新作业"——用户反馈的联合通知场景)。
|
||||||
|
this.explicitShowId = this.state.inClass ? active.id : null;
|
||||||
this.setState({ mode: 'expanded' });
|
this.setState({ mode: 'expanded' });
|
||||||
this.scheduleCollapse(
|
this.scheduleCollapse(
|
||||||
this.isImmediate(active)
|
this.isImmediate(active)
|
||||||
@@ -511,6 +539,7 @@ class IslandController {
|
|||||||
|
|
||||||
hide(): void {
|
hide(): void {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
|
this.explicitShowId = null;
|
||||||
if (!this.win || this.win.isDestroyed()) return;
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
// 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏,
|
// 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏,
|
||||||
// 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。
|
// 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。
|
||||||
@@ -529,6 +558,7 @@ class IslandController {
|
|||||||
hideImmediately(): void {
|
hideImmediately(): void {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
this.cancelBoundsAnimation();
|
this.cancelBoundsAnimation();
|
||||||
|
this.explicitShowId = null;
|
||||||
this.fadeToken += 1;
|
this.fadeToken += 1;
|
||||||
const changed = this.state.mode !== 'hidden' || this.state.reason !== null;
|
const changed = this.state.mode !== 'hidden' || this.state.reason !== null;
|
||||||
if (changed) {
|
if (changed) {
|
||||||
@@ -699,7 +729,10 @@ class IslandController {
|
|||||||
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
|
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
|
||||||
// (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏)
|
// (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏)
|
||||||
const openableShowing = this.state.active !== null && this.isOpenable(this.state.active);
|
const openableShowing = this.state.active !== null && this.isOpenable(this.state.active);
|
||||||
if (this.state.inClass && !openableShowing) {
|
// 用户在课中主动点开的那条同样允许显示(否则"点开了又被立刻隐藏")
|
||||||
|
const explicitShowing =
|
||||||
|
this.state.active !== null && this.state.active.id === this.explicitShowId && !this.previewId;
|
||||||
|
if (this.state.inClass && !openableShowing && !explicitShowing) {
|
||||||
this.hideImmediately();
|
this.hideImmediately();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -978,6 +1011,11 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 当前是否接收鼠标(供冒烟验证) */
|
/** 当前是否接收鼠标(供冒烟验证) */
|
||||||
|
/** 诊断/冒烟用:开关"失焦自动收起" */
|
||||||
|
setBlurCollapseEnabled(enabled: boolean): void {
|
||||||
|
this.blurCollapseEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
getInteractive(): boolean {
|
getInteractive(): boolean {
|
||||||
return this.interactive;
|
return this.interactive;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,61 @@ async function dumpIslandDom(_label: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟用:用教师账号发一条普通通知,返回通知 id(失败返回空串)。
|
||||||
|
* 用于"未读红点位置"这类需要真实未读数据的断言。
|
||||||
|
*/
|
||||||
|
async function createSmokeNotification(): Promise<string> {
|
||||||
|
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
|
||||||
|
try {
|
||||||
|
const login = (await fetch(`${base}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
|
||||||
|
}).then((response) => response.json())) as { data?: { token?: string } };
|
||||||
|
const token = login?.data?.token;
|
||||||
|
if (!token) return '';
|
||||||
|
const classes = (await fetch(`${base}/classes`, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
}).then((response) => response.json())) as { data?: Array<{ id?: string }> };
|
||||||
|
const classId = classes?.data?.[0]?.id ?? '';
|
||||||
|
if (!classId) return '';
|
||||||
|
const created = (await fetch(`${base}/notifications`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
classId,
|
||||||
|
title: '红点位置校验',
|
||||||
|
content: '自动化验证用通知(断言未读红点位置后会删除)。',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
}),
|
||||||
|
}).then((response) => response.json())) as { data?: { id?: string } };
|
||||||
|
return created?.data?.id ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冒烟用:删除上面造的通知(失败忽略) */
|
||||||
|
async function deleteSmokeNotification(id: string): Promise<void> {
|
||||||
|
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
|
||||||
|
try {
|
||||||
|
const login = (await fetch(`${base}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
|
||||||
|
}).then((response) => response.json())) as { data?: { token?: string } };
|
||||||
|
const token = login?.data?.token;
|
||||||
|
if (!token) return;
|
||||||
|
await fetch(`${base}/notifications/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 清理失败不影响冒烟结论
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeNotification(
|
function makeNotification(
|
||||||
id: string,
|
id: string,
|
||||||
priority: IslandNotification['priority'],
|
priority: IslandNotification['priority'],
|
||||||
@@ -492,6 +547,9 @@ async function runIslandChecks(
|
|||||||
if (!island.isReady()) return;
|
if (!island.isReady()) return;
|
||||||
|
|
||||||
const islandWindow = island.getWindow();
|
const islandWindow = island.getWindow();
|
||||||
|
// 默认关掉"失焦自动收起":冒烟是自动化环境,系统/其它窗口抢焦点会随时触发 blur,
|
||||||
|
// 把展开态收回会让断言随机失败。专门验证该行为的用例(4.5)会临时打开它。
|
||||||
|
island.setBlurCollapseEnabled(false);
|
||||||
|
|
||||||
// 1) 上课期间:普通通知必须完全不显示(窗口隐藏)并进入队列
|
// 1) 上课期间:普通通知必须完全不显示(窗口隐藏)并进入队列
|
||||||
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||||
@@ -837,6 +895,7 @@ async function runIslandChecks(
|
|||||||
await sleep(700);
|
await sleep(700);
|
||||||
const focusable = islandWindow?.isFocusable() ?? false;
|
const focusable = islandWindow?.isFocusable() ?? false;
|
||||||
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
|
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
|
||||||
|
island.setBlurCollapseEnabled(true); // 这一条专门验证"失焦收起",先打开该行为
|
||||||
islandWindow?.emit('blur');
|
islandWindow?.emit('blur');
|
||||||
await sleep(700);
|
await sleep(700);
|
||||||
const afterBlur = island.getState();
|
const afterBlur = island.getState();
|
||||||
@@ -845,6 +904,8 @@ async function runIslandChecks(
|
|||||||
focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill',
|
focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill',
|
||||||
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
|
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
|
||||||
);
|
);
|
||||||
|
// 其余用例关掉"失焦收起":避免无关的焦点变化(系统抢焦点等)把展开态收回,干扰断言
|
||||||
|
island.setBlurCollapseEnabled(false);
|
||||||
|
|
||||||
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
|
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
|
||||||
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
|
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
|
||||||
@@ -1149,6 +1210,9 @@ async function runIslandChecks(
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
|
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
|
||||||
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
|
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
|
||||||
|
// 先把光标注入到屏幕左上角(等价于指针离开岛体),否则轮询会立刻把命中又打开。
|
||||||
|
island.setHitTestCursor({ x: 1, y: 1 });
|
||||||
|
await sleep(260);
|
||||||
island.setInteractive(false);
|
island.setInteractive(false);
|
||||||
await sleep(60);
|
await sleep(60);
|
||||||
const forcedThrough = island.getInteractive();
|
const forcedThrough = island.getInteractive();
|
||||||
@@ -1316,6 +1380,66 @@ async function runIslandChecks(
|
|||||||
`mode=${previewDismissed.mode} active=${previewDismissed.active?.id ?? '-'}`,
|
`mode=${previewDismissed.mode} active=${previewDismissed.active?.id ?? '-'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4.12) 用户复现路径:紧急通知 + 新作业(联合通知)时,收起后点击必须能打开**新作业**
|
||||||
|
// 之前 active 仍是紧急那条,点击只会反复打开紧急,表现为"点不开新作业"。
|
||||||
|
await drainForCollapseCheck();
|
||||||
|
await sleep(300);
|
||||||
|
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||||
|
await sleep(300);
|
||||||
|
island.pushNotification(makeNotification('smoke-mixed-urgent', 'URGENT', '上课期间的紧急通知'), {
|
||||||
|
inClass: true,
|
||||||
|
});
|
||||||
|
await sleep(700);
|
||||||
|
const mixedUrgent = island.getState();
|
||||||
|
// 收起(学生点空白处)
|
||||||
|
await islandWindow?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
document.querySelector('.island-card')?.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
await sleep(600);
|
||||||
|
const mixedCollapsed = island.getState();
|
||||||
|
// 又来了新作业(上课时段,属普通通知 → 进队列,但胶囊已把这个类型汇总出来了)
|
||||||
|
island.pushNotification(
|
||||||
|
{
|
||||||
|
...makeNotification('smoke-mixed-homework', 'NORMAL', '今天的新作业'),
|
||||||
|
kind: 'homework',
|
||||||
|
},
|
||||||
|
{ inClass: true },
|
||||||
|
);
|
||||||
|
await sleep(600);
|
||||||
|
const mixedPillDom = await islandWindow?.webContents.executeJavaScript(
|
||||||
|
`document.querySelector('.island-card.pill .pill-title')?.textContent?.trim() ?? ''`,
|
||||||
|
);
|
||||||
|
// 点击胶囊:必须打开**新作业**这条
|
||||||
|
await islandWindow?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
document.querySelector('.island-card')?.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
await sleep(750);
|
||||||
|
const mixedOpened = island.getState();
|
||||||
|
record(
|
||||||
|
'紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)',
|
||||||
|
mixedUrgent.mode === 'expanded' &&
|
||||||
|
mixedCollapsed.mode === 'pill' &&
|
||||||
|
(mixedPillDom ?? '').includes('作业') &&
|
||||||
|
mixedOpened.mode === 'expanded' &&
|
||||||
|
mixedOpened.active?.id === 'smoke-mixed-homework',
|
||||||
|
`紧急态=${mixedUrgent.mode} 收起=${mixedCollapsed.mode} 胶囊标题="${mixedPillDom ?? '-'}" ` +
|
||||||
|
`点击后=${mixedOpened.mode} active=${mixedOpened.active?.id ?? '-'}`,
|
||||||
|
);
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(300);
|
||||||
|
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||||
|
await sleep(300);
|
||||||
|
|
||||||
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||||
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
||||||
const shotDetails = islandShots.map((shot) => {
|
const shotDetails = islandShots.map((shot) => {
|
||||||
@@ -1389,6 +1513,74 @@ async function runIslandChecks(
|
|||||||
|
|
||||||
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
|
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
|
||||||
const appearanceBefore = island.getAppearance();
|
const appearanceBefore = island.getAppearance();
|
||||||
|
// 8.1) 设置页真实链路(渲染进程 → IPC → 主进程):用户反馈"设置不生效",
|
||||||
|
// 因此必须走设置页用的 `window.desktop.islandSetAppearance`,而不是直接调主进程 API。
|
||||||
|
const rendererApplied = await mainWin?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
if (!window.desktop?.islandSetAppearance) return false;
|
||||||
|
window.desktop.islandSetAppearance({
|
||||||
|
height: 62,
|
||||||
|
width: 320,
|
||||||
|
radius: 28,
|
||||||
|
fontSize: 16,
|
||||||
|
opacity: 0.9,
|
||||||
|
accent: '#ff7043',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => false);
|
||||||
|
await sleep(450);
|
||||||
|
const rendererAppearance = island.getAppearance();
|
||||||
|
const rendererCssVars = await islandWindow?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
return {
|
||||||
|
h: style.getPropertyValue('--island-h').trim(),
|
||||||
|
w: style.getPropertyValue('--island-w').trim(),
|
||||||
|
font: style.getPropertyValue('--island-font').trim(),
|
||||||
|
accent: style.getPropertyValue('--island-accent').trim(),
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
record(
|
||||||
|
'设置页链路:渲染进程 islandSetAppearance 实时生效(尺寸/字号/主题色)',
|
||||||
|
rendererApplied === true &&
|
||||||
|
rendererAppearance.height === 62 &&
|
||||||
|
rendererAppearance.width === 320 &&
|
||||||
|
rendererAppearance.fontSize === 16 &&
|
||||||
|
rendererAppearance.accent.toLowerCase() === '#ff7043' &&
|
||||||
|
rendererCssVars?.h === '62px' &&
|
||||||
|
rendererCssVars?.w === '320px' &&
|
||||||
|
rendererCssVars?.font === '16px' &&
|
||||||
|
rendererCssVars?.accent.toLowerCase() === '#ff7043',
|
||||||
|
`渲染进程调用=${rendererApplied} 主进程外观=${rendererAppearance.width}x${rendererAppearance.height} ` +
|
||||||
|
`字号=${rendererAppearance.fontSize} 主题色=${rendererAppearance.accent} ` +
|
||||||
|
`CSS 变量 h=${rendererCssVars?.h} w=${rendererCssVars?.w} font=${rendererCssVars?.font} accent=${rendererCssVars?.accent}`,
|
||||||
|
);
|
||||||
|
// 岛**可见**时改尺寸也要立刻生效(用户在设置页调滑块时岛正显示着)
|
||||||
|
island.pushNotification(makeNotification('smoke-appearance-live', 'NORMAL', '实时预览校验'), {
|
||||||
|
inClass: false,
|
||||||
|
});
|
||||||
|
await sleep(500);
|
||||||
|
await mainWin?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
window.desktop?.islandSetAppearance({ height: 66, width: 336, fontSize: 17 });
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
await sleep(450);
|
||||||
|
const liveBounds = (await readIslandGeometry())?.island ?? null;
|
||||||
|
record(
|
||||||
|
'设置页链路:岛显示中改尺寸也立刻生效(实时预览)',
|
||||||
|
liveBounds?.height === 66 && Math.abs((liveBounds?.width ?? 0) - 336) <= 1,
|
||||||
|
`岛=${liveBounds?.width?.toFixed(0) ?? '-'}x${liveBounds?.height?.toFixed(0) ?? '-'}(期望 336x66)`,
|
||||||
|
);
|
||||||
island.setAppearance({ height: 56, width: 300, radius: 26, fontSize: 14, opacity: 0.92, speed: 1.5 });
|
island.setAppearance({ height: 56, width: 300, radius: 26, fontSize: 14, opacity: 0.92, speed: 1.5 });
|
||||||
island.handleAction({ action: 'dismiss' });
|
island.handleAction({ action: 'dismiss' });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
@@ -2281,6 +2473,60 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
);
|
);
|
||||||
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
|
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
|
||||||
|
|
||||||
|
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
|
||||||
|
const readBadgeGeometry = async (): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
skipped?: string;
|
||||||
|
reason?: string;
|
||||||
|
dx?: number;
|
||||||
|
dy?: number;
|
||||||
|
text?: string;
|
||||||
|
} | null> =>
|
||||||
|
await win.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
const item = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
||||||
|
(node.textContent ?? '').includes('通知'),
|
||||||
|
);
|
||||||
|
if (!item) return { ok: false, reason: '找不到通知菜单项' };
|
||||||
|
const badge = item.querySelector('.menu-badge');
|
||||||
|
const icon = item.querySelector('.menu-icon-slot .el-icon');
|
||||||
|
if (!badge) return { ok: true, skipped: '当前没有未读消息' };
|
||||||
|
if (!icon) return { ok: false, reason: '通知图标没有 .menu-icon-slot 定位父级' };
|
||||||
|
const b = badge.getBoundingClientRect();
|
||||||
|
const i = icon.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dx: Math.round(b.left + b.width / 2 - (i.left + i.width / 2)),
|
||||||
|
dy: Math.round(b.top + b.height / 2 - (i.top + i.height / 2)),
|
||||||
|
text: (badge.textContent ?? '').trim(),
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
let badgeCheck = await readBadgeGeometry();
|
||||||
|
let injectedId = '';
|
||||||
|
if (badgeCheck?.skipped) {
|
||||||
|
// 没有未读消息时红点不会渲染:造一条未读通知(教师账号),等实时推送刷新后再断言位置
|
||||||
|
const created = await createSmokeNotification();
|
||||||
|
if (created) {
|
||||||
|
injectedId = created;
|
||||||
|
await sleep(1400);
|
||||||
|
badgeCheck = await readBadgeGeometry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record(
|
||||||
|
'未读红点位置正确(挂在"通知"图标右上方)',
|
||||||
|
badgeCheck?.ok === true &&
|
||||||
|
(badgeCheck?.skipped !== undefined || ((badgeCheck?.dx ?? 0) > 0 && (badgeCheck?.dy ?? 0) < 0)),
|
||||||
|
badgeCheck?.skipped
|
||||||
|
? `${badgeCheck.skipped}(未能构造未读消息,已跳过位置断言)`
|
||||||
|
: `红点=${badgeCheck?.text} 相对图标中心 dx=${badgeCheck?.dx} dy=${badgeCheck?.dy}(要求 dx>0 且 dy<0)` +
|
||||||
|
`${badgeCheck?.reason ? ` 原因=${badgeCheck.reason}` : ''}`,
|
||||||
|
);
|
||||||
|
if (injectedId) await deleteSmokeNotification(injectedId);
|
||||||
|
|
||||||
const cleanup = await win.webContents.executeJavaScript(
|
const cleanup = await win.webContents.executeJavaScript(
|
||||||
`(async () => {
|
`(async () => {
|
||||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||||
|
|||||||
@@ -67,5 +67,6 @@ export const gradeApi = {
|
|||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
|
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
|
||||||
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
|
term: (classId?: string): Promise<{ currentWeek: number; maxWeek: number }> =>
|
||||||
|
api.get('/dashboard/term', classId ? { classId } : undefined),
|
||||||
};
|
};
|
||||||
@@ -106,13 +106,17 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
|
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||||
|
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
||||||
|
<span class="menu-icon-slot">
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
<el-badge
|
<el-badge
|
||||||
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
||||||
:value="notifications.unreadCount"
|
:value="notifications.unreadCount"
|
||||||
|
:max="99"
|
||||||
class="menu-badge"
|
class="menu-badge"
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
<div class="aside-footer">
|
<div class="aside-footer">
|
||||||
@@ -217,8 +221,40 @@ onUnmounted(() => {
|
|||||||
--el-menu-active-color: var(--ch-text);
|
--el-menu-active-color: var(--ch-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 图标槽:作为未读红点的定位父级(红点在图标右上角) */
|
||||||
|
.menu-icon-slot {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 未读红点:定位到图标"右上角外侧"。
|
||||||
|
* el-badge 默认把角标中线压在包裹元素右上角(translateY(-50%) translateX(100%)),
|
||||||
|
* 这里显式覆盖成相对图标槽的负 top / 负 right,保证红点在图标右上方而不是右侧或下方。
|
||||||
|
*/
|
||||||
.menu-badge {
|
.menu-badge {
|
||||||
margin-left: 10px;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-badge :deep(.el-badge__content) {
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
right: -11px;
|
||||||
|
transform: none;
|
||||||
|
height: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-footer {
|
.aside-footer {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { SOCKET_EVENTS, formatDate, type HomeworkDto, type HomeworkSubmissionDto } 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';
|
||||||
@@ -13,7 +13,12 @@ const auth = useAuthStore();
|
|||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
/** 看板/列表模式与看板外观(持久化到客户端配置,重启仍生效) */
|
/** 看板/列表模式与看板外观(持久化到客户端配置,重启仍生效) */
|
||||||
const DEFAULT_BOARD = { mode: 'board' as 'board' | 'list', showTime: false, fontSize: 15 };
|
const DEFAULT_BOARD = {
|
||||||
|
mode: 'board' as 'board' | 'list',
|
||||||
|
showTime: false,
|
||||||
|
fontSize: 15,
|
||||||
|
todayOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const homeworks = ref<HomeworkDto[]>([]);
|
const homeworks = ref<HomeworkDto[]>([]);
|
||||||
@@ -27,20 +32,51 @@ const submitting = ref(false);
|
|||||||
const viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
|
const viewMode = ref<'board' | 'list'>(DEFAULT_BOARD.mode);
|
||||||
const boardShowTime = ref(DEFAULT_BOARD.showTime);
|
const boardShowTime = ref(DEFAULT_BOARD.showTime);
|
||||||
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
|
const boardFontSize = ref(DEFAULT_BOARD.fontSize);
|
||||||
|
const todayOnly = ref(DEFAULT_BOARD.todayOnly);
|
||||||
const savingBoard = ref(false);
|
const savingBoard = ref(false);
|
||||||
|
|
||||||
/** 全屏放大的科目(null = 未打开) */
|
/** 全屏放大的科目(null = 未打开) */
|
||||||
const fullscreenCourse = ref<string | null>(null);
|
const fullscreenCourse = ref<string | null>(null);
|
||||||
const fullscreenBoard = ref(false);
|
const fullscreenBoard = ref(false);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 当天的判定 */
|
||||||
|
|
||||||
|
/** 本地"今天"的日期串(YYYY-MM-DD) */
|
||||||
|
function localDayKey(value: string | Date): string {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每分钟刷新一次"现在",用于标题栏时钟与"今天"的判定 */
|
||||||
|
const now = ref(new Date());
|
||||||
|
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const todayKey = computed(() => localDayKey(now.value));
|
||||||
|
const todayText = computed(
|
||||||
|
() =>
|
||||||
|
`${now.value.getFullYear()} 年 ${now.value.getMonth() + 1} 月 ${now.value.getDate()} 日 ` +
|
||||||
|
`${['周日', '周一', '周二', '周三', '周四', '周五', '周六'][now.value.getDay()]} ` +
|
||||||
|
`${String(now.value.getHours()).padStart(2, '0')}:${String(now.value.getMinutes()).padStart(2, '0')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 按"今天"与完成状态过滤 */
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
if (filter.value === 'pending') return homeworks.value.filter((item) => item.completed !== true);
|
let list = homeworks.value;
|
||||||
if (filter.value === 'done') return homeworks.value.filter((item) => item.completed === true);
|
if (todayOnly.value) list = list.filter((item) => localDayKey(item.createdAt) === todayKey.value);
|
||||||
return homeworks.value;
|
if (filter.value === 'pending') return list.filter((item) => item.completed !== true);
|
||||||
|
if (filter.value === 'done') return list.filter((item) => item.completed === true);
|
||||||
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingCount = computed(() => homeworks.value.filter((item) => item.completed !== true).length);
|
const todayCount = computed(
|
||||||
const doneCount = computed(() => homeworks.value.filter((item) => item.completed === true).length);
|
() => homeworks.value.filter((item) => localDayKey(item.createdAt) === todayKey.value).length,
|
||||||
|
);
|
||||||
|
const pendingCount = computed(() => filtered.value.filter((item) => item.completed !== true).length);
|
||||||
|
const doneCount = computed(() => filtered.value.filter((item) => item.completed === true).length);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 看板分组:按科目聚合成卡片(未关联课程的归到「其他」),
|
* 看板分组:按科目聚合成卡片(未关联课程的归到「其他」),
|
||||||
@@ -60,6 +96,7 @@ const COURSE_ORDER = [
|
|||||||
'音乐',
|
'音乐',
|
||||||
'美术',
|
'美术',
|
||||||
'信息技术',
|
'信息技术',
|
||||||
|
'通用技术',
|
||||||
];
|
];
|
||||||
|
|
||||||
interface BoardColumn {
|
interface BoardColumn {
|
||||||
@@ -98,6 +135,39 @@ const fullscreenItems = computed(() =>
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 看板自适应缩放
|
||||||
|
*
|
||||||
|
* 需求:作业必须**全部完整显示**(标题 + 作业要求),且**不需要上下滚动**。
|
||||||
|
* 做法:先把卡片按视口宽度铺成网格,再按"内容高度 / 可用高度"算一个缩放系数,
|
||||||
|
* 用 transform: scale() 整体缩到刚好铺满(字号仍由用户滑块决定,缩放只是兜底适配)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const boardHostRef = ref<HTMLElement | null>(null);
|
||||||
|
const boardInnerRef = ref<HTMLElement | null>(null);
|
||||||
|
const boardScale = ref(1);
|
||||||
|
const fullscreenHostRef = ref<HTMLElement | null>(null);
|
||||||
|
const fullscreenInnerRef = ref<HTMLElement | null>(null);
|
||||||
|
const fullscreenScale = ref(1);
|
||||||
|
|
||||||
|
function computeScale(host: HTMLElement | null, inner: HTMLElement | null): number {
|
||||||
|
if (!host || !inner) return 1;
|
||||||
|
const available = host.clientHeight;
|
||||||
|
// 先按 1 倍量一次内容高度(transform 不影响 offsetHeight)
|
||||||
|
const content = inner.offsetHeight;
|
||||||
|
if (available <= 0 || content <= 0) return 1;
|
||||||
|
const scale = Math.min(1, available / content);
|
||||||
|
return Math.max(0.45, Math.round(scale * 1000) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitBoards(): void {
|
||||||
|
void nextTick(() => {
|
||||||
|
boardScale.value = computeScale(boardHostRef.value, boardInnerRef.value);
|
||||||
|
fullscreenScale.value = computeScale(fullscreenHostRef.value, fullscreenInnerRef.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 数据加载 */
|
||||||
|
|
||||||
async function loadHomework(): Promise<void> {
|
async function loadHomework(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -115,6 +185,7 @@ async function loadHomework(): Promise<void> {
|
|||||||
if (current.value) {
|
if (current.value) {
|
||||||
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
|
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
|
||||||
}
|
}
|
||||||
|
fitBoards();
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -127,6 +198,7 @@ async function loadBoardSettings(): Promise<void> {
|
|||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
|
if (saved.mode === 'board' || saved.mode === 'list') viewMode.value = saved.mode;
|
||||||
if (typeof saved.showTime === 'boolean') boardShowTime.value = saved.showTime;
|
if (typeof saved.showTime === 'boolean') boardShowTime.value = saved.showTime;
|
||||||
|
if (typeof saved.todayOnly === 'boolean') todayOnly.value = saved.todayOnly;
|
||||||
if (typeof saved.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
|
if (typeof saved.fontSize === 'number' && Number.isFinite(saved.fontSize)) {
|
||||||
boardFontSize.value = Math.min(28, Math.max(11, Math.round(saved.fontSize)));
|
boardFontSize.value = Math.min(28, Math.max(11, Math.round(saved.fontSize)));
|
||||||
}
|
}
|
||||||
@@ -141,8 +213,10 @@ async function saveBoardSettings(): Promise<void> {
|
|||||||
mode: viewMode.value,
|
mode: viewMode.value,
|
||||||
showTime: boardShowTime.value,
|
showTime: boardShowTime.value,
|
||||||
fontSize: boardFontSize.value,
|
fontSize: boardFontSize.value,
|
||||||
|
todayOnly: todayOnly.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
fitBoards();
|
||||||
} finally {
|
} finally {
|
||||||
savingBoard.value = false;
|
savingBoard.value = false;
|
||||||
}
|
}
|
||||||
@@ -155,12 +229,14 @@ function openDetail(item: HomeworkDto): void {
|
|||||||
|
|
||||||
function openCourseFullscreen(column: BoardColumn): void {
|
function openCourseFullscreen(column: BoardColumn): void {
|
||||||
fullscreenCourse.value = column.course;
|
fullscreenCourse.value = column.course;
|
||||||
|
fitBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 覆盖全屏:整块看板放大(点击标题栏「全屏」按钮) */
|
/** 覆盖全屏:整块看板放大(点击标题栏「全屏」按钮) */
|
||||||
function openBoardFullscreen(): void {
|
function openBoardFullscreen(): void {
|
||||||
fullscreenCourse.value = null;
|
fullscreenCourse.value = null;
|
||||||
fullscreenBoard.value = true;
|
fullscreenBoard.value = true;
|
||||||
|
fitBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
||||||
@@ -266,15 +342,28 @@ function onRecovered(): void {
|
|||||||
void loadHomework();
|
void loadHomework();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadBoardSettings();
|
await loadBoardSettings();
|
||||||
await loadHomework();
|
await loadHomework();
|
||||||
|
clockTimer = setInterval(() => {
|
||||||
|
now.value = new Date();
|
||||||
|
}, 30_000);
|
||||||
|
// 视口变化后重新适配(保证"全部显示在屏幕上"始终成立)
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
resizeObserver = new ResizeObserver(() => fitBoards());
|
||||||
|
if (boardHostRef.value) resizeObserver.observe(boardHostRef.value);
|
||||||
|
}
|
||||||
|
fitBoards();
|
||||||
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
|
||||||
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
|
||||||
appStore.onServerRecovered(onRecovered);
|
appStore.onServerRecovered(onRecovered);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (clockTimer) clearInterval(clockTimer);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
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);
|
||||||
@@ -283,16 +372,22 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page homework-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">我的作业</h2>
|
<h2 class="page-title">我的作业</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
共 {{ homeworks.length }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
|
今天 {{ todayCount }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
|
||||||
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
<el-switch
|
||||||
|
v-model="todayOnly"
|
||||||
|
active-text="只看今天"
|
||||||
|
inactive-text="全部日期"
|
||||||
|
@change="saveBoardSettings"
|
||||||
|
/>
|
||||||
<el-radio-group v-model="filter">
|
<el-radio-group v-model="filter">
|
||||||
<el-radio-button value="all">全部</el-radio-button>
|
<el-radio-button value="all">全部</el-radio-button>
|
||||||
<el-radio-button value="pending">未完成</el-radio-button>
|
<el-radio-button value="pending">未完成</el-radio-button>
|
||||||
@@ -322,20 +417,22 @@ onUnmounted(() => {
|
|||||||
:min="11"
|
:min="11"
|
||||||
:max="28"
|
:max="28"
|
||||||
:step="1"
|
:step="1"
|
||||||
style="width: 200px"
|
style="width: 180px"
|
||||||
@change="saveBoardSettings"
|
@change="saveBoardSettings"
|
||||||
/>
|
/>
|
||||||
<span class="text-muted">{{ boardFontSize }}px</span>
|
<span class="text-muted">{{ boardFontSize }}px</span>
|
||||||
<el-button :icon="'FullScreen'" @click="openBoardFullscreen">全屏看板</el-button>
|
<el-button :icon="'FullScreen'" @click="openBoardFullscreen">全屏看板</el-button>
|
||||||
|
<span class="text-muted">自适应缩放 {{ Math.round(boardScale * 100) }}%</span>
|
||||||
<span v-if="savingBoard" class="text-muted">保存中…</span>
|
<span v-if="savingBoard" class="text-muted">保存中…</span>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-loading="loading" shadow="never">
|
<el-card v-loading="loading" shadow="never" class="homework-body">
|
||||||
<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">
|
<div v-else-if="viewMode === 'board'" ref="boardHostRef" class="board-host">
|
||||||
|
<div ref="boardInnerRef" class="board" :style="{ ...boardStyle, '--board-scale': boardScale }">
|
||||||
<section
|
<section
|
||||||
v-for="column in boardColumns"
|
v-for="column in boardColumns"
|
||||||
:key="column.course"
|
:key="column.course"
|
||||||
@@ -356,7 +453,11 @@ onUnmounted(() => {
|
|||||||
@click.stop="openDetail(item)"
|
@click.stop="openDetail(item)"
|
||||||
>
|
>
|
||||||
<span class="board-index">{{ index + 1 }}.</span>
|
<span class="board-index">{{ index + 1 }}.</span>
|
||||||
|
<span class="board-text">
|
||||||
<span class="board-title">{{ item.title }}</span>
|
<span class="board-title">{{ item.title }}</span>
|
||||||
|
<!-- 需求:作业要完整显示(标题 + 作业要求),不能只显示标题 -->
|
||||||
|
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
|
||||||
|
</span>
|
||||||
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||||
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -367,6 +468,7 @@ onUnmounted(() => {
|
|||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 列表模式(默认表格视图) -->
|
<!-- 列表模式(默认表格视图) -->
|
||||||
<el-table v-else :data="filtered" @row-click="openDetail">
|
<el-table v-else :data="filtered" @row-click="openDetail">
|
||||||
@@ -377,7 +479,8 @@ onUnmounted(() => {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="title" label="作业标题" min-width="220" show-overflow-tooltip />
|
<el-table-column prop="title" label="作业标题" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="content" label="作业要求" min-width="240" show-overflow-tooltip />
|
||||||
<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>
|
||||||
@@ -410,7 +513,19 @@ onUnmounted(() => {
|
|||||||
:title="`${fullscreenCourse ?? ''} · 作业`"
|
:title="`${fullscreenCourse ?? ''} · 作业`"
|
||||||
class="board-fullscreen"
|
class="board-fullscreen"
|
||||||
>
|
>
|
||||||
<ol class="board-list board-list-lg" :style="fullscreenStyle">
|
<template #header="{ titleId, titleClass }">
|
||||||
|
<div class="board-dialog-head">
|
||||||
|
<span :id="titleId" :class="titleClass">{{ fullscreenCourse ?? '' }} · 作业</span>
|
||||||
|
<!-- 需求:当天时间显示在标题栏正中间(受"显示时间"开关控制) -->
|
||||||
|
<span v-if="boardShowTime" class="board-dialog-clock">{{ todayText }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="fullscreenHostRef" class="board-host board-host-lg">
|
||||||
|
<ol
|
||||||
|
ref="fullscreenInnerRef"
|
||||||
|
class="board-list board-list-lg"
|
||||||
|
:style="{ ...fullscreenStyle, '--board-scale': fullscreenScale }"
|
||||||
|
>
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in fullscreenItems"
|
v-for="(item, index) in fullscreenItems"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -419,17 +534,32 @@ onUnmounted(() => {
|
|||||||
@click="openDetail(item)"
|
@click="openDetail(item)"
|
||||||
>
|
>
|
||||||
<span class="board-index">{{ index + 1 }}.</span>
|
<span class="board-index">{{ index + 1 }}.</span>
|
||||||
|
<span class="board-text">
|
||||||
<span class="board-title">{{ item.title }}</span>
|
<span class="board-title">{{ item.title }}</span>
|
||||||
|
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
|
||||||
|
</span>
|
||||||
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||||
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
<span v-if="item.completed" class="board-done-tag">已完成</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<el-empty v-if="fullscreenItems.length === 0" description="该科目暂无作业" />
|
</div>
|
||||||
|
<el-empty v-if="fullscreenItems.length === 0" description="该科目今天没有作业" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 全屏:整块看板放大 -->
|
<!-- 全屏:整块看板放大 -->
|
||||||
<el-dialog v-model="fullscreenBoard" fullscreen title="今日作业看板" class="board-fullscreen">
|
<el-dialog v-model="fullscreenBoard" fullscreen class="board-fullscreen">
|
||||||
<div class="board board-lg" :style="fullscreenStyle">
|
<template #header="{ titleId, titleClass }">
|
||||||
|
<div class="board-dialog-head">
|
||||||
|
<span :id="titleId" :class="titleClass">今日作业看板</span>
|
||||||
|
<span v-if="boardShowTime" class="board-dialog-clock">{{ todayText }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="fullscreenHostRef" class="board-host board-host-lg">
|
||||||
|
<div
|
||||||
|
ref="fullscreenInnerRef"
|
||||||
|
class="board board-lg"
|
||||||
|
:style="{ ...fullscreenStyle, '--board-scale': fullscreenScale }"
|
||||||
|
>
|
||||||
<section v-for="column in boardColumns" :key="column.course" class="board-card">
|
<section v-for="column in boardColumns" :key="column.course" class="board-card">
|
||||||
<header class="board-card-head">
|
<header class="board-card-head">
|
||||||
<span class="board-course">{{ column.course }}</span>
|
<span class="board-course">{{ column.course }}</span>
|
||||||
@@ -444,12 +574,16 @@ onUnmounted(() => {
|
|||||||
@click="openDetail(item)"
|
@click="openDetail(item)"
|
||||||
>
|
>
|
||||||
<span class="board-index">{{ index + 1 }}.</span>
|
<span class="board-index">{{ index + 1 }}.</span>
|
||||||
|
<span class="board-text">
|
||||||
<span class="board-title">{{ item.title }}</span>
|
<span class="board-title">{{ item.title }}</span>
|
||||||
|
<span v-if="item.content.trim()" class="board-content">{{ item.content }}</span>
|
||||||
|
</span>
|
||||||
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
<span v-if="boardShowTime" class="board-time">{{ formatDate(item.createdAt, true) }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 未交名单:勾选谁没交(其余学生一律视为已交) -->
|
<!-- 未交名单:勾选谁没交(其余学生一律视为已交) -->
|
||||||
@@ -468,7 +602,7 @@ onUnmounted(() => {
|
|||||||
<el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" />
|
<el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" />
|
||||||
<el-checkbox-group v-model="notSubmittedIds">
|
<el-checkbox-group v-model="notSubmittedIds">
|
||||||
<el-checkbox v-for="student in submissions" :key="student.userId" :value="student.userId">
|
<el-checkbox v-for="student in submissions" :key="student.userId" :value="student.userId">
|
||||||
{{ student.name }}({{ student.username }})
|
{{ student.name }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
</div>
|
</div>
|
||||||
@@ -543,6 +677,27 @@ onUnmounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 作业页整体不滚动:看板靠自适应缩放铺满,避免"要上下翻页才能看全" */
|
||||||
|
.homework-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.homework-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.homework-body :deep(.el-card__body) {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 看板 */
|
/* ---------------------------------------------------------------- 看板 */
|
||||||
|
|
||||||
.board-config {
|
.board-config {
|
||||||
@@ -560,15 +715,32 @@ onUnmounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 承载区:高度固定,里面靠 scale 适配,绝不出现滚动条 */
|
||||||
|
.board-host {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-host-lg {
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
.board {
|
.board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 15), 1fr));
|
||||||
gap: 14px;
|
gap: calc(var(--board-font, 15px) * 0.8);
|
||||||
|
width: 100%;
|
||||||
|
transform: scale(var(--board-scale, 1));
|
||||||
|
transform-origin: top center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-lg {
|
.board-lg {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 16), 1fr));
|
||||||
gap: 20px;
|
gap: calc(var(--board-font, 15px) * 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-card {
|
.board-card {
|
||||||
@@ -577,8 +749,7 @@ onUnmounted(() => {
|
|||||||
background: #1c1c1e;
|
background: #1c1c1e;
|
||||||
color: #f5f5f7;
|
color: #f5f5f7;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: calc(var(--board-font, 15px) * 0.9) calc(var(--board-font, 15px) * 1.1);
|
padding: calc(var(--board-font, 15px) * 0.8) calc(var(--board-font, 15px) * 0.95);
|
||||||
min-height: 120px;
|
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
transition:
|
transition:
|
||||||
transform 0.16s ease,
|
transform 0.16s ease,
|
||||||
@@ -596,7 +767,7 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: calc(var(--board-font, 15px) * 0.5);
|
margin-bottom: calc(var(--board-font, 15px) * 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-course {
|
.board-course {
|
||||||
@@ -625,13 +796,13 @@ onUnmounted(() => {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: calc(var(--board-font, 15px) * 0.35);
|
gap: calc(var(--board-font, 15px) * 0.4);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-item {
|
.board-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: var(--board-font, 15px);
|
font-size: var(--board-font, 15px);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -649,12 +820,29 @@ onUnmounted(() => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-title {
|
.board-text {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
font-weight: 600;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-item.done .board-title {
|
/* 作业要求:完整展示(不截断),字号略小以保持层级 */
|
||||||
|
.board-content {
|
||||||
|
font-size: calc(var(--board-font, 15px) * 0.82);
|
||||||
|
color: #c7c7cc;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-item.done .board-title,
|
||||||
|
.board-item.done .board-content {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: #8e8e93;
|
color: #8e8e93;
|
||||||
}
|
}
|
||||||
@@ -675,7 +863,7 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: calc(var(--board-font, 15px) * 0.5);
|
margin-top: calc(var(--board-font, 15px) * 0.45);
|
||||||
font-size: calc(var(--board-font, 15px) * 0.7);
|
font-size: calc(var(--board-font, 15px) * 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +879,31 @@ onUnmounted(() => {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全屏弹窗标题栏:标题 + 当天时间(时间绝对居中对齐) */
|
||||||
|
.board-dialog-head {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-right: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-dialog-clock {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-fullscreen :deep(.el-dialog__body) {
|
||||||
|
padding-top: 6px;
|
||||||
|
height: calc(100vh - 84px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.submission-toolbar {
|
.submission-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ function remainText(minutes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTerm(): Promise<void> {
|
async function loadTerm(): Promise<void> {
|
||||||
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
|
// 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周)
|
||||||
|
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(auth.classId ?? undefined), {
|
||||||
currentWeek: 1,
|
currentWeek: 1,
|
||||||
maxWeek: 20,
|
maxWeek: 20,
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -8,10 +8,12 @@ import type { IslandAppearance, IslandNotification, IslandState } from '@classhe
|
|||||||
export interface HomeworkBoardSettings {
|
export interface HomeworkBoardSettings {
|
||||||
/** 展示模式:board = 按科目卡片看板,list = 默认表格 */
|
/** 展示模式:board = 按科目卡片看板,list = 默认表格 */
|
||||||
mode: 'board' | 'list';
|
mode: 'board' | 'list';
|
||||||
/** 看板条目是否显示时间 */
|
/** 看板条目是否显示时间(同时也控制"今日作业看板"标题栏中间的当天时间) */
|
||||||
showTime: boolean;
|
showTime: boolean;
|
||||||
/** 看板字号(px) */
|
/** 看板字号(px) */
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
/** 只看"今天布置"的作业(默认 true,避免昨天的过期作业混进来) */
|
||||||
|
todayOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopStoredConfig {
|
export interface DesktopStoredConfig {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 班级教学周数(班主任可调):默认 20 周,旧数据行为不变(原来固定按 20 周展示,见 seed/导入默认值)
|
||||||
|
ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20;
|
||||||
@@ -58,6 +58,8 @@ model Class {
|
|||||||
code String @unique
|
code String @unique
|
||||||
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
|
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
|
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
|
||||||
|
termWeeks Int @default(20)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
|
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
|
||||||
* student01..student15 / student123
|
* student01..student15 / student123
|
||||||
*/
|
*/
|
||||||
|
import { SUBJECT_CATALOG } from '@classhelper/shared';
|
||||||
import { disconnectPrisma, prisma } from '../src/lib/db.js';
|
import { disconnectPrisma, prisma } from '../src/lib/db.js';
|
||||||
import { logger } from '../src/lib/logger.js';
|
import { logger } from '../src/lib/logger.js';
|
||||||
import { hashPassword } from '../src/lib/password.js';
|
import { hashPassword } from '../src/lib/password.js';
|
||||||
@@ -33,7 +34,8 @@ const STUDENT_NAMES = [
|
|||||||
'胡一鸣',
|
'胡一鸣',
|
||||||
];
|
];
|
||||||
|
|
||||||
const COURSE_NAMES = ['语文', '数学', '英语', '物理', '化学'];
|
// 课程目录:与 shared 的 SUBJECT_CATALOG 保持一致(演示数据覆盖全部常见科目)
|
||||||
|
const COURSE_NAMES = [...SUBJECT_CATALOG];
|
||||||
|
|
||||||
/** 每天的上课节次模板(dayOfWeek 1-5) */
|
/** 每天的上课节次模板(dayOfWeek 1-5) */
|
||||||
const PERIOD_TEMPLATE = [
|
const PERIOD_TEMPLATE = [
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表);
|
||||||
|
* 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist**
|
||||||
|
* 部署,就需要显式跑一次本脚本。
|
||||||
|
*
|
||||||
|
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
|
||||||
|
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
|
||||||
|
*
|
||||||
|
* node apply-column-migrations.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-column-migrations.cjs <已编译的 lib/db.js 绝对路径>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 需要保证存在的列(新增迁移时在这里追加一行) */
|
||||||
|
const COLUMNS = [
|
||||||
|
{
|
||||||
|
table: 'Schedule',
|
||||||
|
column: 'weekParity',
|
||||||
|
sql: `ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL'`,
|
||||||
|
note: '课表单双周(ALL/ODD/EVEN)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'Class',
|
||||||
|
column: 'termWeeks',
|
||||||
|
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`,
|
||||||
|
note: '班级教学周数(班主任可调,默认 20)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let prisma;
|
||||||
|
try {
|
||||||
|
const module = await import(pathToFileURL(dbEntry).href);
|
||||||
|
prisma = module.prisma ?? module.default?.prisma;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[migrate] 无法加载服务端的 Prisma 客户端:',
|
||||||
|
error && error.message ? error.message : error,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!prisma) {
|
||||||
|
console.error('[migrate] lib/db.js 里没有导出 prisma');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const item of COLUMNS) {
|
||||||
|
const columns = await prisma.$queryRawUnsafe(`PRAGMA table_info("${item.table}")`);
|
||||||
|
if (columns.some((column) => column.name === item.column)) {
|
||||||
|
console.log(`[migrate] ${item.table}.${item.column} 已存在(跳过)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.$executeRawUnsafe(item.sql);
|
||||||
|
console.log(`[migrate] 已新增 ${item.table}.${item.column} —— ${item.note}`);
|
||||||
|
}
|
||||||
|
for (const table of [...new Set(COLUMNS.map((item) => item.table))]) {
|
||||||
|
const after = await prisma.$queryRawUnsafe(`PRAGMA table_info("${table}")`);
|
||||||
|
console.log(`[migrate] ${table} 列:${after.map((column) => column.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[migrate] 失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect?.();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* 维护脚本:给"已有数据库"补上 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?.();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -85,13 +85,46 @@ async function main() {
|
|||||||
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
|
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
|
||||||
if (!adminToken) process.exit(1);
|
if (!adminToken) process.exit(1);
|
||||||
|
|
||||||
const studentLogin = await api('/auth/login', {
|
// 学生端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
|
||||||
|
const classesForLogin = await api('/classes', { token: teacherToken });
|
||||||
|
const loginClass = classesForLogin.payload?.data?.[0];
|
||||||
|
if (!loginClass?.code) {
|
||||||
|
record(
|
||||||
|
'学生端班级登录(班级码 + 班级密码 → classSession)',
|
||||||
|
false,
|
||||||
|
'没有可用班级码,请先执行 pnpm db:seed',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let studentLogin = await api('/auth/class-login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { username: 'student01', password: 'student123' },
|
body: { code: loginClass.code, password: '123456' },
|
||||||
});
|
});
|
||||||
|
if (studentLogin.status !== 200) {
|
||||||
|
// 演示库密码被改过时,由管理员重置为已知值(仅验证环境)
|
||||||
|
await api(`/classes/${loginClass.id}/class-account`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: adminToken,
|
||||||
|
body: { code: loginClass.code, password: '123456' },
|
||||||
|
});
|
||||||
|
studentLogin = await api('/auth/class-login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { code: loginClass.code, password: '123456' },
|
||||||
|
});
|
||||||
|
}
|
||||||
const studentToken = studentLogin.payload?.data?.token;
|
const studentToken = studentLogin.payload?.data?.token;
|
||||||
const studentUser = studentLogin.payload?.data?.user;
|
const studentUser = studentLogin.payload?.data?.user;
|
||||||
record('学生登录 student01', Boolean(studentToken), `classId=${studentUser?.classId}`);
|
record(
|
||||||
|
'学生端班级登录(班级码 + 班级密码 → classSession)',
|
||||||
|
Boolean(studentToken) && studentUser?.classSession === true,
|
||||||
|
`code=${loginClass.code} status=${studentLogin.status} classId=${studentUser?.classId ?? studentUser?.id}`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'个人学生账号登录已停用(403)',
|
||||||
|
(await api('/auth/login', { method: 'POST', body: { username: 'student01', password: 'student123' } }))
|
||||||
|
.status === 403,
|
||||||
|
'student01 → 403',
|
||||||
|
);
|
||||||
|
|
||||||
const wrongPassword = await api('/auth/login', {
|
const wrongPassword = await api('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -128,7 +161,8 @@ async function main() {
|
|||||||
|
|
||||||
const studentList = await api(`/students?classId=${classId}`, { token: adminToken });
|
const studentList = await api(`/students?classId=${classId}`, { token: adminToken });
|
||||||
const classmates = studentList.payload?.data ?? [];
|
const classmates = studentList.payload?.data ?? [];
|
||||||
const targetStudent = classmates.find((item) => item.id === studentUser?.id) ?? classmates[0];
|
// 班级会话下没有"当前学生",目标学生固定取班里第一位(用于成绩/叫人等需要具体学生 id 的用例)
|
||||||
|
const targetStudent = classmates[0];
|
||||||
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
|
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
|
||||||
|
|
||||||
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
|
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
|
||||||
@@ -468,8 +502,8 @@ async function main() {
|
|||||||
body: { notSubmittedUserIds: [] },
|
body: { notSubmittedUserIds: [] },
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'普通学生不能维护未交名单(403)',
|
'班级设备可以维护未交名单(非教师/非班级会话会被拒)',
|
||||||
studentSubmissions.status === 403,
|
studentSubmissions.status === 200,
|
||||||
`status=${studentSubmissions.status}`,
|
`status=${studentSubmissions.status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -497,7 +531,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
record('教师录入成绩', createdGrade.status === 201, `status=${createdGrade.status}`);
|
record('教师录入成绩', createdGrade.status === 201, `status=${createdGrade.status}`);
|
||||||
|
|
||||||
if (targetStudent?.id === studentUser?.id) {
|
if (targetStudent) {
|
||||||
try {
|
try {
|
||||||
const { payload, elapsed } = await gradeWait;
|
const { payload, elapsed } = await gradeWait;
|
||||||
record(
|
record(
|
||||||
@@ -808,7 +842,7 @@ async function main() {
|
|||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
body: {
|
body: {
|
||||||
classId,
|
classId,
|
||||||
studentId: studentUser.id,
|
studentId: targetStudent?.id,
|
||||||
quickPhrase: '请到办公室找我',
|
quickPhrase: '请到办公室找我',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -829,7 +863,7 @@ async function main() {
|
|||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
body: {
|
body: {
|
||||||
classId,
|
classId,
|
||||||
studentId: studentUser.id,
|
studentId: targetStudent?.id,
|
||||||
quickPhrase: '请立刻到办公室',
|
quickPhrase: '请立刻到办公室',
|
||||||
urgent: true,
|
urgent: true,
|
||||||
},
|
},
|
||||||
@@ -845,7 +879,7 @@ async function main() {
|
|||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
body: {
|
body: {
|
||||||
classId,
|
classId,
|
||||||
studentId: studentUser.id,
|
studentId: targetStudent?.id,
|
||||||
quickPhrase: '请到讲台找我',
|
quickPhrase: '请到讲台找我',
|
||||||
message: '带上昨天的数学作业本',
|
message: '带上昨天的数学作业本',
|
||||||
},
|
},
|
||||||
@@ -860,7 +894,7 @@ async function main() {
|
|||||||
const callDuringClass = await api('/calls', {
|
const callDuringClass = await api('/calls', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
body: { classId, studentId: studentUser.id, quickPhrase: '请马上来一趟' },
|
body: { classId, studentId: targetStudent?.id, quickPhrase: '请马上来一趟' },
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'上课时段允许叫人(普通叫人同样不受紧急通知 409 限制)',
|
'上课时段允许叫人(普通叫人同样不受紧急通知 409 限制)',
|
||||||
@@ -871,7 +905,7 @@ async function main() {
|
|||||||
const studentCalls = await api('/calls', {
|
const studentCalls = await api('/calls', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: studentToken,
|
token: studentToken,
|
||||||
body: { classId, studentId: studentUser.id, quickPhrase: '学生不能叫人' },
|
body: { classId, studentId: targetStudent?.id, quickPhrase: '学生不能叫人' },
|
||||||
});
|
});
|
||||||
record('学生调用叫人接口被拒绝(403)', studentCalls.status === 403, `status=${studentCalls.status}`);
|
record('学生调用叫人接口被拒绝(403)', studentCalls.status === 403, `status=${studentCalls.status}`);
|
||||||
|
|
||||||
@@ -1006,7 +1040,13 @@ async function main() {
|
|||||||
const subjectGrades = await api('/grades', {
|
const subjectGrades = await api('/grades', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: teacher2Token,
|
token: teacher2Token,
|
||||||
body: { classId: classId, userId: studentUser?.id, examName: '越权成绩', score: 90, totalScore: 100 },
|
body: {
|
||||||
|
classId: classId,
|
||||||
|
userId: targetStudent?.id,
|
||||||
|
examName: '越权成绩',
|
||||||
|
score: 90,
|
||||||
|
totalScore: 100,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
record('科任老师录入成绩被拒绝(403)', subjectGrades.status === 403, `status=${subjectGrades.status}`);
|
record('科任老师录入成绩被拒绝(403)', subjectGrades.status === 403, `status=${subjectGrades.status}`);
|
||||||
|
|
||||||
@@ -1016,7 +1056,7 @@ async function main() {
|
|||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
body: {
|
body: {
|
||||||
classId: classId,
|
classId: classId,
|
||||||
userId: studentUser?.id,
|
userId: targetStudent?.id,
|
||||||
examName: '班主任本班成绩',
|
examName: '班主任本班成绩',
|
||||||
score: 90,
|
score: 90,
|
||||||
totalScore: 100,
|
totalScore: 100,
|
||||||
@@ -1037,7 +1077,7 @@ async function main() {
|
|||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
body: {
|
body: {
|
||||||
classId: foreignClass.id,
|
classId: foreignClass.id,
|
||||||
userId: studentUser?.id,
|
userId: targetStudent?.id,
|
||||||
examName: '班主任跨班成绩',
|
examName: '班主任跨班成绩',
|
||||||
score: 90,
|
score: 90,
|
||||||
totalScore: 100,
|
totalScore: 100,
|
||||||
@@ -1055,7 +1095,7 @@ async function main() {
|
|||||||
token: adminToken,
|
token: adminToken,
|
||||||
body: {
|
body: {
|
||||||
classId: classId,
|
classId: classId,
|
||||||
userId: studentUser?.id,
|
userId: targetStudent?.id,
|
||||||
examName: '管理员录入成绩',
|
examName: '管理员录入成绩',
|
||||||
score: 88,
|
score: 88,
|
||||||
totalScore: 100,
|
totalScore: 100,
|
||||||
@@ -1132,7 +1172,7 @@ async function main() {
|
|||||||
const studentVisible = studentClasses.payload?.data ?? [];
|
const studentVisible = studentClasses.payload?.data ?? [];
|
||||||
record(
|
record(
|
||||||
'学生班级列表仅含自己所在班级',
|
'学生班级列表仅含自己所在班级',
|
||||||
studentVisible.length === 1 && studentVisible[0]?.id === studentUser?.classId,
|
studentVisible.length === 1 && studentVisible[0]?.id === classId,
|
||||||
`共 ${studentVisible.length} 个`,
|
`共 ${studentVisible.length} 个`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1140,7 +1180,7 @@ async function main() {
|
|||||||
const homeworkClassIds = new Set((studentHomeworkAll.payload?.data ?? []).map((item) => item.classId));
|
const homeworkClassIds = new Set((studentHomeworkAll.payload?.data ?? []).map((item) => item.classId));
|
||||||
record(
|
record(
|
||||||
'学生作业列表已按班级收敛',
|
'学生作业列表已按班级收敛',
|
||||||
homeworkClassIds.size <= 1 && (homeworkClassIds.size === 0 || homeworkClassIds.has(studentUser?.classId)),
|
homeworkClassIds.size <= 1 && (homeworkClassIds.size === 0 || homeworkClassIds.has(classId)),
|
||||||
`涉及 ${homeworkClassIds.size} 个班级`,
|
`涉及 ${homeworkClassIds.size} 个班级`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1750,7 +1790,7 @@ async function main() {
|
|||||||
const classGradesWrite = await api('/grades', {
|
const classGradesWrite = await api('/grades', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: classToken,
|
token: classToken,
|
||||||
body: { classId, userId: studentUser?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
|
body: { classId, userId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'班级账号录入成绩被拒绝(403)',
|
'班级账号录入成绩被拒绝(403)',
|
||||||
@@ -1843,7 +1883,7 @@ async function main() {
|
|||||||
const classNameGrade = await api('/grades', {
|
const classNameGrade = await api('/grades', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: adminToken,
|
||||||
body: { classId, userId: studentUser?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
|
body: { classId, userId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
|
||||||
});
|
});
|
||||||
const classMyGrades = await api('/grades/my', { token: classToken });
|
const classMyGrades = await api('/grades/my', { token: classToken });
|
||||||
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId));
|
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId));
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export interface ClassLike {
|
|||||||
name: string;
|
name: string;
|
||||||
grade: string;
|
grade: string;
|
||||||
teacherId: string;
|
teacherId: string;
|
||||||
|
/** 本学期教学周数(班主任可调,默认 20) */
|
||||||
|
termWeeks?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
teacher?: TeacherBriefLike | null;
|
teacher?: TeacherBriefLike | null;
|
||||||
_count?: {
|
_count?: {
|
||||||
@@ -110,6 +112,7 @@ export function toClassDto(item: ClassLike): ClassDto {
|
|||||||
name: item.name,
|
name: item.name,
|
||||||
grade: item.grade,
|
grade: item.grade,
|
||||||
teacherId: item.teacherId,
|
teacherId: item.teacherId,
|
||||||
|
termWeeks: item.termWeeks ?? 20,
|
||||||
createdAt: toIso(item.createdAt),
|
createdAt: toIso(item.createdAt),
|
||||||
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
||||||
studentCount: item._count?.students ?? undefined,
|
studentCount: item._count?.students ?? undefined,
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { hashPassword, verifyPassword } from '../../lib/password.js';
|
|||||||
import { isClassSession } from '../../lib/session.js';
|
import { isClassSession } from '../../lib/session.js';
|
||||||
import type { ChangePasswordInput, ClassLoginInput, LoginInput } from './auth.schemas.js';
|
import type { ChangePasswordInput, ClassLoginInput, LoginInput } from './auth.schemas.js';
|
||||||
|
|
||||||
/** 登录:校验用户名密码并签发 JWT(教师/管理员/个人学生账号) */
|
/**
|
||||||
|
* 登录:校验用户名密码并签发 JWT。
|
||||||
|
*
|
||||||
|
* 学生端主体是**班级**(班级码 + 班级密码):个人学生账号(student01…)**不再允许登录**,
|
||||||
|
* 它们的用户名只是"作业完成 / 通知已读 / 成绩"等个人数据的记录键(由班级会话代全班读写)。
|
||||||
|
* 教师与管理员仍用用户名登录。
|
||||||
|
*/
|
||||||
export async function login(input: LoginInput): Promise<LoginResponse> {
|
export async function login(input: LoginInput): Promise<LoginResponse> {
|
||||||
const user = await prisma.user.findUnique({ where: { username: input.username } });
|
const user = await prisma.user.findUnique({ where: { username: input.username } });
|
||||||
if (!user) throw ApiError.unauthorized('用户名或密码错误');
|
if (!user) throw ApiError.unauthorized('用户名或密码错误');
|
||||||
@@ -16,6 +22,11 @@ export async function login(input: LoginInput): Promise<LoginResponse> {
|
|||||||
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
|
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
|
||||||
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
|
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
|
||||||
|
|
||||||
|
// 个人学生账号已停用登录入口(需求:以班级为单位,删掉学生用自己用户名登录)
|
||||||
|
if (user.role === 'STUDENT') {
|
||||||
|
throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)');
|
||||||
|
}
|
||||||
|
|
||||||
const payload: TokenPayload = {
|
const payload: TokenPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export const createClassSchema = z.object({
|
|||||||
export const updateClassSchema = z.object({
|
export const updateClassSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(64).optional(),
|
name: z.string().trim().min(1).max(64).optional(),
|
||||||
grade: z.string().trim().min(1).max(32).optional(),
|
grade: z.string().trim().min(1).max(32).optional(),
|
||||||
|
/** 本学期教学周数(1~40,班主任可调) */
|
||||||
|
termWeeks: z.coerce.number().int().min(1).max(40).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||||
|
|||||||
@@ -174,7 +174,12 @@ export async function updateClass(
|
|||||||
|
|
||||||
const updated = await prisma.class.update({
|
const updated = await prisma.class.update({
|
||||||
where: { id: classId },
|
where: { id: classId },
|
||||||
data: { ...(input.name ? { name: input.name } : {}), ...(input.grade ? { grade: input.grade } : {}) },
|
data: {
|
||||||
|
...(input.name ? { name: input.name } : {}),
|
||||||
|
...(input.grade ? { grade: input.grade } : {}),
|
||||||
|
// 教学周数(班主任可调):课表周次选择与默认 weekEnd 都用它
|
||||||
|
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
teacher: { select: { id: true, name: true, username: true } },
|
teacher: { select: { id: true, name: true, username: true } },
|
||||||
_count: { select: countSelect },
|
_count: { select: countSelect },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendOk } from '../../lib/http.js';
|
import { sendOk } from '../../lib/http.js';
|
||||||
import { resolveCurrentWeek, MAX_TERM_WEEK } from '../../lib/term.js';
|
import { resolveCurrentWeek } from '../../lib/term.js';
|
||||||
import { authenticate, getAuthUser } from '../../middleware/auth.js';
|
import { authenticate, getAuthUser } from '../../middleware/auth.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import * as dashboardService from './dashboard.service.js';
|
import * as dashboardService from './dashboard.service.js';
|
||||||
@@ -14,9 +14,17 @@ router.get('/summary', async (req, res) => {
|
|||||||
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
|
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
|
||||||
});
|
});
|
||||||
|
|
||||||
/** GET /api/dashboard/term - 学期信息(当前教学周) */
|
/**
|
||||||
router.get('/term', (_req, res) => {
|
* GET /api/dashboard/term?classId= - 学期信息(当前教学周 + 本班教学周数)
|
||||||
sendOk(res, { currentWeek: resolveCurrentWeek(), maxWeek: MAX_TERM_WEEK }, '获取学期信息成功');
|
*
|
||||||
|
* `maxWeek` 取**班级的教学周数**(`Class.termWeeks`,班主任可在班级设置里调,默认 20),
|
||||||
|
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
|
||||||
|
*/
|
||||||
|
router.get('/term', async (req, res) => {
|
||||||
|
const user = getAuthUser(req);
|
||||||
|
const classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined;
|
||||||
|
const maxWeek = await dashboardService.resolveTermWeeks(user, classId);
|
||||||
|
sendOk(res, { currentWeek: resolveCurrentWeek(), maxWeek }, '获取学期信息成功');
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dashboardModule = defineModule({
|
export const dashboardModule = defineModule({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
requireStudentClassId,
|
requireStudentClassId,
|
||||||
} from '../../lib/access.js';
|
} from '../../lib/access.js';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
|
import { MAX_TERM_WEEK } from '../../lib/term.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js';
|
import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js';
|
||||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||||
@@ -18,6 +19,21 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
|
|||||||
* 仪表盘汇总。
|
* 仪表盘汇总。
|
||||||
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
|
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 本班教学周数(班主任可调,默认 20)。
|
||||||
|
* - 学生 / 班级会话:固定为自己所在班级;
|
||||||
|
* - 教师 / 管理员:优先用显式 classId(越权查不到时回退默认值);
|
||||||
|
* - 都不传:全局默认(MAX_TERM_WEEK 兜底)。
|
||||||
|
*/
|
||||||
|
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
|
||||||
|
const targetClassId = classId ?? (isStudent(user) ? requireStudentClassId(user) : undefined);
|
||||||
|
if (!targetClassId) return MAX_TERM_WEEK;
|
||||||
|
const record = await prisma.class.findUnique({
|
||||||
|
where: { id: targetClassId },
|
||||||
|
select: { termWeeks: true },
|
||||||
|
});
|
||||||
|
return record?.termWeeks && record.termWeeks > 0 ? record.termWeeks : MAX_TERM_WEEK;
|
||||||
|
}
|
||||||
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
|
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
|
||||||
const student = isStudent(user);
|
const student = isStudent(user);
|
||||||
// 学生强制限定在自己班级,避免越权统计
|
// 学生强制限定在自己班级,避免越权统计
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ export async function getClassStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
|
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
|
||||||
|
// 教学周数由班级设置决定(班主任可调,默认 20 周)
|
||||||
|
const classRecord = await prisma.class.findUnique({
|
||||||
|
where: { id: input.classId },
|
||||||
|
select: { termWeeks: true },
|
||||||
|
});
|
||||||
|
const termWeeks = classRecord?.termWeeks ?? 20;
|
||||||
await assertCanManageSchedule(user, input.classId);
|
await assertCanManageSchedule(user, input.classId);
|
||||||
await assertCourseInClass(input.courseId, input.classId);
|
await assertCourseInClass(input.courseId, input.classId);
|
||||||
|
|
||||||
@@ -117,7 +123,7 @@ export async function createSchedule(user: TokenPayload, input: CreateScheduleIn
|
|||||||
endTime: input.endTime,
|
endTime: input.endTime,
|
||||||
location: input.location ?? null,
|
location: input.location ?? null,
|
||||||
weekStart: input.weekStart ?? 1,
|
weekStart: input.weekStart ?? 1,
|
||||||
weekEnd: input.weekEnd ?? 20,
|
weekEnd: input.weekEnd ?? termWeeks,
|
||||||
// 单双周:默认 ALL(每周),与旧数据行为一致
|
// 单双周:默认 ALL(每周),与旧数据行为一致
|
||||||
weekParity: input.weekParity ?? 'ALL',
|
weekParity: input.weekParity ?? 'ALL',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,6 +66,29 @@ 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];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 常用科目目录:作业/课表里"新建课程"时可直接选(也用于种子数据与导入时的科目建议)。
|
||||||
|
* 顺序按中学常见排课习惯,前 9 个是文化课,后面是技术与体艺类。
|
||||||
|
*/
|
||||||
|
export const SUBJECT_CATALOG: readonly string[] = [
|
||||||
|
'语文',
|
||||||
|
'数学',
|
||||||
|
'英语',
|
||||||
|
'物理',
|
||||||
|
'化学',
|
||||||
|
'生物',
|
||||||
|
'政治',
|
||||||
|
'历史',
|
||||||
|
'地理',
|
||||||
|
'信息技术',
|
||||||
|
'通用技术',
|
||||||
|
'体育',
|
||||||
|
'音乐',
|
||||||
|
'美术',
|
||||||
|
'心理健康',
|
||||||
|
'劳动',
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单双周标签与取值(课表用)。
|
* 单双周标签与取值(课表用)。
|
||||||
* 语义与 ClassIsland 课表一致:WeekCountDivTotal=1 → 每周;=2 时 WeekCountDiv=1 → 单周、=2 → 双周。
|
* 语义与 ClassIsland 课表一致:WeekCountDivTotal=1 → 每周;=2 时 WeekCountDiv=1 → 单周、=2 → 双周。
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export interface ClassDto {
|
|||||||
code?: string;
|
code?: string;
|
||||||
/** 是否已设置班级密码(哈希永不外泄) */
|
/** 是否已设置班级密码(哈希永不外泄) */
|
||||||
hasPassword?: boolean;
|
hasPassword?: boolean;
|
||||||
|
/** 本学期教学周数(班主任可调,默认 20):课表周次选择与默认 weekEnd 都用它 */
|
||||||
|
termWeeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClassTeacherBrief {
|
export interface ClassTeacherBrief {
|
||||||
@@ -128,6 +130,8 @@ export interface CreateClassRequest {
|
|||||||
export interface UpdateClassRequest {
|
export interface UpdateClassRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
grade?: string;
|
grade?: string;
|
||||||
|
/** 本学期教学周数(1~40,班主任可调) */
|
||||||
|
termWeeks?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 管理员设置/重置班级账号(班级码 + 班级密码) */
|
/** 管理员设置/重置班级账号(班级码 + 班级密码) */
|
||||||
|
|||||||
@@ -192,7 +192,12 @@ export const studentApi = {
|
|||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
|
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
|
||||||
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
|
/**
|
||||||
|
* 学期信息:`maxWeek` 是该班的教学周数(`Class.termWeeks`,班主任可调,默认 20),
|
||||||
|
* 不带 classId 时后端回退全局默认值。
|
||||||
|
*/
|
||||||
|
term: (classId?: string): Promise<{ currentWeek: number; maxWeek: number }> =>
|
||||||
|
api.get('/dashboard/term', classId ? { classId } : undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 协作教师 */
|
/* ------------------------------------------------------------------ 协作教师 */
|
||||||
|
|||||||
@@ -281,8 +281,13 @@ function parityLabel(item: ClassPlanEntryDto): string {
|
|||||||
<template #default="{ row }">{{ row.startTime }}-{{ row.endTime }}</template>
|
<template #default="{ row }">{{ row.startTime }}-{{ row.endTime }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="subject" label="科目" min-width="110" />
|
<el-table-column prop="subject" label="科目" min-width="110" />
|
||||||
<el-table-column label="单双周" width="90">
|
<el-table-column label="单双周" width="100">
|
||||||
<template #default="{ row }">{{ parityLabel(row) }}</template>
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.weekParity !== 'ALL'" size="small" effect="plain">
|
||||||
|
{{ parityLabel(row) }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else class="text-muted">{{ parityLabel(row) }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="planName" label="来源" min-width="120" />
|
<el-table-column prop="planName" label="来源" min-width="120" />
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ const connectionType = computed(() =>
|
|||||||
realtime.connected ? 'success' : realtime.connecting ? 'warning' : 'danger',
|
realtime.connected ? 'success' : realtime.connecting ? 'warning' : 'danger',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小屏顶栏的实时通道提示:
|
||||||
|
* 连接正常时 **什么都不显示**(旧版只显示一个绿色圆点,看起来像坏掉的元素);
|
||||||
|
* 只有"连接中 / 已断开"这种异常态才显示带文字的紧凑标签。
|
||||||
|
*/
|
||||||
|
const mobileConnectionText = computed(() => (realtime.connecting ? '重连中…' : '已断开'));
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
@@ -167,15 +174,15 @@ async function submitPassword(): Promise<void> {
|
|||||||
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light">
|
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light">
|
||||||
<span class="header-conn">{{ connectionText }}</span>
|
<span class="header-conn">{{ connectionText }}</span>
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
<!-- 小屏:连接正常时不显示任何圆点/标签,只在异常态显示带文字的紧凑标签 -->
|
||||||
<el-tag
|
<el-tag
|
||||||
v-else
|
v-else-if="!realtime.connected"
|
||||||
:type="connectionType"
|
:type="connectionType"
|
||||||
size="small"
|
size="small"
|
||||||
effect="light"
|
effect="light"
|
||||||
class="header-conn-dot"
|
class="header-conn-mobile"
|
||||||
:title="connectionText"
|
|
||||||
>
|
>
|
||||||
<span class="conn-dot" :class="`conn-${connectionType}`"></span>
|
{{ mobileConnectionText }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-tag v-if="realtime.eventCount > 0 && !isMobile" size="small" type="info" effect="plain">
|
<el-tag v-if="realtime.eventCount > 0 && !isMobile" size="small" type="info" effect="plain">
|
||||||
已接收 {{ realtime.eventCount }} 条实时事件
|
已接收 {{ realtime.eventCount }} 条实时事件
|
||||||
@@ -389,6 +396,12 @@ async function submitPassword(): Promise<void> {
|
|||||||
.header-title {
|
.header-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 异常态才出现的实时通道标签:紧凑、不挤占标题 */
|
||||||
|
.header-conn-mobile {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -416,6 +416,15 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 手机端按钮换行后各自独占整行:必须清掉 Element Plus 的 `.el-button + .el-button`
|
||||||
|
* 12px 左外边距,否则第二个按钮的"内容盒 + 12px"超出容器被 flex 压缩,
|
||||||
|
* 出现「宽度不一致、右边不齐」(通知页的「叫人 / 发布通知」就是这样)。
|
||||||
|
*/
|
||||||
|
.toolbar > .el-button + .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 统计卡片:小屏两列 */
|
/* 统计卡片:小屏两列 */
|
||||||
.stat-grid {
|
.stat-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async function loadClasses(): Promise<void> {
|
|||||||
const formVisible = ref(false);
|
const formVisible = ref(false);
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
const form = reactive({ name: '', grade: '', code: '' });
|
const form = reactive({ name: '', grade: '', code: '', termWeeks: 20 });
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
|
||||||
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
|
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
|
||||||
@@ -55,6 +55,7 @@ function openCreate(): void {
|
|||||||
form.name = '';
|
form.name = '';
|
||||||
form.grade = '';
|
form.grade = '';
|
||||||
form.code = '';
|
form.code = '';
|
||||||
|
form.termWeeks = 20;
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ function openEdit(row: ClassDto): void {
|
|||||||
form.name = row.name;
|
form.name = row.name;
|
||||||
form.grade = row.grade;
|
form.grade = row.grade;
|
||||||
form.code = '';
|
form.code = '';
|
||||||
|
form.termWeeks = row.termWeeks ?? 20;
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +73,11 @@ async function submitForm(): Promise<void> {
|
|||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await classApi.update(editingId.value, { name: form.name, grade: form.grade });
|
await classApi.update(editingId.value, {
|
||||||
|
name: form.name,
|
||||||
|
grade: form.grade,
|
||||||
|
termWeeks: form.termWeeks,
|
||||||
|
});
|
||||||
ElMessage.success('班级已更新');
|
ElMessage.success('班级已更新');
|
||||||
} else {
|
} else {
|
||||||
await classApi.create({
|
await classApi.create({
|
||||||
@@ -395,6 +401,12 @@ onUnmounted(() => {
|
|||||||
<el-form-item v-if="!editingId" label="班级码">
|
<el-form-item v-if="!editingId" label="班级码">
|
||||||
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
|
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="学期周数">
|
||||||
|
<el-input-number v-model="form.termWeeks" :min="1" :max="40" :step="1" />
|
||||||
|
<span class="text-muted ml-8">
|
||||||
|
本学期一共多少教学周(默认 20);课表周次选择与默认结束周都按它来
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
|||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
|
SUBJECT_CATALOG,
|
||||||
formatDate,
|
formatDate,
|
||||||
truncate,
|
truncate,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
@@ -136,6 +137,38 @@ const form = reactive({
|
|||||||
attachmentUrl: '',
|
attachmentUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布弹窗里的「其他科目(新建)」选项前缀。
|
||||||
|
* 下拉只有课程 id 一个 v-model,新建科目用 `subject:语文` 这种带前缀的伪值表达,
|
||||||
|
* 提交时再解析成「复用同名课程 / 新建课程」。
|
||||||
|
*/
|
||||||
|
const NEW_SUBJECT_PREFIX = 'subject:';
|
||||||
|
/** 弹窗里所选班级的课程(与页面筛选用的 courses 分开,避免互相污染) */
|
||||||
|
const formCourses = ref<CourseDto[]>([]);
|
||||||
|
/** 课程列表加载中:加载完成前不展示「其他科目(新建)」,避免把该班已有科目误当成新科目 */
|
||||||
|
const formCoursesLoading = ref(false);
|
||||||
|
|
||||||
|
/** 该班还没有的科目(来自 shared 的 SUBJECT_CATALOG) */
|
||||||
|
const newSubjectOptions = computed(() => {
|
||||||
|
const existing = new Set(formCourses.value.map((item) => item.name));
|
||||||
|
return SUBJECT_CATALOG.filter((name) => !existing.has(name));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFormCourses(): Promise<void> {
|
||||||
|
if (!form.classId) {
|
||||||
|
formCourses.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formCoursesLoading.value = true;
|
||||||
|
try {
|
||||||
|
formCourses.value = await courseApi.list(form.classId);
|
||||||
|
} catch {
|
||||||
|
formCourses.value = [];
|
||||||
|
} finally {
|
||||||
|
formCoursesLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
|
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
|
||||||
title: [{ required: true, message: '请输入作业标题', trigger: 'blur' }],
|
title: [{ required: true, message: '请输入作业标题', trigger: 'blur' }],
|
||||||
@@ -150,6 +183,7 @@ function openCreate(): void {
|
|||||||
form.content = '';
|
form.content = '';
|
||||||
form.attachmentUrl = '';
|
form.attachmentUrl = '';
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
|
void loadFormCourses();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: HomeworkDto): void {
|
function openEdit(row: HomeworkDto): void {
|
||||||
@@ -160,15 +194,62 @@ function openEdit(row: HomeworkDto): void {
|
|||||||
form.content = row.content;
|
form.content = row.content;
|
||||||
form.attachmentUrl = row.attachmentUrl ?? '';
|
form.attachmentUrl = row.attachmentUrl ?? '';
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
|
void loadFormCourses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹窗里改班级:课程下拉要跟着换成该班的课程 */
|
||||||
|
async function onFormClassChange(): Promise<void> {
|
||||||
|
form.courseId = '';
|
||||||
|
await loadFormCourses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析课程下拉的取值:
|
||||||
|
* - 已有课程 id → 直接用;
|
||||||
|
* - `subject:语文`(其他科目新建)→ 该班已有同名课程则复用,否则先 `courseApi.create` 建课再返回新 id;
|
||||||
|
* - 空 → 不关联课程。
|
||||||
|
*/
|
||||||
|
async function resolveCourseId(): Promise<string | null> {
|
||||||
|
const value = form.courseId;
|
||||||
|
if (!value) return null;
|
||||||
|
if (!value.startsWith(NEW_SUBJECT_PREFIX)) return value;
|
||||||
|
|
||||||
|
const name = value.slice(NEW_SUBJECT_PREFIX.length);
|
||||||
|
const reuse =
|
||||||
|
formCourses.value.find((item) => item.name === name) ??
|
||||||
|
(await courseApi.list(form.classId).catch(() => [])).find((item) => item.name === name);
|
||||||
|
if (reuse) return reuse.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await courseApi.create({ classId: form.classId, name });
|
||||||
|
formCourses.value = [...formCourses.value, created];
|
||||||
|
return created.id;
|
||||||
|
} catch (error) {
|
||||||
|
// 新建课程需要"管理员或本班班主任"权限(与课表管理一致),科任老师会被 403 拦截
|
||||||
|
ElMessage.error(
|
||||||
|
`新建科目「${name}」失败:${
|
||||||
|
error instanceof Error ? error.message : '仅班主任/管理员可以在班级下新建课程'
|
||||||
|
},可改用已有课程或直接留空发布`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitForm(): Promise<void> {
|
async function submitForm(): Promise<void> {
|
||||||
const valid = await formRef.value?.validate().catch(() => false);
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
|
let courseId: string | null;
|
||||||
|
try {
|
||||||
|
courseId = await resolveCourseId();
|
||||||
|
} catch {
|
||||||
|
// 建课失败的提示已在 resolveCourseId 里给出,这里保持弹窗打开让老师改选
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
classId: form.classId,
|
classId: form.classId,
|
||||||
courseId: form.courseId || null,
|
courseId,
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
content: form.content,
|
content: form.content,
|
||||||
attachmentUrl: form.attachmentUrl.trim() || null,
|
attachmentUrl: form.attachmentUrl.trim() || null,
|
||||||
@@ -281,14 +362,43 @@ onUnmounted(() => {
|
|||||||
<el-dialog v-model="formVisible" :title="editingId ? '编辑作业' : '发布作业'" width="560px">
|
<el-dialog v-model="formVisible" :title="editingId ? '编辑作业' : '发布作业'" width="560px">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
<el-form-item label="目标班级" prop="classId">
|
<el-form-item label="目标班级" prop="classId">
|
||||||
<el-select v-model="form.classId" style="width: 100%" :disabled="Boolean(editingId)">
|
<el-select
|
||||||
|
v-model="form.classId"
|
||||||
|
style="width: 100%"
|
||||||
|
:disabled="Boolean(editingId)"
|
||||||
|
@change="onFormClassChange"
|
||||||
|
>
|
||||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="所属课程">
|
<el-form-item label="所属课程">
|
||||||
<el-select v-model="form.courseId" placeholder="可选" clearable style="width: 100%">
|
<el-select
|
||||||
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
|
v-model="form.courseId"
|
||||||
|
placeholder="可选"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
:loading="formCoursesLoading"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option-group v-if="formCourses.length" label="已有课程">
|
||||||
|
<el-option v-for="item in formCourses" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-option-group>
|
||||||
|
<el-option-group v-if="!formCoursesLoading && newSubjectOptions.length" label="其他科目(新建)">
|
||||||
|
<el-option
|
||||||
|
v-for="name in newSubjectOptions"
|
||||||
|
:key="`new-${name}`"
|
||||||
|
:label="name"
|
||||||
|
:value="`${NEW_SUBJECT_PREFIX}${name}`"
|
||||||
|
/>
|
||||||
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<div class="form-hint">
|
||||||
|
{{
|
||||||
|
formCoursesLoading
|
||||||
|
? '正在加载该班课程…'
|
||||||
|
: '该班还没有的科目在「其他科目(新建)」里,选中后会自动建课再发布(需班主任/管理员)'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标题" prop="title">
|
<el-form-item label="标题" prop="title">
|
||||||
<el-input v-model="form.title" maxlength="120" show-word-limit />
|
<el-input v-model="form.title" maxlength="120" show-word-limit />
|
||||||
@@ -396,6 +506,14 @@ onUnmounted(() => {
|
|||||||
color: #f56c6c;
|
color: #f56c6c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.content-block {
|
.content-block {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
CALL_QUICK_PHRASES,
|
CALL_QUICK_PHRASES,
|
||||||
@@ -25,12 +25,39 @@ const realtime = useRealtimeStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const classes = ref<ClassDto[]>([]);
|
const classes = ref<ClassDto[]>([]);
|
||||||
const notifications = ref<NotificationDto[]>([]);
|
const notifications = ref<NotificationDto[]>([]);
|
||||||
const studentCount = ref(0);
|
|
||||||
const filter = reactive({ classId: '', priority: '', keyword: '' });
|
const filter = reactive({ classId: '', priority: '', keyword: '' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否"单班视图":选中了具体班级时列表只服务一个班。
|
||||||
|
* 单班视图只展示"已读情况"(已读 N 人 / 未读),跨班视图才展示已读率百分比。
|
||||||
|
*/
|
||||||
|
const isSingleClass = computed(() => Boolean(filter.classId));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各班学生数(classId → 人数)。
|
||||||
|
* NotificationDto 只有 readCount(已读人数),没有 unreadCount / reads,
|
||||||
|
* 已读率的分母只能自己算,所以进入页面时按班级各取一次学生名单长度。
|
||||||
|
*/
|
||||||
|
const classStudentCounts = ref<Record<string, number>>({});
|
||||||
|
|
||||||
|
function classSizeOf(classId: string): number {
|
||||||
|
return classStudentCounts.value[classId] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
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 ?? '';
|
||||||
|
await loadClassStudentCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClassStudentCounts(): Promise<void> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
classes.value.map(async (item) => {
|
||||||
|
const students = await classApi.students(item.id).catch(() => []);
|
||||||
|
return [item.id, students.length] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
classStudentCounts.value = Object.fromEntries(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNotifications(): Promise<void> {
|
async function loadNotifications(): Promise<void> {
|
||||||
@@ -41,11 +68,6 @@ async function loadNotifications(): Promise<void> {
|
|||||||
if (filter.priority) params.priority = filter.priority;
|
if (filter.priority) params.priority = filter.priority;
|
||||||
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
|
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
|
||||||
notifications.value = await notificationApi.list(params);
|
notifications.value = await notificationApi.list(params);
|
||||||
|
|
||||||
if (filter.classId) {
|
|
||||||
const students = await classApi.students(filter.classId);
|
|
||||||
studentCount.value = students.length;
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -121,89 +143,185 @@ async function submitCall(): Promise<void> {
|
|||||||
|
|
||||||
const formVisible = ref(false);
|
const formVisible = ref(false);
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const form = reactive<{ classId: string; title: string; content: string; priority: NotificationPriority }>({
|
const form = reactive<{
|
||||||
classId: '',
|
classIds: string[];
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
priority: NotificationPriority;
|
||||||
|
}>({
|
||||||
|
classIds: [],
|
||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
priority: 'NORMAL',
|
priority: 'NORMAL',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
|
classIds: [{ required: true, type: 'array', min: 1, message: '请选择目标班级', trigger: 'change' }],
|
||||||
title: [{ required: true, message: '请输入通知标题', trigger: 'blur' }],
|
title: [{ required: true, message: '请输入通知标题', trigger: 'blur' }],
|
||||||
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
|
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 上课时段发布紧急通知的全屏二次确认状态 */
|
/** 上课时段发布紧急通知的全屏二次确认状态(classIds = 本次要发布的班级) */
|
||||||
const urgentWarning = reactive({
|
const urgentWarning = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
period: null as ClassPeriod | null,
|
period: null as ClassPeriod | null,
|
||||||
title: '',
|
title: '',
|
||||||
|
classIds: [] as string[],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function classNameOf(classId: string): string {
|
||||||
|
return classes.value.find((item) => item.id === classId)?.name ?? classId;
|
||||||
|
}
|
||||||
|
|
||||||
async function openCreate(): Promise<void> {
|
async function openCreate(): Promise<void> {
|
||||||
// 班级列表可能还没加载完(进入页面后立刻点按钮):先补一次,避免「目标班级」为空导致校验失败
|
// 班级列表可能还没加载完(进入页面后立刻点按钮):先补一次,避免「目标班级」为空导致校验失败
|
||||||
if (classes.value.length === 0) await loadClasses().catch(() => undefined);
|
if (classes.value.length === 0) await loadClasses().catch(() => undefined);
|
||||||
form.classId = filter.classId || classes.value[0]?.id || '';
|
form.classIds = filter.classId ? [filter.classId] : classes.value[0] ? [classes.value[0].id] : [];
|
||||||
form.title = '';
|
form.title = '';
|
||||||
form.content = '';
|
form.content = '';
|
||||||
form.priority = 'NORMAL';
|
form.priority = 'NORMAL';
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上课时段检查:多班发布时逐班查询(GET /api/schedules/current?classId=...),
|
||||||
|
* 任一班级正在上课就需要二次确认;返回命中的班级与时段。
|
||||||
|
*/
|
||||||
|
async function findClassInSession(
|
||||||
|
classIds: string[],
|
||||||
|
): Promise<{ classId: string; period: ClassPeriod | null } | null> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
classIds.map(async (classId) => {
|
||||||
|
const status = await scheduleApi.classStatus(classId).catch(() => null);
|
||||||
|
return status?.inClass ? { classId, period: status.current } : null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results.find((item) => item !== null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function submitForm(): Promise<void> {
|
async function submitForm(): Promise<void> {
|
||||||
const valid = await formRef.value?.validate().catch(() => false);
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
// 上课时段发布紧急通知:先查询班级上课状态,命中则弹全屏二次确认(3 秒倒计时)
|
const classIds = [...form.classIds];
|
||||||
|
if (classIds.length === 0) {
|
||||||
|
ElMessage.warning('请至少选择一个目标班级');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上课时段发布紧急通知:先查询所有目标班级的上课状态,任一命中则弹全屏二次确认(3 秒倒计时)
|
||||||
if (form.priority === 'URGENT') {
|
if (form.priority === 'URGENT') {
|
||||||
const status = await scheduleApi.classStatus(form.classId).catch(() => null);
|
const hit = await findClassInSession(classIds);
|
||||||
if (status?.inClass) {
|
if (hit) {
|
||||||
urgentWarning.period = status.current;
|
urgentWarning.period = hit.period;
|
||||||
urgentWarning.title = form.title.trim();
|
urgentWarning.title = form.title.trim();
|
||||||
|
urgentWarning.classIds = classIds;
|
||||||
urgentWarning.visible = true;
|
urgentWarning.visible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await doPublish(false);
|
await doPublish(classIds, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 真正提交;confirmDuringClass 为 true 表示教师已在上课警告中确认 */
|
/** 单个班级的发布失败信息(用于汇总提示) */
|
||||||
async function doPublish(confirmDuringClass: boolean): Promise<void> {
|
interface PublishFailure {
|
||||||
try {
|
classId: string;
|
||||||
const created = await notificationApi.create({
|
reason: string;
|
||||||
classId: form.classId,
|
/** 服务端因"上课时段紧急通知"拦截(409 URGENT_DURING_CLASS) */
|
||||||
|
urgentConflict: boolean;
|
||||||
|
period: ClassPeriod | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真正提交:对每个班级各调一次 `POST /api/notifications`(Promise.allSettled),
|
||||||
|
* confirmDuringClass 为 true 表示教师已在上课警告中确认。
|
||||||
|
*/
|
||||||
|
async function doPublish(classIds: string[], confirmDuringClass: boolean): Promise<void> {
|
||||||
|
if (classIds.length === 0) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
content: form.content,
|
content: form.content,
|
||||||
priority: form.priority,
|
priority: form.priority,
|
||||||
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
|
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
|
||||||
});
|
};
|
||||||
ElMessage.success(`通知已发布(${created.title}),已推送给学生客户端`);
|
|
||||||
formVisible.value = false;
|
const settled = await Promise.allSettled(
|
||||||
await loadNotifications();
|
classIds.map((classId) => notificationApi.create({ ...payload, classId })),
|
||||||
} catch (error) {
|
);
|
||||||
// 兜底:并发场景下服务端仍可能拦截(例如刚好上课铃响),此时同样弹出警告
|
|
||||||
|
let successCount = 0;
|
||||||
|
const failures: PublishFailure[] = [];
|
||||||
|
|
||||||
|
settled.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
successCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const response = (
|
const response = (
|
||||||
error as {
|
result.reason as {
|
||||||
response?: { status?: number; data?: { code?: string; details?: { current?: ClassPeriod | null } } };
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
data?: { code?: string; message?: string; details?: { current?: ClassPeriod | null } };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
).response;
|
).response;
|
||||||
if (response?.status === 409 && response.data?.code === 'URGENT_DURING_CLASS') {
|
failures.push({
|
||||||
urgentWarning.period = response.data.details?.current ?? null;
|
classId: classIds[index] ?? '',
|
||||||
|
reason:
|
||||||
|
response?.data?.message ?? (result.reason instanceof Error ? result.reason.message : '发布失败'),
|
||||||
|
urgentConflict: response?.status === 409 && response?.data?.code === 'URGENT_DURING_CLASS',
|
||||||
|
period: response?.data?.details?.current ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 兜底:并发场景下服务端仍可能拦截(例如刚好上课铃响),对这批班级重新走一次二次确认
|
||||||
|
const conflicted = failures.filter((item) => item.urgentConflict);
|
||||||
|
const pendingConfirm = !confirmDuringClass && conflicted.length > 0;
|
||||||
|
if (pendingConfirm) {
|
||||||
|
urgentWarning.period = conflicted[0]?.period ?? null;
|
||||||
urgentWarning.title = form.title.trim();
|
urgentWarning.title = form.title.trim();
|
||||||
|
urgentWarning.classIds = conflicted.map((item) => item.classId);
|
||||||
urgentWarning.visible = true;
|
urgentWarning.visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reported = confirmDuringClass ? failures : failures.filter((item) => !item.urgentConflict);
|
||||||
|
reportPublishResult(successCount, reported);
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
// 有班级发布成功:关掉弹窗并刷新列表(仍需二次确认时保留弹窗内的数据)
|
||||||
|
formVisible.value = !pendingConfirm;
|
||||||
|
await loadNotifications();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多班发布结果汇总:成功 N 个班、失败 M 个班(失败列出班级名 + 原因) */
|
||||||
|
function reportPublishResult(successCount: number, failures: PublishFailure[]): void {
|
||||||
|
if (successCount === 0 && failures.length === 0) return;
|
||||||
|
if (failures.length === 0) {
|
||||||
|
ElMessage.success(`通知已发布到 ${successCount} 个班级,已推送给学生客户端`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail = failures.map((item) => `${classNameOf(item.classId)}(${item.reason})`).join(';');
|
||||||
|
ElMessage({
|
||||||
|
type: successCount > 0 ? 'warning' : 'error',
|
||||||
|
message: `成功 ${successCount} 个班,失败 ${failures.length} 个班|${detail}`,
|
||||||
|
duration: 6000,
|
||||||
|
showClose: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onUrgentConfirmed(): void {
|
function onUrgentConfirmed(): void {
|
||||||
|
const classIds = [...urgentWarning.classIds];
|
||||||
urgentWarning.visible = false;
|
urgentWarning.visible = false;
|
||||||
void doPublish(true);
|
urgentWarning.classIds = [];
|
||||||
|
void doPublish(classIds, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUrgentCancelled(): void {
|
function onUrgentCancelled(): void {
|
||||||
urgentWarning.visible = false;
|
urgentWarning.visible = false;
|
||||||
|
urgentWarning.classIds = [];
|
||||||
ElMessage.info('已取消发布,紧急通知未发出');
|
ElMessage.info('已取消发布,紧急通知未发出');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,9 +332,11 @@ async function removeNotification(row: NotificationDto): Promise<void> {
|
|||||||
await loadNotifications();
|
await loadNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已读率(跨班视图使用):分母按该通知所属班级的学生数算 */
|
||||||
function readRate(row: NotificationDto): number {
|
function readRate(row: NotificationDto): number {
|
||||||
if (!studentCount.value) return 0;
|
const total = classSizeOf(row.classId);
|
||||||
return Math.round(((row.readCount ?? 0) / studentCount.value) * 100);
|
if (!total) return 0;
|
||||||
|
return Math.round(((row.readCount ?? 0) / total) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNotificationEvent(): void {
|
function onNotificationEvent(): void {
|
||||||
@@ -245,6 +365,7 @@ onUnmounted(() => {
|
|||||||
<el-select
|
<el-select
|
||||||
v-model="filter.classId"
|
v-model="filter.classId"
|
||||||
placeholder="选择班级"
|
placeholder="选择班级"
|
||||||
|
clearable
|
||||||
style="width: 170px"
|
style="width: 170px"
|
||||||
@change="loadNotifications"
|
@change="loadNotifications"
|
||||||
>
|
>
|
||||||
@@ -303,10 +424,24 @@ onUnmounted(() => {
|
|||||||
<div class="text-muted">{{ relativeTime(row.createdAt) }}</div>
|
<div class="text-muted">{{ relativeTime(row.createdAt) }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="已读率" width="150">
|
<el-table-column :label="isSingleClass ? '已读情况' : '已读率'" width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<!--
|
||||||
|
单班视图:只显示该通知的已读情况(不显示百分比)。
|
||||||
|
NotificationDto 只有 readCount(已读人数),没有 unreadCount / reads 明细,
|
||||||
|
所以这里用「已读 N 人 / 未读」标签 + 班级总人数表达已读情况。
|
||||||
|
-->
|
||||||
|
<template v-if="isSingleClass">
|
||||||
|
<el-tag :type="(row.readCount ?? 0) > 0 ? 'success' : 'info'" size="small" effect="light">
|
||||||
|
{{ (row.readCount ?? 0) > 0 ? `已读 ${row.readCount} 人` : '未读' }}
|
||||||
|
</el-tag>
|
||||||
|
<div class="text-muted">共 {{ classSizeOf(row.classId) }} 人</div>
|
||||||
|
</template>
|
||||||
|
<!-- 跨班视图(未选具体班级):显示已读率百分比,分母按该通知所属班级算 -->
|
||||||
|
<template v-else>
|
||||||
<el-progress :percentage="readRate(row)" :stroke-width="10" />
|
<el-progress :percentage="readRate(row)" :stroke-width="10" />
|
||||||
<span class="text-muted">{{ row.readCount ?? 0 }}/{{ studentCount }} 人</span>
|
<span class="text-muted">{{ row.readCount ?? 0 }}/{{ classSizeOf(row.classId) }} 人</span>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90" fixed="right">
|
<el-table-column label="操作" width="90" fixed="right">
|
||||||
@@ -319,8 +454,16 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<el-dialog v-model="formVisible" title="发布通知" width="560px">
|
<el-dialog v-model="formVisible" title="发布通知" width="560px">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
<el-form-item label="目标班级" prop="classId">
|
<el-form-item label="目标班级" prop="classIds">
|
||||||
<el-select v-model="form.classId" style="width: 100%">
|
<el-select
|
||||||
|
v-model="form.classIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="可多选;发布时逐班提交"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} 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 ClassPlanImportDialog from '@/components/ClassPlanImportDialog.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';
|
||||||
|
|
||||||
@@ -73,13 +72,27 @@ function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): Schedul
|
|||||||
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
|
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学期信息(班级维度):`maxWeek` = 该班教学周数(`Class.termWeeks`,默认 20)。
|
||||||
|
* 切换班级时要重新取一次,并按新上限收敛周次下拉与当前选中周,
|
||||||
|
* 避免出现"实际 20 周却给到 30 周"或选中一个不存在的周。
|
||||||
|
*/
|
||||||
|
async function loadTerm(): Promise<void> {
|
||||||
|
const term = await dashboardApi.term(query.classId || undefined).catch(() => null);
|
||||||
|
if (!term) return;
|
||||||
|
weekOptions.value = buildWeekOptions(term.maxWeek);
|
||||||
|
const maxWeek = weekOptions.value.at(-1) ?? term.maxWeek;
|
||||||
|
if (query.week > maxWeek) query.week = maxWeek;
|
||||||
|
if (query.week < 1) query.week = 1;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBase(): Promise<void> {
|
async function loadBase(): Promise<void> {
|
||||||
const [classList, term] = await Promise.all([classApi.list(), dashboardApi.term()]);
|
const [classList, term] = await Promise.all([classApi.list(), dashboardApi.term()]);
|
||||||
classes.value = classList;
|
classes.value = classList;
|
||||||
weekOptions.value = buildWeekOptions(term.maxWeek);
|
weekOptions.value = buildWeekOptions(term.maxWeek);
|
||||||
query.week = term.currentWeek;
|
query.week = Math.min(Math.max(term.currentWeek, 1), weekOptions.value.at(-1) ?? term.maxWeek);
|
||||||
if (!query.classId && classList.length > 0) query.classId = classList[0]?.id ?? '';
|
if (!query.classId && classList.length > 0) query.classId = classList[0]?.id ?? '';
|
||||||
if (query.classId) await loadCourses();
|
if (query.classId) await Promise.all([loadCourses(), loadTerm()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCourses(): Promise<void> {
|
async function loadCourses(): Promise<void> {
|
||||||
@@ -106,6 +119,7 @@ async function loadSchedules(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onClassChange(): Promise<void> {
|
async function onClassChange(): Promise<void> {
|
||||||
|
await loadTerm();
|
||||||
await loadCourses();
|
await loadCourses();
|
||||||
await loadSchedules();
|
await loadSchedules();
|
||||||
}
|
}
|
||||||
@@ -211,18 +225,6 @@ function onScheduleEvent(): void {
|
|||||||
void loadSchedules();
|
void loadSchedules();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ ClassIsland 时间配置导入 */
|
|
||||||
|
|
||||||
const timeLayoutVisible = ref(false);
|
|
||||||
|
|
||||||
function openTimeLayoutImport(): void {
|
|
||||||
if (!query.classId) {
|
|
||||||
ElMessage.warning('请先选择班级');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timeLayoutVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ ClassIsland 课程表导入(支持单双周) */
|
/* ------------------------------------------------------------ ClassIsland 课程表导入(支持单双周) */
|
||||||
|
|
||||||
const classPlanVisible = ref(false);
|
const classPlanVisible = ref(false);
|
||||||
@@ -276,9 +278,6 @@ onUnmounted(() => {
|
|||||||
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
|
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
|
||||||
新增课表
|
新增课表
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="canManageSchedule" type="warning" :icon="'Upload'" @click="openTimeLayoutImport">
|
|
||||||
导入时间配置
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canManageSchedule"
|
v-if="canManageSchedule"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -291,7 +290,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card v-loading="loading" shadow="never" class="table-card">
|
<el-card v-loading="loading" shadow="never" class="table-card timetable-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<span>
|
<span>
|
||||||
@@ -386,13 +385,6 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- ClassIsland 时间配置导入(覆盖 / 合并,失败不改动原配置) -->
|
|
||||||
<TimeLayoutImportDialog
|
|
||||||
v-model="timeLayoutVisible"
|
|
||||||
:class-id="query.classId"
|
|
||||||
:class-name="classes.find((item) => item.id === query.classId)?.name"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- ClassIsland 课程表导入(支持单双周;导入成功后刷新周视图与列表) -->
|
<!-- ClassIsland 课程表导入(支持单双周;导入成功后刷新周视图与列表) -->
|
||||||
<ClassPlanImportDialog
|
<ClassPlanImportDialog
|
||||||
v-model="classPlanVisible"
|
v-model="classPlanVisible"
|
||||||
@@ -402,3 +394,17 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/*
|
||||||
|
* 手机端周视图:7 天列各 130px + 节次列 120px ≈ 1030px,
|
||||||
|
* 直接把表格最小宽度撑到 1040px,让单元格保持可读宽度;
|
||||||
|
* 外层 el-card__body(全局 .table-card 规则)已是 overflow-x:auto,
|
||||||
|
* 因此手指左右拖动能在卡片内看全周一~周日,页面本身不横向滚动。
|
||||||
|
*/
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timetable-card .el-table {
|
||||||
|
min-width: 1040px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -47,18 +47,14 @@ function openImport(): void {
|
|||||||
const formVisible = ref(false);
|
const formVisible = ref(false);
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
const form = reactive({ username: '', name: '', password: '', classId: '' });
|
/** 表单不再暴露「用户名」:新建时由前端自动生成学号式登录名(见 autoCreateStudent) */
|
||||||
|
const form = reactive({ name: '', password: '', classId: '' });
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
username: [
|
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
|
||||||
{ min: 3, message: '用户名至少 3 位', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
form.username = '';
|
|
||||||
form.name = '';
|
form.name = '';
|
||||||
form.password = '';
|
form.password = '';
|
||||||
form.classId = filter.classId;
|
form.classId = filter.classId;
|
||||||
@@ -67,32 +63,74 @@ function openCreate(): void {
|
|||||||
|
|
||||||
function openEdit(row: StudentDto): void {
|
function openEdit(row: StudentDto): void {
|
||||||
editingId.value = row.id;
|
editingId.value = row.id;
|
||||||
form.username = row.username;
|
|
||||||
form.name = row.name;
|
form.name = row.name;
|
||||||
form.password = '';
|
form.password = '';
|
||||||
form.classId = row.classId ?? '';
|
form.classId = row.classId ?? '';
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动生成学生登录名(学号式:student01、student02 …,与种子数据 student01~student15 同风格)。
|
||||||
|
*
|
||||||
|
* 后端 `POST /api/students` 的 username 是必填且全局唯一的(教师/管理员账号同样占位),
|
||||||
|
* 界面已按需求隐藏「用户名」输入,因此这里用「全量学生账号 + 递增序号」生成,
|
||||||
|
* 并在服务端返回 409(唯一约束冲突,例如并发创建或撞上教师账号)时换下一个序号重试;
|
||||||
|
* 其它错误(姓名/密码不合法等)立即抛出,不再重试,避免刷屏。
|
||||||
|
*/
|
||||||
|
function studentUsernameAt(index: number): string {
|
||||||
|
return index < 100 ? `student${String(index).padStart(2, '0')}` : `student${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoCreateStudent(): Promise<void> {
|
||||||
|
const existing = new Set((await studentApi.list()).map((item) => item.username));
|
||||||
|
let index = existing.size + 1;
|
||||||
|
let lastError: unknown = new Error('自动生成学生账号失败');
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
let username = studentUsernameAt(index);
|
||||||
|
while (existing.has(username)) {
|
||||||
|
index += 1;
|
||||||
|
username = studentUsernameAt(index);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await studentApi.create({
|
||||||
|
username,
|
||||||
|
name: form.name.trim(),
|
||||||
|
classId: form.classId || null,
|
||||||
|
...(form.password ? { password: form.password } : {}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as { response?: { status?: number } }).response?.status;
|
||||||
|
if (status !== 409) throw error;
|
||||||
|
lastError = error;
|
||||||
|
existing.add(username);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
async function submitForm(): Promise<void> {
|
async function submitForm(): Promise<void> {
|
||||||
const valid = await formRef.value?.validate().catch(() => false);
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
|
// 用户名不再由界面维护:编辑只改姓名与班级
|
||||||
await studentApi.update(editingId.value, {
|
await studentApi.update(editingId.value, {
|
||||||
username: form.username.trim(),
|
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
classId: form.classId || null,
|
classId: form.classId || null,
|
||||||
});
|
});
|
||||||
ElMessage.success('学生信息已更新');
|
ElMessage.success('学生信息已更新');
|
||||||
} else {
|
} else {
|
||||||
await studentApi.create({
|
try {
|
||||||
username: form.username.trim(),
|
await autoCreateStudent();
|
||||||
name: form.name.trim(),
|
ElMessage.success('学生账号创建成功(登录名已自动生成)');
|
||||||
classId: form.classId || null,
|
} catch {
|
||||||
...(form.password ? { password: form.password } : {}),
|
// 接口层已弹出服务端原因,这里补充可执行的兜底建议
|
||||||
});
|
ElMessage.error('创建失败:可稍后重试,或用「导入名单」在表格里显式指定用户名');
|
||||||
ElMessage.success('学生账号创建成功');
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
formVisible.value = false;
|
formVisible.value = false;
|
||||||
await loadStudents();
|
await loadStudents();
|
||||||
@@ -185,7 +223,7 @@ async function submitCall(): Promise<void> {
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filter.keyword"
|
v-model="filter.keyword"
|
||||||
placeholder="姓名 / 用户名"
|
placeholder="姓名"
|
||||||
clearable
|
clearable
|
||||||
style="width: 180px"
|
style="width: 180px"
|
||||||
@keyup.enter="loadStudents"
|
@keyup.enter="loadStudents"
|
||||||
@@ -199,7 +237,6 @@ async function submitCall(): Promise<void> {
|
|||||||
|
|
||||||
<el-card shadow="never" class="table-card">
|
<el-card shadow="never" class="table-card">
|
||||||
<el-table v-loading="loading" :data="students" empty-text="暂无学生数据">
|
<el-table v-loading="loading" :data="students" empty-text="暂无学生数据">
|
||||||
<el-table-column prop="username" label="用户名" width="140" />
|
|
||||||
<el-table-column prop="name" label="姓名" width="120" />
|
<el-table-column prop="name" label="姓名" width="120" />
|
||||||
<el-table-column label="角色" width="90">
|
<el-table-column label="角色" width="90">
|
||||||
<template #default="{ row }">{{ ROLE_LABELS[row.role as 'STUDENT'] ?? row.role }}</template>
|
<template #default="{ row }">{{ ROLE_LABELS[row.role as 'STUDENT'] ?? row.role }}</template>
|
||||||
@@ -289,9 +326,6 @@ async function submitCall(): Promise<void> {
|
|||||||
|
|
||||||
<el-dialog v-model="formVisible" :title="editingId ? '编辑学生' : '新建学生'" width="460px">
|
<el-dialog v-model="formVisible" :title="editingId ? '编辑学生' : '新建学生'" width="460px">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
<el-form-item label="用户名" prop="username">
|
|
||||||
<el-input v-model="form.username" :disabled="Boolean(editingId)" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="姓名" prop="name">
|
<el-form-item label="姓名" prop="name">
|
||||||
<el-input v-model="form.name" />
|
<el-input v-model="form.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
+38
-15
@@ -498,6 +498,9 @@ async function main() {
|
|||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|
||||||
// 7.7 ClassIsland 时间配置导入弹窗(班主任可见:粘贴 JSON → 解析预览 → 确认导入)
|
// 7.7 ClassIsland 时间配置导入弹窗(班主任可见:粘贴 JSON → 解析预览 → 确认导入)
|
||||||
|
// 7.7 ClassIsland 课程表导入弹窗(班主任可见:粘贴 JSON → 解析预览 → 单双周识别)
|
||||||
|
// 注:需求调整后课表页只保留「导入 ClassIsland 课程表」入口("导入时间配置"按钮已下线,
|
||||||
|
// 时间表由课程表 JSON 里的 TimeLayouts 一起带进来)。
|
||||||
const layoutDialog = await win.webContents.executeJavaScript(`(async () => {
|
const layoutDialog = await win.webContents.executeJavaScript(`(async () => {
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
||||||
@@ -509,17 +512,18 @@ async function main() {
|
|||||||
while (Date.now() < deadline && location.pathname !== '/schedules') await sleep(80);
|
while (Date.now() < deadline && location.pathname !== '/schedules') await sleep(80);
|
||||||
await sleep(600);
|
await sleep(600);
|
||||||
|
|
||||||
const openButton = Array.from(document.querySelectorAll('button')).find((node) =>
|
const buttons = Array.from(document.querySelectorAll('button'));
|
||||||
(node.textContent ?? '').trim() === '导入时间配置',
|
const openButton = buttons.find((node) => (node.textContent ?? '').trim() === '导入 ClassIsland 课程表');
|
||||||
);
|
if (!openButton) return { ok: false, reason: '课表页没有"导入 ClassIsland 课程表"按钮(班主任权限未生效?)' };
|
||||||
if (!openButton) return { ok: false, reason: '课表页没有"导入时间配置"按钮(班主任权限未生效?)' };
|
// 旧入口必须已经下线(需求 ⑩)
|
||||||
|
const legacy = buttons.some((node) => (node.textContent ?? '').trim() === '导入时间配置');
|
||||||
openButton.click();
|
openButton.click();
|
||||||
await sleep(700);
|
await sleep(700);
|
||||||
|
|
||||||
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
||||||
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('ClassIsland'),
|
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('ClassIsland'),
|
||||||
);
|
);
|
||||||
if (!dialog) return { ok: false, reason: '时间配置导入弹窗未打开' };
|
if (!dialog) return { ok: false, reason: '课程表导入弹窗未打开' };
|
||||||
|
|
||||||
const sampleButton = Array.from(dialog.querySelectorAll('button')).find((node) =>
|
const sampleButton = Array.from(dialog.querySelectorAll('button')).find((node) =>
|
||||||
(node.textContent ?? '').trim() === '填入示例',
|
(node.textContent ?? '').trim() === '填入示例',
|
||||||
@@ -534,35 +538,54 @@ async function main() {
|
|||||||
if (!previewButton) return { ok: false, reason: '缺少"解析预览"按钮' };
|
if (!previewButton) return { ok: false, reason: '缺少"解析预览"按钮' };
|
||||||
previewButton.click();
|
previewButton.click();
|
||||||
|
|
||||||
const previewDeadline = Date.now() + 8000;
|
const previewDeadline = Date.now() + 9000;
|
||||||
let rows = 0;
|
let rows = 0;
|
||||||
let importEnabled = false;
|
let importEnabled = false;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
|
let parityTags = 0;
|
||||||
|
let content = '';
|
||||||
while (Date.now() < previewDeadline) {
|
while (Date.now() < previewDeadline) {
|
||||||
rows = dialog.querySelectorAll('.el-table__body tbody tr').length;
|
rows = dialog.querySelectorAll('.el-table__body tbody tr').length;
|
||||||
errors = dialog.querySelectorAll('.el-alert--error').length;
|
errors = dialog.querySelectorAll('.el-alert--error').length;
|
||||||
const confirm = Array.from(dialog.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
const tags = Array.from(dialog.querySelectorAll('.el-tag')).map((node) => (node.textContent ?? '').trim());
|
||||||
(node.textContent ?? '').trim() === '确认导入',
|
parityTags = tags.filter((text) => text === '单周' || text === '双周').length;
|
||||||
|
content = (dialog.textContent ?? '').replace(/\s+/g, ' ');
|
||||||
|
const confirm = Array.from(dialog.querySelectorAll('.el-dialog__footer button')).find(
|
||||||
|
(node) => (node.textContent ?? '').trim() === '开始导入',
|
||||||
);
|
);
|
||||||
importEnabled = Boolean(confirm) && !confirm.disabled;
|
importEnabled = Boolean(confirm) && !confirm.disabled;
|
||||||
if (rows >= 2) break;
|
if (rows >= 2) break;
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
}
|
}
|
||||||
return { ok: true, rows, importEnabled, errors, hasFileInput: Boolean(dialog.querySelector('input[type=file]')) };
|
return {
|
||||||
|
ok: true,
|
||||||
|
rows,
|
||||||
|
importEnabled,
|
||||||
|
errors,
|
||||||
|
parityTags,
|
||||||
|
legacyGone: !legacy,
|
||||||
|
hasFileInput: Boolean(dialog.querySelector('input[type=file]')) || content.includes('选择 .json 文件'),
|
||||||
|
};
|
||||||
})()`);
|
})()`);
|
||||||
record(
|
record(
|
||||||
'ClassIsland 时间配置导入弹窗(示例 → 解析预览 → 可导入)',
|
'ClassIsland 课程表导入弹窗(示例 → 解析预览 → 识别单双周,旧"导入时间配置"入口已下线)',
|
||||||
Boolean(layoutDialog?.ok) &&
|
Boolean(layoutDialog?.ok) &&
|
||||||
(layoutDialog?.rows ?? 0) >= 2 &&
|
(layoutDialog?.rows ?? 0) >= 2 &&
|
||||||
layoutDialog?.importEnabled === true &&
|
layoutDialog?.importEnabled === true &&
|
||||||
layoutDialog?.errors === 0,
|
layoutDialog?.errors === 0 &&
|
||||||
`节次行=${layoutDialog?.rows ?? 0} 可导入=${layoutDialog?.importEnabled} 校验错误=${layoutDialog?.errors ?? '-'} 文件选择=${layoutDialog?.hasFileInput}` +
|
(layoutDialog?.parityTags ?? 0) >= 1 &&
|
||||||
|
layoutDialog?.legacyGone === true,
|
||||||
|
`解析行=${layoutDialog?.rows ?? 0} 可导入=${layoutDialog?.importEnabled} 校验错误=${layoutDialog?.errors ?? '-'} ` +
|
||||||
|
`单双周标签=${layoutDialog?.parityTags ?? 0} 旧入口已下线=${layoutDialog?.legacyGone} 文件选择=${layoutDialog?.hasFileInput}` +
|
||||||
`${layoutDialog?.reason ? ` 原因=${layoutDialog.reason}` : ''}`,
|
`${layoutDialog?.reason ? ` 原因=${layoutDialog.reason}` : ''}`,
|
||||||
);
|
);
|
||||||
|
// 关闭导入弹窗(用标题栏关闭按钮,兼容不同对话框的底部按钮文案)
|
||||||
await win.webContents.executeJavaScript(`(() => {
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
const buttons = Array.from(document.querySelectorAll('.el-dialog__footer button'));
|
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
||||||
const close = buttons.find((node) => (node.textContent ?? '').trim() === '关闭');
|
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('ClassIsland'),
|
||||||
if (close) close.click();
|
);
|
||||||
|
const closeBtn = dialog?.querySelector('.el-dialog__headerbtn');
|
||||||
|
if (closeBtn) closeBtn.click();
|
||||||
return true;
|
return true;
|
||||||
})()`);
|
})()`);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|||||||
Reference in new issue
Block a user