feat: 完善课表档案、课件归档、提醒与客户端更新并发布 1.4.1
- 重写课表档案工作区并合并时间表、课表与完整档案导入导出 - 修复课前/上下课提醒遮罩与通知收回冲突 - 增加课件文件夹分类、教师归档路径和客户端本机归档 - 打通客户端下载/安装/完成状态上报并在管理端展示更新过程 - 修复侧栏折叠裁切、课时多选与上传控件样式 - 统一版本到 1.4.1
This commit is contained in:
1 parent
2612a912ef
commit
6347d1e5aa
59 files changed
+2196
-529
No files matched your search
@@ -23,7 +23,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.4.0}
|
image: classhelper-server:${APP_VERSION:-1.4.1}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.4.0}
|
image: classhelper-server:${APP_VERSION:-1.4.1}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "classhelper-server-runtime",
|
"name": "classhelper-server-runtime",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
1. 选择本地文件。
|
1. 选择本地文件。
|
||||||
2. 选择可以查看课件的班级。候选项只包含本人担任任课教师的在读班级;仅担任班主任、级长或部长不会额外获得上传到这些班级的权限。
|
2. 选择可以查看课件的班级。候选项只包含本人担任任课教师的在读班级;仅担任班主任、级长或部长不会额外获得上传到这些班级的权限。
|
||||||
3. 选择「仅保存云端」或「保存后推送所选班级」,点击上传。
|
3. 可新建分类文件夹,使用 `/` 分隔多层目录,例如 `地理/必修一`;不选文件夹时直接按日期归档。
|
||||||
|
4. 选择「仅保存云端」或「保存后推送所选班级」,点击上传。
|
||||||
|
|
||||||
不选择班级时,课件仅自己可见。仅保存云端并指定可见班级后,这些班级可在客户端自主下载。推送模式会先把文件完整保存到服务端,再给所选班级创建下载任务;不会从教师电脑直接传到教室电脑。
|
不选择班级时,课件仅自己可见。仅保存云端并指定可见班级后,这些班级可在客户端自主下载。推送模式会先把文件完整保存到服务端,再给所选班级创建下载任务;不会从教师电脑直接传到教室电脑。
|
||||||
|
|
||||||
@@ -14,10 +15,14 @@
|
|||||||
|
|
||||||
「查看与下载课件」和「上传、设置可见范围与推送自己的课件」为独立权限,可在权限组中分配。默认教师与管理员、内置教师与班主任岗位包含这两项权限;升级时自定义权限组需管理员按需授权。
|
「查看与下载课件」和「上传、设置可见范围与推送自己的课件」为独立权限,可在权限组中分配。默认教师与管理员、内置教师与班主任岗位包含这两项权限;升级时自定义权限组需管理员按需授权。
|
||||||
|
|
||||||
|
「日期归档」按教师、年、月、日和上传时间选择目录;「文件夹分类」按自建目录筛选,已有文件可随时调整分类。文件夹按教师隔离,分类本身不授予班级下载权限。
|
||||||
|
|
||||||
## 班级客户端
|
## 班级客户端
|
||||||
|
|
||||||
客户端侧栏新增「课件」,仅列出本班可见的文件,显示文件名、类型、上传教师、日期与大小。点击「下载」将课件保存到本机,点击「打开文件夹」定位已下载文件。客户端不会自动运行文件。
|
客户端侧栏新增「课件」,仅列出本班可见的文件,显示文件名、类型、上传教师、日期与大小。点击「下载」将课件保存到本机,点击「打开文件夹」定位已下载文件。客户端不会自动运行文件。
|
||||||
|
|
||||||
|
「本机归档」独立列出已经接收的课件,离线时也可按教师和日期目录查找。每个服务端与班级会话有独立归档根目录,文件路径采用 `Teachers\老师名字\上传年份\上传月\上传日\上传时间\文件名`,例如 `Teachers\Wanghong\2026\10\18\15-30-42\大气环流.pptx`。目录时间取上传时间,重复推送会复用已校验的文件;同秒同名的不同课件会添加编号,避免覆盖。
|
||||||
|
|
||||||
收到教师推送后,客户端自动下载;离线时任务保留在服务端,上线后的心跳会补拉,每台设备独立回执。实时通道正常时会立即触发检查;断线时通过心跳继续补收。传输失败会显示原因,推送任务在后续心跳中重试,不会影响远控和在线状态上报。
|
收到教师推送后,客户端自动下载;离线时任务保留在服务端,上线后的心跳会补拉,每台设备独立回执。实时通道正常时会立即触发检查;断线时通过心跳继续补收。传输失败会显示原因,推送任务在后续心跳中重试,不会影响远控和在线状态上报。
|
||||||
|
|
||||||
下载期间,固定岛和自定义常驻岛都会显示文件名、文件类型、传输教师、接收字节数和进度条。下载完成后校验 SHA256,再保存正式文件并回执。断网时可以查看已缓存的课件目录与已经下载的本地文件;重新联网后同步当前可见范围。
|
下载期间,固定岛和自定义常驻岛都会显示文件名、文件类型、传输教师、接收字节数和进度条。下载完成后校验 SHA256,再保存正式文件并回执。断网时可以查看已缓存的课件目录与已经下载的本地文件;重新联网后同步当前可见范围。
|
||||||
@@ -26,11 +31,11 @@
|
|||||||
|
|
||||||
## 服务端归档与备份
|
## 服务端归档与备份
|
||||||
|
|
||||||
默认文件实体目录为 `<服务端根目录的上一级>/data/courseware/YYYY-MM-DD/<类型>/<随机编号>`,展示名称与扩展名单独保存在数据库中。可通过环境变量 `COURSEWARE_DIR` 指定文件目录。上传使用流式写入,未完成文件使用 `.part` 临时文件;失败或上传中断时清理。只有完整收到文件并完成摘要计算后才记录元数据和创建推送任务。
|
默认文件实体目录为 `<服务端根目录的上一级>/data/courseware/<随机编号>/Teachers/<教师>/<年>/<月>/<日>/<时间>/<文件名>`,随机编号隔离同名教师和同秒上传,旧课件保留原存储位置。可通过环境变量 `COURSEWARE_DIR` 指定文件目录。上传使用流式写入,未完成文件使用 `.part` 临时文件;失败或上传中断时清理。只有完整收到文件并完成摘要计算后才记录元数据和创建推送任务。
|
||||||
|
|
||||||
数据库迁移 `20261018000000_add_courseware` 新增课件、可见班级、推送和设备回执四张表。SQLite 安装部署按现有自动迁移流程更新;源码开发部署执行 `pnpm --filter @classhelper/server db:deploy`。MySQL 按现有换库流程基于更新后的 schema 建模。
|
数据库迁移新增课件、可见班级、推送、设备回执和分类目录,并记录上传归档路径。SQLite 安装部署按现有自动迁移流程更新;源码开发部署执行 `pnpm --filter @classhelper/server db:deploy`。MySQL 按现有换库流程基于更新后的 schema 建模。
|
||||||
|
|
||||||
数据库 JSON 备份包含这四张表,但**不包含二进制文件本身**。完整备份必须同时备份 `data/courseware`(自定义目录时备份对应目录)。SQLite Docker 的现有数据卷覆盖此目录;MySQL Docker 配置增加 `server-data:/app/data` 数据卷。复制到新机器时需同时恢复数据库和文件目录。
|
数据库 JSON 备份包含课件元数据和分类目录,但**不包含二进制文件本身**。完整备份必须同时备份 `data/courseware`(自定义目录时备份对应目录)。SQLite Docker 的现有数据卷覆盖此目录;MySQL Docker 配置增加 `server-data:/app/data` 数据卷。复制到新机器时需同时恢复数据库和文件目录。
|
||||||
|
|
||||||
使用反向代理时,需允许课件上传大小和足够的读写超时。仓库的 Nginx 样例单独为 `/api/courseware/` 配置 512 MB 和 1800 秒超时,其他接口维持原有大小限制。
|
使用反向代理时,需允许课件上传大小和足够的读写超时。仓库的 Nginx 样例单独为 `/api/courseware/` 配置 512 MB 和 1800 秒超时,其他接口维持原有大小限制。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ description: 表格预览、名单与成绩校验,以及完整离线档案导
|
|||||||
|
|
||||||
## 完整 ClassIsland 档案
|
## 完整 ClassIsland 档案
|
||||||
|
|
||||||
在“课表管理 → 导入 ClassIsland 课程表”选择或粘贴 JSON,先预览再导入。
|
在“课表档案 → 导入档案”选择或粘贴 JSON,先预览再导入。
|
||||||
完整输入包含 TimeLayouts、ClassPlans、Subjects 字典,可包含 ClassPlanGroups 和 OrderedSchedules 等配置。
|
完整输入包含 TimeLayouts、ClassPlans、Subjects 字典,可包含 ClassPlanGroups 和 OrderedSchedules 等配置。
|
||||||
JSON 只作为离线文件导入,不依赖在线联动插件。
|
JSON 只作为离线文件导入,不依赖在线联动插件。
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ description: 日课表、共享作息、轮换、临时换课和教学周。
|
|||||||
|
|
||||||
# 课表
|
# 课表
|
||||||
|
|
||||||
Web 管理端可查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。
|
Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。
|
||||||
上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。
|
上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。
|
||||||
|
|
||||||
|
编辑工作区参照 [CsesWebEditor](https://github.com/SmartTeachCN/CsesWebEditor) 的资源列表、设置卡片、课时列表与快速选科布局实现。左侧集中切换课程表、时间表、科目、群组与预定、整周预览和文件预览。旧管理页地址 `/schedules` 自动进入同一工作区。
|
||||||
|
|
||||||
|
点击课时后可用右侧科目按钮快速填充;拖动课时交换课程,拖动时间点调整时间表顺序,完成后保存。时间表支持任意分钟输入;设置的课程/课间分钟数会用于新建时间点的默认起止时间。完整档案可在页头导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。
|
||||||
|
|
||||||
## 日课表与时间表
|
## 日课表与时间表
|
||||||
|
|
||||||
时间表保存时间点:上课、课间、分割线、行动;日课表引用时间表,每节课程通过外键关联时间点。
|
时间表保存时间点:上课、课间、分割线、行动;日课表引用时间表,每节课程通过外键关联时间点。
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
|
|||||||
| /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查角色、导入权限、班级和科目 |
|
| /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查角色、导入权限、班级和科目 |
|
||||||
| /imports/class-plan/preview、/imports/class-plan | 旧预览与完整档案提交;提交走关系化导入 |
|
| /imports/class-plan/preview、/imports/class-plan | 旧预览与完整档案提交;提交走关系化导入 |
|
||||||
| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,写入/删除需 timetable:manage |
|
| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,写入/删除需 timetable:manage |
|
||||||
|
| GET /class-plans/profile?classId= | 完整档案导出,按班级范围读取,保留时间点与课表关联 |
|
||||||
| GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 |
|
| GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 |
|
||||||
| POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 |
|
| POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 |
|
||||||
|
| GET/POST /courseware/folders;PATCH /courseware/:id/folder | 员工 courseware:write;目录与课件均限本人 |
|
||||||
| GET /courseware/transfers;POST /courseware/transfers/:id/ack | 班级端补领、逐设备回执 |
|
| GET /courseware/transfers;POST /courseware/transfers/:id/ack | 班级端补领、逐设备回执 |
|
||||||
|
|
||||||
## 设备、系统与运维
|
## 设备、系统与运维
|
||||||
@@ -53,6 +55,7 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
|
|||||||
| 路径/动作 | 行为及边界 |
|
| 路径/动作 | 行为及边界 |
|
||||||
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 |
|
| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 |
|
||||||
|
| POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 |
|
||||||
| GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 |
|
| GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 |
|
||||||
| POST /clients/commands | remote:control + 对应范围,白名单动作 |
|
| POST /clients/commands | remote:control + 对应范围,白名单动作 |
|
||||||
| POST /clients/updates | client:update,安装策略 immediate/after-school/idle |
|
| POST /clients/updates | client:update,安装策略 immediate/after-school/idle |
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ description: 现行模型职责、迁移与快照边界。
|
|||||||
| `ScheduledClassPlan` | 按本地日期预定日课表 |
|
| `ScheduledClassPlan` | 按本地日期预定日课表 |
|
||||||
| `PromotionRun` | 年度升级幂等与审计 |
|
| `PromotionRun` | 年度升级幂等与审计 |
|
||||||
| `Courseware` | 文件元数据、上传教师、存储路径与 SHA256 |
|
| `Courseware` | 文件元数据、上传教师、存储路径与 SHA256 |
|
||||||
|
| `CoursewareFolder` | 教师自建分类目录,按上传者隔离 |
|
||||||
| `CoursewareVisibility` | 课件可见班级 |
|
| `CoursewareVisibility` | 课件可见班级 |
|
||||||
| `CoursewarePush` | 按班级的推送任务 |
|
| `CoursewarePush` | 按班级的推送任务 |
|
||||||
| `CoursewareReceipt` | 按设备的课件接收回执 |
|
| `CoursewareReceipt` | 按设备的课件接收回执 |
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ docker compose up -d
|
|||||||
|
|
||||||
### 客户端(ClassHelper 班级端)
|
### 客户端(ClassHelper 班级端)
|
||||||
|
|
||||||
|
Web「客户端」可缓存安装包并推送目标版本,客户端从本服务下载并校验 SHA256,按立即、放学后或空闲策略安装。管理页显示下载字节与百分比、校验中、等待安装、安装中和失败原因。安装前先上报「正在更新,将会短暂离线」,新版上线后的心跳确认「已完成更新,版本 xxxx」;仅离线不会被判为更新成功。关闭自动更新的客户端会显示原因。
|
||||||
|
|
||||||
从 Releases 取新的 `setup.exe` 覆盖安装即可,配置在 `%APPDATA%`,不受影响。
|
从 Releases 取新的 `setup.exe` 覆盖安装即可,配置在 `%APPDATA%`,不受影响。
|
||||||
|
|
||||||
> 如果你是在**自己维护的机器**上只替换了 `releases/` 里的产物、而没有同步到安装目录,
|
> 如果你是在**自己维护的机器**上只替换了 `releases/` 里的产物、而没有同步到安装目录,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "class-helper",
|
"name": "class-helper",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/desktop-client",
|
"name": "@classhelper/desktop-client",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||||
"author": "Class Helper",
|
"author": "Class Helper",
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ const islandClass = computed(() => ({
|
|||||||
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
|
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
|
||||||
'ci-lesson-card': !!state.value.lessonReminder,
|
'ci-lesson-card': !!state.value.lessonReminder,
|
||||||
'message-surface': !!state.value.active && mode.value !== 'hidden' && !state.value.lessonReminder,
|
'message-surface': !!state.value.active && mode.value !== 'hidden' && !state.value.lessonReminder,
|
||||||
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
|
sliver: mode.value === 'hidden' && appearance.value.idleSliver && !state.value.lessonReminder,
|
||||||
urgent: isUrgent.value,
|
urgent: isUrgent.value,
|
||||||
call: isCall.value,
|
call: isCall.value,
|
||||||
homework: isHomework.value,
|
homework: isHomework.value,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const time = computed(() =>
|
|||||||
);
|
);
|
||||||
const regions = ref([0, 0, 0, 0, 0]);
|
const regions = ref([0, 0, 0, 0, 0]);
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
|
let fallback: ReturnType<typeof setTimeout> | undefined;
|
||||||
// ClassIsland SlantedMaskControl:5 个斜切区域、30°偏移、两侧向中央打开,中央向两侧关闭。
|
// ClassIsland SlantedMaskControl:5 个斜切区域、30°偏移、两侧向中央打开,中央向两侧关闭。
|
||||||
const polygons = computed(() => {
|
const polygons = computed(() => {
|
||||||
const offset = Math.tan(Math.PI / 6) * 40;
|
const offset = Math.tan(Math.PI / 6) * 40;
|
||||||
@@ -71,12 +72,21 @@ const polygons = computed(() => {
|
|||||||
});
|
});
|
||||||
function animateMask() {
|
function animateMask() {
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
|
clearTimeout(fallback);
|
||||||
const target = mask.value ? 1 : 0;
|
const target = mask.value ? 1 : 0;
|
||||||
if (!props.motion) {
|
if (!props.motion) {
|
||||||
regions.value = [target, target, target, target, target];
|
regions.value = [target, target, target, target, target];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const from = [...regions.value];
|
const from = [...regions.value];
|
||||||
|
// 隐藏窗口恢复或系统合成暂停时 rAF 可能停住,遮罩仍须在有限时间内成为可见实体。
|
||||||
|
fallback = setTimeout(
|
||||||
|
() => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
regions.value = [target, target, target, target, target];
|
||||||
|
},
|
||||||
|
600 / Math.max(0.25, props.appearance.speed),
|
||||||
|
);
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
const delays = mask.value ? [0, 90, 120, 90, 0] : [120, 90, 0, 90, 120];
|
const delays = mask.value ? [0, 90, 120, 90, 0] : [120, 90, 0, 90, 120];
|
||||||
const update = (at: number) => {
|
const update = (at: number) => {
|
||||||
@@ -109,6 +119,7 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
|
clearTimeout(fallback);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -137,7 +148,7 @@ onUnmounted(() => {
|
|||||||
<strong>{{ leftSeconds }}</strong>
|
<strong>{{ leftSeconds }}</strong>
|
||||||
秒
|
秒
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="ci-countdown">课间休息</span>
|
<span v-else class="ci-countdown">{{ reminder.kind === 'on-class' ? '开始上课' : '课间休息' }}</span>
|
||||||
<span class="ci-lesson-course">
|
<span class="ci-lesson-course">
|
||||||
<span>
|
<span>
|
||||||
{{
|
{{
|
||||||
@@ -146,8 +157,8 @@ onUnmounted(() => {
|
|||||||
: '下节课是:'
|
: '下节课是:'
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<strong>{{ course }}</strong>
|
<strong>{{ course || '今日课程已结束' }}</strong>
|
||||||
<small>{{ time }}</small>
|
<small v-if="course">{{ time }}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -194,8 +205,8 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 12px;
|
||||||
padding: 0 28px;
|
padding: 0 14px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: opacity var(--ci-duration) ease-in;
|
transition: opacity var(--ci-duration) ease-in;
|
||||||
}
|
}
|
||||||
@@ -222,6 +233,7 @@ onUnmounted(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.ci-lesson-course small {
|
.ci-lesson-course small {
|
||||||
|
flex-shrink: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,26 @@ import {
|
|||||||
import { getConfig, saveConfig } from './config.js';
|
import { getConfig, saveConfig } from './config.js';
|
||||||
import { island } from './island.js';
|
import { island } from './island.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { connectionEvents } from './config-events.js';
|
||||||
|
|
||||||
|
let updateEpoch = 0;
|
||||||
|
let downloadAbort = new AbortController();
|
||||||
|
connectionEvents.on('changed', () => {
|
||||||
|
updateEpoch++;
|
||||||
|
downloadAbort.abort();
|
||||||
|
downloadAbort = new AbortController();
|
||||||
|
policy = null;
|
||||||
|
installationRetryAt = 0;
|
||||||
|
installResultChecked = false;
|
||||||
|
updateProgress = {
|
||||||
|
version: '',
|
||||||
|
stage: 'idle',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
percent: null,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
|
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
|
||||||
@@ -28,15 +48,24 @@ import { logger } from './logger.js';
|
|||||||
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
|
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let policy: Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> | null =
|
let policy:
|
||||||
null;
|
| (Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> & {
|
||||||
|
serverUrl: string;
|
||||||
|
username: string;
|
||||||
|
})
|
||||||
|
| null = null;
|
||||||
function policyPath(): string {
|
function policyPath(): string {
|
||||||
return path.join(pendingUpdateDir(), 'policy.json');
|
return path.join(pendingUpdateDir(), 'policy.json');
|
||||||
}
|
}
|
||||||
function loadPolicy(): typeof policy {
|
function loadPolicy(): typeof policy {
|
||||||
if (policy) return policy;
|
if (policy)
|
||||||
|
return policy.serverUrl === getConfig().serverUrl && policy.username === getConfig().username
|
||||||
|
? policy
|
||||||
|
: null;
|
||||||
try {
|
try {
|
||||||
policy = JSON.parse(fs.readFileSync(policyPath(), 'utf8'));
|
policy = JSON.parse(fs.readFileSync(policyPath(), 'utf8'));
|
||||||
|
if (policy?.serverUrl !== getConfig().serverUrl || policy?.username !== getConfig().username)
|
||||||
|
policy = null;
|
||||||
} catch {
|
} catch {
|
||||||
/* 尚无下发策略 */
|
/* 尚无下发策略 */
|
||||||
}
|
}
|
||||||
@@ -58,7 +87,23 @@ function removeManagedInstaller(file: string): void {
|
|||||||
|
|
||||||
/** 每次成功心跳先同步策略;取消或更换目标后旧包不得自行安装。 */
|
/** 每次成功心跳先同步策略;取消或更换目标后旧包不得自行安装。 */
|
||||||
export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
|
export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
|
||||||
|
if (policy && policy.updateTargetVersion !== next.updateTargetVersion) {
|
||||||
|
installationRetryAt = 0;
|
||||||
|
updateEpoch++;
|
||||||
|
downloadAbort.abort();
|
||||||
|
downloadAbort = new AbortController();
|
||||||
|
updateProgress = {
|
||||||
|
version: next.updateTargetVersion || '',
|
||||||
|
stage: 'idle',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
percent: null,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
policy = {
|
policy = {
|
||||||
|
serverUrl: getConfig().serverUrl,
|
||||||
|
username: getConfig().username,
|
||||||
updateTargetVersion: next.updateTargetVersion,
|
updateTargetVersion: next.updateTargetVersion,
|
||||||
updateMode: next.updateMode ?? 'idle',
|
updateMode: next.updateMode ?? 'idle',
|
||||||
updateNotBefore: next.updateNotBefore ?? null,
|
updateNotBefore: next.updateNotBefore ?? null,
|
||||||
@@ -86,6 +131,8 @@ export function pendingUpdateDir(): string {
|
|||||||
let mainWindowHiddenAt: number | null = null;
|
let mainWindowHiddenAt: number | null = null;
|
||||||
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
|
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
|
||||||
let installing = false;
|
let installing = false;
|
||||||
|
let installationRetryAt = 0;
|
||||||
|
let installResultChecked = false;
|
||||||
let updateProgress: ContractReleaseProgressDto = {
|
let updateProgress: ContractReleaseProgressDto = {
|
||||||
version: '',
|
version: '',
|
||||||
stage: 'idle',
|
stage: 'idle',
|
||||||
@@ -95,12 +142,69 @@ let updateProgress: ContractReleaseProgressDto = {
|
|||||||
error: '',
|
error: '',
|
||||||
};
|
};
|
||||||
export function getUpdateProgress() {
|
export function getUpdateProgress() {
|
||||||
|
if (!installResultChecked) {
|
||||||
|
installResultChecked = true;
|
||||||
|
const resultFile = path.join(pendingUpdateDir(), 'install-result.json');
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(fs.readFileSync(resultFile, 'utf8').replace(/^\uFEFF/, ''));
|
||||||
|
if (
|
||||||
|
result.serverUrl === getConfig().serverUrl &&
|
||||||
|
result.username === getConfig().username &&
|
||||||
|
typeof result.version === 'string' &&
|
||||||
|
result.version === getConfig().pendingUpdateVersion &&
|
||||||
|
isNewerVersion(app.getVersion(), result.version) &&
|
||||||
|
typeof result.error === 'string' &&
|
||||||
|
result.error
|
||||||
|
) {
|
||||||
|
updateProgress = {
|
||||||
|
...updateProgress,
|
||||||
|
version: result.version,
|
||||||
|
stage: 'failed',
|
||||||
|
error: result.error.slice(0, 1000),
|
||||||
|
};
|
||||||
|
installationRetryAt = Date.now() + 5 * 60_000;
|
||||||
|
fs.rmSync(resultFile, { force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 尚未执行安装助手,或上一次已成功。 */
|
||||||
|
}
|
||||||
|
}
|
||||||
return { ...updateProgress };
|
return { ...updateProgress };
|
||||||
}
|
}
|
||||||
let progressPublishedAt = 0;
|
let progressPublishedAt = 0;
|
||||||
|
let reportAt = 0;
|
||||||
|
let reporting: Promise<boolean> = Promise.resolve(true);
|
||||||
|
/** 与心跳分离,按顺序上报;安装前等待该队列完成再退出。 */
|
||||||
|
function reportProgress(): Promise<boolean> {
|
||||||
|
const config = getConfig(),
|
||||||
|
snapshot = getUpdateProgress(),
|
||||||
|
epoch = updateEpoch;
|
||||||
|
if (!config.token || process.env.ELECTRON_SMOKE_TEST === '1') return Promise.resolve(true);
|
||||||
|
reporting = reporting
|
||||||
|
.catch(() => false)
|
||||||
|
.then(async () => {
|
||||||
|
if (epoch !== updateEpoch) return false;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.serverUrl}/api/clients/update-progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: `Bearer ${config.token}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ deviceKey: config.deviceKey, progress: snapshot }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
redirect: 'error',
|
||||||
|
});
|
||||||
|
if (!response.ok) logger.warn(`更新进度上报失败:HTTP ${response.status}`);
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`更新进度上报失败:${String(error)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return reporting;
|
||||||
|
}
|
||||||
function publishProgress(patch: Partial<typeof updateProgress>) {
|
function publishProgress(patch: Partial<typeof updateProgress>) {
|
||||||
const previousStage = updateProgress.stage;
|
const previousStage = updateProgress.stage;
|
||||||
updateProgress = { ...updateProgress, ...patch };
|
updateProgress = { ...updateProgress, ...patch };
|
||||||
|
updateProgress.error = updateProgress.error.slice(0, 1000);
|
||||||
updateProgress.percent = updateProgress.totalBytes
|
updateProgress.percent = updateProgress.totalBytes
|
||||||
? Math.min(100, Math.round((updateProgress.downloadedBytes / updateProgress.totalBytes) * 100))
|
? Math.min(100, Math.round((updateProgress.downloadedBytes / updateProgress.totalBytes) * 100))
|
||||||
: null;
|
: null;
|
||||||
@@ -108,6 +212,10 @@ function publishProgress(patch: Partial<typeof updateProgress>) {
|
|||||||
progressPublishedAt = Date.now();
|
progressPublishedAt = Date.now();
|
||||||
for (const win of BrowserWindow.getAllWindows())
|
for (const win of BrowserWindow.getAllWindows())
|
||||||
if (!win.isDestroyed()) win.webContents.send('client:update-progress', getUpdateProgress());
|
if (!win.isDestroyed()) win.webContents.send('client:update-progress', getUpdateProgress());
|
||||||
|
if (previousStage !== updateProgress.stage || Date.now() - reportAt >= 2000) {
|
||||||
|
reportAt = Date.now();
|
||||||
|
void reportProgress();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
|
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
|
||||||
@@ -175,6 +283,7 @@ export function shouldConsider(version: string): boolean {
|
|||||||
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
|
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
|
||||||
*/
|
*/
|
||||||
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
|
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
|
||||||
|
const epoch = updateEpoch;
|
||||||
const version = targetVersion.trim() || info.version.trim();
|
const version = targetVersion.trim() || info.version.trim();
|
||||||
if (!shouldConsider(version)) {
|
if (!shouldConsider(version)) {
|
||||||
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
|
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
|
||||||
@@ -189,6 +298,13 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
|||||||
if (config.pendingUpdateVersion === version) {
|
if (config.pendingUpdateVersion === version) {
|
||||||
const existing = config.pendingUpdateFile;
|
const existing = config.pendingUpdateFile;
|
||||||
if (existing && fs.existsSync(existing)) {
|
if (existing && fs.existsSync(existing)) {
|
||||||
|
publishProgress({
|
||||||
|
version,
|
||||||
|
stage: 'waiting',
|
||||||
|
downloadedBytes: info.size,
|
||||||
|
totalBytes: info.size,
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
|
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
|
||||||
await maybeInstallPendingUpdate();
|
await maybeInstallPendingUpdate();
|
||||||
return;
|
return;
|
||||||
@@ -222,7 +338,8 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
|||||||
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
|
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
signal: AbortSignal.any([AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), downloadAbort.signal]),
|
||||||
|
redirect: 'error',
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`下载失败:HTTP ${response.status}`);
|
throw new Error(`下载失败:HTTP ${response.status}`);
|
||||||
@@ -235,13 +352,20 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
|||||||
const handle = await fsp.open(temp, 'w');
|
const handle = await fsp.open(temp, 'w');
|
||||||
try {
|
try {
|
||||||
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
|
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||||||
await handle.write(chunk);
|
let offset = 0;
|
||||||
|
while (offset < chunk.byteLength) {
|
||||||
|
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
|
||||||
|
if (!bytesWritten) throw new Error('更新包写入失败');
|
||||||
|
offset += bytesWritten;
|
||||||
|
}
|
||||||
publishProgress({ downloadedBytes: updateProgress.downloadedBytes + chunk.byteLength });
|
publishProgress({ downloadedBytes: updateProgress.downloadedBytes + chunk.byteLength });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (epoch !== updateEpoch) return;
|
||||||
|
if (info.size > 0 && updateProgress.downloadedBytes !== info.size) throw new Error('更新包长度不匹配');
|
||||||
publishProgress({ stage: 'verifying' });
|
publishProgress({ stage: 'verifying' });
|
||||||
const digest = await sha256File(temp);
|
const digest = await sha256File(temp);
|
||||||
const expected = info.sha256.trim().toLowerCase();
|
const expected = info.sha256.trim().toLowerCase();
|
||||||
@@ -264,7 +388,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
|||||||
await fsp.rm(target, { force: true });
|
await fsp.rm(target, { force: true });
|
||||||
await fsp.rename(temp, target);
|
await fsp.rename(temp, target);
|
||||||
publishProgress({ stage: 'waiting' });
|
publishProgress({ stage: 'waiting' });
|
||||||
if (loadPolicy()?.updateTargetVersion !== version) {
|
if (epoch !== updateEpoch || loadPolicy()?.updateTargetVersion !== version) {
|
||||||
removeManagedInstaller(target);
|
removeManagedInstaller(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -277,6 +401,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
|
|||||||
await maybeInstallPendingUpdate();
|
await maybeInstallPendingUpdate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fsp.rm(temp, { force: true }).catch(() => undefined);
|
await fsp.rm(temp, { force: true }).catch(() => undefined);
|
||||||
|
if (epoch !== updateEpoch) return;
|
||||||
publishProgress({ stage: 'failed', error: String(error) });
|
publishProgress({ stage: 'failed', error: String(error) });
|
||||||
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
|
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -319,6 +444,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
|||||||
const courseware = island.getCoursewareTransfer();
|
const courseware = island.getCoursewareTransfer();
|
||||||
if (courseware && ['downloading', 'verifying'].includes(courseware.stage)) return;
|
if (courseware && ['downloading', 'verifying'].includes(courseware.stage)) return;
|
||||||
if (installing) return;
|
if (installing) return;
|
||||||
|
if (Date.now() < installationRetryAt) return;
|
||||||
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
|
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
@@ -375,15 +501,36 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
installing = true;
|
installing = true;
|
||||||
publishProgress({ stage: 'installing' });
|
const installEpoch = updateEpoch;
|
||||||
|
publishProgress({ version, stage: 'installing', error: '' });
|
||||||
|
const reported = await reporting;
|
||||||
|
if (!reported) {
|
||||||
|
installing = false;
|
||||||
|
publishProgress({ stage: 'waiting', error: '安装前状态尚未送达服务端,等待网络恢复后重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
installEpoch !== updateEpoch ||
|
||||||
|
loadPolicy()?.updateTargetVersion !== version ||
|
||||||
|
getConfig().autoUpdate === false
|
||||||
|
) {
|
||||||
|
installing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
||||||
try {
|
try {
|
||||||
const exe = app.getPath('exe');
|
const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
|
||||||
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
|
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
|
||||||
|
const config = getConfig();
|
||||||
|
const resultFile = path.join(pendingUpdateDir(), 'install-result.json');
|
||||||
|
// 上一次失败的结果不能冒充这一次:安装开始时先清掉,只有本轮失败才重新写入。
|
||||||
|
fs.rmSync(resultFile, { force: true });
|
||||||
const script =
|
const script =
|
||||||
`$ErrorActionPreference = 'Stop'; Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
|
`$ErrorActionPreference = 'Stop'; try { Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
|
||||||
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -Wait -PassThru; ` +
|
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -WindowStyle Hidden -Wait -PassThru; ` +
|
||||||
`if ($installer.ExitCode -ne 0 -and (Test-Path -LiteralPath ${quote(exe)})) { Start-Process -FilePath ${quote(exe)} }`;
|
`if ($installer.ExitCode -ne 0) { throw ('安装器退出码:' + $installer.ExitCode) } ` +
|
||||||
|
`} catch { @{ version=${quote(version)}; serverUrl=${quote(config.serverUrl)}; username=${quote(config.username)}; error=$_.Exception.Message } | ConvertTo-Json -Compress | Set-Content -LiteralPath ${quote(resultFile)} -Encoding UTF8; ` +
|
||||||
|
`if (Test-Path -LiteralPath ${quote(exe)}) { Start-Process -FilePath ${quote(exe)} -WindowStyle Hidden } }`;
|
||||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
'powershell.exe',
|
'powershell.exe',
|
||||||
@@ -408,6 +555,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
installing = false;
|
installing = false;
|
||||||
|
publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) });
|
||||||
logger.error(
|
logger.error(
|
||||||
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
|
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import {
|
|||||||
type ClientHeartbeatResponse,
|
type ClientHeartbeatResponse,
|
||||||
type ClientUpdateInfoDto,
|
type ClientUpdateInfoDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { maybeInstallPendingUpdate, considerUpdate, syncUpdatePolicy } from './auto-update.js';
|
import {
|
||||||
|
maybeInstallPendingUpdate,
|
||||||
|
considerUpdate,
|
||||||
|
syncUpdatePolicy,
|
||||||
|
getUpdateProgress,
|
||||||
|
} from './auto-update.js';
|
||||||
import { connectionEvents, type ConnectionEvent } from './config-events.js';
|
import { connectionEvents, type ConnectionEvent } from './config-events.js';
|
||||||
import { getConfig } from './config.js';
|
import { getConfig } from './config.js';
|
||||||
import { island } from './island.js';
|
import { island } from './island.js';
|
||||||
@@ -178,6 +183,7 @@ async function beat(): Promise<void> {
|
|||||||
autoUpdate: config.autoUpdate !== false,
|
autoUpdate: config.autoUpdate !== false,
|
||||||
pendingVersion: pendingVersionOrNull(),
|
pendingVersion: pendingVersionOrNull(),
|
||||||
stateNote: currentStateNote(),
|
stateNote: currentStateNote(),
|
||||||
|
updateProgress: getUpdateProgress(),
|
||||||
};
|
};
|
||||||
const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, {
|
const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { app, BrowserWindow, shell } from 'electron';
|
|||||||
import {
|
import {
|
||||||
COURSEWARE_MAX_BYTES,
|
COURSEWARE_MAX_BYTES,
|
||||||
COURSEWARE_TRANSFER_TIMEOUT_MS,
|
COURSEWARE_TRANSFER_TIMEOUT_MS,
|
||||||
|
coursewareArchivePath,
|
||||||
|
coursewarePathSegment,
|
||||||
type ApiResponse,
|
type ApiResponse,
|
||||||
type CoursewareDto,
|
type CoursewareDto,
|
||||||
type CoursewarePushDto,
|
type CoursewarePushDto,
|
||||||
@@ -74,6 +76,34 @@ async function digest(file: string) {
|
|||||||
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
||||||
|
interface LocalCourseware {
|
||||||
|
item: CoursewareDto;
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
function localFile(root: string, relative: string) {
|
||||||
|
const file = path.resolve(root, relative);
|
||||||
|
if (!file.startsWith(path.resolve(root) + path.sep)) throw new Error('课件路径无效');
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
async function localRecord(id: string, root = scopeDirectory()): Promise<LocalCourseware | null> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fsp.readFile(path.join(root, '.index', `${id}.json`), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function listLocalCourseware(): Promise<CoursewareDto[]> {
|
||||||
|
if (!getConfig().token) return [];
|
||||||
|
const root = scopeDirectory();
|
||||||
|
const startedEpoch = epoch;
|
||||||
|
const names = await fsp.readdir(path.join(root, '.index')).catch(() => []);
|
||||||
|
const result: CoursewareDto[] = [];
|
||||||
|
for (const name of names.filter((name) => /^[a-z0-9-]+\.json$/i.test(name))) {
|
||||||
|
const record = await localRecord(name.slice(0, -5), root);
|
||||||
|
if (record && fs.existsSync(localFile(root, record.relativePath))) result.push(record.item);
|
||||||
|
}
|
||||||
|
return startedEpoch === epoch ? result.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : [];
|
||||||
|
}
|
||||||
export function downloadCourseware(id: string): Promise<void> {
|
export function downloadCourseware(id: string): Promise<void> {
|
||||||
checkId(id);
|
checkId(id);
|
||||||
const startedEpoch = epoch,
|
const startedEpoch = epoch,
|
||||||
@@ -99,10 +129,22 @@ export function downloadCourseware(id: string): Promise<void> {
|
|||||||
!/^[a-f0-9]{64}$/.test(item.sha256)
|
!/^[a-f0-9]{64}$/.test(item.sha256)
|
||||||
)
|
)
|
||||||
throw new Error('课件文件信息无效');
|
throw new Error('课件文件信息无效');
|
||||||
const safeName =
|
const root = scopeDirectory();
|
||||||
`_${item.name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/[. ]+$/, '')}`.slice(0, 182);
|
const stored = await localRecord(id, root);
|
||||||
const dir = path.join(scopeDirectory(), id),
|
let relativePath =
|
||||||
file = path.join(dir, safeName);
|
stored?.relativePath ||
|
||||||
|
(item.archivePath || coursewareArchivePath(item.teacher.name, new Date(item.createdAt), item.name))
|
||||||
|
.split('/')
|
||||||
|
.map(coursewarePathSegment)
|
||||||
|
.join(path.sep);
|
||||||
|
let file = localFile(root, relativePath);
|
||||||
|
// 同秒、同名文件不得覆盖先前课件;重试通过本地索引始终定位同一文件。
|
||||||
|
if (!stored && fs.existsSync(file)) {
|
||||||
|
const ext = path.extname(file);
|
||||||
|
relativePath = relativePath.slice(0, relativePath.length - ext.length) + ` (${id})` + ext;
|
||||||
|
file = localFile(root, relativePath);
|
||||||
|
}
|
||||||
|
const dir = path.dirname(file);
|
||||||
partial = `${file}.part`;
|
partial = `${file}.part`;
|
||||||
const base: CoursewareTransfer = {
|
const base: CoursewareTransfer = {
|
||||||
id,
|
id,
|
||||||
@@ -164,6 +206,17 @@ export function downloadCourseware(id: string): Promise<void> {
|
|||||||
await fsp.rename(partial, file);
|
await fsp.rename(partial, file);
|
||||||
}
|
}
|
||||||
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
|
if (startedEpoch !== epoch) throw new Error('登录会话已切换');
|
||||||
|
await fsp.mkdir(path.join(root, '.index'), { recursive: true });
|
||||||
|
const indexFile = path.join(root, '.index', `${id}.json`);
|
||||||
|
await fsp.writeFile(
|
||||||
|
`${indexFile}.part`,
|
||||||
|
JSON.stringify({
|
||||||
|
item: { ...item, archivePath: relativePath.split(path.sep).join('/') },
|
||||||
|
relativePath,
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await fsp.rename(`${indexFile}.part`, indexFile);
|
||||||
publish({ ...base, receivedBytes: item.size, progress: 100, stage: 'completed' });
|
publish({ ...base, receivedBytes: item.size, progress: 100, stage: 'completed' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (partial) await fsp.rm(partial, { force: true }).catch(() => undefined);
|
if (partial) await fsp.rm(partial, { force: true }).catch(() => undefined);
|
||||||
@@ -232,6 +285,15 @@ export async function pollCourseware() {
|
|||||||
}
|
}
|
||||||
export async function revealCourseware(id: string) {
|
export async function revealCourseware(id: string) {
|
||||||
checkId(id);
|
checkId(id);
|
||||||
|
if (!getConfig().token) throw new Error('请先登录班级账号');
|
||||||
|
const stored = await localRecord(id);
|
||||||
|
if (stored) {
|
||||||
|
const file = localFile(scopeDirectory(), stored.relativePath);
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
shell.showItemInFolder(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const dir = path.join(scopeDirectory(), id);
|
const dir = path.join(scopeDirectory(), id);
|
||||||
const names = await fsp.readdir(dir).catch(() => []);
|
const names = await fsp.readdir(dir).catch(() => []);
|
||||||
const file = names.find((name) => !name.endsWith('.part'));
|
const file = names.find((name) => !name.endsWith('.part'));
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { downloadCourseware, getCoursewareTransfer, pollCourseware, revealCourseware } from './courseware.js';
|
import {
|
||||||
|
downloadCourseware,
|
||||||
|
getCoursewareTransfer,
|
||||||
|
pollCourseware,
|
||||||
|
revealCourseware,
|
||||||
|
listLocalCourseware,
|
||||||
|
} from './courseware.js';
|
||||||
import { island } from './island.js';
|
import { island } from './island.js';
|
||||||
import { getUpdateProgress } from './auto-update.js';
|
import { getUpdateProgress } from './auto-update.js';
|
||||||
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||||
@@ -17,7 +23,10 @@ export { IPC_CHANNELS };
|
|||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
|
ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id));
|
||||||
ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
|
ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id));
|
||||||
ipcMain.handle('courseware:sync', () => { void pollCourseware(); });
|
ipcMain.handle('courseware:local-list', () => listLocalCourseware());
|
||||||
|
ipcMain.handle('courseware:sync', () => {
|
||||||
|
void pollCourseware();
|
||||||
|
});
|
||||||
ipcMain.handle('courseware:get-progress', () => getCoursewareTransfer());
|
ipcMain.handle('courseware:get-progress', () => getCoursewareTransfer());
|
||||||
ipcMain.handle('client:get-update-progress', () => getUpdateProgress());
|
ipcMain.handle('client:get-update-progress', () => getUpdateProgress());
|
||||||
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
|
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
|
||||||
|
|||||||
@@ -195,7 +195,14 @@ class IslandController {
|
|||||||
if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return;
|
if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return;
|
||||||
this.lessonCue = next;
|
this.lessonCue = next;
|
||||||
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
|
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
|
||||||
if (this.lessonShowing) this.syncTouchLayout();
|
if (this.lessonShowing) {
|
||||||
|
// 通知收回的延迟回调不能在课表提醒出现后再隐藏同一扇原生窗口。
|
||||||
|
if (this.closeTimer) clearTimeout(this.closeTimer);
|
||||||
|
this.closeTimer = null;
|
||||||
|
this.cancelFade();
|
||||||
|
this.syncResidentShape(true);
|
||||||
|
this.syncTouchLayout();
|
||||||
|
}
|
||||||
this.emit();
|
this.emit();
|
||||||
this.syncWindow();
|
this.syncWindow();
|
||||||
}
|
}
|
||||||
@@ -1106,6 +1113,10 @@ class IslandController {
|
|||||||
* 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。
|
* 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。
|
||||||
*/
|
*/
|
||||||
private finishClose(): void {
|
private finishClose(): void {
|
||||||
|
if (this.lessonShowing || this.coursewareShowing) {
|
||||||
|
this.setState({ mode: 'hidden', reason: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 动画期间又来了新消息 / 被显式唤起:这次收回归属于旧的一批,放弃收尾
|
// 动画期间又来了新消息 / 被显式唤起:这次收回归属于旧的一批,放弃收尾
|
||||||
if (this.state.mode !== 'pill' || this.state.reason !== 'closing') return;
|
if (this.state.mode !== 'pill' || this.state.reason !== 'closing') return;
|
||||||
if (this.state.active || this.state.queued.length > 0) return;
|
if (this.state.active || this.state.queued.length > 0) return;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
dayKeyLocal,
|
dayKeyLocal,
|
||||||
resolveClassStatus,
|
|
||||||
DEFAULT_LESSON_REMINDERS,
|
DEFAULT_LESSON_REMINDERS,
|
||||||
type LessonReminderSettings,
|
type LessonReminderSettings,
|
||||||
type IslandLessonReminder,
|
type IslandLessonReminder,
|
||||||
@@ -23,16 +22,18 @@ export class LessonReminderTracker {
|
|||||||
const at = now.getTime();
|
const at = now.getTime();
|
||||||
const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) =>
|
const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) =>
|
||||||
`${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`;
|
`${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`;
|
||||||
const status = resolveClassStatus(schedules, now);
|
|
||||||
const timeAt = (time: string) => {
|
const timeAt = (time: string) => {
|
||||||
const d = new Date(now);
|
const d = new Date(now);
|
||||||
const [h, m] = time.split(':').map(Number);
|
const [h, m, s = 0] = time.split(':').map(Number);
|
||||||
d.setHours(h!, m!, 0, 0);
|
d.setHours(h!, m!, s, 0);
|
||||||
return d.getTime();
|
return d.getTime();
|
||||||
};
|
};
|
||||||
const today = schedules.filter((s) => s.dayOfWeek === (now.getDay() || 7));
|
const today = schedules
|
||||||
const current = today.find((s) => s.id === status.current?.scheduleId);
|
.filter((s) => s.dayOfWeek === (now.getDay() || 7))
|
||||||
const next = today.find((s) => s.id === status.next?.scheduleId);
|
.sort((a, b) => timeAt(a.startTime) - timeAt(b.startTime));
|
||||||
|
// 已解析日课表只按准确时间比较,避免带秒的课时提前触发或错过上课遮罩。
|
||||||
|
const current = today.find((s) => at >= timeAt(s.startTime) && at < timeAt(s.endTime));
|
||||||
|
const next = today.find((s) => timeAt(s.startTime) > at);
|
||||||
const ended = today
|
const ended = today
|
||||||
.filter((s) => at >= timeAt(s.endTime) && at - timeAt(s.endTime) < 12_000)
|
.filter((s) => at >= timeAt(s.endTime) && at - timeAt(s.endTime) < 12_000)
|
||||||
.sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
|
.sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
|
||||||
@@ -74,12 +75,14 @@ export class LessonReminderTracker {
|
|||||||
const entered = this.current?.id === id && at >= this.current.startedAt ? this.current.startedAt : at;
|
const entered = this.current?.id === id && at >= this.current.startedAt ? this.current.startedAt : at;
|
||||||
const start = timeAt(lesson.startTime);
|
const start = timeAt(lesson.startTime);
|
||||||
const end = timeAt(lesson.endTime);
|
const end = timeAt(lesson.endTime);
|
||||||
|
// 导入的 ClassIsland 时间点可能带秒;提醒条只显示到分钟,宽度保持稳定。
|
||||||
|
const clock = (value: string) => value.slice(0, 5);
|
||||||
return (this.current = {
|
return (this.current = {
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
courseName: lesson.course?.name || '课程',
|
courseName: lesson.course?.name || '课程',
|
||||||
startTime: lesson.startTime,
|
startTime: clock(lesson.startTime),
|
||||||
endTime: lesson.endTime,
|
endTime: clock(lesson.endTime),
|
||||||
startedAt: entered,
|
startedAt: entered,
|
||||||
maskUntil:
|
maskUntil:
|
||||||
kind === 'ending' || kind === 'in-class'
|
kind === 'ending' || kind === 'in-class'
|
||||||
@@ -100,8 +103,8 @@ export class LessonReminderTracker {
|
|||||||
lessonStartsAt: start,
|
lessonStartsAt: start,
|
||||||
lessonEndsAt: end,
|
lessonEndsAt: end,
|
||||||
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
|
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
|
||||||
nextStartTime: kind === 'break' ? next?.startTime : undefined,
|
nextStartTime: kind === 'break' && next ? clock(next.startTime) : undefined,
|
||||||
nextEndTime: kind === 'break' ? next?.endTime : undefined,
|
nextEndTime: kind === 'break' && next ? clock(next.endTime) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import { app } from 'electron';
|
|||||||
import type { CoursewareDto } from '@classhelper/shared';
|
import type { CoursewareDto } from '@classhelper/shared';
|
||||||
import { defaultIslandLayouts } from '@classhelper/shared';
|
import { defaultIslandLayouts } from '@classhelper/shared';
|
||||||
import { getConfig, saveConfig } from './config.js';
|
import { getConfig, saveConfig } from './config.js';
|
||||||
import { downloadCourseware, getCoursewareTransfer } from './courseware.js';
|
import { downloadCourseware, getCoursewareTransfer, listLocalCourseware } from './courseware.js';
|
||||||
import { island } from './island.js';
|
import { island } from './island.js';
|
||||||
export async function verifyCoursewareSmoke(
|
export async function verifyCoursewareSmoke(
|
||||||
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
|
check: (name: string, action: () => Promise<boolean | string> | boolean) => Promise<void>,
|
||||||
@@ -93,7 +93,7 @@ export async function verifyCoursewareSmoke(
|
|||||||
});
|
});
|
||||||
await job;
|
await job;
|
||||||
await check('课件校验完成后保存正式文件并显示完成', async () => {
|
await check('课件校验完成后保存正式文件并显示完成', async () => {
|
||||||
const files = await localFiles();
|
const files = (await localFiles()).filter((file) => file.endsWith('.pptx'));
|
||||||
return (
|
return (
|
||||||
getCoursewareTransfer()?.stage === 'completed' &&
|
getCoursewareTransfer()?.stage === 'completed' &&
|
||||||
getCoursewareTransfer()?.progress === 100 &&
|
getCoursewareTransfer()?.progress === 100 &&
|
||||||
@@ -101,6 +101,18 @@ export async function verifyCoursewareSmoke(
|
|||||||
(await fs.readFile(files[0]!)).equals(bytes)
|
(await fs.readFile(files[0]!)).equals(bytes)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await check('本机归档索引支持离线浏览且路径按教师年月日时间分类', async () => {
|
||||||
|
const saved = await listLocalCourseware();
|
||||||
|
const files = (await localFiles()).filter((file) => file.endsWith('.pptx'));
|
||||||
|
return (
|
||||||
|
saved.some((local) => local.id === item.id) &&
|
||||||
|
files.some((file) =>
|
||||||
|
/Teachers[\\/]临时课件教师[\\/]\d{4}[\\/]\d{2}[\\/]\d{2}[\\/]\d{2}-\d{2}-\d{2}[\\/]课件接收验证\.pptx$/.test(
|
||||||
|
file,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
await check('已完整接收的相同课件不重复传输', async () => {
|
await check('已完整接收的相同课件不重复传输', async () => {
|
||||||
await downloadCourseware(item.id);
|
await downloadCourseware(item.id);
|
||||||
return downloads === 1;
|
return downloads === 1;
|
||||||
|
|||||||
@@ -558,7 +558,15 @@ export async function verifyResidentSmoke(
|
|||||||
await pause(800);
|
await pause(800);
|
||||||
if (island.getState().lessonReminder?.kind !== 'prepare') return false;
|
if (island.getState().lessonReminder?.kind !== 'prepare') return false;
|
||||||
const mask = await win.webContents.executeJavaScript(
|
const mask = await win.webContents.executeJavaScript(
|
||||||
`(() => {const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'} .ci-mask')||document.querySelector('.ci-fixed-lesson .ci-mask');return Boolean(cue&&cue.querySelectorAll('polygon').length===5);})()`,
|
`(() => {
|
||||||
|
const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'} .ci-mask')||document.querySelector('.ci-fixed-lesson .ci-mask');
|
||||||
|
if (!cue || cue.querySelectorAll('polygon').length !== 5) return false;
|
||||||
|
const card=cue.closest('.ci-lesson'), title=card?.querySelector('.ci-mask-title');
|
||||||
|
const bounds=card?.getBoundingClientRect(), painted=cue.getBBox();
|
||||||
|
let opacity=1, node=card;
|
||||||
|
while(node&&node!==document.body){const style=getComputedStyle(node);if(style.visibility==='hidden'||style.display==='none')return false;opacity*=Number(style.opacity);node=node.parentElement;}
|
||||||
|
return Boolean(bounds&&bounds.width>=290&&bounds.height>=39&&painted.width>=bounds.width*.9&&title&&Number(getComputedStyle(title).opacity)>.8&&opacity>.8);
|
||||||
|
})()`,
|
||||||
);
|
);
|
||||||
if (!mask) return false;
|
if (!mask) return false;
|
||||||
await shot('lesson-prepare-mask-' + layout);
|
await shot('lesson-prepare-mask-' + layout);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { ReleaseProgressDto as ContractReleaseProgressDto, CoursewareTransfer } from '@classhelper/shared';
|
import type {
|
||||||
|
ReleaseProgressDto as ContractReleaseProgressDto,
|
||||||
|
CoursewareTransfer,
|
||||||
|
} from '@classhelper/shared';
|
||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
|
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
|
||||||
import type { ClientAgentStatus } from '../main/client-agent.js';
|
import type { ClientAgentStatus } from '../main/client-agent.js';
|
||||||
@@ -22,6 +25,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
|
|||||||
const bridge: DesktopBridge = {
|
const bridge: DesktopBridge = {
|
||||||
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
|
downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id),
|
||||||
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
|
revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id),
|
||||||
|
listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'),
|
||||||
syncCourseware: () => ipcRenderer.invoke('courseware:sync'),
|
syncCourseware: () => ipcRenderer.invoke('courseware:sync'),
|
||||||
getCoursewareTransfer: () => ipcRenderer.invoke('courseware:get-progress'),
|
getCoursewareTransfer: () => ipcRenderer.invoke('courseware:get-progress'),
|
||||||
onCoursewareTransfer: (handler) => {
|
onCoursewareTransfer: (handler) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
COURSEWARE_CATEGORIES,
|
COURSEWARE_CATEGORIES,
|
||||||
@@ -12,6 +12,27 @@ import { fetchWithCache } from '../cache/index.js';
|
|||||||
import { getCacheScope } from '../cache/scope.js';
|
import { getCacheScope } from '../cache/scope.js';
|
||||||
import { useRealtimeStore } from '../stores/realtime.js';
|
import { useRealtimeStore } from '../stores/realtime.js';
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
const localItems = ref<CoursewareDto[]>([]),
|
||||||
|
library = ref<'shared' | 'local'>('shared'),
|
||||||
|
teacher = ref(''),
|
||||||
|
archive = ref('');
|
||||||
|
const source = computed(() => (library.value === 'local' ? localItems.value : items.value));
|
||||||
|
const teachers = computed(() => [...new Set(source.value.map((item) => item.teacher.name))]);
|
||||||
|
const archiveFolders = computed(() => [
|
||||||
|
...new Set(
|
||||||
|
source.value
|
||||||
|
.filter((item) => !teacher.value || item.teacher.name === teacher.value)
|
||||||
|
.map((item) => item.archivePath?.split('/').slice(0, -1).join('/') || item.archiveDate),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
async function refreshLocal() {
|
||||||
|
localItems.value = (await window.desktop?.listLocalCourseware()) || [];
|
||||||
|
}
|
||||||
|
// 两个来源的教师与目录不通用,切换时清空,避免看到空列表却找不到原因。
|
||||||
|
watch(library, () => {
|
||||||
|
teacher.value = '';
|
||||||
|
archive.value = '';
|
||||||
|
});
|
||||||
const items = ref<CoursewareDto[]>([]),
|
const items = ref<CoursewareDto[]>([]),
|
||||||
loading = ref(false),
|
loading = ref(false),
|
||||||
offline = ref(false),
|
offline = ref(false),
|
||||||
@@ -22,15 +43,19 @@ const query = ref(''),
|
|||||||
busy = ref<string[]>([]),
|
busy = ref<string[]>([]),
|
||||||
transfer = ref<CoursewareTransfer | null>(null);
|
transfer = ref<CoursewareTransfer | null>(null);
|
||||||
const filtered = computed(() =>
|
const filtered = computed(() =>
|
||||||
items.value.filter(
|
source.value.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
(!query.value || `${item.name} ${item.teacher.name}`.includes(query.value)) &&
|
(!query.value || `${item.name} ${item.teacher.name}`.includes(query.value)) &&
|
||||||
(!category.value || item.category === category.value) &&
|
(!category.value || item.category === category.value) &&
|
||||||
|
(!teacher.value || item.teacher.name === teacher.value) &&
|
||||||
|
(!archive.value ||
|
||||||
|
(item.archivePath?.startsWith(archive.value + '/') ?? item.archiveDate === archive.value)) &&
|
||||||
(!date.value || item.archiveDate === date.value),
|
(!date.value || item.archiveDate === date.value),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
await refreshLocal();
|
||||||
const scope = getCacheScope();
|
const scope = getCacheScope();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -53,6 +78,7 @@ async function download(item: CoursewareDto) {
|
|||||||
busy.value.push(item.id);
|
busy.value.push(item.id);
|
||||||
try {
|
try {
|
||||||
await window.desktop.downloadCourseware(item.id);
|
await window.desktop.downloadCourseware(item.id);
|
||||||
|
await refreshLocal();
|
||||||
ElMessage.success('课件已下载,可打开所在文件夹');
|
ElMessage.success('课件已下载,可打开所在文件夹');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(e instanceof Error ? e.message : String(e));
|
ElMessage.error(e instanceof Error ? e.message : String(e));
|
||||||
@@ -77,6 +103,7 @@ onMounted(() => {
|
|||||||
timer = setInterval(() => void refresh(), 30000);
|
timer = setInterval(() => void refresh(), 30000);
|
||||||
offProgress = window.desktop?.onCoursewareTransfer((value) => {
|
offProgress = window.desktop?.onCoursewareTransfer((value) => {
|
||||||
transfer.value = value;
|
transfer.value = value;
|
||||||
|
if (value?.stage === 'completed') void refreshLocal();
|
||||||
});
|
});
|
||||||
void window.desktop?.getCoursewareTransfer().then((value) => {
|
void window.desktop?.getCoursewareTransfer().then((value) => {
|
||||||
if (!disposed) transfer.value = value;
|
if (!disposed) transfer.value = value;
|
||||||
@@ -122,6 +149,16 @@ onUnmounted(() => {
|
|||||||
<p v-if="transfer.error">{{ transfer.error }}</p>
|
<p v-if="transfer.error">{{ transfer.error }}</p>
|
||||||
</el-card>
|
</el-card>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
|
<el-radio-group v-model="library">
|
||||||
|
<el-radio-button value="shared">班级共享</el-radio-button>
|
||||||
|
<el-radio-button value="local">本机归档</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-select v-model="teacher" clearable placeholder="全部教师" @change="archive = ''">
|
||||||
|
<el-option v-for="name in teachers" :key="name" :label="name" :value="name" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="archive" clearable filterable placeholder="年 / 月 / 日 / 时间">
|
||||||
|
<el-option v-for="folder in archiveFolders" :key="folder" :label="folder" :value="folder" />
|
||||||
|
</el-select>
|
||||||
<el-input v-model="query" clearable placeholder="搜索课件或教师" />
|
<el-input v-model="query" clearable placeholder="搜索课件或教师" />
|
||||||
<el-select v-model="category" clearable placeholder="全部类型">
|
<el-select v-model="category" clearable placeholder="全部类型">
|
||||||
<el-option v-for="(label, key) in COURSEWARE_CATEGORIES" :key="key" :label="label" :value="key" />
|
<el-option v-for="(label, key) in COURSEWARE_CATEGORIES" :key="key" :label="label" :value="key" />
|
||||||
@@ -129,8 +166,12 @@ onUnmounted(() => {
|
|||||||
<el-date-picker v-model="date" value-format="YYYY-MM-DD" placeholder="归档日期" />
|
<el-date-picker v-model="date" value-format="YYYY-MM-DD" placeholder="归档日期" />
|
||||||
</div>
|
</div>
|
||||||
<el-card shadow="never" class="table-card" v-loading="loading">
|
<el-card shadow="never" class="table-card" v-loading="loading">
|
||||||
<el-table :data="filtered" empty-text="老师还没有向本班共享课件">
|
<el-table
|
||||||
|
:data="filtered"
|
||||||
|
:empty-text="library === 'local' ? '本机还没有已归档的课件' : '老师还没有向本班共享课件'"
|
||||||
|
>
|
||||||
<el-table-column prop="name" label="课件名称" min-width="200" show-overflow-tooltip />
|
<el-table-column prop="name" label="课件名称" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="archivePath" label="归档路径" min-width="250" show-overflow-tooltip />
|
||||||
<el-table-column label="类型" min-width="110">
|
<el-table-column label="类型" min-width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
|
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
|
||||||
@@ -143,7 +184,13 @@ onUnmounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" min-width="200">
|
<el-table-column label="操作" min-width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" :loading="busy.includes(row.id)" @click="download(row)">
|
<el-button
|
||||||
|
v-if="library === 'shared'"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:loading="busy.includes(row.id)"
|
||||||
|
@click="download(row)"
|
||||||
|
>
|
||||||
下载
|
下载
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link @click="reveal(row.id)">打开文件夹</el-button>
|
<el-button link @click="reveal(row.id)">打开文件夹</el-button>
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export interface IslandClassStatePayload {
|
|||||||
export interface DesktopBridge {
|
export interface DesktopBridge {
|
||||||
downloadCourseware(id: string): Promise<void>;
|
downloadCourseware(id: string): Promise<void>;
|
||||||
revealCourseware(id: string): Promise<void>;
|
revealCourseware(id: string): Promise<void>;
|
||||||
|
listLocalCourseware(): Promise<import('@classhelper/shared').CoursewareDto[]>;
|
||||||
syncCourseware(): Promise<void>;
|
syncCourseware(): Promise<void>;
|
||||||
getCoursewareTransfer(): Promise<import('@classhelper/shared').CoursewareTransfer | null>;
|
getCoursewareTransfer(): Promise<import('@classhelper/shared').CoursewareTransfer | null>;
|
||||||
onCoursewareTransfer(
|
onCoursewareTransfer(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/server",
|
"name": "@classhelper/server",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE "Courseware" ADD COLUMN "archivePath" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "Courseware" ADD COLUMN "folderPath" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "ClientDevice" ADD COLUMN "updateProgress" TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE TABLE "CoursewareFolder" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "CoursewareFolder_ownerId_path_key" ON "CoursewareFolder"("ownerId", "path");
|
||||||
@@ -35,6 +35,7 @@ datasource db {
|
|||||||
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
|
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
|
||||||
model User {
|
model User {
|
||||||
coursewares Courseware[]
|
coursewares Courseware[]
|
||||||
|
coursewareFolders CoursewareFolder[]
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
/// 登录名:教师即**工号**,班级端即班级码
|
/// 登录名:教师即**工号**,班级端即班级码
|
||||||
username String @unique
|
username String @unique
|
||||||
@@ -530,6 +531,7 @@ model ClientDevice {
|
|||||||
lastIp String @default("")
|
lastIp String @default("")
|
||||||
/// 客户端上报的当前状态说明(如"上课中/已隐藏",仅展示用)
|
/// 客户端上报的当前状态说明(如"上课中/已隐藏",仅展示用)
|
||||||
stateNote String @default("")
|
stateNote String @default("")
|
||||||
|
updateProgress String @default("")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -730,6 +732,8 @@ model Courseware {
|
|||||||
category String
|
category String
|
||||||
extension String
|
extension String
|
||||||
archiveDate String
|
archiveDate String
|
||||||
|
archivePath String @default("")
|
||||||
|
folderPath String @default("")
|
||||||
storagePath String @unique
|
storagePath String @unique
|
||||||
sha256 String
|
sha256 String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -739,6 +743,15 @@ model Courseware {
|
|||||||
@@index([ownerId, createdAt])
|
@@index([ownerId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 教师自建目录与文件可见权限分开;目录本身不授予班级访问权。
|
||||||
|
model CoursewareFolder {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ownerId String
|
||||||
|
path String
|
||||||
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
@@unique([ownerId, path])
|
||||||
|
}
|
||||||
|
|
||||||
model CoursewareVisibility {
|
model CoursewareVisibility {
|
||||||
coursewareId String
|
coursewareId String
|
||||||
classId String
|
classId String
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ if (!dbEntry) {
|
|||||||
|
|
||||||
/** 需要保证存在的列(新增迁移时在这里追加一行) */
|
/** 需要保证存在的列(新增迁移时在这里追加一行) */
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
|
{
|
||||||
|
table: 'Courseware',
|
||||||
|
column: 'archivePath',
|
||||||
|
sql: `ALTER TABLE "Courseware" ADD COLUMN "archivePath" TEXT NOT NULL DEFAULT ''`,
|
||||||
|
note: '上传日期归档路径',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'Courseware',
|
||||||
|
column: 'folderPath',
|
||||||
|
sql: `ALTER TABLE "Courseware" ADD COLUMN "folderPath" TEXT NOT NULL DEFAULT ''`,
|
||||||
|
note: '自建分类目录',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'ClientDevice',
|
||||||
|
column: 'updateProgress',
|
||||||
|
sql: `ALTER TABLE "ClientDevice" ADD COLUMN "updateProgress" TEXT NOT NULL DEFAULT ''`,
|
||||||
|
note: '设备更新进度',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
table: 'Schedule',
|
table: 'Schedule',
|
||||||
column: 'weekParity',
|
column: 'weekParity',
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function request(route: string, token = tokens[0], body?: unknown, method
|
|||||||
}
|
}
|
||||||
const bytes = Buffer.from('ClassHelper 课件流式上传验收\n'.repeat(3000));
|
const bytes = Buffer.from('ClassHelper 课件流式上传验收\n'.repeat(3000));
|
||||||
async function upload(
|
async function upload(
|
||||||
options: Partial<{ name: string; size: number; classIds: string[]; mode: string }> = {},
|
options: Partial<{ name: string; size: number; classIds: string[]; mode: string; folderPath: string }> = {},
|
||||||
token = tokens[0],
|
token = tokens[0],
|
||||||
data = bytes,
|
data = bytes,
|
||||||
) {
|
) {
|
||||||
@@ -166,10 +166,42 @@ try {
|
|||||||
check('API 不暴露服务端路径', !('storagePath' in item));
|
check('API 不暴露服务端路径', !('storagePath' in item));
|
||||||
const stored = await prisma.courseware.findUniqueOrThrow({ where: { id: item.id } });
|
const stored = await prisma.courseware.findUniqueOrThrow({ where: { id: item.id } });
|
||||||
check(
|
check(
|
||||||
'文件按日期和类型保存',
|
'文件按教师、年、月、日、上传时间保存',
|
||||||
stored.storagePath.startsWith(path.join(item.archiveDate, 'presentation')) &&
|
/^Teachers\/课件教师甲\/\d{4}\/\d{2}\/\d{2}\/\d{2}-\d{2}-\d{2}\/第1课 演示\.pptx$/.test(
|
||||||
|
item.archivePath || '',
|
||||||
|
) &&
|
||||||
|
stored.storagePath.endsWith(path.join(...item.archivePath!.split('/'))) &&
|
||||||
(await fs.readFile(path.join(process.env.COURSEWARE_DIR!, stored.storagePath))).equals(bytes),
|
(await fs.readFile(path.join(process.env.COURSEWARE_DIR!, stored.storagePath))).equals(bytes),
|
||||||
);
|
);
|
||||||
|
const folder = await request('/courseware/folders', tokens[0], { path: '地理/必修一' });
|
||||||
|
check('教师可创建多层分类文件夹', folder.status === 201 && folder.data.path === '地理/必修一');
|
||||||
|
check('文件夹按教师隔离', (await request('/courseware/folders', tokens[1])).data.length === 0);
|
||||||
|
check(
|
||||||
|
'拒绝目录穿越文件夹',
|
||||||
|
(await request('/courseware/folders', tokens[0], { path: '../越界' })).status === 422,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'班级不能创建文件夹',
|
||||||
|
(await request('/courseware/folders', classTokens[0], { path: '越权' })).status === 403,
|
||||||
|
);
|
||||||
|
const moved = await request(
|
||||||
|
`/courseware/${item.id}/folder`,
|
||||||
|
tokens[0],
|
||||||
|
{ folderPath: folder.data.path },
|
||||||
|
'PATCH',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'课件可分类到自建文件夹且日期归档保持稳定',
|
||||||
|
moved.data.folderPath === folder.data.path && moved.data.archivePath === item.archivePath,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'不能借用其他教师文件夹上传',
|
||||||
|
(await upload({ folderPath: folder.data.path, classIds: [] }, tokens[1])).status === 400,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'不能移动其他教师课件',
|
||||||
|
(await request(`/courseware/${item.id}/folder`, tokens[1], { folderPath: '' }, 'PATCH')).status === 403,
|
||||||
|
);
|
||||||
check(
|
check(
|
||||||
'班级仅看到自己可见的课件',
|
'班级仅看到自己可见的课件',
|
||||||
(await list(classTokens[0]!)).length === 1 && (await list(classTokens[1]!)).length === 0,
|
(await list(classTokens[0]!)).length === 1 && (await list(classTokens[1]!)).length === 0,
|
||||||
@@ -266,6 +298,83 @@ try {
|
|||||||
});
|
});
|
||||||
check('收回写权限立即禁止上传', (await upload()).status === 403);
|
check('收回写权限立即禁止上传', (await upload()).status === 403);
|
||||||
await prisma.permissionGroupItem.create({ data: { groupId: defaultGroup.id, key: 'courseware:write' } });
|
await prisma.permissionGroupItem.create({ data: { groupId: defaultGroup.id, key: 'courseware:write' } });
|
||||||
|
const device = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: 'courseware-device-1' } });
|
||||||
|
await prisma.clientDevice.update({ where: { id: device.id }, data: { updateTargetVersion: '2.0.0' } });
|
||||||
|
const updateProgress = {
|
||||||
|
version: '2.0.0',
|
||||||
|
stage: 'downloading',
|
||||||
|
downloadedBytes: 50,
|
||||||
|
totalBytes: 100,
|
||||||
|
percent: 50,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
'客户端可上报更新下载进度',
|
||||||
|
(
|
||||||
|
await request('/clients/update-progress', classTokens[0], {
|
||||||
|
deviceKey: device.deviceKey,
|
||||||
|
progress: updateProgress,
|
||||||
|
})
|
||||||
|
).status === 200,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'其他班级不能冒报设备更新进度',
|
||||||
|
(
|
||||||
|
await request('/clients/update-progress', classTokens[1], {
|
||||||
|
deviceKey: device.deviceKey,
|
||||||
|
progress: updateProgress,
|
||||||
|
})
|
||||||
|
).status === 403,
|
||||||
|
);
|
||||||
|
await request('/clients/update-progress', classTokens[0], {
|
||||||
|
deviceKey: device.deviceKey,
|
||||||
|
progress: { ...updateProgress, stage: 'installing', percent: 100 },
|
||||||
|
});
|
||||||
|
await request('/clients/update-progress', classTokens[0], {
|
||||||
|
deviceKey: device.deviceKey,
|
||||||
|
progress: updateProgress,
|
||||||
|
});
|
||||||
|
let state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
|
||||||
|
check('迟到的下载状态不覆盖安装中状态', JSON.parse(state.updateProgress).stage === 'installing');
|
||||||
|
await request('/clients/heartbeat', classTokens[0], {
|
||||||
|
deviceKey: device.deviceKey,
|
||||||
|
clientVersion: '2.0.0',
|
||||||
|
});
|
||||||
|
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
|
||||||
|
check(
|
||||||
|
'新版上线心跳确认完成并记录实际版本',
|
||||||
|
JSON.parse(state.updateProgress).stage === 'completed' &&
|
||||||
|
JSON.parse(state.updateProgress).version === '2.0.0',
|
||||||
|
);
|
||||||
|
await prisma.clientDevice.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: { updateTargetVersion: '', updateProgress: '' },
|
||||||
|
});
|
||||||
|
await request('/clients/update-progress', classTokens[0], {
|
||||||
|
deviceKey: device.deviceKey,
|
||||||
|
progress: updateProgress,
|
||||||
|
});
|
||||||
|
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
|
||||||
|
check('取消目标后忽略旧任务进度', state.updateProgress === '');
|
||||||
|
const teacherDeviceKey = 'courseware-teacher-device';
|
||||||
|
await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' });
|
||||||
|
let teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } });
|
||||||
|
check('教师电脑设备不绑定班级', teacherDevice.classId === null);
|
||||||
|
await prisma.clientDevice.update({
|
||||||
|
where: { id: teacherDevice.id },
|
||||||
|
data: { updateTargetVersion: '2.0.0' },
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'教师电脑可上报自己的更新进度',
|
||||||
|
(
|
||||||
|
await request('/clients/update-progress', tokens[0], {
|
||||||
|
deviceKey: teacherDeviceKey,
|
||||||
|
progress: { ...updateProgress, version: '2.0.0' },
|
||||||
|
})
|
||||||
|
).status === 200,
|
||||||
|
);
|
||||||
|
teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } });
|
||||||
|
check('教师电脑更新进度已记录', JSON.parse(teacherDevice.updateProgress).stage === 'downloading');
|
||||||
const snapshot = await exportSnapshot(prisma, 'sqlite');
|
const snapshot = await exportSnapshot(prisma, 'sqlite');
|
||||||
check(
|
check(
|
||||||
'备份包含课件、可见范围、推送和回执',
|
'备份包含课件、可见范围、推送和回执',
|
||||||
@@ -275,7 +384,13 @@ try {
|
|||||||
snapshot.data.CoursewareVisibility.length > 0,
|
snapshot.data.CoursewareVisibility.length > 0,
|
||||||
);
|
);
|
||||||
const oldSnapshot = structuredClone(snapshot);
|
const oldSnapshot = structuredClone(snapshot);
|
||||||
for (const key of ['Courseware', 'CoursewarePush', 'CoursewareReceipt', 'CoursewareVisibility'])
|
for (const key of [
|
||||||
|
'Courseware',
|
||||||
|
'CoursewareFolder',
|
||||||
|
'CoursewarePush',
|
||||||
|
'CoursewareReceipt',
|
||||||
|
'CoursewareVisibility',
|
||||||
|
])
|
||||||
delete (oldSnapshot.data as Record<string, unknown>)[key];
|
delete (oldSnapshot.data as Record<string, unknown>)[key];
|
||||||
assertValidSnapshot(oldSnapshot);
|
assertValidSnapshot(oldSnapshot);
|
||||||
check('允许读取升级前的旧版快照', true);
|
check('允许读取升级前的旧版快照', true);
|
||||||
@@ -319,7 +434,9 @@ try {
|
|||||||
await restoreSnapshot(prisma, 'sqlite', snapshot);
|
await restoreSnapshot(prisma, 'sqlite', snapshot);
|
||||||
check(
|
check(
|
||||||
'恢复数据库后保留课件共享和回执数据',
|
'恢复数据库后保留课件共享和回执数据',
|
||||||
(await prisma.courseware.count()) === 3 && (await prisma.coursewareReceipt.count()) === 1,
|
(await prisma.courseware.count()) === 3 &&
|
||||||
|
(await prisma.coursewareReceipt.count()) === 1 &&
|
||||||
|
(await prisma.coursewareFolder.count()) === 1,
|
||||||
);
|
);
|
||||||
console.log(`=== 课件隔离验收:${count}/${count} 项通过 ===`);
|
console.log(`=== 课件隔离验收:${count}/${count} 项通过 ===`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3223,6 +3223,7 @@ async function main() {
|
|||||||
'AppSetting',
|
'AppSetting',
|
||||||
'ClientDevice',
|
'ClientDevice',
|
||||||
'Courseware',
|
'Courseware',
|
||||||
|
'CoursewareFolder',
|
||||||
'CoursewareVisibility',
|
'CoursewareVisibility',
|
||||||
'CoursewarePush',
|
'CoursewarePush',
|
||||||
'CoursewareReceipt',
|
'CoursewareReceipt',
|
||||||
|
|||||||
@@ -421,6 +421,27 @@ try {
|
|||||||
assert.equal(rows[0].weekCountDivTotal, 4);
|
assert.equal(rows[0].weekCountDivTotal, 4);
|
||||||
assert.equal(rows[0].layout.points.length, 4);
|
assert.equal(rows[0].layout.points.length, 4);
|
||||||
});
|
});
|
||||||
|
const archive = await request(`/class-plans/profile?classId=${cls.id}`);
|
||||||
|
const beforeArchive = imported.find((p: { name: string }) => p.name === '上游四周');
|
||||||
|
const exportedPlan = archive.ClassPlans[beforeArchive.id];
|
||||||
|
check('完整档案导出保留四种时间点、空课位置与轮换规则', () => {
|
||||||
|
assert.equal(archive.TimeLayouts[exportedPlan.TimeLayoutId].Layouts.length, 4);
|
||||||
|
assert.equal(exportedPlan.TimeRule.WeekCountDivTotal, 4);
|
||||||
|
assert.equal(
|
||||||
|
exportedPlan.Classes.length,
|
||||||
|
beforeArchive.layout.points.filter((p: { timeType: string }) => p.timeType === 'LESSON').length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await request('/class-plans/import/profile', 'POST', { classId: cls.id, payload: archive }, undefined, 201);
|
||||||
|
const afterArchive = await request(`/class-plans?classId=${cls.id}`);
|
||||||
|
check('导出档案可重新导入且保留课程与周次范围', () => {
|
||||||
|
const restored = afterArchive.find(
|
||||||
|
(p: { id: string; name: string }) => p.name === '上游四周' && p.id !== beforeArchive.id,
|
||||||
|
);
|
||||||
|
assert.equal(restored.weekStart, beforeArchive.weekStart);
|
||||||
|
assert.equal(restored.weekEnd, beforeArchive.weekEnd);
|
||||||
|
assert.equal(restored.entries.length, beforeArchive.entries.length);
|
||||||
|
});
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
const os = {
|
const os = {
|
||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const SNAPSHOT_TABLES = [
|
|||||||
'Grade',
|
'Grade',
|
||||||
'ClientDevice',
|
'ClientDevice',
|
||||||
'Courseware',
|
'Courseware',
|
||||||
|
'CoursewareFolder',
|
||||||
'CoursewareVisibility',
|
'CoursewareVisibility',
|
||||||
'CoursewarePush',
|
'CoursewarePush',
|
||||||
'CoursewareReceipt',
|
'CoursewareReceipt',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { importProfile } from './profile-import.service.js';
|
import { importProfile } from './profile-import.service.js';
|
||||||
|
import { exportProfile } from './profile-export.service.js';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
@@ -82,6 +83,9 @@ layouts.delete(
|
|||||||
|
|
||||||
const plans = Router();
|
const plans = Router();
|
||||||
plans.use(authenticate());
|
plans.use(authenticate());
|
||||||
|
plans.get('/profile', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) =>
|
||||||
|
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
|
||||||
|
);
|
||||||
plans.post(
|
plans.post(
|
||||||
'/import/profile',
|
'/import/profile',
|
||||||
requirePermission('schedule:write'),
|
requirePermission('schedule:write'),
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { prisma } from '../../lib/db.js';
|
||||||
|
import { assertClassAccess } from '../../lib/access.js';
|
||||||
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
|
|
||||||
|
/** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */
|
||||||
|
export async function exportProfile(user: TokenPayload, classId: string) {
|
||||||
|
await assertClassAccess(user, classId);
|
||||||
|
const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([
|
||||||
|
prisma.class.findUniqueOrThrow({ where: { id: classId } }),
|
||||||
|
prisma.timeLayout.findMany({
|
||||||
|
where: { OR: [{ classId }, { classId: null }] },
|
||||||
|
include: { points: { orderBy: { position: 'asc' } } },
|
||||||
|
}),
|
||||||
|
prisma.classPlan.findMany({ where: { classId }, include: { entries: true } }),
|
||||||
|
prisma.course.findMany({ where: { classId } }),
|
||||||
|
prisma.classPlanGroup.findMany({ where: { classId } }),
|
||||||
|
prisma.scheduledClassPlan.findMany({ where: { classId } }),
|
||||||
|
prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }),
|
||||||
|
]);
|
||||||
|
let selection: {
|
||||||
|
selected?: string;
|
||||||
|
temporary?: string;
|
||||||
|
expireTime?: string;
|
||||||
|
temporaryPlan?: string;
|
||||||
|
temporaryPlanDate?: string;
|
||||||
|
} = {};
|
||||||
|
try {
|
||||||
|
selection = JSON.parse(setting?.value || '{}');
|
||||||
|
} catch {
|
||||||
|
/* 使用默认群组 */
|
||||||
|
}
|
||||||
|
const types = ['LESSON', 'BREAK', 'DIVIDER', 'ACTION'];
|
||||||
|
const actions = (value: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Name: cls.name,
|
||||||
|
Subjects: Object.fromEntries(
|
||||||
|
subjects.map((s) => [s.id, { Name: s.name, Initial: s.initial, IsOutDoor: s.isOutDoor }]),
|
||||||
|
),
|
||||||
|
TimeLayouts: Object.fromEntries(
|
||||||
|
layouts.map((l) => [
|
||||||
|
l.id,
|
||||||
|
{
|
||||||
|
Name: l.name,
|
||||||
|
IsOverlay: l.isOverlay,
|
||||||
|
OverlaySourceId: l.overlaySourceId,
|
||||||
|
Layouts: l.points.map((p) => ({
|
||||||
|
Name: p.name,
|
||||||
|
StartTime: p.startTime,
|
||||||
|
EndTime: p.endTime,
|
||||||
|
TimeType: types.indexOf(p.timeType),
|
||||||
|
IsHideDefault: p.isHideDefault,
|
||||||
|
DefaultClassId: p.defaultClassId,
|
||||||
|
BreakName: p.breakName,
|
||||||
|
ActionSet: actions(p.actionSet),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
ClassPlans: Object.fromEntries(
|
||||||
|
plans.map((p) => [
|
||||||
|
p.id,
|
||||||
|
{
|
||||||
|
Name: p.name,
|
||||||
|
TimeLayoutId: p.timeLayoutId,
|
||||||
|
AssociatedGroup: p.associatedGroup,
|
||||||
|
TimeRule: {
|
||||||
|
WeekDay: p.weekday,
|
||||||
|
WeekCountDiv: p.weekCountDiv,
|
||||||
|
WeekCountDivTotal: p.weekCountDivTotal,
|
||||||
|
},
|
||||||
|
WeekStart: p.weekStart,
|
||||||
|
WeekEnd: p.weekEnd,
|
||||||
|
IsEnabled: p.isEnabled,
|
||||||
|
IsOverlay: p.isOverlay,
|
||||||
|
OverlaySourceId: p.overlaySourceId,
|
||||||
|
OverlayDate: p.overlayDate,
|
||||||
|
Classes: (layouts.find((l) => l.id === p.timeLayoutId)?.points || [])
|
||||||
|
.filter((point) => point.timeType === 'LESSON')
|
||||||
|
.map((point) => {
|
||||||
|
const e = p.entries.find((entry) => entry.timeLayoutItemId === point.id);
|
||||||
|
return {
|
||||||
|
SubjectId: e?.courseId || '',
|
||||||
|
IsEnabled: e?.isEnabled ?? true,
|
||||||
|
IsChangedClass: e?.isChangedClass ?? false,
|
||||||
|
Location: e?.location || '',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
ClassPlanGroups: Object.fromEntries(groups.map((g) => [g.id, { Name: g.name, IsGlobal: g.isGlobal }])),
|
||||||
|
SelectedClassPlanGroupId: selection.selected || groups.find((g) => g.isDefault)?.id || '',
|
||||||
|
TempClassPlanGroupId: selection.temporary || '',
|
||||||
|
IsTempClassPlanGroupEnabled: !!selection.temporary,
|
||||||
|
TempClassPlanGroupExpireTime: selection.expireTime,
|
||||||
|
TempClassPlanId: selection.temporaryPlan,
|
||||||
|
TempClassPlanSetupTime: selection.temporaryPlanDate,
|
||||||
|
OrderedSchedules: Object.fromEntries(scheduled.map((s) => [s.date, { ClassPlanId: s.classPlanId }])),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { assertCanManageSchedule } from '../../lib/access.js';
|
|||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { notifyScheduleChanged } from './class-plans.service.js';
|
import { notifyScheduleChanged } from './class-plans.service.js';
|
||||||
|
import { getMaxTermWeeks } from '../../lib/term.js';
|
||||||
const record = z.record(z.string(), z.unknown());
|
const record = z.record(z.string(), z.unknown());
|
||||||
function object(value: unknown): Record<string, unknown> {
|
function object(value: unknown): Record<string, unknown> {
|
||||||
const parsed = record.safeParse(value);
|
const parsed = record.safeParse(value);
|
||||||
@@ -39,6 +40,7 @@ export async function importProfile(user: TokenPayload, classId: string, payload
|
|||||||
if (Object.keys(plans).length > 500 || Object.keys(layouts).length > 100)
|
if (Object.keys(plans).length > 500 || Object.keys(layouts).length > 100)
|
||||||
throw ApiError.badRequest('导入课表过多');
|
throw ApiError.badRequest('导入课表过多');
|
||||||
const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } });
|
const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } });
|
||||||
|
const maxWeeks = await getMaxTermWeeks();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const stable = (kind: string, id: string) =>
|
const stable = (kind: string, id: string) =>
|
||||||
`profile-${kind}-${createHash('sha256')
|
`profile-${kind}-${createHash('sha256')
|
||||||
@@ -183,6 +185,14 @@ export async function importProfile(user: TokenPayload, classId: string, payload
|
|||||||
)
|
)
|
||||||
throw ApiError.badRequest('导入启用规则不合法(支持1–9周轮换)');
|
throw ApiError.badRequest('导入启用规则不合法(支持1–9周轮换)');
|
||||||
const previous = await tx.classPlan.findUnique({ where: { id: stable('plan', id) } });
|
const previous = await tx.classPlan.findUnique({ where: { id: stable('plan', id) } });
|
||||||
|
const range = z
|
||||||
|
.object({
|
||||||
|
start: z.number().int().min(1).max(maxWeeks),
|
||||||
|
end: z.number().int().min(1).max(maxWeeks),
|
||||||
|
})
|
||||||
|
.refine((r) => r.start <= r.end)
|
||||||
|
.safeParse({ start: p.WeekStart ?? 1, end: Math.min(Number(p.WeekEnd ?? maxWeeks), maxWeeks) });
|
||||||
|
if (!range.success) throw ApiError.badRequest('导入课表生效周次无效');
|
||||||
const data = {
|
const data = {
|
||||||
classId,
|
classId,
|
||||||
name: String(p.Name || '导入课表').slice(0, 64),
|
name: String(p.Name || '导入课表').slice(0, 64),
|
||||||
@@ -191,8 +201,11 @@ export async function importProfile(user: TokenPayload, classId: string, payload
|
|||||||
weekday,
|
weekday,
|
||||||
weekCountDiv,
|
weekCountDiv,
|
||||||
weekCountDivTotal,
|
weekCountDivTotal,
|
||||||
|
weekStart: range.data.start,
|
||||||
|
weekEnd: range.data.end,
|
||||||
isEnabled: p.IsEnabled !== false,
|
isEnabled: p.IsEnabled !== false,
|
||||||
isOverlay: p.IsOverlay === true,
|
isOverlay: p.IsOverlay === true,
|
||||||
|
overlayDate: typeof p.OverlayDate === 'string' && isDayKey(p.OverlayDate) ? p.OverlayDate : null,
|
||||||
};
|
};
|
||||||
const plan = await tx.classPlan.upsert({
|
const plan = await tx.classPlan.upsert({
|
||||||
where: { id: stable('plan', id) },
|
where: { id: stable('plan', id) },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import type { ClientUpdateProgressDto } from '@classhelper/shared';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
|
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
|
||||||
@@ -7,6 +8,7 @@ import { defineModule } from '../module.types.js';
|
|||||||
import {
|
import {
|
||||||
clientCommandAckSchema,
|
clientCommandAckSchema,
|
||||||
clientHeartbeatSchema,
|
clientHeartbeatSchema,
|
||||||
|
clientProgressSchema,
|
||||||
createClientCommandSchema,
|
createClientCommandSchema,
|
||||||
listClientDevicesQuerySchema,
|
listClientDevicesQuerySchema,
|
||||||
pushClientUpdateSchema,
|
pushClientUpdateSchema,
|
||||||
@@ -20,6 +22,14 @@ import * as clientService from './clients.service.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
|
router.post('/update-progress', validate({ body: clientProgressSchema }), async (req, res) => {
|
||||||
|
const input = validatedBody<{
|
||||||
|
deviceKey: string;
|
||||||
|
progress: ClientUpdateProgressDto;
|
||||||
|
}>(req);
|
||||||
|
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
|
||||||
|
sendOk(res, null);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
|
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ const clientCommandKindSchema = z.enum(
|
|||||||
|
|
||||||
/** 设备标识(客户端主进程生成并持久化的 UUID) */
|
/** 设备标识(客户端主进程生成并持久化的 UUID) */
|
||||||
const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80, '设备标识不合法');
|
const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80, '设备标识不合法');
|
||||||
|
export const updateProgressSchema = z.object({
|
||||||
|
version: z.string().trim().max(40),
|
||||||
|
stage: z.enum([
|
||||||
|
'idle',
|
||||||
|
'downloading',
|
||||||
|
'verifying',
|
||||||
|
'ready',
|
||||||
|
'waiting',
|
||||||
|
'installing',
|
||||||
|
'failed',
|
||||||
|
'completed',
|
||||||
|
]),
|
||||||
|
downloadedBytes: z.number().int().min(0),
|
||||||
|
totalBytes: z.number().int().min(0),
|
||||||
|
percent: z.number().min(0).max(100).nullable(),
|
||||||
|
error: z.string().max(1000),
|
||||||
|
});
|
||||||
|
export const clientProgressSchema = z.object({ deviceKey: deviceKeySchema, progress: updateProgressSchema });
|
||||||
|
|
||||||
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
||||||
export const clientHeartbeatSchema = z.object({
|
export const clientHeartbeatSchema = z.object({
|
||||||
@@ -22,6 +40,7 @@ export const clientHeartbeatSchema = z.object({
|
|||||||
autoUpdate: z.boolean().optional(),
|
autoUpdate: z.boolean().optional(),
|
||||||
pendingVersion: z.string().trim().max(40).nullable().optional(),
|
pendingVersion: z.string().trim().max(40).nullable().optional(),
|
||||||
stateNote: z.string().trim().max(120).optional(),
|
stateNote: z.string().trim().max(120).optional(),
|
||||||
|
updateProgress: updateProgressSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
CLIENT_COMMAND_EXPIRE_MS,
|
CLIENT_COMMAND_EXPIRE_MS,
|
||||||
CLIENT_COMMAND_LABELS,
|
CLIENT_COMMAND_LABELS,
|
||||||
CLIENT_ONLINE_WINDOW_MS,
|
CLIENT_ONLINE_WINDOW_MS,
|
||||||
|
compareVersions,
|
||||||
|
type ClientUpdateProgressDto,
|
||||||
type ClientUpdateMode,
|
type ClientUpdateMode,
|
||||||
type ClientCommandAckRequest,
|
type ClientCommandAckRequest,
|
||||||
type ClientCommandDto,
|
type ClientCommandDto,
|
||||||
@@ -47,6 +49,7 @@ type DeviceRow = {
|
|||||||
lastHeartbeatAt: Date | null;
|
lastHeartbeatAt: Date | null;
|
||||||
lastIp: string;
|
lastIp: string;
|
||||||
stateNote: string;
|
stateNote: string;
|
||||||
|
updateProgress: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
class?: { id: string; name: string } | null;
|
class?: { id: string; name: string } | null;
|
||||||
@@ -153,6 +156,25 @@ export async function heartbeat(
|
|||||||
})) as unknown as DeviceRow;
|
})) as unknown as DeviceRow;
|
||||||
|
|
||||||
const targetVersion = device.updateTargetVersion.trim();
|
const targetVersion = device.updateTargetVersion.trim();
|
||||||
|
if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) {
|
||||||
|
await prisma.clientDevice.updateMany({
|
||||||
|
where: { id: device.id, updateTargetVersion: targetVersion },
|
||||||
|
data: {
|
||||||
|
pendingVersion: '',
|
||||||
|
updateProgress: JSON.stringify({
|
||||||
|
version: input.clientVersion,
|
||||||
|
stage: 'completed',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
percent: 100,
|
||||||
|
error: '',
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (input.updateProgress && input.updateProgress.stage !== 'idle') {
|
||||||
|
await reportUpdateProgress(user, input.deviceKey, input.updateProgress);
|
||||||
|
}
|
||||||
const [command, update] = await Promise.all([
|
const [command, update] = await Promise.all([
|
||||||
findPendingCommand(device.id, now),
|
findPendingCommand(device.id, now),
|
||||||
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
|
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
|
||||||
@@ -176,6 +198,46 @@ export async function heartbeat(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reportUpdateProgress(
|
||||||
|
user: TokenPayload,
|
||||||
|
deviceKey: string,
|
||||||
|
progress: ClientUpdateProgressDto,
|
||||||
|
) {
|
||||||
|
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
|
||||||
|
// 教师/管理员电脑不绑定班级(会话与设备两侧都是 null),同样允许上报自己的进度。
|
||||||
|
if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级');
|
||||||
|
// 已取消或更换目标的迟到进度不得覆盖当前任务。
|
||||||
|
if (progress.version !== device.updateTargetVersion || progress.stage === 'completed') return;
|
||||||
|
const previous = readUpdateProgress(device.updateProgress);
|
||||||
|
if (previous?.version === progress.version && previous.stage === 'completed') return;
|
||||||
|
const stages = ['idle', 'downloading', 'verifying', 'ready', 'waiting', 'installing', 'completed'];
|
||||||
|
if (
|
||||||
|
previous?.version === progress.version &&
|
||||||
|
previous.stage !== 'failed' &&
|
||||||
|
progress.stage !== 'failed' &&
|
||||||
|
!progress.error &&
|
||||||
|
stages.indexOf(progress.stage) < stages.indexOf(previous.stage)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await prisma.clientDevice.updateMany({
|
||||||
|
where: {
|
||||||
|
id: device.id,
|
||||||
|
updateTargetVersion: progress.version,
|
||||||
|
updateProgress: device.updateProgress,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
updateProgress: JSON.stringify({ ...progress, updatedAt: new Date().toISOString() }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readUpdateProgress(raw: string): ClientUpdateProgressDto | null {
|
||||||
|
try {
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
|
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
|
||||||
export async function ackCommand(
|
export async function ackCommand(
|
||||||
commandId: string,
|
commandId: string,
|
||||||
@@ -263,6 +325,7 @@ export async function listDevices(
|
|||||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||||
lastIp: row.lastIp,
|
lastIp: row.lastIp,
|
||||||
stateNote: row.stateNote,
|
stateNote: row.stateNote,
|
||||||
|
updateProgress: readUpdateProgress(row.updateProgress),
|
||||||
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
|
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
|
||||||
pendingCommand: pending ? toCommandDto(pending) : null,
|
pendingCommand: pending ? toCommandDto(pending) : null,
|
||||||
lastCommand: last ? toCommandDto(last) : null,
|
lastCommand: last ? toCommandDto(last) : null,
|
||||||
@@ -390,6 +453,7 @@ export async function pushUpdate(
|
|||||||
where: { id: device.id },
|
where: { id: device.id },
|
||||||
data: {
|
data: {
|
||||||
updateTargetVersion: target,
|
updateTargetVersion: target,
|
||||||
|
updateProgress: '',
|
||||||
updateMode: clear ? 'idle' : input.mode,
|
updateMode: clear ? 'idle' : input.mode,
|
||||||
updateNotBefore:
|
updateNotBefore:
|
||||||
!clear && input.mode === 'after-school'
|
!clear && input.mode === 'after-school'
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import {
|
|||||||
} from '../../middleware/auth.js';
|
} from '../../middleware/auth.js';
|
||||||
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
|
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import { coursewareDeviceQuery, coursewareScopeSchema, coursewareUploadQuery } from './courseware.schemas.js';
|
import {
|
||||||
|
coursewareDeviceQuery,
|
||||||
|
coursewareScopeSchema,
|
||||||
|
coursewareUploadQuery,
|
||||||
|
coursewareFolderSchema,
|
||||||
|
coursewareMoveSchema,
|
||||||
|
} from './courseware.schemas.js';
|
||||||
import type { CoursewareUploadRequest } from '@classhelper/shared';
|
import type { CoursewareUploadRequest } from '@classhelper/shared';
|
||||||
import * as service from './courseware.service.js';
|
import * as service from './courseware.service.js';
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -22,6 +28,26 @@ router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
|
|||||||
router.get('/classes', ...staff, async (req, res) =>
|
router.get('/classes', ...staff, async (req, res) =>
|
||||||
sendOk(res, await service.teachingClasses(getAuthUser(req))),
|
sendOk(res, await service.teachingClasses(getAuthUser(req))),
|
||||||
);
|
);
|
||||||
|
router.get('/folders', ...staff, async (req, res) =>
|
||||||
|
sendOk(res, await service.listFolders(getAuthUser(req))),
|
||||||
|
);
|
||||||
|
router.post('/folders', ...staff, validate({ body: coursewareFolderSchema }), async (req, res) =>
|
||||||
|
sendCreated(res, await service.createFolder(getAuthUser(req), validatedBody<{ path: string }>(req).path)),
|
||||||
|
);
|
||||||
|
router.patch(
|
||||||
|
'/:id/folder',
|
||||||
|
...staff,
|
||||||
|
validate({ params: idParamSchema, body: coursewareMoveSchema }),
|
||||||
|
async (req, res) =>
|
||||||
|
sendOk(
|
||||||
|
res,
|
||||||
|
await service.moveCourseware(
|
||||||
|
getAuthUser(req),
|
||||||
|
validatedParams<{ id: string }>(req).id,
|
||||||
|
validatedBody<{ folderPath: string }>(req).folderPath,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
router.get(
|
router.get(
|
||||||
'/transfers',
|
'/transfers',
|
||||||
requireRole('CLASS_DEVICE'),
|
requireRole('CLASS_DEVICE'),
|
||||||
|
|||||||
@@ -2,6 +2,27 @@ import { z } from 'zod';
|
|||||||
import { COURSEWARE_MAX_BYTES } from '@classhelper/shared';
|
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 coursewareScopeSchema = z.object({ classIds: z.array(z.string().min(1).max(100)).max(100) });
|
||||||
|
const folderPath = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(240)
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
value
|
||||||
|
.split('/')
|
||||||
|
.every(
|
||||||
|
(part) =>
|
||||||
|
part.length > 0 &&
|
||||||
|
part.length <= 60 &&
|
||||||
|
!/[<>:"\\|?*\u0000-\u001f]/.test(part) &&
|
||||||
|
!/[. ]$/.test(part) &&
|
||||||
|
!/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part),
|
||||||
|
),
|
||||||
|
'文件夹名称无效,请使用 / 分隔子文件夹',
|
||||||
|
);
|
||||||
|
export const coursewareFolderSchema = z.object({ path: folderPath });
|
||||||
|
export const coursewareMoveSchema = z.object({ folderPath: folderPath.or(z.literal('')) });
|
||||||
export const coursewareUploadSchema = coursewareScopeSchema
|
export const coursewareUploadSchema = coursewareScopeSchema
|
||||||
.extend({
|
.extend({
|
||||||
name: z
|
name: z
|
||||||
@@ -15,6 +36,7 @@ export const coursewareUploadSchema = coursewareScopeSchema
|
|||||||
),
|
),
|
||||||
size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
|
size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
|
||||||
mode: z.enum(['cloud', 'push']),
|
mode: z.enum(['cloud', 'push']),
|
||||||
|
folderPath: folderPath.or(z.literal('')).optional(),
|
||||||
})
|
})
|
||||||
.refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
|
.refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
|
||||||
export const coursewareUploadQuery = z.object({
|
export const coursewareUploadQuery = z.object({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Request } from 'express';
|
|||||||
import {
|
import {
|
||||||
COURSEWARE_MAX_BYTES,
|
COURSEWARE_MAX_BYTES,
|
||||||
coursewareCategory,
|
coursewareCategory,
|
||||||
|
coursewareArchivePath,
|
||||||
dayKeyLocal,
|
dayKeyLocal,
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
type CoursewareDto,
|
type CoursewareDto,
|
||||||
@@ -37,6 +38,8 @@ function dto(row: RecordWithRelations): CoursewareDto {
|
|||||||
category: row.category as CoursewareDto['category'],
|
category: row.category as CoursewareDto['category'],
|
||||||
extension: row.extension,
|
extension: row.extension,
|
||||||
archiveDate: row.archiveDate,
|
archiveDate: row.archiveDate,
|
||||||
|
archivePath: row.archivePath || coursewareArchivePath(row.owner.name, row.createdAt, row.name),
|
||||||
|
folderPath: row.folderPath,
|
||||||
sha256: row.sha256,
|
sha256: row.sha256,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
teacher: row.owner,
|
teacher: row.owner,
|
||||||
@@ -104,13 +107,18 @@ export async function listCourseware(user: TokenPayload) {
|
|||||||
}
|
}
|
||||||
export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
|
export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
|
||||||
const classIds = await validateClasses(user, input.classIds);
|
const classIds = await validateClasses(user, input.classIds);
|
||||||
|
await assertFolder(user, input.folderPath || '');
|
||||||
if (!req.is('application/octet-stream')) throw ApiError.badRequest('请使用二进制文件上传');
|
if (!req.is('application/octet-stream')) throw ApiError.badRequest('请使用二进制文件上传');
|
||||||
if (req.headers['content-length'] && Number(req.headers['content-length']) !== input.size)
|
if (req.headers['content-length'] && Number(req.headers['content-length']) !== input.size)
|
||||||
throw ApiError.badRequest('文件长度与上传信息不符');
|
throw ApiError.badRequest('文件长度与上传信息不符');
|
||||||
|
const uploadedAt = new Date();
|
||||||
|
const owner = await prisma.user.findUniqueOrThrow({ where: { id: user.sub }, select: { name: true } });
|
||||||
|
const archivePath = coursewareArchivePath(owner.name, uploadedAt, input.name);
|
||||||
const category = coursewareCategory(input.name),
|
const category = coursewareCategory(input.name),
|
||||||
archiveDate = dayKeyLocal(new Date());
|
archiveDate = dayKeyLocal(uploadedAt);
|
||||||
const extension = path.extname(input.name).slice(1).toLowerCase();
|
const extension = path.extname(input.name).slice(1).toLowerCase();
|
||||||
const relative = path.join(archiveDate, category, randomUUID());
|
// UUID 隔离同名教师或同秒上传;对外归档路径始终使用上传时的教师名和时间。
|
||||||
|
const relative = path.join(randomUUID(), archivePath);
|
||||||
const file = path.join(coursewareRoot, relative),
|
const file = path.join(coursewareRoot, relative),
|
||||||
partial = `${file}.part`;
|
partial = `${file}.part`;
|
||||||
await fsp.mkdir(path.dirname(file), { recursive: true });
|
await fsp.mkdir(path.dirname(file), { recursive: true });
|
||||||
@@ -142,6 +150,9 @@ export async function uploadCourseware(user: TokenPayload, input: CoursewareUplo
|
|||||||
category,
|
category,
|
||||||
extension,
|
extension,
|
||||||
archiveDate,
|
archiveDate,
|
||||||
|
archivePath,
|
||||||
|
folderPath: input.folderPath || '',
|
||||||
|
createdAt: uploadedAt,
|
||||||
storagePath: relative,
|
storagePath: relative,
|
||||||
sha256: hash.digest('hex'),
|
sha256: hash.digest('hex'),
|
||||||
visibility: { create: classIds.map((classId) => ({ classId })) },
|
visibility: { create: classIds.map((classId) => ({ classId })) },
|
||||||
@@ -156,6 +167,35 @@ export async function uploadCourseware(user: TokenPayload, input: CoursewareUplo
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function assertFolder(user: TokenPayload, folderPath: string) {
|
||||||
|
if (
|
||||||
|
folderPath &&
|
||||||
|
!(await prisma.coursewareFolder.findUnique({
|
||||||
|
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
throw ApiError.badRequest('请选择自己创建的文件夹');
|
||||||
|
}
|
||||||
|
export async function listFolders(user: TokenPayload) {
|
||||||
|
const rows = await prisma.coursewareFolder.findMany({
|
||||||
|
where: { ownerId: user.sub },
|
||||||
|
orderBy: { path: 'asc' },
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({ id: row.id, path: row.path }));
|
||||||
|
}
|
||||||
|
export async function createFolder(user: TokenPayload, folderPath: string) {
|
||||||
|
const row = await prisma.coursewareFolder.upsert({
|
||||||
|
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
|
||||||
|
create: { ownerId: user.sub, path: folderPath },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
return { id: row.id, path: row.path };
|
||||||
|
}
|
||||||
|
export async function moveCourseware(user: TokenPayload, id: string, folderPath: string) {
|
||||||
|
await owned(user, id);
|
||||||
|
await assertFolder(user, folderPath);
|
||||||
|
return dto(await prisma.courseware.update({ where: { id }, data: { folderPath }, include }));
|
||||||
|
}
|
||||||
export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
|
export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
|
||||||
await owned(user, id);
|
await owned(user, id);
|
||||||
const classIds = await validateClasses(user, ids);
|
const classIds = await validateClasses(user, ids);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/shared",
|
"name": "@classhelper/shared",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export interface ReleaseProgressDto {
|
|||||||
percent: number | null;
|
percent: number | null;
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
/** 设备上报状态与服务端缓存安装包的下载状态分开。 */
|
||||||
|
export interface ClientUpdateProgressDto extends Omit<ReleaseProgressDto, 'stage'> {
|
||||||
|
stage: ReleaseProgressDto['stage'] | 'completed';
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
export interface CollectiveGradeAnalysisDto {
|
export interface CollectiveGradeAnalysisDto {
|
||||||
mode: 'collective';
|
mode: 'collective';
|
||||||
classId: string;
|
classId: string;
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export interface CoursewareDto {
|
|||||||
archiveDate: string;
|
archiveDate: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/** 上传时固定的归档相对路径,不含服务端磁盘根目录。 */
|
||||||
|
archivePath?: string;
|
||||||
|
folderPath?: string;
|
||||||
teacher: { id: string; name: string };
|
teacher: { id: string; name: string };
|
||||||
classes: CoursewareClassDto[];
|
classes: CoursewareClassDto[];
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,34 @@ export interface CoursewareUploadRequest {
|
|||||||
size: number;
|
size: number;
|
||||||
classIds: string[];
|
classIds: string[];
|
||||||
mode: 'cloud' | 'push';
|
mode: 'cloud' | 'push';
|
||||||
|
folderPath?: string;
|
||||||
|
}
|
||||||
|
export interface CoursewareFolderDto {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跨平台目录名;保留中文,处理 Windows 保留名与路径分隔符。 */
|
||||||
|
export function coursewarePathSegment(value: string): string {
|
||||||
|
const safe =
|
||||||
|
value
|
||||||
|
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
|
||||||
|
.replace(/[. ]+$/, '')
|
||||||
|
.slice(0, 180) || '_';
|
||||||
|
return /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(safe) ? `_${safe}` : safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coursewareArchivePath(teacher: string, uploadedAt: Date, name: string): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return [
|
||||||
|
'Teachers',
|
||||||
|
coursewarePathSegment(teacher),
|
||||||
|
String(uploadedAt.getFullYear()),
|
||||||
|
pad(uploadedAt.getMonth() + 1),
|
||||||
|
pad(uploadedAt.getDate()),
|
||||||
|
[uploadedAt.getHours(), uploadedAt.getMinutes(), uploadedAt.getSeconds()].map(pad).join('-'),
|
||||||
|
coursewarePathSegment(name),
|
||||||
|
].join('/');
|
||||||
}
|
}
|
||||||
export interface CoursewarePushDto {
|
export interface CoursewarePushDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CoursewareTransfer } from './courseware.js';
|
import type { CoursewareTransfer } from './courseware.js';
|
||||||
import type {
|
import type {
|
||||||
|
ClientUpdateProgressDto,
|
||||||
PermissionScope as ContractPermissionScope,
|
PermissionScope as ContractPermissionScope,
|
||||||
ReleaseProgressDto as ContractReleaseProgressDto,
|
ReleaseProgressDto as ContractReleaseProgressDto,
|
||||||
} from './class-plan.js';
|
} from './class-plan.js';
|
||||||
@@ -1552,6 +1553,7 @@ export interface ClientCommandDto {
|
|||||||
|
|
||||||
/** Web 端「客户端设备」列表里的一台机器 */
|
/** Web 端「客户端设备」列表里的一台机器 */
|
||||||
export interface ClientDeviceDto {
|
export interface ClientDeviceDto {
|
||||||
|
updateProgress?: ClientUpdateProgressDto | null;
|
||||||
id: string;
|
id: string;
|
||||||
classId: string | null;
|
classId: string | null;
|
||||||
className: string | null;
|
className: string | null;
|
||||||
@@ -1590,6 +1592,7 @@ export interface ClientDeviceDto {
|
|||||||
|
|
||||||
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
||||||
export interface ClientHeartbeatRequest {
|
export interface ClientHeartbeatRequest {
|
||||||
|
updateProgress?: ClientUpdateProgressDto;
|
||||||
/** 旧版缺省为 1;第二版正确解析统一响应并支持安装时机。 */
|
/** 旧版缺省为 1;第二版正确解析统一响应并支持安装时机。 */
|
||||||
agentProtocol?: number;
|
agentProtocol?: number;
|
||||||
deviceKey: string;
|
deviceKey: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/web-admin",
|
"name": "@classhelper/web-admin",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { ClientDeviceDto } from '@classhelper/shared';
|
||||||
|
const props = defineProps<{ device: ClientDeviceDto }>();
|
||||||
|
const progress = computed(() => props.device.updateProgress);
|
||||||
|
const labels = {
|
||||||
|
idle: '等待客户端接收',
|
||||||
|
downloading: '当前客户端正在下载更新包',
|
||||||
|
verifying: '正在校验更新包',
|
||||||
|
ready: '更新包已就绪',
|
||||||
|
waiting: '已下载,等待安装时机',
|
||||||
|
installing: '当前客户端正在更新,将会短暂离线',
|
||||||
|
completed: '客户端已完成更新',
|
||||||
|
failed: '客户端更新失败',
|
||||||
|
};
|
||||||
|
const waiting = computed(() =>
|
||||||
|
props.device.updateMode === 'immediate'
|
||||||
|
? '立即安装'
|
||||||
|
: props.device.updateMode === 'after-school'
|
||||||
|
? `放学后安装${props.device.updateNotBefore ? ' · ' + new Date(props.device.updateNotBefore).toLocaleString() : ''}`
|
||||||
|
: '课间且主窗口隐藏满五分钟后安装',
|
||||||
|
);
|
||||||
|
const mb = (bytes: number) => `${(bytes / 1048576).toFixed(1)} MB`;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="update-status">
|
||||||
|
<template v-if="progress">
|
||||||
|
<strong>
|
||||||
|
{{ labels[progress.stage] }}
|
||||||
|
<template v-if="progress.stage === 'completed'">,版本 {{ progress.version }}</template>
|
||||||
|
</strong>
|
||||||
|
<el-progress
|
||||||
|
v-if="['downloading', 'verifying', 'waiting'].includes(progress.stage)"
|
||||||
|
:percentage="progress.percent || 0"
|
||||||
|
:indeterminate="progress.percent === null"
|
||||||
|
/>
|
||||||
|
<small v-if="progress.totalBytes">
|
||||||
|
{{ mb(progress.downloadedBytes) }} / {{ mb(progress.totalBytes) }}
|
||||||
|
</small>
|
||||||
|
<small v-if="progress.stage === 'waiting'">{{ waiting }}</small>
|
||||||
|
<el-text v-if="progress.error" type="danger">{{ progress.error }}</el-text>
|
||||||
|
<small v-if="progress.updatedAt">上次报告:{{ new Date(progress.updatedAt).toLocaleString() }}</small>
|
||||||
|
<el-text v-if="progress.stage === 'installing' && !device.online" type="warning">
|
||||||
|
正在等待新版上线确认
|
||||||
|
</el-text>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="device.updateTargetVersion">
|
||||||
|
<strong>已下发 {{ device.updateTargetVersion }},等待客户端接收</strong>
|
||||||
|
<small>{{ waiting }}</small>
|
||||||
|
</template>
|
||||||
|
<span v-else>暂无更新任务</span>
|
||||||
|
<el-text
|
||||||
|
v-if="device.updateTargetVersion && !device.autoUpdate && progress?.stage !== 'completed'"
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
客户端已关闭自动更新,请在客户端开启后重试
|
||||||
|
</el-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.update-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding-block: 6px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -103,7 +103,23 @@ watch(
|
|||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
.management-menu.menu-rail :deep(.el-sub-menu__title) {
|
.management-menu.menu-rail :deep(.el-sub-menu__title) {
|
||||||
padding: 0 14px !important;
|
padding: 0 !important;
|
||||||
|
margin: 4px 0 !important;
|
||||||
|
width: 48px;
|
||||||
|
height: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.management-menu.menu-rail .management-group-title {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
min-width: 20px;
|
||||||
|
}
|
||||||
|
.management-menu.menu-rail .management-group-title span {
|
||||||
|
flex: 0 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.management-menu.menu-rail :deep(.el-sub-menu__icon-arrow) {
|
||||||
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
.management-menu.menu-rail {
|
.management-menu.menu-rail {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const MANAGEMENT_GROUPS = [
|
|||||||
id: 'teaching',
|
id: 'teaching',
|
||||||
title: '教学事务',
|
title: '教学事务',
|
||||||
icon: 'Notebook',
|
icon: 'Notebook',
|
||||||
paths: ['/schedules', '/plan-editor', '/homeworks', '/notifications', '/courseware', '/grades'],
|
paths: ['/plan-editor', '/homeworks', '/notifications', '/courseware', '/grades'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'school',
|
id: 'school',
|
||||||
|
|||||||
@@ -14,13 +14,23 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/layouts/AdminLayout.vue'),
|
component: () => import('@/layouts/AdminLayout.vue'),
|
||||||
redirect: '/dashboard',
|
redirect: '/dashboard',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'courseware', name: 'courseware', component: () => import('@/views/CoursewareView.vue'), meta: { title: '课件', icon: 'FolderOpened', roles: ['ADMIN', 'TEACHER'], permission: 'courseware:read' } },
|
{
|
||||||
|
path: 'courseware',
|
||||||
|
name: 'courseware',
|
||||||
|
component: () => import('@/views/CoursewareView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '课件',
|
||||||
|
icon: 'FolderOpened',
|
||||||
|
roles: ['ADMIN', 'TEACHER'],
|
||||||
|
permission: 'courseware:read',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'plan-editor',
|
path: 'plan-editor',
|
||||||
name: 'plan-editor',
|
name: 'plan-editor',
|
||||||
component: () => import('@/views/PlanEditorView.vue'),
|
component: () => import('@/views/PlanEditorView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '时间表与课表编辑',
|
title: '课表档案',
|
||||||
icon: 'Calendar',
|
icon: 'Calendar',
|
||||||
roles: ['ADMIN', 'TEACHER'],
|
roles: ['ADMIN', 'TEACHER'],
|
||||||
permission: 'schedule:read',
|
permission: 'schedule:read',
|
||||||
@@ -79,7 +89,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'schedules',
|
path: 'schedules',
|
||||||
name: 'schedules',
|
name: 'schedules',
|
||||||
component: () => import('@/views/SchedulesView.vue'),
|
redirect: '/plan-editor',
|
||||||
meta: { title: '课表管理', icon: 'Calendar' },
|
meta: { title: '课表管理', icon: 'Calendar' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -907,6 +907,36 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 原生文件选择保留键盘与读屏能力,按钮外观与管理端控件保持一致。 */
|
||||||
|
input[type='file']:not([hidden]) {
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
input[type='file']::file-selector-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin-right: 12px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
row-gap: 12px;
|
||||||
|
}
|
||||||
|
.toolbar > .el-button + .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.el-form--inline .el-form-item {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.el-form-item__content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 抽屉(el-drawer)。和弹窗不同,抽屉的位移由 `.el-drawer` **自身**的
|
* 抽屉(el-drawer)。和弹窗不同,抽屉的位移由 `.el-drawer` **自身**的
|
||||||
* `transition: all var(--el-transition-duration)` 承载(`.el-drawer-fade-*` 那组类
|
* `transition: all var(--el-transition-duration)` 承载(`.el-drawer-fade-*` 那组类
|
||||||
|
|||||||
@@ -18,13 +18,13 @@
|
|||||||
* **禁用 + tooltip 说明**而不是直接隐藏:让老师知道"有这件事、需要权限",而不是以为系统没有。
|
* **禁用 + tooltip 说明**而不是直接隐藏:让老师知道"有这件事、需要权限",而不是以为系统没有。
|
||||||
*/
|
*/
|
||||||
import ReleaseProgress from '@/components/ReleaseProgress.vue';
|
import ReleaseProgress from '@/components/ReleaseProgress.vue';
|
||||||
|
import ClientUpdateStatus from '@/components/ClientUpdateStatus.vue';
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
CLIENT_COMMAND_EXPIRE_MS,
|
CLIENT_COMMAND_EXPIRE_MS,
|
||||||
CLIENT_COMMAND_KINDS,
|
CLIENT_COMMAND_KINDS,
|
||||||
CLIENT_COMMAND_LABELS,
|
CLIENT_COMMAND_LABELS,
|
||||||
CLIENT_HEARTBEAT_INTERVAL_MS,
|
|
||||||
clientReleaseFileName,
|
clientReleaseFileName,
|
||||||
compareVersions,
|
compareVersions,
|
||||||
normalizeVersion,
|
normalizeVersion,
|
||||||
@@ -47,7 +47,7 @@ const { isMobile } = useResponsive();
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
/** 自动刷新间隔:跟客户端心跳同频,刷太快没有新数据 */
|
/** 自动刷新间隔:跟客户端心跳同频,刷太快没有新数据 */
|
||||||
const AUTO_REFRESH_MS = CLIENT_HEARTBEAT_INTERVAL_MS;
|
const AUTO_REFRESH_MS = 3000;
|
||||||
|
|
||||||
function can(key: PermissionKey): boolean {
|
function can(key: PermissionKey): boolean {
|
||||||
return auth.can(key);
|
return auth.can(key);
|
||||||
@@ -214,9 +214,6 @@ function classLabel(device: ClientDeviceDto): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 「已指定 <版本>」:只显示版本号,避免与「已下载待安装」挤在一起 */
|
/** 「已指定 <版本>」:只显示版本号,避免与「已下载待安装」挤在一起 */
|
||||||
function targetVersionLabel(device: ClientDeviceDto): string {
|
|
||||||
return normalizeVersion(device.updateTargetVersion) || device.updateTargetVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------- 下发更新 */
|
/* -------------------------------------------------------------- 下发更新 */
|
||||||
|
|
||||||
@@ -814,20 +811,8 @@ const deviceEmptyText = computed(() =>
|
|||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="更新" min-width="150">
|
<el-table-column label="更新过程" min-width="290">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }"><ClientUpdateStatus :device="row" /></template>
|
||||||
<template v-if="row.pendingVersion">
|
|
||||||
<el-tag class="client-tag" size="small" type="success" effect="plain">
|
|
||||||
已下载待安装 v{{ normalizeVersion(row.pendingVersion) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
<template v-if="row.updateTargetVersion">
|
|
||||||
<el-tag class="client-tag" size="small" type="primary" effect="plain">
|
|
||||||
已指定 v{{ targetVersionLabel(row) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
<span v-if="!row.pendingVersion && !row.updateTargetVersion" class="text-muted">—</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type CoursewareDto,
|
type CoursewareDto,
|
||||||
type CoursewareClassDto,
|
type CoursewareClassDto,
|
||||||
type CoursewareUploadRequest,
|
type CoursewareUploadRequest,
|
||||||
|
type CoursewareFolderDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { api } from '@/api/http';
|
import { api } from '@/api/http';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
@@ -19,6 +20,7 @@ const items = ref<CoursewareDto[]>([]),
|
|||||||
const file = ref<File | null>(null),
|
const file = ref<File | null>(null),
|
||||||
classIds = ref<string[]>([]),
|
classIds = ref<string[]>([]),
|
||||||
mode = ref<'cloud' | 'push'>('cloud');
|
mode = ref<'cloud' | 'push'>('cloud');
|
||||||
|
const fileInput = ref<HTMLInputElement>();
|
||||||
const loading = ref(false),
|
const loading = ref(false),
|
||||||
uploading = ref(false),
|
uploading = ref(false),
|
||||||
progress = ref(0),
|
progress = ref(0),
|
||||||
@@ -30,11 +32,51 @@ const scopeItem = ref<CoursewareDto | null>(null),
|
|||||||
scopeMode = ref<'visibility' | 'push'>('visibility'),
|
scopeMode = ref<'visibility' | 'push'>('visibility'),
|
||||||
saving = ref(false);
|
saving = ref(false);
|
||||||
const canWrite = computed(() => auth.can('courseware:write'));
|
const canWrite = computed(() => auth.can('courseware:write'));
|
||||||
|
const folders = ref<CoursewareFolderDto[]>([]),
|
||||||
|
folderPath = ref(''),
|
||||||
|
folderFilter = ref('');
|
||||||
|
const grouping = ref<'date' | 'folder'>('date');
|
||||||
|
const dateFolder = ref('');
|
||||||
|
const dateFolders = computed(() =>
|
||||||
|
[
|
||||||
|
...new Set(
|
||||||
|
items.value.map((item) => item.archivePath?.split('/').slice(0, -1).join('/') || item.archiveDate),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.sort()
|
||||||
|
.reverse(),
|
||||||
|
);
|
||||||
|
async function newFolder() {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
'输入文件夹名称,可用 / 创建多层分类,如 地理/必修一',
|
||||||
|
'新建文件夹',
|
||||||
|
);
|
||||||
|
const path = String(value ?? '').trim();
|
||||||
|
if (!path) return;
|
||||||
|
await api.post('/courseware/folders', { path });
|
||||||
|
await refresh();
|
||||||
|
folderPath.value = path;
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function move(item: CoursewareDto, target: string) {
|
||||||
|
await api.patch(`/courseware/${item.id}/folder`, { folderPath: target });
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
const filtered = computed(() =>
|
const filtered = computed(() =>
|
||||||
items.value.filter(
|
items.value.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
(!query.value || `${item.name} ${item.teacher.name}`.includes(query.value)) &&
|
(!query.value || `${item.name} ${item.teacher.name}`.includes(query.value)) &&
|
||||||
(!category.value || item.category === category.value) &&
|
(!category.value || item.category === category.value) &&
|
||||||
|
(grouping.value !== 'folder' ||
|
||||||
|
!folderFilter.value ||
|
||||||
|
item.folderPath === folderFilter.value ||
|
||||||
|
item.folderPath?.startsWith(folderFilter.value + '/')) &&
|
||||||
|
(grouping.value !== 'date' ||
|
||||||
|
!dateFolder.value ||
|
||||||
|
(item.archivePath?.startsWith(dateFolder.value + '/') ?? item.archiveDate === dateFolder.value)) &&
|
||||||
(!date.value || item.archiveDate === date.value),
|
(!date.value || item.archiveDate === date.value),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -42,13 +84,25 @@ function size(bytes: number) {
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
function choose(event: Event) {
|
function choose(event: Event) {
|
||||||
file.value = (event.target as HTMLInputElement).files?.[0] ?? null;
|
const picked = (event.target as HTMLInputElement).files?.[0];
|
||||||
|
// 取消选择时不丢掉上一次选中的文件。
|
||||||
|
if (picked) file.value = picked;
|
||||||
|
}
|
||||||
|
function pickFile() {
|
||||||
|
if (!fileInput.value) return;
|
||||||
|
// 允许重新选择同一个文件:清空后再打开选择器。
|
||||||
|
fileInput.value.value = '';
|
||||||
|
fileInput.value.click();
|
||||||
}
|
}
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
items.value = await api.get<CoursewareDto[]>('/courseware');
|
items.value = await api.get<CoursewareDto[]>('/courseware');
|
||||||
if (canWrite.value) classes.value = await api.get<CoursewareClassDto[]>('/courseware/classes');
|
if (canWrite.value)
|
||||||
|
[classes.value, folders.value] = await Promise.all([
|
||||||
|
api.get<CoursewareClassDto[]>('/courseware/classes'),
|
||||||
|
api.get<CoursewareFolderDto[]>('/courseware/folders'),
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -64,6 +118,7 @@ async function upload() {
|
|||||||
size: file.value.size,
|
size: file.value.size,
|
||||||
classIds: [...classIds.value],
|
classIds: [...classIds.value],
|
||||||
mode: mode.value,
|
mode: mode.value,
|
||||||
|
folderPath: folderPath.value,
|
||||||
};
|
};
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
progress.value = 0;
|
progress.value = 0;
|
||||||
@@ -133,7 +188,7 @@ onMounted(() => void refresh());
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">课件</h2>
|
<h2 class="page-title">课件</h2>
|
||||||
<p>保存教学文件,按日期与类型归档,发送到自己的授课班级。</p>
|
<p>创建文件夹分类,或按教师、上传年份、月、日、时间浏览课件。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button :loading="loading" @click="refresh">刷新</el-button>
|
<el-button :loading="loading" @click="refresh">刷新</el-button>
|
||||||
</header>
|
</header>
|
||||||
@@ -141,7 +196,34 @@ onMounted(() => void refresh());
|
|||||||
<h3>上传课件</h3>
|
<h3>上传课件</h3>
|
||||||
<el-form label-position="top" :disabled="uploading">
|
<el-form label-position="top" :disabled="uploading">
|
||||||
<el-form-item label="文件(最大 512 MB)">
|
<el-form-item label="文件(最大 512 MB)">
|
||||||
<input aria-label="选择课件文件" type="file" @change="choose" />
|
<div class="file-picker">
|
||||||
|
<el-button :disabled="uploading" @click="pickFile">
|
||||||
|
{{ file ? '重新选择文件' : '选择文件' }}
|
||||||
|
</el-button>
|
||||||
|
<span v-if="file" class="file-name">{{ file.name }} · {{ size(file.size) }}</span>
|
||||||
|
<span v-else class="file-placeholder">未选择文件</span>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
aria-label="选择课件文件"
|
||||||
|
type="file"
|
||||||
|
:disabled="uploading"
|
||||||
|
class="hidden-input"
|
||||||
|
@change="choose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类文件夹">
|
||||||
|
<div class="folder-picker">
|
||||||
|
<el-select v-model="folderPath" clearable placeholder="按上传日期自动归档">
|
||||||
|
<el-option
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
:value="folder.path"
|
||||||
|
:label="folder.path"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="newFolder">新建文件夹</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="可见范围">
|
<el-form-item label="可见范围">
|
||||||
<el-select
|
<el-select
|
||||||
@@ -176,6 +258,16 @@ onMounted(() => void refresh());
|
|||||||
</el-card>
|
</el-card>
|
||||||
<el-card shadow="never" class="table-card" v-loading="loading">
|
<el-card shadow="never" class="table-card" v-loading="loading">
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
|
<el-radio-group v-model="grouping">
|
||||||
|
<el-radio-button value="date">日期归档</el-radio-button>
|
||||||
|
<el-radio-button value="folder">文件夹分类</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-select v-if="grouping === 'folder'" v-model="folderFilter" clearable placeholder="全部文件夹">
|
||||||
|
<el-option v-for="folder in folders" :key="folder.id" :value="folder.path" :label="folder.path" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-else v-model="dateFolder" filterable clearable placeholder="教师 / 年 / 月 / 日 / 时间">
|
||||||
|
<el-option v-for="folder in dateFolders" :key="folder" :value="folder" :label="folder" />
|
||||||
|
</el-select>
|
||||||
<el-input v-model="query" clearable placeholder="搜索文件名、教师" />
|
<el-input v-model="query" clearable placeholder="搜索文件名、教师" />
|
||||||
<el-select v-model="category" clearable placeholder="全部类型">
|
<el-select v-model="category" clearable placeholder="全部类型">
|
||||||
<el-option v-for="(label, key) in COURSEWARE_CATEGORIES" :key="key" :label="label" :value="key" />
|
<el-option v-for="(label, key) in COURSEWARE_CATEGORIES" :key="key" :label="label" :value="key" />
|
||||||
@@ -184,6 +276,26 @@ onMounted(() => void refresh());
|
|||||||
</div>
|
</div>
|
||||||
<el-table :data="filtered" empty-text="暂无课件">
|
<el-table :data="filtered" empty-text="暂无课件">
|
||||||
<el-table-column prop="name" label="文件名称" min-width="220" show-overflow-tooltip />
|
<el-table-column prop="name" label="文件名称" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column label="分类 / 归档位置" min-width="230">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-select
|
||||||
|
v-if="canWrite && row.teacher.id === auth.user?.id"
|
||||||
|
:model-value="row.folderPath || ''"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
placeholder="自动日期归档"
|
||||||
|
@change="move(row, $event || '')"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
:value="folder.path"
|
||||||
|
:label="folder.path"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<small class="archive-path">{{ row.archivePath }}</small>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="类型" width="110">
|
<el-table-column label="类型" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
|
{{ COURSEWARE_CATEGORIES[(row as CoursewareDto).category] }} · {{ row.extension || '无扩展名' }}
|
||||||
@@ -277,4 +389,38 @@ p,
|
|||||||
.el-progress {
|
.el-progress {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
.folder-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.folder-picker .el-select {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
.archive-path {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.file-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-name,
|
||||||
|
.file-placeholder {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
.file-placeholder {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large.
Load diff
+68
-179
@@ -36,7 +36,7 @@ const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
|
|||||||
*/
|
*/
|
||||||
const MENU_ITEMS = [
|
const MENU_ITEMS = [
|
||||||
{ label: '仪表盘', path: '/dashboard' },
|
{ label: '仪表盘', path: '/dashboard' },
|
||||||
{ label: '课表管理', path: '/schedules' },
|
{ label: '课表档案', path: '/plan-editor' },
|
||||||
{ label: '作业发布', path: '/homeworks' },
|
{ label: '作业发布', path: '/homeworks' },
|
||||||
{ label: '通知发布', path: '/notifications' },
|
{ label: '通知发布', path: '/notifications' },
|
||||||
{ label: '课件', path: '/courseware' },
|
{ label: '课件', path: '/courseware' },
|
||||||
@@ -504,7 +504,10 @@ async function main() {
|
|||||||
if (!item) { visited=false; continue; }
|
if (!item) { visited=false; continue; }
|
||||||
const parent=item.closest('.el-sub-menu');
|
const parent=item.closest('.el-sub-menu');
|
||||||
if (!parent.classList.contains('is-opened')) parent.querySelector('.el-sub-menu__title').click();
|
if (!parent.classList.contains('is-opened')) parent.querySelector('.el-sub-menu__title').click();
|
||||||
await pause(); item.click(); await pause();
|
await pause(); item.click();
|
||||||
|
const routeDeadline=Date.now()+6000;
|
||||||
|
while(Date.now()<routeDeadline && (location.pathname!==path || !document.querySelector('.management-breadcrumb'))) await new Promise(r=>setTimeout(r,60));
|
||||||
|
await pause();
|
||||||
visited = visited && location.pathname === path && !!document.querySelector('.management-breadcrumb');
|
visited = visited && location.pathname === path && !!document.querySelector('.management-breadcrumb');
|
||||||
}
|
}
|
||||||
const tabs=document.querySelectorAll('.workspace-tab').length;
|
const tabs=document.querySelectorAll('.workspace-tab').length;
|
||||||
@@ -595,9 +598,9 @@ async function main() {
|
|||||||
`document.querySelector('.courseware-page .el-table__body')?.textContent.includes(${JSON.stringify(coursewareName)})`,
|
`document.querySelector('.courseware-page .el-table__body')?.textContent.includes(${JSON.stringify(coursewareName)})`,
|
||||||
);
|
);
|
||||||
const coursewareUploaded = await win.webContents.executeJavaScript(
|
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('演示文稿'));})()`,
|
`(() => {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('演示文稿')&&row.textContent.includes('Teachers'));})()`,
|
||||||
);
|
);
|
||||||
record('课件真实上传并按类型归档,未选班级时仅自己可见', coursewareUploaded);
|
record('课件真实上传并按教师、年、月、日、时间归档,未选班级时仅自己可见', coursewareUploaded);
|
||||||
if (process.env.UI_SMOKE_SHOTS_DIR)
|
if (process.env.UI_SMOKE_SHOTS_DIR)
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(process.env.UI_SMOKE_SHOTS_DIR, 'courseware-web.png'),
|
path.join(process.env.UI_SMOKE_SHOTS_DIR, 'courseware-web.png'),
|
||||||
@@ -1001,16 +1004,16 @@ async function main() {
|
|||||||
const layoutDialog = await win.webContents.executeJavaScript(`(async () => {
|
const layoutDialog = await win.webContents.executeJavaScript(`(async () => {
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
||||||
(node.textContent ?? '').trim().startsWith('课表管理'),
|
(node.textContent ?? '').trim().startsWith('课表档案'),
|
||||||
);
|
);
|
||||||
if (!menu) return { ok: false, reason: '未找到课表管理菜单' };
|
if (!menu) return { ok: false, reason: '未找到课表管理菜单' };
|
||||||
menu.click();
|
menu.click();
|
||||||
const deadline = Date.now() + 6000;
|
const deadline = Date.now() + 6000;
|
||||||
while (Date.now() < deadline && location.pathname !== '/schedules') await sleep(80);
|
while (Date.now() < deadline && location.pathname !== '/plan-editor') await sleep(80);
|
||||||
await sleep(600);
|
await sleep(600);
|
||||||
|
|
||||||
const buttons = Array.from(document.querySelectorAll('button'));
|
const buttons = Array.from(document.querySelectorAll('button'));
|
||||||
const openButton = buttons.find((node) => (node.textContent ?? '').trim() === '导入 ClassIsland 课程表');
|
const openButton = buttons.find((node) => (node.textContent ?? '').trim() === '导入档案');
|
||||||
if (!openButton) return { ok: false, reason: '课表页没有"导入 ClassIsland 课程表"按钮(班主任权限未生效?)' };
|
if (!openButton) return { ok: false, reason: '课表页没有"导入 ClassIsland 课程表"按钮(班主任权限未生效?)' };
|
||||||
// 旧入口必须已经下线(需求 ⑩)
|
// 旧入口必须已经下线(需求 ⑩)
|
||||||
const legacy = buttons.some((node) => (node.textContent ?? '').trim() === '导入时间配置');
|
const legacy = buttons.some((node) => (node.textContent ?? '').trim() === '导入时间配置');
|
||||||
@@ -1087,173 +1090,6 @@ async function main() {
|
|||||||
})()`);
|
})()`);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|
||||||
// 7.8 授课科目统一:新增课表的科目下拉直接列出"全校统一科目"(不必按班级先录入课程),
|
|
||||||
// 选中后自动建课并写入课表;用例结束把这条课表删掉,保证不污染演示数据。
|
|
||||||
//
|
|
||||||
// 前置:种子数据给每个班建满了全部科目,"统一科目(自动建课)"那组就永远是空的。
|
|
||||||
// 这里先删掉本班一门**没有任何课表/作业/成绩引用**的科目(通用技术,演示课表只用前 11 门),
|
|
||||||
// 让下拉里真的出现"可自动建课"的分组;用例跑完会自动把它建回来(净效果为零)。
|
|
||||||
const sparePrep = await win.webContents.executeJavaScript(`(async () => {
|
|
||||||
const token = localStorage.getItem('classhelper.token') ?? '';
|
|
||||||
const headers = { authorization: 'Bearer ' + token };
|
|
||||||
const classLabel = (document.querySelector('.toolbar .el-select')?.textContent ?? '').trim();
|
|
||||||
const classes = (await (await fetch('/api/classes', { headers })).json()).data ?? [];
|
|
||||||
const target = classes.find((item) => item.name === classLabel) ?? classes[0];
|
|
||||||
if (!target) return { ok: false, classLabel, classCount: classes.length };
|
|
||||||
const courses = (await (await fetch('/api/courses?classId=' + target.id, { headers })).json()).data ?? [];
|
|
||||||
const spare = courses.find((item) => item.name === '通用技术');
|
|
||||||
if (!spare) return { ok: true, className: target.name, removed: false };
|
|
||||||
const status = (await fetch('/api/courses/' + spare.id, { method: 'DELETE', headers })).status;
|
|
||||||
// 关键:页面上的课程列表是**进入课表页时拉一次**的,删完必须重新加载,
|
|
||||||
// 否则「统一科目」分组仍按旧的课程表算,依旧是空的。
|
|
||||||
if (status === 200) {
|
|
||||||
location.reload();
|
|
||||||
return { ok: true, className: target.name, removed: true, reloading: true };
|
|
||||||
}
|
|
||||||
return { ok: true, className: target.name, removed: false, status };
|
|
||||||
})()`);
|
|
||||||
console.log('[ui-smoke] 统一科目用例前置:', JSON.stringify(sparePrep));
|
|
||||||
|
|
||||||
if (sparePrep?.reloading) {
|
|
||||||
// 等页面重新加载并回到课表页(新增课表按钮是这条用例的入口)
|
|
||||||
const reloadDeadline = Date.now() + 15000;
|
|
||||||
let ready = false;
|
|
||||||
while (Date.now() < reloadDeadline && !ready) {
|
|
||||||
await sleep(200);
|
|
||||||
ready = await win.webContents.executeJavaScript(
|
|
||||||
`Array.from(document.querySelectorAll('button')).some((node) => (node.textContent ?? '').trim() === '新增课表')`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!ready) console.log('[ui-smoke] 警告:删课科目后页面重载超时');
|
|
||||||
}
|
|
||||||
|
|
||||||
const unifiedSubject = await win.webContents.executeJavaScript(`(async () => {
|
|
||||||
const openButton = Array.from(document.querySelectorAll('button')).find((node) =>
|
|
||||||
(node.textContent ?? '').trim() === '新增课表',
|
|
||||||
);
|
|
||||||
if (!openButton) return { ok: false, reason: '课表页没有「新增课表」按钮(当前账号不是班主任?)' };
|
|
||||||
openButton.click();
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < deadline && !document.querySelector('.el-dialog')) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
||||||
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
|
||||||
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('新增课表'),
|
|
||||||
);
|
|
||||||
if (!dialog) return { ok: false, reason: '新增课表弹窗未打开' };
|
|
||||||
const selects = Array.from(dialog.querySelectorAll('.el-select__wrapper'));
|
|
||||||
if (selects.length < 2) return { ok: false, reason: '弹窗里没有科目/星期下拉' };
|
|
||||||
|
|
||||||
// 打开科目下拉,读取"统一科目"分组
|
|
||||||
selects[0].click();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
|
||||||
const groups = Array.from(document.querySelectorAll('.el-select-group__title')).map((node) =>
|
|
||||||
node.textContent.trim(),
|
|
||||||
);
|
|
||||||
// Element Plus 的 el-option-group 结构是:.el-select-group__wrap > (.el-select-group__title + ul.el-select-group)
|
|
||||||
const unifiedWrap = Array.from(document.querySelectorAll('.el-select-group__wrap')).find((node) =>
|
|
||||||
(node.querySelector('.el-select-group__title')?.textContent ?? '').includes('统一科目'),
|
|
||||||
);
|
|
||||||
const unifiedNames = unifiedWrap
|
|
||||||
? Array.from(unifiedWrap.querySelectorAll('.el-select-dropdown__item')).map((node) =>
|
|
||||||
node.textContent.trim(),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
if (unifiedNames.length === 0) {
|
|
||||||
return { ok: false, reason: '科目下拉里没有「统一科目」分组(该班已把所有科目都建好了?)', groups };
|
|
||||||
}
|
|
||||||
const subjectName = unifiedNames[0];
|
|
||||||
const subjectOption = Array.from(unifiedWrap.querySelectorAll('.el-select-dropdown__item')).find(
|
|
||||||
(node) => node.textContent.trim() === subjectName,
|
|
||||||
);
|
|
||||||
subjectOption.click();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
// 星期改成周日(避开演示课表的周一~周五),时间用默认值
|
|
||||||
selects[1].click();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
||||||
const sunday = Array.from(document.querySelectorAll('.el-select-dropdown__item')).find((node) =>
|
|
||||||
(node.textContent ?? '').trim() === '周日',
|
|
||||||
);
|
|
||||||
if (!sunday) return { ok: false, reason: '星期下拉里没有「周日」' };
|
|
||||||
sunday.click();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
const save = Array.from(dialog.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
|
||||||
(node.textContent ?? '').trim() === '保存',
|
|
||||||
);
|
|
||||||
if (!save) return { ok: false, reason: '弹窗没有保存按钮' };
|
|
||||||
save.click();
|
|
||||||
|
|
||||||
// 等课表列表出现这条(课程名 = 统一科目名)
|
|
||||||
const listDeadline = Date.now() + 8000;
|
|
||||||
let rowText = '';
|
|
||||||
while (Date.now() < listDeadline) {
|
|
||||||
const rows = Array.from(document.querySelectorAll('.timetable-cell'));
|
|
||||||
const hit = rows.find((row) => (row.textContent ?? '').includes(subjectName));
|
|
||||||
if (hit) {
|
|
||||||
rowText = (hit.textContent ?? '').replace(/\\s+/g, ' ').trim();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
}
|
|
||||||
return { ok: Boolean(rowText), subjectName, groups, unifiedCount: unifiedNames.length, rowText };
|
|
||||||
})()`);
|
|
||||||
record(
|
|
||||||
'课表科目为全校统一目录(选中自动建课并写入课表)',
|
|
||||||
Boolean(unifiedSubject?.ok) && (unifiedSubject?.unifiedCount ?? 0) >= 1,
|
|
||||||
`统一科目分组=${(unifiedSubject?.groups ?? []).join('/') || '-'} 可选项=${unifiedSubject?.unifiedCount ?? 0} ` +
|
|
||||||
`科目=${unifiedSubject?.subjectName ?? '-'} 新行="${unifiedSubject?.rowText ?? ''}"` +
|
|
||||||
`${unifiedSubject?.reason ? ` 原因=${unifiedSubject.reason}` : ''}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 删掉刚写入的那条课表**以及自动建出来的课程**,保持演示数据不变
|
|
||||||
const removedUnified = await win.webContents.executeJavaScript(`(async () => {
|
|
||||||
const rows = Array.from(document.querySelectorAll('.timetable-cell'));
|
|
||||||
const row = rows.find((item) => (item.textContent ?? '').includes(${JSON.stringify(
|
|
||||||
unifiedSubject?.subjectName ?? '',
|
|
||||||
)}));
|
|
||||||
if (!row) return { ok: false, reason: '列表里找不到刚写入的课表行' };
|
|
||||||
const removeButton = Array.from(row.querySelectorAll('button')).find((node) =>
|
|
||||||
(node.textContent ?? '').trim() === '删除',
|
|
||||||
);
|
|
||||||
if (!removeButton) return { ok: false, reason: '行内没有删除按钮' };
|
|
||||||
removeButton.click();
|
|
||||||
const confirmDeadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < confirmDeadline && !document.querySelector('.el-message-box')) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
const confirm = Array.from(document.querySelectorAll('.el-message-box__btns button')).find((node) =>
|
|
||||||
(node.textContent ?? '').trim() === '确定',
|
|
||||||
);
|
|
||||||
if (!confirm) return { ok: false, reason: '确认弹窗没有确定按钮' };
|
|
||||||
confirm.click();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
||||||
|
|
||||||
// 自动建课留下的课程行也一并删掉(该科目本来不在这个班里)
|
|
||||||
const token = localStorage.getItem('classhelper.token') ?? '';
|
|
||||||
const headers = { authorization: 'Bearer ' + token };
|
|
||||||
// 工具栏第一个选择器就是班级选择器;el-select 非 filterable 时选中项是 span 文本,读 textContent
|
|
||||||
const classLabel = (document.querySelector('.toolbar .el-select')?.textContent ?? '').trim();
|
|
||||||
const classes = (await (await fetch('/api/classes', { headers })).json()).data ?? [];
|
|
||||||
const target = classes.find((item) => item.name === classLabel);
|
|
||||||
if (!target) return { ok: true, courseRemoved: false, reason: '未能定位当前班级:' + classLabel };
|
|
||||||
const courses = (await (await fetch('/api/courses?classId=' + target.id, { headers })).json()).data ?? [];
|
|
||||||
const created = courses.find((item) => item.name === ${JSON.stringify(unifiedSubject?.subjectName ?? '')});
|
|
||||||
if (!created) return { ok: true, courseRemoved: false };
|
|
||||||
const removed = await fetch('/api/courses/' + created.id, { method: 'DELETE', headers });
|
|
||||||
return { ok: true, courseRemoved: removed.ok, className: target.name };
|
|
||||||
})()`);
|
|
||||||
record(
|
|
||||||
'课表用例收尾:统一科目课表与自动建的课程都已删除(不污染演示数据)',
|
|
||||||
Boolean(removedUnified?.ok),
|
|
||||||
removedUnified?.ok
|
|
||||||
? `课表已删除;课程已删除=${removedUnified.courseRemoved === true}(班级=${removedUnified.className ?? '-'})`
|
|
||||||
: `原因=${removedUnified?.reason ?? '-'}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 客户端页保留设备、远控与安装包入口;退役联动入口已移除。
|
// 客户端页保留设备、远控与安装包入口;退役联动入口已移除。
|
||||||
const clientsUi = await win.webContents.executeJavaScript(`(async () => {
|
const clientsUi = await win.webContents.executeJavaScript(`(async () => {
|
||||||
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
||||||
@@ -1521,24 +1357,77 @@ async function runPlanEditorChecks(win) {
|
|||||||
await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
|
await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
|
||||||
const ready = await waitFor(
|
const ready = await waitFor(
|
||||||
win,
|
win,
|
||||||
`document.body.innerText.includes('星期 × 时间点') && Boolean(document.querySelector('.editor-table tbody tr'))`,
|
`document.body.innerText.includes('课表编辑') && Boolean(document.querySelector('.course-row'))`,
|
||||||
);
|
);
|
||||||
record('日课表编辑器周视图与显式时间点行可用', ready);
|
record('统一课表档案工作区与课程时段编辑可用', ready);
|
||||||
const save = await win.webContents.executeJavaScript(
|
const save = await win.webContents.executeJavaScript(
|
||||||
`(() => {const b=[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='保存日课表');b?.click();return Boolean(b&&!b.disabled);})()`,
|
`(() => {const b=[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='保存课表');b?.click();return Boolean(b&&!b.disabled);})()`,
|
||||||
);
|
);
|
||||||
const saved = save && (await waitFor(win, `document.body.innerText.includes('整张日课表已保存')`));
|
const saved = save && (await waitFor(win, `document.body.innerText.includes('整张日课表已保存')`));
|
||||||
record('日课表整表保存可以从页面提交', saved);
|
record('日课表整表保存可以从页面提交', saved);
|
||||||
await win.webContents.executeJavaScript(
|
await win.webContents.executeJavaScript(
|
||||||
`(() => {[...document.querySelectorAll('[role=tab]')].find(e=>e.textContent.includes('时间表'))?.click();})()`,
|
`(() => {[...document.querySelectorAll('.editor-nav button')].find(e=>e.textContent.trim()==='时间表')?.click();})()`,
|
||||||
);
|
);
|
||||||
record(
|
record(
|
||||||
'时间表页四种时间点与管理员保存入口可见',
|
'时间表页四种时间点与管理员保存入口可见',
|
||||||
await waitFor(
|
await waitFor(
|
||||||
win,
|
win,
|
||||||
`document.body.innerText.includes('添加时间点') && document.body.innerText.includes('保存时间表')`,
|
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表')`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const quickAdd = await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
const rows = () => document.querySelectorAll('.time-row');
|
||||||
|
const before = rows().length;
|
||||||
|
[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='+ 新建课时')?.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||||
|
const after = rows().length;
|
||||||
|
const last = rows()[after - 1];
|
||||||
|
const values = last ? [...last.querySelectorAll('.el-date-editor input')].map((input) => input.value) : [];
|
||||||
|
const [start, end] = values;
|
||||||
|
const diff = (() => {
|
||||||
|
if (!/^\\d{2}:\\d{2}$/.test(start ?? '') || !/^\\d{2}:\\d{2}$/.test(end ?? '')) return -1;
|
||||||
|
const [h1, m1] = start.split(':').map(Number), [h2, m2] = end.split(':').map(Number);
|
||||||
|
return h2 * 60 + m2 - (h1 * 60 + m1);
|
||||||
|
})();
|
||||||
|
return { before, after, start, end, diff };
|
||||||
|
})()`);
|
||||||
|
record(
|
||||||
|
'时间表新建课时按快速时长生成起止时间',
|
||||||
|
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 45,
|
||||||
|
`行数 ${quickAdd.before}→${quickAdd.after} 时间=${quickAdd.start ?? '-'}–${quickAdd.end ?? '-'} 分钟=${quickAdd.diff}`,
|
||||||
|
);
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`document.querySelector('.editor-nav button:nth-child(5)').click()`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'整周预览与当前生效课表保持一致',
|
||||||
|
await waitFor(
|
||||||
|
win,
|
||||||
|
`document.body.innerText.includes('整周课表预览') && document.querySelector('.week-grid .el-table__body')`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('.editor-nav button:first-child').click()`);
|
||||||
|
await sleep(300);
|
||||||
|
const collapsedBefore = await win.webContents.executeJavaScript(
|
||||||
|
`Boolean(document.querySelector('.layout-aside.is-collapsed'))`,
|
||||||
|
);
|
||||||
|
if (!collapsedBefore)
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('.nav-toggle').click()`);
|
||||||
|
await sleep(700);
|
||||||
|
const rail = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const aside=document.querySelector('.layout-aside').getBoundingClientRect();
|
||||||
|
const icons=[...document.querySelectorAll('.management-group-title .el-icon')].filter(e=>e.getBoundingClientRect().width>0);
|
||||||
|
return icons.length>0 && icons.every(e=>{ const r=e.getBoundingClientRect(); return r.width>=18&&r.left>=aside.left&&r.right<=aside.right; });
|
||||||
|
})()`);
|
||||||
|
record('折叠侧栏分组图标完整显示且未被侧栏裁切', rail);
|
||||||
|
if (!collapsedBefore)
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('.nav-toggle').click()`);
|
||||||
|
await sleep(700);
|
||||||
|
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(__dirname, '../../docs/screenshots/plan-editor-workspace.png'),
|
||||||
|
(await win.webContents.capturePage()).toPNG(),
|
||||||
|
);
|
||||||
await win.loadURL(new URL('/system-settings', TARGET_URL).href);
|
await win.loadURL(new URL('/system-settings', TARGET_URL).href);
|
||||||
record(
|
record(
|
||||||
'开发环境自启动明确显示不可用原因',
|
'开发环境自启动明确显示不可用原因',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ let passed = 0;
|
|||||||
const h = {
|
const h = {
|
||||||
config: {
|
config: {
|
||||||
serverUrl: 'http://127.0.0.1:4199',
|
serverUrl: 'http://127.0.0.1:4199',
|
||||||
|
username: 'temporary-class',
|
||||||
token: 'test-session',
|
token: 'test-session',
|
||||||
deviceKey: 'regression-device-key',
|
deviceKey: 'regression-device-key',
|
||||||
autoUpdate: true,
|
autoUpdate: true,
|
||||||
@@ -58,12 +59,13 @@ h.spawn = (file, args, options) => {
|
|||||||
};
|
};
|
||||||
const mockSources = {
|
const mockSources = {
|
||||||
electron: `const h=globalThis.__clientAgentTest;
|
electron: `const h=globalThis.__clientAgentTest;
|
||||||
export const BrowserWindow={getAllWindows:()=>[{isDestroyed:()=>false,isVisible:()=>h.visible}]};
|
export const BrowserWindow={getAllWindows:()=>[{isDestroyed:()=>false,isVisible:()=>h.visible,webContents:{send:()=>{}}}]};
|
||||||
export const app={getVersion:()=>h.version,getPath:(name)=>name==='exe'?'C:/测试目录/ClassHelper.exe':${JSON.stringify(temp)},quit:()=>h.quitCount++};
|
export const app={getVersion:()=>h.version,getPath:(name)=>name==='exe'?'C:/测试目录/ClassHelper.exe':${JSON.stringify(temp)},quit:()=>h.quitCount++};
|
||||||
export const dialog={showMessageBox:async()=>({response:h.confirmed?1:0})};`,
|
export const dialog={showMessageBox:async()=>({response:h.confirmed?1:0})};`,
|
||||||
config: `const h=globalThis.__clientAgentTest; export const getConfig=()=>h.config;
|
config: `const h=globalThis.__clientAgentTest; export const getConfig=()=>h.config;
|
||||||
export const saveConfig=(patch)=>Object.assign(h.config,patch);`,
|
export const saveConfig=(patch)=>Object.assign(h.config,patch);`,
|
||||||
island: `export const island={getState:()=>({inClass:globalThis.__clientAgentTest.inClass})};`,
|
island: `export const island={getState:()=>({inClass:globalThis.__clientAgentTest.inClass}),getCoursewareTransfer:()=>null};`,
|
||||||
|
courseware: `export const pollCourseware=async()=>{};`,
|
||||||
logger: `export const logger={info:()=>{},warn:()=>{},error:()=>{},debug:()=>{}};`,
|
logger: `export const logger={info:()=>{},warn:()=>{},error:()=>{},debug:()=>{}};`,
|
||||||
process: `export const spawn=(...args)=>globalThis.__clientAgentTest.spawn(...args);`,
|
process: `export const spawn=(...args)=>globalThis.__clientAgentTest.spawn(...args);`,
|
||||||
};
|
};
|
||||||
@@ -84,7 +86,7 @@ try {
|
|||||||
path: args.path === 'electron' ? 'electron' : 'process',
|
path: args.path === 'electron' ? 'electron' : 'process',
|
||||||
namespace: 'mock',
|
namespace: 'mock',
|
||||||
}));
|
}));
|
||||||
b.onResolve({ filter: /^\.\/(config|island|logger)\.js$/ }, (args) => ({
|
b.onResolve({ filter: /^\.\/(config|island|logger|courseware)\.js$/ }, (args) => ({
|
||||||
path: path.basename(args.path, '.js'),
|
path: path.basename(args.path, '.js'),
|
||||||
namespace: 'mock',
|
namespace: 'mock',
|
||||||
}));
|
}));
|
||||||
@@ -96,7 +98,7 @@ try {
|
|||||||
contents:
|
contents:
|
||||||
fs.readFileSync(args.path, 'utf8') +
|
fs.readFileSync(args.path, 'utf8') +
|
||||||
`\nexport { beat, handleCommand, executeCommand };
|
`\nexport { beat, handleCommand, executeCommand };
|
||||||
export { syncUpdatePolicy, considerUpdate, maybeInstallPendingUpdate, setQuitHandler } from './auto-update.js';`,
|
export { syncUpdatePolicy, considerUpdate, maybeInstallPendingUpdate, setQuitHandler, getUpdateProgress } from './auto-update.js';`,
|
||||||
loader: 'ts',
|
loader: 'ts',
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -118,6 +120,10 @@ try {
|
|||||||
h.acked.push(JSON.parse(options.body));
|
h.acked.push(JSON.parse(options.body));
|
||||||
return { ok: !h.ackFails, status: h.ackFails ? 503 : 200 };
|
return { ok: !h.ackFails, status: h.ackFails ? 503 : 200 };
|
||||||
}
|
}
|
||||||
|
if (url.includes('/update-progress')) {
|
||||||
|
(h.progressReports ??= []).push(JSON.parse(options.body));
|
||||||
|
return { ok: true, status: 200 };
|
||||||
|
}
|
||||||
if (url.includes('/heartbeat'))
|
if (url.includes('/heartbeat'))
|
||||||
return { ok: true, status: 200, json: async () => ({ success: true, data: responseData }) };
|
return { ok: true, status: 200, json: async () => ({ success: true, data: responseData }) };
|
||||||
// 下载故意暂不完成:心跳必须仍然返回。
|
// 下载故意暂不完成:心跳必须仍然返回。
|
||||||
@@ -146,9 +152,9 @@ try {
|
|||||||
});
|
});
|
||||||
h.confirmed = false;
|
h.confirmed = false;
|
||||||
await agent.handleCommand(command('cancel'));
|
await agent.handleCommand(command('cancel'));
|
||||||
check('本机取消不执行系统命令且失败回执', () => {
|
check('远控立即执行并回执,不受旧版确认选项影响', () => {
|
||||||
assert.equal(h.spawned.length, 1);
|
assert.equal(h.spawned.length, 2);
|
||||||
assert.equal(h.acked.at(-1).ok, false);
|
assert.equal(h.acked.at(-1).ok, true);
|
||||||
});
|
});
|
||||||
h.confirmed = true;
|
h.confirmed = true;
|
||||||
h.spawnCode = 5;
|
h.spawnCode = 5;
|
||||||
@@ -245,6 +251,28 @@ try {
|
|||||||
assert.match(script, /-ArgumentList '\/S','--updated','--force-run'/);
|
assert.match(script, /-ArgumentList '\/S','--updated','--force-run'/);
|
||||||
assert.match(script, /if \(\$installer.ExitCode -ne 0/);
|
assert.match(script, /if \(\$installer.ExitCode -ne 0/);
|
||||||
assert.match(script, /-WindowStyle|Start-Process/);
|
assert.match(script, /-WindowStyle|Start-Process/);
|
||||||
|
assert.equal(h.progressReports.at(-1).progress.stage, 'installing');
|
||||||
|
assert.match(script, /catch \{/);
|
||||||
|
assert.match(script, /install-result\.json/);
|
||||||
|
fs.writeFileSync(path.join(root, '.cache', 'verified-update-helper.ps1'), script, 'utf8');
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(temp, 'updates', 'install-result.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: '2.0.0',
|
||||||
|
serverUrl: h.config.serverUrl,
|
||||||
|
username: h.config.username,
|
||||||
|
error: '模拟安装器退出码:5',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const failedInstall = await import(`${pathToFileURL(bundle).href}?installer-failed=1`);
|
||||||
|
const countBeforeRetry = h.spawned.length;
|
||||||
|
const failure = failedInstall.getUpdateProgress();
|
||||||
|
await failedInstall.maybeInstallPendingUpdate();
|
||||||
|
check('安装助手失败后重启客户端可恢复原因并退避重试', () => {
|
||||||
|
assert.equal(failure.stage, 'failed');
|
||||||
|
assert.match(failure.error, /退出码:5/);
|
||||||
|
assert.equal(h.spawned.length, countBeforeRetry);
|
||||||
});
|
});
|
||||||
console.log(`客户端代理回归:${passed}/${passed} 项通过`);
|
console.log(`客户端代理回归:${passed}/${passed} 项通过`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
|
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
|
||||||
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
|
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
|
||||||
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
|
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
|
||||||
<meta name="ch-version" content="1.4.0" />
|
<meta name="ch-version" content="1.4.1" />
|
||||||
<link rel="icon" href="assets/icon.png" />
|
<link rel="icon" href="assets/icon.png" />
|
||||||
<link rel="apple-touch-icon" href="assets/icon.png" />
|
<link rel="apple-touch-icon" href="assets/icon.png" />
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -44,7 +44,7 @@
|
|||||||
<a class="brand" href="#top" data-press="0.96">
|
<a class="brand" href="#top" data-press="0.96">
|
||||||
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
|
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
|
||||||
<span class="brand-name">ClassHelper</span>
|
<span class="brand-name">ClassHelper</span>
|
||||||
<span class="brand-ver">1.4.0</span>
|
<span class="brand-ver">1.4.1</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="topnav" aria-label="页内导航">
|
<nav class="topnav" aria-label="页内导航">
|
||||||
<span class="nav-pill" aria-hidden="true"></span>
|
<span class="nav-pill" aria-hidden="true"></span>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
|
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
|
||||||
<li>v1.4.0</li>
|
<li>v1.4.1</li>
|
||||||
<li>Windows 10/11</li>
|
<li>Windows 10/11</li>
|
||||||
<li>Linux · Docker</li>
|
<li>Linux · Docker</li>
|
||||||
<li>触摸屏可用</li>
|
<li>触摸屏可用</li>
|
||||||
@@ -733,7 +733,7 @@
|
|||||||
ClassHelper
|
ClassHelper
|
||||||
<span>Class Helper</span>
|
<span>Class Helper</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="foot-ver">版本 1.4.0 · 源码公开 · 装在你自己的机器上</p>
|
<p class="foot-ver">版本 1.4.1 · 源码公开 · 装在你自己的机器上</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="foot-nav" aria-label="相关链接">
|
<nav class="foot-nav" aria-label="相关链接">
|
||||||
|
|||||||
Reference in new issue
Block a user