feat(desktop-client): 阶段4-6 Electron EXE 客户端(登录/课表/作业/通知/成绩/设置 + Socket.IO 实时 + IndexedDB 离线缓存与自动同步 + electron-builder 打包)
This commit is contained in:
1 parent
4566d7cd5e
commit
6e4c7c1050
42 files changed
+5734
-27
No files matched your search
@@ -16,12 +16,12 @@
|
||||
## 当前交付范围
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
| ---- | ----------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| ---- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||
| 4 | EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO Client + 离线缓存) | ⏳ 待开发 |
|
||||
| 5 | 三端联调脚本、打包命令、完整 README | 🟡 后端联调脚本与文档已完成,EXE 打包待阶段 4 |
|
||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 9/9 通过) |
|
||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(electron-builder 打包 Windows EXE) |
|
||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||
|
||||
> **与原始提示词的两处偏差(已与你确认)**
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
```
|
||||
class-helper/
|
||||
├── package.json # 根脚本(dev / build / db:* / verify:e2e)
|
||||
├── package.json # 根脚本(dev / build / db:* / verify:* / dist:*)
|
||||
├── pnpm-workspace.yaml # workspace 定义 + allowBuilds(pnpm 11 依赖构建白名单)
|
||||
├── tsconfig.base.json # 共享 TS 基础配置
|
||||
├── eslint.config.mjs # ESLint 扁平配置(TS + Vue)
|
||||
@@ -50,23 +50,32 @@ class-helper/
|
||||
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
|
||||
│ ├── prisma/seed.ts # 种子数据
|
||||
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
|
||||
│ ├── scripts/verify-e2e.mjs # 端到端验收脚本(54 项)
|
||||
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(54 项)
|
||||
│ └── src/
|
||||
│ ├── app.ts # Express 装配(健康检查 + 模块挂载)
|
||||
│ ├── app.ts # Express 装配(服务首页 + 健康检查 + 模块挂载)
|
||||
│ ├── index.ts # 启动入口(HTTP + Socket.IO + 优雅退出)
|
||||
│ ├── config/env.ts # 环境变量校验(zod)
|
||||
│ ├── lib/ # db / http / jwt / password / access(RBAC) / mappers
|
||||
│ ├── middleware/ # auth / error / validate
|
||||
│ ├── realtime/ # socket.ts + bus.ts(事件总线)
|
||||
│ └── modules/ # 功能模块 + registry.ts
|
||||
└── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus)
|
||||
└── src/{api,stores,router,layouts,views,styles}
|
||||
├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus)
|
||||
│ └── 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 |
|
||||
| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) |
|
||||
| 后端 | Node.js + Express | 24.18 / 5.2 |
|
||||
@@ -75,6 +84,8 @@ class-helper/
|
||||
| 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 |
|
||||
| 校验 | 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 |
|
||||
| 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)。
|
||||
|
||||
@@ -113,15 +124,32 @@ pnpm dev
|
||||
|
||||
也可以分开启动:`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
|
||||
# 另开一个终端先启动后端:pnpm dev:server
|
||||
pnpm verify:e2e
|
||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC:54 项
|
||||
pnpm verify:desktop # Electron 客户端(含联网集成):9 项
|
||||
```
|
||||
|
||||
脚本会自动登录教师与学生账号,验证**实时推送时延、作业完成、成绩下发、权限隔离**等 54 项,
|
||||
并打印逐项结果与耗时(实测:通知 37ms、作业 26ms、成绩 25ms 到达,远优于 5 秒要求)。
|
||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离**等 54 项,实测通知 37ms、作业 26ms、
|
||||
成绩 25ms 到达(要求 < 5 秒);客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、
|
||||
断网回退与联网集成(登录 + 四类数据 + Socket.IO),实测 9/9 通过。
|
||||
|
||||
## 默认账号与种子数据
|
||||
|
||||
@@ -145,10 +173,15 @@ pnpm verify:e2e
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| ------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
|
||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||
| `pnpm build` | 构建 shared + 后端(tsc 产出 `dist`) + Web 端(Vite 产出 `dist`) |
|
||||
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
|
||||
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||
| `pnpm verify:desktop` | Electron 冒烟验证(需先 `pnpm build:desktop`;后端在线则含联网集成) |
|
||||
| `pnpm dist:dir` | 打包免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
||||
| `pnpm dist:win` | 打包 nsis 安装包 + portable 单文件 EXE |
|
||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
||||
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
||||
@@ -160,6 +193,83 @@ pnpm verify:e2e
|
||||
| `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 一览
|
||||
|
||||
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
|
||||
@@ -284,17 +394,26 @@ pnpm db:migrate && pnpm db:seed
|
||||
## 验收标准对照
|
||||
|
||||
| 验收项 | 结果 | 证据 |
|
||||
| --------------------------------------------- | --------- | ----------------------------------------------------------------------------------------- |
|
||||
| --------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- |
|
||||
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
|
||||
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
|
||||
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
|
||||
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` |
|
||||
| 断网后客户端可查看缓存数据 | ⏳ 阶段 4 | 依赖 EXE 客户端(IndexedDB 缓存 + 联网后同步) |
|
||||
| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 |
|
||||
| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) |
|
||||
| 能成功打包 Windows EXE | ⏳ 阶段 4 | 待 EXE 客户端完成后用 electron-builder 打包 |
|
||||
| 提供完整 README(启动、构建、打包、默认账号) | 🟡 | 启动/构建/账号已完成;EXE 打包命令随阶段 4 补充 |
|
||||
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe`(106.9MB) / `-portable.exe`(106.7MB) / `win-unpacked/*.exe` |
|
||||
| 提供完整 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.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 渲染进程),
|
||||
登录页(服务器地址/用户名/密码)、侧边栏(课表/作业/通知/成绩/设置)、
|
||||
Socket.IO 实时更新、IndexedDB 离线缓存与联网后自动同步、`electron-builder` 打包 Windows EXE。
|
||||
2. **阶段 5**:三端联调脚本(含 EXE 端时延与断网恢复用例)、根目录一键打包命令。
|
||||
3. **阶段 6**:打包产物分发说明、云端 MySQL 部署清单(Docker Compose)、CI 流水线。
|
||||
7. **electron-builder 的构建工具(NSIS / winCodeSign)下载慢**
|
||||
通过 `ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/`
|
||||
解决(已内置到 `dist-win.mjs`),实测 nsis-3.0.4.1 与 nsis-resources 正常下载。
|
||||
|
||||
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
@@ -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:server": "pnpm build:shared && pnpm --filter @classhelper/server 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",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
@@ -25,7 +27,10 @@
|
||||
"db:studio": "pnpm --filter @classhelper/server db:studio",
|
||||
"db:switch:mysql": "node scripts/use-database.mjs mysql",
|
||||
"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": {
|
||||
"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}
|
||||
@@ -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>
|
||||
@@ -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 构建完成');
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+1809
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,9 @@ allowBuilds:
|
||||
vue-demi: true
|
||||
'@parcel/watcher': true
|
||||
unrs-resolver: true
|
||||
# electron-builder 的可选依赖(Squirrel.Windows 打包器)。本项目只用 nsis / portable,
|
||||
# 显式置为 false,避免 pnpm 11 因「未声明允许或禁止」而中断整次安装。
|
||||
electron-winstaller: false
|
||||
|
||||
# 上游新版本发布后立即安装可能带来风险,这里保留默认解析行为。
|
||||
# 若要锁定 Node 版本或开启更严格的 peer 校验,可在此追加:
|
||||
|
||||
Reference in new issue
Block a user