feat(desktop-client): 阶段4-6 Electron EXE 客户端(登录/课表/作业/通知/成绩/设置 + Socket.IO 实时 + IndexedDB 离线缓存与自动同步 + electron-builder 打包)

This commit is contained in:
LoveChengke committed 2026-09-11 22:46:31 +08:00
1 parent 4566d7cd5e
commit 6e4c7c1050
42 files changed
+5767 -60

No files matched your search

+204 -58
View File
@@ -15,14 +15,14 @@
## 当前交付范围 ## 当前交付范围
| 阶段 | 内容 | 状态 | | 阶段 | 内容 | 状态 |
| ---- | ----------------------------------------------------------------------------------------- | --------------------------------------------- | | ---- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO Client + 离线缓存) | ⏳ 待开发 | | 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 9/9 通过) |
| 5 | 三端联调脚本、打包命令、完整 README | 🟡 后端联调脚本与文档已完成,EXE 打包待阶段 4 | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(electron-builder 打包 Windows EXE) |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
> **与原始提示词的两处偏差(已与你确认)** > **与原始提示词的两处偏差(已与你确认)**
> >
@@ -36,7 +36,7 @@
``` ```
class-helper/ class-helper/
├── package.json # 根脚本(dev / build / db:* / verify:e2e) ├── package.json # 根脚本(dev / build / db:* / verify:* / dist:*)
├── pnpm-workspace.yaml # workspace 定义 + allowBuilds(pnpm 11 依赖构建白名单) ├── pnpm-workspace.yaml # workspace 定义 + allowBuilds(pnpm 11 依赖构建白名单)
├── tsconfig.base.json # 共享 TS 基础配置 ├── tsconfig.base.json # 共享 TS 基础配置
├── eslint.config.mjs # ESLint 扁平配置(TS + Vue) ├── eslint.config.mjs # ESLint 扁平配置(TS + Vue)
@@ -50,31 +50,42 @@ class-helper/
│ ├── prisma/migrations/ # 迁移历史(已生成并应用) │ ├── prisma/migrations/ # 迁移历史(已生成并应用)
│ ├── prisma/seed.ts # 种子数据 │ ├── prisma/seed.ts # 种子数据
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串) │ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
│ ├── scripts/verify-e2e.mjs # 端到端验收脚本(54 项) │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(54 项)
│ └── src/ │ └── src/
│ ├── app.ts # Express 装配(健康检查 + 模块挂载) │ ├── app.ts # Express 装配(服务首页 + 健康检查 + 模块挂载)
│ ├── index.ts # 启动入口(HTTP + Socket.IO + 优雅退出) │ ├── index.ts # 启动入口(HTTP + Socket.IO + 优雅退出)
│ ├── config/env.ts # 环境变量校验(zod) │ ├── config/env.ts # 环境变量校验(zod)
│ ├── lib/ # db / http / jwt / password / access(RBAC) / mappers │ ├── lib/ # db / http / jwt / password / access(RBAC) / mappers
│ ├── middleware/ # auth / error / validate │ ├── middleware/ # auth / error / validate
│ ├── realtime/ # socket.ts + bus.ts(事件总线) │ ├── realtime/ # socket.ts + bus.ts(事件总线)
│ └── modules/ # 功能模块 + registry.ts │ └── modules/ # 功能模块 + registry.ts
└── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus) ├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus)
└── src/{api,stores,router,layouts,views,styles} │ └── src/{api,stores,router,layouts,views,styles}
└── desktop-client/ # EXE 客户端(Electron + Vue 3)
├── electron-builder.yml # 打包配置(nsis 安装包 + portable 单文件)
├── vite.config.mts # 渲染进程构建(base: './',hash 路由)
├── scripts/{build-main,dev,smoke}.mjs
└── src/
├── main/ # 主进程:窗口/单实例/配置持久化(IPC)/冒烟验证
├── preload/ # contextBridge 安全桥(不暴露 ipcRenderer 本体)
├── types/desktop.d.ts # 主进程 <-> 渲染进程契约
└── renderer/ # 渲染进程:api / stores / cache / router / layouts / views
``` ```
## 技术栈 ## 技术栈
| 层 | 选型 | 版本 | | 层 | 选型 | 版本 |
| ------ | ------------------------------------------------------------------ | ----------------------------------------------------- | | ---------- | ---------------------------------------------------------------------- | ----------------------------------------------------- |
| 包管理 | pnpm workspace | 11.8.0 | | 包管理 | pnpm workspace | 11.8.0 |
| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) | | 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) |
| 后端 | Node.js + Express | 24.18 / 5.2 | | 后端 | Node.js + Express | 24.18 / 5.2 |
| ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`) | | ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`) |
| 实时 | Socket.IO | 4.8 | | 实时 | Socket.IO | 4.8 |
| 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 | | 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 |
| 校验 | zod | 4.6 | | 校验 | zod | 4.6 |
| Web 端 | Vue 3 + Vite + Element Plus + Pinia + Vue Router + Axios + ECharts | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 / 6.1 | | Web 端 | Vue 3 + Vite + Element Plus + Pinia + Vue Router + Axios + ECharts | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 / 6.1 |
| EXE 客户端 | Electron + Vue 3 + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 |
| 打包 | electron-builder(nsis / portable) | 26.15 |
> 运行环境要求:**Node.js ≥ 20.19**(Prisma 7 硬性要求,推荐 22/24)。 > 运行环境要求:**Node.js ≥ 20.19**(Prisma 7 硬性要求,推荐 22/24)。
@@ -113,15 +124,32 @@ pnpm dev
也可以分开启动:`pnpm dev:server`(仅后端)、`pnpm dev:web`(仅 Web 端)。 也可以分开启动:`pnpm dev:server`(仅后端)、`pnpm dev:web`(仅 Web 端)。
### 启动 EXE 客户端(学生端)
```bash
# 开发模式:自动构建主进程 + 启动 Vite 渲染进程(5174) + 打开 Electron 窗口(支持热更新)
pnpm dev:desktop
# 已构建产物的冒烟验证(不弹窗,跑完自动退出)
pnpm build:desktop
pnpm verify:desktop
```
客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,可用学生账号 `student01 / student123` 登录。
若要验证"断网可查看缓存",登录并浏览过程序后关闭后端(`Ctrl+C` 停掉 `pnpm dev:server`),
客户端会在顶部提示「当前处于离线状态」,页面继续显示本地缓存数据;重新启动后端后会自动同步。
### 端到端验收脚本 ### 端到端验收脚本
```bash ```bash
# 另开一个终端先启动后端:pnpm dev:server # 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC:54 项
pnpm verify:desktop # Electron 客户端(含联网集成):9 项
``` ```
脚本会自动登录教师与学生账号,验证**实时推送时延、作业完成、成绩下发、权限隔离**等 54 项, 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离**等 54 项,实测通知 37ms、作业 26ms、
并打印逐项结果与耗时(实测:通知 37ms、作业 26ms、成绩 25ms 到达,远优于 5 秒要求)。 成绩 25ms 到达(要求 < 5 秒);客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、
断网回退与联网集成(登录 + 四类数据 + Socket.IO),实测 9/9 通过。
## 默认账号与种子数据 ## 默认账号与种子数据
@@ -144,21 +172,103 @@ pnpm verify:e2e
## 常用命令 ## 常用命令
| 命令 | 说明 | | 命令 | 说明 |
| ------------------------------------------- | ------------------------------------------------------------------- | | ------------------------------------------- | -------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | | `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | | `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm build` | 构建 shared + 后端(tsc 产出 `dist`) + Web 端(Vite 产出 `dist`) | | `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | | `pnpm verify:desktop` | Electron 冒烟验证(需先 `pnpm build:desktop`;后端在线则含联网集成) |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | | `pnpm dist:dir` | 打包免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm db:migrate` | 创建并应用迁移 | | `pnpm dist:win` | 打包 nsis 安装包 + portable 单文件 EXE |
| `pnpm db:seed` | 写入种子数据 | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm db:reset` | 重置数据库并重新执行 seed | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm db:studio` | 打开 Prisma Studio | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | | `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
| `pnpm verify:e2e` | 端到端验收(需后端已启动) | | `pnpm db:migrate` | 创建并应用迁移 |
| `pnpm db:seed` | 写入种子数据 |
| `pnpm db:reset` | 重置数据库并重新执行 seed |
| `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
| `pnpm verify:e2e` | 端到端验收(需后端已启动) |
## EXE 客户端(学生端)
### 进程结构与安全设置
| 进程 | 文件 | 职责 |
| -------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| 主进程 | `src/main/*` → `dist/main/index.js` | 窗口/单实例锁、配置持久化(`safeStorage` 加密 token)、IPC、冒烟验证 |
| 预加载 | `src/preload/index.ts` → `dist/preload/index.js` | 通过 `contextBridge` 暴露最小 API(`getConfig/saveConfig/getAppInfo/openExternal`),不暴露 `ipcRenderer` 本体 |
| 渲染进程 | `src/renderer/**` → `dist/renderer/*` | Vue 3 界面、Axios 请求、Socket.IO 实时、IndexedDB 离线缓存 |
安全基线:`contextIsolation: true`、`nodeIntegration: false`、`sandbox: true`、`webSecurity: true`,
外部链接一律交给系统浏览器打开,禁止应用内导航到非本地地址。
### 功能页面
| 页面 | 功能 |
| ---- | ----------------------------------------------------------------------------------------- |
| 登录 | 服务器地址 + 用户名 + 密码,附带「测试连接」;已登录但服务器不可达时可「离线进入」 |
| 课表 | 按周展示 7 列课表,支持上一周/下一周/下拉选择周次,高亮"今天",显示周次范围与地点 |
| 作业 | 列表(全部/未完成/已完成过滤)、详情抽屉、标记完成/取消完成、附件链接、逾期高亮 |
| 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 |
| 成绩 | 表格(分数/得分率进度条/等级)+ ECharts 柱状图(各次考试平均得分率)+ 等级分布 + 最近更新 |
| 设置 | 服务器地址(保存并测试)、账号信息、离线缓存条目统计与清空、客户端版本信息、退出登录 |
### 离线缓存与自动同步
```
┌────────────── 在线 ──────────────┐
请求 → fetchWithCache(store, key, loader, fallback)
└── 失败 → 读取 IndexedDB 缓存 → 返回旧数据(UI 标记"离线缓存")
实时推送(notification:new / grade:updated ...) → 更新内存状态 + 写回 IndexedDB
服务器不可达 → 可达(health 轮询 20s 或任意请求成功)→ 触发 onServerRecovered → 各页自动重新拉取
```
- 缓存按业务域分为 6 个 object store:`profile` / `classes` / `schedules` / `homeworks` / `notifications` / `grades`。
- 缓存写入前统一做 JSON 纯化(Vue 响应式对象是 Proxy,直接写入 IndexedDB 会抛 `could not be cloned`)。
- 登录令牌由主进程用 Electron `safeStorage`(Windows 下为 DPAPI)加密后落盘,因此重启客户端可离线进入。
- 令牌缺失/失效时保持缓存资料可见,恢复网络后自动刷新个人资料。
### 打包 Windows EXE
```bash
pnpm dist:dir # 免安装目录:packages/desktop-client/release/win-unpacked/班级小助手.exe
pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像)
```
打包脚本 `packages/desktop-client/scripts/dist-win.mjs` 做了三件事,规避常见网络问题:
1. **复用本地已解压的 Electron**:检测到 `node_modules/electron/dist/electron.exe` 时通过
`--config.electronDist` 直接使用,不再重复下载 150MB+ 发行包;
2. 默认走国内镜像(可用环境变量覆盖):
`ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/`、
`ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/`;
3. 构建缓存固定到仓库内 `.cache/`,不污染全局目录。
产物说明:`release/win-unpacked/班级小助手.exe`(约 235MB,Electron 运行时)+ `resources/app.asar`
(约 2.7MB,仅含 `dist/` 与 `package.json`,运行时不需要 node_modules)。
默认使用 Electron 内置图标(未提供 `build/icon.ico`)。
### 客户端冒烟验证(无需人工点击)
`pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出:
| 校验项 | 实测结果 |
| ------------------------------------- | ----------------------------------------------------------------------------------- |
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 |
| preload contextBridge 注入 | ✅ 6 个方法 |
| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 |
| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` |
| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store |
| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) |
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 5 作业 / 4 通知 / 6 成绩 / 第29周29节课 / Socket.IO 已连接 |
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
已实测 `packaged: true`、9/9 通过、退出码 0)。
## REST API 一览 ## REST API 一览
@@ -283,18 +393,27 @@ pnpm db:migrate && pnpm db:seed
## 验收标准对照 ## 验收标准对照
| 验收项 | 结果 | 证据 | | 验收项 | 结果 | 证据 |
| --------------------------------------------- | --------- | ----------------------------------------------------------------------------------------- | | --------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- |
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | | 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | | 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | | 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | | 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` |
| 断网后客户端可查看缓存数据 | ⏳ 阶段 4 | 依赖 EXE 客户端(IndexedDB 缓存 + 联网后同步) | | 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 |
| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | | 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) |
| 能成功打包 Windows EXE | ⏳ 阶段 4 | 待 EXE 客户端完成后用 electron-builder 打包 | | 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe`(106.9MB) / `-portable.exe`(106.7MB) / `win-unpacked/*.exe` |
| 提供完整 README(启动、构建、打包、默认账号) | 🟡 | 启动/构建/账号已完成;EXE 打包命令随阶段 4 补充 | | 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
当前实测:`pnpm verify:e2e` → **54/54 项通过**;`pnpm typecheck`、`pnpm lint`、`pnpm build` 全部通过。 打包产物验证(对最终 EXE 实测,非仅开发产物):
| 产物 | 大小 | 冒烟结果 |
| ---------------------------------------- | ------- | -------------------------------------------------------------- |
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 9/9,`packaged: true`,退出码 0 |
| `release/…-x64-portable.exe`(单文件) | 106.7MB | ✅ 9/9,含联网集成(登录王小明 / Socket.IO 已连接 / 四类数据) |
| `release/…-x64-setup.exe`(NSIS 安装包) | 106.9MB | ✅ 构建成功(安装后运行同一份 `win-unpacked` 内容) |
当前实测:`pnpm verify:e2e` → **54/54**;`pnpm verify:desktop` → **9/9**;
`pnpm typecheck`、`pnpm lint`、`pnpm build`、`pnpm dist:win` 全部通过。
## 常见问题(本机环境已知坑) ## 常见问题(本机环境已知坑)
@@ -325,10 +444,37 @@ pnpm db:migrate && pnpm db:seed
生成器为 `prisma-client`(输出到 `src/generated/prisma`,已 gitignore), 生成器为 `prisma-client`(输出到 `src/generated/prisma`,已 gitignore),
连接串在 `prisma.config.ts` 中配置,且必须通过 driver adapter 接入数据库。 连接串在 `prisma.config.ts` 中配置,且必须通过 driver adapter 接入数据库。
## 后续计划(阶段 4-6) 6. **Electron 二进制下载慢或失败**
`electron` 的 postinstall 会从 GitHub 拉取约 151MB 发行包,本机实测 10 分钟仍未完成。
规避:安装前设置镜像与缓存目录(本项目已把 electron 缓存指向 `.cache/electron`):
`ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/`。
打包阶段 `scripts/dist-win.mjs` 已内置该镜像,并优先复用本地已解压的 Electron。
1. **阶段 4 · EXE 客户端**:`packages/desktop-client`(Electron 主进程 + Vue 渲染进程), 7. **electron-builder 的构建工具(NSIS / winCodeSign)下载慢**
登录页(服务器地址/用户名/密码)、侧边栏(课表/作业/通知/成绩/设置)、 通过 `ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/`
Socket.IO 实时更新、IndexedDB 离线缓存与联网后自动同步、`electron-builder` 打包 Windows EXE。 解决(已内置到 `dist-win.mjs`),实测 nsis-3.0.4.1 与 nsis-resources 正常下载。
2. **阶段 5**:三端联调脚本(含 EXE 端时延与断网恢复用例)、根目录一键打包命令。
3. **阶段 6**:打包产物分发说明、云端 MySQL 部署清单(Docker Compose)、CI 流水线。 8. **打包未提供图标**
未放置 `packages/desktop-client/build/icon.ico` 时使用 Electron 默认图标。
放入 256×256 及以上尺寸的 `.ico`(或 256×256 PNG)即可自动使用。
## 交付清单(阶段 4-6 新增)
| 路径 | 说明 |
| --------------------------------------------------------------------- | ----------------------------------------------------------- |
| `packages/desktop-client/src/main/*` | Electron 主进程:窗口、单实例、配置持久化、IPC、冒烟验证 |
| `packages/desktop-client/src/preload/index.ts` | contextBridge 安全桥 |
| `packages/desktop-client/src/renderer/api/*` | Axios 封装(动态服务器地址、JWT、离线不弹错) |
| `packages/desktop-client/src/renderer/cache/{db,index}.ts` | IndexedDB 缓存层 + `fetchWithCache` 离线回退 |
| `packages/desktop-client/src/renderer/stores/*` | Pinia:auth / app(可达性与同步)/ realtime / notifications |
| `packages/desktop-client/src/renderer/views/*` | 登录 / 课表 / 作业 / 通知 / 成绩 / 设置 |
| `packages/desktop-client/scripts/{build-main,dev,smoke,dist-win}.mjs` | 构建、开发、冒烟、打包脚本 |
| `packages/desktop-client/electron-builder.yml` | 打包配置(nsis + portable) |
## 后续可选增强
1. 自定义应用图标与安装向导文案(`build/icon.ico`)。
2. 代码签名证书(消除 SmartScreen 提示)。
3. 客户端"提交类操作离线队列"(当前离线为只读,联网后自动同步读取的数据)。
4. 云数据库部署清单(Docker Compose + `prisma migrate deploy`)与 CI 流水线。
5. 课表按当前时间高亮"正在上的课"与上课提醒(可参考 ClassIsland 的课程提醒设计)。
+7 -2
View File
@@ -12,7 +12,9 @@
"dev": "pnpm build:shared && concurrently -k -n shared,server,web -c cyan,green,magenta \"pnpm --filter @classhelper/shared dev\" \"pnpm --filter @classhelper/server dev\" \"pnpm --filter @classhelper/web-admin dev\"", "dev": "pnpm build:shared && concurrently -k -n shared,server,web -c cyan,green,magenta \"pnpm --filter @classhelper/shared dev\" \"pnpm --filter @classhelper/server dev\" \"pnpm --filter @classhelper/web-admin dev\"",
"dev:server": "pnpm build:shared && pnpm --filter @classhelper/server dev", "dev:server": "pnpm build:shared && pnpm --filter @classhelper/server dev",
"dev:web": "pnpm build:shared && pnpm --filter @classhelper/web-admin dev", "dev:web": "pnpm build:shared && pnpm --filter @classhelper/web-admin dev",
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build", "dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev",
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build",
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck", "typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
@@ -25,7 +27,10 @@
"db:studio": "pnpm --filter @classhelper/server db:studio", "db:studio": "pnpm --filter @classhelper/server db:studio",
"db:switch:mysql": "node scripts/use-database.mjs mysql", "db:switch:mysql": "node scripts/use-database.mjs mysql",
"db:switch:sqlite": "node scripts/use-database.mjs sqlite", "db:switch:sqlite": "node scripts/use-database.mjs sqlite",
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e" "verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
@@ -0,0 +1,42 @@
appId: com.classhelper.desktop
productName: 班级小助手
copyright: Copyright © 2026 Class Helper
directories:
output: release
buildResources: build
# 渲染进程产物已由 Vite 打包、主进程/preload 已由 esbuild 打包,
# 运行时不需要任何 node_modules(electron 模块由 Electron 运行时提供)。
files:
- dist/**/*
- package.json
- '!node_modules/**/*'
- '!**/*.map'
asar: true
# 没有原生模块,跳过 rebuild,避免不必要的 node-gyp 依赖
npmRebuild: false
compression: normal
removePackageScripts: true
win:
target:
- target: nsis
arch: [x64]
- target: portable
arch: [x64]
# 未提供 build/icon.ico 时使用 Electron 默认图标
artifactName: ${productName}-${version}-${arch}.${ext}
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: 班级小助手
artifactName: ${productName}-${version}-${arch}-setup.${ext}
portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Electron 渲染进程安全策略:只允许加载本地资源与后端 HTTP/WS 连接 -->
<title>班级小助手</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/renderer/main.ts"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@classhelper/desktop-client",
"version": "0.1.0",
"private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
"main": "dist/main/index.js",
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "npm run build:main && npm run build:renderer",
"build:main": "node scripts/build-main.mjs",
"build:renderer": "vite build",
"start": "electron .",
"smoke": "node scripts/smoke.mjs",
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"dist:win": "npm run build && node scripts/dist-win.mjs",
"dist:dir": "npm run build && node scripts/dist-win.mjs --dir"
},
"dependencies": {
"@classhelper/shared": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.20.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.5",
"pinia": "^4.0.3",
"socket.io-client": "^4.8.3",
"vue": "^3.5.42",
"vue-router": "^5.3.1"
},
"devDependencies": {
"@types/node": "^22.20.2",
"@vitejs/plugin-vue": "^6.0.8",
"electron": "^44.3.0",
"electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"typescript": "~5.9.3",
"vite": "^8.3.0",
"vue-tsc": "^3.3.11"
}
}
@@ -0,0 +1,37 @@
/**
* 主进程 / preload 构建:esbuild 打包为 CommonJS。
* - external: electron(由 Electron 运行时提供)
* - @classhelper/shared 会被内联打包(ESM 源码 -> CJS 产物)
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const shared = {
bundle: true,
platform: 'node',
target: 'node20',
format: 'cjs',
sourcemap: false,
external: ['electron'],
logLevel: 'warning',
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
},
};
await build({
...shared,
entryPoints: [path.join(root, 'src/main/index.ts')],
outfile: path.join(root, 'dist/main/index.js'),
});
await build({
...shared,
entryPoints: [path.join(root, 'src/preload/index.ts')],
outfile: path.join(root, 'dist/preload/index.js'),
});
console.log('[build:main] dist/main/index.js 与 dist/preload/index.js 构建完成');
+67
View File
@@ -0,0 +1,67 @@
/**
* 开发模式:先构建主进程/preload,再启动 Vite 渲染进程开发服务器,
* 等端口就绪后用 Electron 打开开发地址(支持热更新)。
*
* 说明:子进程统一使用 stdio: 'inherit',在受限沙箱中也能正常启动。
*/
import { spawn } from 'node:child_process';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import electronPath from 'electron';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const HOST = '127.0.0.1';
const PORT = 5174;
const DEV_URL = `http://${HOST}:${PORT}`;
const viteBin = path.join(root, 'node_modules', 'vite', 'bin', 'vite.js');
const vite = spawn(process.execPath, [viteBin, '--host', HOST, '--port', String(PORT), '--strictPort'], {
cwd: root,
stdio: 'inherit',
env: process.env,
});
let electron = null;
function cleanup(code = 0) {
if (electron && !electron.killed) electron.kill();
if (!vite.killed) vite.kill();
process.exit(code);
}
function waitForPort(attempt = 0) {
const socket = net.connect({ host: HOST, port: PORT });
socket.once('connect', () => {
socket.destroy();
startElectron();
});
socket.once('error', () => {
socket.destroy();
if (attempt > 120) {
console.error('[dev] 等待 Vite 开发服务器超时');
cleanup(1);
return;
}
setTimeout(() => waitForPort(attempt + 1), 250);
});
}
function startElectron() {
console.log(`[dev] Vite 已就绪,启动 Electron -> ${DEV_URL}`);
electron = spawn(electronPath, ['.'], {
cwd: root,
stdio: 'inherit',
env: { ...process.env, VITE_DEV_SERVER_URL: DEV_URL },
});
electron.on('exit', (code) => cleanup(code ?? 0));
}
vite.on('exit', (code) => {
if (code !== 0) console.error(`[dev] Vite 退出,code=${code}`);
});
process.on('SIGINT', () => cleanup(0));
process.on('SIGTERM', () => cleanup(0));
waitForPort();
@@ -0,0 +1,63 @@
/**
* Windows 打包入口(等价于 electron-builder --win,但做了三件工程化处理):
*
* 1) 复用本地已解压的 Electron:node_modules/electron/dist 存在时通过
* --config.electronDist 直接使用,避免再下载 150MB+ 发行包(受网络影响最大的步骤)。
* 2) 默认使用国内镜像(可用环境变量覆盖):
* ELECTRON_MIRROR Electron 发行包
* ELECTRON_BUILDER_BINARIES_MIRROR nsis / winCodeSign 等构建工具
* 3) 缓存固定到仓库内的 .cache 目录,便于清理与迁移,不污染用户全局目录。
*
* 用法:
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..');
const args = process.argv.slice(2);
const dirOnly = args.includes('--dir');
const passthrough = args.filter((item) => item !== '--dir');
const env = {
...process.env,
ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/',
ELECTRON_BUILDER_BINARIES_MIRROR:
process.env.ELECTRON_BUILDER_BINARIES_MIRROR ??
'https://npmmirror.com/mirrors/electron-builder-binaries/',
ELECTRON_BUILDER_CACHE:
process.env.ELECTRON_BUILDER_CACHE ?? path.join(repoRoot, '.cache', 'electron-builder'),
ELECTRON_CACHE: process.env.ELECTRON_CACHE ?? path.join(repoRoot, '.cache', 'electron'),
};
const localElectronDist = path.join(root, 'node_modules', 'electron', 'dist');
const overrides = [];
if (fs.existsSync(path.join(localElectronDist, 'electron.exe'))) {
overrides.push(`--config.electronDist=${localElectronDist}`);
console.log('[dist] 复用本地已解压的 Electron:', localElectronDist);
} else {
console.log('[dist] 未找到本地 Electron,将由 electron-builder 下载(镜像:' + env.ELECTRON_MIRROR + ')');
}
const cli = path.join(root, 'node_modules', 'electron-builder', 'out', 'cli', 'cli.js');
if (!fs.existsSync(cli)) {
console.error('[dist] 未找到 electron-builder,请先执行 pnpm install');
process.exit(2);
}
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
const child = spawn(process.execPath, cliArgs, { cwd: root, stdio: 'inherit', env });
child.on('exit', (code) => {
if (code === 0) {
const output = path.join(root, 'release');
console.log('\n[dist] 打包完成,产物目录:', output);
}
process.exit(code ?? 1);
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Electron 冒烟验证:以 ELECTRON_SMOKE_TEST=1 启动应用(不弹窗),
* 主进程会依次校验 preload 桥接、渲染进程挂载、登录页 DOM、
* IPC 往返、IndexedDB 缓存读写、断网回退缓存,然后自动退出。
*
* 前置条件:已执行 npm run build(需要 dist/main 与 dist/renderer)。
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import electronPath from 'electron';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
const missing = required.filter((file) => !fs.existsSync(file));
if (missing.length > 0) {
console.error('[smoke] 缺少构建产物,请先执行构建:');
console.error(' pnpm --filter @classhelper/desktop-client build');
for (const file of missing) console.error(` 缺失: ${path.relative(root, file)}`);
process.exit(2);
}
console.log('[smoke] 启动 Electron 冒烟验证...\n');
const child = spawn(electronPath, ['.'], {
cwd: root,
stdio: 'inherit',
env: { ...process.env, ELECTRON_SMOKE_TEST: '1' },
});
child.on('exit', (code) => {
console.log(`\n[smoke] Electron 退出,code=${code ?? 1}`);
process.exit(code ?? 1);
});
+110
View File
@@ -0,0 +1,110 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
import type { DesktopStoredConfig } from '../types/desktop.js';
interface PersistedConfig {
serverUrl: string;
username: string;
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
token: string | null;
tokenEncrypted: boolean;
}
const DEFAULT_CONFIG: PersistedConfig = {
serverUrl: DEFAULT_SERVER_URL,
username: '',
token: null,
tokenEncrypted: false,
};
function configFilePath(): string {
return path.join(app.getPath('userData'), 'config.json');
}
function encryptToken(token: string | null): { token: string | null; tokenEncrypted: boolean } {
if (!token) return { token: null, tokenEncrypted: false };
try {
if (safeStorage.isEncryptionAvailable()) {
return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true };
}
} catch {
// 某些环境下 DPAPI 不可用,降级为明文存储(仅本地开发场景)
}
return { token, tokenEncrypted: false };
}
function decryptToken(raw: PersistedConfig): string | null {
if (!raw.token) return null;
if (!raw.tokenEncrypted) return raw.token;
try {
return safeStorage.decryptString(Buffer.from(raw.token, 'base64'));
} catch {
return null;
}
}
function readPersisted(): PersistedConfig {
try {
const content = fs.readFileSync(configFilePath(), 'utf8');
const parsed = JSON.parse(content) as Partial<PersistedConfig>;
return {
serverUrl:
typeof parsed.serverUrl === 'string' && parsed.serverUrl
? parsed.serverUrl
: DEFAULT_CONFIG.serverUrl,
username: typeof parsed.username === 'string' ? parsed.username : '',
token: typeof parsed.token === 'string' ? parsed.token : null,
tokenEncrypted: parsed.tokenEncrypted === true,
};
} catch {
return { ...DEFAULT_CONFIG };
}
}
function writePersisted(config: PersistedConfig): void {
const target = configFilePath();
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
}
/** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig {
const persisted = readPersisted();
return {
serverUrl: persisted.serverUrl,
username: persisted.username,
token: decryptToken(persisted),
};
}
/** 局部更新配置:只覆盖传入的字段 */
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
const persisted = readPersisted();
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
persisted.serverUrl = patch.serverUrl.trim().replace(/\/+$/, '');
}
if (typeof patch.username === 'string') {
persisted.username = patch.username;
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
persisted.tokenEncrypted = encrypted.tokenEncrypted;
}
writePersisted(persisted);
return getConfig();
}
/** 清空为默认配置(退出登录 / 重置) */
export function clearConfig(): DesktopStoredConfig {
writePersisted({ ...DEFAULT_CONFIG });
return getConfig();
}
export function getConfigPath(): string {
return configFilePath();
}
+102
View File
@@ -0,0 +1,102 @@
import path from 'node:path';
import { BrowserWindow, app, shell } from 'electron';
import { registerIpcHandlers } from './ipc.js';
import { runSmokeTest } from './smoke.js';
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
let mainWindow: BrowserWindow | null = null;
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 980,
minHeight: 640,
title: '班级小助手',
// 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest,
autoHideMenuBar: true,
backgroundColor: '#f5f7fa',
webPreferences: {
preload: path.join(currentDir, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
});
// 外部链接交给系统浏览器,禁止在应用内新开窗口
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (event, url) => {
const allowed = devServerUrl ? url.startsWith(devServerUrl) : url.startsWith('file://');
if (!allowed) {
event.preventDefault();
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
}
});
win.on('closed', () => {
mainWindow = null;
});
return win;
}
async function loadRenderer(win: BrowserWindow): Promise<void> {
if (devServerUrl) {
await win.loadURL(devServerUrl);
win.webContents.openDevTools({ mode: 'detach' });
return;
}
// 生产:加载打包后的本地 HTML(vite base 为 './',使用 file:// 相对路径)
await win.loadFile(path.join(currentDir, '../renderer/index.html'));
}
// 单实例:重复启动时聚焦已有窗口
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
app.whenReady().then(async () => {
registerIpcHandlers();
mainWindow = createWindow();
await loadRenderer(mainWindow);
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
} catch (error) {
console.error('[SMOKE] 冒烟验证异常', error);
app.exit(1);
}
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
await loadRenderer(mainWindow);
}
});
}
+45
View File
@@ -0,0 +1,45 @@
import { app, ipcMain, shell } from 'electron';
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
export const IPC_CHANNELS = {
getConfig: 'classhelper:config:get',
saveConfig: 'classhelper:config:save',
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
} as const;
export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
ipcMain.handle(
IPC_CHANNELS.saveConfig,
(_event, patch: Partial<DesktopStoredConfig>): DesktopStoredConfig => {
return saveConfig(patch ?? {});
},
);
ipcMain.handle(IPC_CHANNELS.clearConfig, (): DesktopStoredConfig => clearConfig());
ipcMain.handle(IPC_CHANNELS.appInfo, (): DesktopAppInfo => ({
appVersion: app.getVersion(),
electron: process.versions.electron ?? '',
chrome: process.versions.chrome ?? '',
node: process.versions.node ?? '',
platform: `${process.platform} ${process.arch}`,
userDataPath: app.getPath('userData'),
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
}));
ipcMain.handle(IPC_CHANNELS.openExternal, async (_event, url: string): Promise<boolean> => {
if (!/^https?:\/\//i.test(url)) return false;
await shell.openExternal(url);
return true;
});
}
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
return { configPath: getConfigPath(), config: getConfig() };
}
+161
View File
@@ -0,0 +1,161 @@
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
import type { BrowserWindow } from 'electron';
import { getDiagnostics } from './ipc.js';
interface SmokeResult {
name: string;
ok: boolean;
detail: string;
}
/**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
* 以及可选的联网集成(ELECTRON_SMOKE_ONLINE=1)。
*
* 结果同时输出到 stdout,并在设置 ELECTRON_SMOKE_RESULT 时写入 JSON 文件 ——
* 打包后的 Windows GUI 进程没有 stdout,用结果文件才能在 CI/脚本里验证安装包。
*/
export async function runSmokeTest(win: BrowserWindow): Promise<void> {
const results: SmokeResult[] = [];
const record = (name: string, ok: boolean, detail = ''): void => {
results.push({ name, ok, detail });
console.log(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ` - ${detail}` : ''}`);
};
// 等待 Vue 挂载并完成启动流程(离线时最多等 14 秒)
let dom: {
title: string;
appChildren: number;
hasLoginPage: boolean;
hasInput: boolean;
text: string;
} | null = null;
for (let attempt = 0; attempt < 56; attempt += 1) {
dom = await win.webContents.executeJavaScript(
`(() => {
const app = document.querySelector('#app');
return {
title: document.title,
appChildren: app ? app.children.length : 0,
hasLoginPage: Boolean(document.querySelector('.login-page')),
hasInput: Boolean(document.querySelector('input')),
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 80),
};
})()`,
);
// 等到登录页真正渲染出来(而不是停在启动过渡页)
if (dom?.hasLoginPage && dom?.hasInput) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
record(
'窗口标题正确',
dom?.title === '班级小助手' || (dom?.title ?? '').includes('班级小助手'),
`title=${dom?.title}`,
);
record(
'登录页渲染(服务器地址/用户名/密码输入框)',
Boolean(dom?.hasLoginPage && dom?.hasInput),
`text=${dom?.text ?? ''}`,
);
// preload 桥接
const bridge = await win.webContents.executeJavaScript(
`(() => ({
hasBridge: typeof window.desktop === 'object' && window.desktop !== null,
methods: Object.keys(window.desktop ?? {}).sort(),
}))()`,
);
record(
'preload contextBridge 注入',
Boolean(bridge?.hasBridge) && Array.isArray(bridge?.methods) && bridge.methods.includes('getConfig'),
`methods=${(bridge?.methods ?? []).join(',')}`,
);
// IPC 往返(渲染进程 <-> 主进程)
const ipcRoundTrip = await win.webContents.executeJavaScript(
`(async () => {
const info = await window.desktop.getAppInfo();
const saved = await window.desktop.saveConfig({ serverUrl: 'http://127.0.0.1:4000' });
return { electron: info.electron, hasSmokeFlag: info.smokeTest === true, serverUrl: saved.serverUrl, userData: info.userDataPath };
})()`,
);
record(
'IPC 调用主进程(getAppInfo / saveConfig)',
Boolean(ipcRoundTrip?.electron) && ipcRoundTrip?.serverUrl === 'http://127.0.0.1:4000',
`electron=${ipcRoundTrip?.electron} serverUrl=${ipcRoundTrip?.serverUrl}`,
);
const diagnostics = getDiagnostics();
record('配置文件已写入用户目录', diagnostics.config.serverUrl.length > 0, diagnostics.configPath);
// IndexedDB 缓存自检
const cacheSelfTest = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.cacheSelfTest();
})()`,
);
record('IndexedDB 缓存读写', Boolean(cacheSelfTest?.ok), String(cacheSelfTest?.detail ?? ''));
// 离线回退场景
const offline = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.offlineScenario();
})()`,
);
record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
const online = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.onlineScenario();
})()`,
);
record(
'联网集成(登录 + 四类数据 + Socket.IO + 缓存写入)',
Boolean(online?.ok),
String(online?.detail ?? ''),
);
}
const failed = results.filter((item) => !item.ok);
const passed = results.length - failed.length;
console.log(`\n[SMOKE] ${passed}/${results.length} 项通过`);
const resultFile = process.env.ELECTRON_SMOKE_RESULT;
if (resultFile) {
try {
fs.mkdirSync(path.dirname(resultFile), { recursive: true });
fs.writeFileSync(
resultFile,
JSON.stringify(
{
passed,
total: results.length,
ok: failed.length === 0,
results,
finishedAt: new Date().toISOString(),
appVersion: app.getVersion(),
packaged: app.isPackaged,
},
null,
2,
),
'utf8',
);
console.log(`[SMOKE] 结果已写入 ${resultFile}`);
} catch (error) {
console.error('[SMOKE] 写入结果文件失败', error);
}
}
app.exit(failed.length === 0 ? 0 : 1);
}
@@ -0,0 +1,19 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../main/ipc.js';
import type { DesktopAppInfo, DesktopBridge, DesktopStoredConfig } from '../types/desktop.js';
/**
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
*/
const bridge: DesktopBridge = {
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
clearConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.clearConfig),
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url),
};
contextBridge.exposeInMainWorld('desktop', bridge);
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useRealtimeStore } from './stores/realtime.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const booting = ref(true);
const bootText = ref('正在读取本地配置...');
/**
* 启动流程:
* 1) 从主进程配置恢复服务器地址与登录令牌
* 2) 先用缓存资料渲染,再尝试向服务器刷新(不可达则进入离线会话)
* 3) 已登录则建立 Socket.IO 连接并进入主界面
*/
onMounted(async () => {
try {
bootText.value = '正在恢复登录状态...';
const session = await auth.restore();
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
void appStore.init(session.serverUrl);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
} finally {
booting.value = false;
}
});
</script>
<template>
<div v-if="booting" class="boot-screen">
<el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon>
<p class="boot-title">班级小助手</p>
<p class="boot-text">{{ bootText }}</p>
</div>
<router-view v-else />
</template>
<style scoped>
.boot-screen {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: linear-gradient(135deg, #1f2d3d 0%, #3a5169 60%, #409eff 100%);
color: #fff;
}
.boot-title {
margin: 6px 0 0;
font-size: 20px;
font-weight: 600;
}
.boot-text {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
}
</style>
@@ -0,0 +1,95 @@
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
import { ElMessage } from 'element-plus';
import type { ApiErrorResponse, ApiResponse } from '@classhelper/shared';
import { apiBaseOf } from '../config.js';
export const http = axios.create({ timeout: 15000 });
/** 服务器地址变化时更新 baseURL(登录页/设置页可改) */
export function setApiBaseUrl(serverUrl: string): void {
http.defaults.baseURL = apiBaseOf(serverUrl);
}
let tokenProvider: () => string | null = () => null;
export function setTokenProvider(provider: () => string | null): void {
tokenProvider = provider;
}
let unauthorizedHandler: () => void = () => undefined;
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler;
}
let reachabilityReporter: (reachable: boolean) => void = () => undefined;
export function setReachabilityReporter(reporter: (reachable: boolean) => void): void {
reachabilityReporter = reporter;
}
/** 判断是否为「网络不可达」(用于离线判定,不弹提示) */
export function isNetworkError(error: unknown): boolean {
const axiosError = error as AxiosError | undefined;
return Boolean(axiosError && !axiosError.response);
}
http.interceptors.request.use((config) => {
const token = tokenProvider();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
http.interceptors.response.use(
(response) => {
reachabilityReporter(true);
const body = response.data as ApiResponse<unknown> | undefined;
if (body && typeof body === 'object' && 'success' in body) {
if (!body.success) {
ElMessage.error(body.message || '请求失败');
return Promise.reject(new Error(body.message || '请求失败'));
}
return body.data as never;
}
return response.data as never;
},
(error: AxiosError<ApiErrorResponse>) => {
// 网络不可达:交给调用方走离线缓存,不打扰用户
if (!error.response) {
reachabilityReporter(false);
return Promise.reject(error);
}
reachabilityReporter(true);
const status = error.response.status;
const message = error.response.data?.message ?? `请求失败(${status})`;
if (status === 401) {
ElMessage.warning('登录状态已失效,请重新登录');
unauthorizedHandler();
} else {
ElMessage.error(message);
}
return Promise.reject(error);
},
);
async function request<T>(config: AxiosRequestConfig): Promise<T> {
return (await http.request(config)) as unknown as T;
}
export const api = {
get: <T>(url: string, params?: Record<string, unknown>): Promise<T> =>
request<T>({ method: 'GET', url, params }),
post: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'POST', url, data }),
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }),
delete: <T>(url: string): Promise<T> => request<T>({ method: 'DELETE', url }),
};
/** 健康检查(用于可达性轮询,不弹任何提示) */
export async function pingHealth(): Promise<{ ok: boolean; status?: string }> {
try {
const base = http.defaults.baseURL ?? '';
const response = await axios.get(`${base}/health`, { timeout: 5000 });
return { ok: true, status: response.data?.data?.status as string | undefined };
} catch {
return { ok: false };
}
}
@@ -0,0 +1,61 @@
import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type DashboardSummary,
type GradeDto,
type HomeworkDto,
type HomeworkStatusDto,
type LoginRequest,
type LoginResponse,
type NotificationDto,
type ScheduleDto,
type ScheduleWeekView,
type StudentDto,
} from '@classhelper/shared';
import { api } from './http.js';
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
export const authApi = {
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
};
export const classApi = {
list: (): Promise<ClassDto[]> => api.get(API_PATHS.classes),
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
};
export const scheduleApi = {
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params),
grid: (params: { classId?: string; week?: number }): Promise<ScheduleWeekView> =>
api.get(`${API_PATHS.schedules}/grid`, params),
};
export const homeworkApi = {
list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise<HomeworkDto[]> =>
api.get(API_PATHS.homeworks, params),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
};
export const notificationApi = {
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
api.get(API_PATHS.notifications, params),
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> =>
api.post(`${API_PATHS.notifications}/${id}/read`, {}),
markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
};
export const gradeApi = {
my: (): Promise<GradeDto[]> => api.get(`${API_PATHS.grades}/my`),
};
export const dashboardApi = {
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
};
+133
View File
@@ -0,0 +1,133 @@
/**
* IndexedDB 缓存层(无第三方依赖)。
*
* 设计:
* - 一个数据库 classhelper-cache,按业务域拆分为多个 object store
* - 每条记录 = { key, value, updatedAt },key 形如 `${classId}` / `${classId}:${week}`
* - 所有操作都包成 Promise;任何一步失败都不会抛出到 UI(离线时降级为空)
*/
export const CACHE_STORES = [
'profile',
'classes',
'schedules',
'homeworks',
'notifications',
'grades',
] as const;
export type CacheStore = (typeof CACHE_STORES)[number];
export interface CacheRecord<T> {
key: string;
value: T;
updatedAt: number;
}
export interface CacheStoreStat {
store: CacheStore;
count: number;
updatedAt: number | null;
}
const DB_NAME = 'classhelper-cache';
const DB_VERSION = 1;
let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
if (databasePromise) return databasePromise;
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
for (const store of CACHE_STORES) {
if (!db.objectStoreNames.contains(store)) {
db.createObjectStore(store, { keyPath: 'key' });
}
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 打开失败'));
request.onblocked = () => reject(new Error('IndexedDB 被其它窗口占用'));
});
return databasePromise;
}
function runRequest<T>(
store: CacheStore,
mode: IDBTransactionMode,
action: (objectStore: IDBObjectStore) => IDBRequest,
): Promise<T> {
return openDatabase().then(
(db) =>
new Promise<T>((resolve, reject) => {
const transaction = db.transaction(store, mode);
const request = action(transaction.objectStore(store));
request.onsuccess = () => resolve(request.result as T);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 操作失败'));
}),
);
}
/**
* 转成可被 IndexedDB 结构化克隆的纯数据。
*
* 关键:Vue 的 reactive/ref 包装对象是 Proxy,直接 put 会抛
* "could not be cloned"。缓存内容都是后端 JSON 响应,因此用 JSON 往返即可,
* 既去掉代理又保证与 DTO(日期为 ISO 字符串)一致。
*/
function toPlain<T>(value: T): T {
if (value === undefined) return null as unknown as T;
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value;
}
}
export async function cacheSet<T>(store: CacheStore, key: string, value: T): Promise<void> {
const record: CacheRecord<T> = { key, value: toPlain(value), updatedAt: Date.now() };
await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record));
}
export function cacheGet<T>(store: CacheStore, key: string): Promise<CacheRecord<T> | null> {
return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) =>
objectStore.get(key),
).then((record) => record ?? null);
}
export function cacheList<T>(store: CacheStore): Promise<Array<CacheRecord<T>>> {
return runRequest<Array<CacheRecord<T>>>(store, 'readonly', (objectStore) => objectStore.getAll());
}
export async function cacheRemove(store: CacheStore, key: string): Promise<void> {
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(key));
}
export async function cacheClearStore(store: CacheStore): Promise<void> {
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.clear());
}
export async function cacheClearAll(): Promise<void> {
for (const store of CACHE_STORES) {
await cacheClearStore(store);
}
}
/** 各业务域的条目数与最近更新时间,用于「设置」页展示离线缓存状况 */
export async function cacheStats(): Promise<CacheStoreStat[]> {
const stats: CacheStoreStat[] = [];
for (const store of CACHE_STORES) {
const records = await cacheList<unknown>(store);
const latest = records.reduce<number | null>(
(max, record) => (max === null || record.updatedAt > max ? record.updatedAt : max),
null,
);
stats.push({ store, count: records.length, updatedAt: latest });
}
return stats;
}
+54
View File
@@ -0,0 +1,54 @@
import { cacheGet, cacheSet, type CacheStore } from './db.js';
export interface CachedFetchResult<T> {
data: T;
/** true 表示本次数据来自本地缓存(网络失败或未联网) */
fromCache: boolean;
/** 缓存写入时间(来自缓存时有效) */
updatedAt: number | null;
/** 网络失败原因(成功时为 null) */
error: string | null;
}
/**
* 带离线回退的取数:
* 1) 先请求网络,成功则写入缓存并返回
* 2) 网络失败则读取缓存;缓存缺失时返回调用方提供的兜底值
*
* 「联网后自动同步」由调用方在重连事件里再次调用本函数完成。
*/
export async function fetchWithCache<T>(
store: CacheStore,
key: string,
loader: () => Promise<T>,
fallback: T,
): Promise<CachedFetchResult<T>> {
try {
const data = await loader();
await cacheSet(store, key, data);
return { data, fromCache: false, updatedAt: Date.now(), error: null };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const cached = await cacheGet<T>(store, key).catch(() => null);
if (cached) {
return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: message };
}
return { data: fallback, fromCache: false, updatedAt: null, error: message };
}
}
/** 只读缓存(不再发请求),用于启动时先渲染旧数据 */
export async function readCache<T>(
store: CacheStore,
key: string,
fallback: T,
): Promise<CachedFetchResult<T>> {
const cached = await cacheGet<T>(store, key).catch(() => null);
if (cached) return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: null };
return { data: fallback, fromCache: false, updatedAt: null, error: null };
}
/** 把实时推送的数据直接写入缓存,保证离线时看到的是最新内容 */
export async function writeCache<T>(store: CacheStore, key: string, value: T): Promise<void> {
await cacheSet(store, key, value);
}
+152
View File
@@ -0,0 +1,152 @@
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
import { fetchWithCache } from './index.js';
export interface SmokeCheckResult {
ok: boolean;
detail: string;
}
/**
* 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。
* 由主进程通过 webContents.executeJavaScript 调用。
*/
export async function cacheSelfTest(): Promise<SmokeCheckResult> {
const key = '__smoke_selftest__';
const payload = { text: 'ok', at: Date.now() };
await cacheSet('profile', key, payload);
const readBack = await cacheGet<typeof payload>('profile', key);
const stats = await cacheStats();
await cacheRemove('profile', key);
const removed = await cacheGet<typeof payload>('profile', key);
const ok = readBack?.value.text === 'ok' && removed === null && stats.length > 0;
return {
ok,
detail: `readBack=${readBack?.value.text ?? '-'} removed=${removed === null} stores=${stats.length}`,
};
}
/**
* 冒烟自检 2:断网回退。
* 先写入缓存,再向一个不存在的端口发请求(必然失败),
* 验证 fetchWithCache 能回退到本地缓存。
*/
export async function offlineScenario(): Promise<SmokeCheckResult> {
const key = '__smoke_offline__';
const payload = [{ id: 'offline-1', title: '离线缓存中的作业' }];
await cacheSet('homeworks', key, payload);
const result = await fetchWithCache<Array<{ id: string; title: string }>>(
'homeworks',
key,
async () => {
const response = await fetch('http://127.0.0.1:59999/api/homeworks');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as Array<{ id: string; title: string }>;
},
[],
);
await cacheRemove('homeworks', key);
const ok = result.fromCache === true && result.data.length === 1 && Boolean(result.error);
return {
ok,
detail: `fromCache=${result.fromCache} items=${result.data.length} error=${result.error ?? '-'}`,
};
}
/**
* 冒烟自检 3(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
*/
export async function onlineScenario(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
import('../stores/auth.js'),
import('../stores/app.js'),
import('../stores/realtime.js'),
import('../api/index.js'),
import('./index.js'),
]);
const auth = useAuthStore();
const appStore = useAppStore();
const realtime = useRealtimeStore();
const serverUrl = 'http://127.0.0.1:4000';
const detail: string[] = [];
try {
await auth.login(serverUrl, 'student01', 'student123');
detail.push(`login=${auth.user?.name ?? '-'}`);
await appStore.init(serverUrl);
detail.push(`reachable=${appStore.serverReachable}`);
const [classes, homeworks, notifications, grades] = await Promise.all([
api.classApi.list(),
api.homeworkApi.list(),
api.notificationApi.list(),
api.gradeApi.my(),
]);
const term = await api.dashboardApi.term();
const schedules = await api.scheduleApi.list({ week: term.currentWeek });
detail.push(`classes=${classes.length}`);
detail.push(`homeworks=${homeworks.length}`);
detail.push(`notifications=${notifications.length}`);
detail.push(`grades=${grades.length}`);
detail.push(`week${term.currentWeek}Schedules=${schedules.length}`);
// 写入缓存(离线可用的前提)
await cache.writeCache('homeworks', 'self', homeworks);
await cache.writeCache('grades', 'my', grades);
await cache.writeCache('notifications', 'self', notifications);
// Socket.IO:连接并等待服务端回执
if (auth.token) {
realtime.connect(serverUrl, auth.token);
const connected = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 8000);
const handler = (): void => {
clearTimeout(timer);
resolve(true);
};
realtime.on('__connected__', handler);
if (realtime.connected) handler();
});
detail.push(`socket=${connected}`);
if (!connected) {
realtime.disconnect();
await auth.logout();
return { ok: false, detail: `${detail.join(' ')} (实时通道未连接)` };
}
}
const ok =
classes.length === 1 &&
schedules.length > 0 &&
homeworks.length > 0 &&
notifications.length > 0 &&
grades.length > 0 &&
realtime.connected;
realtime.disconnect();
await auth.logout(); // 清理登录态,保证下次冒烟从登录页开始
return { ok, detail: detail.join(' ') };
} catch (error) {
realtime.disconnect();
await auth.logout().catch(() => undefined);
return {
ok: false,
detail: `${detail.join(' ')} error=${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 注册到 window,供主进程冒烟脚本调用 */
export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = { cacheSelfTest, offlineScenario, onlineScenario };
}
@@ -0,0 +1,26 @@
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
/** 默认后端地址(首次启动的预填值) */
export const DEFAULT_SERVER = DEFAULT_SERVER_URL;
/** 由服务器地址推导 API 基地址,例如 http://127.0.0.1:4000 -> http://127.0.0.1:4000/api */
export function apiBaseOf(serverUrl: string): string {
return `${serverUrl.trim().replace(/\/+$/, '')}/api`;
}
/** 由服务器地址推导 Socket.IO 地址 */
export function socketUrlOf(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, '');
}
/** 校验用户输入的服务器地址 */
export function normalizeServerUrl(input: string): string | null {
const value = input.trim().replace(/\/+$/, '');
if (!/^https?:\/\/[^\s]+$/i.test(value)) return null;
try {
const url = new URL(value);
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const menuItems = [
{ name: 'schedule', title: '课表', icon: 'Calendar' },
{ name: 'homeworks', title: '作业', icon: 'Notebook' },
{ name: 'notifications', title: '通知', icon: 'Bell' },
{ name: 'grades', title: '成绩', icon: 'Trophy' },
{ name: 'settings', title: '设置', icon: 'Setting' },
];
const activeMenu = computed(() => `/${String(route.name ?? 'schedule')}`);
const connectionType = computed(() => {
if (realtime.connected) return 'success';
if (appStore.serverReachable) return 'warning';
return 'danger';
});
const connectionText = computed(() => {
if (realtime.connected) return '实时连接正常';
if (appStore.serverReachable) return '服务器可达(实时通道重连中)';
return '离线模式 · 显示缓存数据';
});
function go(name: string): void {
void router.push({ name });
}
async function handleLogout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
void router.push({ name: 'login' });
}
/** 通知实时推送:更新列表 + 红点 + 缓存 */
function onNotification(payload: unknown): void {
void notifications.pushRealtime(payload as NotificationDto);
ElMessage({ message: '收到新通知,点击「通知」查看', type: 'info', duration: 3000 });
}
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
function onRecovered(): void {
ElMessage.success('已重新连接服务器,正在同步最新数据');
void notifications.load();
}
onMounted(async () => {
await notifications.load().catch(() => undefined);
await notifications.refreshUnreadCount();
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<el-container class="layout">
<el-aside width="190px" class="aside">
<div class="brand">
<el-icon :size="20"><School /></el-icon>
<span>班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="menu" @select="go">
<el-menu-item v-for="item in menuItems" :key="item.name" :index="`/${item.name}`">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
<el-badge
v-if="item.name === 'notifications' && notifications.unreadCount > 0"
:value="notifications.unreadCount"
class="menu-badge"
/>
</el-menu-item>
</el-menu>
<div class="aside-footer">
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
</div>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-tag :type="connectionType" size="small" effect="light">{{ connectionText }}</el-tag>
<el-tag v-if="auth.offlineSession" type="warning" size="small" effect="plain">离线会话</el-tag>
<span class="server-url">{{ appStore.serverUrl }}</span>
</div>
<div class="header-right">
<el-dropdown trigger="click">
<span class="user-chip">
<el-icon><UserFilled /></el-icon>
{{ auth.displayName }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="go('settings')">
<el-icon><Setting /></el-icon>
设置
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main">
<el-alert
v-if="appStore.offline && appStore.initialized"
class="offline-banner"
type="warning"
:closable="false"
show-icon
title="当前处于离线状态"
:description="`服务器不可达,正在显示本地缓存数据(最近同步:${appStore.lastSyncText})。恢复网络后会自动同步。`"
/>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.layout {
height: 100vh;
}
.aside {
background: var(--ch-sidebar);
color: #fff;
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 54px;
padding: 0 16px;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.menu {
flex: 1;
border-right: none;
background: transparent;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #fff;
}
.menu :deep(.el-menu-item) {
margin: 2px 8px;
border-radius: 6px;
}
.menu :deep(.el-menu-item.is-active) {
background: #409eff;
}
.menu-badge {
margin-left: 10px;
}
.aside-footer {
padding: 10px 16px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.aside-meta {
font-size: 12px;
color: #8fa3b8;
line-height: 1.8;
}
.header {
background: #fff;
border-bottom: 1px solid var(--ch-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.server-url {
font-size: 12px;
color: #909399;
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
outline: none;
}
.main {
background: var(--ch-bg);
padding: 0;
overflow-y: auto;
}
</style>
@@ -0,0 +1,29 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router/index.js';
import { registerSmokeHooks } from './cache/self-test.js';
import './styles/index.css';
const app = createApp(App);
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)
if (window.desktop?.smokeTest) {
registerSmokeHooks();
}
await router.isReady();
app.mount('#app');
@@ -0,0 +1,70 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth.js';
/**
* Electron 生产环境通过 file:// 加载页面,无法使用 history 模式,
* 因此统一使用 hash 路由(开发与打包行为一致)。
*/
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { public: true, title: '登录' },
},
{
path: '/',
component: () => import('../layouts/ClientLayout.vue'),
redirect: '/schedule',
children: [
{
path: 'schedule',
name: 'schedule',
component: () => import('../views/ScheduleView.vue'),
meta: { title: '课表' },
},
{
path: 'homeworks',
name: 'homeworks',
component: () => import('../views/HomeworkView.vue'),
meta: { title: '作业' },
},
{
path: 'notifications',
name: 'notifications',
component: () => import('../views/NotificationView.vue'),
meta: { title: '通知' },
},
{
path: 'grades',
name: 'grades',
component: () => import('../views/GradeView.vue'),
meta: { title: '成绩' },
},
{
path: 'settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
meta: { title: '设置' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/schedule' },
];
export const router = createRouter({
history: createWebHashHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.public) {
if (to.name === 'login' && auth.isAuthenticated) return { name: 'schedule' };
return true;
}
if (!auth.isAuthenticated) return { name: 'login' };
return true;
});
export default router;
@@ -0,0 +1,132 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
type RecoveryHandler = () => void;
/**
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
*/
export const useAppStore = defineStore('app', () => {
const serverUrl = ref(DEFAULT_SERVER);
const serverReachable = ref(false);
const lastSyncAt = ref<number | null>(null);
const cacheStoreStats = ref<CacheStoreStat[]>([]);
const currentWeek = ref(1);
const maxWeek = ref(20);
const initialized = ref(false);
const offline = computed(() => !serverReachable.value);
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
const cacheItemCount = computed(() => cacheStoreStats.value.reduce((sum, item) => sum + item.count, 0));
const recoveryHandlers = new Set<RecoveryHandler>();
let healthTimer: ReturnType<typeof setInterval> | null = null;
function onServerRecovered(handler: RecoveryHandler): void {
recoveryHandlers.add(handler);
}
function offServerRecovered(handler: RecoveryHandler): void {
recoveryHandlers.delete(handler);
}
/** 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性 */
setReachabilityReporter((reachable) => {
if (reachable && !serverReachable.value && initialized.value) {
serverReachable.value = true;
notifyRecovered();
return;
}
serverReachable.value = reachable;
});
function notifyRecovered(): void {
for (const handler of recoveryHandlers) handler();
}
async function ping(): Promise<void> {
const result = await pingHealth();
if (result.ok) {
if (!serverReachable.value && initialized.value) notifyRecovered();
serverReachable.value = true;
} else {
serverReachable.value = false;
}
}
function startHealthPolling(): void {
if (healthTimer) return;
healthTimer = setInterval(() => {
void ping();
}, 20000);
}
function stopHealthPolling(): void {
if (!healthTimer) return;
clearInterval(healthTimer);
healthTimer = null;
}
async function applyServerUrl(next: string): Promise<boolean> {
const normalized = normalizeServerUrl(next);
if (!normalized) return false;
serverUrl.value = normalized;
setApiBaseUrl(normalized);
localStorage.setItem(STORAGE_KEYS.serverUrl, normalized);
if (window.desktop) await window.desktop.saveConfig({ serverUrl: normalized });
return true;
}
async function refreshCacheStats(): Promise<void> {
try {
cacheStoreStats.value = await cacheStats();
} catch {
cacheStoreStats.value = [];
}
}
async function clearCache(): Promise<void> {
await cacheClearAll();
await refreshCacheStats();
}
async function init(serverFromConfig: string): Promise<void> {
if (serverFromConfig) await applyServerUrl(serverFromConfig);
await refreshCacheStats();
await ping();
startHealthPolling();
initialized.value = true;
}
function markSynced(): void {
lastSyncAt.value = Date.now();
void refreshCacheStats();
}
return {
serverUrl,
serverReachable,
offline,
lastSyncAt,
lastSyncText,
cacheStoreStats,
cacheItemCount,
currentWeek,
maxWeek,
initialized,
init,
ping,
applyServerUrl,
refreshCacheStats,
clearCache,
markSynced,
onServerRecovered,
offServerRecovered,
stopHealthPolling,
};
});
@@ -0,0 +1,131 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, type LoginResponse, type StudentDto } from '@classhelper/shared';
import { authApi } from '../api/index.js';
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
import { cacheGet, cacheSet } from '../cache/db.js';
import { normalizeServerUrl } from '../config.js';
const PROFILE_KEY = 'me';
/**
* 登录态。
* token 通过 IPC 交给主进程用 safeStorage 加密落盘,刷新/重启后可直接离线进入;
* 用户资料同时写入 IndexedDB,断网时也能显示姓名与班级。
*/
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null);
const user = ref<StudentDto | null>(null);
const loading = ref(false);
const offlineSession = ref(false);
const isAuthenticated = computed(() => Boolean(token.value));
const displayName = computed(() => user.value?.name ?? '未登录');
const classId = computed(() => user.value?.classId ?? null);
setTokenProvider(() => token.value);
function applyToken(next: string | null): void {
token.value = next;
setTokenProvider(() => token.value);
}
async function persistSession(
serverUrl: string,
username: string,
nextToken: string | null,
): Promise<void> {
if (!window.desktop) return;
await window.desktop.saveConfig({ serverUrl, username, token: nextToken });
}
/** 登录:写主进程配置 + 缓存资料 */
async function login(serverUrl: string, username: string, password: string): Promise<void> {
const normalized = normalizeServerUrl(serverUrl);
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
setApiBaseUrl(normalized);
loading.value = true;
try {
const payload: LoginResponse = await authApi.login({ username, password });
applyToken(payload.token);
user.value = payload.user as StudentDto;
offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, user.value);
await persistSession(normalized, username, payload.token);
} finally {
loading.value = false;
}
}
/**
* 启动恢复:先用缓存资料立即渲染,再尝试向服务器刷新。
* 服务器不可达时保持离线会话(可查看缓存数据)。
*/
async function restore(): Promise<{ serverUrl: string; username: string }> {
let serverUrl = '';
let username = '';
if (window.desktop) {
const config = await window.desktop.getConfig();
serverUrl = config.serverUrl;
username = config.username;
applyToken(config.token);
}
if (serverUrl) setApiBaseUrl(serverUrl);
if (!serverUrl) {
const fallback = localStorage.getItem(STORAGE_KEYS.serverUrl);
if (fallback) {
serverUrl = fallback;
setApiBaseUrl(fallback);
}
}
const cachedProfile = await cacheGet<StudentDto>('profile', PROFILE_KEY).catch(() => null);
if (cachedProfile) user.value = cachedProfile.value;
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
if (token.value) void refreshProfile();
return { serverUrl, username };
}
/** 后台刷新个人资料;服务器不可达时保持缓存资料与离线会话标记 */
async function refreshProfile(): Promise<void> {
if (!token.value) return;
try {
const profile = await authApi.me();
user.value = profile;
offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, profile);
} catch {
offlineSession.value = Boolean(user.value);
}
}
async function logout(): Promise<void> {
try {
if (token.value) await authApi.logout();
} catch {
// 离线退出忽略错误
}
applyToken(null);
user.value = null;
offlineSession.value = false;
if (window.desktop) await window.desktop.saveConfig({ token: null });
}
return {
token,
user,
loading,
offlineSession,
isAuthenticated,
displayName,
classId,
login,
restore,
refreshProfile,
logout,
applyToken,
};
});
@@ -0,0 +1,107 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import type { NotificationDto } from '@classhelper/shared';
import { notificationApi } from '../api/index.js';
import { cacheSet } from '../cache/db.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from './app.js';
/**
* 通知状态:列表 + 未读数。
* 抽成 store 是因为侧边栏红点与通知页共用同一份数据,
* 同时所有变更都会同步写回 IndexedDB,保证离线可见。
*/
export const useNotificationStore = defineStore('notifications', () => {
const items = ref<NotificationDto[]>([]);
const unreadCount = ref(0);
const loading = ref(false);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const error = ref<string | null>(null);
const unreadItems = computed(() => items.value.filter((item) => !item.read));
function currentKey(): string {
return 'self';
}
async function persist(): Promise<void> {
await cacheSet('notifications', currentKey(), items.value).catch(() => undefined);
}
function recount(): void {
unreadCount.value = items.value.filter((item) => !item.read).length;
}
async function load(): Promise<void> {
loading.value = true;
try {
const result = await fetchWithCache<NotificationDto[]>(
'notifications',
currentKey(),
() => notificationApi.list(),
[],
);
items.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
error.value = result.error;
recount();
if (!result.fromCache) useAppStore().markSynced();
} finally {
loading.value = false;
}
}
/** 实时推送:插入到列表头部并写回缓存 */
async function pushRealtime(payload: NotificationDto): Promise<void> {
if (items.value.some((item) => item.id === payload.id)) return;
items.value = [{ ...payload, read: false }, ...items.value];
recount();
await persist();
}
async function markRead(id: string): Promise<void> {
await notificationApi.markRead(id);
const target = items.value.find((item) => item.id === id);
if (target) {
target.read = true;
target.readAt = new Date().toISOString();
}
recount();
await persist();
}
async function markAllRead(): Promise<number> {
const result = await notificationApi.markAllRead();
const now = new Date().toISOString();
items.value = items.value.map((item) => ({ ...item, read: true, readAt: item.readAt ?? now }));
recount();
await persist();
return result.marked;
}
async function refreshUnreadCount(): Promise<void> {
try {
const result = await notificationApi.unreadCount();
unreadCount.value = result.count;
} catch {
// 离线时保留缓存推算出的未读数
}
}
return {
items,
unreadItems,
unreadCount,
loading,
fromCache,
updatedAt,
error,
load,
pushRealtime,
markRead,
markAllRead,
refreshUnreadCount,
};
});
@@ -0,0 +1,148 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { ElMessage } from 'element-plus';
import { io, type Socket } from 'socket.io-client';
import {
PRIORITY_LABELS,
SOCKET_EVENTS,
type GradeDto,
type HomeworkDto,
type NotificationDto,
type ServerToClientEvents,
} from '@classhelper/shared';
import { socketUrlOf } from '../config.js';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
/**
* 实时通道(学生端)。
* 与 Web 管理端使用同一套事件名;收到事件后除了通知用户,
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
*/
export const useRealtimeStore = defineStore('realtime', () => {
const connected = ref(false);
const connecting = ref(false);
const lastEventName = ref<string | null>(null);
const lastEventAt = ref<number | null>(null);
const eventCount = ref(0);
const handlers = new Map<string, Set<EventHandler>>();
let socket: Socket | null = null;
const statusText = computed(() => {
if (connected.value) return '实时连接正常';
if (connecting.value) return '正在连接服务器';
return '实时连接已断开';
});
function dispatch(event: string, payload: unknown): void {
for (const handler of handlers.get(event) ?? []) handler(payload);
}
function mark(event: string): void {
lastEventName.value = event;
lastEventAt.value = Date.now();
eventCount.value += 1;
}
function bind(instance: Socket): void {
instance.on('connect', () => {
connected.value = true;
connecting.value = false;
dispatch('__connected__', null);
});
instance.on('disconnect', () => {
connected.value = false;
dispatch('__disconnected__', null);
});
instance.on('connect_error', (error: Error) => {
connected.value = false;
connecting.value = false;
dispatch('__connect_error__', error.message);
});
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.notificationNew);
ElMessage({
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
type: payload.priority === 'URGENT' ? 'error' : 'info',
duration: 5000,
showClose: true,
});
dispatch(SOCKET_EVENTS.notificationNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
mark(SOCKET_EVENTS.homeworkNew);
ElMessage.success(`新作业:${payload.title}`);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto & { deleted?: boolean }) => {
mark(SOCKET_EVENTS.homeworkUpdated);
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
});
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => {
mark(SOCKET_EVENTS.gradeUpdated);
ElMessage.success(`成绩更新:${payload.examName} ${payload.score}/${payload.totalScore}`);
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
});
instance.on(SOCKET_EVENTS.scheduleUpdated, (payload: unknown) => {
mark(SOCKET_EVENTS.scheduleUpdated);
dispatch(SOCKET_EVENTS.scheduleUpdated, payload);
});
}
function connect(serverUrl: string, token: string): void {
disconnect();
connecting.value = true;
socket = io(socketUrlOf(serverUrl), {
auth: { token, clientType: 'desktop' },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1500,
reconnectionDelayMax: 10000,
});
bind(socket);
}
function disconnect(): void {
socket?.removeAllListeners();
socket?.disconnect();
socket = null;
connected.value = false;
connecting.value = false;
}
function on(
event: RealtimeEventName | '__connected__' | '__disconnected__' | '__connect_error__',
handler: EventHandler,
): void {
const set = handlers.get(event) ?? new Set<EventHandler>();
set.add(handler);
handlers.set(event, set);
}
function off(
event: RealtimeEventName | '__connected__' | '__disconnected__' | '__connect_error__',
handler: EventHandler,
): void {
handlers.get(event)?.delete(handler);
}
return {
connected,
connecting,
statusText,
lastEventName,
lastEventAt,
eventCount,
connect,
disconnect,
on,
off,
};
});
@@ -0,0 +1,115 @@
:root {
--ch-bg: #f5f7fa;
--ch-border: #e4e7ed;
--ch-sidebar: #1f2d3d;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: var(--ch-bg);
color: #303133;
/* 桌面端禁用整页选中,更接近原生应用体验 */
user-select: none;
}
input,
textarea {
user-select: text;
}
.page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.page-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.text-muted {
color: #909399;
}
.offline-banner {
margin: 0 0 12px;
}
.timetable-cell {
min-height: 30px;
}
.timetable-course {
font-weight: 600;
}
.timetable-meta {
font-size: 12px;
color: #909399;
}
.unread-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
margin-right: 6px;
}
/* 滚动条美化(Windows 默认滚动条较粗) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #c8ccd4;
border-radius: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
@@ -0,0 +1,259 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import * as echarts from 'echarts';
import {
SOCKET_EVENTS,
averageOf,
formatDate,
gradeLevel,
gradePercent,
type GradeDto,
} from '@classhelper/shared';
import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore();
const realtime = useRealtimeStore();
const loading = ref(false);
const grades = ref<GradeDto[]>([]);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const chartRef = ref<HTMLDivElement>();
let chart: ReturnType<typeof echarts.init> | null = null;
const averagePercent = computed(() =>
grades.value.length === 0
? 0
: averageOf(grades.value.map((item) => gradePercent(item.score, item.totalScore))),
);
const bestGrade = computed(() => {
if (grades.value.length === 0) return null;
return grades.value.reduce((best, item) =>
gradePercent(item.score, item.totalScore) > gradePercent(best.score, best.totalScore) ? item : best,
);
});
const latestGrades = computed(() =>
[...grades.value].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)).slice(0, 5),
);
const levelCounts = computed(() => {
const levels: Record<string, number> = { A: 0, B: 0, C: 0, D: 0, E: 0 };
for (const item of grades.value) {
levels[gradeLevel(gradePercent(item.score, item.totalScore))] =
(levels[gradeLevel(gradePercent(item.score, item.totalScore))] ?? 0) + 1;
}
return levels;
});
async function loadGrades(): Promise<void> {
loading.value = true;
try {
const result = await fetchWithCache<GradeDto[]>('grades', 'my', () => gradeApi.my(), []);
grades.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
if (!result.fromCache) appStore.markSynced();
renderChart();
} finally {
loading.value = false;
}
}
function renderChart(): void {
if (!chartRef.value) return;
chart ??= echarts.init(chartRef.value);
// 按考试聚合平均得分率(最近 8 次考试)
const byExam = new Map<string, number[]>();
for (const item of grades.value) {
const bucket = byExam.get(item.examName) ?? [];
bucket.push(gradePercent(item.score, item.totalScore));
byExam.set(item.examName, bucket);
}
const exams = [...byExam.entries()].slice(-8);
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 44, right: 18, top: 30, bottom: 34 },
xAxis: {
type: 'category',
data: exams.map(([name]) => name),
axisLabel: { interval: 0, rotate: exams.length > 4 ? 20 : 0, fontSize: 11 },
},
yAxis: { type: 'value', max: 100, name: '得分率%' },
series: [
{
type: 'bar',
barMaxWidth: 46,
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top', formatter: '{c}%', fontSize: 11 },
data: exams.map(([, percents]) => averageOf(percents)),
},
],
});
}
function handleResize(): void {
chart?.resize();
}
function onGradeEvent(): void {
void loadGrades();
}
function onRecovered(): void {
void loadGrades();
}
onMounted(async () => {
await loadGrades();
window.addEventListener('resize', handleResize);
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
chart?.dispose();
chart = null;
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">我的成绩</h2>
<p class="page-subtitle">
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-button :icon="'Refresh'" @click="loadGrades">刷新</el-button>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="8">
<div class="stat-card">
<div class="stat-label">平均得分率</div>
<div class="stat-value">{{ averagePercent }}%</div>
<div class="stat-hint">共 {{ grades.length }} 次记录</div>
</div>
<div class="stat-card mt-12">
<div class="stat-label">最好成绩</div>
<div class="stat-value">
{{ bestGrade ? `${bestGrade.score}/${bestGrade.totalScore}` : '-' }}
</div>
<div class="stat-hint">{{ bestGrade?.examName ?? '暂无数据' }}</div>
</div>
<div class="stat-card mt-12">
<div class="stat-label">等级分布</div>
<div class="level-list">
<el-tag v-for="(count, level) in levelCounts" :key="level" effect="plain">
{{ level }} 等:{{ count }}
</el-tag>
</div>
</div>
</el-col>
<el-col :xs="24" :md="16">
<el-card v-loading="loading" shadow="never">
<template #header><span>各次考试平均得分率</span></template>
<div ref="chartRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="mt-16">
<template #header><span>成绩明细</span></template>
<el-table :data="grades" empty-text="暂无成绩" max-height="420">
<el-table-column prop="examName" label="考试" min-width="150" />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="分数" width="130">
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
</el-table-column>
<el-table-column label="得分率" width="200">
<template #default="{ row }">
<el-progress
:percentage="gradePercent(row.score, row.totalScore)"
:stroke-width="10"
:status="gradePercent(row.score, row.totalScore) >= 60 ? 'success' : 'exception'"
/>
</template>
</el-table-column>
<el-table-column label="等级" width="80">
<template #default="{ row }">
{{ gradeLevel(gradePercent(row.score, row.totalScore)) }}
</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card v-if="latestGrades.length > 0" shadow="never" class="mt-16">
<template #header><span>最近更新</span></template>
<el-timeline>
<el-timeline-item
v-for="item in latestGrades"
:key="item.id"
:timestamp="formatDate(item.publishedAt, true)"
placement="top"
>
{{ item.examName }} · {{ item.course?.name ?? '未关联课程' }}:
<strong>{{ item.score }}/{{ item.totalScore }}</strong>
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</template>
<style scoped>
.stat-card {
background: #fff;
border: 1px solid var(--ch-border);
border-radius: 10px;
padding: 16px;
}
.stat-label {
font-size: 13px;
color: #909399;
}
.stat-value {
margin-top: 6px;
font-size: 26px;
font-weight: 700;
}
.stat-hint {
margin-top: 4px;
font-size: 12px;
color: #c0c4cc;
}
.level-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.chart {
width: 100%;
height: 300px;
}
</style>
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { SOCKET_EVENTS, formatDate, formatDeadline, isOverdue, type HomeworkDto } from '@classhelper/shared';
import { homeworkApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const loading = ref(false);
const homeworks = ref<HomeworkDto[]>([]);
const filter = ref<'all' | 'pending' | 'done'>('all');
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
const submitting = ref(false);
const filtered = computed(() => {
if (filter.value === 'pending') return homeworks.value.filter((item) => item.completed !== true);
if (filter.value === 'done') return homeworks.value.filter((item) => item.completed === true);
return homeworks.value;
});
const pendingCount = computed(() => homeworks.value.filter((item) => item.completed !== true).length);
const doneCount = computed(() => homeworks.value.filter((item) => item.completed === true).length);
async function loadHomework(): Promise<void> {
loading.value = true;
try {
const classId = auth.classId ?? undefined;
const result = await fetchWithCache<HomeworkDto[]>(
'homeworks',
'self',
() => homeworkApi.list(classId ? { classId } : undefined),
[],
);
homeworks.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
if (!result.fromCache) appStore.markSynced();
if (current.value) {
current.value = homeworks.value.find((item) => item.id === current.value?.id) ?? current.value;
}
} finally {
loading.value = false;
}
}
function openDetail(item: HomeworkDto): void {
current.value = item;
detailVisible.value = true;
}
async function toggleComplete(item: HomeworkDto | null): Promise<void> {
if (!item) return;
if (appStore.offline) {
ElMessage.warning('离线状态下无法提交,请恢复网络后重试');
return;
}
submitting.value = true;
try {
const next = item.completed !== true;
await homeworkApi.updateStatus(item.id, next);
item.completed = next;
if (item.homeworkStatus) item.homeworkStatus.completed = next;
else
item.homeworkStatus = {
id: `local-${item.id}`,
homeworkId: item.id,
userId: auth.user?.id ?? '',
completed: next,
updatedAt: new Date().toISOString(),
};
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败');
} finally {
submitting.value = false;
}
}
function onHomeworkEvent(): void {
void loadHomework();
}
/** 附件链接交给系统浏览器打开(模板里无法直接访问 window) */
function openAttachment(url: string | null | undefined): void {
if (!url) return;
if (window.desktop) void window.desktop.openExternal(url);
else window.open(url, '_blank');
}
function onRecovered(): void {
void loadHomework();
}
onMounted(async () => {
await loadHomework();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">我的作业</h2>
<p class="page-subtitle">
共 {{ homeworks.length }} 份 · 待完成 {{ pendingCount }} · 已完成 {{ doneCount }}
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-radio-group v-model="filter">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="pending">未完成</el-radio-button>
<el-radio-button value="done">已完成</el-radio-button>
</el-radio-group>
<el-button :icon="'Refresh'" @click="loadHomework">刷新</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never">
<el-empty v-if="filtered.length === 0" description="没有符合条件的作业" />
<el-table v-else :data="filtered" @row-click="openDetail">
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
:type="row.completed ? 'success' : isOverdue(row.dueAt) ? 'danger' : 'info'"
size="small"
effect="light"
>
{{ row.completed ? '已完成' : isOverdue(row.dueAt) ? '已逾期' : '待完成' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="作业标题" min-width="220" show-overflow-tooltip />
<el-table-column label="课程" width="110">
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="截止时间" width="180">
<template #default="{ row }">{{ formatDate(row.dueAt, true) || '不限' }}</template>
</el-table-column>
<el-table-column label="剩余" width="120">
<template #default="{ row }">
<span :class="{ 'text-danger': isOverdue(row.dueAt) && !row.completed }">
{{ row.completed ? '—' : formatDeadline(row.dueAt) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button
link
:type="row.completed ? 'warning' : 'primary'"
:loading="submitting"
@click.stop="toggleComplete(row)"
>
{{ row.completed ? '取消完成' : '标记完成' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
<template v-if="current">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="课程">{{ current.course?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ current.creator?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="截止时间">
{{ formatDate(current.dueAt, true) || '不限' }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDate(current.createdAt, true) }}
</el-descriptions-item>
<el-descriptions-item label="我的状态" :span="2">
<el-tag :type="current.completed ? 'success' : 'info'" size="small">
{{ current.completed ? '已完成' : '未完成' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="mt-16">
<h4>作业要求</h4>
<p class="content-block">{{ current.content }}</p>
</div>
<div v-if="current.attachmentUrl" class="mt-12">
<el-button link type="primary" @click="openAttachment(current.attachmentUrl)">
打开附件链接
</el-button>
</div>
<div class="mt-16">
<el-button
type="primary"
:loading="submitting"
:disabled="appStore.offline"
@click="toggleComplete(current)"
>
{{ current.completed ? '取消完成标记' : '标记为已完成' }}
</el-button>
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.text-danger {
color: #f56c6c;
}
.content-block {
white-space: pre-wrap;
line-height: 1.75;
background: #fafafa;
border-radius: 8px;
padding: 12px;
margin: 0;
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const formRef = ref<FormInstance>();
const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' });
const testing = ref(false);
const testResult = ref<'ok' | 'fail' | null>(null);
const rules: FormRules = {
serverUrl: [
{
validator: (_rule, value: string, callback: (error?: Error) => void) => {
if (!value?.trim()) callback(new Error('请输入服务器地址'));
else if (!normalizeServerUrl(value)) callback(new Error('地址格式应为 http://主机:端口'));
else callback();
},
trigger: 'blur',
},
],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
onMounted(async () => {
// 主进程已保存过服务器地址/上次用户名则预填
if (window.desktop) {
const config = await window.desktop.getConfig();
if (config.serverUrl) form.serverUrl = config.serverUrl;
if (config.username) form.username = config.username;
}
});
async function testConnection(): Promise<void> {
const normalized = normalizeServerUrl(form.serverUrl);
if (!normalized) {
ElMessage.warning('请先填写正确的服务器地址');
return;
}
testing.value = true;
testResult.value = null;
try {
setApiBaseUrl(normalized);
const result = await pingHealth();
testResult.value = result.ok ? 'ok' : 'fail';
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
try {
await auth.login(form.serverUrl, form.username.trim(), form.password);
await appStore.init(form.serverUrl);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
ElMessage.success(`欢迎回来,${auth.displayName}`);
await router.replace('/schedule');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败');
}
}
/** 已保存登录态但服务器不可达时,允许用缓存数据离线进入 */
async function enterOffline(): Promise<void> {
if (!auth.isAuthenticated) return;
await appStore.init(appStore.serverUrl);
ElMessage.warning('已进入离线模式,仅显示上次同步的数据');
await router.replace('/schedule');
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-head">
<el-icon :size="30" color="#409eff"><School /></el-icon>
<div>
<h1 class="login-title">班级小助手</h1>
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-form-item label="服务器地址" prop="serverUrl">
<el-input v-model="form.serverUrl" placeholder="http://127.0.0.1:4000" clearable>
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit">
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
show-password
@keyup.enter="submit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<div class="login-actions">
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
</div>
<el-alert
v-if="testResult === 'ok'"
class="mt-12"
type="success"
:closable="false"
title="服务器可达,可以登录"
/>
<el-alert
v-if="testResult === 'fail'"
class="mt-12"
type="error"
:closable="false"
title="无法连接服务器"
description="请确认后端已启动(pnpm dev:server)、地址与端口正确、防火墙未拦截。"
/>
</el-form>
<el-divider v-if="auth.isAuthenticated">或</el-divider>
<el-button v-if="auth.isAuthenticated" class="offline-button" @click="enterOffline">
<el-icon><Connection /></el-icon>
离线进入(使用本地缓存数据)
</el-button>
<p class="login-tip">
演示账号:student01 / student123(高一(1)班)
<br />
数据来源:教师或管理员在 Web 管理端发布的内容。
</p>
</div>
</div>
</template>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a5169 55%, #409eff 100%);
}
.login-card {
width: 420px;
background: #fff;
border-radius: 14px;
padding: 28px 26px 20px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
}
.login-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.login-title {
margin: 0;
font-size: 20px;
}
.login-sub {
margin: 4px 0 0;
font-size: 12px;
color: #909399;
}
.login-actions {
display: flex;
gap: 10px;
}
.login-actions .el-button {
flex: 1;
}
.offline-button {
width: 100%;
}
.login-tip {
margin: 16px 0 0;
font-size: 12px;
line-height: 1.7;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
formatDate,
relativeTime,
type NotificationDto,
type NotificationPriority,
} from '@classhelper/shared';
import { useAppStore } from '../stores/app.js';
import { useNotificationStore } from '../stores/notifications.js';
const appStore = useAppStore();
const store = useNotificationStore();
const filter = ref<'all' | 'unread'>('all');
const detailVisible = ref(false);
const current = ref<NotificationDto | null>(null);
const marking = ref(false);
const visibleItems = computed(() =>
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
);
async function openDetail(item: NotificationDto): Promise<void> {
current.value = item;
detailVisible.value = true;
if (!item.read) await markRead(item);
}
async function markRead(item: NotificationDto): Promise<void> {
if (appStore.offline || item.read) return;
marking.value = true;
try {
await store.markRead(item.id);
if (current.value?.id === item.id) current.value = { ...item, read: true };
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '标记已读失败');
} finally {
marking.value = false;
}
}
async function markAll(): Promise<void> {
if (appStore.offline) {
ElMessage.warning('离线状态下无法同步已读状态');
return;
}
marking.value = true;
try {
const marked = await store.markAllRead();
ElMessage.success(`已将 ${marked} 条通知标为已读`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '操作失败');
} finally {
marking.value = false;
}
}
onMounted(async () => {
await store.load().catch(() => undefined);
await store.refreshUnreadCount();
});
onUnmounted(() => {
// store 是全局的,这里无需清理;保留钩子以便将来取消订阅
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">通知</h2>
<p class="page-subtitle">
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-radio-group v-model="filter">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="unread">未读</el-radio-button>
</el-radio-group>
<el-button :loading="marking" :disabled="store.unreadCount === 0" @click="markAll">
全部已读
</el-button>
<el-button :icon="'Refresh'" @click="store.load()">刷新</el-button>
</div>
</div>
<el-card v-loading="store.loading" shadow="never">
<el-empty v-if="visibleItems.length === 0" description="暂无通知" />
<ul v-else class="notice-list">
<li
v-for="item in visibleItems"
:key="item.id"
class="notice-item"
:class="{ unread: !item.read }"
@click="openDetail(item)"
>
<div class="notice-main">
<div class="notice-title">
<span v-if="!item.read" class="unread-dot"></span>
<el-tag
:type="PRIORITY_TAG_TYPES[item.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<div class="notice-content">{{ item.content }}</div>
</div>
<div class="notice-meta">
<div>{{ relativeTime(item.createdAt) }}</div>
<div class="text-muted">{{ formatDate(item.createdAt, true) }}</div>
<el-tag v-if="item.read" size="small" type="info" effect="plain">已读</el-tag>
<el-tag v-else size="small" type="danger" effect="plain">未读</el-tag>
</div>
</li>
</ul>
</el-card>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
<template v-if="current">
<div class="drawer-meta">
<el-tag :type="PRIORITY_TAG_TYPES[current.priority as NotificationPriority]" size="small">
{{ PRIORITY_LABELS[current.priority as NotificationPriority] }}
</el-tag>
<span class="text-muted">{{ formatDate(current.createdAt, true) }}</span>
<span class="text-muted">发布人:{{ current.creator?.name ?? '-' }}</span>
</div>
<p class="content-block">{{ current.content }}</p>
<el-alert
v-if="!current.read && appStore.offline"
type="warning"
:closable="false"
title="离线状态:已读状态将在恢复网络后同步"
/>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.notice-list {
list-style: none;
margin: 0;
padding: 0;
}
.notice-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 12px 6px;
border-bottom: 1px dashed var(--ch-border);
cursor: pointer;
border-radius: 6px;
}
.notice-item:hover {
background: #f7f9fc;
}
.notice-item.unread {
background: #f0f7ff;
}
.notice-item:last-child {
border-bottom: none;
}
.notice-main {
flex: 1;
min-width: 0;
}
.notice-title {
display: flex;
align-items: center;
gap: 8px;
}
.title-text {
font-weight: 600;
}
.notice-content {
margin-top: 6px;
font-size: 13px;
color: #606266;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notice-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 12px;
white-space: nowrap;
}
.drawer-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.content-block {
white-space: pre-wrap;
line-height: 1.8;
background: #fafafa;
border-radius: 8px;
padding: 14px;
margin: 0 0 16px;
}
</style>
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
SOCKET_EVENTS,
WEEKDAYS,
WEEKDAY_LABELS,
buildWeekOptions,
formatWeekRange,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { dashboardApi, scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const loading = ref(false);
const grid = ref<ScheduleWeekView | null>(null);
const items = ref<ScheduleDto[]>([]);
const week = ref(appStore.currentWeek);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const weekOptions = ref<number[]>(buildWeekOptions(20));
const periods = computed(() => {
const buckets = new Map<string, ScheduleDto[]>();
for (const item of items.value) {
const key = `${item.startTime}-${item.endTime}`;
const bucket = buckets.get(key) ?? [];
bucket.push(item);
buckets.set(key, bucket);
}
return [...buckets.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, list]) => {
const [start, end] = key.split('-');
return { start: start ?? '', end: end ?? '', items: list };
});
});
function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): ScheduleDto[] {
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
}
const todayWeekday = computed(() => {
const day = new Date().getDay();
return day === 0 ? 7 : day;
});
const courseCount = computed(() => new Set(items.value.map((item) => item.courseId)).size);
async function loadTerm(): Promise<void> {
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
currentWeek: 1,
maxWeek: 20,
});
appStore.currentWeek = result.data.currentWeek;
appStore.maxWeek = result.data.maxWeek;
weekOptions.value = buildWeekOptions(result.data.maxWeek);
if (!week.value || week.value === 1) week.value = result.data.currentWeek;
}
async function loadSchedules(): Promise<void> {
const classId = auth.classId ?? undefined;
loading.value = true;
try {
const result = await fetchWithCache<ScheduleDto[]>(
'schedules',
`week:${week.value}`,
() => scheduleApi.list({ classId, week: week.value }),
[],
);
items.value = result.data;
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
grid.value = {
week: week.value,
columns: WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
label: WEEKDAY_LABELS[dayOfWeek] ?? '',
items: result.data.filter((item) => item.dayOfWeek === dayOfWeek),
})),
};
if (!result.fromCache) appStore.markSynced();
} finally {
loading.value = false;
}
}
async function changeWeek(delta: number): Promise<void> {
const next = week.value + delta;
if (next < 1 || next > appStore.maxWeek) return;
week.value = next;
await loadSchedules();
}
function onScheduleEvent(): void {
void loadSchedules();
}
function onRecovered(): void {
void loadSchedules();
}
onMounted(async () => {
await loadTerm();
await loadSchedules();
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
appStore.offServerRecovered(onRecovered);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">我的课表</h2>
<p class="page-subtitle">
第 {{ week }} 周 · 共 {{ items.length }} 节课 · {{ courseCount }} 门课程
<el-tag v-if="fromCache" class="mt-12" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)">上一周</el-button>
<el-select v-model="week" style="width: 120px" @change="loadSchedules">
<el-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
</el-select>
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
下一周
<el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never">
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
<el-table v-else :data="periods" border size="small" :cell-class-name="() => ''">
<el-table-column label="节次" width="110" fixed>
<template #default="{ row }">
<div>{{ row.start }}</div>
<div class="timetable-meta">{{ row.end }}</div>
</template>
</el-table-column>
<el-table-column
v-for="day in WEEKDAYS"
:key="day"
:label="`${WEEKDAY_LABELS[day]}${day === todayWeekday ? ' · 今天' : ''}`"
min-width="132"
>
<template #default="{ row }">
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
<div class="timetable-course">{{ item.course?.name ?? '课程' }}</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate } from '@classhelper/shared';
import type { DesktopAppInfo } from '../../types/desktop.js';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { normalizeServerUrl } from '../config.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
const saving = ref(false);
const appInfo = ref<DesktopAppInfo | null>(null);
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function loadAppInfo(): Promise<void> {
if (!window.desktop) return;
appInfo.value = await window.desktop.getAppInfo();
}
async function saveServer(): Promise<void> {
const normalized = normalizeServerUrl(serverInput.value);
if (!normalized) {
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
return;
}
saving.value = true;
try {
await appStore.applyServerUrl(normalized);
serverInput.value = normalized;
ElMessage.success('服务器地址已保存');
await testConnection();
if (auth.token) realtime.connect(normalized, auth.token);
} finally {
saving.value = false;
}
}
async function testConnection(): Promise<void> {
testing.value = true;
try {
const result = await pingHealth();
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function manualSync(): Promise<void> {
setApiBaseUrl(appStore.serverUrl);
await appStore.ping();
if (appStore.offline) {
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
return;
}
await notifications.load();
appStore.markSynced();
ElMessage.success('已同步最新数据');
}
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
await router.replace('/login');
}
onMounted(async () => {
await loadAppInfo();
await appStore.refreshCacheStats();
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">设置</h2>
<p class="page-subtitle">服务器连接、账号信息与离线缓存管理</p>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>服务器连接</span></template>
<el-form label-position="top">
<el-form-item label="服务器地址">
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button @click="manualSync">立即同步</el-button>
</div>
<el-descriptions class="mt-16" :column="1" border size="small">
<el-descriptions-item label="当前状态">
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
{{ realtime.statusText }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ auth.user?.username ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<el-button class="mt-16" type="danger" plain @click="logout">退出登录</el-button>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
show-icon
title="离线可用"
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>客户端信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Electron">{{ appInfo?.electron ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Chromium">{{ appInfo?.chrome ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Node.js">{{ appInfo?.node ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.ml-8 {
margin-left: 8px;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
/**
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
* 渲染进程只依赖这个最小 API,不接触 Node.js。
*/
export interface DesktopStoredConfig {
/** 后端服务地址,例如 http://127.0.0.1:4000 */
serverUrl: string;
/** 上次登录的用户名(便于自动填充) */
username: string;
/** 登录令牌(主进程用 safeStorage 加密后落盘,读回时自动解密) */
token: string | null;
}
export interface DesktopAppInfo {
appVersion: string;
electron: string;
chrome: string;
node: string;
platform: string;
userDataPath: string;
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */
smokeTest: boolean;
}
export interface DesktopBridge {
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
smokeTest: boolean;
getConfig(): Promise<DesktopStoredConfig>;
saveConfig(patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig>;
clearConfig(): Promise<DesktopStoredConfig>;
getAppInfo(): Promise<DesktopAppInfo>;
openExternal(url: string): Promise<boolean>;
}
declare global {
interface Window {
/** 仅在 Electron 中注入;浏览器里为 undefined(渲染进程做了降级处理) */
desktop?: DesktopBridge;
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
__classhelperSmoke__?: {
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
};
}
}
export {};
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"jsx": "preserve",
"types": ["node"],
"baseUrl": ".",
"paths": {
"@renderer/*": ["./src/renderer/*"]
},
"declaration": false,
"declarationMap": false
},
"include": [
"src/renderer/**/*.ts",
"src/renderer/**/*.d.ts",
"src/renderer/**/*.vue",
"src/types/**/*.d.ts"
]
}
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"types": ["node"],
"declaration": false,
"declarationMap": false
},
"include": ["src/main/**/*.ts", "src/preload/**/*.ts", "src/types/**/*.d.ts", "scripts/**/*.mjs"]
}
+32
View File
@@ -0,0 +1,32 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
/**
* 渲染进程构建配置(.mts 以保证 Vite 原生配置加载器按 ESM 解析)。
*
* 关键点:
* 1) base: './' —— Electron 通过 file:// 加载 index.html,必须使用相对路径
* 2) outDir: dist/renderer —— 与主进程产物 dist/main、preload 产物 dist/preload 分离
* 3) 端口 5174(Web 管理端占用 5173),dev 时主进程读取 VITE_DEV_SERVER_URL
*/
export default defineConfig({
base: './',
plugins: [vue()],
resolve: {
alias: {
'@renderer': fileURLToPath(new URL('./src/renderer', import.meta.url)),
},
},
build: {
outDir: 'dist/renderer',
emptyOutDir: true,
sourcemap: false,
chunkSizeWarningLimit: 2000,
},
server: {
host: '127.0.0.1',
port: 5174,
strictPort: true,
},
});
+1809
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -13,6 +13,9 @@ allowBuilds:
vue-demi: true vue-demi: true
'@parcel/watcher': true '@parcel/watcher': true
unrs-resolver: true unrs-resolver: true
# electron-builder 的可选依赖(Squirrel.Windows 打包器)。本项目只用 nsis / portable,
# 显式置为 false,避免 pnpm 11 因「未声明允许或禁止」而中断整次安装。
electron-winstaller: false
# 上游新版本发布后立即安装可能带来风险,这里保留默认解析行为。 # 上游新版本发布后立即安装可能带来风险,这里保留默认解析行为。
# 若要锁定 Node 版本或开启更严格的 peer 校验,可在此追加: # 若要锁定 Node 版本或开启更严格的 peer 校验,可在此追加: