Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c8980a82 | ||
|
|
828fde80f9 |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
# 班级小助手(Class Helper)
|
# 班级小助手(Class Helper)
|
||||||
|
|
||||||
班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与
|
班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与
|
||||||
**桌面客户端**(学生)三端。核心链路:
|
**桌面客户端**(学生)三端。核心链路:
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 26/26) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 27/27) |
|
||||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||||
@@ -173,13 +173,13 @@ pnpm verify:desktop
|
|||||||
# 另开一个终端先启动后端:pnpm dev:server
|
# 另开一个终端先启动后端:pnpm dev:server
|
||||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项
|
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项
|
||||||
pnpm verify:web # Web 管理端真实点击(含手机小屏适配、上课时段紧急通知全屏警告):15 项
|
pnpm verify:web # Web 管理端真实点击(含手机小屏适配、上课时段紧急通知全屏警告):15 项
|
||||||
pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):26 项
|
pnpm verify:desktop # Electron 客户端(含灵动岛、真实通知链路与联网集成):27 项
|
||||||
```
|
```
|
||||||
|
|
||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
|
||||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||||
(登录 + 四类数据 + Socket.IO),实测 26/26 通过。
|
(登录 + 四类数据 + Socket.IO),实测 27/27 通过。
|
||||||
|
|
||||||
## 默认账号与种子数据
|
## 默认账号与种子数据
|
||||||
|
|
||||||
@@ -202,30 +202,30 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成)
|
|||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| ------------------------------------------- | ------------------------------------------------------------------------------ |
|
| ------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
|
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
|
||||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||||
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||||
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
|
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
|
||||||
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||||
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
|
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
|
||||||
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
|
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
|
||||||
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
||||||
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
|
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
|
||||||
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
||||||
| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) |
|
| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) |
|
||||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,15 项,含手机小屏适配) |
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,15 项,含手机小屏适配) |
|
||||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(26 项,含灵动岛行为/像素级截图校验、侧边栏点击与离线回退) |
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(27 项,含灵动岛行为/像素级截图/真实通知链路、侧边栏点击与离线回退) |
|
||||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||||
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
||||||
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
||||||
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
|
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
|
||||||
| `pnpm db:migrate` | 创建并应用迁移 |
|
| `pnpm db:migrate` | 创建并应用迁移 |
|
||||||
| `pnpm db:seed` | 写入种子数据 |
|
| `pnpm db:seed` | 写入种子数据 |
|
||||||
| `pnpm db:reset` | 重置数据库并重新执行 seed |
|
| `pnpm db:reset` | 重置数据库并重新执行 seed |
|
||||||
| `pnpm db:studio` | 打开 Prisma Studio |
|
| `pnpm db:studio` | 打开 Prisma Studio |
|
||||||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
|
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
|
||||||
|
|
||||||
## Web 管理端(含手机小屏适配)
|
## Web 管理端(含手机小屏适配)
|
||||||
|
|
||||||
@@ -347,6 +347,27 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成)
|
|||||||
|
|
||||||
> 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`);
|
> 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`);
|
||||||
> 归档目录里的图就是打包后 EXE 跑自动化验证时产出的同一份文件。
|
> 归档目录里的图就是打包后 EXE 跑自动化验证时产出的同一份文件。
|
||||||
|
> 不设置该变量时只做像素统计与断言、不落盘,验证同样通过。
|
||||||
|
|
||||||
|
**灵动岛不出现时怎么排查**(客户端是 GUI,看不到控制台,因此提供两条自查通道):
|
||||||
|
|
||||||
|
1. 客户端 → **设置 → 灵动岛(桌面通知浮窗)**:实时显示当前形态(隐藏 / 新消息胶囊 / 展开详情)、
|
||||||
|
是否处于上课时段、待查看条数与原因说明,并提供「测试灵动岛」按钮——
|
||||||
|
点击后本地推一条自检通知;能看到胶囊就说明灵动岛本身正常,问题在"没有通知到达"或"上课时段自动隐藏"。
|
||||||
|
2. 客户端日志:`%APPDATA%\@classhelper\desktop-client\logs\client.log`(超过 1MB 轮转为 `client.log.1`),
|
||||||
|
按时间记录灵动岛的每次状态流转,例如 `灵动岛:进入上课时间段,自动隐藏`、
|
||||||
|
`灵动岛暂存通知(上课中,下课后弹出)`、`灵动岛:下课,弹出上课期间暂存的通知`。
|
||||||
|
|
||||||
|
常见原因(按出现频率):
|
||||||
|
|
||||||
|
| 现象 | 原因与处理 |
|
||||||
|
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 一整天都不出现 | 客户端判定为"上课中"(课表覆盖当前时刻)→ 普通通知按设计暂存,紧急通知才立刻展开;下课后自动弹出 |
|
||||||
|
| 登录页时就期待出现 | 未登录不启动灵动岛桥接(`auth.token` 为空),登录后才有 |
|
||||||
|
| 点过「知道了」后消失 | 正常行为:该条已看过,队列为空时整体隐藏,下一条通知会再出现 |
|
||||||
|
| 换显示器 / 分辨率后看不到 | 已修复:显示器参数变化会自动重新定位到主屏顶部居中(此前会停留在旧坐标,可能跑到屏幕外) |
|
||||||
|
| 偶发"再也不出现" | 已修复:Windows 上 `hide()`/`show()` 异步生效,原先用 `isVisible()` 判断存在竞态(刚 hide 完仍报 true)→ 只做动画不再 show;现改为控制器自己维护显示标记 |
|
||||||
|
| 客户端在跑但服务端离线 | 实时通道断开时收不到新通知;恢复连接自动重连,历史未读仍可在"通知"页查看 |
|
||||||
|
|
||||||
### 离线缓存与自动同步
|
### 离线缓存与自动同步
|
||||||
|
|
||||||
@@ -403,6 +424,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
|
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
|
||||||
| **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸弹簧展开) |
|
| **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸弹簧展开) |
|
||||||
| 上课期间收起不回缩成胶囊 | ✅ `mode=hidden visible=false` |
|
| 上课期间收起不回缩成胶囊 | ✅ `mode=hidden visible=false` |
|
||||||
|
| **真实通知链路(Socket.IO → 灵动岛)** | ✅ 教师真实发布通知 → 客户端实时通道 → `mode=pill 可见=true 文案="新消息 张老师 · 点击查看"`(用后删除测试通知) |
|
||||||
| **普通通知直接显示胶囊(无入场动画)** | ✅ 推送前已隐藏,60ms 内窗口即为 `268x44`、`mode=pill`(无"上岛"动画) |
|
| **普通通知直接显示胶囊(无入场动画)** | ✅ 推送前已隐藏,60ms 内窗口即为 `268x44`、`mode=pill`(无"上岛"动画) |
|
||||||
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) |
|
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) |
|
||||||
| **点击卡片空白处回缩为胶囊** | ✅ 真实点击 `.body` 空白区域 → `mode=pill` |
|
| **点击卡片空白处回缩为胶囊** | ✅ 真实点击 `.body` 空白区域 → `mode=pill` |
|
||||||
@@ -594,14 +616,14 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
|
|
||||||
当前实测:
|
当前实测:
|
||||||
|
|
||||||
| 验证 | 结果 |
|
| 验证 | 结果 |
|
||||||
| ------------------------------------------------------------------------------- | ----------------------------------- |
|
| --------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ |
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ |
|
||||||
| `pnpm verify:web`(Web 管理端真实点击 + 手机小屏适配 + 上课时段紧急通知 + PWA) | **15/15** ✅ |
|
| `pnpm verify:web`(Web 管理端真实点击 + 手机小屏适配 + 上课时段紧急通知 + PWA) | **15/15** ✅ |
|
||||||
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛行为 + 像素级截图校验 + 离线回退) | **26/26** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛行为 + 真实通知链路 + 像素级校验 + 离线回退) | **27/27** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|
||||||
## 常见问题(本机环境已知坑)
|
## 常见问题(本机环境已知坑)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
@@ -62,6 +62,15 @@ class IslandController {
|
|||||||
private boundsToken = 0;
|
private boundsToken = 0;
|
||||||
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
|
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
|
||||||
private fadeToken = 0;
|
private fadeToken = 0;
|
||||||
|
/**
|
||||||
|
* 自己维护的"窗口当前是否显示"标记。
|
||||||
|
*
|
||||||
|
* 不直接用 `win.isVisible()`:Windows 上 hide/show 是异步生效的,
|
||||||
|
* 在 hide() 刚调用完的瞬间 isVisible() 仍可能返回 true。若此时来了一条新通知,
|
||||||
|
* 逻辑会误判成"窗口已经显示",于是只做尺寸动画、不调用 show(),
|
||||||
|
* 结果灵动岛再也不出现(真机上表现为"灵动岛消失了")。
|
||||||
|
*/
|
||||||
|
private shown = false;
|
||||||
|
|
||||||
private state: IslandState = {
|
private state: IslandState = {
|
||||||
mode: 'hidden',
|
mode: 'hidden',
|
||||||
@@ -115,6 +124,24 @@ class IslandController {
|
|||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
this.win = null;
|
this.win = null;
|
||||||
this.ready = false;
|
this.ready = false;
|
||||||
|
this.shown = false;
|
||||||
|
});
|
||||||
|
win.on('hide', () => {
|
||||||
|
this.shown = false;
|
||||||
|
});
|
||||||
|
win.on('show', () => {
|
||||||
|
this.shown = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示器变化(外接屏插拔/改分辨率)后重算顶部居中位置,避免灵动岛"跑"到屏幕外
|
||||||
|
const reposition = (): void => this.reposition();
|
||||||
|
screen.on('display-metrics-changed', reposition);
|
||||||
|
screen.on('display-added', reposition);
|
||||||
|
screen.on('display-removed', reposition);
|
||||||
|
win.on('closed', () => {
|
||||||
|
screen.off('display-metrics-changed', reposition);
|
||||||
|
screen.off('display-added', reposition);
|
||||||
|
screen.off('display-removed', reposition);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.win = win;
|
this.win = win;
|
||||||
@@ -308,6 +335,7 @@ class IslandController {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
if (!this.win || this.win.isDestroyed()) return;
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
|
this.shown = false;
|
||||||
if (this.win.isVisible()) this.win.hide();
|
if (this.win.isVisible()) this.win.hide();
|
||||||
this.win.setOpacity(1);
|
this.win.setOpacity(1);
|
||||||
}
|
}
|
||||||
@@ -438,7 +466,7 @@ class IslandController {
|
|||||||
: SIZES.expanded
|
: SIZES.expanded
|
||||||
: SIZES.pill;
|
: SIZES.pill;
|
||||||
|
|
||||||
if (!this.win.isVisible()) {
|
if (!this.shown) {
|
||||||
const pillBounds = this.computeBounds(SIZES.pill);
|
const pillBounds = this.computeBounds(SIZES.pill);
|
||||||
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画
|
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画
|
||||||
if (size === SIZES.pill) {
|
if (size === SIZES.pill) {
|
||||||
@@ -446,6 +474,7 @@ class IslandController {
|
|||||||
this.win.setOpacity(1);
|
this.win.setOpacity(1);
|
||||||
this.win.setBounds(pillBounds);
|
this.win.setBounds(pillBounds);
|
||||||
this.win.showInactive();
|
this.win.showInactive();
|
||||||
|
this.shown = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见
|
// 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见
|
||||||
@@ -453,6 +482,7 @@ class IslandController {
|
|||||||
this.win.setOpacity(0);
|
this.win.setOpacity(0);
|
||||||
this.win.setBounds(pillBounds);
|
this.win.setBounds(pillBounds);
|
||||||
this.win.showInactive();
|
this.win.showInactive();
|
||||||
|
this.shown = true;
|
||||||
this.animateBounds(size, { spring: true });
|
this.animateBounds(size, { spring: true });
|
||||||
this.fadeIn();
|
this.fadeIn();
|
||||||
return;
|
return;
|
||||||
@@ -460,6 +490,15 @@ class IslandController {
|
|||||||
this.animateBounds(size, { spring: size !== SIZES.pill });
|
this.animateBounds(size, { spring: size !== SIZES.pill });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 显示器参数变化后把窗口重新摆到当前主屏顶部居中 */
|
||||||
|
private reposition(): void {
|
||||||
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
|
const size = this.win.getBounds();
|
||||||
|
const target = this.computeBounds({ width: size.width, height: size.height });
|
||||||
|
this.boundsToken += 1;
|
||||||
|
this.win.setBounds(target);
|
||||||
|
}
|
||||||
|
|
||||||
/** 作废进行中的淡入淡出动画 */
|
/** 作废进行中的淡入淡出动画 */
|
||||||
private cancelFade(): void {
|
private cancelFade(): void {
|
||||||
this.fadeToken += 1;
|
this.fadeToken += 1;
|
||||||
@@ -479,7 +518,7 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fadeOut(): void {
|
private fadeOut(): void {
|
||||||
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return;
|
if (!this.win || this.win.isDestroyed() || !this.shown) return;
|
||||||
const token = ++this.fadeToken;
|
const token = ++this.fadeToken;
|
||||||
let opacity = this.win.getOpacity();
|
let opacity = this.win.getOpacity();
|
||||||
const step = (): void => {
|
const step = (): void => {
|
||||||
@@ -490,6 +529,7 @@ class IslandController {
|
|||||||
setTimeout(step, 16);
|
setTimeout(step, 16);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.shown = false;
|
||||||
this.win.hide();
|
this.win.hide();
|
||||||
this.win.setOpacity(1);
|
this.win.setOpacity(1);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,80 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主进程日志(控制台输出)。
|
* 主进程日志。
|
||||||
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
|
*
|
||||||
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
|
* 打包后的 GUI 进程没有可见的控制台,所以除了 console 输出,
|
||||||
* - 主窗口右上角的连接状态 / 灵动岛状态
|
* 关键信息还会追加写入 `%APPDATA%/<app>/logs/client.log`(超过 1MB 自动轮转一次),
|
||||||
* 对外可见,避免"出了问题却看不到日志"。
|
* 便于出问题时(例如"灵动岛去哪了")直接查看日志文件。
|
||||||
*/
|
*/
|
||||||
|
const MAX_LOG_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
let logFile: string | null = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
function timestamp(): string {
|
function timestamp(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureLogFile(): string | null {
|
||||||
|
if (initialized) return logFile;
|
||||||
|
initialized = true;
|
||||||
|
try {
|
||||||
|
const dir = path.join(app.getPath('userData'), 'logs');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
logFile = path.join(dir, 'client.log');
|
||||||
|
if (fs.existsSync(logFile) && fs.statSync(logFile).size > MAX_LOG_BYTES) {
|
||||||
|
fs.renameSync(logFile, path.join(dir, 'client.log.1'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logFile = null;
|
||||||
|
}
|
||||||
|
return logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(level: string, message: string, args: unknown[]): void {
|
||||||
|
const extra = args.length > 0 ? ` ${args.map((item) => safeStringify(item)).join(' ')}` : '';
|
||||||
|
const line = `[${timestamp()}] ${level} ${message}${extra}\n`;
|
||||||
|
const file = ensureLogFile();
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(file, line, 'utf8');
|
||||||
|
} catch {
|
||||||
|
/* 日志写失败不能影响主流程 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeStringify(value: unknown): string {
|
||||||
|
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value) ?? String(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
debug(message: string, ...args: unknown[]): void {
|
debug(message: string, ...args: unknown[]): void {
|
||||||
console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
|
console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
|
||||||
|
write('DEBUG', message, args);
|
||||||
},
|
},
|
||||||
info(message: string, ...args: unknown[]): void {
|
info(message: string, ...args: unknown[]): void {
|
||||||
console.log(`[${timestamp()}] INFO ${message}`, ...args);
|
console.log(`[${timestamp()}] INFO ${message}`, ...args);
|
||||||
|
write('INFO ', message, args);
|
||||||
},
|
},
|
||||||
warn(message: string, ...args: unknown[]): void {
|
warn(message: string, ...args: unknown[]): void {
|
||||||
console.warn(`[${timestamp()}] WARN ${message}`, ...args);
|
console.warn(`[${timestamp()}] WARN ${message}`, ...args);
|
||||||
|
write('WARN ', message, args);
|
||||||
},
|
},
|
||||||
error(message: string, ...args: unknown[]): void {
|
error(message: string, ...args: unknown[]): void {
|
||||||
console.error(`[${timestamp()}] ERROR ${message}`, ...args);
|
console.error(`[${timestamp()}] ERROR ${message}`, ...args);
|
||||||
|
write('ERROR', message, args);
|
||||||
|
},
|
||||||
|
/** 当前日志文件路径(设置页/排查时展示) */
|
||||||
|
filePath(): string | null {
|
||||||
|
return ensureLogFile();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -82,14 +82,19 @@ function analyzeBitmap(image: NativeImage): {
|
|||||||
return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish };
|
return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */
|
/**
|
||||||
|
* 灵动岛状态截图留档 + 像素统计。
|
||||||
|
*
|
||||||
|
* 像素统计**总是执行**(断言不依赖环境变量);写盘留档仅在设置了
|
||||||
|
* `ISLAND_SHOTS_DIR` 时进行(用于 docs/screenshots 归档与人工复核)。
|
||||||
|
*/
|
||||||
async function captureIsland(
|
async function captureIsland(
|
||||||
name: string,
|
name: string,
|
||||||
expected: { width: number; height: number },
|
expected: { width: number; height: number },
|
||||||
): Promise<IslandShotStats | null> {
|
): Promise<IslandShotStats | null> {
|
||||||
const dir = process.env.ISLAND_SHOTS_DIR;
|
const dir = process.env.ISLAND_SHOTS_DIR ?? '';
|
||||||
const win = island.getWindow();
|
const win = island.getWindow();
|
||||||
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return null;
|
if (!win || win.isDestroyed() || !win.isVisible()) return null;
|
||||||
try {
|
try {
|
||||||
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
|
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
|
||||||
await win.webContents.executeJavaScript(
|
await win.webContents.executeJavaScript(
|
||||||
@@ -100,9 +105,12 @@ async function captureIsland(
|
|||||||
await win.webContents.executeJavaScript(
|
await win.webContents.executeJavaScript(
|
||||||
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
|
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
|
||||||
);
|
);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
const filePath = path.join(dir, `${name}.png`);
|
const filePath = dir ? path.join(dir, `${name}.png`) : '';
|
||||||
fs.writeFileSync(filePath, image.toPNG());
|
if (filePath) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, image.toPNG());
|
||||||
|
}
|
||||||
|
|
||||||
const size = image.getSize();
|
const size = image.getSize();
|
||||||
const stats: IslandShotStats = {
|
const stats: IslandShotStats = {
|
||||||
@@ -115,7 +123,7 @@ async function captureIsland(
|
|||||||
};
|
};
|
||||||
islandShots.push(stats);
|
islandShots.push(stats);
|
||||||
console.log(
|
console.log(
|
||||||
`[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` +
|
`[SMOKE] 灵动岛截图:${filePath || '(仅内存统计)'}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` +
|
||||||
`颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})`,
|
`颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})`,
|
||||||
);
|
);
|
||||||
return stats;
|
return stats;
|
||||||
@@ -403,6 +411,67 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
|||||||
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
|
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实通知链路自检:教师发通知 → 服务端广播 → 客户端实时通道 → 灵动岛。
|
||||||
|
*
|
||||||
|
* 与 runIslandChecks 的区别:那里是直接调 IPC 验证状态机;这里走完
|
||||||
|
* "HTTP 发布 + Socket.IO 广播 + 渲染进程 realtime store + bridge + IPC" 全链路,
|
||||||
|
* 能抓到"状态机没问题但真机不弹"的问题。
|
||||||
|
*/
|
||||||
|
async function runIslandRealtimeCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> {
|
||||||
|
const scenario = await win.webContents.executeJavaScript(
|
||||||
|
`(async () => {
|
||||||
|
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||||
|
return await window.__classhelperSmoke__.islandRealtimeScenario();
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!scenario?.ok) {
|
||||||
|
return { ok: false, detail: `投递失败:${scenario?.detail ?? '未知原因'}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等通知经实时通道到达并弹出胶囊(实测 <100ms,这里留足余量)
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
let state = island.getState();
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
state = island.getState();
|
||||||
|
if (state.mode === 'pill' && state.active?.id === scenario.notificationId) break;
|
||||||
|
await sleep(120);
|
||||||
|
}
|
||||||
|
const visible = island.getWindow()?.isVisible() ?? false;
|
||||||
|
const domText = await island
|
||||||
|
.getWindow()
|
||||||
|
?.webContents.executeJavaScript(
|
||||||
|
`(document.querySelector('.island-card.pill')?.textContent ?? '').replace(/\\s+/g, ' ').trim()`,
|
||||||
|
)
|
||||||
|
.catch(() => '');
|
||||||
|
await captureIsland('island-6-realtime', ISLAND_SIZES.pill);
|
||||||
|
|
||||||
|
const matched = state.mode === 'pill' && state.active?.id === scenario.notificationId;
|
||||||
|
const detail =
|
||||||
|
`通知=${scenario.notificationId} 客户端判定上课中=${scenario.inClass} ` +
|
||||||
|
`灵动岛 mode=${state.mode} 可见=${visible} 文案="${String(domText).slice(0, 40)}"`;
|
||||||
|
|
||||||
|
// 清理:删除本次自检产生的通知,避免污染演示数据
|
||||||
|
const cleanup = await win.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(async () => {
|
||||||
|
if (!window.__classhelperSmoke__?.islandRealtimeCleanup) return { ok: false, detail: '缺少清理钩子' };
|
||||||
|
return await window.__classhelperSmoke__.islandRealtimeCleanup(
|
||||||
|
${JSON.stringify(scenario.notificationId)},
|
||||||
|
${JSON.stringify(scenario.teacherToken)},
|
||||||
|
);
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
console.log(`[SMOKE] 真实链路清理:${cleanup?.detail ?? '未执行'}`);
|
||||||
|
|
||||||
|
// 收尾:把胶囊收起来,避免影响后续用例
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(300);
|
||||||
|
|
||||||
|
return { ok: matched && visible, detail };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
||||||
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
||||||
@@ -545,6 +614,11 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
String(online?.detail ?? ''),
|
String(online?.detail ?? ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 真实通知链路:教师发通知 → 学生客户端实时通道收到 → 灵动岛弹出胶囊
|
||||||
|
// (前面的灵动岛用例只验证状态机,这一条验证"真机上通知真的会浮出来")
|
||||||
|
const realtime = await runIslandRealtimeCheck(win);
|
||||||
|
record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.ok, realtime.detail);
|
||||||
|
|
||||||
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
|
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
|
||||||
const navigation = await win.webContents.executeJavaScript(
|
const navigation = await win.webContents.executeJavaScript(
|
||||||
`(async () => {
|
`(async () => {
|
||||||
|
|||||||
@@ -215,6 +215,88 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟自检 5(需后端在线):真实通知链路 → 灵动岛。
|
||||||
|
*
|
||||||
|
* 前面的灵动岛用例是直接调 IPC 验证"状态机",这一条走**真机链路**:
|
||||||
|
* 教师账号给"当前登录学生所在班级"发一条普通通知 → 服务端 Socket.IO 广播
|
||||||
|
* → 客户端 realtime store → bridge → 主进程灵动岛。主进程随后断言灵动岛弹出胶囊。
|
||||||
|
*
|
||||||
|
* 返回临时信息(通知 id / 教师令牌 / 标题)供主进程断言与收尾清理。
|
||||||
|
*/
|
||||||
|
export async function islandRealtimeScenario(): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
notificationId?: string;
|
||||||
|
teacherToken?: string;
|
||||||
|
title?: string;
|
||||||
|
classId?: string;
|
||||||
|
inClass?: boolean;
|
||||||
|
}> {
|
||||||
|
const [{ useAuthStore }, { getIslandClassContext }] = await Promise.all([
|
||||||
|
import('../stores/auth.js'),
|
||||||
|
import('../island/bridge.js'),
|
||||||
|
]);
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const serverUrl = 'http://127.0.0.1:4000';
|
||||||
|
const classId = auth.classId ?? null;
|
||||||
|
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
|
||||||
|
|
||||||
|
const title = `灵动岛真机链路自检 ${Date.now()}`;
|
||||||
|
try {
|
||||||
|
const login = await fetch(`${serverUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
|
||||||
|
}).then((response) => response.json());
|
||||||
|
const teacherToken: string | undefined = login?.data?.token;
|
||||||
|
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
|
||||||
|
|
||||||
|
const created = await fetch(`${serverUrl}/api/notifications`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
classId,
|
||||||
|
title,
|
||||||
|
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
}),
|
||||||
|
}).then((response) => response.json());
|
||||||
|
const notificationId: string | undefined = created?.data?.id;
|
||||||
|
if (!notificationId)
|
||||||
|
return { ok: false, detail: `发布通知失败:${JSON.stringify(created).slice(0, 160)}` };
|
||||||
|
|
||||||
|
const context = getIslandClassContext();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
detail: `classId=${classId} 通知=${notificationId} 客户端判定上课中=${context.inClass}`,
|
||||||
|
notificationId,
|
||||||
|
teacherToken,
|
||||||
|
title,
|
||||||
|
classId,
|
||||||
|
inClass: context.inClass,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, detail: `投递失败:${error instanceof Error ? error.message : String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冒烟收尾:删除真实链路自检产生的通知 */
|
||||||
|
export async function islandRealtimeCleanup(
|
||||||
|
notificationId: string,
|
||||||
|
teacherToken: string,
|
||||||
|
): Promise<SmokeCheckResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { authorization: `Bearer ${teacherToken}` },
|
||||||
|
});
|
||||||
|
return { ok: response.ok, detail: `清理通知 ${notificationId} → HTTP ${response.status}` };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
|
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
|
||||||
export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||||
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
||||||
@@ -237,6 +319,8 @@ export function registerSmokeHooks(): void {
|
|||||||
offlineScenario,
|
offlineScenario,
|
||||||
layoutNavigationSelfTest,
|
layoutNavigationSelfTest,
|
||||||
onlineScenario,
|
onlineScenario,
|
||||||
|
islandRealtimeScenario,
|
||||||
|
islandRealtimeCleanup,
|
||||||
sessionCleanup,
|
sessionCleanup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { formatDate } from '@classhelper/shared';
|
import { PRIORITY_LABELS, formatDate, type IslandState } from '@classhelper/shared';
|
||||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||||
import { normalizeServerUrl } from '../config.js';
|
import { normalizeServerUrl } from '../config.js';
|
||||||
|
import { getIslandClassContext } from '../island/bridge.js';
|
||||||
import { useAppStore } from '../stores/app.js';
|
import { useAppStore } from '../stores/app.js';
|
||||||
import { useAuthStore } from '../stores/auth.js';
|
import { useAuthStore } from '../stores/auth.js';
|
||||||
import { useNotificationStore } from '../stores/notifications.js';
|
import { useNotificationStore } from '../stores/notifications.js';
|
||||||
@@ -102,9 +103,75 @@ async function logout(): Promise<void> {
|
|||||||
await router.replace('/login');
|
await router.replace('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 灵动岛自检 */
|
||||||
|
|
||||||
|
const islandState = ref<IslandState | null>(null);
|
||||||
|
const islandClassContext = ref(getIslandClassContext());
|
||||||
|
let islandTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** 主进程侧灵动岛状态(隐藏/胶囊/详情 + 待发条数 + 是否上课) */
|
||||||
|
async function refreshIslandState(): Promise<void> {
|
||||||
|
islandClassContext.value = getIslandClassContext();
|
||||||
|
if (!window.desktop?.islandGetState) return;
|
||||||
|
islandState.value = await window.desktop.islandGetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const islandModeLabel = computed(() => {
|
||||||
|
switch (islandState.value?.mode) {
|
||||||
|
case 'pill':
|
||||||
|
return '新消息胶囊';
|
||||||
|
case 'expanded':
|
||||||
|
return '展开详情';
|
||||||
|
case 'hidden':
|
||||||
|
return '隐藏(无通知时不显示)';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const islandHint = computed(() => {
|
||||||
|
const state = islandState.value;
|
||||||
|
if (!state) return '灵动岛尚未就绪';
|
||||||
|
if (islandClassContext.value.inClass) {
|
||||||
|
return state.active?.priority === 'URGENT'
|
||||||
|
? '上课中:正在显示紧急通知'
|
||||||
|
: '上课中:通知会暂存,下课后自动弹出(紧急通知除外)';
|
||||||
|
}
|
||||||
|
if (state.queued.length > 0) return `有 ${state.queued.length} 条通知待查看`;
|
||||||
|
return '收到通知时会在这里浮出胶囊';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 本地推一条测试通知,立刻确认灵动岛能出现(走主进程,不依赖服务器) */
|
||||||
|
function testIsland(): void {
|
||||||
|
if (!window.desktop?.islandPush) {
|
||||||
|
ElMessage.error('当前环境不支持灵动岛');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.desktop.islandPush({
|
||||||
|
notification: {
|
||||||
|
id: `local-test-${Date.now()}`,
|
||||||
|
title: '灵动岛自检通知',
|
||||||
|
content: '如果你看到这条胶囊/卡片,说明灵动岛工作正常;点击它可以展开查看详情。',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
courseName: null,
|
||||||
|
teacherName: '本地自检',
|
||||||
|
},
|
||||||
|
context: islandClassContext.value,
|
||||||
|
});
|
||||||
|
void refreshIslandState();
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadAppInfo();
|
await loadAppInfo();
|
||||||
await appStore.refreshCacheStats();
|
await appStore.refreshCacheStats();
|
||||||
|
await refreshIslandState();
|
||||||
|
islandTimer = setInterval(() => void refreshIslandState(), 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (islandTimer) clearInterval(islandTimer);
|
||||||
|
islandTimer = null;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -150,6 +217,36 @@ onMounted(async () => {
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</el-card>
|
</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="当前形态">
|
||||||
|
<el-tag :type="islandState?.mode === 'hidden' ? 'info' : 'success'" size="small">
|
||||||
|
{{ islandModeLabel }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag class="ml-8" :type="islandClassContext.inClass ? 'warning' : 'success'" size="small">
|
||||||
|
{{ islandClassContext.inClass ? '上课中(普通通知暂存)' : '非上课时段' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="窗口可见">
|
||||||
|
{{ islandState ? (islandState.mode === 'hidden' ? '否(无通知时隐藏)' : '是') : '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="待查看通知">
|
||||||
|
{{ islandState?.queued.length ?? 0 }} 条
|
||||||
|
<template v-if="islandState?.active">
|
||||||
|
· 当前:{{ islandState.active.title }}({{ PRIORITY_LABELS[islandState.active.priority] }})
|
||||||
|
</template>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="说明">{{ islandHint }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="toolbar mt-12">
|
||||||
|
<el-button type="primary" @click="testIsland">测试灵动岛</el-button>
|
||||||
|
<el-button @click="refreshIslandState">刷新状态</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never" class="mt-12">
|
<el-card shadow="never" class="mt-12">
|
||||||
<template #header><span>账号信息</span></template>
|
<template #header><span>账号信息</span></template>
|
||||||
<el-descriptions :column="1" border size="small">
|
<el-descriptions :column="1" border size="small">
|
||||||
|
|||||||
+14
@@ -76,6 +76,20 @@ declare global {
|
|||||||
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
||||||
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
/** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */
|
||||||
|
islandRealtimeScenario(): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
notificationId?: string;
|
||||||
|
teacherToken?: string;
|
||||||
|
title?: string;
|
||||||
|
classId?: string;
|
||||||
|
inClass?: boolean;
|
||||||
|
}>;
|
||||||
|
islandRealtimeCleanup(
|
||||||
|
notificationId: string,
|
||||||
|
teacherToken: string,
|
||||||
|
): Promise<{ ok: boolean; detail: string }>;
|
||||||
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user