diff --git a/.gitignore b/.gitignore
index 1185f16..5e5db46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,7 +39,7 @@ eslint-report.json
.DS_Store
Thumbs.db
-# ²ο¼ÓõĵÚÈý·½ԴÂë°ü£¨Éè¼Æ token ÒÑÌáȡµ½ docs/winisland-design-tokens.md£©
+# 设计参考压缩包(令牌见 docs/dev/winisland-design-tokens.md)
WinIsland-master.zip
releases/
@@ -51,3 +51,5 @@ packages/server/prisma/database-settings.json
# 文档站构建产物(pnpm docs:build 生成,由 GitHub Pages 发布;见 docs/)
docs/_site/
+
+packages/data/courseware/
diff --git a/AGENTS.md b/AGENTS.md
index fde2d4e..b383a9f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,1719 +1,131 @@
# AGENTS.md — ClassHelper(Class Helper)
-本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
+本文件供本仓库的 AI 编码代理使用。先读这里,再改代码。当前工作目录为 `F:\ClassHelper`。
+现行规则按工作区源码维护;产品说明见 `docs/app/`,开发契约见 `docs/dev/`,部署见 `docs/management/`。
+查证路径见 `docs/reference.md`。文档与代码冲突时,阅读实现及其校验规则后同步修正文档,不沿用历史报告中的结论。
-**2026-10-16 更新**:当前仓库位于 `F:\ClassHelper`,F 盘可用。ClassIsland 联动插件、设备令牌、提醒通道与镜像接口已退役;离线课表 JSON 导入和界面设计参考保留。下文历史联动部署记录不再适用。
-客户端维护只走 `/api/clients/*`,页面为 `ClientsView.vue`;默认权限按数据库中对应角色的默认组生效。
-**2026-10-17 本批功能约定**:课表业务真值为 `ClassPlan / ClassPlanEntry / TimeLayoutItem`,
-旧 `Schedule` 仅保留迁移审计来源,`/api/schedules/*` 是新模型的兼容门面。统一解析器在
-`lib/class-plan.ts`,不得在客户端另写在线启用规则。自定义岛方案在本机 `config.json.islandLayout`,
-默认 `layout=fixed`;新尺寸上报仅在 custom 模式执行。详见 `docs/dev/20261017-implementation.md`。
+## 1. 项目与目录
-**2026-10-17 更新**:日常 `verify:desktop` / `verify:packaged` 默认是后台快速检查,不创建可见灵动岛或托盘;
-完整桌面动画、透明度与截图验收必须显式加 `--visual`(`pnpm verify:desktop:visual`)。
-下文关于置顶验证窗口、鼠标穿透与截图时序的历史约定仅适用于视觉模式。不要为了日常修改反复占用用户桌面。
-联网冒烟显式启用,使用临时教师和班级;禁止回退到真实班级、重置其密码或向其发送测试通知。
+pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界面文案及提交描述使用中文。
+远端为 `LoveChengke/ClassHelper`,默认分支 `master`。
-本次 PowerShell 会话的 PATH 不含 pnpm;受限工具进程读取启动器可能需要工具级提权,不能据此判断未安装。
-本次 PowerShell 会话的 PATH 不含 pnpm:需要先把 `C:\Users\hck16\AppData\Local\deepseek-harness\bin`
-加到 PATH 前面,再按下文设置 `DSH_NODE` 和 `DSH_PNPM_BIN`。Git Bash 外层找不到 pnpm 时可执行:
+| 目录 | 用途 |
+| -------------------------- | --------------------------------------------------------- |
+| `packages/shared/src/` | 三端共享类型、权限、常量、动效、岛方案与课件契约 |
+| `packages/server/` | Express、Prisma、Socket.IO;API 前缀 `/api` |
+| `packages/web-admin/` | Vue Web 管理端,PWA |
+| `packages/desktop-client/` | Electron 主进程、preload、主窗口与独立灵动岛渲染进程 |
+| `scripts/` | 版本、构建、验证、打包、图标与 Pages 脚本 |
+| `deploy/` | Windows/Linux/Docker 部署与运维模板 |
+| `docs/` | docfx 文档;`images/` 为展示图,`screenshots/` 为回归截图 |
+| `website/` | 纯静态官网与下载页 |
+| `releases/` | 按组件和版本存放本地交付物,gitignored |
-```bash
-export DSH_NODE='D:\Node.js\node.exe'
-export DSH_PNPM_BIN='C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
-node "C:/Users/hck16/AppData/Local/node/corepack/v1/pnpm/11.8.0/bin/pnpm.cjs" typecheck
+不要在文档中手抄模块、表、权限键、测试用例的总数;分别查注册表、schema、共享目录与本次验证输出。
+
+## 2. 服务端与数据边界
+
+- 模块由 `packages/server/src/modules/registry.ts` 注册;新增或下线功能改注册表,不另改 `app.ts` 挂载。
+- 路由用 `middleware/validate.ts` 与 zod;响应复用 `lib/http.ts`,错误交给统一中间件。
+- 学生是 `Student` 名单记录,没有账号、角色或密码。教师/管理员存 `User`;班级登录读取 `Class.code/passwordHash`,会话角色 `CLASS_DEVICE`,主体 id 为班级 id。
+- 任课关系只在 `Course(classId, name, teacherId)`。班主任不自动获得别的科目的作业、成绩写权限,统一走 `assertCanManageSubjectContent`。班级端可录作业,不可写成绩;员工还须对应功能权限。
+- 功能权限取 `lib/permissions.ts` 的 `loadEffectivePermissions()`:已分组取并集;未分组使用数据库对应角色的可编辑默认组;管理员永远保留 `permission:manage/system:manage`;班级端为空集。
+- 岗位组不新增 `User.role`。班主任/教师/级长/部长仍为 TEACHER;范围在 `PermissionGroup.scope/scopeValue`,由 `lib/permission-scope.ts` 收口,空年级/部范围拒绝。设备写权限只合并含对应键的分组范围,范围不替代科目授权。
+- 只有一份 `prisma/schema.prisma`,切库改 provider;保持 SQLite/MySQL 通用,不加 Prisma enum 或 `@db.*`。模型变动同步迁移、`lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 与旧库兼容逻辑。
+- `deleteTeacher` 必须保护班主任、课程任课、发布的作业/通知和所属课件,避免级联删除业务内容。
+- 作业和通知按本地 `assignDate` 归类,使用 `dayKeyLocal/isDayKey/shiftDayKey`,不用 UTC 日期切片。
+- 批量通知/作业逐班返回结果;跨班作业按科目名匹配既有课程,找不到则该班失败,不自动把发布者设为任课老师。
+- 两个登录接口共用登录限流。附件链接校验 scheme;表格文件上限 8 MB、JSON 请求体 12 MB,超限映射 413。
+- AI 助手只解析待确认动作,前端确认后调既有发布接口;参数重新校验,Key 加密存储且不回传。自动化用 mock,不访问外网。
+
+## 3. 课表、周次、成绩与学年
+
+- 课表真值是 `ClassPlan/ClassPlanEntry/TimeLayoutItem`;旧 `Schedule` 与 `TimeLayout.items` 保留迁移审计用途,不双写或回读复活旧业务。
+- `/api/schedules/*` 是新模型兼容门面。启用解析统一走 `lib/class-plan.ts`,在线客户端不得另写启用优先级。
+- 时间点有上课、课间、分割线、行动四种;课程显式引用时间点外键。排序不能靠数组下标代替关联。
+- 支持 1–9 周轮换、群组、预定及临时层;完整档案导入走 `profile-import.service.ts`,不经过单双周有损格式。
+- 全校时间表写入用 `timetable:manage`;班级日课表写入还须班主任/管理员断言。离线整套课表导入可建立班级自有作息,不把 JSON 的 TeacherName 当授权。
+- 周次统一 `loadTermContext/resolveCurrentWeek`:班级逐周区间优先于全校区间,缺省再线性推算。开学日会参考班级设置、第 1 周区间、全校设置与环境兜底,详见 `lib/term.ts`。
+- 全校周数上限存 `term.maxWeeks`,默认 30、范围 1–60;服务端 `getMaxTermWeeks()`,shared 做归一化与离线兜底。班级周数不超全校上限。
+- 关闭个人成绩查询后,班级端仍可看服务端集体聚合;个人明细、原始全班成绩和排名拒绝读取。稀疏样本及补集抑制,不下发原始成绩后在客户端聚合。
+- 总分开关属于 `Exam`;排名同学生同科取最高分,并列排名为 1、2、2、4。客户端只展示,不能修改成绩查询或考试配置。
+- 年度升级由 `term.promotionAt` 配置,`PromotionRun.year` 保证幂等,升级前保存快照。运行期到点自动执行,启动漏跑先预览补跑;开发机启动不得擅自升级业务数据。
+- 高三归档,其余升一级;新班仅建骨架,不复制班主任/任课关系。转出过滤复用 `lib/students.ts`,学号不复用,归档页保留历史与撤回转出。
+
+## 4. 客户端、灵动岛与课件
+
+- preload 仅暴露 contextBridge 白名单,不暴露 ipcRenderer;跨桥接传纯数据,不传 Vue Proxy。契约写在 `src/types/desktop.d.ts`。
+- 保留 CSP、导航同源校验与弹窗限制。安装包不能含凭据,保留 `check-bundle-secrets` 构建门禁。
+- 离线缓存按服务端与班级隔离,缓存故障不应阻断在线读取。退出、切服务器时清理会话状态,设备 `deviceKey` 保持不变,渲染层不能改写它。
+- 设备维护只走 `/api/clients/*`,管理页 `ClientsView.vue`。心跳约 30 秒,90 秒窗口判在线;未登录不报,401/403 退避,切配置重置计时并隔离迟到响应。
+- 远控须 `remote:control`,只允许 shutdown/restart/logoff/lock,客户端立即执行、持久去重并回执。冒烟始终假执行,不得真关机/重启/注销/锁屏。
+- 更新写入口用 `client:update`;服务端缓存包并登记 SHA256,教室机从本服务下载;无哈希拒收,按立即/放学后/空闲策略执行,关闭自动更新不得强装。冒烟不运行安装器。
+- 灵动岛方案保存在本机 `config.json.islandLayout`,默认 `layout=fixed`;`custom` 当前即常驻模式,旧 `residentEnabled` 是兼容字段。新尺寸上报仅在 custom 模式执行。
+- `LayoutIslandApp.vue` 选择经典 `IslandApp.vue` 或 `CustomIslandRuntime.vue`,共用一个原生岛窗口;课件传输状态优先显示 `CoursewareTransfer.vue`。
+- 经典模式:普通通知/作业上课暂存;普通叫人保留可点击胶囊;紧急消息立即展开。自定义常驻模式:消息先提示铃铛数量,用户主动展开;上课也可主动查看。不要把两个模式规则混写。
+- 自定义方案的常态 `rows` 与无消息展开 `expandedRows` 分开编辑。课程、时钟、容器等使用 shared 组件契约;测量自然内容并按工作区约束适配。
+- 保留经典岛连续圆角、投影常驻、触摸输入、失焦宽限与固定窗口几何护栏。不要调用 `setBackgroundMaterial()` 破坏逐像素透明。左右边距仅左/右停靠生效。
+- 消息按 id 去重,通知/叫人双播不得重复;列表布局复用 shared 度量。崩溃恢复保持限流,冒烟不得自重启。
+- 课件权限和任课范围独立校验:拥有文件、文件可见范围与本人授课班级共同约束推送,岗位范围不代替任课授权。
+- 课件最大 512 MB,流式收发、临时文件、SHA256 校验;客户端不自动运行文件,每设备独立回执。JSON 快照只含元数据,完整备份另带文件目录。
+
+## 5. 实时、界面与资源
+
+- 事件名与房间名取 shared 常量。业务用 `emitToClass/emitToSession`,不直接依赖 Socket.IO 实例。
+- 会话房间为 `session:{id}`;教师/管理员 id 为账号 id,班级端为班级 id。`students` 是班级端广播房间,不是个人学生账号房间。
+- 新实时事件同步服务端、Web 订阅、客户端订阅。成绩实时载荷不可泄漏个人成绩。
+- Web 菜单分组在 `config/navigation.ts`,标题/图标/权限取路由;守卫与服务端都检查权限,前端隐藏不能代替鉴权。
+- 侧栏折叠用 CSS 保持 DOM,关怀模式放大字号/控件,不改变几何令牌。使用语义色,动效统一 shared 令牌;图标新增后检查注册表。
+- 图标源是 `build/classhelper.png` 与设计导出的 `.ico`,`pnpm icons` 派生其余资源。ICO 原样透传,客户端兜底 `build/icon.*` 同步保留。
+- Web 品牌图用 `/logo.png`,客户端用 `BRAND_LOGO_URL`,避免 file:// 绝对路径问题。
+- 官网和文档站共用一个 Pages 站点,`scripts/build-pages.mjs` 拼装根站与 `/docs/`;文档不写指向 docs 树外的相对链接,源码用 GitHub 绝对链接。
+- 展示图引用 `docs/images/`,回归图放 `docs/screenshots/`。官网 `.reveal-grad` 不拆逐字节点,动效改 token 表。
+- ClassIsland 联动插件、设备令牌、提醒渠道与镜像接口已退役;保留离线 JSON 导入和设计参考,不恢复在线联动依赖。
+
+## 6. 本机命令与验证
+
+脚本以根/各包 `package.json` 为准;端口:后端 4000,Web dev 5173,客户端 dev 5174,官网 5180,文档 5181。
+先构建 shared 再启动下游。新环境按需执行 install → db:generate → server db:deploy → build:shared;`db:seed/db:reset` 会清业务表,只用于明确选择的演示/隔离库。
+
+本机 Node 路径为 `D:\Node.js\node.exe`。受限终端的 pnpm 可能解析到运行时兜底版本,先看 `Get-Command pnpm` 与 `pnpm --version`。
+仓库锁定 pnpm 11.8.0,可用 `C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs`。
+经 DSH 转发壳时可设置:
+
+```powershell
+$env:PATH = 'C:\Users\hck16\AppData\Local\deepseek-harness\bin;' + $env:PATH
+$env:DSH_NODE = 'D:\Node.js\node.exe'
+$env:DSH_PNPM_BIN = 'C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
+Remove-Item Env:DSH_PNPM -ErrorAction SilentlyContinue
+pnpm --version
```
-外层直接调用用于解决找不到命令;环境变量让内部嵌套的 pnpm 仍使用真实运行时。
-文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
-
----
-
-## 1. 这是什么
-
-班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物:
-
-| 交付物 | 位置 | 形态 |
-| ---------- | ------------------------- | -------------------------------------------------------- |
-| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
-| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
-| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
-| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
-
-核心链路:
-
-```
-教师在 Web 端发布内容
- │ REST API(JWT 鉴权)
- ▼
- 后端服务(Express + Prisma)
- │ 写库 + 按班级房间广播
- ▼
- Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
-
-```
-
-- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
-- 默认分支:`master`
-- 语言:**文档、注释、提交信息、界面文案一律中文**
-
----
-
-## 2. 目录速查
-
-```
-package.json 根脚本(dev / build / db:* / verify:* / dist:* / icons)
-pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白名单,勿随手删条目)
-tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
-eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/
-.prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格
-build/classhelper.{png,ico} 图标源(**设计导出、手工放入**;`pnpm icons` 从它派生其余全部图标,见 §5 第 62 条)
-scripts/
- use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema)
- generate-icons.mjs 启动 Electron 跑 icons/render.cjs(= `pnpm icons`)
- icons/render.cjs 把 classhelper.png 重采样成各端图标(CommonJS,见 §5 第 62 条)
- dist-server.mjs 服务端 + Web 管理端打包(产物落在 releases/server/<版本>/)
- nsis/server-installer.nsi 服务端 NSIS 安装脚本
- verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟
- check-icons.mjs 图标注册表静态门禁(漏注册会静默渲染空白,见 §5 第 53 条)
- version.mjs 版本号单一来源的读写与一致性校验(= `pnpm version:*`,见 §4.4)
- ui-smoke/{run,main.cjs,live-probe.mjs} Web 管理端真实点击回归(Electron 驱动)
- lib/{electron-env,node-runtime}.mjs 启动 Electron / 定位真实 node.exe
-deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / package.runtime.json
- install.sh(Linux 一键安装器,TUI)/ install-linux.sh(转发壳)
- classhelper(运维命令本体)/ verify-linux.sh(Linux 验收,自统计 N/N)
- tools/admin-cli.mjs(离线改密 / SQLite 一致性快照)
- profile.d/ logrotate.d/ systemd/(装到 /etc 的模板)
- Dockerfile.linux-package(安装包内那份,--mode docker 用)
- classhelper.service(systemd 模板,5 个占位符)/ .env{,.sqlite}.example
-docs/ **文档站**(docfx 项目;不进 pnpm workspace)
- ⚠ 与官网(website/)共用**同一个** GitHub Pages 站点:官网在根、
- 文档站在 /docs/ 下。一个仓库在 Pages 上只有一个站点,
- 拼装由 scripts/build-pages.mjs 完成,见 §4.4「官网与文档站怎么发布」。
- docfx.json / toc.yml(顶栏)+ 各章节自己的 toc.yml(侧栏)
- get-started/ app/ management/ dev/(四章内容)
- management/{production,linux-deploy,mysql}.md(部署与换库)
- dev/winisland-design-tokens.md
- images/(**展示图**:文档站 / README / 官网共用的同一份,1× 逻辑像素)
- screenshots/(回归留档的截图:island / client / web-mobile / classisland,
- 页面不展示;两者分工见 docs/dev/testing.md「截图留档」)
- templates/classhelper/(模板覆盖:_master.tmpl + public/main.css + 图标)
- build.mjs(构建,注入版本号与 404 的 base)/ serve.mjs(本地预览 :5181)
- _site/(构建产物,gitignored)
- ⚠ 站点的顶栏版本徽标取自根 package.json(build.mjs 用 --metadata 注入),
- 不要在文档里再写一份版本号。
- ⚠ 页面里的照片一律指向 images/ —— 它就是 README 与官网吃的那一份。
- 别再往 screenshots/ 引图:那是冒烟脚本滚动的留档,随时被下一次验证覆盖。
- ⚠ 模板目录里的 logo.png / favicon.png 由 `pnpm icons` 派生(见 §5 第 62 条),
- 别手工替换;改图标只改 build/classhelper.png 再跑 pnpm icons。
- ⚠ 文档站只发布 docs/ 这一棵树,页面里**不要**写指向 docs/ 之外的相对链接
- (如 ../AGENTS.md)—— 发布出去全是 404,要用 GitHub 的绝对 URL。
-website/ 产品官网(**纯静态、无构建步骤**,不进 pnpm workspace)
- index.html(门面)/ download.html(下载页,首页的「立即下载」跳到它)
- assets/{styles.css,motion.js,main.js,download.js,icon.png,shots/}
- serve.mjs(本地预览)/ tools/capture-shots.mjs(实机图采集)
- 404.html(**站点根** 404,含 {{BASE}} 占位符,由 build-pages.mjs 注入)
- ⚠ serve.mjs 把 ../docs/_site 挂在 /docs/ 下,为的是让本地与线上同形 ——
- 否则页脚那几个「文档站」链接在本地全是 404。文档站得先 pnpm docs:build。
- ⚠ 首屏标题第二行是 .reveal-grad:**它不参与逐字揭示**
- (main.js 会跳过它)。拆成 per-char 的 inline-block 后每个字各自建一层合成,
- 祖先的 background-clip: text 就穿不过去,渐变会整行失效。
- ⚠ assets/motion.js 是动效层:曲线与弹簧 token 逐条取自 beUI
- (beui.dev/components/motion,见 docs/reference.md 的「官网」一节)。
- 改官网动效请改 token 表,不要在组件里另写 duration / cubic-bezier。
- ⚠ assets/shots/ 里全是**跑起来截的**实机图,重采集步骤见
- docs/dev/testing.md 的「截图留档」;客户端那两张要配合冒烟的
- ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。
- ⚠ download.html 的版本清单是**当场**读 GitHub Releases 的
- (assets/download.js):版本分段控件 → 一行状态 → 「装什么」分段控件
- (客户端 / 服务端 / 插件,**默认停在客户端**)→ 卡片。卡片是拉到数据之后
- 才建的,建完会派发一次 ch:content,由 main.js 补挂按压反馈
- (新增动态内容照这个来)。两个分段控件共用同一套指示器实现。
- 卡片排版照 ClassIsland 官网那页:图标 + 名称 + 系统要求 + 主按钮 + 变体下拉
- (,点别处或 Esc 收起);插件那张只有一个文件,不摆点不开的箭头。
- 读不到时退回 并按发布命名约定拼直链 ——
- 那个 meta 是 version.mjs 的落点之一,发版必须一起改。
- 命名约定与 README「开始使用」表里那三个名字要对齐
- (ClassHelper-<版本>-x64-client-setup.exe / -client-portable.exe /
- -x64-server-setup.exe),它们是离线兜底链接的唯一依据。
-packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts
-packages/server/
- prisma/schema.prisma 数据模型(29 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
- prisma/migrations/ 17 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
- 2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query /
- add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks;
- 最后 1 个是 2026-10-10 的 add_permission_groups_and_clients:
- 权限组 3 表 + AppSetting + 客户端设备/命令/安装包 + AiAudit +
- Notification.assignDate + User.careMode)
- prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
- prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
- scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项)
- scripts/verify-classisland.mjs 联动链路验收(实测 44 项)
- scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用)
- src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
- src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
- src/config/env.ts zod 环境变量校验 + 生产自检
- src/lib/ access(RBAC) class-account db db-bootstrap http jwt logger mappers
- password schemas session term version(读 package.json) web-static
- src/middleware/ auth validate error security
- src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
- src/modules/ 20 个功能模块 + registry.ts + module.types.ts
- (含 archives 毕业归档、term 学期周次、permissions 权限组、
- clients 客户端心跳与远控、system 开发者模式/开机自启、ai 大模型助手)
-packages/web-admin/src/ api stores router layouts views(18) components(5) composables styles config.ts
- (含 ArchivesView 毕业归档、TermWeeksView 学期周次、PermissionsView 权限组、
- ClientsView 客户端设备、AssistantView/aiSettingsView 大模型助手)
-packages/desktop-client/
- src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
- src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
- src/island/ 灵动岛渲染进程:IslandApp.vue / spring.ts / squircle.ts / main.ts
- src/renderer/ ClassHelper 班级端渲染进程:api / cache(IndexedDB) / stores / views / island / router
- src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
- scripts/ build-main / dev / smoke / dist-win
-```
-
----
-
-## 3. 本机环境(2026-09-30 实测)
-
-| 项 | 状态 |
-| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Node | **v22.15.0**(`D:\Node.js\node.exe`;根 `engines` 要求 ≥ 20.19,Prisma 7 的 engine 约束是 `^20.19 \|\| ^22.12 \|\| >=24.0`,v22.15 都满足) |
-| pnpm | 11.8.0(`packageManager` 锁定 11.8.0)。本机可用的本体在 `C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs`;PATH 里那个 `pnpm.ps1` 是 DSH 桌面端的转发壳,**别绕它**(见下) |
-| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
-| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
-| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
-| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
-| 数据库 | SQLite `packages/server/prisma/dev.db`:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) |
-| `shared/dist` | 已构建(`pnpm build:shared`)—— **它是三端与 seed 的前置产物,缺了会 `ERR_MODULE_NOT_FOUND`** |
-| Electron 二进制 | 已手动装好(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`,246 MB) |
-| .NET SDK | ✅ **8.0.425**(`C:\Program Files\dotnet\sdk`,`dotnet --list-sdks` 有输出)⇒ 插件可编译/打包;**别再照旧文档说"没装"**(`dotnet` 在 PATH 里,`--list-sdks` 才是权威判据) |
-| ClassIsland | **2.1.0.1,装在 `D:\Classisland`**(启动器 `D:\Classisland\ClassIsland.exe`,实际进程 `D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe`) |
-| ClassIsland 数据 | 数据根 **`D:\Classisland\data`**:`Config/`、`Profiles/`、`Plugins/`(当前为空,尚未装本插件)、`Logs/`、`Cache/`、`Settings.json` |
-| 桌面会话 | 有(`ClassIsland.Desktop.exe` 跑在 session 1);**Electron GUI 在 AI 会话里也能跑**(需先修完整性标签,见 §7 第 7 条) |
-
-> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
-> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
-
-#### AI 会话里跑 pnpm:别撞上 DSH 的转发壳(2026-10-11 实测)
-
-在 DSH 的 shell 工具里直接写 `pnpm xxx` 会拿到
-`pnpm.ps1 : Node.js runtime not found. Please run DeepSeek Harness Desktop to install it first.`,
-而同一台机器上人在普通终端里跑得好好的。原因与绕过办法:
-
-- PATH 里排在前面的是 **DSH 桌面端的转发壳** `C:\Users\hck16\AppData\Local\deepseek-harness\bin\pnpm.ps1`
- (`.cmd` 同理)。它有两道会挡住的判据:① 认 `$env:DSH_NODE` / PATH 上的 `node`,
- **但要求 `major ≥ 24` 或 `22.19+`**,本机的 `v22.15.0` 卡在门外;
- ② 它自己的兜底 `%APPDATA%\dsh-tauri\runtime\node.exe` 与 bundled pnpm
- `%APPDATA%\dsh-tauri\dependencies\pnpm\bin\pnpm.cjs` **本机都不存在**。
-- **正确解法是喂给这个壳两个环境变量**(它自己就认这两个覆盖开关,见 shim 第 10、52 行),
- 把 `node` 与 `pnpm` 都指到本机真实的那份,然后就**照常写 `pnpm xxx`**:
-
- ```powershell
- $env:DSH_NODE = 'D:\Node.js\node.exe'
- $env:DSH_PNPM_BIN = 'C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
- pnpm typecheck # 与人在终端里跑完全等价
- pnpm build:shared
- ```
-
- - `DSH_NODE` 必须是**绝对路径**:壳拿到它就不再走"发现 PATH 上的 node 再卡版本"那段。
- - `DSH_PNPM_BIN` 是**本体 .cjs 的路径**(不是目录,也不是 `pnpm.ps1`);壳会 `& $node $pnpmBin @args`
- 直接执行它。它同时替掉那条会失败的 `pnpmBin` 存在性检查。
- - **`pnpm` 的嵌套调用**(`typecheck` = `build:shared && pnpm -r --no-bail typecheck`)靠这两个变量就够了:
- 子进程继承同一份环境,于是又落进同一个壳、又被同一对覆盖接住。
- - **这三条都是踩过的坑**(同一个会话里依次试错,前两条各失败一次):
- - 不要用 `DSH_PNPM`,那是"用户 pnpm 的可执行路径":壳转发给它时**仍然先过 ② 的版本门槛**,
- 而且**残留的 `DSH_PNPM` 会污染后面所有调用**——它与 `DSH_PNPM_BIN` 同时存在时壳会转发进
- corepack 的 `pnpm.ps1`,那个壳又把它重置回不存在的 bundled 路径,于是原本能跑的
- `typecheck` 报出 `[pnpm] pnpm not found`。要么别设,要么先 `Remove-Item Env:DSH_PNPM`。
- - 也不要用 `node
-
-
-
-
组件设置
-
{{ ISLAND_COMPONENT_LABELS[node.kind] }}
-
只影响当前选中的组件
-
-
-
-
-
-
- 组件操作
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/desktop-client/src/island/CoursewareTransfer.vue b/packages/desktop-client/src/island/CoursewareTransfer.vue
new file mode 100644
index 0000000..62344ce
--- /dev/null
+++ b/packages/desktop-client/src/island/CoursewareTransfer.vue
@@ -0,0 +1,132 @@
+
+
+
+
+ {{ status }}
+ {{ transfer.stage === 'failed' ? '失败' : `${transfer.progress}%` }}
+
+ {{ transfer.name }}
+
+ {{ COURSEWARE_CATEGORIES[transfer.category] }} · {{ transfer.teacherName || '教师' }}
+
+ {{ (transfer.receivedBytes / 1048576).toFixed(1) }} /
+ {{ (transfer.totalBytes / 1048576).toFixed(1) }} MB
+
+
+
+ {{ transfer.error }}
+
+
+
diff --git a/packages/desktop-client/src/island/CustomIsland.vue b/packages/desktop-client/src/island/CustomIsland.vue
deleted file mode 100644
index b3e0fb2..0000000
--- a/packages/desktop-client/src/island/CustomIsland.vue
+++ /dev/null
@@ -1,815 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
startDrag(e, 'node', id)"
- >
-
-
-
-
-
-
startDrag(e, 'node', id)"
- >
-
-
-
-
-
-
拖入组件,或从左侧添加
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/desktop-client/src/island/LayoutIslandApp.vue b/packages/desktop-client/src/island/LayoutIslandApp.vue
index f752535..0b09971 100644
--- a/packages/desktop-client/src/island/LayoutIslandApp.vue
+++ b/packages/desktop-client/src/island/LayoutIslandApp.vue
@@ -8,6 +8,7 @@ import {
type ScheduleDto,
} from '@classhelper/shared';
import FixedIsland from './IslandApp.vue';
+import CoursewareTransfer from './CoursewareTransfer.vue';
import './custom-island.css';
import CustomIslandRuntime from './CustomIslandRuntime.vue';
const settings = ref(defaultIslandLayouts()),
@@ -50,12 +51,13 @@ onMounted(async () => {
-
+
+
diff --git a/packages/desktop-client/src/island/classic-island.css b/packages/desktop-client/src/island/classic-island.css
index b7ac9ff..1f8a38d 100644
--- a/packages/desktop-client/src/island/classic-island.css
+++ b/packages/desktop-client/src/island/classic-island.css
@@ -730,7 +730,7 @@ body {
*
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
- * 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。
+ * 的按钮会永远保持高亮(同 AGENTS.md 的现行工程约定那类问题)。
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
*/
@media (hover: hover) and (pointer: fine) {
diff --git a/packages/desktop-client/src/island/custom-island.css b/packages/desktop-client/src/island/custom-island.css
index 25d87f7..f013c08 100644
--- a/packages/desktop-client/src/island/custom-island.css
+++ b/packages/desktop-client/src/island/custom-island.css
@@ -203,535 +203,3 @@
.custom-window .preview-stage {
width: max-content;
}
-
-.layout-editor {
- --editor-bg: #f6f7f9;
- --editor-border: #e6e9ef;
- --editor-text: #202735;
- --editor-muted: #7b8494;
- --editor-accent: #3c69de;
- position: fixed;
- inset: 10px;
- display: grid;
- grid-template-columns: 232px minmax(0, 1fr) 300px;
- grid-template-rows: 76px minmax(0, 1fr);
- color: var(--editor-text);
- background: #fff;
- border: 1px solid #dce1e9;
- border-radius: 18px;
- overflow: hidden;
- font:
- 13px 'Segoe UI Variable Display',
- 'Microsoft YaHei',
- sans-serif;
- box-shadow: 0 12px 40px #15203c22;
-}
-.layout-editor h1,
-.layout-editor h2,
-.layout-editor h3,
-.layout-editor p {
- margin: 0;
-}
-.layout-editor button,
-.layout-editor select,
-.layout-editor input,
-.layout-editor textarea {
- font: inherit;
-}
-.layout-editor button,
-.layout-editor select,
-.layout-editor input:not([type='checkbox']):not([type='range']),
-.layout-editor textarea {
- min-height: 36px;
- border: 1px solid var(--editor-border);
- border-radius: 8px;
- padding: 7px 10px;
- color: var(--editor-text);
- background: #fff;
- max-width: 100%;
-}
-.layout-editor button {
- cursor: pointer;
- touch-action: none;
- transition: background var(--ch-dur-fast) var(--ch-ease-out);
-}
-.layout-editor button:hover {
- background: #f0f3f9;
-}
-.layout-editor button:disabled {
- cursor: default;
- opacity: 0.45;
-}
-.layout-editor button:focus-visible,
-.layout-editor input:focus-visible,
-.layout-editor select:focus-visible,
-.layout-editor textarea:focus-visible,
-.layout-editor summary:focus-visible {
- outline: 2px solid var(--editor-accent);
- outline-offset: 2px;
-}
-.layout-editor input[type='checkbox'],
-.layout-editor input[type='range'] {
- accent-color: var(--editor-accent);
-}
-.layout-editor input[type='checkbox'] {
- width: 18px;
- height: 18px;
- cursor: pointer;
- flex-shrink: 0;
-}
-.layout-editor input[type='color'] {
- width: 48px;
- padding: 4px !important;
- cursor: pointer;
-}
-.layout-editor .editor-header {
- grid-column: 1/-1;
- display: flex;
- align-items: center;
- gap: 24px;
- padding: 0 24px;
- border-bottom: 1px solid var(--editor-border);
- z-index: 3;
-}
-.layout-editor .editor-title {
- display: flex;
- align-items: center;
- gap: 12px;
- flex-shrink: 0;
-}
-.layout-editor .editor-mark {
- font-size: 30px;
- color: #222b3b;
-}
-.layout-editor h1 {
- font-size: 17px;
- font-weight: 650;
- letter-spacing: 0.5px;
-}
-.layout-editor .editor-title p {
- font-size: 11px;
- color: var(--editor-muted);
- margin-top: 3px;
-}
-.layout-editor .scheme-picker {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-left: auto;
-}
-.layout-editor .scheme-picker > span {
- color: var(--editor-muted);
- font-size: 12px;
-}
-.layout-editor .scheme-picker select {
- width: 170px;
- background: var(--editor-bg);
-}
-.layout-editor .scheme-menu {
- position: relative;
-}
-.layout-editor .scheme-menu summary {
- list-style: none;
- cursor: pointer;
- padding: 10px;
- border-radius: 8px;
-}
-.layout-editor .scheme-menu-body {
- position: absolute;
- right: 0;
- top: 40px;
- width: 170px;
- padding: 6px;
- background: white;
- border: 1px solid var(--editor-border);
- border-radius: 12px;
- box-shadow: 0 8px 28px #1c2d4c1a;
-}
-.layout-editor .scheme-menu-body button,
-.layout-editor .import-button {
- width: 100%;
- display: block;
- border: 0;
- text-align: left;
- padding: 10px;
- cursor: pointer;
- border-radius: 6px;
-}
-.layout-editor .import-button:hover {
- background: var(--editor-bg);
-}
-.layout-editor .import-button input {
- display: none;
-}
-.layout-editor .save-state {
- color: var(--editor-muted);
- font-size: 11px;
-}
-.layout-editor .primary-button {
- color: #fff;
- background: var(--editor-accent);
- border-color: var(--editor-accent);
-}
-.layout-editor .primary-button:hover {
- background: #305ac8;
-}
-.layout-editor .done-button {
- min-width: 80px;
-}
-.layout-editor .library,
-.layout-editor .settings {
- overflow: auto;
- background: #fff;
-}
-.layout-editor .library {
- border-right: 1px solid var(--editor-border);
- padding: 22px 16px 16px;
-}
-.layout-editor .panel-heading {
- padding: 0 4px 20px;
-}
-.layout-editor h2 {
- font-size: 17px;
- font-weight: 650;
-}
-.layout-editor .panel-heading p,
-.layout-editor .inspector-heading p {
- font-size: 12px;
- color: var(--editor-muted);
- margin-top: 7px;
- line-height: 1.6;
-}
-.layout-editor .library-group {
- margin-bottom: 22px;
-}
-.layout-editor .library-group h3 {
- color: var(--editor-muted);
- font-size: 11px;
- font-weight: 500;
- margin: 0 4px 10px;
-}
-.layout-editor .component-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
-}
-.layout-editor .library-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- padding: 12px 4px;
- font-size: 12px;
- border-color: #eff1f5;
-}
-.layout-editor .component-symbol {
- font-size: 22px;
- height: 24px;
- display: flex;
- align-items: center;
- color: #65728a;
-}
-.layout-editor .library-item.active {
- background: #f0f4ff;
- border-color: #97b2f4;
- color: var(--editor-accent);
-}
-.layout-editor .library-item.active .component-symbol {
- color: inherit;
-}
-.layout-editor .library-add {
- position: sticky;
- bottom: -16px;
- padding: 16px 0;
- background: white;
- border-top: 1px solid var(--editor-border);
-}
-.layout-editor .canvas {
- position: relative;
- overflow: hidden;
- touch-action: none;
- display: flex;
- align-items: flex-start;
- justify-content: center;
- padding: 160px 36px 100px;
- background:
- radial-gradient(#d9dfe9 0.8px, transparent 0.8px) 0 0 / 20px 20px,
- #f2f4f8;
-}
-.layout-editor .canvas-toolbar {
- position: absolute;
- top: 22px;
- left: 24px;
- right: 24px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- pointer-events: none;
-}
-.layout-editor .canvas-toolbar > span {
- color: var(--editor-muted);
- font-size: 12px;
-}
-.layout-editor .canvas-toolbar > div {
- display: flex;
- gap: 6px;
- pointer-events: auto;
-}
-.layout-editor .canvas-toolbar button {
- font-size: 12px;
- box-shadow: 0 2px 4px #18305a05;
-}
-.layout-editor .canvas-toolbar button.active {
- color: var(--editor-accent);
- border-color: #b6c8f0;
-}
-.layout-editor .preview-stage {
- transform-origin: center top;
- flex-shrink: 0;
- max-width: none;
-}
-.layout-editor .custom-card {
- min-width: 280px;
- overflow: visible;
-}
-.layout-editor .row-editing {
- position: relative;
- padding: 34px 4px 8px;
- border: 1px dashed #ffffff36;
- border-radius: 12px;
-}
-.layout-editor .row-editing.row-selected {
- border-color: #8baeff;
-}
-.layout-editor .row-tools {
- position: absolute;
- top: 0;
- left: 2px;
- right: 2px;
- display: flex;
- align-items: center;
- gap: 3px;
- height: 28px;
-}
-.layout-editor .row-tools button {
- border: 0;
- min-height: 24px;
- padding: 3px 6px;
- background: transparent;
- color: #ffffffab;
- font-size: 11px;
-}
-.layout-editor .row-tools button:hover {
- background: #ffffff12;
- color: white;
-}
-.layout-editor .row-name {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-.layout-editor .row-name span {
- font-size: 9px;
- padding: 1px 5px;
- color: #a9c3ff;
- border-radius: 4px;
- background: #83a8ff22;
-}
-.layout-editor .row-tools .row-settings {
- margin-left: auto;
-}
-.layout-editor .row-content {
- min-width: 260px;
- min-height: 52px;
-}
-.layout-editor .empty-row {
- color: #ffffff60;
- padding: 10px 18px;
- font-size: 12px;
-}
-.layout-editor .add-row-button {
- display: block;
- width: 100%;
- margin-top: 16px;
- color: #7d899d;
- background: #ffffff70;
- border: 1px dashed #c7d0e0;
- font-size: 12px;
-}
-.layout-editor .canvas-footer {
- position: absolute;
- bottom: 18px;
- left: 24px;
- right: 24px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- color: var(--editor-muted);
- font-size: 11px;
- pointer-events: none;
-}
-.layout-editor .canvas-footer label {
- display: flex;
- align-items: center;
- gap: 8px;
- pointer-events: auto;
-}
-.layout-editor .canvas-footer input {
- width: 90px;
-}
-.layout-editor .canvas-footer output {
- width: 34px;
-}
-.layout-editor .settings {
- border-left: 1px solid var(--editor-border);
-}
-.layout-editor .inspector-heading {
- padding: 24px 24px 20px;
-}
-.layout-editor .eyebrow {
- display: block;
- color: var(--editor-muted);
- font-size: 10px;
- margin-bottom: 8px;
-}
-.layout-editor .panel-tabs {
- display: flex;
- gap: 6px;
- padding: 0 24px 16px;
- border-bottom: 1px solid var(--editor-border);
-}
-.layout-editor .panel-tabs button {
- flex: 1;
- border: 0;
- min-height: 32px;
- font-size: 12px;
- background: var(--editor-bg);
- color: #788295;
-}
-.layout-editor .panel-tabs button.active {
- color: var(--editor-accent);
- background: #edf2ff;
-}
-.layout-editor .inspector-body {
- padding: 0 24px 24px;
-}
-.layout-editor .setting-section {
- padding: 20px 0;
- border-bottom: 1px solid var(--editor-border);
-}
-.layout-editor .setting-section:last-child {
- border-bottom: 0;
-}
-.layout-editor .setting-section h3 {
- font-size: 12px;
- font-weight: 650;
- margin-bottom: 16px;
-}
-.layout-editor .field {
- display: flex;
- flex-direction: column;
- gap: 8px;
- font-size: 12px;
- margin-bottom: 16px;
-}
-.layout-editor .field:last-child {
- margin-bottom: 0;
-}
-.layout-editor .field > input:not([type='color']),
-.layout-editor .field > textarea,
-.layout-editor .field > select {
- width: 100%;
-}
-.layout-editor .inline-field {
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-.layout-editor .inline-field input[type='number'] {
- width: 80px;
-}
-.layout-editor .toggle-field {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- min-height: 38px;
- font-size: 12px;
-}
-.layout-editor .field-hint {
- font-size: 11px;
- color: var(--editor-muted);
- line-height: 1.8;
- margin: 8px 0 12px;
-}
-.layout-editor .range-field {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-.layout-editor .range-field input {
- flex: 1;
- min-width: 0;
-}
-.layout-editor .range-field output {
- width: 36px;
- color: var(--editor-muted);
-}
-.layout-editor .wide-button {
- width: 100%;
- margin-bottom: 12px;
-}
-.layout-editor .wide-button:last-child {
- margin-bottom: 0;
-}
-.layout-editor .danger-button {
- color: #cc4a52;
- background: #fff;
-}
-.layout-editor .danger-button:hover {
- background: #fff3f4;
-}
-.layout-editor fieldset {
- padding: 0;
- margin: 0;
- border: 0;
-}
-.layout-editor fieldset:disabled {
- opacity: 0.45;
-}
-.layout-editor .editor-error {
- margin: 12px 24px;
- padding: 10px;
- background: #fff1f1;
- color: #bc3f4b;
- border-radius: 8px;
- line-height: 1.7;
-}
-@media (max-width: 1100px) {
- .layout-editor {
- grid-template-columns: 188px minmax(0, 1fr) 260px;
- }
- .layout-editor .editor-header {
- gap: 12px;
- padding: 0 18px;
- }
- .layout-editor .scheme-picker > span,
- .layout-editor .save-state,
- .layout-editor .canvas-footer > span {
- display: none;
- }
- .layout-editor .inspector-heading,
- .layout-editor .inspector-body {
- padding-left: 18px;
- padding-right: 18px;
- }
-}
-@media (prefers-reduced-motion: reduce) {
- .layout-editor button {
- transition: none;
- }
-}
diff --git a/packages/desktop-client/src/main/auto-update.ts b/packages/desktop-client/src/main/auto-update.ts
index 6b41aeb..2527c2f 100644
--- a/packages/desktop-client/src/main/auto-update.ts
+++ b/packages/desktop-client/src/main/auto-update.ts
@@ -316,6 +316,8 @@ function formatSize(bytes: number): string {
* 每次心跳后与主窗口隐藏后都会调用。
*/
export async function maybeInstallPendingUpdate(): Promise {
+ const courseware = island.getCoursewareTransfer();
+ if (courseware && ['downloading', 'verifying'].includes(courseware.stage)) return;
if (installing) return;
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
diff --git a/packages/desktop-client/src/main/client-agent.ts b/packages/desktop-client/src/main/client-agent.ts
index b731fc1..c1854ac 100644
--- a/packages/desktop-client/src/main/client-agent.ts
+++ b/packages/desktop-client/src/main/client-agent.ts
@@ -1,3 +1,4 @@
+import { pollCourseware } from './courseware.js';
import { spawn } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
@@ -206,6 +207,7 @@ async function beat(): Promise {
if (!envelope.success || !envelope.data?.deviceId) throw new Error('心跳响应格式不正确');
if (epoch !== connectionEpoch) return;
const payload = envelope.data;
+ void pollCourseware();
state.lastHeartbeatAt = new Date().toISOString();
state.lastError = '';
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
diff --git a/packages/desktop-client/src/main/courseware.ts b/packages/desktop-client/src/main/courseware.ts
new file mode 100644
index 0000000..b248cd7
--- /dev/null
+++ b/packages/desktop-client/src/main/courseware.ts
@@ -0,0 +1,240 @@
+import type { ReadableStream } from 'node:stream/web';
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import { createHash } from 'node:crypto';
+import { Readable, Transform } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+import { app, BrowserWindow, shell } from 'electron';
+import {
+ COURSEWARE_MAX_BYTES,
+ COURSEWARE_TRANSFER_TIMEOUT_MS,
+ type ApiResponse,
+ type CoursewareDto,
+ type CoursewarePushDto,
+ type CoursewareTransfer,
+} from '@classhelper/shared';
+import { getConfig } from './config.js';
+import { connectionEvents } from './config-events.js';
+import { island } from './island.js';
+import { logger } from './logger.js';
+
+let transfer: CoursewareTransfer | null = null,
+ epoch = 0;
+let abort: AbortController | null = null,
+ polling = false;
+let queue: Promise = Promise.resolve();
+const jobs = new Map>();
+let clearTimer: ReturnType | undefined;
+export function getCoursewareTransfer() {
+ return transfer;
+}
+function publish(value: CoursewareTransfer | null) {
+ transfer = value;
+ island.setCoursewareTransfer(value);
+ for (const win of BrowserWindow.getAllWindows())
+ if (!win.isDestroyed()) win.webContents.send('courseware:progress', value);
+}
+connectionEvents.on('changed', () => {
+ epoch++;
+ abort?.abort();
+ clearTimeout(clearTimer);
+ publish(null);
+});
+function checkId(id: string) {
+ if (typeof id !== 'string' || !/^[a-z0-9-]{1,100}$/i.test(id)) throw new Error('课件编号无效');
+}
+function scopeDirectory() {
+ const config = getConfig();
+ const scope = createHash('sha256')
+ .update(`${config.serverUrl}\n${config.username}`)
+ .digest('hex')
+ .slice(0, 24);
+ return path.join(app.getPath('userData'), 'courseware', scope);
+}
+async function json(route: string, signal: AbortSignal, body?: object): Promise {
+ const config = getConfig();
+ if (!config.token) throw new Error('请先登录班级账号');
+ const response = await fetch(`${config.serverUrl}/api/courseware${route}`, {
+ method: body ? 'POST' : 'GET',
+ headers: {
+ authorization: `Bearer ${config.token}`,
+ ...(body ? { 'content-type': 'application/json' } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ signal,
+ redirect: 'error',
+ });
+ const value = (await response.json()) as ApiResponse;
+ if (!response.ok || !value.success) throw new Error(value.message || `请求失败(${response.status})`);
+ return value.data;
+}
+async function digest(file: string) {
+ const hash = createHash('sha256');
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
+ return hash.digest('hex');
+}
+export function downloadCourseware(id: string): Promise {
+ checkId(id);
+ const startedEpoch = epoch,
+ key = `${epoch}:${id}`;
+ const existing = jobs.get(key);
+ if (existing) return existing;
+ const job = queue
+ .catch(() => undefined)
+ .then(async () => {
+ if (startedEpoch !== epoch) throw new Error('登录会话已切换');
+ clearTimeout(clearTimer);
+ abort = new AbortController();
+ const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(COURSEWARE_TRANSFER_TIMEOUT_MS)]);
+ const config = getConfig();
+ let partial = '';
+ try {
+ const item = await json(`/${encodeURIComponent(id)}`, signal);
+ if (startedEpoch !== epoch) throw new Error('登录会话已切换');
+ if (
+ !Number.isInteger(item.size) ||
+ item.size < 1 ||
+ item.size > COURSEWARE_MAX_BYTES ||
+ !/^[a-f0-9]{64}$/.test(item.sha256)
+ )
+ throw new Error('课件文件信息无效');
+ const safeName =
+ `_${item.name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/[. ]+$/, '')}`.slice(0, 182);
+ const dir = path.join(scopeDirectory(), id),
+ file = path.join(dir, safeName);
+ partial = `${file}.part`;
+ const base: CoursewareTransfer = {
+ id,
+ name: item.name,
+ category: item.category,
+ teacherName: item.teacher.name,
+ receivedBytes: 0,
+ totalBytes: item.size,
+ progress: 0,
+ stage: 'downloading',
+ };
+ publish(base);
+ await fsp.mkdir(dir, { recursive: true });
+ let valid = false;
+ try {
+ valid = (await fsp.stat(file)).size === item.size && (await digest(file)) === item.sha256;
+ } catch {
+ /* 尚未下载 */
+ }
+ if (!valid) {
+ const response = await fetch(
+ `${config.serverUrl}/api/courseware/${encodeURIComponent(id)}/download`,
+ { headers: { authorization: `Bearer ${config.token}` }, signal, redirect: 'error' },
+ );
+ if (!response.ok || !response.body) throw new Error(`课件下载失败(HTTP ${response.status})`);
+ const hash = createHash('sha256');
+ let received = 0,
+ last = 0;
+ await pipeline(
+ Readable.fromWeb(response.body as ReadableStream),
+ new Transform({
+ transform(chunk: Buffer, _encoding, callback) {
+ received += chunk.length;
+ if (received > item.size) return callback(new Error('课件大小不匹配'));
+ hash.update(chunk);
+ if (Date.now() - last > 120) {
+ last = Date.now();
+ if (startedEpoch === epoch)
+ publish({
+ ...base,
+ receivedBytes: received,
+ progress: Math.min(99, Math.floor((received / item.size) * 100)),
+ });
+ }
+ callback(null, chunk);
+ },
+ }),
+ fs.createWriteStream(partial, { flags: 'w' }),
+ { signal },
+ );
+ if (startedEpoch !== epoch) throw new Error('登录会话已切换');
+ publish({ ...base, receivedBytes: received, progress: 99, stage: 'verifying' });
+ if (received !== item.size || hash.digest('hex') !== item.sha256)
+ throw new Error('课件校验失败,请重新下载');
+ // 下载完成后再次检查可见权限,撤回的文件不得落成本地副本。
+ await json(`/${encodeURIComponent(id)}`, signal);
+ if (startedEpoch !== epoch || signal.aborted) throw new Error('登录会话已切换或下载已取消');
+ await fsp.rm(file, { force: true });
+ await fsp.rename(partial, file);
+ }
+ if (startedEpoch !== epoch) throw new Error('登录会话已切换');
+ publish({ ...base, receivedBytes: item.size, progress: 100, stage: 'completed' });
+ } catch (error) {
+ if (partial) await fsp.rm(partial, { force: true }).catch(() => undefined);
+ if (startedEpoch === epoch)
+ publish({
+ ...(transfer?.id === id
+ ? transfer
+ : {
+ id,
+ name: '课件',
+ category: 'other',
+ teacherName: '',
+ receivedBytes: 0,
+ totalBytes: 0,
+ progress: 0,
+ }),
+ stage: 'failed',
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ } finally {
+ abort = null;
+ if (startedEpoch === epoch)
+ clearTimer = setTimeout(
+ () => {
+ if (transfer?.id === id) publish(null);
+ },
+ transfer?.stage === 'failed' ? 12000 : 5000,
+ );
+ }
+ })
+ .finally(() => jobs.delete(key));
+ jobs.set(key, job);
+ queue = job;
+ // IPC 调用者或轮询负责呈现错误;队列自身始终有拒绝处理器。
+ void job.catch(() => undefined);
+ return job;
+}
+export async function pollCourseware() {
+ if (polling || !getConfig().token || process.env.ELECTRON_SMOKE_TEST === '1') return;
+ const startedEpoch = epoch,
+ config = getConfig();
+ polling = true;
+ try {
+ const tasks = await json(
+ `/transfers?deviceKey=${encodeURIComponent(config.deviceKey)}`,
+ AbortSignal.timeout(10000),
+ );
+ for (const task of tasks) {
+ if (startedEpoch !== epoch) break;
+ try {
+ await downloadCourseware(task.courseware.id);
+ if (startedEpoch !== epoch) break;
+ await json(`/transfers/${encodeURIComponent(task.id)}/ack`, AbortSignal.timeout(10000), {
+ deviceKey: config.deviceKey,
+ });
+ } catch (error) {
+ logger.warn(`课件接收失败,下次心跳重试:${String(error)}`);
+ }
+ }
+ } catch (error) {
+ logger.debug(`读取课件传输任务失败:${String(error)}`);
+ } finally {
+ polling = false;
+ }
+}
+export async function revealCourseware(id: string) {
+ checkId(id);
+ const dir = path.join(scopeDirectory(), id);
+ const names = await fsp.readdir(dir).catch(() => []);
+ const file = names.find((name) => !name.endsWith('.part'));
+ if (!file) throw new Error('请先完成课件下载');
+ shell.showItemInFolder(path.join(dir, file));
+}
diff --git a/packages/desktop-client/src/main/ipc.ts b/packages/desktop-client/src/main/ipc.ts
index 036e91e..eea0b53 100644
--- a/packages/desktop-client/src/main/ipc.ts
+++ b/packages/desktop-client/src/main/ipc.ts
@@ -1,3 +1,4 @@
+import { downloadCourseware, getCoursewareTransfer, pollCourseware, revealCourseware } from './courseware.js';
import { island } from './island.js';
import { getUpdateProgress } from './auto-update.js';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
@@ -14,6 +15,10 @@ import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
export { IPC_CHANNELS };
export function registerIpcHandlers(): void {
+ ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
+ ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
+ ipcMain.handle('courseware:sync', () => { void pollCourseware(); });
+ ipcMain.handle('courseware:get-progress', () => getCoursewareTransfer());
ipcMain.handle('client:get-update-progress', () => getUpdateProgress());
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
@@ -136,7 +141,7 @@ export function registerIpcHandlers(): void {
return true;
});
- /** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
+ /** 服务端连接状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md 的现行工程约定) */
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
/** 立即上报一次(设置页「立即上报」按钮) */
diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts
index 79b3c14..75407d7 100644
--- a/packages/desktop-client/src/main/island.ts
+++ b/packages/desktop-client/src/main/island.ts
@@ -3,6 +3,8 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron';
import {
+ COURSEWARE_ISLAND_SIZE,
+ type CoursewareTransfer,
DEFAULT_ISLAND_APPEARANCE,
CLASSISLAND_ISLAND_METRICS,
normalizeIslandLayouts,
@@ -629,11 +631,29 @@ class IslandController {
getState(): IslandState {
return {
...this.state,
+ coursewareTransfer: this.coursewareShowing ? this.state.coursewareTransfer : null,
lessonReminder: this.lessonShowing ? this.lessonCue : null,
queued: [...this.state.queued],
};
}
+ setCoursewareTransfer(value: CoursewareTransfer | null): void {
+ this.setState({ coursewareTransfer: value });
+ }
+ getCoursewareTransfer(): CoursewareTransfer | null {
+ return this.state.coursewareTransfer ?? null;
+ }
+ private get coursewareShowing(): boolean {
+ return (
+ !!this.state.coursewareTransfer &&
+ !(
+ this.state.mode !== 'hidden' &&
+ this.state.active &&
+ (this.state.active.priority === 'URGENT' || this.state.active.kind === 'call')
+ )
+ );
+ }
+
isReady(): boolean {
return this.ready && Boolean(this.win) && !this.win?.isDestroyed();
}
@@ -662,7 +682,7 @@ class IslandController {
/**
* 同一条消息**可能经由两条广播链路到达**:服务端 `createCall` 既发 `notification:new`
- * (班级房间)又发 `call:new`(学生/班级 user 房间),两条 DTO 的 id 相同、
+ * (班级房间)又发 `call:new`(班级会话 session 房间),两条 DTO 的 id 相同、
* 只有"是否叫人"的语义不同。后到的那条绝不能把已有的"叫人"降级成普通通知 ——
* 否则胶囊显示成"新消息"、上课时段还会被当成普通通知隐藏(用户实测截图:
* 叫人胶囊写着"新消息 / 系统管理员 · 点击查看")。
@@ -897,8 +917,7 @@ class IslandController {
if (this.state.active) this.scheduleCollapse(TIMEOUTS.expanded);
break;
}
- // 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
- // 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
+ // 经典模式课堂普通通知保持隐藏;普通叫人可主动点开,紧急消息也须支持收起后再次查看。
const showing = this.state.active;
// 岛当前可见(胶囊态)时,用户是"对着屏幕上这条岛"点的:
// 只要队列里有更**新**的消息(例如紧急通知之后又来了作业),就应当打开新那条,
@@ -1055,7 +1074,7 @@ class IslandController {
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
- * (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
+ * (见 AGENTS.md 的现行工程约定:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
*/
private beginClose(): void {
@@ -1069,7 +1088,7 @@ class IslandController {
* 而窗口此刻还是展开/列表态的大包围盒 —— 若这里同步缩窗,卡片会在动画的 360ms 里
* 被窗口裁掉(冒烟用例「整批关闭过程中卡片不被窗口裁切」实测最大越界 554.9px,
* 用户看到的是一瞬间的硬裁边)。窗口要变小一律等**隐藏之后**由 `syncWindow()` 做,
- * 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md §5 第 30 条)是同一条纪律。
+ * 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md 的现行工程约定)是同一条纪律。
*/
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
const speed = clamp(this.appearance.speed, 0.5, 2, 1);
@@ -1368,6 +1387,16 @@ class IslandController {
*/
private syncWindow(): void {
if (!this.win || this.win.isDestroyed()) return;
+ if (this.coursewareShowing && !this.editingLayout) {
+ this.cancelFade();
+ if (!this.win.isVisible() || this.customLayout) this.applyWindowLayout();
+ this.syncTouchLayout();
+ this.win.setOpacity(this.appearance.opacity);
+ if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual'))
+ this.win.showInactive();
+ this.setInteractive(false, '课件传输不遮挡桌面操作');
+ return;
+ }
if (this.editingLayout) {
this.setInteractive(true, '布局编辑');
return;
@@ -1894,6 +1923,7 @@ class IslandController {
/** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */
private formSize(): { width: number; height: number } {
+ if (this.coursewareShowing) return COURSEWARE_ISLAND_SIZE;
if (this.customLayout) return this.customSize;
if (this.lessonShowing) return { width: SIZES.expanded.width, height: 40 };
// 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
@@ -1964,6 +1994,10 @@ class IslandController {
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
private syncHitFromCursor(cursor?: { x: number; y: number } | null): void {
+ if (this.coursewareShowing) {
+ this.setInteractive(false, '课件传输');
+ return;
+ }
this.updateResidentPointer(cursor);
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
this.setInteractive(false, '窗口不可见');
diff --git a/packages/desktop-client/src/main/smoke-courseware.ts b/packages/desktop-client/src/main/smoke-courseware.ts
new file mode 100644
index 0000000..38a01c1
--- /dev/null
+++ b/packages/desktop-client/src/main/smoke-courseware.ts
@@ -0,0 +1,195 @@
+/** 本地 HTTP 替身验证真实下载管线;只使用冒烟专属 userData,不连接真实服务端。 */
+import http from 'node:http';
+import type { AddressInfo } from 'node:net';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { createHash } from 'node:crypto';
+import { app } from 'electron';
+import type { CoursewareDto } from '@classhelper/shared';
+import { defaultIslandLayouts } from '@classhelper/shared';
+import { getConfig, saveConfig } from './config.js';
+import { downloadCourseware, getCoursewareTransfer } from './courseware.js';
+import { island } from './island.js';
+export async function verifyCoursewareSmoke(
+ check: (name: string, action: () => Promise | boolean) => Promise,
+) {
+ if (process.env.ELECTRON_SMOKE_TEST !== '1' || !app.getPath('userData').includes('smoke'))
+ throw new Error('课件自检只允许使用冒烟临时配置');
+ const original = getConfig(),
+ bytes = Buffer.alloc(1024 * 1024, 83);
+ let corrupt = false,
+ downloads = 0,
+ slow = false;
+ const item: CoursewareDto = {
+ id: 'courseware-smoke',
+ name: '课件接收验证.pptx',
+ size: bytes.length,
+ category: 'presentation',
+ extension: 'pptx',
+ archiveDate: '2026-10-18',
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ createdAt: new Date().toISOString(),
+ teacher: { id: 'temporary-teacher', name: '临时课件教师' },
+ classes: [],
+ };
+ const server = http.createServer((req, res) => {
+ if (req.url?.endsWith('/download')) {
+ downloads++;
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': bytes.length });
+ let offset = 0;
+ const timer = setInterval(
+ () => {
+ const chunk = bytes.subarray(offset, offset + 32768);
+ res.write(corrupt ? Buffer.alloc(chunk.length) : chunk);
+ offset += chunk.length;
+ if (offset >= bytes.length) {
+ clearInterval(timer);
+ res.end();
+ }
+ },
+ slow ? 100 : 12,
+ );
+ res.on('close', () => clearInterval(timer));
+ return;
+ }
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ success: true, data: item, message: 'ok' }));
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const port = (server.address() as AddressInfo).port;
+ const wait = async (predicate: () => boolean | Promise) => {
+ const end = Date.now() + 10000;
+ while (Date.now() < end) {
+ if (await predicate()) return true;
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ }
+ return false;
+ };
+ async function localFiles(dir = path.join(app.getPath('userData'), 'courseware')): Promise {
+ return (
+ await Promise.all(
+ (await fs.readdir(dir, { withFileTypes: true }).catch(() => [])).map((entry) =>
+ entry.isDirectory() ? localFiles(path.join(dir, entry.name)) : [path.join(dir, entry.name)],
+ ),
+ )
+ ).flat();
+ }
+ try {
+ saveConfig({
+ serverUrl: `http://127.0.0.1:${port}`,
+ username: 'courseware-smoke',
+ token: 'local-courseware-smoke',
+ islandLayout: defaultIslandLayouts(),
+ });
+ island.syncCustomLayout();
+ const job = downloadCourseware(item.id);
+ await check('课件后台下载去重且持续报告真实字节进度', async () => {
+ const same = job === downloadCourseware(item.id);
+ const progressed = await wait(() => {
+ const value = getCoursewareTransfer();
+ return !!value && value.progress > 0 && value.progress < 100;
+ });
+ return same && progressed && getCoursewareTransfer()?.teacherName === item.teacher.name;
+ });
+ await job;
+ await check('课件校验完成后保存正式文件并显示完成', async () => {
+ const files = await localFiles();
+ return (
+ getCoursewareTransfer()?.stage === 'completed' &&
+ getCoursewareTransfer()?.progress === 100 &&
+ files.length === 1 &&
+ (await fs.readFile(files[0]!)).equals(bytes)
+ );
+ });
+ await check('已完整接收的相同课件不重复传输', async () => {
+ await downloadCourseware(item.id);
+ return downloads === 1;
+ });
+ for (const layout of ['fixed', 'custom'] as const) {
+ await check(
+ `${layout === 'fixed' ? '固定岛' : '自定义常驻岛'}显示课件名称、类型、教师和进度`,
+ async () => {
+ saveConfig({ islandLayout: { ...defaultIslandLayouts(), layout } });
+ island.syncCustomLayout();
+ island.setCoursewareTransfer({
+ id: item.id,
+ name: item.name,
+ category: item.category,
+ teacherName: item.teacher.name,
+ receivedBytes: 512000,
+ totalBytes: item.size,
+ progress: 48,
+ stage: 'downloading',
+ });
+ return wait(
+ async () =>
+ !!(await island
+ .getWindow()!
+ .webContents.executeJavaScript(
+ `(() => {const card=document.querySelector('.courseware-transfer');return card?.textContent.includes('课件接收验证.pptx')&&card.textContent.includes('临时课件教师')&&card.textContent.includes('演示文稿')&&card.querySelector('progress')?.value===48;})()`,
+ )),
+ );
+ },
+ );
+ }
+ await check('紧急通知优先展示,关闭后恢复课件进度', async () => {
+ island.pushNotification({
+ id: 'courseware-urgent',
+ title: '紧急通知优先',
+ content: '课件接收继续进行',
+ priority: 'URGENT',
+ createdAt: new Date().toISOString(),
+ });
+ const urgent =
+ island.getState().mode === 'expanded' &&
+ !island.getState().coursewareTransfer &&
+ island.getCoursewareTransfer()?.progress === 48;
+ island.removeNotification('courseware-urgent');
+ return urgent && (await wait(() => island.getState().coursewareTransfer?.progress === 48));
+ });
+ island.setCoursewareTransfer(null);
+ item.id = 'corrupt-courseware';
+ corrupt = true;
+ await check('摘要不匹配时拒绝保存且清理临时文件', async () => {
+ let rejected = false;
+ try {
+ await downloadCourseware(item.id);
+ } catch {
+ rejected = true;
+ }
+ return (
+ rejected &&
+ getCoursewareTransfer()?.stage === 'failed' &&
+ !(await localFiles()).some((file) => file.includes(item.id))
+ );
+ });
+ corrupt = false;
+ slow = true;
+ item.id = 'cancel-courseware';
+ const cancelled = downloadCourseware(item.id).then(
+ () => false,
+ () => true,
+ );
+ await wait(() => getCoursewareTransfer()?.id === item.id && (getCoursewareTransfer()?.progress ?? 0) > 0);
+ saveConfig({ token: null });
+ await check(
+ '切换登录会话会中止接收并清理进度与临时文件',
+ async () =>
+ (await cancelled) &&
+ getCoursewareTransfer() === null &&
+ !(await localFiles()).some((file) => file.endsWith('.part')),
+ );
+ } finally {
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(() => resolve()));
+ saveConfig({
+ serverUrl: original.serverUrl,
+ username: original.username,
+ token: original.token,
+ islandLayout: original.islandLayout,
+ });
+ island.setCoursewareTransfer(null);
+ island.syncCustomLayout();
+ island.getWindow()?.hide();
+ }
+}
diff --git a/packages/desktop-client/src/main/smoke-layout.ts b/packages/desktop-client/src/main/smoke-layout.ts
index 4b73c53..02d56fc 100644
--- a/packages/desktop-client/src/main/smoke-layout.ts
+++ b/packages/desktop-client/src/main/smoke-layout.ts
@@ -43,7 +43,7 @@ export async function verifyLayoutSmoke(
'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))',
)) &&
editor.webContents.executeJavaScript(
- `(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('在下方新建'));b?.click();return Boolean(document.querySelector('.layout-editor')); })()`,
+ `(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='新增行');b?.click();return Boolean(document.querySelector('.island-editor-page .el-card')) && !document.querySelector('.layout-editor, iframe'); })()`,
)
);
});
@@ -52,11 +52,12 @@ export async function verifyLayoutSmoke(
const dragGeometry = await editor.webContents.executeJavaScript(`(() => {
const handle=document.querySelector('.node-handle[data-kind=clock]');
const row=document.querySelectorAll('[data-row]')[1]; if(!handle||!row)throw Error('缺少拖动目标');
+ document.querySelector('.preview-canvas')?.scrollIntoView({block:'center'});
const r=row.getBoundingClientRect(); const x=r.left+Math.min(20,r.width/2),y=r.top+r.height/2;
handle.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:19,pointerType:'touch',clientX:10,clientY:10}));
handle.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
handle.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
- return {row:r.toJSON(), point:{x,y}, hit:document.elementFromPoint(x,y)?.outerHTML.slice(0,600), canvas:document.querySelector('.canvas')?.getBoundingClientRect().toJSON(), stage:document.querySelector('.preview-stage')?.getBoundingClientRect().toJSON()};
+ return {row:r.toJSON(), point:{x,y}, hit:document.elementFromPoint(x,y)?.outerHTML.slice(0,600), canvas:document.querySelector('.preview-canvas')?.getBoundingClientRect().toJSON()};
})()`);
await pause();
const moved =
@@ -66,7 +67,11 @@ export async function verifyLayoutSmoke(
});
await check('组件可以包裹进容器且设置独立保存', async () => {
await editor.webContents.executeJavaScript(
- `(() => { const select=document.querySelector('[data-action=wrap]');if(!select)throw Error('未选中组件');select.value='group';select.dispatchEvent(new Event('change',{bubbles:true})); })()`,
+ `document.querySelector('[data-action=wrap] .el-select__wrapper')?.click()`,
+ );
+ await pause();
+ await editor.webContents.executeJavaScript(
+ `(() => { const option=[...document.querySelectorAll('.el-select-dropdown__item')].find(e=>e.textContent.trim()==='分组'); if(!option)throw Error('容器菜单未出现');option.click(); })()`,
);
await pause();
return (
@@ -77,40 +82,49 @@ export async function verifyLayoutSmoke(
});
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
const initial = await editor.webContents.executeJavaScript(`(() => {
- const panel=document.querySelector('.settings');
- return panel?.textContent.includes('显示内容') && !panel.textContent.includes('字体大小') && !panel.textContent.includes('背景颜色');
+ const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
+ return panel?.textContent.includes('排列方向') && !panel.textContent.includes('背景颜色');
})()`);
await editor.webContents.executeJavaScript(
- `document.querySelectorAll('.panel-tabs button')[1].click()`,
+ `document.querySelectorAll('.component-settings .el-tabs__item')[1].click()`,
);
await pause();
const appearance = await editor.webContents.executeJavaScript(`(() => {
- const panel=document.querySelector('.settings');
- return panel?.textContent.includes('字体大小') && !panel.textContent.includes('组件操作');
+ const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
+ return panel?.textContent.includes('正文字号') && !panel.textContent.includes('排列方向');
})()`);
await editor.webContents.executeJavaScript(
- `document.querySelectorAll('.panel-tabs button')[2].click()`,
+ `document.querySelectorAll('.component-settings .el-tabs__item')[2].click()`,
);
await pause();
const advanced = await editor.webContents.executeJavaScript(`(() => {
- const panel=document.querySelector('.settings');
- return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('字体大小');
+ const panel=[...document.querySelectorAll('.component-settings .el-tab-pane')].find(e=>getComputedStyle(e).display!=='none');
+ return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('背景颜色');
})()`);
await editor.webContents.executeJavaScript(
- `document.querySelectorAll('.panel-tabs button')[0].click()`,
+ `document.querySelectorAll('.component-settings .el-tabs__item')[0].click()`,
);
await pause();
return initial && appearance && advanced;
});
await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => {
return editor.webContents.executeJavaScript(`(() => {
- const stage=document.querySelector('.preview-stage')?.getBoundingClientRect();
- const canvas=document.querySelector('.canvas')?.getBoundingClientRect();
- const footer=document.querySelector('.canvas-footer')?.getBoundingClientRect();
- return Boolean(stage&&canvas&&footer&&stage.left>=canvas.left&&stage.right<=canvas.right&&stage.bottom<=footer.top-4);
+ const canvas=document.querySelector('.preview-canvas')?.getBoundingClientRect();
+ const footer=document.querySelector('.preview-toolbar')?.getBoundingClientRect();
+ return Boolean(canvas&&footer&&canvas.bottom<=footer.top+1&&document.documentElement.scrollWidth<=innerWidth);
+ })()`);
+ });
+ await check('原生编辑页使用客户端主题并兼容深色', async () => {
+ return editor.webContents.executeJavaScript(`(() => {
+ const root=document.documentElement, wasDark=root.classList.contains('dark');
+ root.classList.add('dark');
+ const card=document.querySelector('.scheme-card'), style=getComputedStyle(card);
+ const ok=style.getPropertyValue('--ch-text').trim() && !document.querySelector('.layout-editor, iframe') && document.querySelectorAll('.island-editor-page .el-card').length>=4;
+ root.classList.toggle('dark',wasDark);return Boolean(ok);
})()`);
});
if (process.env.ISLAND_SHOTS_DIR) {
+ await editor.webContents.executeJavaScript(`document.querySelector('.main').scrollTop=0`);
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
fs.writeFileSync(
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-editor.png'),
@@ -118,7 +132,9 @@ export async function verifyLayoutSmoke(
);
}
await check('退出编辑后配置可从 getConfig 读回', async () => {
- await editor.webContents.executeJavaScript('document.querySelector(".done-button")?.click()');
+ await editor.webContents.executeJavaScript(
+ `[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='应用并返回')?.click()`,
+ );
await pause();
return (
!island.getEditingLayout() &&
diff --git a/packages/desktop-client/src/main/smoke-quick.ts b/packages/desktop-client/src/main/smoke-quick.ts
index 4208868..f52a1ac 100644
--- a/packages/desktop-client/src/main/smoke-quick.ts
+++ b/packages/desktop-client/src/main/smoke-quick.ts
@@ -1,3 +1,4 @@
+import { verifyCoursewareSmoke } from './smoke-courseware.js';
import { verifyLayoutSmoke } from './smoke-layout.js';
import { verifyAgentSmoke } from './smoke-agent.js';
import fs from 'node:fs';
@@ -84,6 +85,7 @@ export async function runQuickSmoke(win: BrowserWindow): Promise {
}
await verifyAgentSmoke(check);
await verifyLayoutSmoke(check);
+ await verifyCoursewareSmoke(check);
await check('检查结束时没有可见或聚焦的验证窗口', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
diff --git a/packages/desktop-client/src/preload/index.ts b/packages/desktop-client/src/preload/index.ts
index c816c5a..d3fcf95 100644
--- a/packages/desktop-client/src/preload/index.ts
+++ b/packages/desktop-client/src/preload/index.ts
@@ -1,4 +1,4 @@
-import type { ReleaseProgressDto as ContractReleaseProgressDto } from '@classhelper/shared';
+import type { ReleaseProgressDto as ContractReleaseProgressDto, CoursewareTransfer } from '@classhelper/shared';
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
import type { ClientAgentStatus } from '../main/client-agent.js';
@@ -20,6 +20,15 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/
const bridge: DesktopBridge = {
+ downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
+ revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
+ syncCourseware: () => ipcRenderer.invoke('courseware:sync'),
+ getCoursewareTransfer: () => ipcRenderer.invoke('courseware:get-progress'),
+ onCoursewareTransfer: (handler) => {
+ const listener = (_event: unknown, value: CoursewareTransfer | null) => handler(value);
+ ipcRenderer.on('courseware:progress', listener);
+ return () => ipcRenderer.removeListener('courseware:progress', listener);
+ },
islandEditLayout: () => ipcRenderer.invoke('island:edit-layout'),
onIslandEditorOpen: (handler) => {
const listener = () => handler();
diff --git a/packages/desktop-client/src/renderer/cache/db.ts b/packages/desktop-client/src/renderer/cache/db.ts
index 2342fdd..62ea305 100644
--- a/packages/desktop-client/src/renderer/cache/db.ts
+++ b/packages/desktop-client/src/renderer/cache/db.ts
@@ -10,6 +10,7 @@ import { getCacheScope, scopedKey } from './scope.js';
*/
export const CACHE_STORES = [
+ 'courseware',
'profile',
'classes',
'schedules',
@@ -32,7 +33,7 @@ export interface CacheStoreStat {
}
const DB_NAME = 'classhelper-cache';
-const DB_VERSION = 1;
+const DB_VERSION = 2;
let databasePromise: Promise | null = null;
diff --git a/packages/desktop-client/src/renderer/components/IslandComponentSettings.vue b/packages/desktop-client/src/renderer/components/IslandComponentSettings.vue
new file mode 100644
index 0000000..df08302
--- /dev/null
+++ b/packages/desktop-client/src/renderer/components/IslandComponentSettings.vue
@@ -0,0 +1,254 @@
+
+
+
+
{{ ISLAND_COMPONENT_LABELS[node.kind] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 支持 {date} 日期、{time} 时间、{course} 当前课程。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 此组件使用实时数据。可在外观和高级设置中调整显示。
+
+
+ emit('wrap', kind)"
+ >
+
+
+
+
+ 复制组件
+ 删除
+
+
+
+
+
+
+
+
+ 关闭时沿用灵动岛的整体外观。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 宽度为 0 时自适应;对齐在父容器设定宽度时生效。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/desktop-client/src/renderer/composables/motion.ts b/packages/desktop-client/src/renderer/composables/motion.ts
index 27297a4..a1589ae 100644
--- a/packages/desktop-client/src/renderer/composables/motion.ts
+++ b/packages/desktop-client/src/renderer/composables/motion.ts
@@ -8,7 +8,7 @@
*
* 客户端相较 Web 端多一层现实约束:教室机器多为**希沃触摸屏**,
* 触摸点按会产生粘住的幽灵 hover,因此 `useHoverCapable()` 在客户端是**必需**的,
- * 不是锦上添花(见 AGENTS.md §5 第 30 条)。
+ * 不是锦上添花(见 AGENTS.md 的现行工程约定)。
*/
import { computed, onUnmounted, ref, watch, type CSSProperties, type Ref } from 'vue';
import {
diff --git a/packages/desktop-client/src/renderer/icons.ts b/packages/desktop-client/src/renderer/icons.ts
index aea1c0f..4cc6a00 100644
--- a/packages/desktop-client/src/renderer/icons.ts
+++ b/packages/desktop-client/src/renderer/icons.ts
@@ -11,6 +11,7 @@ import {
Connection,
Expand,
Fold,
+ FolderOpened,
FullScreen,
Guide,
InfoFilled,
@@ -62,6 +63,7 @@ const APP_ICONS = {
Connection,
Expand,
Fold,
+ FolderOpened,
FullScreen,
Guide,
InfoFilled,
diff --git a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue
index 58e5709..d737ab0 100644
--- a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue
+++ b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue
@@ -40,6 +40,7 @@ const primaryItems = [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
+ { path: '/courseware', title: '课件', icon: 'FolderOpened' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
];
diff --git a/packages/desktop-client/src/renderer/router/index.ts b/packages/desktop-client/src/renderer/router/index.ts
index e12a052..8c74827 100644
--- a/packages/desktop-client/src/renderer/router/index.ts
+++ b/packages/desktop-client/src/renderer/router/index.ts
@@ -18,6 +18,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('../layouts/ClientLayout.vue'),
redirect: '/schedule',
children: [
+ { path: 'courseware', name: 'courseware', component: () => import('../views/CoursewareView.vue'), meta: { title: '课件' } },
{
path: 'schedule',
name: 'schedule',
diff --git a/packages/desktop-client/src/renderer/stores/realtime.ts b/packages/desktop-client/src/renderer/stores/realtime.ts
index 93f02aa..fb51a5e 100644
--- a/packages/desktop-client/src/renderer/stores/realtime.ts
+++ b/packages/desktop-client/src/renderer/stores/realtime.ts
@@ -48,6 +48,10 @@ export const useRealtimeStore = defineStore('realtime', () => {
}
function bind(instance: Socket): void {
+ instance.on(SOCKET_EVENTS.coursewareChanged, (payload: { id: string }) => {
+ dispatch(SOCKET_EVENTS.coursewareChanged, payload);
+ void window.desktop?.syncCourseware();
+ });
instance.on(SOCKET_EVENTS.clientReleaseProgress, (payload) => {
dispatch(SOCKET_EVENTS.clientReleaseProgress, payload);
});
diff --git a/packages/desktop-client/src/renderer/styles/index.css b/packages/desktop-client/src/renderer/styles/index.css
index 5b1b7cc..603dc25 100644
--- a/packages/desktop-client/src/renderer/styles/index.css
+++ b/packages/desktop-client/src/renderer/styles/index.css
@@ -784,7 +784,7 @@ textarea {
/*
* 卡片抬升。**必须关在 `@media (hover: hover) and (pointer: fine)` 里**:
* 教室机器的希沃触摸屏点一下会产生"幽灵 hover"并一直粘着,
- * 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md §5 第 30 条)。
+ * 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md 的现行工程约定)。
* 这也正是 `composables/motion.ts` 里 `useHoverCapable()` 的 CSS 版本 ——
* 纯 CSS 效果用媒体查询就够了,不必为它挂一个 composable。
*/
diff --git a/packages/desktop-client/src/renderer/views/CoursewareView.vue b/packages/desktop-client/src/renderer/views/CoursewareView.vue
new file mode 100644
index 0000000..9cc41b0
--- /dev/null
+++ b/packages/desktop-client/src/renderer/views/CoursewareView.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+ {{ transfer.name }}
+
+ {{ COURSEWARE_CATEGORIES[transfer.category] }} · {{ transfer.teacherName }} ·
+ {{
+ transfer.stage === 'completed' ? '接收完成' : transfer.stage === 'failed' ? '接收失败' : '正在接收'
+ }}
+
+
+ {{ transfer.error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
+
+
+
+
+
+ {{ (row.size / 1048576).toFixed(2) }} MB
+
+
+
+
+ 下载
+
+ 打开文件夹
+
+
+
+
+
+
+
diff --git a/packages/desktop-client/src/renderer/views/NotificationView.vue b/packages/desktop-client/src/renderer/views/NotificationView.vue
index 55066e2..ae04a37 100644
--- a/packages/desktop-client/src/renderer/views/NotificationView.vue
+++ b/packages/desktop-client/src/renderer/views/NotificationView.vue
@@ -30,9 +30,9 @@ const visibleItems = computed(() =>
/* ------------------------------------------------------- 按天归档 / 按发布人 / 按类型 */
/**
* 三个筛选都在**已加载的列表**上做,不再请求服务端:
- * 客户端是离线优先的(AGENTS.md §5 第 10 条),断网回退缓存时归档功能必须照常可用。
+ * 客户端是离线优先的(AGENTS.md 的现行工程约定),断网回退缓存时归档功能必须照常可用。
* 口径与服务端一致:按 `assignDate`(本地日期)分组 —— 用 createdAt 的 UTC 切片会把晚上
- * 发出来的通知算到第二天(AGENTS.md §5 第 19 条)。
+ * 发出来的通知算到第二天(AGENTS.md 的现行工程约定)。
*/
const dayFilter = ref('');
const creatorFilter = ref('');
diff --git a/packages/desktop-client/src/renderer/views/settings/IslandEditorView.vue b/packages/desktop-client/src/renderer/views/settings/IslandEditorView.vue
index eec74ec..207e533 100644
--- a/packages/desktop-client/src/renderer/views/settings/IslandEditorView.vue
+++ b/packages/desktop-client/src/renderer/views/settings/IslandEditorView.vue
@@ -1,19 +1,48 @@
-
- {{ error }}
-
+
+
+
+ 重试
+
+
+
+
+
+
+
+
+
+ 复制方案
+ 导入
+ 导出
+
+ 删除方案
+
+
+
+
+
+
+
+
+
+ 实时预览
+
+ 常态内容
+ 无通知展开时
+
+
+
+
+
+ 预览缩放
+
+ 重置
+
+
+
+
+
+
内容结构
+
+ 新增行
+ 添加组件
+
+
+
+ 拖动可调整顺序,或将组件移入分组、轮播等容器。点选后在右侧设置。
+
+
+
+ {{ data.label }}
+ 主要行
+
+
+
+
+
+
+
+
+ 方案
+ 行
+ 组件
+
+
+
+
+ 组件行
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ activeRow.main ? '已设为主要行' : '设为主要行' }}
+
+ 提醒优先使用主要行;关闭提醒后使用下一条允许提醒的行。
+ 删除此行
+
+
+ 方案设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 字体、透明度、位置和动画与灵动岛共用设置。
+
+ 前往灵动岛外观设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ group.label }}
+
+
+
+ {{ ISLAND_COMPONENT_LABELS[kind] }}
+
+
+
+
-
diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts
index 545fc73..ec41cf8 100644
--- a/packages/desktop-client/src/types/desktop.d.ts
+++ b/packages/desktop-client/src/types/desktop.d.ts
@@ -117,6 +117,13 @@ export interface IslandClassStatePayload {
}
export interface DesktopBridge {
+ downloadCourseware(id: string): Promise;
+ revealCourseware(id: string): Promise;
+ syncCourseware(): Promise;
+ getCoursewareTransfer(): Promise;
+ onCoursewareTransfer(
+ handler: (value: import('@classhelper/shared').CoursewareTransfer | null) => void,
+ ): () => void;
islandEditLayout(): Promise;
onIslandEditorOpen(handler: () => void): () => void;
getUpdateProgress(): Promise;
diff --git a/packages/server/package.json b/packages/server/package.json
index 330cd99..128f979 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
- "version": "1.3.3",
+ "version": "1.4.0",
"private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
diff --git a/packages/server/prisma/migrations/20261018000000_add_courseware/migration.sql b/packages/server/prisma/migrations/20261018000000_add_courseware/migration.sql
new file mode 100644
index 0000000..4ed71a7
--- /dev/null
+++ b/packages/server/prisma/migrations/20261018000000_add_courseware/migration.sql
@@ -0,0 +1,35 @@
+CREATE TABLE "Courseware" (
+ "id" TEXT NOT NULL PRIMARY KEY, "ownerId" TEXT NOT NULL, "name" TEXT NOT NULL,
+ "size" INTEGER NOT NULL, "category" TEXT NOT NULL, "extension" TEXT NOT NULL,
+ "archiveDate" TEXT NOT NULL, "storagePath" TEXT NOT NULL, "sha256" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "Courseware_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE UNIQUE INDEX "Courseware_storagePath_key" ON "Courseware"("storagePath");
+CREATE INDEX "Courseware_ownerId_createdAt_idx" ON "Courseware"("ownerId", "createdAt");
+CREATE TABLE "CoursewareVisibility" (
+ "coursewareId" TEXT NOT NULL, "classId" TEXT NOT NULL, PRIMARY KEY ("coursewareId", "classId"),
+ FOREIGN KEY ("coursewareId") REFERENCES "Courseware"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE INDEX "CoursewareVisibility_classId_idx" ON "CoursewareVisibility"("classId");
+CREATE TABLE "CoursewarePush" (
+ "id" TEXT NOT NULL PRIMARY KEY, "coursewareId" TEXT NOT NULL, "classId" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY ("coursewareId") REFERENCES "Courseware"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE INDEX "CoursewarePush_classId_createdAt_idx" ON "CoursewarePush"("classId", "createdAt");
+CREATE TABLE "CoursewareReceipt" (
+ "pushId" TEXT NOT NULL, "deviceId" TEXT NOT NULL, "completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY ("pushId", "deviceId"),
+ FOREIGN KEY ("pushId") REFERENCES "CoursewarePush"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ FOREIGN KEY ("deviceId") REFERENCES "ClientDevice"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+-- 仅为旧默认组和内置教学岗位补充新能力;自定义组仍需管理员按需授权。
+INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
+SELECT 'cw-read-' || "id", "id", 'courseware:read' FROM "PermissionGroup"
+WHERE "defaultRole" IN ('ADMIN', 'TEACHER') OR "id" IN ('builtin-head', 'builtin-teacher');
+INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
+SELECT 'cw-write-' || "id", "id", 'courseware:write' FROM "PermissionGroup"
+WHERE "defaultRole" IN ('ADMIN', 'TEACHER') OR "id" IN ('builtin-head', 'builtin-teacher');
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index d475108..e49b6e7 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -34,6 +34,7 @@ datasource db {
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
model User {
+ coursewares Courseware[]
id String @id @default(cuid())
/// 登录名:教师即**工号**,班级端即班级码
username String @unique
@@ -201,6 +202,8 @@ model TermWeek {
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
model Class {
+ coursewares CoursewareVisibility[]
+ coursewarePushes CoursewarePush[]
id String @id @default(cuid())
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
name String
@@ -501,6 +504,7 @@ model AppSetting {
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
/// 指令按「查库 + 心跳拉取」的范式下发,不新增长连接握手。
model ClientDevice {
+ coursewareReceipts CoursewareReceipt[]
id String @id @default(cuid())
/// 该设备当前登录的班级(未登录时为空)
classId String?
@@ -717,3 +721,50 @@ model PromotionRun {
createdAt DateTime @default(now())
finishedAt DateTime?
}
+/// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。
+model Courseware {
+ id String @id @default(cuid())
+ ownerId String
+ name String
+ size Int
+ category String
+ extension String
+ archiveDate String
+ storagePath String @unique
+ sha256 String
+ createdAt DateTime @default(now())
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
+ visibility CoursewareVisibility[]
+ pushes CoursewarePush[]
+ @@index([ownerId, createdAt])
+}
+
+model CoursewareVisibility {
+ coursewareId String
+ classId String
+ courseware Courseware @relation(fields: [coursewareId], references: [id], onDelete: Cascade)
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
+ @@id([coursewareId, classId])
+ @@index([classId])
+}
+
+/// 下发持久化,班级离线时不丢失;每台设备完成下载后单独回执。
+model CoursewarePush {
+ id String @id @default(cuid())
+ coursewareId String
+ classId String
+ createdAt DateTime @default(now())
+ courseware Courseware @relation(fields: [coursewareId], references: [id], onDelete: Cascade)
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
+ receipts CoursewareReceipt[]
+ @@index([classId, createdAt])
+}
+
+model CoursewareReceipt {
+ pushId String
+ deviceId String
+ completedAt DateTime @default(now())
+ push CoursewarePush @relation(fields: [pushId], references: [id], onDelete: Cascade)
+ device ClientDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
+ @@id([pushId, deviceId])
+}
diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts
index ba6d0d2..0fd7b53 100644
--- a/packages/server/prisma/seed.ts
+++ b/packages/server/prisma/seed.ts
@@ -78,6 +78,7 @@ async function resetDatabase(): Promise {
await prisma.clientCommand.deleteMany();
await prisma.clientDevice.deleteMany();
await prisma.clientRelease.deleteMany();
+ await prisma.courseware.deleteMany();
await prisma.aiAudit.deleteMany();
await prisma.userPermissionGroup.deleteMany();
await prisma.permissionGroupItem.deleteMany();
diff --git a/packages/server/scripts/verify-courseware.ts b/packages/server/scripts/verify-courseware.ts
new file mode 100644
index 0000000..5db9cc3
--- /dev/null
+++ b/packages/server/scripts/verify-courseware.ts
@@ -0,0 +1,330 @@
+import type { AddressInfo } from 'node:net';
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import http from 'node:http';
+import { createHash } from 'node:crypto';
+import { once } from 'node:events';
+import { createApp } from '../src/app.js';
+import { prisma } from '../src/lib/db.js';
+import { signToken } from '../src/lib/jwt.js';
+import { exportSnapshot, assertValidSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
+import { ensureDefaultGroups } from '../src/lib/permissions.js';
+import { registerEmitter } from '../src/realtime/bus.js';
+import {
+ COURSEWARE_MAX_BYTES,
+ SOCKET_EVENTS,
+ coursewareCategory,
+ dayKeyLocal,
+ type CoursewareDto,
+} from '@classhelper/shared';
+if (
+ !process.env.DATABASE_URL?.includes('v14-verification') ||
+ !process.env.COURSEWARE_DIR?.includes('v14-verification')
+)
+ throw new Error('课件验收只能使用临时库和文件目录');
+let count = 0;
+function check(name: string, value: unknown) {
+ assert.ok(value, name);
+ console.log(`通过 ${++count}:${name}`);
+}
+const emitted: { rooms: string[]; event: string; payload: unknown }[] = [];
+registerEmitter((rooms, event, payload) => emitted.push({ rooms, event, payload }));
+const staff = await Promise.all(
+ ['课件教师甲', '课件教师乙', '仅班主任', '临时管理员'].map((name, i) =>
+ prisma.user.create({
+ data: {
+ name,
+ username: `cw-${Date.now()}-${i}`,
+ passwordHash: '仅签发临时令牌',
+ role: i === 3 ? 'ADMIN' : 'TEACHER',
+ },
+ }),
+ ),
+);
+const tokens = staff.map((user) =>
+ signToken({
+ sub: user.id,
+ username: user.username,
+ name: user.name,
+ role: user.role as 'ADMIN' | 'TEACHER',
+ classId: null,
+ }),
+);
+const classes = await Promise.all(
+ [0, 1, 2].map((i) =>
+ prisma.class.create({
+ data: {
+ name: `课件验证班${i + 1}`,
+ grade: '验证',
+ code: `CW${Date.now()}${i}`,
+ teacherId: staff[2]!.id,
+ },
+ }),
+ ),
+);
+await prisma.course.createMany({
+ data: classes.map((c, i) => ({ name: '语文', classId: c.id, teacherId: staff[i === 2 ? 1 : 0]!.id })),
+});
+const classTokens = classes.map((c) =>
+ signToken({
+ sub: c.id,
+ classId: c.id,
+ username: c.code,
+ name: c.name,
+ role: 'CLASS_DEVICE',
+ classSession: true,
+ }),
+);
+await ensureDefaultGroups();
+let server = http.createServer(createApp());
+await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+const base = () => `http://127.0.0.1:${(server.address() as AddressInfo).port}/api`;
+async function request(route: string, token = tokens[0], body?: unknown, method = body ? 'POST' : 'GET') {
+ const res = await fetch(`${base()}${route}`, {
+ method,
+ headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: AbortSignal.timeout(10000),
+ });
+ return { status: res.status, ...(await res.json()) };
+}
+const bytes = Buffer.from('ClassHelper 课件流式上传验收\n'.repeat(3000));
+async function upload(
+ options: Partial<{ name: string; size: number; classIds: string[]; mode: string }> = {},
+ token = tokens[0],
+ data = bytes,
+) {
+ const meta = {
+ name: '第1课 演示.pptx',
+ size: data.length,
+ classIds: [classes[0]!.id],
+ mode: 'cloud',
+ ...options,
+ };
+ const res = await fetch(
+ `${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(meta))}`,
+ {
+ method: 'POST',
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' },
+ body: data,
+ signal: AbortSignal.timeout(10000),
+ },
+ );
+ return { status: res.status, ...(await res.json()) };
+}
+const list = async (token: string) => (await request('/courseware', token)).data as CoursewareDto[];
+const heartbeat = async (i: number, key: string) =>
+ request('/clients/heartbeat', classTokens[i], {
+ deviceKey: key,
+ clientVersion: '1.4.0',
+ platform: 'verification',
+ });
+const pending = async (i: number, key: string) =>
+ request(`/courseware/transfers?deviceKey=${key}`, classTokens[i]);
+async function files(dir = process.env.COURSEWARE_DIR!): Promise {
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
+ return (
+ await Promise.all(
+ entries.map((entry) =>
+ entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)],
+ ),
+ )
+ ).flat();
+}
+try {
+ check('未登录不能列出课件', (await request('/courseware', '')).status === 401);
+ const ownClasses = (await request('/courseware/classes')).data;
+ check(
+ '只能选择自己的授课班级',
+ ownClasses.length === 2 && !ownClasses.some((c: { id: string }) => c.id === classes[2]!.id),
+ );
+ check(
+ '仅班主任而无任课关系不能选择班级',
+ (await request('/courseware/classes', tokens[2])).data.length === 0,
+ );
+ check('管理员上传同样只能选择本人任课班级', (await upload({}, tokens[3])).status === 403);
+ check('班级端不能上传', (await upload({}, classTokens[0])).status === 403);
+ check('拒绝非任课班级上传', (await upload({ classIds: [classes[2]!.id] })).status === 403);
+ check('拒绝目录穿越文件名', (await upload({ name: '../secret.pptx' })).status === 422);
+ check('拒绝空文件', (await upload({ size: 0 }, tokens[0], Buffer.alloc(0))).status === 422);
+ check('拒绝超过 512 MB 的课件', (await upload({ size: COURSEWARE_MAX_BYTES + 1 })).status === 422);
+ check('拒绝错误文件长度', (await upload({ size: 1 })).status === 400);
+ check('推送不能不选班级', (await upload({ mode: 'push', classIds: [] })).status === 422);
+ const created = await upload();
+ const item = created.data as CoursewareDto;
+ check(
+ '完整上传后创建课件元数据',
+ created.status === 201 && item.size === bytes.length && item.name === '第1课 演示.pptx',
+ );
+ check(
+ '摘要、日期与类型正确',
+ item.sha256 === createHash('sha256').update(bytes).digest('hex') &&
+ item.archiveDate === dayKeyLocal(new Date()) &&
+ item.category === 'presentation',
+ );
+ check('API 不暴露服务端路径', !('storagePath' in item));
+ const stored = await prisma.courseware.findUniqueOrThrow({ where: { id: item.id } });
+ check(
+ '文件按日期和类型保存',
+ stored.storagePath.startsWith(path.join(item.archiveDate, 'presentation')) &&
+ (await fs.readFile(path.join(process.env.COURSEWARE_DIR!, stored.storagePath))).equals(bytes),
+ );
+ check(
+ '班级仅看到自己可见的课件',
+ (await list(classTokens[0]!)).length === 1 && (await list(classTokens[1]!)).length === 0,
+ );
+ check('其他教师不能访问本人文件', (await request(`/courseware/${item.id}`, tokens[1])).status === 403);
+ check(
+ '其他班级不能绕过列表下载',
+ (await request(`/courseware/${item.id}/download`, classTokens[1])).status === 403,
+ );
+ const downloaded = await fetch(`${base()}/courseware/${item.id}/download`, {
+ headers: { authorization: `Bearer ${classTokens[0]}` },
+ });
+ check(
+ '中文文件按附件下载且内容一致',
+ downloaded.headers.get('content-disposition')?.includes('attachment') &&
+ Buffer.from(await downloaded.arrayBuffer()).equals(bytes),
+ );
+ check('下载响应禁止共享缓存', downloaded.headers.get('cache-control')?.includes('no-store'));
+ await heartbeat(0, 'courseware-device-1');
+ await heartbeat(0, 'courseware-device-2');
+ await heartbeat(1, 'courseware-device-3');
+ check('仅存云端不会自动发送任务', (await pending(0, 'courseware-device-1')).data.length === 0);
+ check('未登记设备不能拉取传输', (await pending(0, 'unknown-device')).status === 403);
+ check('不能使用其他班级设备标识', (await pending(1, 'courseware-device-1')).status === 403);
+ const privateItem = (await upload({ classIds: [], name: '私有讲义.pdf' })).data as CoursewareDto;
+ check('仅自己可见文件不向班级披露', !(await list(classTokens[0]!)).some((c) => c.id === privateItem.id));
+ check(
+ '无法推送到课件可见范围以外',
+ (await request(`/courseware/${item.id}/push`, tokens[0], { classIds: [classes[1]!.id] })).status === 400,
+ );
+ check(
+ '他人不能改可见范围',
+ (await request(`/courseware/${item.id}/visibility`, tokens[1], { classIds: [] }, 'PATCH')).status === 403,
+ );
+ const pushed = (
+ await upload({ mode: 'push', name: '课堂练习.docx', classIds: classes.slice(0, 2).map((c) => c.id) })
+ ).data as CoursewareDto;
+ check(
+ '推送模式先存云端实体',
+ !!(await prisma.courseware.findUnique({ where: { id: pushed.id } })) &&
+ (await list(tokens[0]!)).some((c) => c.id === pushed.id),
+ );
+ check(
+ '班级端不泄露其他可见班级',
+ (await list(classTokens[0]!)).every((c) => c.classes.every((v) => v.id === classes[0]!.id)),
+ );
+ const task = (await pending(0, 'courseware-device-1')).data[0];
+ check('推送任务可由离线班级下次上线拉取', task.courseware.id === pushed.id);
+ check(
+ '实时提示只发送到所选班级房间',
+ emitted
+ .filter((e) => e.event === SOCKET_EVENTS.coursewareChanged)
+ .every((e) =>
+ e.rooms.every((room) => [classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}`)),
+ ),
+ );
+ await new Promise((resolve) => server.close(() => resolve()));
+ server = http.createServer(createApp());
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ check('重启服务端后仍保留未完成传输', (await pending(0, 'courseware-device-1')).data[0].id === task.id);
+ const ackRoute = `/courseware/transfers/${task.id}/ack`;
+ check(
+ '其他班级不能回执任务',
+ (await request(ackRoute, classTokens[1], { deviceKey: 'courseware-device-3' })).status === 404,
+ );
+ check(
+ '接收完成后可回执',
+ (await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
+ );
+ check(
+ '重复回执幂等',
+ (await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
+ );
+ check(
+ '回执仅清除本设备任务',
+ (await pending(0, 'courseware-device-1')).data.length === 0 &&
+ (await pending(0, 'courseware-device-2')).data.length === 1,
+ );
+ await request(`/courseware/${pushed.id}/visibility`, tokens[0], { classIds: [classes[0]!.id] }, 'PATCH');
+ check(
+ '撤回班级可见范围同时取消其待传任务',
+ (await pending(1, 'courseware-device-3')).data.length === 0 &&
+ !(await list(classTokens[1]!)).some((c) => c.id === pushed.id),
+ );
+ check(
+ '撤回后不能通过直链下载',
+ (await request(`/courseware/${pushed.id}/download`, classTokens[1])).status === 403,
+ );
+ await request(`/courseware/${pushed.id}/push`, tokens[0], { classIds: [classes[0]!.id] });
+ check('已归档课件可再次推送', (await pending(0, 'courseware-device-1')).data.length === 1);
+ const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
+ await prisma.permissionGroupItem.deleteMany({
+ where: { groupId: defaultGroup.id, key: 'courseware:write' },
+ });
+ check('收回写权限立即禁止上传', (await upload()).status === 403);
+ await prisma.permissionGroupItem.create({ data: { groupId: defaultGroup.id, key: 'courseware:write' } });
+ const snapshot = await exportSnapshot(prisma, 'sqlite');
+ check(
+ '备份包含课件、可见范围、推送和回执',
+ snapshot.data.Courseware.length === 3 &&
+ snapshot.data.CoursewareReceipt.length === 1 &&
+ snapshot.data.CoursewarePush.length > 0 &&
+ snapshot.data.CoursewareVisibility.length > 0,
+ );
+ const oldSnapshot = structuredClone(snapshot);
+ for (const key of ['Courseware', 'CoursewarePush', 'CoursewareReceipt', 'CoursewareVisibility'])
+ delete (oldSnapshot.data as Record)[key];
+ assertValidSnapshot(oldSnapshot);
+ check('允许读取升级前的旧版快照', true);
+ await request(`/courseware/${pushed.id}`, tokens[0], undefined, 'DELETE');
+ check(
+ '删除课件级联清理推送任务',
+ (await pending(0, 'courseware-device-1')).data.length === 0 &&
+ (await pending(0, 'courseware-device-2')).data.length === 0,
+ );
+ check('删除实体后无残余文件', (await files()).length === 2);
+ const before = (await files()).length;
+ await new Promise((resolve) => {
+ const metadata = { name: '中断上传.zip', size: 100000, classIds: [], mode: 'cloud' };
+ const req = http.request(
+ `${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(metadata))}`,
+ {
+ method: 'POST',
+ headers: {
+ authorization: `Bearer ${tokens[0]}`,
+ 'content-type': 'application/octet-stream',
+ 'content-length': '100000',
+ },
+ },
+ );
+ req.on('error', () => resolve());
+ req.write(Buffer.alloc(1000));
+ setTimeout(() => req.destroy(), 250);
+ });
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ check(
+ '上传中断自动清理临时文件',
+ (await files()).length === before && !(await files()).some((file) => file.endsWith('.part')),
+ );
+ check(
+ '文件分类覆盖演示、文档、媒体与压缩包',
+ coursewareCategory('A.PPTX') === 'presentation' &&
+ coursewareCategory('a.pdf') === 'document' &&
+ coursewareCategory('v.mp4') === 'video' &&
+ coursewareCategory('a.7z') === 'archive',
+ );
+ await restoreSnapshot(prisma, 'sqlite', snapshot);
+ check(
+ '恢复数据库后保留课件共享和回执数据',
+ (await prisma.courseware.count()) === 3 && (await prisma.coursewareReceipt.count()) === 1,
+ );
+ console.log(`=== 课件隔离验收:${count}/${count} 项通过 ===`);
+} finally {
+ server.closeAllConnections();
+ server.close();
+ await once(server, 'close').catch(() => undefined);
+ await prisma.$disconnect();
+}
diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs
index a5fec90..a42b9fd 100644
--- a/packages/server/scripts/verify-e2e.mjs
+++ b/packages/server/scripts/verify-e2e.mjs
@@ -3222,6 +3222,10 @@ async function main() {
'UserPermissionGroup',
'AppSetting',
'ClientDevice',
+ 'Courseware',
+ 'CoursewareVisibility',
+ 'CoursewarePush',
+ 'CoursewareReceipt',
'ClientCommand',
'ClientRelease',
'AiAudit',
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 722113d..7b0bb6e 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -1,3 +1,4 @@
+import { COURSEWARE_TRANSFER_TIMEOUT_MS } from '@classhelper/shared';
import fs from 'node:fs';
import path from 'node:path';
import { createServer } from 'node:http';
@@ -61,6 +62,8 @@ await refreshDeveloperMode();
const app = createApp();
const httpServer = createServer(app);
+// 大课件允许较慢的内网上传;请求头仍沿用 Node 的独立超时限制。
+httpServer.requestTimeout = COURSEWARE_TRANSFER_TIMEOUT_MS;
const io = initRealtime(httpServer);
/** 写入 PID 文件(安装包的 start.cmd / stop.cmd 依赖它精确控制进程) */
diff --git a/packages/server/src/lib/snapshot.ts b/packages/server/src/lib/snapshot.ts
index 1e761c3..253c8b9 100644
--- a/packages/server/src/lib/snapshot.ts
+++ b/packages/server/src/lib/snapshot.ts
@@ -59,6 +59,10 @@ export const SNAPSHOT_TABLES = [
'NotificationRead',
'Grade',
'ClientDevice',
+ 'Courseware',
+ 'CoursewareVisibility',
+ 'CoursewarePush',
+ 'CoursewareReceipt',
'ClientCommand',
'ClientRelease',
'AiAudit',
@@ -132,6 +136,8 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
}
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data as Record)[table];
+ // 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。
+ if (rows === undefined && table.startsWith('Courseware')) continue;
if (!Array.isArray(rows)) {
throw new Error(`快照缺少表 ${table} 的数据`);
}
@@ -174,7 +180,7 @@ export async function restoreSnapshot(
}
}
for (const table of SNAPSHOT_TABLES) {
- const rows = snapshot.data[table].map((row) => {
+ const rows = (snapshot.data[table] ?? []).map((row) => {
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
const copy = { ...(row as Record) };
delete copy.notificationChannel;
diff --git a/packages/server/src/modules/ai/ai.module.ts b/packages/server/src/modules/ai/ai.module.ts
index 8ad6878..5b9281b 100644
--- a/packages/server/src/modules/ai/ai.module.ts
+++ b/packages/server/src/modules/ai/ai.module.ts
@@ -22,7 +22,7 @@ router.use(authenticate());
*
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
- * 都在那条路径上(AGENTS.md §5 第 5/14 条)。
+ * 都在那条路径上(AGENTS.md 的现行工程约定)。
*/
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
diff --git a/packages/server/src/modules/ai/ai.service.ts b/packages/server/src/modules/ai/ai.service.ts
index 018602e..6a46f59 100644
--- a/packages/server/src/modules/ai/ai.service.ts
+++ b/packages/server/src/modules/ai/ai.service.ts
@@ -24,7 +24,7 @@ import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
* 设计边界(照 approved 计划):
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
- * 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md §5 第 5/14 条);
+ * 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md 的现行工程约定);
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
diff --git a/packages/server/src/modules/archives/archives.module.ts b/packages/server/src/modules/archives/archives.module.ts
index 409ccaa..0c7e14c 100644
--- a/packages/server/src/modules/archives/archives.module.ts
+++ b/packages/server/src/modules/archives/archives.module.ts
@@ -30,7 +30,7 @@ router.post(
*
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
* 通过 `GET /:id/classes/:classId/content` 只读查看。
- * 「未毕业而升级的班级不归档」—— 升级走 `POST /api/classes/promote`,只改年级。
+ * 年度升级走 `POST /api/term/promotion/run`,毕业班归档,其余升一级;本模块读取归档结果。
*/
/** GET /api/archives - 届别列表 */
diff --git a/packages/server/src/modules/clients/clients.service.ts b/packages/server/src/modules/clients/clients.service.ts
index 88ed25f..dca3fae 100644
--- a/packages/server/src/modules/clients/clients.service.ts
+++ b/packages/server/src/modules/clients/clients.service.ts
@@ -27,7 +27,7 @@ import type {
/**
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
*
- * 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
+ * 范式(AGENTS.md 的现行工程约定):**DB 记状态 + 客户端定时拉取** ——
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
* 由客户端下一次心跳(30 秒内)带走。
*/
diff --git a/packages/server/src/modules/courseware/courseware.module.ts b/packages/server/src/modules/courseware/courseware.module.ts
new file mode 100644
index 0000000..8570ee8
--- /dev/null
+++ b/packages/server/src/modules/courseware/courseware.module.ts
@@ -0,0 +1,109 @@
+import { Router } from 'express';
+import { sendOk, sendCreated } from '../../lib/http.js';
+import { idParamSchema } from '../../lib/schemas.js';
+import {
+ authenticate,
+ getAuthUser,
+ requireRole,
+ requirePermission,
+ requireStaffPermission,
+} from '../../middleware/auth.js';
+import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
+import { defineModule } from '../module.types.js';
+import { coursewareDeviceQuery, coursewareScopeSchema, coursewareUploadQuery } from './courseware.schemas.js';
+import type { CoursewareUploadRequest } from '@classhelper/shared';
+import * as service from './courseware.service.js';
+const router = Router();
+router.use(authenticate());
+const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
+router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
+ sendOk(res, await service.listCourseware(getAuthUser(req))),
+);
+router.get('/classes', ...staff, async (req, res) =>
+ sendOk(res, await service.teachingClasses(getAuthUser(req))),
+);
+router.get(
+ '/transfers',
+ requireRole('CLASS_DEVICE'),
+ validate({ query: coursewareDeviceQuery }),
+ async (req, res) =>
+ sendOk(
+ res,
+ await service.pendingTransfers(getAuthUser(req), validatedQuery<{ deviceKey: string }>(req).deviceKey),
+ ),
+);
+router.post(
+ '/transfers/:id/ack',
+ requireRole('CLASS_DEVICE'),
+ validate({ params: idParamSchema, body: coursewareDeviceQuery }),
+ async (req, res) => {
+ await service.acknowledgeTransfer(
+ getAuthUser(req),
+ validatedParams<{ id: string }>(req).id,
+ validatedBody<{ deviceKey: string }>(req).deviceKey,
+ );
+ sendOk(res, null);
+ },
+);
+router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), async (req, res) =>
+ sendCreated(
+ res,
+ await service.uploadCourseware(
+ getAuthUser(req),
+ validatedQuery<{ metadata: CoursewareUploadRequest }>(req).metadata,
+ req,
+ ),
+ ),
+);
+router.get(
+ '/:id/download',
+ requireStaffPermission('courseware:read'),
+ validate({ params: idParamSchema }),
+ async (req, res, next) => {
+ const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id);
+ res.setHeader('Cache-Control', 'private, no-store');
+ res.setHeader('Content-Type', 'application/octet-stream');
+ res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
+ if (error && !res.headersSent) next(error);
+ });
+ },
+);
+router.patch(
+ '/:id/visibility',
+ ...staff,
+ validate({ params: idParamSchema, body: coursewareScopeSchema }),
+ async (req, res) =>
+ sendOk(
+ res,
+ await service.setVisibility(
+ getAuthUser(req),
+ validatedParams<{ id: string }>(req).id,
+ validatedBody<{ classIds: string[] }>(req).classIds,
+ ),
+ ),
+);
+router.post(
+ '/:id/push',
+ ...staff,
+ validate({ params: idParamSchema, body: coursewareScopeSchema }),
+ async (req, res) => {
+ await service.pushCourseware(
+ getAuthUser(req),
+ validatedParams<{ id: string }>(req).id,
+ validatedBody<{ classIds: string[] }>(req).classIds,
+ );
+ sendOk(res, null);
+ },
+);
+router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req, res) => {
+ await service.deleteCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id);
+ sendOk(res, null);
+});
+router.get(
+ '/:id',
+ requireStaffPermission('courseware:read'),
+ validate({ params: idParamSchema }),
+ async (req, res) =>
+ sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
+);
+export const coursewareModule = defineModule({ name: 'courseware', basePath: '/courseware', router });
diff --git a/packages/server/src/modules/courseware/courseware.schemas.ts b/packages/server/src/modules/courseware/courseware.schemas.ts
new file mode 100644
index 0000000..54a898d
--- /dev/null
+++ b/packages/server/src/modules/courseware/courseware.schemas.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+import { COURSEWARE_MAX_BYTES } from '@classhelper/shared';
+
+export const coursewareScopeSchema = z.object({ classIds: z.array(z.string().min(1).max(100)).max(100) });
+export const coursewareUploadSchema = coursewareScopeSchema
+ .extend({
+ name: z
+ .string()
+ .trim()
+ .min(1)
+ .max(180)
+ .refine(
+ (name) => !/[<>:"/\\|?*\u0000-\u001f]/.test(name) && !name.endsWith('.') && !name.endsWith(' '),
+ '文件名含无效字符',
+ ),
+ size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
+ mode: z.enum(['cloud', 'push']),
+ })
+ .refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
+export const coursewareUploadQuery = z.object({
+ metadata: z
+ .string()
+ .transform((value, ctx): unknown => {
+ try {
+ return JSON.parse(value);
+ } catch {
+ ctx.addIssue({ code: 'custom', message: '上传信息格式错误' });
+ return z.NEVER;
+ }
+ })
+ .pipe(coursewareUploadSchema),
+});
+export const coursewareDeviceQuery = z.object({ deviceKey: z.string().min(1).max(100) });
diff --git a/packages/server/src/modules/courseware/courseware.service.ts b/packages/server/src/modules/courseware/courseware.service.ts
new file mode 100644
index 0000000..778b921
--- /dev/null
+++ b/packages/server/src/modules/courseware/courseware.service.ts
@@ -0,0 +1,228 @@
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
+import path from 'node:path';
+import { randomUUID, createHash } from 'node:crypto';
+import { Transform } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+import type { Request } from 'express';
+import {
+ COURSEWARE_MAX_BYTES,
+ coursewareCategory,
+ dayKeyLocal,
+ SOCKET_EVENTS,
+ type CoursewareDto,
+ type CoursewareUploadRequest,
+} from '@classhelper/shared';
+import { env } from '../../config/env.js';
+import { prisma } from '../../lib/db.js';
+import { ApiError } from '../../lib/http.js';
+import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js';
+import type { TokenPayload } from '../../lib/jwt.js';
+import type { Prisma } from '../../generated/prisma/client.js';
+import { emitToClass } from '../../realtime/bus.js';
+
+export const coursewareRoot = path.resolve(
+ process.env.COURSEWARE_DIR || path.join(env.serverRoot, '..', 'data', 'courseware'),
+);
+const include = {
+ owner: { select: { id: true, name: true } },
+ visibility: { include: { class: { select: { id: true, name: true } } } },
+} as const;
+type RecordWithRelations = Prisma.CoursewareGetPayload<{ include: typeof include }>;
+function dto(row: RecordWithRelations): CoursewareDto {
+ return {
+ id: row.id,
+ name: row.name,
+ size: row.size,
+ category: row.category as CoursewareDto['category'],
+ extension: row.extension,
+ archiveDate: row.archiveDate,
+ sha256: row.sha256,
+ createdAt: row.createdAt.toISOString(),
+ teacher: row.owner,
+ classes: row.visibility.map((item) => item.class),
+ };
+}
+export async function teachingClasses(user: TokenPayload) {
+ return prisma.class.findMany({
+ where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } },
+ select: { id: true, name: true },
+ orderBy: { name: 'asc' },
+ });
+}
+async function validateClasses(user: TokenPayload, ids: string[]) {
+ const classIds = [...new Set(ids)];
+ const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
+ if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
+ for (const classId of classIds) {
+ const course = await prisma.course.findFirst({
+ where: { classId, teacherId: user.sub },
+ select: { id: true },
+ });
+ await assertCanManageSubjectContent(user, classId, course?.id, '发布课件');
+ }
+ return classIds;
+}
+export async function readable(user: TokenPayload, id: string) {
+ const row = await prisma.courseware.findUnique({ where: { id }, include });
+ if (!row) throw ApiError.notFound('课件不存在');
+ if (user.role === 'CLASS_DEVICE') {
+ if (!row.visibility.some((item) => item.classId === requireClassDeviceClassId(user)))
+ throw ApiError.forbidden('课件不在本班可见范围内');
+ } else if (row.ownerId !== user.sub && user.role !== 'ADMIN')
+ throw ApiError.forbidden('只能访问自己的课件');
+ return row;
+}
+async function owned(user: TokenPayload, id: string) {
+ const row = await readable(user, id);
+ if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
+ return row;
+}
+export async function getCourseware(user: TokenPayload, id: string) {
+ const item = dto(await readable(user, id));
+ if (user.role === 'CLASS_DEVICE')
+ item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
+ return item;
+}
+export async function listCourseware(user: TokenPayload) {
+ const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
+ const rows = await prisma.courseware.findMany({
+ where: classId
+ ? { visibility: { some: { classId } } }
+ : user.role === 'ADMIN'
+ ? {}
+ : { ownerId: user.sub },
+ include,
+ orderBy: { createdAt: 'desc' },
+ });
+ // 班级端只暴露本班的可见关系。
+ return rows.map((row) => {
+ const item = dto(row);
+ if (classId) item.classes = item.classes.filter((c) => c.id === classId);
+ return item;
+ });
+}
+export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
+ const classIds = await validateClasses(user, input.classIds);
+ if (!req.is('application/octet-stream')) throw ApiError.badRequest('请使用二进制文件上传');
+ if (req.headers['content-length'] && Number(req.headers['content-length']) !== input.size)
+ throw ApiError.badRequest('文件长度与上传信息不符');
+ const category = coursewareCategory(input.name),
+ archiveDate = dayKeyLocal(new Date());
+ const extension = path.extname(input.name).slice(1).toLowerCase();
+ const relative = path.join(archiveDate, category, randomUUID());
+ const file = path.join(coursewareRoot, relative),
+ partial = `${file}.part`;
+ await fsp.mkdir(path.dirname(file), { recursive: true });
+ let bytes = 0;
+ const hash = createHash('sha256');
+ try {
+ await pipeline(
+ req,
+ new Transform({
+ transform(chunk: Buffer, _encoding, callback) {
+ bytes += chunk.length;
+ if (bytes > COURSEWARE_MAX_BYTES || bytes > input.size)
+ return callback(ApiError.badRequest('课件超过上传大小限制'));
+ hash.update(chunk);
+ callback(null, chunk);
+ },
+ }),
+ fs.createWriteStream(partial, { flags: 'wx' }),
+ );
+ if (bytes !== input.size) throw ApiError.badRequest('文件上传不完整,请重试');
+ await fsp.rename(partial, file);
+ // 上传耗时较长,完成后重新验证任课关系。
+ await validateClasses(user, classIds);
+ const row = await prisma.courseware.create({
+ data: {
+ ownerId: user.sub,
+ name: input.name,
+ size: bytes,
+ category,
+ extension,
+ archiveDate,
+ storagePath: relative,
+ sha256: hash.digest('hex'),
+ visibility: { create: classIds.map((classId) => ({ classId })) },
+ pushes: input.mode === 'push' ? { create: classIds.map((classId) => ({ classId })) } : undefined,
+ },
+ include,
+ });
+ for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id: row.id });
+ return dto(row);
+ } catch (error) {
+ await Promise.all([fsp.rm(partial, { force: true }), fsp.rm(file, { force: true })]);
+ throw error;
+ }
+}
+export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
+ await owned(user, id);
+ const classIds = await validateClasses(user, ids);
+ const old = await prisma.coursewareVisibility.findMany({ where: { coursewareId: id } });
+ const row = await prisma.$transaction(async (tx) => {
+ await tx.coursewarePush.deleteMany({ where: { coursewareId: id, classId: { notIn: classIds } } });
+ await tx.coursewareVisibility.deleteMany({ where: { coursewareId: id } });
+ return tx.courseware.update({
+ where: { id },
+ data: { visibility: { create: classIds.map((classId) => ({ classId })) } },
+ include,
+ });
+ });
+ for (const classId of new Set([...classIds, ...old.map((v) => v.classId)]))
+ emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
+ return dto(row);
+}
+export async function pushCourseware(user: TokenPayload, id: string, ids: string[]) {
+ const row = await owned(user, id);
+ const classIds = await validateClasses(user, ids);
+ if (!classIds.length || classIds.some((classId) => !row.visibility.some((v) => v.classId === classId)))
+ throw ApiError.badRequest('推送班级必须在课件可见范围内');
+ await prisma.$transaction(
+ classIds.map((classId) => prisma.coursewarePush.create({ data: { coursewareId: id, classId } })),
+ );
+ for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
+}
+export async function deleteCourseware(user: TokenPayload, id: string) {
+ const row = await owned(user, id);
+ await prisma.courseware.delete({ where: { id } });
+ await fsp.rm(path.join(coursewareRoot, row.storagePath), { force: true });
+ for (const v of row.visibility) emitToClass(v.classId, SOCKET_EVENTS.coursewareChanged, { id });
+}
+export async function deviceFor(user: TokenPayload, deviceKey: string) {
+ const classId = requireClassDeviceClassId(user);
+ const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
+ if (!device || device.classId !== classId) throw ApiError.forbidden('设备未登记或不属于当前班级');
+ return { ...device, classId };
+}
+export async function pendingTransfers(user: TokenPayload, deviceKey: string) {
+ const device = await deviceFor(user, deviceKey);
+ const rows = await prisma.coursewarePush.findMany({
+ where: {
+ classId: device.classId,
+ receipts: { none: { deviceId: device.id } },
+ courseware: { visibility: { some: { classId: device.classId } } },
+ },
+ include: { courseware: { include } },
+ orderBy: { createdAt: 'asc' },
+ take: 50,
+ });
+ return rows.map((row) => ({
+ id: row.id,
+ courseware: {
+ ...dto(row.courseware),
+ classes: dto(row.courseware).classes.filter((c) => c.id === device.classId),
+ },
+ }));
+}
+export async function acknowledgeTransfer(user: TokenPayload, pushId: string, deviceKey: string) {
+ const device = await deviceFor(user, deviceKey);
+ const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
+ if (!push) throw ApiError.notFound('传输任务不存在');
+ await readable(user, push.coursewareId);
+ await prisma.coursewareReceipt.upsert({
+ where: { pushId_deviceId: { pushId, deviceId: device.id } },
+ create: { pushId, deviceId: device.id },
+ update: {},
+ });
+}
diff --git a/packages/server/src/modules/homeworks/homeworks.module.ts b/packages/server/src/modules/homeworks/homeworks.module.ts
index b2a13dd..7999a70 100644
--- a/packages/server/src/modules/homeworks/homeworks.module.ts
+++ b/packages/server/src/modules/homeworks/homeworks.module.ts
@@ -77,7 +77,7 @@ router.post(
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
*
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
- * 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
+ * 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
* 批量不是绕过科目门的后门(§5 第 5 条)。
*/
diff --git a/packages/server/src/modules/homeworks/homeworks.service.ts b/packages/server/src/modules/homeworks/homeworks.service.ts
index 9318849..36e74f9 100644
--- a/packages/server/src/modules/homeworks/homeworks.service.ts
+++ b/packages/server/src/modules/homeworks/homeworks.service.ts
@@ -149,7 +149,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
* - **没选科目** → 本班班主任或管理员;
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
- * 见 AGENTS.md §5 第 20 条),班级端录入的作业归属该班班主任。
+ * 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
*/
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
diff --git a/packages/server/src/modules/registry.ts b/packages/server/src/modules/registry.ts
index 352f0cb..bf4b603 100644
--- a/packages/server/src/modules/registry.ts
+++ b/packages/server/src/modules/registry.ts
@@ -1,3 +1,4 @@
+import { coursewareModule } from './courseware/courseware.module.js';
import {
classPlansModule,
timeLayoutsModule,
@@ -33,6 +34,7 @@ import type { ApiModule } from './module.types.js';
* 临时下线:把 enabled 置为 false。
*/
export const apiModules: ApiModule[] = [
+ coursewareModule,
authModule,
aiModule,
classesModule,
diff --git a/packages/server/src/modules/teachers/teachers.module.ts b/packages/server/src/modules/teachers/teachers.module.ts
index 832ff0f..fb96f7f 100644
--- a/packages/server/src/modules/teachers/teachers.module.ts
+++ b/packages/server/src/modules/teachers/teachers.module.ts
@@ -18,11 +18,7 @@ import * as teacherService from './teachers.service.js';
const router = Router();
router.use(authenticate());
-/**
- * 教师账号管理:**全部接口仅管理员**。
- * ClassHelper 班级端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
- * 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
- */
+/** 教师列表使用 teacher:read/teacher:manage,写入统一要求 teacher:manage;额外业务约束见 service。 */
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
router.get(
@@ -35,7 +31,7 @@ router.get(
},
);
-/** POST /api/teachers - 录入教师账号(仅管理员) */
+/** POST /api/teachers - 录入教师账号(需 teacher:manage) */
router.post(
'/',
requirePermission('teacher:manage'),
diff --git a/packages/server/src/modules/teachers/teachers.service.ts b/packages/server/src/modules/teachers/teachers.service.ts
index df2a3e8..40a897d 100644
--- a/packages/server/src/modules/teachers/teachers.service.ts
+++ b/packages/server/src/modules/teachers/teachers.service.ts
@@ -104,11 +104,12 @@ export async function resetTeacherPassword(
export async function deleteTeacher(userId: string): Promise {
const teacher = await findTeacherOrThrow(userId);
- const [ownedClasses, courses, homeworks, notifications] = await Promise.all([
+ const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }),
prisma.course.count({ where: { teacherId: teacher.id } }),
prisma.homework.count({ where: { createdBy: teacher.id } }),
prisma.notification.count({ where: { createdBy: teacher.id } }),
+ prisma.courseware.count({ where: { ownerId: teacher.id } }),
]);
if (ownedClasses > 0) {
throw ApiError.conflict(
@@ -127,5 +128,7 @@ export async function deleteTeacher(userId: string): Promise {
);
}
+ if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
+
await prisma.user.delete({ where: { id: teacher.id } });
}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index e3e4194..f4d1bf0 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
- "version": "1.3.3",
+ "version": "1.4.0",
"private": true,
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
"type": "module",
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 97bcb58..b2cca34 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -170,12 +170,11 @@ export const DEFAULT_LETTER_LEVELS: readonly string[] = ['A', 'B', 'C', 'D'];
/**
* ClassHelper 班级端在线判定窗口(毫秒)。
*
- * 插件默认 60 秒打一次心跳,因此「最近 60 秒内有心跳 = 在线」是最贴合的口径;
- * 判定与展示(教师网页的「ClassHelper 在线状态」)共用这一个常量,避免两处各写一个数。
+ * @deprecated 旧联动在线窗口兼容导出,当前设备在线状态使用 CLIENT_ONLINE_WINDOW_MS。
*/
export const CLASS_HELPER_ONLINE_WINDOW_MS = 60_000;
-/** 教师网页「ClassHelper 在线状态」的自动刷新间隔(秒) */
+/** @deprecated 旧联动状态页刷新间隔,现行设备页不使用。 */
export const CLASS_HELPER_STATUS_REFRESH_SECONDS = 30;
/** 通知优先级 */
@@ -199,8 +198,7 @@ export const PRIORITY_TAG_TYPES: Record = {
LOW: 0,
@@ -480,6 +478,7 @@ export const DEFAULT_WEEK_COUNT = 20;
/** WebSocket 事件名(服务端广播 / 客户端监听保持一致) */
export const SOCKET_EVENTS = {
+ coursewareChanged: 'courseware:changed',
notificationNew: 'notification:new',
notificationDeleted: 'notification:deleted',
homeworkNew: 'homework:new',
@@ -489,7 +488,7 @@ export const SOCKET_EVENTS = {
scheduleUpdated: 'schedule:updated',
clientReleaseProgress: 'client:release-progress',
classUpdated: 'class:updated',
- /** "叫人":老师点名让某位同学去找他(定向到 user:{studentId} 房间) */
+ /** "叫人":老师点名让某位同学去找他(定向到班级会话 session:{classId} 房间) */
callNew: 'call:new',
connected: 'connected',
} as const;
@@ -599,7 +598,7 @@ export const MAX_PAGE_SIZE = 200;
/* ------------------------------------------------------------------ 更新检查 */
/**
- * 更新来源:本项目用 **GitHub Releases** 分发交付物(服务端 / 客户端安装包、便携版、插件 .cipx),
+ * 更新来源:本项目用 **GitHub Releases** 分发交付物(服务端 / 客户端安装包与便携版),
* 因此"有没有新版本"就是问 GitHub 上最新的那个 Release。
*
* 仓库是**公开**的,匿名调用即可(不需要 token);但匿名限流是 60 次/小时/IP,
diff --git a/packages/shared/src/courseware.ts b/packages/shared/src/courseware.ts
new file mode 100644
index 0000000..dbb29a1
--- /dev/null
+++ b/packages/shared/src/courseware.ts
@@ -0,0 +1,63 @@
+/** 课件契约:文件先保存到服务端,再向可见班级下发下载任务。 */
+export const COURSEWARE_MAX_BYTES = 512 * 1024 * 1024;
+export const COURSEWARE_TRANSFER_TIMEOUT_MS = 30 * 60_000;
+export const COURSEWARE_ISLAND_SIZE = { width: 424, height: 132 } as const;
+export const COURSEWARE_CATEGORIES = {
+ presentation: '演示文稿',
+ document: '文档',
+ spreadsheet: '表格',
+ image: '图片',
+ video: '视频',
+ audio: '音频',
+ archive: '压缩包',
+ other: '其他',
+} as const;
+export type CoursewareCategory = keyof typeof COURSEWARE_CATEGORIES;
+export function coursewareCategory(name: string): CoursewareCategory {
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
+ if (['ppt', 'pptx', 'odp', 'key'].includes(ext)) return 'presentation';
+ if (['pdf', 'doc', 'docx', 'txt', 'md', 'odt', 'rtf'].includes(ext)) return 'document';
+ if (['xls', 'xlsx', 'csv', 'ods'].includes(ext)) return 'spreadsheet';
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return 'image';
+ if (['mp4', 'webm', 'avi', 'mov', 'mkv', 'wmv'].includes(ext)) return 'video';
+ if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
+ if (['zip', '7z', 'rar', 'tar', 'gz'].includes(ext)) return 'archive';
+ return 'other';
+}
+export interface CoursewareClassDto {
+ id: string;
+ name: string;
+}
+export interface CoursewareDto {
+ id: string;
+ name: string;
+ size: number;
+ category: CoursewareCategory;
+ extension: string;
+ archiveDate: string;
+ sha256: string;
+ createdAt: string;
+ teacher: { id: string; name: string };
+ classes: CoursewareClassDto[];
+}
+export interface CoursewareUploadRequest {
+ name: string;
+ size: number;
+ classIds: string[];
+ mode: 'cloud' | 'push';
+}
+export interface CoursewarePushDto {
+ id: string;
+ courseware: CoursewareDto;
+}
+export interface CoursewareTransfer {
+ id: string;
+ name: string;
+ category: CoursewareCategory;
+ teacherName: string;
+ receivedBytes: number;
+ totalBytes: number;
+ progress: number;
+ stage: 'downloading' | 'verifying' | 'completed' | 'failed';
+ error?: string;
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 5c38472..b87bedc 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -9,3 +9,4 @@ export * from './utils.js';
export * from './class-plan.js';
export * from './island-layout.js';
export type * from './types.js';
+export * from './courseware.js';
diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts
index 83dcd9f..ffbb751 100644
--- a/packages/shared/src/permissions.ts
+++ b/packages/shared/src/permissions.ts
@@ -106,7 +106,7 @@ export function canManageSubjectContent(input: {
isSubjectTeacherOfCourse: boolean;
/**
* 是否允许 ClassHelper 班级端代写。
- * 教室机器可以录**作业**(老师在教室随手录一条很常见,见 AGENTS.md §5 第 20 条),
+ * 教室机器可以录**作业**(老师在教室随手录一条很常见,见 AGENTS.md 的现行工程约定),
* 但**成绩**不给 —— 成绩是面向家长/学生的评价数据,仍由教师在 Web 端录入。
*/
allowClassDevice?: boolean;
@@ -160,9 +160,11 @@ export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind
* - 桌面客户端:功能入口显隐。
*
* 键名用 `领域:动作` 的形式,新增功能要**先在这里加键**,再去两端用。
- * **不要**在页面或服务里硬编码角色判断来代替它(AGENTS.md §5 第 3 条)。
+ * **不要**在页面或服务里硬编码角色判断来代替它(AGENTS.md 的现行工程约定)。
*/
export const PERMISSION_KEYS = [
+ 'courseware:read',
+ 'courseware:write',
// 班级与人员
'class:read',
'class:manage',
@@ -189,7 +191,7 @@ export const PERMISSION_KEYS = [
'import:teachers',
'import:schedule',
'import:class-teachers',
- // 设备与联动
+ // 客户端设备维护
'client:read',
'client:manage',
'client:update',
@@ -214,6 +216,8 @@ export type PermissionKey = (typeof PERMISSION_KEYS)[number];
/** 默认角色组之外的教师岗位组;空年级范围必须由管理员配置,不能自动授权全校。 */
const TEACHING_KEYS: PermissionKey[] = [
+ 'courseware:read',
+ 'courseware:write',
'homework:write',
'grade:write',
'notification:publish',
@@ -249,6 +253,8 @@ export const BUILTIN_TEACHER_GROUPS = [
/** 权限键的中文名(权限组管理页与文档展示,别再各写一份) */
export const PERMISSION_LABELS: Record = {
+ 'courseware:read': '查看与下载课件',
+ 'courseware:write': '上传、设置可见范围与推送自己的课件',
'class:read': '查看班级与班级列表',
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
'roster:read': '查看学生名单',
@@ -309,6 +315,8 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
{
title: '内容发布',
keys: [
+ 'courseware:read',
+ 'courseware:write',
'homework:read',
'homework:write',
'notification:read',
@@ -359,6 +367,8 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
* (`canManageSubjectContent(..., { allowClassDevice })` 等)决定,不受权限组影响。
*/
export const PERMISSION_DEFAULT_ROLES: Record = {
+ 'courseware:read': ['ADMIN', 'TEACHER'],
+ 'courseware:write': ['ADMIN', 'TEACHER'],
'class:read': ['ADMIN', 'TEACHER'],
'class:manage': ['ADMIN'],
'roster:read': ['ADMIN', 'TEACHER'],
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index d2b67b2..976f43e 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -1,3 +1,4 @@
+import type { CoursewareTransfer } from './courseware.js';
import type {
PermissionScope as ContractPermissionScope,
ReleaseProgressDto as ContractReleaseProgressDto,
@@ -1156,6 +1157,7 @@ export type IslandStyle = 'black' | 'glass' | 'tinted';
/** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */
export interface IslandState {
+ coursewareTransfer?: CoursewareTransfer | null;
mode: IslandMode;
lessonReminder?: IslandLessonReminder | null;
/** 正在展示的通知(展开态或胶囊态) */
@@ -1172,7 +1174,7 @@ export interface IslandState {
* `closing` 是"用户点了知道了 / 标为已读"之后的**收回动画**阶段:状态在同一帧就已经落地
* (`active` 清空、`queued` 清空),主进程只是把形态切回胶囊让渲染进程把卡片收回去,
* 待 `CLOSE_ANIMATION_MS` 之后再淡出隐藏。它不是跨进程握手,渲染进程也不需要任何回执
- * (见 AGENTS.md §5 第 34 条)。
+ * (见 AGENTS.md 的现行工程约定)。
*/
reason: 'new' | 'after-class' | 'urgent' | 'call' | 'closing' | null;
/**
@@ -1208,6 +1210,7 @@ export interface DashboardSummary {
/** 服务端 -> 客户端事件名 */
export interface ServerToClientEvents {
+ 'courseware:changed': (payload: { id: string }) => void;
'client:release-progress': (payload: ContractReleaseProgressDto) => void;
'notification:deleted': (payload: { id: string; classId: string }) => void;
'notification:new': (payload: NotificationDto) => void;
@@ -1515,7 +1518,7 @@ export interface AssignUserGroupsRequest {
/**
* `GET /api/permissions/me`:当前账号的生效权限。
*
- * 两端用它决定"入口显不显示",而**不是**再写一套权限规则(AGENTS.md §5 第 3 条):
+ * 两端用它决定"入口显不显示",而**不是**再写一套权限规则(AGENTS.md 的现行工程约定):
* 真正的拦截始终在服务端。
*/
export interface MyPermissionsDto {
diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts
index 3dd710e..b3afcea 100644
--- a/packages/shared/src/utils.ts
+++ b/packages/shared/src/utils.ts
@@ -531,7 +531,7 @@ export function isNewerVersion(
* 客户端安装包的**文件名约定**。
*
* 与 GitHub Release 上上传的 ASCII 名、以及官网下载页的离线兜底直链
- * **必须一致**(AGENTS.md §4.4:改名要三处一起改)。
+ * **必须一致**(AGENTS.md 的现行工程约定:改名要三处一起改)。
*/
export function clientReleaseFileName(version: string): string {
return `ClassHelper-${normalizeVersion(version)}-x64-client-setup.exe`;
@@ -541,7 +541,7 @@ export function clientReleaseFileName(version: string): string {
* 服务端从 GitHub 拉取客户端安装包的直链。
*
* 服务端替教室机器"代下"再缓存(教室常常没有外网)—— Web 端**不能**直连 GitHub
- * (CSP 的 connect-src 只有 self / ws / CORS_ORIGIN,见 AGENTS.md §5 第 52 条)。
+ * (CSP 的 connect-src 只有 self / ws / CORS_ORIGIN,见 AGENTS.md 的现行工程约定)。
*/
export function clientReleaseDownloadUrl(version: string): string {
const tag = `v${normalizeVersion(version)}`;
diff --git a/packages/web-admin/package.json b/packages/web-admin/package.json
index 199d469..1a0b915 100644
--- a/packages/web-admin/package.json
+++ b/packages/web-admin/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/web-admin",
- "version": "1.3.3",
+ "version": "1.4.0",
"private": true,
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts
index 6a7122c..adc516b 100644
--- a/packages/web-admin/src/api/index.ts
+++ b/packages/web-admin/src/api/index.ts
@@ -593,7 +593,7 @@ const CLIENT_RELEASE_FETCH_TIMEOUT_MS = 10 * 60 * 1000;
* 客户端安装包缓存管理(服务端 `modules/update/client-releases.service.ts`)。
*
* 教室机器常常没有外网,所以安装包由**服务端替教室下载**再缓存:
- * Web 端不直连 GitHub(CSP 的 connect-src 只放行 self 与 WebSocket,见 AGENTS.md §5 第 52 条)。
+ * Web 端不直连 GitHub(CSP 的 connect-src 只放行 self 与 WebSocket,见 AGENTS.md 的现行工程约定)。
*
* 权限:`list` 要查看、设备管理或更新任一权限;`fetch` / `register` / `remove` 要 `client:update-push`。
*/
diff --git a/packages/web-admin/src/config/navigation.ts b/packages/web-admin/src/config/navigation.ts
index 3b3151f..4ebfd5c 100644
--- a/packages/web-admin/src/config/navigation.ts
+++ b/packages/web-admin/src/config/navigation.ts
@@ -5,7 +5,7 @@ export const MANAGEMENT_GROUPS = [
id: 'teaching',
title: '教学事务',
icon: 'Notebook',
- paths: ['/schedules', '/plan-editor', '/homeworks', '/notifications', '/grades'],
+ paths: ['/schedules', '/plan-editor', '/homeworks', '/notifications', '/courseware', '/grades'],
},
{
id: 'school',
diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue
index 44af606..0f34bc5 100644
--- a/packages/web-admin/src/layouts/AdminLayout.vue
+++ b/packages/web-admin/src/layouts/AdminLayout.vue
@@ -25,7 +25,7 @@ const { isMobile } = useResponsive();
* 顶栏的主题切换。整页"从点击位置扩散的圆"由 `useThemeReveal()` 提供
* (与桌面客户端共用同一份实现,见 composables/motion.ts)。
*
- * 入口只放在**登录后的主布局**里:登录页是刻意固定的深色渐变底(AGENTS.md §5 第 46 条),
+ * 入口只放在**登录后的主布局**里:登录页是刻意固定的深色渐变底(AGENTS.md 的现行工程约定),
* 它不随主题走,在那里放一个切换按钮只会让人以为按钮坏了。
*/
const revealThemeChange = useThemeReveal();
@@ -43,7 +43,7 @@ function handleThemeToggle(event: MouseEvent): void {
* `styles/index.css` 里 `.ch-nav.is-collapsed …` 那一段(那里有完整推导)——
* 模板里没有任何 `v-if` 增删菜单项,折叠才是连续的收拢动画而不是"重排一下"。
*
- * 缓动刻意与桌面客户端同一套参数(`--nav-dur` / `--nav-ease`,AGENTS.md §5 第 49 条):
+ * 缓动刻意与桌面客户端同一套参数(`--nav-dur` / `--nav-ease`,AGENTS.md 的现行工程约定):
* 这里不要换成 EASE_OUT 之类的"起步快"曲线 —— 两端观感要一致,且自动化会量到中间态。
*/
const collapseLabel = computed(() => (ui.sidebarCollapsed ? '展开菜单' : '收起菜单'));
@@ -523,7 +523,7 @@ const tourSteps = computed(() => [
注意 `.header-right` 必须是 flex 容器:Element Plus 给 `.el-button` 的是
`vertical-align: middle`、给 `.el-dropdown` 的是 `vertical-align: top`,
放在普通 block 里两个 inline-level 子元素会按各自的规则落位、错开几像素
- (AGENTS.md §5 第 58 条,客户端当初就是这么翻的车)。
+ (AGENTS.md 的现行工程约定,客户端当初就是这么翻的车)。
-->
import('@/layouts/AdminLayout.vue'),
redirect: '/dashboard',
children: [
+ { path: 'courseware', name: 'courseware', component: () => import('@/views/CoursewareView.vue'), meta: { title: '课件', icon: 'FolderOpened', roles: ['ADMIN', 'TEACHER'], permission: 'courseware:read' } },
{
path: 'plan-editor',
name: 'plan-editor',
diff --git a/packages/web-admin/src/stores/auth.ts b/packages/web-admin/src/stores/auth.ts
index 61c54e2..9721a16 100644
--- a/packages/web-admin/src/stores/auth.ts
+++ b/packages/web-admin/src/stores/auth.ts
@@ -36,7 +36,7 @@ export const useAuthStore = defineStore('auth', () => {
/**
* 当前账号的**生效权限**(`GET /api/permissions/me`)。
*
- * 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md §5 第 3 条)——
+ * 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md 的现行工程约定)——
* 真正的拦截始终在服务端。页面刷新后这里是空的,由 `AdminLayout` 挂载时调
* {@link ensurePermissions} 补上。
*/
diff --git a/packages/web-admin/src/styles/index.css b/packages/web-admin/src/styles/index.css
index c414fc8..1ee6c1a 100644
--- a/packages/web-admin/src/styles/index.css
+++ b/packages/web-admin/src/styles/index.css
@@ -73,7 +73,7 @@
* 只覆盖**语义色**,不动圆角/间距/动效令牌 —— 两套主题的几何必须完全一致,
* 否则切换主题会看到布局跳一下。
*
- * 刻意**不随主题**的区域(见 AGENTS.md §5 第 46 条):登录页的深色渐变底、
+ * 刻意**不随主题**的区域(见 AGENTS.md 的现行工程约定):登录页的深色渐变底、
* 侧栏(本来就是深色)、以及深色底上的白色文字。
*/
html.dark {
@@ -155,7 +155,7 @@ html.care-mode .care-emphasis {
}
/* ============================================================================
- * 侧栏导航:折叠态是"图标导轨"(AGENTS.md §5 第 49 条)
+ * 侧栏导航:折叠态是"图标导轨"(AGENTS.md 的现行工程约定)
*
* 折叠**不用** el-menu 的 `:collapse` 属性:那个 prop 会把每个分组的子项卸载进浮层
* (一次结构切换)并让整条菜单重排一遍 —— 既做不出连续形变,也是"点汉堡卡一下"的根因。
diff --git a/packages/web-admin/src/views/ClientsView.vue b/packages/web-admin/src/views/ClientsView.vue
index 643b544..362e533 100644
--- a/packages/web-admin/src/views/ClientsView.vue
+++ b/packages/web-admin/src/views/ClientsView.vue
@@ -1159,7 +1159,7 @@ const deviceEmptyText = computed(() =>
diff --git a/packages/web-admin/src/views/LoginView.vue b/packages/web-admin/src/views/LoginView.vue
index f2ea0d2..66333d1 100644
--- a/packages/web-admin/src/views/LoginView.vue
+++ b/packages/web-admin/src/views/LoginView.vue
@@ -84,7 +84,7 @@ async function submit(): Promise {
/*
* 登录卡片**永远按浅色渲染**。
*
- * 页面底色是刻意固定的深色渐变(AGENTS.md §5 第 46 条),卡片按设计就是"深底白卡"。
+ * 页面底色是刻意固定的深色渐变(AGENTS.md 的现行工程约定),卡片按设计就是"深底白卡"。
* 但卡片的内部颜色原本会随 `html.dark` 变:Element Plus 的深色 css-vars 与 `--ch-*`
* 是挂在 `` 上的,照常继承进白卡片 —— 于是深色主题下出现"白卡片 + 深色输入框 +
* 浅灰字"的黑白混搭(用户反馈的「登录页出现黑夜白天模式混合显示」)。
diff --git a/packages/web-admin/src/views/NotificationsView.vue b/packages/web-admin/src/views/NotificationsView.vue
index 7956059..46431fe 100644
--- a/packages/web-admin/src/views/NotificationsView.vue
+++ b/packages/web-admin/src/views/NotificationsView.vue
@@ -291,7 +291,7 @@ interface PublishFailure {
* 服务端逐班做权限断言与上课保护,并把每个班的结果回给我们。
*
* 为什么不再由前端对每个班各发一次请求:N 个班就是 N 次往返,权限/上课保护
- * 分散在 N 个响应里,前端还得自己合并;服务端批量端点把这些收敛成一份逐班结果(见 AGENTS.md §5)。
+ * 分散在 N 个响应里,前端还得自己合并;服务端批量端点把这些收敛成一份逐班结果(见 AGENTS.md 的现行工程约定)。
* confirmDuringClass 为 true 表示教师已在上课警告里确认。
*/
const doPublishSaving = ref(false);
diff --git a/packages/web-admin/src/views/PermissionsView.vue b/packages/web-admin/src/views/PermissionsView.vue
index 3ff878c..f2615de 100644
--- a/packages/web-admin/src/views/PermissionsView.vue
+++ b/packages/web-admin/src/views/PermissionsView.vue
@@ -8,7 +8,7 @@ import type { PermissionScope as ContractPermissionScope } from '@classhelper/sh
* 1. **权限组**:新建 / 编辑 / 删除权限组(一组权限键),内置组只允许改、禁止删;
* 2. **成员**:把管理员 / 教师账号分配到权限组(不选任何组 = 回到角色默认模板)。
*
- * 权限规则本身**不在这里实现**(AGENTS.md §5 第 3 条):本页只是把服务端
+ * 权限规则本身**不在这里实现**(AGENTS.md 的现行工程约定):本页只是把服务端
* `GET /permissions/catalog` 给的目录、以及 `lib/permissions.ts` 的生效规则
* 如实展示出来,真正的判定始终在服务端 —— 页面拿到的 `fromGroups` / `permissionCount`
* 都是服务端算好的结果。
diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs
index d1a4672..f13363b 100644
--- a/scripts/dist-server.mjs
+++ b/scripts/dist-server.mjs
@@ -1,7 +1,7 @@
/**
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
*
- * 交付产物统一落到 releases/server/<版本>/(releases/ 不入库,与客户端/插件同级):
+ * 交付产物统一落到 releases/server/<版本>/(releases/ 不入库,与客户端同级):
*
* 产出(Windows):
* releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
@@ -50,7 +50,7 @@ const PRODUCT = 'ClassHelper 服务端';
const PORT = '4000';
const LINUX_ARCH = 'x64';
-// 产物根:releases/server/<版本>/(releases/ 不入库,见 AGENTS.md §4)
+// 产物根:releases/server/<版本>/(releases/ 不入库,见 AGENTS.md 的现行工程约定)
const outRoot = path.join(root, 'releases', 'server', VERSION);
const INSTALLER_DIR = path.join(outRoot, '安装包');
const GENERATED_DIR = path.join(outRoot, '构建中间');
@@ -219,7 +219,7 @@ function copyRecursive(from, to) {
function buildRuntime() {
// 后端代码:保留 dist 层级,保证代码里 serverRoot 的计算(../..)与开发环境一致
// 注:dist 里没有 .d.ts / *.map —— packages/server/tsconfig.build.json 显式关掉了它们
- //(那三样占了原 dist 的 82%,运行时一个都用不到,见 AGENTS.md §5 第 54 条)。
+ //(那三样占了原 dist 的 82%,运行时一个都用不到,见 AGENTS.md 的现行工程约定)。
copyRecursive(path.join(serverDir, 'dist'), path.join(staging, 'server', 'dist'));
// 迁移文件(首次启动 AUTO_MIGRATE 使用)
copyRecursive(
@@ -312,7 +312,7 @@ const sizeMb = (target) => Math.round(dirSize(target) / 1024 / 1024);
* 其余方言(postgresql / cockroachdb / sqlserver)的 wasm 一个字节都用不上。
*
* 原则:只删数据文件与文档,**不碰任何会被 require 的入口**;每一条都写明依据。
- * 改这里之前先看「为什么安全」,改完务必按 AGENTS.md §8 的清单复验一次
+ * 改这里之前先看「为什么安全」,改完务必按 AGENTS.md 的现行工程约定 的清单复验一次
* (尤其是数据库管理模块的「生成客户端 / 一键切换」—— 它是唯一会调用 Prisma CLI 的地方)。
*/
function pruneRuntime() {
diff --git a/scripts/ui-smoke/main.cjs b/scripts/ui-smoke/main.cjs
index 38494c0..6fc6f1f 100644
--- a/scripts/ui-smoke/main.cjs
+++ b/scripts/ui-smoke/main.cjs
@@ -39,6 +39,7 @@ const MENU_ITEMS = [
{ label: '课表管理', path: '/schedules' },
{ label: '作业发布', path: '/homeworks' },
{ label: '通知发布', path: '/notifications' },
+ { label: '课件', path: '/courseware' },
{ label: '成绩录入', path: '/grades' },
// 按 client:read 展示设备状态入口。
{ label: '客户端', path: '/clients' },
@@ -575,6 +576,46 @@ async function main() {
);
// 5. 上课时段发布紧急通知:全屏警告 + 3 秒倒计时 + 教师确认后才真正发布
+ await win.webContents.executeJavaScript(
+ `[...document.querySelectorAll('.el-menu-item')].find(e=>e.textContent.trim()==='课件')?.click()`,
+ );
+ await waitFor(win, `Boolean(document.querySelector('.courseware-page input[type=file]'))`);
+ const coursewareName = `UI课件验收-${Date.now()}.pptx`;
+ await win.webContents.executeJavaScript(`(() => {
+ const input=document.querySelector('.courseware-page input[type=file]'), transfer=new DataTransfer();
+ transfer.items.add(new File(['ClassHelper UI 课件验收'],${JSON.stringify(coursewareName)},{type:'application/octet-stream'}));
+ input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
+ })()`);
+ await sleep(100);
+ await win.webContents.executeJavaScript(
+ `[...document.querySelectorAll('.courseware-page button')].find(b=>b.textContent.trim()==='上传云端')?.click()`,
+ );
+ await waitFor(
+ win,
+ `document.querySelector('.courseware-page .el-table__body')?.textContent.includes(${JSON.stringify(coursewareName)})`,
+ );
+ const coursewareUploaded = await win.webContents.executeJavaScript(
+ `(() => {const row=[...document.querySelectorAll('.courseware-page .el-table__row')].find(e=>e.textContent.includes(${JSON.stringify(coursewareName)}));return Boolean(row&&row.textContent.includes('仅自己')&&row.textContent.includes('演示文稿'));})()`,
+ );
+ record('课件真实上传并按类型归档,未选班级时仅自己可见', coursewareUploaded);
+ if (process.env.UI_SMOKE_SHOTS_DIR)
+ fs.writeFileSync(
+ path.join(process.env.UI_SMOKE_SHOTS_DIR, 'courseware-web.png'),
+ (await win.webContents.capturePage()).toPNG(),
+ );
+ await win.webContents.executeJavaScript(
+ `(() => {const row=[...document.querySelectorAll('.courseware-page .el-table__row')].find(e=>e.textContent.includes(${JSON.stringify(coursewareName)}));[...row.querySelectorAll('button')].find(b=>b.textContent.trim()==='删除')?.click();})()`,
+ );
+ await waitFor(win, `Boolean(document.querySelector('.el-message-box__btns .el-button--primary'))`);
+ await win.webContents.executeJavaScript(
+ `document.querySelector('.el-message-box__btns .el-button--primary')?.click()`,
+ );
+ await waitFor(
+ win,
+ `!document.querySelector('.courseware-page .el-table__body')?.textContent.includes(${JSON.stringify(coursewareName)})`,
+ );
+ record('课件可从原生页面删除并清理测试文件', true);
+
// "上课时段"由 run.mjs 的探针提前用 HTTP 建好(今天 00:00-23:59 的课表)
const urgentTitle = process.env.UI_SMOKE_URGENT_TITLE ?? 'UI 冒烟紧急通知';
const inClass = process.env.UI_SMOKE_INCLASS === '1';
diff --git a/scripts/verify-courseware.mjs b/scripts/verify-courseware.mjs
new file mode 100644
index 0000000..d915e71
--- /dev/null
+++ b/scripts/verify-courseware.mjs
@@ -0,0 +1,43 @@
+/** 课件验收始终使用临时库和临时文件目录,不访问正在使用的班级。 */
+import fs from 'node:fs';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const dir = path.join(root, '.cache', 'v14-verification', `courseware-${process.pid}-${Date.now()}`);
+fs.mkdirSync(dir, { recursive: true });
+const env = {
+ ...process.env,
+ DATABASE_PROVIDER: 'sqlite',
+ DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
+ COURSEWARE_DIR: path.join(dir, 'files'),
+ TZ: 'Asia/Shanghai',
+ RATE_LIMIT_ENABLED: 'false',
+ LOG_LEVEL: 'warn',
+};
+const require = createRequire(path.join(root, 'packages/server/package.json'));
+const { createClient } = require('@libsql/client');
+const db = createClient({ url: env.DATABASE_URL });
+await db.execute('PRAGMA user_version=0');
+db.close();
+async function run(args) {
+ await new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, args, { cwd: root, env, stdio: 'inherit', windowsHide: true });
+ child.once('error', reject);
+ child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`课件验收失败:${code}`))));
+ });
+}
+await run([
+ 'packages/server/node_modules/prisma/build/index.js',
+ 'migrate',
+ 'deploy',
+ '--config',
+ 'packages/server/prisma.config.ts',
+]);
+await run([
+ '--import',
+ './packages/server/node_modules/tsx/dist/loader.mjs',
+ 'packages/server/scripts/verify-courseware.ts',
+]);
+console.log(`隔离验收留档:${dir}`);
diff --git a/scripts/verify-e2e-isolated.mjs b/scripts/verify-e2e-isolated.mjs
index 9a4d0b8..0647f0d 100644
--- a/scripts/verify-e2e-isolated.mjs
+++ b/scripts/verify-e2e-isolated.mjs
@@ -102,7 +102,12 @@ try {
namespace(fs.readFileSync(path.join(root, 'packages/server/scripts/verify-e2e.mjs'), 'utf8')),
{ flag: 'wx' },
);
- await run([testFile], 'e2e.log');
+ if (process.argv.includes('--web')) {
+ env.UI_SMOKE_URL = env.VERIFY_BASE_URL;
+ env.UI_SMOKE_ADMIN = `${prefix}admin`;
+ env.UI_SMOKE_ADMIN_PASS = 'admin123';
+ await run(['scripts/ui-smoke/run.mjs'], 'web.log');
+ } else await run([testFile], 'e2e.log');
} finally {
if (server && server.exitCode === null) {
server.kill();
diff --git a/scripts/verify-packaged.mjs b/scripts/verify-packaged.mjs
index 4c9ba2d..1e6cb89 100644
--- a/scripts/verify-packaged.mjs
+++ b/scripts/verify-packaged.mjs
@@ -5,13 +5,10 @@
* 资源路径、Electron 版本、`app.isPackaged` 都可能不同。交付前应实测 `packaged: true`。
*
* 用法:
- * node scripts/verify-packaged.mjs
- * node scripts/verify-packaged.mjs --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe"
- * node scripts/verify-packaged.mjs --offline
- * node scripts/verify-packaged.mjs --code G101 --password 123456
- *
- * 联网用例需要后端在 http://127.0.0.1:4000(默认自动探测;探测不到时退化为离线用例)。
- * 退出码:0 = 两次启动都全绿且 `packaged: true`。
+ * node scripts/verify-packaged.mjs --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe" --offline
+ * 显式 --visual 才进行桌面视觉验收,显式 --online 才探测并启用联网。
+ * 联网前由调用方准备隔离服务、临时教师和班级,并显式传入测试凭据;本脚本不自动创建它们。
+ * 默认后台快速、离线,两次启动均成功且 packaged=true 才返回 0。
*/
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
diff --git a/website/assets/main.js b/website/assets/main.js
index 2609bb2..b5454a5 100644
--- a/website/assets/main.js
+++ b/website/assets/main.js
@@ -929,7 +929,7 @@
{
file: 'web-schedules.jpg',
kind: 'screen',
- cap: '课表管理 —— 按周排课、单双周标签,也能直接导入 ClassIsland 的档案',
+ cap: '课表管理 —— 周视图与多周轮换,支持完整 ClassIsland 档案离线导入',
},
{
file: 'web-homeworks.jpg',
diff --git a/website/download.html b/website/download.html
index 76f6365..0af1916 100644
--- a/website/download.html
+++ b/website/download.html
@@ -18,7 +18,7 @@
-
+
diff --git a/website/index.html b/website/index.html
index aaeb8f4..7aeeec4 100644
--- a/website/index.html
+++ b/website/index.html
@@ -44,7 +44,7 @@
ClassHelper
- 1.3.3
+ 1.4.0
-
+