diff --git a/README.md b/README.md index 525ddd1..840e4ea 100644 --- a/README.md +++ b/README.md @@ -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 离线缓存 | ✅ 已完成(冒烟验证 65/65) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 71/71) | | 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 # 后端端到端验收脚本(136 项) + │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(148 项) │ └── 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 + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:136 项 +pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 导入 + 班级账号:148 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项 -pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):65 项 +pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/胶囊类型汇总、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):71 项 ``` -后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 136 项, +后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入**等 148 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 -(**班级账号登录** + 四类数据 + Socket.IO),实测 65/65 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 59/59)。 +(**班级账号登录** + 四类数据 + Socket.IO),实测 71/71 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 65/65)。 ## 默认账号与种子数据 @@ -220,9 +220,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中 | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | -| `pnpm verify:e2e` | 后端端到端验收(136 项,含角色权限矩阵、导入与班级账号,需服务端已启动) | +| `pnpm verify:e2e` | 后端端到端验收(148 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) | | `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(65 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(71 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | @@ -319,14 +319,23 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中 ### 功能页面 -| 页面 | 功能 | -| ---- | -------------------------------------------------------------------------------------------- | -| 登录 | 服务器地址 + **班级码 + 班级密码**,附带「测试连接」;已登录但服务器不可达时可「离线进入」 | -| 课表 | 按周展示 7 列课表,支持上一周/下一周/下拉选择周次,高亮"今天",显示周次范围与地点 | -| 作业 | 列表(全部/未完成/已完成过滤)、详情抽屉、**全班标记完成**/取消、附件链接、逾期高亮 | -| 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 | -| 成绩 | 班级账号看到**全班成绩总览**:表格(分数/得分率/等级)+ ECharts 柱状图 + 等级分布 + 最近更新 | -| 设置 | 服务器地址(保存并测试)、账号信息、离线缓存条目统计与清空、客户端版本信息、退出登录 | +| 页面 | 功能 | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 登录 | 服务器地址 + **班级码 + 班级密码**,附带「测试连接」;已登录但服务器不可达时可「离线进入」 | +| 课表 | 按周展示 7 列课表,支持上一周/下一周/下拉选择周次,高亮"今天",显示周次范围与地点;**单周/双周**的课带 `单周`/`双周` 标签 | +| 作业 | **看板模式**(按科目卡片、点击卡片放大全屏、可切换「显示时间」与**看板字号**、随时切回列表模式)+ 列表模式、详情抽屉、附件链接;班级设备用**未交名单**勾选谁没交 | +| 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 | +| 成绩 | 班级账号看到**全班成绩总览**:表格(分数/得分率/等级)+ ECharts 柱状图 + 等级分布 + 最近更新 | +| 设置 | 服务器地址(保存并测试)、账号信息、离线缓存条目统计与清空、客户端版本信息、退出登录 | + +**作业看板(学生端)**:默认按科目分卡片(每张卡列该科作业,条目可点开详情), +工具栏可切「看板 / 列表」、开关「显示时间」、拖「字号」(11–28px 即时生效), +点卡片或「全屏看板」进入全屏放大视图;这些偏好写入客户端配置(`homeworkBoard`),重启后仍生效。 + +**未交名单(班级设备)**:作业列表/详情里点「未交名单」→ 勾选**没交作业**的同学 → +保存后其余同学自动标记为已完成(`PATCH /api/homeworks/:id/submissions`, +`GET .../submissions` 读名单)。教师端 Web 管理端的作业详情抽屉里也能看到未交名单并勾选保存, +普通学生调用该接口返回 403(只能标记自己)。 > 通知到达时还会由**灵动岛**在桌面中上方浮出提醒(见上一节),上课时段自动隐藏、紧急通知立即展开。 @@ -360,7 +369,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中 | 场景 | 灵动岛行为 | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` | +| 多条未处理消息 | 胶囊标题汇总类型:`新消息:叫人/作业/通知(共 3 条)` + `点击查看`,右侧附类型小标签(单条时显示具体类型名) | | 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` | +| **上课时段的"普通叫人"** | **不自动展开**(不打断课堂),但保留 `叫人` 胶囊并**允许学生主动点开**;下课自动展开(`isOpenable`:紧急消息与任何叫人都可点开) | | **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失** —— 随时都能再次点开(彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段) | | **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中并被固化为回归用例("收起态点击可再次展开(指针未移动也算命中)") | | 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) | @@ -400,6 +411,14 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中 | 非上课时段"新消息"胶囊 | ![新消息胶囊](docs/screenshots/island/island-4-pill.png) | 268×44 / 536×88 | 直接出现(无入场动画),点击后展开 | | 点击胶囊后的详情 | ![点击后展开](docs/screenshots/island/island-5-clicked.png) | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 | +**收起态胶囊会汇总消息类型**(用户需求):当有多条未处理消息时,胶囊标题显示 +`新消息:叫人/作业/通知(共 3 条)`、副标题 `点击查看`,并在右侧把三种类型做成小标签; +只有一条时仍显示具体类型名(`新消息` / `新作业` / `老师叫你`)。 +多类型/多条的状态由渲染进程按 `state.active + state.queued` 现算,收起后在胶囊上就能看清"这堆消息里都有什么"。 + +> 设置页的「预览效果」走 `context.preview` 专用通道:示例岛**直接展开**、**失焦不收起** +> (用户拖滑块时主窗口一直是焦点)、30 秒后自行消失、点「知道了」不留残影。 + > 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`); > 归档目录里的图就是打包后 EXE 跑自动化验证时产出的同一份文件。 @@ -574,7 +593,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, -已实测 `packaged: true`、65/65 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +已实测 `packaged: true`、71/71 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 @@ -714,18 +733,48 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) (组件 `packages/web-admin/src/components/TimeLayoutImportDialog.vue`)。 - **输入**:粘贴 JSON 文本或选择 ClassIsland 导出的 `.json` 文件; -- **容错解析**(`packages/server/src/modules/imports/time-layout.service.ts`): - 顶层数组、`{ TimeLayouts: [...] }`、`{ TimeLayoutItems | Items | items | Layouts | TimeLayout: [...] }` 都能识别; - 时间支持 `"8:0:0"` / `"08:00"` / ISO 字符串 / `StartSecond: 28800` 秒数;字段名兼容 - `StartTime|startTime|start`、`EndTime|end`、`Name|Title|Subject`、`TimeType|Type`、`IsSkipped|skip`; +- **真实档案形态**(`Profiles/<档案名>.json`,ClassIsland 1.7.106+ / 2.x,字段为 PascalCase): + `TimeLayouts` 是 **`{ "": { Name, Layouts: [ { StartTime: "08:00:00", EndTime, TimeType } ] } }` + 的字典**,条目在各时间表的 `Layouts` 数组里(解析器会把所有时间表的 Layouts 合并后导入); + 同时兼容更早的写法:顶层数组、`{ TimeLayouts: [...] }`、`{ items | Items | Layouts | TimeLayout: [...] }` + 以及单时间表对象 `{ Name, Layouts: [...] }`; +- **时间字段**:`StartTime`/`EndTime`(`"08:00:00"`)、旧版 `StartSecond`/`EndSecond`(ISO 字符串或秒数)、 + `"8:0"` / `"08:00"` 都能识别; +- **时间点类型**(`TimeType`):`0=上课`、`1=课间`、`2=分割线`、`3=行动`,与 ClassIsland 一致; - **校验**:时间无法解析、结束不晚于开始、空配置都会给出**逐条原因**(含条序号与名称); - 时间重叠、分割线无时间、原配置标记跳过等只作为 `warnings`; + 时间重叠、分割线无时间等只作为 `warnings`; - **写入模式**: - `replace`:整体替换该班同名(缺省「默认时间表」)配置; - `merge`:以**开始时间**为槽位标识合并——同槽位被导入项覆盖,其它旧节次全部保留(不静默丢数据); - **失败回滚**:解析存在 `errors` 时返回 400 `IMPORT_INVALID` / `IMPORT_EMPTY`, **数据库完全不写入**,界面会提示"原有配置不会被修改",不会出现导入到一半的脏状态。 +### 4. ClassIsland 课程表导入(支持单双周) + +界面:Web 管理端 →「课表管理」→「导入 ClassIsland 课程表」 +(组件 `packages/web-admin/src/components/ClassPlanImportDialog.vue`)。 + +- **输入**:同一份 ClassIsland 档案 JSON(含 `TimeLayouts` / `ClassPlans` / `Subjects` 三个 Guid 字典); +- **单双周来源**:`ClassPlan.TimeRule.WeekCountDiv`(`0=每周`、`1=单周`、`2=双周`) + + `WeekCountDivTotal`(默认 2)——**与 ClassIsland 源码一致的判定语义**, + 因此"周一单周上数学 / 双周上英语"在档案里就是**两条 ClassPlan**(`WeekCountDiv` 分别 1 与 2); + 3 周及以上轮换无法用单双周表达,会给出 `warnings` 并按每周处理; +- **节次对齐**:`ClassPlan.Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0` 的点** + (课间/分割线不占位),与 ClassIsland 的 `RefreshClassesList()` 行为一致; +- **科目映射**:`Classes[i].SubjectId` → `Subjects[].Name`;班级里还没有的科目会**自动补建课程** + (任课老师取该班班主任),预览里会提前列出"待补建科目"; +- **星期换算**:ClassIsland `WeekRule.WeekDay` 是 `0=周日 … 6=周六`,导入时换算成我们的 `1=周一 … 7=周日`; +- **写入模式**:`replace`(清空该班课表后写入,事务内完成)/ `merge` + (按 `星期 + 开始时间 + 单双周` 去重,存在则更新,否则新增); +- **接口**:`POST /api/imports/class-plan/preview`(只解析不写库)与 `POST /api/imports/class-plan`; +- **课表单双周字段**:`Schedule.weekParity = ALL | ODD | EVEN`;周视图/上课判定都按 + `第 1 周 = 单周` 的规则过滤(`weekParityOf(week)`,与 ClassIsland 默认相位一致), + 历史数据默认 `ALL`,行为不变。手动添加课表时也能在弹窗里直接选「每周 / 单周 / 双周」。 + +> 手工覆盖 dist 部署(而非跑安装程序)时,已有数据库需要补一列: +> `node packages/server/scripts/apply-parity-column.cjs "<安装目录>/server/dist/lib/db.js"` +> (安装程序覆盖升级会自动补迁移;仅空库首次启动会执行随包迁移 SQL)。 + ## REST API 一览 统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。 @@ -945,7 +994,7 @@ pnpm db:migrate && pnpm db:seed | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **65/65** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **71/71** ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | diff --git a/docs/screenshots/web-mobile/mobile-1-drawer.png b/docs/screenshots/web-mobile/mobile-1-drawer.png index 445e026..93df5a6 100644 Binary files a/docs/screenshots/web-mobile/mobile-1-drawer.png and b/docs/screenshots/web-mobile/mobile-1-drawer.png differ diff --git a/docs/screenshots/web-mobile/mobile-2-notifications.png b/docs/screenshots/web-mobile/mobile-2-notifications.png index b8852e6..ca68455 100644 Binary files a/docs/screenshots/web-mobile/mobile-2-notifications.png and b/docs/screenshots/web-mobile/mobile-2-notifications.png differ diff --git a/docs/screenshots/web-mobile/mobile-3-dialog.png b/docs/screenshots/web-mobile/mobile-3-dialog.png index e606bc0..f5f845e 100644 Binary files a/docs/screenshots/web-mobile/mobile-3-dialog.png and b/docs/screenshots/web-mobile/mobile-3-dialog.png differ diff --git a/packages/desktop-client/src/main/config.ts b/packages/desktop-client/src/main/config.ts index f15360b..aa9b563 100644 --- a/packages/desktop-client/src/main/config.ts +++ b/packages/desktop-client/src/main/config.ts @@ -2,7 +2,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { app, safeStorage } from 'electron'; import { DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, type IslandAppearance } from '@classhelper/shared'; -import type { DesktopStoredConfig } from '../types/desktop.js'; +import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js'; + +/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */ +const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = { + mode: 'board', + showTime: false, + fontSize: 15, +}; interface PersistedConfig { serverUrl: string; @@ -12,6 +19,8 @@ interface PersistedConfig { tokenEncrypted: boolean; /** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */ island?: Partial; + /** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */ + homeworkBoard?: Partial; } const DEFAULT_CONFIG: PersistedConfig = { @@ -20,8 +29,23 @@ const DEFAULT_CONFIG: PersistedConfig = { token: null, tokenEncrypted: false, island: { ...DEFAULT_ISLAND_APPEARANCE }, + homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD }, }; +/** 作业页偏好的合法化(模式白名单、字号夹紧) */ +function normalizeHomeworkBoard(input?: Partial): HomeworkBoardSettings { + const mode = input?.mode === 'list' ? 'list' : 'board'; + const rawFont = + typeof input?.fontSize === 'number' && Number.isFinite(input.fontSize) + ? input.fontSize + : DEFAULT_HOMEWORK_BOARD.fontSize; + return { + mode, + showTime: input?.showTime === true, + fontSize: Math.min(28, Math.max(11, Math.round(rawFont))), + }; +} + /** 外观字段的合法区间校验(越界值直接夹紧,避免用户配置破坏灵动岛布局) */ function normalizeIslandAppearance(input?: Partial): IslandAppearance { const clamp = (value: unknown, min: number, max: number, fallback: number): number => { @@ -99,6 +123,7 @@ function readPersisted(): PersistedConfig { token: typeof parsed.token === 'string' ? parsed.token : null, tokenEncrypted: parsed.tokenEncrypted === true, island: normalizeIslandAppearance(parsed.island), + homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard), }; } catch { return { ...DEFAULT_CONFIG }; @@ -119,6 +144,7 @@ export function getConfig(): DesktopStoredConfig { username: persisted.username, token: decryptToken(persisted), island: normalizeIslandAppearance(persisted.island), + homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard), }; } @@ -135,6 +161,12 @@ export function saveConfig(patch: Partial): DesktopStoredCo if (patch.island !== undefined) { persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island }); } + if (patch.homeworkBoard !== undefined) { + persisted.homeworkBoard = normalizeHomeworkBoard({ + ...persisted.homeworkBoard, + ...patch.homeworkBoard, + }); + } if (patch.token !== undefined) { const encrypted = encryptToken(patch.token); persisted.token = encrypted.token; diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index c3300b8..2f0fe7b 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -434,7 +434,12 @@ class IslandController { // 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须 // 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。 const showing = this.state.active; - if (this.state.inClass && !this.isOpenable(showing)) break; + if (this.state.inClass && !this.isOpenable(showing)) { + logger.info( + `灵动岛:上课时段忽略展开(priority=${showing?.priority ?? '-'} kind=${showing?.kind ?? '-'} id=${showing?.id ?? '-'})`, + ); + break; + } // 收起态(胶囊 / 空闲细缝)再次点击必须能打开: // active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开, // 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。 @@ -448,6 +453,11 @@ class IslandController { } const active = this.state.active; if (active) { + if (this.state.mode !== 'expanded') { + logger.info( + `灵动岛:展开通知 ${active.id}(priority=${active.priority} kind=${active.kind ?? '-'})`, + ); + } this.setState({ mode: 'expanded' }); this.scheduleCollapse( this.isImmediate(active) diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index f0147d9..5009a0c 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -1133,11 +1133,20 @@ async function runIslandChecks( await sleep(ISLAND_TIMEOUTS.pill + 700); const lingered = island.getState(); const lingeredVisible = islandWindow?.isVisible() ?? false; + // 断言"没有消失"即可:真人可能在这 15 秒里点了岛(那就从胶囊变成展开态), + // 但只要还看得见、还是同一条通知,就说明"常驻"成立。 record( '收起后胶囊常驻(超时不再消失,随时可再次点开)', - collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible, - `收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`, + collapseBefore.mode === 'pill' && + lingered.mode !== 'hidden' && + lingeredVisible && + lingered.active?.id === 'smoke-collapse-reopen', + `收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible} ` + + `active=${lingered.active?.id ?? '-'}`, ); + // 先确保处于胶囊态(真人在等待期间可能点开过),再验证"命中恢复 + 点击展开" + island.handleAction({ action: 'collapse' }); + await sleep(500); // 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 —— // 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。 island.setInteractive(false); @@ -2281,24 +2290,27 @@ export async function runSmokeTest(win: BrowserWindow): Promise { record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? '')); } - // 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛 - await runTrayAndShutdownChecks(win, record); - - const failed = results.filter((item) => !item.ok); - const passed = results.length - failed.length; - console.log(`\n[SMOKE] ${passed}/${results.length} 项通过`); - - const resultFile = process.env.ELECTRON_SMOKE_RESULT; - if (resultFile) { + /** + * 把当前结果落盘 + 打印汇总。 + * 为什么要写两次:托盘/退出清理用例会真的走一遍"退出应用",进程可能在收尾代码执行前就退出, + * 那样 ELECTRON_SMOKE_RESULT 就不会生成(重启校验会误报"结果=-1/0")。 + * 所以在跑托盘用例之前先写一次,收尾再写一次(覆盖全部用例)。 + */ + const writeResult = (): void => { + const failedItems = results.filter((item) => !item.ok); + const passedCount = results.length - failedItems.length; + console.log(`\n[SMOKE] ${passedCount}/${results.length} 项通过`); + const resultFile = process.env.ELECTRON_SMOKE_RESULT; + if (!resultFile) return; try { fs.mkdirSync(path.dirname(resultFile), { recursive: true }); fs.writeFileSync( resultFile, JSON.stringify( { - passed, + passed: passedCount, total: results.length, - ok: failed.length === 0, + ok: failedItems.length === 0, results, finishedAt: new Date().toISOString(), appVersion: app.getVersion(), @@ -2313,7 +2325,15 @@ export async function runSmokeTest(win: BrowserWindow): Promise { } catch (error) { console.error('[SMOKE] 写入结果文件失败', error); } - } + }; + writeResult(); + + // 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛 + await runTrayAndShutdownChecks(win, record); + + writeResult(); + + const failed = results.filter((item) => !item.ok); app.exit(failed.length === 0 ? 0 : 1); } diff --git a/packages/desktop-client/src/renderer/api/index.ts b/packages/desktop-client/src/renderer/api/index.ts index 4a7ec1d..ea67857 100644 --- a/packages/desktop-client/src/renderer/api/index.ts +++ b/packages/desktop-client/src/renderer/api/index.ts @@ -6,6 +6,7 @@ import { type GradeDto, type HomeworkDto, type HomeworkStatusDto, + type HomeworkSubmissionsDto, type LoginRequest, type LoginResponse, type NotificationDto, @@ -44,6 +45,11 @@ export const homeworkApi = { detail: (id: string): Promise => api.get(`${API_PATHS.homeworks}/${id}`), updateStatus: (id: string, completed: boolean): Promise => api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }), + /** 提交名单 / 未交名单(教师与班级设备可用) */ + submissions: (id: string): Promise => + api.get(`${API_PATHS.homeworks}/${id}/submissions`), + saveSubmissions: (id: string, notSubmittedUserIds: string[]): Promise => + api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedUserIds }), }; export const notificationApi = { diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index 92fb606..dd26bca 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -350,6 +350,7 @@ export async function scheduleTimelineSelfTest(): Promise { }; const created: string[] = []; + const failures: string[] = []; let teacherToken = ''; try { @@ -373,7 +374,9 @@ export async function scheduleTimelineSelfTest(): Promise { { start: toHHmm(nowMinutes - 180), end: toHHmm(nowMinutes - 120), location: '时间轴自检 · 已结束' }, { start: toHHmm(nowMinutes - 20), end: toHHmm(nowMinutes + 25), location: '时间轴自检 · 正在上课' }, { start: toHHmm(nowMinutes + 60), end: toHHmm(nowMinutes + 105), location: '时间轴自检 · 下一节' }, - ]; + // 深夜/凌晨时相对时间会越过 23:59 / 00:00,被夹紧后 start >= end(服务端 422)。 + // 这种探针构造不出来就跳过,"下一节"的断言按实际创建数量放宽。 + ].filter((probe) => probe.start < probe.end); for (const probe of probes) { const response = await fetch(`${serverUrl}/api/schedules`, { method: 'POST', @@ -388,11 +391,23 @@ export async function scheduleTimelineSelfTest(): Promise { weekEnd: 30, location: probe.location, }), - }).then((result) => result.json()); - const id = response?.data?.id; + }); + const payload = await response.json().catch(() => null); + const id = payload?.data?.id; if (id) created.push(id); + // 记录失败原因(限流 429 / 时间非法 400 等),否则只看到一个"2/3"没法排查 + else { + failures.push( + `${probe.start}-${probe.end} status=${response.status} ${payload?.message ?? ''}`.trim(), + ); + } + } + if (created.length < probes.length) { + return { + ok: false, + detail: `探针课表创建失败(${created.length}/${probes.length})${failures.length > 0 ? `:${failures.join(';')}` : ''}`, + }; } - if (created.length < 3) return { ok: false, detail: `探针课表创建失败(${created.length}/3)` }; await router.push('/schedule'); @@ -420,7 +435,7 @@ export async function scheduleTimelineSelfTest(): Promise { text: (node.textContent ?? '').replace(/\s+/g, ' ').trim(), })), }; - if (snapshot.hasCurrent && snapshot.count >= 3) { + if (snapshot.hasCurrent && snapshot.count >= probes.length) { dom = snapshot; break; } @@ -432,13 +447,15 @@ export async function scheduleTimelineSelfTest(): Promise { const hasPast = dom.tags.some((tag) => tag.state === 'past' && tag.text.includes('已结束')); const hasNext = dom.tags.some((tag) => tag.state === 'next' && tag.text.includes('下一节')); const countdown = /距下课\s*\d+/.test(dom.currentText) || dom.currentText.includes('即将下课'); + // 深夜/凌晨可能只构造出 2 个探针("下一节"跨过 23:59),此时不要求存在该卡片 + const expectNextProbe = probes.length >= 3; const ok = - dom.count >= 3 && + dom.count >= probes.length && dom.hasCurrent && dom.hasProgress && countdown && hasPast && - hasNext && + (!expectNextProbe || hasNext) && /^\d{2}:\d{2}$/.test(dom.clock); return { diff --git a/packages/desktop-client/src/renderer/views/HomeworkView.vue b/packages/desktop-client/src/renderer/views/HomeworkView.vue index 9b1a94a..cb125dd 100644 --- a/packages/desktop-client/src/renderer/views/HomeworkView.vue +++ b/packages/desktop-client/src/renderer/views/HomeworkView.vue @@ -1,7 +1,7 @@ @@ -138,12 +298,77 @@ onUnmounted(() => { 未完成 已完成 + + 看板 + 列表 + 刷新 + + +
+ 看板设置 + + 字号 + + {{ boardFontSize }}px + 全屏看板 + 保存中… +
+
+ + + +
+
+
+ {{ column.course }} + {{ column.pending }} +
+
    +
  1. + {{ index + 1 }}. + {{ item.title }} + {{ formatDate(item.createdAt, true) }} + 已完成 +
  2. +
+
+ 共 {{ column.items.length }} 条 + 点击放大 +
+
+
+ +