diff --git a/deploy/docker-compose.sqlite.yml b/deploy/docker-compose.sqlite.yml index e9545c8..ee9956e 100644 --- a/deploy/docker-compose.sqlite.yml +++ b/deploy/docker-compose.sqlite.yml @@ -23,7 +23,7 @@ services: context: .. dockerfile: deploy/Dockerfile target: runtime - image: classhelper-server:${APP_VERSION:-1.4.0} + image: classhelper-server:${APP_VERSION:-1.4.1} restart: unless-stopped environment: NODE_ENV: production diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index b2cbd47..4aaaed0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -60,7 +60,7 @@ services: context: .. dockerfile: deploy/Dockerfile target: runtime - image: classhelper-server:${APP_VERSION:-1.4.0} + image: classhelper-server:${APP_VERSION:-1.4.1} restart: unless-stopped environment: NODE_ENV: production diff --git a/deploy/package.runtime.json b/deploy/package.runtime.json index bfec0a2..13e4a30 100644 --- a/deploy/package.runtime.json +++ b/deploy/package.runtime.json @@ -1,6 +1,6 @@ { "name": "classhelper-server-runtime", - "version": "1.4.0", + "version": "1.4.1", "private": true, "type": "module", "description": "ClassHelper 服务端运行时(安装包 / 容器内置)", diff --git a/docs/app/courseware.md b/docs/app/courseware.md index bc73bfd..7e9f72d 100644 --- a/docs/app/courseware.md +++ b/docs/app/courseware.md @@ -6,7 +6,8 @@ 1. 选择本地文件。 2. 选择可以查看课件的班级。候选项只包含本人担任任课教师的在读班级;仅担任班主任、级长或部长不会额外获得上传到这些班级的权限。 -3. 选择「仅保存云端」或「保存后推送所选班级」,点击上传。 +3. 可新建分类文件夹,使用 `/` 分隔多层目录,例如 `地理/必修一`;不选文件夹时直接按日期归档。 +4. 选择「仅保存云端」或「保存后推送所选班级」,点击上传。 不选择班级时,课件仅自己可见。仅保存云端并指定可见班级后,这些班级可在客户端自主下载。推送模式会先把文件完整保存到服务端,再给所选班级创建下载任务;不会从教师电脑直接传到教室电脑。 @@ -14,10 +15,14 @@ 「查看与下载课件」和「上传、设置可见范围与推送自己的课件」为独立权限,可在权限组中分配。默认教师与管理员、内置教师与班主任岗位包含这两项权限;升级时自定义权限组需管理员按需授权。 +「日期归档」按教师、年、月、日和上传时间选择目录;「文件夹分类」按自建目录筛选,已有文件可随时调整分类。文件夹按教师隔离,分类本身不授予班级下载权限。 + ## 班级客户端 客户端侧栏新增「课件」,仅列出本班可见的文件,显示文件名、类型、上传教师、日期与大小。点击「下载」将课件保存到本机,点击「打开文件夹」定位已下载文件。客户端不会自动运行文件。 +「本机归档」独立列出已经接收的课件,离线时也可按教师和日期目录查找。每个服务端与班级会话有独立归档根目录,文件路径采用 `Teachers\老师名字\上传年份\上传月\上传日\上传时间\文件名`,例如 `Teachers\Wanghong\2026\10\18\15-30-42\大气环流.pptx`。目录时间取上传时间,重复推送会复用已校验的文件;同秒同名的不同课件会添加编号,避免覆盖。 + 收到教师推送后,客户端自动下载;离线时任务保留在服务端,上线后的心跳会补拉,每台设备独立回执。实时通道正常时会立即触发检查;断线时通过心跳继续补收。传输失败会显示原因,推送任务在后续心跳中重试,不会影响远控和在线状态上报。 下载期间,固定岛和自定义常驻岛都会显示文件名、文件类型、传输教师、接收字节数和进度条。下载完成后校验 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 秒超时,其他接口维持原有大小限制。 diff --git a/docs/app/imports.md b/docs/app/imports.md index 76c0971..8698a20 100644 --- a/docs/app/imports.md +++ b/docs/app/imports.md @@ -22,7 +22,7 @@ description: 表格预览、名单与成绩校验,以及完整离线档案导 ## 完整 ClassIsland 档案 -在“课表管理 → 导入 ClassIsland 课程表”选择或粘贴 JSON,先预览再导入。 +在“课表档案 → 导入档案”选择或粘贴 JSON,先预览再导入。 完整输入包含 TimeLayouts、ClassPlans、Subjects 字典,可包含 ClassPlanGroups 和 OrderedSchedules 等配置。 JSON 只作为离线文件导入,不依赖在线联动插件。 diff --git a/docs/app/schedules.md b/docs/app/schedules.md index 653ff4a..7890ac4 100644 --- a/docs/app/schedules.md +++ b/docs/app/schedules.md @@ -5,9 +5,13 @@ description: 日课表、共享作息、轮换、临时换课和教学周。 # 课表 -Web 管理端可查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。 +Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。 上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。 +编辑工作区参照 [CsesWebEditor](https://github.com/SmartTeachCN/CsesWebEditor) 的资源列表、设置卡片、课时列表与快速选科布局实现。左侧集中切换课程表、时间表、科目、群组与预定、整周预览和文件预览。旧管理页地址 `/schedules` 自动进入同一工作区。 + +点击课时后可用右侧科目按钮快速填充;拖动课时交换课程,拖动时间点调整时间表顺序,完成后保存。时间表支持任意分钟输入;设置的课程/课间分钟数会用于新建时间点的默认起止时间。完整档案可在页头导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。 + ## 日课表与时间表 时间表保存时间点:上课、课间、分割线、行动;日课表引用时间表,每节课程通过外键关联时间点。 diff --git a/docs/dev/api.md b/docs/dev/api.md index 4a065d0..0adeafb 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -44,8 +44,10 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib | /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查角色、导入权限、班级和科目 | | /imports/class-plan/preview、/imports/class-plan | 旧预览与完整档案提交;提交走关系化导入 | | /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,写入/删除需 timetable:manage | +| GET /class-plans/profile?classId= | 完整档案导出,按班级范围读取,保留时间点与课表关联 | | GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 | | 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 | 班级端补领、逐设备回执 | ## 设备、系统与运维 @@ -53,6 +55,7 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib | 路径/动作 | 行为及边界 | | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 | +| POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 | | GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 | | POST /clients/commands | remote:control + 对应范围,白名单动作 | | POST /clients/updates | client:update,安装策略 immediate/after-school/idle | diff --git a/docs/dev/data-model.md b/docs/dev/data-model.md index 04f0a61..76abaf9 100644 --- a/docs/dev/data-model.md +++ b/docs/dev/data-model.md @@ -42,6 +42,7 @@ description: 现行模型职责、迁移与快照边界。 | `ScheduledClassPlan` | 按本地日期预定日课表 | | `PromotionRun` | 年度升级幂等与审计 | | `Courseware` | 文件元数据、上传教师、存储路径与 SHA256 | +| `CoursewareFolder` | 教师自建分类目录,按上传者隔离 | | `CoursewareVisibility` | 课件可见班级 | | `CoursewarePush` | 按班级的推送任务 | | `CoursewareReceipt` | 按设备的课件接收回执 | diff --git a/docs/management/upgrade.md b/docs/management/upgrade.md index ef29086..15ebf05 100644 --- a/docs/management/upgrade.md +++ b/docs/management/upgrade.md @@ -109,6 +109,8 @@ docker compose up -d ### 客户端(ClassHelper 班级端) +Web「客户端」可缓存安装包并推送目标版本,客户端从本服务下载并校验 SHA256,按立即、放学后或空闲策略安装。管理页显示下载字节与百分比、校验中、等待安装、安装中和失败原因。安装前先上报「正在更新,将会短暂离线」,新版上线后的心跳确认「已完成更新,版本 xxxx」;仅离线不会被判为更新成功。关闭自动更新的客户端会显示原因。 + 从 Releases 取新的 `setup.exe` 覆盖安装即可,配置在 `%APPDATA%`,不受影响。 > 如果你是在**自己维护的机器**上只替换了 `releases/` 里的产物、而没有同步到安装目录, diff --git a/docs/screenshots/plan-editor-workspace.png b/docs/screenshots/plan-editor-workspace.png new file mode 100644 index 0000000..d169aad Binary files /dev/null and b/docs/screenshots/plan-editor-workspace.png differ diff --git a/package.json b/package.json index 56d5c6f..417039b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "class-helper", - "version": "1.4.0", + "version": "1.4.1", "private": true, "description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "packageManager": "pnpm@11.8.0", diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 0e6e139..7d677c0 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/desktop-client", - "version": "1.4.0", + "version": "1.4.1", "private": true, "description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "author": "Class Helper", diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index 9265f87..6e41744 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -390,7 +390,7 @@ const islandClass = computed(() => ({ mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder, 'ci-lesson-card': !!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, call: isCall.value, homework: isHomework.value, diff --git a/packages/desktop-client/src/island/LessonReminderContent.vue b/packages/desktop-client/src/island/LessonReminderContent.vue index 6c7247e..65ac88c 100644 --- a/packages/desktop-client/src/island/LessonReminderContent.vue +++ b/packages/desktop-client/src/island/LessonReminderContent.vue @@ -58,6 +58,7 @@ const time = computed(() => ); const regions = ref([0, 0, 0, 0, 0]); let frame = 0; +let fallback: ReturnType | undefined; // ClassIsland SlantedMaskControl:5 个斜切区域、30°偏移、两侧向中央打开,中央向两侧关闭。 const polygons = computed(() => { const offset = Math.tan(Math.PI / 6) * 40; @@ -71,12 +72,21 @@ const polygons = computed(() => { }); function animateMask() { cancelAnimationFrame(frame); + clearTimeout(fallback); const target = mask.value ? 1 : 0; if (!props.motion) { regions.value = [target, target, target, target, target]; return; } 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 delays = mask.value ? [0, 90, 120, 90, 0] : [120, 90, 0, 90, 120]; const update = (at: number) => { @@ -109,6 +119,7 @@ onMounted(() => { onUnmounted(() => { observer?.disconnect(); cancelAnimationFrame(frame); + clearTimeout(fallback); }); @@ -137,7 +148,7 @@ onUnmounted(() => { {{ leftSeconds }} 秒 - 课间休息 + {{ reminder.kind === 'on-class' ? '开始上课' : '课间休息' }} {{ @@ -146,8 +157,8 @@ onUnmounted(() => { : '下节课是:' }} - {{ course }} - {{ time }} + {{ course || '今日课程已结束' }} + {{ time }}
{ display: flex; align-items: center; justify-content: center; - gap: 24px; - padding: 0 28px; + gap: 12px; + padding: 0 14px; white-space: nowrap; transition: opacity var(--ci-duration) ease-in; } @@ -222,6 +233,7 @@ onUnmounted(() => { text-overflow: ellipsis; } .ci-lesson-course small { + flex-shrink: 0; font-size: 14px; font-variant-numeric: tabular-nums; } diff --git a/packages/desktop-client/src/main/auto-update.ts b/packages/desktop-client/src/main/auto-update.ts index 2527c2f..37f62c2 100644 --- a/packages/desktop-client/src/main/auto-update.ts +++ b/packages/desktop-client/src/main/auto-update.ts @@ -14,6 +14,26 @@ import { import { getConfig, saveConfig } from './config.js'; import { island } from './island.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 上正在运行的程序文件是删不掉的)。 */ -let policy: Pick | null = - null; +let policy: + | (Pick & { + serverUrl: string; + username: string; + }) + | null = null; function policyPath(): string { return path.join(pendingUpdateDir(), 'policy.json'); } function loadPolicy(): typeof policy { - if (policy) return policy; + if (policy) + return policy.serverUrl === getConfig().serverUrl && policy.username === getConfig().username + ? policy + : null; try { policy = JSON.parse(fs.readFileSync(policyPath(), 'utf8')); + if (policy?.serverUrl !== getConfig().serverUrl || policy?.username !== getConfig().username) + policy = null; } catch { /* 尚无下发策略 */ } @@ -58,7 +87,23 @@ function removeManagedInstaller(file: string): 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 = { + serverUrl: getConfig().serverUrl, + username: getConfig().username, updateTargetVersion: next.updateTargetVersion, updateMode: next.updateMode ?? 'idle', updateNotBefore: next.updateNotBefore ?? null, @@ -86,6 +131,8 @@ export function pendingUpdateDir(): string { let mainWindowHiddenAt: number | null = null; /** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */ let installing = false; +let installationRetryAt = 0; +let installResultChecked = false; let updateProgress: ContractReleaseProgressDto = { version: '', stage: 'idle', @@ -95,12 +142,69 @@ let updateProgress: ContractReleaseProgressDto = { error: '', }; 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 }; } let progressPublishedAt = 0; +let reportAt = 0; +let reporting: Promise = Promise.resolve(true); +/** 与心跳分离,按顺序上报;安装前等待该队列完成再退出。 */ +function reportProgress(): Promise { + 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) { const previousStage = updateProgress.stage; updateProgress = { ...updateProgress, ...patch }; + updateProgress.error = updateProgress.error.slice(0, 1000); updateProgress.percent = updateProgress.totalBytes ? Math.min(100, Math.round((updateProgress.downloadedBytes / updateProgress.totalBytes) * 100)) : null; @@ -108,6 +212,10 @@ function publishProgress(patch: Partial) { progressPublishedAt = Date.now(); for (const win of BrowserWindow.getAllWindows()) 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()`)。 @@ -175,6 +283,7 @@ export function shouldConsider(version: string): boolean { * - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。 */ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise { + const epoch = updateEpoch; const version = targetVersion.trim() || info.version.trim(); if (!shouldConsider(version)) { logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`); @@ -189,6 +298,13 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s if (config.pendingUpdateVersion === version) { const existing = config.pendingUpdateFile; if (existing && fs.existsSync(existing)) { + publishProgress({ + version, + stage: 'waiting', + downloadedBytes: info.size, + totalBytes: info.size, + error: '', + }); logger.info(`更新 ${version} 早已下载完成,直接尝试安装`); await maybeInstallPendingUpdate(); return; @@ -222,7 +338,8 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`); const response = await fetch(url, { 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) { 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'); try { for await (const chunk of response.body as unknown as AsyncIterable) { - 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 }); } } finally { await handle.close(); } + if (epoch !== updateEpoch) return; + if (info.size > 0 && updateProgress.downloadedBytes !== info.size) throw new Error('更新包长度不匹配'); publishProgress({ stage: 'verifying' }); const digest = await sha256File(temp); 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.rename(temp, target); publishProgress({ stage: 'waiting' }); - if (loadPolicy()?.updateTargetVersion !== version) { + if (epoch !== updateEpoch || loadPolicy()?.updateTargetVersion !== version) { removeManagedInstaller(target); return; } @@ -277,6 +401,7 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s await maybeInstallPendingUpdate(); } catch (error) { await fsp.rm(temp, { force: true }).catch(() => undefined); + if (epoch !== updateEpoch) return; publishProgress({ stage: 'failed', error: String(error) }); logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`); } finally { @@ -319,6 +444,7 @@ export async function maybeInstallPendingUpdate(): Promise { const courseware = island.getCoursewareTransfer(); if (courseware && ['downloading', 'verifying'].includes(courseware.stage)) return; if (installing) return; + if (Date.now() < installationRetryAt) return; if (process.env.ELECTRON_SMOKE_TEST === '1') return; const config = getConfig(); @@ -375,15 +501,36 @@ export async function maybeInstallPendingUpdate(): Promise { } 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}`); try { - const exe = app.getPath('exe'); + const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'); 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 = - `$ErrorActionPreference = 'Stop'; Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` + - `$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -Wait -PassThru; ` + - `if ($installer.ExitCode -ne 0 -and (Test-Path -LiteralPath ${quote(exe)})) { Start-Process -FilePath ${quote(exe)} }`; + `$ErrorActionPreference = 'Stop'; try { Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` + + `$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -WindowStyle Hidden -Wait -PassThru; ` + + `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 child = spawn( 'powershell.exe', @@ -408,6 +555,7 @@ export async function maybeInstallPendingUpdate(): Promise { } } catch (error) { installing = false; + publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) }); logger.error( `启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`, ); diff --git a/packages/desktop-client/src/main/client-agent.ts b/packages/desktop-client/src/main/client-agent.ts index c1854ac..db57d30 100644 --- a/packages/desktop-client/src/main/client-agent.ts +++ b/packages/desktop-client/src/main/client-agent.ts @@ -15,7 +15,12 @@ import { type ClientHeartbeatResponse, type ClientUpdateInfoDto, } 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 { getConfig } from './config.js'; import { island } from './island.js'; @@ -178,6 +183,7 @@ async function beat(): Promise { autoUpdate: config.autoUpdate !== false, pendingVersion: pendingVersionOrNull(), stateNote: currentStateNote(), + updateProgress: getUpdateProgress(), }; const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, { method: 'POST', diff --git a/packages/desktop-client/src/main/courseware.ts b/packages/desktop-client/src/main/courseware.ts index b248cd7..24fd4b1 100644 --- a/packages/desktop-client/src/main/courseware.ts +++ b/packages/desktop-client/src/main/courseware.ts @@ -9,6 +9,8 @@ import { app, BrowserWindow, shell } from 'electron'; import { COURSEWARE_MAX_BYTES, COURSEWARE_TRANSFER_TIMEOUT_MS, + coursewareArchivePath, + coursewarePathSegment, type ApiResponse, type CoursewareDto, type CoursewarePushDto, @@ -74,6 +76,34 @@ async function digest(file: string) { for await (const chunk of fs.createReadStream(file)) hash.update(chunk); 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 { + try { + return JSON.parse(await fsp.readFile(path.join(root, '.index', `${id}.json`), 'utf8')); + } catch { + return null; + } +} +export async function listLocalCourseware(): Promise { + 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 { checkId(id); const startedEpoch = epoch, @@ -99,10 +129,22 @@ export function downloadCourseware(id: string): Promise { !/^[a-f0-9]{64}$/.test(item.sha256) ) throw new Error('课件文件信息无效'); - const safeName = - `_${item.name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/[. ]+$/, '')}`.slice(0, 182); - const dir = path.join(scopeDirectory(), id), - file = path.join(dir, safeName); + const root = scopeDirectory(); + const stored = await localRecord(id, root); + let relativePath = + 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`; const base: CoursewareTransfer = { id, @@ -164,6 +206,17 @@ export function downloadCourseware(id: string): Promise { await fsp.rename(partial, file); } 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' }); } catch (error) { if (partial) await fsp.rm(partial, { force: true }).catch(() => undefined); @@ -232,6 +285,15 @@ export async function pollCourseware() { } export async function revealCourseware(id: string) { 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 names = await fsp.readdir(dir).catch(() => []); const file = names.find((name) => !name.endsWith('.part')); diff --git a/packages/desktop-client/src/main/ipc.ts b/packages/desktop-client/src/main/ipc.ts index eea0b53..47d879e 100644 --- a/packages/desktop-client/src/main/ipc.ts +++ b/packages/desktop-client/src/main/ipc.ts @@ -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 { getUpdateProgress } from './auto-update.js'; import { app, BrowserWindow, ipcMain, shell } from 'electron'; @@ -17,7 +23,10 @@ export { IPC_CHANNELS }; export function registerIpcHandlers(): void { ipcMain.handle('courseware:download', (_event, id: string) => downloadCourseware(id)); ipcMain.handle('courseware:reveal', (_event, id: string) => revealCourseware(id)); - ipcMain.handle('courseware:sync', () => { void pollCourseware(); }); + ipcMain.handle('courseware:local-list', () => listLocalCourseware()); + ipcMain.handle('courseware:sync', () => { + void pollCourseware(); + }); ipcMain.handle('courseware:get-progress', () => getCoursewareTransfer()); ipcMain.handle('client:get-update-progress', () => getUpdateProgress()); ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig()); diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 75407d7..722bf1f 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -195,7 +195,14 @@ class IslandController { if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return; 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.syncWindow(); } @@ -1106,6 +1113,10 @@ class IslandController { * 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。 */ 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.active || this.state.queued.length > 0) return; diff --git a/packages/desktop-client/src/main/lesson-reminder.ts b/packages/desktop-client/src/main/lesson-reminder.ts index f84dc8a..9f4dc43 100644 --- a/packages/desktop-client/src/main/lesson-reminder.ts +++ b/packages/desktop-client/src/main/lesson-reminder.ts @@ -1,6 +1,5 @@ import { dayKeyLocal, - resolveClassStatus, DEFAULT_LESSON_REMINDERS, type LessonReminderSettings, type IslandLessonReminder, @@ -23,16 +22,18 @@ export class LessonReminderTracker { const at = now.getTime(); const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) => `${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`; - const status = resolveClassStatus(schedules, now); const timeAt = (time: string) => { const d = new Date(now); - const [h, m] = time.split(':').map(Number); - d.setHours(h!, m!, 0, 0); + const [h, m, s = 0] = time.split(':').map(Number); + d.setHours(h!, m!, s, 0); return d.getTime(); }; - const today = schedules.filter((s) => s.dayOfWeek === (now.getDay() || 7)); - const current = today.find((s) => s.id === status.current?.scheduleId); - const next = today.find((s) => s.id === status.next?.scheduleId); + const today = schedules + .filter((s) => s.dayOfWeek === (now.getDay() || 7)) + .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 .filter((s) => at >= timeAt(s.endTime) && at - timeAt(s.endTime) < 12_000) .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 start = timeAt(lesson.startTime); const end = timeAt(lesson.endTime); + // 导入的 ClassIsland 时间点可能带秒;提醒条只显示到分钟,宽度保持稳定。 + const clock = (value: string) => value.slice(0, 5); return (this.current = { id, kind, courseName: lesson.course?.name || '课程', - startTime: lesson.startTime, - endTime: lesson.endTime, + startTime: clock(lesson.startTime), + endTime: clock(lesson.endTime), startedAt: entered, maskUntil: kind === 'ending' || kind === 'in-class' @@ -100,8 +103,8 @@ export class LessonReminderTracker { lessonStartsAt: start, lessonEndsAt: end, nextCourseName: kind === 'break' ? next?.course?.name : undefined, - nextStartTime: kind === 'break' ? next?.startTime : undefined, - nextEndTime: kind === 'break' ? next?.endTime : undefined, + nextStartTime: kind === 'break' && next ? clock(next.startTime) : undefined, + nextEndTime: kind === 'break' && next ? clock(next.endTime) : undefined, }); } } diff --git a/packages/desktop-client/src/main/smoke-courseware.ts b/packages/desktop-client/src/main/smoke-courseware.ts index 38a01c1..eaefcee 100644 --- a/packages/desktop-client/src/main/smoke-courseware.ts +++ b/packages/desktop-client/src/main/smoke-courseware.ts @@ -8,7 +8,7 @@ import { app } from 'electron'; import type { CoursewareDto } from '@classhelper/shared'; import { defaultIslandLayouts } from '@classhelper/shared'; import { getConfig, saveConfig } from './config.js'; -import { downloadCourseware, getCoursewareTransfer } from './courseware.js'; +import { downloadCourseware, getCoursewareTransfer, listLocalCourseware } from './courseware.js'; import { island } from './island.js'; export async function verifyCoursewareSmoke( check: (name: string, action: () => Promise | boolean) => Promise, @@ -93,7 +93,7 @@ export async function verifyCoursewareSmoke( }); await job; await check('课件校验完成后保存正式文件并显示完成', async () => { - const files = await localFiles(); + const files = (await localFiles()).filter((file) => file.endsWith('.pptx')); return ( getCoursewareTransfer()?.stage === 'completed' && getCoursewareTransfer()?.progress === 100 && @@ -101,6 +101,18 @@ export async function verifyCoursewareSmoke( (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 downloadCourseware(item.id); return downloads === 1; diff --git a/packages/desktop-client/src/main/smoke-resident.ts b/packages/desktop-client/src/main/smoke-resident.ts index 8af9dc8..bcc5d5d 100644 --- a/packages/desktop-client/src/main/smoke-resident.ts +++ b/packages/desktop-client/src/main/smoke-resident.ts @@ -558,7 +558,15 @@ export async function verifyResidentSmoke( await pause(800); if (island.getState().lessonReminder?.kind !== 'prepare') return false; 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; await shot('lesson-prepare-mask-' + layout); diff --git a/packages/desktop-client/src/preload/index.ts b/packages/desktop-client/src/preload/index.ts index d3fcf95..f807e4d 100644 --- a/packages/desktop-client/src/preload/index.ts +++ b/packages/desktop-client/src/preload/index.ts @@ -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 { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js'; import type { ClientAgentStatus } from '../main/client-agent.js'; @@ -22,6 +25,7 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared'; const bridge: DesktopBridge = { downloadCourseware: (id) => ipcRenderer.invoke('courseware:download', id), revealCourseware: (id) => ipcRenderer.invoke('courseware:reveal', id), + listLocalCourseware: () => ipcRenderer.invoke('courseware:local-list'), syncCourseware: () => ipcRenderer.invoke('courseware:sync'), getCoursewareTransfer: () => ipcRenderer.invoke('courseware:get-progress'), onCoursewareTransfer: (handler) => { diff --git a/packages/desktop-client/src/renderer/views/CoursewareView.vue b/packages/desktop-client/src/renderer/views/CoursewareView.vue index 9cc41b0..cd5866b 100644 --- a/packages/desktop-client/src/renderer/views/CoursewareView.vue +++ b/packages/desktop-client/src/renderer/views/CoursewareView.vue @@ -1,5 +1,5 @@ + + diff --git a/packages/web-admin/src/components/ManagementMenu.vue b/packages/web-admin/src/components/ManagementMenu.vue index 5d9a226..0e499a9 100644 --- a/packages/web-admin/src/components/ManagementMenu.vue +++ b/packages/web-admin/src/components/ManagementMenu.vue @@ -103,7 +103,23 @@ watch( width: 0; } .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 { overflow-x: hidden; diff --git a/packages/web-admin/src/config/navigation.ts b/packages/web-admin/src/config/navigation.ts index 4ebfd5c..08e3007 100644 --- a/packages/web-admin/src/config/navigation.ts +++ b/packages/web-admin/src/config/navigation.ts @@ -5,7 +5,7 @@ export const MANAGEMENT_GROUPS = [ id: 'teaching', title: '教学事务', icon: 'Notebook', - paths: ['/schedules', '/plan-editor', '/homeworks', '/notifications', '/courseware', '/grades'], + paths: ['/plan-editor', '/homeworks', '/notifications', '/courseware', '/grades'], }, { id: 'school', diff --git a/packages/web-admin/src/router/index.ts b/packages/web-admin/src/router/index.ts index 04baa92..0ff28fd 100644 --- a/packages/web-admin/src/router/index.ts +++ b/packages/web-admin/src/router/index.ts @@ -14,13 +14,23 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/layouts/AdminLayout.vue'), redirect: '/dashboard', children: [ - { path: 'courseware', name: 'courseware', component: () => import('@/views/CoursewareView.vue'), meta: { title: '课件', icon: 'FolderOpened', roles: ['ADMIN', 'TEACHER'], permission: 'courseware:read' } }, + { + path: 'courseware', + name: 'courseware', + component: () => import('@/views/CoursewareView.vue'), + meta: { + title: '课件', + icon: 'FolderOpened', + roles: ['ADMIN', 'TEACHER'], + permission: 'courseware:read', + }, + }, { path: 'plan-editor', name: 'plan-editor', component: () => import('@/views/PlanEditorView.vue'), meta: { - title: '时间表与课表编辑', + title: '课表档案', icon: 'Calendar', roles: ['ADMIN', 'TEACHER'], permission: 'schedule:read', @@ -79,7 +89,7 @@ const routes: RouteRecordRaw[] = [ { path: 'schedules', name: 'schedules', - component: () => import('@/views/SchedulesView.vue'), + redirect: '/plan-editor', meta: { title: '课表管理', icon: 'Calendar' }, }, { diff --git a/packages/web-admin/src/styles/index.css b/packages/web-admin/src/styles/index.css index 1ee6c1a..cf34c3b 100644 --- a/packages/web-admin/src/styles/index.css +++ b/packages/web-admin/src/styles/index.css @@ -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` **自身**的 * `transition: all var(--el-transition-duration)` 承载(`.el-drawer-fade-*` 那组类 diff --git a/packages/web-admin/src/views/ClientsView.vue b/packages/web-admin/src/views/ClientsView.vue index 362e533..b616418 100644 --- a/packages/web-admin/src/views/ClientsView.vue +++ b/packages/web-admin/src/views/ClientsView.vue @@ -18,13 +18,13 @@ * **禁用 + tooltip 说明**而不是直接隐藏:让老师知道"有这件事、需要权限",而不是以为系统没有。 */ import ReleaseProgress from '@/components/ReleaseProgress.vue'; +import ClientUpdateStatus from '@/components/ClientUpdateStatus.vue'; import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'; import { ElMessage, ElMessageBox } from 'element-plus'; import { CLIENT_COMMAND_EXPIRE_MS, CLIENT_COMMAND_KINDS, CLIENT_COMMAND_LABELS, - CLIENT_HEARTBEAT_INTERVAL_MS, clientReleaseFileName, compareVersions, normalizeVersion, @@ -47,7 +47,7 @@ const { isMobile } = useResponsive(); const auth = useAuthStore(); /** 自动刷新间隔:跟客户端心跳同频,刷太快没有新数据 */ -const AUTO_REFRESH_MS = CLIENT_HEARTBEAT_INTERVAL_MS; +const AUTO_REFRESH_MS = 3000; function can(key: PermissionKey): boolean { 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(() => — - - + + diff --git a/packages/web-admin/src/views/CoursewareView.vue b/packages/web-admin/src/views/CoursewareView.vue index d1fdcc6..c28d19f 100644 --- a/packages/web-admin/src/views/CoursewareView.vue +++ b/packages/web-admin/src/views/CoursewareView.vue @@ -8,6 +8,7 @@ import { type CoursewareDto, type CoursewareClassDto, type CoursewareUploadRequest, + type CoursewareFolderDto, } from '@classhelper/shared'; import { api } from '@/api/http'; import { useAuthStore } from '@/stores/auth'; @@ -19,6 +20,7 @@ const items = ref([]), const file = ref(null), classIds = ref([]), mode = ref<'cloud' | 'push'>('cloud'); +const fileInput = ref(); const loading = ref(false), uploading = ref(false), progress = ref(0), @@ -30,11 +32,51 @@ const scopeItem = ref(null), scopeMode = ref<'visibility' | 'push'>('visibility'), saving = ref(false); const canWrite = computed(() => auth.can('courseware:write')); +const folders = ref([]), + 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(() => items.value.filter( (item) => (!query.value || `${item.name} ${item.teacher.name}`.includes(query.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), ), ); @@ -42,13 +84,25 @@ function size(bytes: number) { return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } 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() { loading.value = true; try { items.value = await api.get('/courseware'); - if (canWrite.value) classes.value = await api.get('/courseware/classes'); + if (canWrite.value) + [classes.value, folders.value] = await Promise.all([ + api.get('/courseware/classes'), + api.get('/courseware/folders'), + ]); } finally { loading.value = false; } @@ -64,6 +118,7 @@ async function upload() { size: file.value.size, classIds: [...classIds.value], mode: mode.value, + folderPath: folderPath.value, }; uploading.value = true; progress.value = 0; @@ -133,7 +188,7 @@ onMounted(() => void refresh()); @@ -141,7 +196,34 @@ onMounted(() => void refresh());

上传课件

- +
+ + {{ file ? '重新选择文件' : '选择文件' }} + + {{ file.name }} · {{ size(file.size) }} + 未选择文件 + +
+
+ +
+ + + + 新建文件夹 +
void refresh());
+ + 日期归档 + 文件夹分类 + + + + + + + @@ -184,6 +276,26 @@ onMounted(() => void refresh());
+ + +