feat(web,desktop,server): 灵动岛收起后可再次打开 · 下线作业截止时间 · Web 端圆角设计

灵动岛"收起后部分情况无法再次打开"(用户反馈):
- 收起不再等于消失:只要有未处理通知(当前展示的或队列里的),超时后保持胶囊常驻,
  只有"知道了 / 标为已读"、队列清空或进入上课时段才彻底隐藏。
- 收起态点击兜底:active 为空但队列还有未看通知时,点击胶囊会把它取出并展开;
  上课时段仍严格不显示(该兜底放在上课守卫之后,不影响"上课不打扰")。
- 命中测试自愈:渲染进程记住最近指针位置,状态/外观变化与形变每一帧都按新几何重算命中;
  岛可见时主进程也会重新下发可交互,避免"窗口还处于穿透 → 点了没反应"。
- 新增 3 条回归断言:收起后胶囊常驻、收起态点击可再次展开(命中恢复)、空闲细缝也可点开。

作业"截止时间"功能下线(用户要求):
- 接口/类型:HomeworkDto 与创建请求去掉 dueAt,homeworks schema/service 不再读写;
  仪表盘去掉"即将截止的作业"卡片与待截止统计口径(改为"未完成/待完成")。
- Web:作业发布表单移除截止时间选择、列表与详情移除截止时间列;
  学生端作业列表/详情移除截止时间与"已逾期"状态(只保留完成状态)。
- 灵动岛:作业卡不再显示"截止时间:…"副标题,改为只展示作业要求。
- DB 中的 Homework.dueAt 列**保留**(已在 schema 注明为遗留字段),避免对已安装库做破坏性迁移;
  种子数据不再写入该列。

Web 管理端圆角设计(用户要求):
- 新增设计令牌 --ch-radius-xl/lg/md/sm/pill 与两级柔和投影,并让 Element Plus 的
  --el-border-radius-* 对齐同一套令牌;卡片、弹窗、抽屉、按钮、输入、标签、分页、
  侧栏菜单项、表格表头统一改造,卡片去硬边框改投影 + 悬停微浮起,顶栏/侧栏做圆角收口。
- 未改组件源码,全部为 CSS 变量 + 类选择器覆盖;手机端媒体查询保持生效(截图已更新)。

验证:verify:desktop 62/62(含 3 条收起态回归)与离线复跑 56/56、打包便携版 62/62(packaged=true)、
verify:e2e 136/136(新增"作业接口不再返回 dueAt")、verify:web 20/20、typecheck/lint/format 全绿;
服务端与 Web 已重新打包并部署到本机安装目录。
This commit is contained in:
LoveChengke committed 2026-09-12 21:01:31 +08:00
1 parent 0690546307
commit b69f708aa2
23 files changed
+534 -222

No files matched your search

+80 -57
View File
@@ -20,7 +20,7 @@
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 59/59) |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 62/62) |
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
@@ -75,7 +75,7 @@ class-helper/
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
│ ├── prisma/seed.ts # 种子数据
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(135 项)
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(136 项)
│ └── src/
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
@@ -171,15 +171,15 @@ pnpm verify:desktop
```bash
# 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:135 项
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:136 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/紧急与普通叫人、真实链路、课表时间轴、开合不震动、连续圆角四角一致、个性化 10 项参数与托盘/退出无残留):59 项
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/再次展开、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):62 项
```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 135 项,
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 136 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
(**班级账号登录** + 四类数据 + Socket.IO),实测 59/59 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 53/53)。
(**班级账号登录** + 四类数据 + Socket.IO),实测 62/62 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 56/56)。
## 默认账号与种子数据
@@ -208,36 +208,54 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
## 常用命令
| 命令 | 说明 |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(135 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(59 项,含灵动岛圆角/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
| `pnpm db:migrate` | 创建并应用迁移 |
| `pnpm db:seed` | 写入种子数据 |
| `pnpm db:reset` | 重置数据库并重新执行 seed |
| `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
| 命令 | 说明 |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(136 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(62 项,含灵动岛圆角/收起常驻与再次展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
| `pnpm db:migrate` | 创建并应用迁移 |
| `pnpm db:seed` | 写入种子数据 |
| `pnpm db:reset` | 重置数据库并重新执行 seed |
| `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
## Web 管理端(含手机小屏适配)
布局沿用 **1Panel 风格**:固定深色侧边栏(`#1f2d3d` + 蓝色圆角选中态)+ 白色顶栏(当前页标题/连接状态/用户菜单)+
浅灰底 + 白色圆角卡片内容区。手机小屏下侧边栏收进抽屉,由顶栏汉堡按钮唤出。
### 圆角设计(统一设计令牌)
全站圆角与柔和投影集中定义在 `packages/web-admin/src/styles/index.css` 的 `:root` 令牌里,
组件层不写死数值;Element Plus 也通过对齐它的圆角变量(`--el-border-radius-*`)一并改造:
| 令牌 | 值 | 用在哪 |
| ---------------------- | -------- | ------------------------------------------------------------ |
| `--ch-radius-xl: 22px` | 22px | 弹窗、抽屉、侧栏外缘 |
| `--ch-radius-lg: 18px` | 18px | 卡片(`el-card`)、统计卡、消息框 |
| `--ch-radius-md: 14px` | 14px | 下拉/浮层/气泡、`el-alert`、描述列表、上传区 |
| `--ch-radius-sm: 10px` | 10px | 按钮、输入框、下拉框、日期选择、分页、侧栏菜单项、单选按钮组 |
| `--ch-radius-pill` | 999px | 标签 `el-tag`、开关、进度条、圆形按钮 |
| `--ch-shadow-sm/md` | 两级投影 | 卡片默认 / 悬停与弹窗 |
配套观感调整:卡片去掉 1px 灰边框(改用轻投影 + 悬停微浮起)、顶栏底部圆角、深色侧栏右侧圆角、
表头底色统一为 `#f7f9fc`、弹窗头部/底部留白加大。全部通过 CSS 变量与少量类选择器覆盖,未改组件源码。
`pnpm verify:web` 会在真实浏览器里点击各页面并断言这些视觉不破坏交互(含手机端截图)。
| 断点 | 布局行为 |
| ---------------- | --------------------------------------------------------------------------------------------- |
| `> 992px` 桌面 | 常驻 210px 深色侧边栏 + 顶栏显示连接状态与"已接收 N 条实时事件" |
@@ -332,23 +350,25 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
> 渲染层做命中测试后只在指针进入岛体时打开命中——**大窗口不会吞掉桌面点击**(有专项断言)。
> 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。
| 场景 | 灵动岛行为 |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 |
| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 |
| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
| **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") |
| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) |
| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见"截止时间:…" |
| **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) |
| **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) |
| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 —— 收回时不会出现被拉伸的"方框",图标也不会上下跳 |
| 点击右上角收起按钮 | 同上:回缩为胶囊 |
| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情(14 秒)→ 收起为胶囊(25 秒) |
| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 |
| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 |
| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" |
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
| 场景 | 灵动岛行为 |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` |
| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` |
| **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失** —— 随时都能再次点开(彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段) |
| **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中并被固化为回归用例("收起态点击可再次展开(指针未移动也算命中)") |
| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
| **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") |
| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) |
| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见作业要求(**截止时间功能已下线**) |
| **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) |
| **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) |
| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 —— 收回时不会出现被拉伸的"方框",图标也不会上下跳 |
| 点击右上角收起按钮 | 同上:回缩为胶囊 |
| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情 → 收起为胶囊 |
| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 |
| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 |
| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" |
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
- 动画:窗口尺寸用逐帧缓动实现"形变",**单调不过冲**——展开 300ms、收回 220ms,统一 `easeOutCubic`
(早期版本用 `easeOutBack` 弹簧缓动 + 卡片 `cubic-bezier(0.34, 1.56, 0.64, 1)` 缩放入场,
@@ -529,6 +549,8 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| **开合不震动:收回** | ✅ 逐帧采样 56 帧:岛中心/上边缘波动 **0.00px**;窗口全程冻结;岛 `386x230→…→268x44` 单调收缩无回跳 |
| **WinIsland 架构:窗口恒定 + 形状正确** | ✅ 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种);SVG 形状 bbox 与卡片框一致(±2px);**路径绕行 \|winding\| ≈ 2π**(凸、不自交,可抓出"角上往回折") |
| **岛外鼠标穿透** | ✅ 指针在岛内 `interactive=true`、在岛外 `interactive=false`(固定大包围盒窗口不吞桌面点击) |
| **收起后胶囊常驻(可再次点开)** | ✅ 收起后等满 `TIMEOUTS.pill`(15s)超时:`mode=pill 可见=true`(有未处理通知就不消失);显式把命中置为穿透后,一次状态变化即恢复 `interactive=true`,点击后 `mode=expanded active=smoke-collapse-reopen` |
| **空闲细缝也可点开** | ✅ `mode=hidden 可见=true interactive=true 细缝=6x22`(细缝虽窄,指针在其上即可命中) |
| **紧急叫人(URGENT + 叫人)** | ✅ 上课中也立即展开:`mode=expanded kind=call reason=call 徽标=叫人 按钮=收到` |
| **普通叫人(HIGH + 叫人)** | ✅ 上课只进队列:`mode=hidden active≠smoke-call-normal queued=[…smoke-call-normal]`;下课后自动弹出 `active=smoke-call-normal kind=call` |
| **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** |
@@ -543,7 +565,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
已实测 `packaged: true`、59/59 通过、退出码 0)。灵动岛各状态的窗口截图会写到
已实测 `packaged: true`、62/62 通过、退出码 0)。灵动岛各状态的窗口截图会写到
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
@@ -885,7 +907,8 @@ pnpm db:migrate && pnpm db:seed
| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) |
| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 |
| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` |
| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" |
| 作业发布也上岛("新作业"胶囊) | ✅ | `kind=homework` + 展开显示作业要求;**截止时间功能已下线**(接口不再返回 `dueAt`,e2e 断言 `dueAt === undefined`) |
| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:「收起后胶囊常驻(超时不再消失)」「收起态点击可再次展开(指针未移动也算命中)」「空闲细缝态保持可交互」 |
| 叫人(老师点名,分紧急/普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 |
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 |
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 |
@@ -907,14 +930,14 @@ pnpm db:migrate && pnpm db:seed
当前实测:
| 验证 | 结果 |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/开合不震动 + 个性化全参数 + 托盘与退出无残留) | **59/59** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
| 验证 | 结果 |
| ------------------------------------------------------------------------------------------------------------------ | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻与再次展开 + 个性化全参数 + 托盘与退出无残留) | **62/62** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
## 常见问题(本机环境已知坑)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 111 KiB

@@ -186,12 +186,15 @@ function syncTargets(snap = false): void {
springs.radius.snap(radius);
morph.value = { width: size.width, height: size.height, radius };
progress.value = mode.value === 'expanded' ? 1 : 0;
refreshInteractive();
return;
}
springs.width.setTarget(size.width);
springs.height.setTarget(size.height);
springs.radius.setTarget(radius);
startFrameLoop();
// 目标几何变了:立刻按新尺寸重算一次命中(形变过程中每帧也会重算)
refreshInteractive();
}
function startFrameLoop(): void {
@@ -218,6 +221,8 @@ function startFrameLoop(): void {
span < 1 ? (mode.value === 'expanded' ? 1 : 0) : Math.abs(morph.value.height - pill) / span;
if (moving || !springs.width.settled || !springs.height.settled || !springs.radius.settled) {
// 形变过程中岛的可点区域在变:每帧按缓存指针位置重算命中(值不变时不发 IPC)
refreshInteractive();
rafHandle = requestAnimationFrame(frame);
return;
}
@@ -228,6 +233,7 @@ function startFrameLoop(): void {
springs.radius.snap(targetRadius(size));
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
progress.value = mode.value === 'expanded' ? 1 : 0;
refreshInteractive();
rafHandle = null;
};
rafHandle = requestAnimationFrame(frame);
@@ -293,7 +299,11 @@ const islandClass = computed(() => ({
* 指针在岛体上(或可交互控件上)时才让窗口接收鼠标,否则穿透,保证不吞桌面点击。
*/
let lastInteractive: boolean | null = null;
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
let lastPointer: { x: number; y: number } | null = null;
function updateInteractive(clientX: number, clientY: number): void {
lastPointer = { x: clientX, y: clientY };
const node = document.querySelector('.island-card') as HTMLElement | null;
let inside = false;
if (node) {
@@ -310,6 +320,29 @@ function updateInteractive(clientX: number, clientY: number): void {
bridge?.setInteractive?.(inside);
}
/**
* 状态 / 外观变化后主动重算一次命中测试。
*
* 为什么必须主动算:窗口默认 `setIgnoreMouseEvents(true, {forward:true})`,
* 只有在收到 mousemove 时才会打开命中。岛"收起 / 展开 / 换形态"时几何变了,
* 但指针可能一动没动(没有新的 mousemove)—— 此时窗口会一直保持"穿透",
* 表现就是**收起后怎么点都打不开**(用户反馈)。所以每次状态变化都用缓存的指针位置重算一次。
*/
function refreshInteractive(): void {
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
if (fullyHidden) {
if (lastInteractive !== false) {
lastInteractive = false;
bridge?.setInteractive?.(false);
}
return;
}
if (!lastPointer) return;
const pointer = lastPointer;
lastInteractive = null; // 强制按新几何再算一次
updateInteractive(pointer.x, pointer.y);
}
function applyAppearance(next: IslandAppearance | null | undefined): void {
if (!next) return;
appearance.value = next;
+33 -11
View File
@@ -371,10 +371,22 @@ class IslandController {
handleAction(payload: IslandActionPayload): void {
switch (payload.action) {
case 'expand': {
const active = this.state.active;
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
if (this.state.inClass && !(active && this.isImmediate(active))) break;
const showing = this.state.active;
if (this.state.inClass && !(showing && this.isImmediate(showing))) break;
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
if (!this.state.active) {
const pending = this.state.queued[this.state.queued.length - 1];
if (pending) {
this.state.queued = this.state.queued.filter((item) => item.id !== pending.id);
this.state.active = pending;
logger.info(`灵动岛:收起态点击,重新打开队列中的通知 ${pending.id}`);
}
}
const active = this.state.active;
if (active) {
this.setState({ mode: 'expanded' });
this.scheduleCollapse(
@@ -515,14 +527,19 @@ class IslandController {
if (this.state.mode === 'expanded') this.setState({ mode: 'pill' });
return;
}
if (this.state.mode === 'expanded') {
const hasMore = this.state.queued.length > 1;
if (hasMore) {
this.setState({ mode: 'pill' });
this.scheduleCollapse(TIMEOUTS.afterClassPill);
} else {
this.hide();
}
// 关键行为:只要还有"没处理完"的通知(当前展示的或队列里的),收起后**保持胶囊**,
// 不再直接消失 —— 否则用户看到岛收起了却点不到("收起状态无法再次打开")。
// 彻底隐藏只发生在:用户点了"知道了 / 标为已读"、队列清空、或进入上课时段。
const pending = this.state.active !== null || this.state.queued.length > 0;
if (this.state.mode === 'expanded' && this.state.queued.length > 1) {
// 还有多条未看:先回缩为"还有 N 条"胶囊
this.setState({ mode: 'pill' });
if (pending) return;
this.hide();
return;
}
if (pending) {
if (this.state.mode !== 'pill') this.setState({ mode: 'pill' });
return;
}
this.hide();
@@ -959,4 +976,9 @@ export function registerIslandIpc(): void {
});
}
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE, CALL_SIZE as ISLAND_CALL_SIZE };
export {
SIZES as ISLAND_SIZES,
URGENT_SIZE as ISLAND_URGENT_SIZE,
CALL_SIZE as ISLAND_CALL_SIZE,
TIMEOUTS as ISLAND_TIMEOUTS,
};
+114 -8
View File
@@ -5,7 +5,14 @@ import type { BrowserWindow, NativeImage } from 'electron';
import type { IslandAppearance, IslandNotification } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { getDiagnostics } from './ipc.js';
import { getSliverSize, island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
import {
getSliverSize,
island,
ISLAND_CALL_SIZE,
ISLAND_SIZES,
ISLAND_TIMEOUTS,
ISLAND_URGENT_SIZE,
} from './island.js';
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
interface SmokeResult {
@@ -839,14 +846,14 @@ async function runIslandChecks(
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
);
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示截止时间
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
island.handleAction({ action: 'dismiss' });
await sleep(400);
island.pushNotification(
{
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
kind: 'homework',
subtitle: '截止时间:2026-09-15 22:00',
teacherName: '张老师',
},
{ inClass: false },
@@ -868,13 +875,13 @@ async function runIslandChecks(
);
island.handleAction({ action: 'expand' });
await sleep(700);
const homeworkHint = await islandWindow?.webContents.executeJavaScript(
`document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? ''`,
const homeworkCardText = await islandWindow?.webContents.executeJavaScript(
`document.querySelector('.island-card.expanded')?.innerText?.replace(/\\s+/g, ' ').trim() ?? ''`,
);
record(
'作业详情显示截止时间',
(homeworkHint ?? '').includes('截止时间'),
`附加说明="${homeworkHint ?? '-'}"`,
'作业详情只显示作业要求(截止时间已下线)',
(homeworkCardText ?? '').includes('第 3 章课后练习') && !(homeworkCardText ?? '').includes('截止时间'),
`作业卡文案="${homeworkCardText ?? '-'}"`,
);
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
@@ -957,6 +964,105 @@ async function runIslandChecks(
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(400);
// 4.9) 收起态回归(用户反馈"收起后部分情况无法再次打开"):
// a) 胶囊不再因超时消失(有未处理通知时常驻);
// b) 收起后胶囊仍然可命中(窗口保持可交互)→ 点击可再次展开;
// c) 空闲细缝态同样是"可见即可点"(细缝 6x22 也能命中)。
// 注:此处不使用后面的 drainIsland(它在本函数更靠下的位置定义),就地排空队列。
const drainForCollapseCheck = async (): Promise<void> => {
for (let index = 0; index < 20; index += 1) {
const current = island.getState();
if (!current.active && current.queued.length === 0) return;
island.handleAction({ action: 'dismiss' });
await sleep(120);
}
};
/** 合成"指针移到卡片上":命中测试由渲染进程按指针位置决定(真实场景由系统转发 mousemove) */
const hoverCard = async (): Promise<boolean> =>
Boolean(
await islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return false;
const r = card.getBoundingClientRect();
window.dispatchEvent(
new MouseEvent('mousemove', {
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
bubbles: true,
}),
);
return true;
})()`,
)
.catch(() => false),
);
await drainForCollapseCheck();
await sleep(300);
island.pushNotification(makeNotification('smoke-collapse-reopen', 'NORMAL', '收起态回归校验'), {
inClass: false,
});
await sleep(500);
const collapseBefore = island.getState();
await hoverCard();
await sleep(200);
await sleep(ISLAND_TIMEOUTS.pill + 700);
const lingered = island.getState();
const lingeredVisible = islandWindow?.isVisible() ?? false;
record(
'收起后胶囊常驻(超时不再消失,随时可再次点开)',
collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible,
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`,
);
// 命中:把窗口显式置为"穿透"(等价于收起瞬间窗口被重置),再做一次状态变化 ——
// 主进程会按当前形态重新下发命中,渲染进程也会按缓存指针重算,两者都必须把命中恢复。
island.setInteractive(false);
await sleep(60);
const forcedThrough = island.getInteractive();
island.handleAction({ action: 'collapse' });
await sleep(300);
const interactiveWhilePill = island.getInteractive();
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
await sleep(750);
const reopened = island.getState();
record(
'收起态点击可再次展开(胶囊命中恢复 + 点击后展开)',
forcedThrough === false &&
interactiveWhilePill === true &&
reopened.mode === 'expanded' &&
reopened.active?.id === 'smoke-collapse-reopen',
`置穿透后=${forcedThrough} 状态变化后=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
`active=${reopened.active?.id ?? '-'}`,
);
const sliverForInteractive = getSliverSize();
island.setAppearance({ ...island.getAppearance(), idleSliver: true });
await sleep(200);
await drainForCollapseCheck();
await sleep(500);
const sliverState = island.getState();
await hoverCard();
await sleep(250);
const sliverInteractive = island.getInteractive();
const sliverVisible = islandWindow?.isVisible() ?? false;
record(
'空闲细缝态保持可交互(细缝很窄,指针停在岛上仍能点开)',
sliverState.mode === 'hidden' && sliverVisible && sliverInteractive === true,
`mode=${sliverState.mode} 可见=${sliverVisible} interactive=${sliverInteractive} ` +
`细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`,
);
island.setAppearance({ ...island.getAppearance(), idleSliver: false });
await drainForCollapseCheck();
await sleep(300);
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
const shotDetails = islandShots.map((shot) => {
@@ -1,6 +1,5 @@
import {
SOCKET_EVENTS,
formatDate,
resolveClassStatus,
type ClassPeriod,
type HomeworkDto,
@@ -91,9 +90,8 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
});
}
/** 新作业上岛(homework:new):胶囊提示"新作业",展开看截止时间 */
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
export function pushHomeworkToIsland(homework: HomeworkDto): void {
const due = homework.dueAt ? formatDate(homework.dueAt, true) : '未设置截止时间';
window.desktop?.islandPush({
notification: {
id: `homework-${homework.id}`,
@@ -104,7 +102,6 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void {
courseName: homework.course?.name ?? null,
teacherName: homework.creator?.name ?? null,
kind: 'homework',
subtitle: `截止时间:${due}`,
},
context: getIslandClassContext(),
});
@@ -79,7 +79,7 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
mark(SOCKET_EVENTS.homeworkNew);
ElMessage.success(`新作业:${payload.title}`);
// 作业发布也上岛:胶囊提示"新作业",点击展开看截止时间与要求
// 作业发布也上岛:胶囊提示"新作业",点击展开看作业要求
pushHomeworkToIsland(payload);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { SOCKET_EVENTS, formatDate, formatDeadline, isOverdue, type HomeworkDto } from '@classhelper/shared';
import { SOCKET_EVENTS, formatDate, type HomeworkDto } from '@classhelper/shared';
import { homeworkApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
@@ -147,12 +147,8 @@ onUnmounted(() => {
<el-table v-else :data="filtered" @row-click="openDetail">
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
:type="row.completed ? 'success' : isOverdue(row.dueAt) ? 'danger' : 'info'"
size="small"
effect="light"
>
{{ row.completed ? '已完成' : isOverdue(row.dueAt) ? '已逾期' : '待完成' }}
<el-tag :type="row.completed ? 'success' : 'info'" size="small" effect="light">
{{ row.completed ? '已完成' : '待完成' }}
</el-tag>
</template>
</el-table-column>
@@ -160,16 +156,6 @@ onUnmounted(() => {
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
</el-table-column>
<el-table-column label="剩余" width="120">
<template #default="{ row }">
<span :class="{ 'text-danger': isOverdue(row.dueAt) && !row.completed }">
{{ row.completed ? '—' : formatDeadline(row.dueAt) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button
@@ -198,9 +184,6 @@ onUnmounted(() => {
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDate(current.dueAt, true) || '不限' }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDate(current.createdAt, true) }}
</el-descriptions-item>
+2
View File
@@ -155,6 +155,8 @@ model Homework {
title String
content String
attachmentUrl String?
/// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。
/// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。
dueAt DateTime?
createdBy String
createdAt DateTime @default(now())
+1 -6
View File
@@ -186,37 +186,33 @@ async function main(): Promise<void> {
}
// ---------------------------------------------------------------- 作业
// 注:作业"截止时间"功能已下线,种子数据不再写入 dueAt(库字段保留以兼容已装库)
const now = Date.now();
const day = 86_400_000;
const homeworkSeeds = [
{
title: '《劝学》全文背诵',
content: '背诵《劝学》并录制音频上传到班级群,注意断句。',
dueInDays: 3,
courseIndex: 0,
},
{
title: '数学必修一 3.2 习题',
content: '完成课本 P78 习题 3.2 全部题目,第 12 题选做。',
dueInDays: 1,
courseIndex: 1,
},
{
title: '英语周记一篇',
content: '以"My School Life"为题写一篇 150 词左右的英语周记。',
dueInDays: 5,
courseIndex: 2,
},
{
title: '物理实验报告',
content: '整理"探究匀变速直线运动"实验数据,撰写实验报告。',
dueInDays: -2,
courseIndex: 3,
},
{
title: '化学方程式默写',
content: '默写第一至第三章全部化学方程式,家长签字。',
dueInDays: -1,
courseIndex: 4,
},
];
@@ -234,7 +230,6 @@ async function main(): Promise<void> {
content: seed.content,
attachmentUrl:
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
dueAt: new Date(now + seed.dueInDays * day),
createdBy: targetClass.teacherId,
},
});
+6 -1
View File
@@ -198,11 +198,16 @@ async function main() {
courseId,
title: `联调验证作业 ${new Date().toISOString()}`,
content: '请完成验证脚本创建的作业。',
dueAt: new Date(Date.now() + 86_400_000).toISOString(),
},
});
const homeworkId = createdHomework.payload?.data?.id;
record('教师发布作业', createdHomework.status === 201, `status=${createdHomework.status}`);
// 截止时间功能已下线:接口不再返回该字段(历史数据保留在库里但不读写)
record(
'作业接口不再返回截止时间(dueAt 已下线)',
createdHomework.payload?.data?.dueAt === undefined,
`dueAt=${String(createdHomework.payload?.data?.dueAt)}`,
);
try {
const { payload, elapsed } = await homeworkWait;
-2
View File
@@ -190,7 +190,6 @@ export interface HomeworkLike {
title: string;
content: string;
attachmentUrl: string | null;
dueAt: Date | null;
createdBy: string;
createdAt: Date;
course?: CourseBriefLike | null;
@@ -228,7 +227,6 @@ export function toHomeworkDto(
title: item.title,
content: item.content,
attachmentUrl: item.attachmentUrl,
dueAt: toIsoOrNull(item.dueAt),
createdBy: item.createdBy,
createdAt: toIso(item.createdAt),
course: item.course ? toCourseBrief(item.course) : null,
@@ -25,7 +25,6 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
const where = classScopeWhere(scope);
const classWhere = classScopeIdFilter(scope);
const now = new Date();
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
@@ -42,7 +41,6 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
pendingHomeworkCount,
recentNotifications,
recentHomeworks,
upcoming,
] = await Promise.all([
prisma.class.count({ where: classWhere }),
prisma.user.count({ where: { ...where, role: 'STUDENT' } }),
@@ -56,7 +54,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
? prisma.homework.count({
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
})
: prisma.homework.count({ where: { ...where, dueAt: { gte: now } } }),
: prisma.homework.count({ where }),
prisma.notification.findMany({
where,
include: { creator: creatorSelect, reads: { where: personalWhere } },
@@ -73,16 +71,6 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.homework.findMany({
where: { ...where, dueAt: { gte: now } },
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: personalWhere } : true,
},
orderBy: { dueAt: 'asc' },
take: 5,
}),
]);
return {
@@ -101,8 +89,5 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
recentHomeworks: recentHomeworks.map((item): HomeworkDto =>
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
),
upcomingDeadlines: upcoming.map((item): HomeworkDto =>
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
),
};
}
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { booleanFlagSchema, optionalDateTimeSchema } from '../../lib/schemas.js';
import { booleanFlagSchema } from '../../lib/schemas.js';
export const listHomeworksQuerySchema = z.object({
classId: z.string().min(1).optional(),
@@ -15,7 +15,6 @@ export const createHomeworkSchema = z.object({
title: z.string().trim().min(1, '请输入作业标题').max(120),
content: z.string().min(1, '请输入作业内容').max(5000),
attachmentUrl: z.string().trim().max(500).nullish(),
dueAt: optionalDateTimeSchema,
});
export const updateHomeworkSchema = z.object({
@@ -23,7 +22,6 @@ export const updateHomeworkSchema = z.object({
title: z.string().trim().min(1).max(120).optional(),
content: z.string().min(1).max(5000).optional(),
attachmentUrl: z.string().trim().max(500).nullish(),
dueAt: optionalDateTimeSchema,
});
export const updateHomeworkStatusSchema = z.object({
@@ -10,7 +10,6 @@ import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js';
import type {
@@ -101,7 +100,6 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
title: input.title,
content: input.content,
attachmentUrl: input.attachmentUrl ?? null,
dueAt: parseOptionalDate(input.dueAt),
createdBy: user.sub,
},
include: { course: courseSelect, creator: creatorSelect, statuses: true },
@@ -130,7 +128,6 @@ export async function updateHomework(
...(input.content ? { content: input.content } : {}),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
...(input.dueAt !== undefined ? { dueAt: parseOptionalDate(input.dueAt) } : {}),
},
include: { course: courseSelect, creator: creatorSelect, statuses: true },
});
-3
View File
@@ -215,7 +215,6 @@ export interface HomeworkDto {
title: string;
content: string;
attachmentUrl: string | null;
dueAt: string | null;
createdBy: string;
createdAt: string;
course?: CourseBrief | null;
@@ -241,7 +240,6 @@ export interface CreateHomeworkRequest {
title: string;
content: string;
attachmentUrl?: string | null;
dueAt?: string | null;
}
export interface UpdateHomeworkStatusRequest {
@@ -491,7 +489,6 @@ export interface DashboardSummary {
pendingHomeworkCount: number;
recentNotifications: NotificationDto[];
recentHomeworks: HomeworkDto[];
upcomingDeadlines: HomeworkDto[];
}
/* ------------------------------------------------------------------ WebSocket */
-22
View File
@@ -102,28 +102,6 @@ export function durationMinutes(startTime: string, endTime: string): number {
return (eh as number) * 60 + (em as number) - ((sh as number) * 60 + (sm as number));
}
/* ------------------------------------------------------------------ 作业 */
/** 是否已过截止时间 */
export function isOverdue(dueAt: string | null | undefined, now: Date = new Date()): boolean {
if (!dueAt) return false;
const due = toDate(dueAt);
return !Number.isNaN(due.getTime()) && due.getTime() < now.getTime();
}
/** 距离截止时间的可读描述,例如"还剩 2 天"、"已逾期 3 小时" */
export function formatDeadline(dueAt: string | null | undefined, now: Date = new Date()): string {
if (!dueAt) return '无截止时间';
const due = toDate(dueAt);
if (Number.isNaN(due.getTime())) return '无截止时间';
const diffMinutes = Math.round((due.getTime() - now.getTime()) / 60000);
const prefix = diffMinutes < 0 ? '已逾期' : '还剩';
const abs = Math.abs(diffMinutes);
if (abs < 60) return `${prefix} ${abs} 分钟`;
if (abs < 60 * 24) return `${prefix} ${Math.floor(abs / 60)} 小时`;
return `${prefix} ${Math.floor(abs / (60 * 24))} 天`;
}
/* ------------------------------------------------------------------ 成绩 */
/** 得分率(0-100),总分非法时返回 0 */
@@ -299,16 +299,19 @@ async function submitPassword(): Promise<void> {
.layout-menu :deep(.el-menu-item.is-active) {
background: #409eff;
border-radius: 6px;
border-radius: var(--ch-radius-sm);
margin: 0 8px;
}
.layout-menu :deep(.el-menu-item) {
margin: 2px 8px;
border-radius: 6px;
border-radius: var(--ch-radius-sm);
}
.layout-header {
/* 顶栏跟随圆角设计:底部圆角 + 轻投影,与内容区一体化 */
border-radius: 0 0 var(--ch-radius-lg) var(--ch-radius-lg);
box-shadow: var(--ch-shadow-sm);
background: #fff;
border-bottom: 1px solid var(--ch-border);
display: flex;
@@ -326,7 +329,7 @@ async function submitPassword(): Promise<void> {
height: 32px;
margin-right: 2px;
border: none;
border-radius: 8px;
border-radius: var(--ch-radius-sm);
background: #f2f3f5;
color: #303133;
cursor: pointer;
@@ -392,6 +395,9 @@ async function submitPassword(): Promise<void> {
<style>
/* 侧边栏配色放在全局:桌面 asider 与移动端抽屉复用同一套样式(抽屉内容在 teleport 中,scoped 样式不生效) */
.ch-sidebar {
/* 圆角设计:深色侧栏右侧做圆角,与内容区形成"卡片浮起"观感 */
border-radius: 0 var(--ch-radius-xl) var(--ch-radius-xl) 0;
overflow: hidden;
background: #1f2d3d;
color: #fff;
}
+245 -7
View File
@@ -1,10 +1,239 @@
:root {
--ch-bg: #f5f7fa;
--ch-card-radius: 10px;
--ch-border: #e4e7ed;
--ch-bg: #f2f4f8;
/* 圆角设计令牌:卡片 > 面板 > 控件 > 小元素,统一走这四个档位 */
--ch-radius-xl: 22px;
--ch-radius-lg: 18px;
--ch-radius-md: 14px;
--ch-radius-sm: 10px;
--ch-radius-pill: 999px;
--ch-card-radius: var(--ch-radius-lg);
--ch-border: #e6e8ef;
--ch-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.04);
--ch-shadow-md: 0 6px 24px rgba(15, 23, 42, 0.08);
/* 1Panel 风格:固定顶栏 + 深色侧栏 */
--ch-header-height: 56px;
--ch-aside-width: 210px;
/* Element Plus 圆角令牌对齐(组件库内部都引用这些变量,改这里即可全局生效) */
--el-border-radius-base: var(--ch-radius-sm);
--el-border-radius-small: 8px;
--el-border-radius-round: var(--ch-radius-pill);
--el-box-shadow-light: var(--ch-shadow-sm);
}
/* ---------------------------------------------------------------- 圆角设计:
统一"大圆角 + 轻阴影 + 无硬边框"的观感:
- 卡片 / 面板 / 弹窗 / 抽屉:18~22px 圆角 + 柔和投影(去掉 1px 灰边)
- 按钮 / 输入框 / 下拉 / 标签:10px 圆角,胶囊类(标签页、分页、单选组)走 999px
- 表格:外层由卡片包裹,表头首末单元格跟随卡片圆角
全部通过 Element Plus 的 CSS 变量 + 少量类选择器覆盖,不改组件源码。 */
.el-card {
border: none;
border-radius: var(--ch-radius-lg);
box-shadow: var(--ch-shadow-sm);
overflow: hidden;
}
.el-card__header {
border-bottom: 1px solid var(--ch-border);
padding: 14px 18px;
font-weight: 600;
}
.el-card__body {
padding: 18px;
}
.el-button {
border-radius: var(--ch-radius-sm);
font-weight: 500;
}
.el-button.is-round,
.el-button--circle {
border-radius: var(--ch-radius-pill);
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper,
.el-input-number,
.el-date-editor.el-input__wrapper {
border-radius: var(--ch-radius-sm);
}
.el-input__wrapper,
.el-select__wrapper {
box-shadow: 0 0 0 1px var(--ch-border) inset;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
.el-textarea__inner {
box-shadow: 0 0 0 1px var(--ch-border) inset;
}
.el-dialog {
border-radius: var(--ch-radius-xl);
overflow: hidden;
box-shadow: var(--ch-shadow-md);
}
.el-dialog__header {
margin-right: 0;
padding: 18px 22px 8px;
font-weight: 600;
}
.el-dialog__body {
padding: 10px 22px 6px;
}
.el-dialog__footer {
padding: 8px 22px 18px;
}
.el-drawer {
border-radius: var(--ch-radius-xl) 0 0 var(--ch-radius-xl);
}
.el-drawer.rtl {
border-radius: var(--ch-radius-xl) 0 0 var(--ch-radius-xl);
}
.el-drawer.ltr {
border-radius: 0 var(--ch-radius-xl) var(--ch-radius-xl) 0;
}
.el-drawer.ttb,
.el-drawer.btt {
border-radius: var(--ch-radius-xl);
}
.el-message,
.el-message-box,
.el-notification,
.el-popper.is-light,
.el-dropdown-menu,
.el-select-dropdown,
.el-picker-panel {
border-radius: var(--ch-radius-md);
}
.el-message-box {
border-radius: var(--ch-radius-lg);
padding: 20px 22px;
}
.el-popper.is-light {
border: 1px solid var(--ch-border);
box-shadow: var(--ch-shadow-sm);
}
.el-dropdown-menu__item,
.el-select-dropdown__item {
border-radius: var(--ch-radius-sm);
margin: 2px 6px;
}
.el-tag {
border-radius: var(--ch-radius-pill);
padding: 0 10px;
}
.el-alert {
border-radius: var(--ch-radius-md);
}
.el-descriptions__body {
border-radius: var(--ch-radius-md);
overflow: hidden;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
border-radius: 0;
}
.el-radio-group,
.el-radio-button:first-child .el-radio-button__inner {
border-radius: var(--ch-radius-sm) 0 0 var(--ch-radius-sm);
}
.el-radio-button:last-child .el-radio-button__inner {
border-radius: 0 var(--ch-radius-sm) var(--ch-radius-sm) 0;
}
.el-radio-button__inner {
border-radius: 0;
}
.el-checkbox__inner,
.el-switch__core,
.el-progress-bar__outer,
.el-progress-bar__inner {
border-radius: var(--ch-radius-pill);
}
.el-tabs__item {
border-radius: var(--ch-radius-sm) var(--ch-radius-sm) 0 0;
}
.el-pagination button,
.el-pager li,
.el-pagination .el-input__wrapper {
border-radius: var(--ch-radius-sm);
}
.el-pagination.is-background .el-pager li {
border-radius: var(--ch-radius-sm);
}
.el-table {
border-radius: var(--ch-radius-md);
--el-table-border-color: var(--ch-border);
}
.el-table th.el-table__cell {
background: #f7f9fc;
}
.el-table__inner-wrapper::before {
display: none;
}
.el-table--border .el-table__inner-wrapper,
.el-table--border {
border-radius: var(--ch-radius-md);
}
.el-empty__image {
border-radius: var(--ch-radius-md);
}
.el-form-item__label {
font-weight: 500;
}
.el-upload-dragger,
.el-upload-list__item {
border-radius: var(--ch-radius-md);
}
.el-step__icon,
.el-collapse,
.el-collapse-item__header {
border-radius: var(--ch-radius-sm);
}
.el-menu-item,
.el-sub-menu__title {
border-radius: var(--ch-radius-sm);
}
* {
@@ -28,7 +257,7 @@ body {
}
.page {
padding: 16px 20px 24px;
padding: 18px 22px 28px;
}
.page-header {
@@ -67,9 +296,18 @@ body {
.stat-card {
background: #fff;
border-radius: var(--ch-card-radius);
padding: 16px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-lg);
padding: 18px;
border: none;
box-shadow: var(--ch-shadow-sm);
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--ch-shadow-md);
}
.stat-label {
+3 -26
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
formatDate,
formatDeadline,
relativeTime,
type DashboardSummary,
} from '@classhelper/shared';
@@ -32,7 +31,7 @@ const statCards = computed(() => {
{
label: '作业总数',
value: data?.homeworkCount ?? 0,
hint: `待截止/待完成 ${data?.pendingHomeworkCount ?? 0}`,
hint: `未完成/待完成 ${data?.pendingHomeworkCount ?? 0}`,
},
{ label: '通知总数', value: data?.notificationCount ?? 0, hint: `未读 ${unreadCount.value}` },
{ label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
@@ -102,7 +101,7 @@ function openNotification(item: NotificationDto): void {
</div>
<el-row :gutter="12" class="mt-16">
<el-col :xs="24" :md="12">
<el-col :xs="24" :md="24">
<el-card shadow="never">
<template #header>
<div class="card-header">
@@ -127,25 +126,6 @@ function openNotification(item: NotificationDto): void {
</ul>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>即将截止的作业</span>
<el-button link type="primary" @click="go('homeworks')">去发布</el-button>
</div>
</template>
<el-empty v-if="(summary?.upcomingDeadlines ?? []).length === 0" description="暂无待截止作业" />
<ul v-else class="list">
<li v-for="item in summary?.upcomingDeadlines" :key="item.id" class="list-item">
<el-tag size="small" effect="plain">{{ item.course?.name ?? '未关联课程' }}</el-tag>
<span class="list-title">{{ item.title }}</span>
<span class="text-muted">{{ formatDeadline(item.dueAt) }}</span>
</li>
</ul>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="mt-16 table-card">
@@ -165,9 +145,6 @@ function openNotification(item: NotificationDto): void {
<span>{{ row.completedCount ?? 0 }} 人已完成</span>
</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
</el-table-column>
<el-table-column label="发布时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
+1 -29
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
formatDate,
formatDeadline,
isOverdue,
truncate,
type ClassDto,
type CourseDto,
@@ -76,7 +74,6 @@ const form = reactive({
title: '',
content: '',
attachmentUrl: '',
dueAt: '',
});
const rules: FormRules = {
@@ -92,7 +89,6 @@ function openCreate(): void {
form.title = '';
form.content = '';
form.attachmentUrl = '';
form.dueAt = '';
formVisible.value = true;
}
@@ -103,7 +99,6 @@ function openEdit(row: HomeworkDto): void {
form.title = row.title;
form.content = row.content;
form.attachmentUrl = row.attachmentUrl ?? '';
form.dueAt = row.dueAt ? row.dueAt.slice(0, 16) : '';
formVisible.value = true;
}
@@ -117,7 +112,6 @@ async function submitForm(): Promise<void> {
title: form.title.trim(),
content: form.content,
attachmentUrl: form.attachmentUrl.trim() || null,
dueAt: form.dueAt ? new Date(form.dueAt).toISOString() : null,
};
if (editingId.value) {
@@ -207,16 +201,6 @@ onUnmounted(() => {
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">
<span :class="{ 'text-danger': isOverdue(row.dueAt) }">
{{ formatDate(row.dueAt, true) || '不限' }}
</span>
</template>
</el-table-column>
<el-table-column label="剩余" width="130">
<template #default="{ row }">{{ formatDeadline(row.dueAt) }}</template>
</el-table-column>
<el-table-column label="已完成" width="100">
<template #default="{ row }">{{ row.completedCount ?? 0 }} 人</template>
</el-table-column>
@@ -254,15 +238,6 @@ onUnmounted(() => {
<el-form-item label="附件链接">
<el-input v-model="form.attachmentUrl" placeholder="https://..." />
</el-form-item>
<el-form-item label="截止时间">
<el-date-picker
v-model="form.dueAt"
type="datetime"
placeholder="选择截止时间"
value-format="YYYY-MM-DDTHH:mm"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
@@ -275,9 +250,6 @@ onUnmounted(() => {
<el-descriptions :column="detailColumns" border size="small">
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDate(current.dueAt, true) || '不限' }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDate(current.createdAt, true) }}
</el-descriptions-item>