Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c27586af9 | ||
|
|
7536abfc95 | ||
|
|
d0830f6830 | ||
|
|
fe41ebb7d6 | ||
|
|
14ac16ff29 | ||
|
|
d900280760 | ||
|
|
337d6eae53 | ||
|
|
a7b0e9c2b3 | ||
|
|
70250151d0 | ||
|
|
845fbdd863 | ||
|
|
0ff2dc228b | ||
|
|
11008928a1 | ||
|
|
b8ef5d8dbb | ||
|
|
86430c5f4a | ||
|
|
d8b0eb05c2 | ||
|
|
80a0efbcfe | ||
|
|
b69f708aa2 | ||
|
|
0690546307 | ||
|
|
1b4dd14e4a | ||
|
|
e1e7e6ef5d | ||
|
|
7a4668cb1b | ||
|
|
93447922dc | ||
|
|
a5c717e33e | ||
|
|
3f2b135211 | ||
|
|
9c9ac69ac6 | ||
|
|
97a89de944 | ||
|
|
21485ea294 | ||
|
|
910e520d09 | ||
|
|
b07ee4f8d7 | ||
|
|
d11f8e43a1 | ||
|
|
1073436ad6 | ||
|
|
8b85079d81 | ||
|
|
0dcab6f369 | ||
|
|
30e4c36809 | ||
|
|
71f43042ca | ||
|
|
6b8563d0d3 | ||
|
|
219f6ae83a |
No files matched your search
@@ -38,3 +38,6 @@ eslint-report.json
|
|||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# 参考用的第三方源码包(设计 token 已提取到 docs/winisland-design-tokens.md)
|
||||||
|
WinIsland-master.zip
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
# AGENTS.md — 班级小助手(Class Helper)
|
||||||
|
|
||||||
|
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 这是什么
|
||||||
|
|
||||||
|
班级信息管理系统,**pnpm monorepo**,一份代码产出三个交付物:
|
||||||
|
|
||||||
|
| 交付物 | 位置 | 形态 |
|
||||||
|
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| 后端服务 | `packages/server` | Express + Prisma + Socket.IO,`/api` 前缀 |
|
||||||
|
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用) |
|
||||||
|
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
|
||||||
|
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
||||||
|
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
|
||||||
|
|
||||||
|
核心链路:
|
||||||
|
|
||||||
|
```
|
||||||
|
教师在 Web 端发布内容
|
||||||
|
│ REST API(JWT 鉴权)
|
||||||
|
▼
|
||||||
|
后端服务(Express + Prisma)
|
||||||
|
│ 写库 + 按班级房间广播
|
||||||
|
▼
|
||||||
|
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
|
||||||
|
|
||||||
|
ClassIsland(教室机器)──► 班级小助手联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
|
||||||
|
▲ │
|
||||||
|
└──── 老师下发的提醒(上报返回值里顺带带回)◄┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
|
||||||
|
- 默认分支:`master`
|
||||||
|
- 语言:**文档、注释、提交信息、界面文案一律中文**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目录速查
|
||||||
|
|
||||||
|
```
|
||||||
|
package.json 根脚本(dev / build / db:* / verify:* / dist:* / icons)
|
||||||
|
pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白名单,勿随手删条目)
|
||||||
|
tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
|
||||||
|
eslint.config.mjs ESLint 扁平配置(TS + Vue)
|
||||||
|
packages/shared/src/ types / constants / permissions / utils
|
||||||
|
packages/server/
|
||||||
|
prisma/schema.prisma 数据模型(sqlite;另有 schema.mysql.prisma)
|
||||||
|
prisma/migrations/ 迁移历史(已生成并应用)
|
||||||
|
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
|
||||||
|
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
|
||||||
|
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
|
||||||
|
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
|
||||||
|
src/config/env.ts zod 环境变量校验 + 生产自检
|
||||||
|
src/lib/ db / jwt / access(RBAC) / http / mappers / term / web-static
|
||||||
|
src/middleware/ auth / validate / error / security
|
||||||
|
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
|
||||||
|
src/modules/ 功能模块 + registry.ts(模块注册表)
|
||||||
|
scripts/verify-e2e.mjs 后端端到端验收(163 项)
|
||||||
|
packages/web-admin/src/ api / stores / router / layouts / views / styles / components
|
||||||
|
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
|
||||||
|
manifest.yml 插件清单:id / apiVersion / entranceAssembly / version
|
||||||
|
src/Plugin.cs 入口:读 Settings.json → 注册提醒提供方 / 设置页 / BridgeService
|
||||||
|
src/Services/ BridgeService(上报·接收·镜像)/ ScheduleMapper / ClassPlanWriter
|
||||||
|
ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
|
||||||
|
src/Views/ BridgeSettingsPage.axaml(.cs):Avalonia 设置页
|
||||||
|
src/Interop/ ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO
|
||||||
|
scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验)
|
||||||
|
packages/desktop-client/
|
||||||
|
src/main/ 主进程:窗口 / 单实例 / IPC 配置 / 托盘 / 灵动岛 / smoke
|
||||||
|
src/preload/ contextBridge 白名单桥(**不暴露 ipcRenderer 本体**)
|
||||||
|
src/island/ 灵动岛独立透明置顶窗口的渲染进程
|
||||||
|
src/renderer/ 学生端渲染进程:api / stores / cache(IndexedDB) / views
|
||||||
|
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
|
||||||
|
scripts/ build-main / dev / smoke / dist-win
|
||||||
|
scripts/ use-database.mjs / generate-icons.mjs / dist-server.mjs / ui-smoke/
|
||||||
|
deploy/ Dockerfile / docker-compose.yml / nginx.conf / install-linux.sh
|
||||||
|
docs/ production.md(生产部署)/ mysql.md / winisland-design-tokens.md
|
||||||
|
README.md 完整产品与交付说明(改动交付形态时同步更新)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 本机环境(已配置好,可直接开工)
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Node | v22.15.0(要求 ≥ 20.19,Prisma 7 硬性要求) |
|
||||||
|
| pnpm | 11.19.0(`packageManager` 锁定 11.8.0) |
|
||||||
|
| npm registry | `https://registry.npmmirror.com/`(全局 pnpm 配置;仓库内无 `.npmrc`) |
|
||||||
|
| 依赖 | `pnpm install` 已完成(707 包) |
|
||||||
|
| 后端 `.env` | 已由 `.env.example` 生成 `packages/server/.env`(**gitignored**),`JWT_SECRET` 为本机随机值 |
|
||||||
|
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
|
||||||
|
| 数据库 | SQLite `packages/server/prisma/dev.db`,5 个迁移已应用、种子已写入 |
|
||||||
|
| Electron 二进制 | 已下载(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`) |
|
||||||
|
| .NET SDK | 8.0.303(构建 ClassIsland 插件用;插件与 pnpm workspace 无关,不参与 `pnpm install`) |
|
||||||
|
| ClassIsland 本体 | `F:\classisland\ClassIsland.Desktop.exe`(2.1.0.1,与本插件目标版本一致);**数据根目录是 `F:\data`**(配置 `F:\data\Config`、档案 `F:\data\Profiles`、日志 `F:\data\Logs`、插件 `F:\data\Plugins\<id>`),因此插件可以真跑起来验证 |
|
||||||
|
|
||||||
|
### 从零重建环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 后端环境变量(.env 不入库,必须自己生成)
|
||||||
|
cp packages/server/.env.example packages/server/.env # 然后至少改 JWT_SECRET
|
||||||
|
|
||||||
|
pnpm db:generate
|
||||||
|
pnpm db:deploy # 应用迁移(见 §7 第 6 条:不要用 db:migrate)
|
||||||
|
pnpm db:seed # 写演示数据
|
||||||
|
|
||||||
|
pnpm build:shared # 三端都依赖 shared 的 dist,先构建它
|
||||||
|
```
|
||||||
|
|
||||||
|
Electron 二进制**不会**随 `pnpm install` 自动下载(本机 postinstall 被跳过),需要手动补:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ELECTRON_MIRROR="https://cdn.npmmirror.com/binaries/electron/"
|
||||||
|
$env:ELECTRON_CACHE="F:\ClassHelper\.cache\electron"
|
||||||
|
$env:NODE_OPTIONS="--use-system-ca"
|
||||||
|
node install.js # 工作目录:node_modules/.pnpm/electron@44.3.0/node_modules/electron
|
||||||
|
```
|
||||||
|
|
||||||
|
### 演示账号(`pnpm db:seed` 产出)
|
||||||
|
|
||||||
|
| 角色 | 用户名 | 密码 |
|
||||||
|
| -------------- | ------------------------------- | ------------ |
|
||||||
|
| 管理员 | `admin` | `admin123` |
|
||||||
|
| 班主任 | `teacher1` / `teacher2` | `teacher123` |
|
||||||
|
| 学生个人号 | `student01` … `student15` | `student123` |
|
||||||
|
| 学生端班级账号 | 班级码 `G101` / `G102` / `G103` | `123456` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 常用命令
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
| --------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
|
||||||
|
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||||
|
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||||
|
| `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 |
|
||||||
|
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
|
||||||
|
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||||
|
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) |
|
||||||
|
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` |
|
||||||
|
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||||
|
| `pnpm lint` / `pnpm lint:fix` | ESLint |
|
||||||
|
| `pnpm format` / `pnpm format:check` | Prettier |
|
||||||
|
| `pnpm db:generate` | 生成 Prisma Client |
|
||||||
|
| `pnpm db:deploy` | 应用已有迁移(**本机推荐**) |
|
||||||
|
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
|
||||||
|
| `pnpm db:studio` | Prisma Studio |
|
||||||
|
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider(配合 `docs/mysql.md`) |
|
||||||
|
| `pnpm verify:e2e` | 后端端到端验收 163 项(**需后端已启动**) |
|
||||||
|
| `pnpm verify:web` | Web 管理端真实点击回归 27 项(需后端已启动且 Web 产物已构建) |
|
||||||
|
| `pnpm verify:desktop` | 客户端冒烟 77+ 项(Electron,无人工点击) |
|
||||||
|
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟 |
|
||||||
|
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约,需后端已启动) |
|
||||||
|
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) |
|
||||||
|
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 两者 |
|
||||||
|
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
|
||||||
|
|
||||||
|
### 端口
|
||||||
|
|
||||||
|
| 端口 | 用途 |
|
||||||
|
| ---- | --------------------------------------------------------- |
|
||||||
|
| 4000 | 后端 REST + Socket.IO + (生产/构建后)Web 管理端静态托管 |
|
||||||
|
| 5173 | Web 管理端开发服务器(`/api`、`/socket.io` 代理到 4000) |
|
||||||
|
| 5174 | 桌面客户端渲染进程开发服务器 |
|
||||||
|
|
||||||
|
### 交付产物归集(本机约定:`releases/`)
|
||||||
|
|
||||||
|
打包产物统一归集到仓库根的 `releases/`(已被忽略,不入库):
|
||||||
|
|
||||||
|
```
|
||||||
|
releases/
|
||||||
|
client/ win-unpacked/ 免安装目录(verify:packaged 直接指它)
|
||||||
|
班级小助手-<版本>-x64-setup.exe 学生端 NSIS 安装包
|
||||||
|
班级小助手-<版本>-x64-portable.exe 学生端单文件版
|
||||||
|
server/ classhelper-server/ 免安装目录(内置 node.exe,双击 start.cmd 即用)
|
||||||
|
班级小助手服务端-<版本>-x64-setup.exe 服务端安装程序(含 Web 管理端)
|
||||||
|
classisland-plugin/
|
||||||
|
ClassHelper.ClassIslandPlugin.cipx ClassIsland 插件包(从文件安装)
|
||||||
|
ClassHelper.ClassIslandPlugin/ 免安装目录(直接丢进 ClassIsland 的 Plugins)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 客户端:electron-builder 支持直接指定输出目录(dist-win.mjs 会把额外参数透传给它)
|
||||||
|
pnpm build:desktop
|
||||||
|
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
|
||||||
|
|
||||||
|
# 服务端:脚本输出路径写死在 release-server/,打完需要自己移进 releases/server/
|
||||||
|
pnpm dist:server # 首次会跑 46s 左右的 npm install;重试可加 --reuse-deps 跳过
|
||||||
|
|
||||||
|
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
|
||||||
|
pnpm dist:classisland-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 架构硬约定(改代码前必须知道)
|
||||||
|
|
||||||
|
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
|
||||||
|
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。新增/下线功能**只改
|
||||||
|
`src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
|
||||||
|
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
|
||||||
|
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
|
||||||
|
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
|
||||||
|
`@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/ STUDENT / NONE`,
|
||||||
|
权限判 `canManageSchedule` / `canPublishContent` / `canManageRoster` 等,**不要在页面里另写一套规则**。
|
||||||
|
4. **学生端主体是「班级账号」。** 班级码 + 班级密码登录后,`classId` 即身份;该设备的「已读 / 完成」
|
||||||
|
代全班操作,个人学生登录已停用。API 里对此有专门分支,别按个人账号语义改。
|
||||||
|
5. **实时推送只走房间。** 服务端 `src/realtime/`(`socket.ts` 房间 `class:{classId}`、`user:{id}`;`bus.ts` 事件总线),
|
||||||
|
客户端 `renderer/stores/realtime.ts` 订阅。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、
|
||||||
|
客户端订阅(灵动岛也依赖它)。
|
||||||
|
6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`,
|
||||||
|
改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。
|
||||||
|
7. **数据模型跨库通用。** `schema.prisma` 只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、
|
||||||
|
不用 `@db.*`;角色与优先级存字符串并在 API 层用 zod 校验(这样 SQLite / MySQL 行为一致)。
|
||||||
|
改模型要同步 `schema.mysql.prisma` 和 `prisma/migrations/`。
|
||||||
|
8. **周次口径唯一。** 当前教学周 = `shared.resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)`,
|
||||||
|
`MAX_TERM_WEEK = 30`,班级还有自己的 `Class.termWeeks`(默认 20)。别在别处重新实现周次换算。
|
||||||
|
9. **Electron 安全边界。** `src/preload/` 只通过 contextBridge 暴露白名单方法,**不要暴露 `ipcRenderer` 本体**;
|
||||||
|
主进程 ↔ 渲染进程的类型统一写在 `src/types/desktop.d.ts`。
|
||||||
|
|
||||||
|
**跨桥接只能传纯数据。** contextBridge 在参数跨越"主窗口 → 隔离世界"时就做结构化克隆,
|
||||||
|
传 Vue 的 `ref.value` / `reactive()` 对象(Proxy)会抛 `An object could not be cloned.`——
|
||||||
|
而且它在**进入 preload 函数体之前**就失败了,所以桥接层**无法兜底**,只能由调用方展开成
|
||||||
|
字面量再传(`saveConfig({ island: { ...island.value } })`)。这类失败是"半静默"的:
|
||||||
|
界面数字照常变化、只多一行 console error,看起来就是"设置不生效"。设置页曾因此翻车,
|
||||||
|
`verify:desktop` 里的「设置页真实 UI:拖拽滑块即时改变灵动岛外观」就是为它加的回归。
|
||||||
|
|
||||||
|
10. **客户端离线优先。** 渲染进程数据先落 IndexedDB(`renderer/cache/`),断网回退缓存并在顶部提示离线;
|
||||||
|
新增页面/数据源要按这个模式接。
|
||||||
|
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
|
||||||
|
(`--ch-radius-*` / `--ch-shadow-*`),组件里**不要写死数值**;Element Plus 通过覆盖它的 CSS 变量对齐。
|
||||||
|
手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card` 约定。
|
||||||
|
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256)
|
||||||
|
调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。新增插件接口时,
|
||||||
|
路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO
|
||||||
|
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
|
||||||
|
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里
|
||||||
|
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
|
||||||
|
而且表现为"课表看着有内容、每节课都对不上",极难排查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
|
||||||
|
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
|
||||||
|
否则又会出现「某个页面发的通知不联动 ClassIsland」。
|
||||||
|
15. **提醒弹在哪个端由教室客户端选**,值存在 `Class.notificationChannel`(`both` / `client` / `classisland`):
|
||||||
|
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
|
||||||
|
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
|
||||||
|
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
|
||||||
|
|
||||||
|
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
|
||||||
|
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
|
||||||
|
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
|
||||||
|
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课),
|
||||||
|
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
|
||||||
|
服务端 `synthesizeTimeLayout` 同口径。
|
||||||
|
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
|
||||||
|
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);`urgent` 同样立刻弹。
|
||||||
|
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
|
||||||
|
客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。
|
||||||
|
19. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期
|
||||||
|
(`YYYY-MM-DD`,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。
|
||||||
|
UTC 切片(`toISOString().slice(0,10)`)在晚上的录入会算到第二天 —— 统一用 `@classhelper/shared` 的
|
||||||
|
`dayKeyLocal / isDayKey / shiftDayKey`。新增按天接口时同样用 `?date=YYYY-MM-DD` + zod 的 `isDayKey` 校验。
|
||||||
|
20. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在
|
||||||
|
`homeworks.service.createHomework` 里判(staff 或本班班级账号)。班级账号的 `sub` 是**班级 id**,
|
||||||
|
写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键。
|
||||||
|
21. **灵动岛不要用窗口背景材质(`win.setBackgroundMaterial()`)**。它看起来像 WinIsland 的
|
||||||
|
`DWMWA_USE_HOSTBACKDROPBRUSH`,其实是**整窗系统材质**:刷在整个窗口矩形上(岛是"固定大包围盒窗口",
|
||||||
|
卡片之外还有留白),且明暗跟随应用主题(本客户端是浅色 Fluent 主题 ⇒ 材质必然是浅色),
|
||||||
|
于是卡片周围会出现一圈浅色"白底面板"。更坑的是**调用过一次就再也回不去逐像素透明**
|
||||||
|
(含 `'none'`:Electron 会让窗口回落到默认不透明底色 `#FFF`),所以从 `glass` 切到
|
||||||
|
`black`/`tinted` 后会永久留一圈**纯白**。CSS `backdrop-filter` 也替代不了(透明窗口里采样不到桌面)。
|
||||||
|
`glass` 因此用 CSS 半透明深色卡(WinIsland 无 backdrop 时的降级色 `rgba(32,32,36,.804)`)。
|
||||||
|
回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它**整屏截图**取样
|
||||||
|
(`webContents.capturePage()` 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因),
|
||||||
|
并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。
|
||||||
|
|
||||||
|
## 6. 代码风格
|
||||||
|
|
||||||
|
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
||||||
|
(写 `./foo.js`,即使源文件是 `foo.ts`)。`strict` 开启。
|
||||||
|
- **Prettier**:单引号、`printWidth: 110`、`trailingComma: all`、`arrowParens: always`、行尾 LF、缩进 2 空格。
|
||||||
|
- **ESLint**(`eslint.config.mjs`):`no-explicit-any` 为 warn;未使用变量为 warn,可用 `_` 前缀豁免;
|
||||||
|
`consistent-type-imports` 建议用 `import type`。`.cjs`(Electron 图标渲染、UI 冒烟)允许 `require`。
|
||||||
|
- **Vue**:视图命名 `XxxView.vue`,组件多单词;路由入口用 `meta.roles` 控制(如 `['ADMIN']`),
|
||||||
|
隐藏入口之外服务端也必须拦(前端隐藏 ≠ 权限)。
|
||||||
|
- **提交信息**:Conventional Commits + 模块 scope,描述用中文,可多 scope,例如
|
||||||
|
`fix(island): 胶囊"点不动"的根因——窗口必须始终可激活`、
|
||||||
|
`feat(teachers,classes,schedules,web): 教师录入(仅管理员)`。
|
||||||
|
- **改动交付形态 / 新增功能后**,同步更新 `README.md`(它兼作产品说明书与验收对照表)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 本机环境已知坑(踩过,别再踩)
|
||||||
|
|
||||||
|
1. **系统代理会拦截 `127.0.0.1` 的自测请求。**
|
||||||
|
`Invoke-WebRequest http://127.0.0.1:4000/api/health` 会返回**别人的 404**(响应带 `Alt-Svc: h3=…`、空 body),
|
||||||
|
看起来像后端路由没挂上。实际是走了代理。自测请加 `-NoProxy`(`curl.exe` 用 `--noproxy '*'`)。
|
||||||
|
项目内的 Node 脚本(`verify:*`)直连,不受影响。
|
||||||
|
**同一个坑对 ClassIsland 插件同样成立**:.NET 的 `HttpClient` 默认跟随系统代理,
|
||||||
|
教室机器上表现为"填好令牌后立刻 HTTP 404"(其实是代理的错误页)。插件里
|
||||||
|
`ClassHelperClient` 用 `LocalAwareProxy` 对本地/内网地址强制直连,
|
||||||
|
`verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
|
||||||
|
2. **`pnpm` 由 Electron 宿主承载**(`pnpm config list` 里 `userAgent` 是 `node/v24.19.0`,而 `node -v` 是 v22.15.0)。
|
||||||
|
个别情况下依赖的 `.bin` 不会进子进程 PATH,`pnpm run <script>` 报「不是内部或外部命令」。
|
||||||
|
规避:直接用 Node 调入口,例如 `node packages/server/node_modules/prisma/build/index.js generate`
|
||||||
|
(`prisma.config.ts` 里的 seed 命令已按这个思路写成 `node ./node_modules/tsx/dist/cli.mjs`)。
|
||||||
|
3. **启动 Electron 的脚本必须清掉 `ELECTRON_RUN_AS_NODE`**,且不要 `import 'electron'` 取可执行文件路径。
|
||||||
|
`scripts/lib/electron-env.mjs`、`scripts/lib/node-runtime.mjs` 已封装,新脚本请复用。
|
||||||
|
4. **Electron 二进制要手动装**(见 §3),否则 `pnpm dev:desktop` / `verify:desktop` / `dist:win` 全部失败。
|
||||||
|
5. **依赖二进制下载需要 `NODE_OPTIONS=--use-system-ca`**(本机 TLS 中间人证书),否则报
|
||||||
|
`unable to verify the first certificate`。
|
||||||
|
6. **`pnpm db:migrate`(= `prisma migrate dev`)在本机不可靠**:首次报 `Schema engine error:`(空消息),
|
||||||
|
数据库建好后再跑会**长时间挂起**(无输出,需手动结束进程)。
|
||||||
|
日常用 **`pnpm db:deploy`**;要写种子直接 `node ./node_modules/tsx/dist/cli.mjs prisma/seed.ts`(cwd 为 `packages/server`)。
|
||||||
|
7. **后端启动时才发现 Web 产物**:`packages/web-admin/dist` 不存在就跳过静态托管,`http://127.0.0.1:4000/`
|
||||||
|
只会返回服务首页(文案「Web 管理端未随本服务托管」)。要跑 `verify:web`,先 `pnpm build` 再重启后端。
|
||||||
|
8. **原生模块按 Electron ABI 构建**:本项目因此用 libSQL 适配器(`@libsql/win32-x64-msvc` 预编译),
|
||||||
|
不要为了图快换回 `better-sqlite3` 之类需要 node-gyp 的方案。
|
||||||
|
9. **Prisma 版本锁死 7.10.0**:npm `latest` 已指向 8.x-rc;生成器是 `prisma-client`,输出到
|
||||||
|
`src/generated/prisma`(gitignored),连接串在 `prisma.config.ts`,并且**必须走 driver adapter**。
|
||||||
|
10. **服务端安装包里的 `.cmd` 提示是英文**:cmd.exe 按 GBK 解析脚本,中文会乱码,属有意为之(中文在安装向导与 `README.txt`)。
|
||||||
|
11. **`pnpm dist:server` 生成安装程序时可能报 NSIS `Internal compiler error #12345: error creating mmap the size of N`。**
|
||||||
|
原因是模板用了 `SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件;
|
||||||
|
本机 `C:` 只剩约 100 MB 时会失败(免安装目录其实已经生成好了,只是最后一步打不出安装包)。
|
||||||
|
规避:把临时目录指到空闲的盘再重试,并加 `--reuse-deps` 跳过依赖安装:
|
||||||
|
`$env:TEMP='F:\ClassHelper\.cache\tmp'; $env:TMP=$env:TEMP; node scripts/dist-server.mjs --reuse-deps`。
|
||||||
|
12. **仓库根 `.gitignore` 是 GBK 编码**(含一段乱码注释),`apply_patch` 与 Prettier 都读不了它。
|
||||||
|
要补忽略规则请写 `.git/info/exclude`(本地生效、不入库),别试图整文件重写。
|
||||||
|
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore,用来忽略 bin/obj/cipx。)
|
||||||
|
13. **`dotnet` 会把 NuGet 缓存与临时文件写进 C 盘**(`%USERPROFILE%\.nuget`、`%TEMP%`),
|
||||||
|
C 盘紧张时直接构建失败。`pnpm build:classisland-plugin` 已把 `NUGET_PACKAGES/TEMP/TMP/DOTNET_CLI_HOME`
|
||||||
|
指到仓库内 `.cache/`;手敲 `dotnet build` 时请自己带上这几个变量。
|
||||||
|
14. **`.cipx` 打包最后一步要 PowerShell**:SDK 的 `CreateCipx` 目标默认调 `pwsh`,
|
||||||
|
本机 PATH 里没有 pwsh 时会失败。构建脚本会探测并把结果透传给 MSBuild
|
||||||
|
(`-p:PowershellBinaryName=…`),直接跑 `dotnet build -p:CreateCipx=true` 则要自己加。
|
||||||
|
15. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
|
||||||
|
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
|
||||||
|
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉)。
|
||||||
|
|
||||||
|
### 已知脆弱用例(不是环境问题,别去"修环境")
|
||||||
|
|
||||||
|
| 用例 | 触发条件 |
|
||||||
|
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) |
|
||||||
|
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
|
||||||
|
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
16. **改完插件必须更新 `F:\data\Plugins\classhelper.classisland.bridge\` 里的 DLL 并重启 ClassIsland**,
|
||||||
|
否则跑的还是旧副本(ClassIsland 运行时会锁住 DLL,必须先退出再覆盖)。
|
||||||
|
**别把该目录下的 `Settings.json` 覆盖掉** —— 那里面是这台机器的服务器地址与设备令牌。
|
||||||
|
17. **ClassIsland 的提醒链路有三个"上机才暴露"的硬约束**(都已在插件里处理,并写进 `verify:classisland-plugin` 的静态断言,别删):
|
||||||
|
- 提醒内容**必须**在 UI 线程构造 + 显示,否则 `LucideIconSource → AvaloniaObject` 构造时抛
|
||||||
|
`Call from invalid thread`,表现为"ClassHelper 客户端收到了、ClassIsland 毫无反应";
|
||||||
|
- 服务端"回执才算送达",因此插件必须对**播放中的提醒按 id 去重**,否则轮询间隔 < 提醒时长时会反复弹;
|
||||||
|
- `Subject.Fallback` 的名字就是 `???` 占位符,上报前要归一化成"没有科目"。
|
||||||
|
|
||||||
|
18. **读 ClassIsland 控制台日志要用 GBK**:`.NET` 的控制台输出是**系统 ANSI(GBK)**编码,
|
||||||
|
用 `-Encoding UTF8` 读会变成乱码、**中文关键字一条都搜不到**(只能搜到 ASCII 的类名)。
|
||||||
|
正确姿势:`[System.IO.File]::ReadAllLines(path, [System.Text.Encoding]::GetEncoding(936))`;
|
||||||
|
日志文件被运行中的进程占用时先 `Copy-Item` 一份再读。
|
||||||
|
19. **改插件设置要趁 ClassIsland 退出时改**:插件配置在 `F:\data\Config\Plugins\<id>\Settings.json`,
|
||||||
|
ClassIsland 运行时会在内存里持有并随时回写,运行中改文件会被覆盖。改完 DLL/设置都要重启 ClassIsland。
|
||||||
|
20. **打包版服务端有生产自检**:`NODE_ENV=production` 时拒绝 `dev.db`("生产环境仍在使用 dev.db"),
|
||||||
|
想用演示数据跑打包版就把库**复制**成 `data/classhelper.db` 再启动。
|
||||||
|
21. **服务端改了代码要重启才生效**(`tsx src/index.ts` 不热载,`pnpm dev:server` 才有 watch)。
|
||||||
|
验证脚本报「某个断言突然不对」时,先确认后端跑的是不是最新代码 —— 这个坑踩过一次。
|
||||||
|
|
||||||
|
22. **灵动岛的位置由「屏幕工作区 + 边距」算,改边距默认值会改外观**:`appearance.marginX/marginY` 默认
|
||||||
|
必须与老行为一致(8px)—— `verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,
|
||||||
|
默认值一动它就红(我先把 marginX 设成 16 踩过一次)。新增边距类设置请只改"可调范围",别动默认值。
|
||||||
|
|
||||||
|
## 8. 交付前检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint # ESLint(0 error 为底线)
|
||||||
|
pnpm typecheck # 全仓库类型检查
|
||||||
|
|
||||||
|
pnpm dev:server # 另开终端
|
||||||
|
pnpm verify:e2e # 后端 163 项
|
||||||
|
pnpm verify:web # Web 29 项(其中 1 项是"已知脆弱用例",见上表)
|
||||||
|
pnpm verify:classisland # ClassIsland 联动链路 24 项
|
||||||
|
pnpm verify:classisland-plugin # 插件静态契约 54 项(不需要后端)
|
||||||
|
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟
|
||||||
|
```
|
||||||
|
|
||||||
|
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
|
||||||
|
并**在真机上过一遍**(本机就有 ClassIsland,见 §3):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
|
||||||
|
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
||||||
|
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll F:\data\Plugins\classhelper.classisland.bridge\ -Force
|
||||||
|
Start-Process F:\classisland\ClassIsland.Desktop.exe -WorkingDirectory F:\classisland
|
||||||
|
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看启动时控制台/日志里依次出现:
|
||||||
|
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
|
||||||
|
```
|
||||||
|
|
||||||
|
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 ClassIsland 的机器上实测"。
|
||||||
|
|
||||||
|
`pnpm format:check` **在 master 基线上本来就是失败的**(175 个文件未过 Prettier,含 `tsconfig.base.json`
|
||||||
|
与 `scripts/*`),不要拿它当门禁、也不要为了让它变绿去批量格式化。只检查你改过的文件:
|
||||||
|
`pnpm exec prettier --check <file>`。
|
||||||
|
|
||||||
|
改动涉及打包/交付形态时,额外跑 `pnpm dist:all`(或 `dist:server` + `dist:win`),
|
||||||
|
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
||||||
|
开发产物通过不等于用户机器上的副本通过。
|
||||||
|
|
||||||
|
自测本地接口记得 `-NoProxy`(见 §7 第 1 条)。
|
||||||
@@ -30,6 +30,11 @@ CORS_ORIGIN=*
|
|||||||
INITIAL_ADMIN_USERNAME=admin
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
INITIAL_ADMIN_PASSWORD=admin123
|
INITIAL_ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# 各类账号的默认初始密码(新增/重置时使用)
|
||||||
|
DEFAULT_STUDENT_PASSWORD=123456
|
||||||
|
DEFAULT_CLASS_PASSWORD=123456
|
||||||
|
DEFAULT_TEACHER_PASSWORD=123456
|
||||||
|
|
||||||
# 第 1 教学周的周一(用于"当前周次")
|
# 第 1 教学周的周一(用于"当前周次")
|
||||||
TERM_START_DATE=2026-08-31
|
TERM_START_DATE=2026-08-31
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 班级小助手 · 服务端 systemd 单元(Linux)
|
||||||
|
#
|
||||||
|
# 这是**模板**:deploy/install-linux.sh 会把 __NODE__ / __DIR__ / __USER__ 替换成真实值后
|
||||||
|
# 安装到 /etc/systemd/system/classhelper.service。手动部署时直接改这三处即可:
|
||||||
|
#
|
||||||
|
# sudo sed -e 's#__NODE__#/usr/bin/node#' \
|
||||||
|
# -e 's#__DIR__#/opt/classhelper#' \
|
||||||
|
# -e 's#__USER__#classhelper#' \
|
||||||
|
# deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service
|
||||||
|
# sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
|
||||||
|
#
|
||||||
|
# 常用命令:
|
||||||
|
# systemctl status classhelper
|
||||||
|
# journalctl -u classhelper -f # 实时日志
|
||||||
|
# systemctl restart classhelper # 重启(改完 .env 需要)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=ClassHelper Server (班级小助手服务端)
|
||||||
|
Documentation=file://__DIR__/README.txt
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=__USER__
|
||||||
|
Group=__USER__
|
||||||
|
WorkingDirectory=__DIR__
|
||||||
|
# .env 由服务端自己从 WorkingDirectory 加载(工作目录即安装根目录)
|
||||||
|
ExecStart=__NODE__ server/dist/index.js
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
# Socket.IO 长连接较多:提高文件描述符上限
|
||||||
|
LimitNOFILE=65535
|
||||||
|
# 优雅退出:给在途请求与 websocket 收尾时间
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
# 日志进 journald:journalctl -u classhelper
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=classhelper
|
||||||
|
|
||||||
|
# 基础加固(服务只需要读程序、写 data/logs)
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=__DIR__/data __DIR__/logs
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -78,6 +78,9 @@ services:
|
|||||||
TERM_START_DATE: ${TERM_START_DATE:-}
|
TERM_START_DATE: ${TERM_START_DATE:-}
|
||||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
||||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
|
||||||
|
DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456}
|
||||||
|
DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456}
|
||||||
|
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
|
||||||
ports:
|
ports:
|
||||||
- '${SERVER_PORT:-4000}:4000'
|
- '${SERVER_PORT:-4000}:4000'
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 班级小助手 · 服务端 Linux 一键部署(systemd + SQLite,单机单实例)
|
||||||
|
#
|
||||||
|
# 干什么:
|
||||||
|
# 在 Linux 服务器上把服务端装成 systemd 服务,布局与已验证的 Windows 免安装目录完全一致:
|
||||||
|
# /opt/classhelper/
|
||||||
|
# ├── .env 全部配置(首次安装随机生成 JWT_SECRET)
|
||||||
|
# ├── server/dist 后端程序(入口 index.js)
|
||||||
|
# ├── server/prisma 迁移 SQL(AUTO_MIGRATE=true 时首启动自动执行)
|
||||||
|
# ├── web/ Web 管理端(由后端托管,单端口 4000)
|
||||||
|
# ├── node_modules/ 仅生产依赖(npm install --omit=dev)
|
||||||
|
# ├── data/ classhelper.db + server.pid
|
||||||
|
# └── logs/ 预留(日志默认进 journald)
|
||||||
|
#
|
||||||
|
# 前置条件:
|
||||||
|
# - 在**仓库根目录**已执行过构建:pnpm build:shared && pnpm --filter @classhelper/server build \
|
||||||
|
# && pnpm --filter @classhelper/web-admin build
|
||||||
|
# - 目标机装好 Node.js ≥ 20.19(推荐 22 LTS);本脚本不代装 Node
|
||||||
|
#
|
||||||
|
# 用法(在仓库根目录执行,需要 root):
|
||||||
|
# sudo bash deploy/install-linux.sh # 安装/升级,默认端口 4000、目录 /opt/classhelper
|
||||||
|
# sudo bash deploy/install-linux.sh --port 8080 # 换端口
|
||||||
|
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
|
||||||
|
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,不装 systemd(自己接管进程)
|
||||||
|
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node 版本/端口探针
|
||||||
|
# sudo bash deploy/install-linux.sh --uninstall # 停止并禁用服务(保留 data 与 .env)
|
||||||
|
#
|
||||||
|
# 升级:把新代码构建好后**再跑一次同样的命令**即可。脚本不会覆盖已有 .env(JWT 密钥与端口保持
|
||||||
|
# 不变,用户不用重新登录),只会补齐缺失项并覆盖程序文件。
|
||||||
|
#
|
||||||
|
# 注意:本脚本未在 Linux 实机验证过(开发机是 Windows,无 Docker/WSL 发行版)。
|
||||||
|
# 它复用的是**已验证**的两套布局:Windows 免安装目录(install/ 结构)与 deploy/Dockerfile
|
||||||
|
# 的 runtime 阶段(package.runtime.json + prisma/migrations + web/)。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TARGET_DIR="/opt/classhelper"
|
||||||
|
SERVICE_USER="classhelper"
|
||||||
|
PORT="4000"
|
||||||
|
WITH_SERVICE=1
|
||||||
|
UNINSTALL=0
|
||||||
|
CHECK_ONLY=0
|
||||||
|
SERVICE_NAME="classhelper"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dir) TARGET_DIR="$2"; shift 2 ;;
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||||
|
--no-service) WITH_SERVICE=0; shift ;;
|
||||||
|
--check) CHECK_ONLY=1; shift ;;
|
||||||
|
--uninstall) UNINSTALL=1; shift ;;
|
||||||
|
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
|
||||||
|
*) echo "未知参数:$1(用 --help 看用法)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { printf '\033[32m[classhelper]\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[33m[classhelper]\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[31m[classhelper]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# 就绪探针:优先 curl(几乎每台 Linux 都有),否则退回 node。
|
||||||
|
# 注意不要用 `node -e "...process.exit()"` —— 在部分 Node 版本上于 promise 回调里硬退会触发
|
||||||
|
# libuv 断言崩溃(本机 Windows 实测退出码 -1073740791),改成设置 process.exitCode 让事件循环自然结束。
|
||||||
|
probe_ready() {
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
"$NODE_BIN" -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>{process.exitCode=r.ok?0:1}).catch(()=>{process.exitCode=1})" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$(id -u)" = "0" ] || { [ "$CHECK_ONLY" = "1" ] || die "请用 root 执行:sudo bash deploy/install-linux.sh"; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 卸载分支
|
||||||
|
if [ "$UNINSTALL" = "1" ]; then
|
||||||
|
if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then
|
||||||
|
systemctl disable --now "$SERVICE_NAME" || true
|
||||||
|
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
log "已停止并移除 systemd 服务 ${SERVICE_NAME}"
|
||||||
|
else
|
||||||
|
warn "没有找到 ${SERVICE_NAME}.service(可能没装成服务)"
|
||||||
|
fi
|
||||||
|
log "程序文件与数据保留在 ${TARGET_DIR};确认不需要后再手动删除"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 定位仓库与产物
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
SERVER_DIST="packages/server/dist/index.js"
|
||||||
|
SERVER_PRISMA="packages/server/prisma"
|
||||||
|
WEB_DIST="packages/web-admin/dist/index.html"
|
||||||
|
SHARED_DIST="packages/shared/dist/index.js"
|
||||||
|
RUNTIME_PKG="deploy/package.runtime.json"
|
||||||
|
|
||||||
|
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG"; do
|
||||||
|
[ -e "$f" ] || die "缺少构建产物:$f
|
||||||
|
请先在仓库根目录执行:
|
||||||
|
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build"
|
||||||
|
done
|
||||||
|
[ -d "$SERVER_PRISMA/migrations" ] || die "缺少迁移文件目录:$SERVER_PRISMA/migrations"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Node 检查
|
||||||
|
NODE_BIN="$(command -v node || true)"
|
||||||
|
[ -n "$NODE_BIN" ] || die "未找到 node,请先安装 Node.js ≥ 20.19(推荐 22 LTS)"
|
||||||
|
NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]')"
|
||||||
|
NODE_MINOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[1]')"
|
||||||
|
if [ "$NODE_MAJOR" -lt 20 ] || { [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -lt 19 ]; }; then
|
||||||
|
die "Node 版本过低:$("$NODE_BIN" -v)(要求 ≥ 20.19)"
|
||||||
|
fi
|
||||||
|
NPM_BIN="$(command -v npm || true)"
|
||||||
|
[ -n "$NPM_BIN" ] || die "未找到 npm(随 Node 一起安装)"
|
||||||
|
log "Node $("$NODE_BIN" -v) → $NODE_BIN"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 只体检
|
||||||
|
if [ "$CHECK_ONLY" = "1" ]; then
|
||||||
|
echo
|
||||||
|
echo "构建产物:"
|
||||||
|
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG" "$SERVER_PRISMA/migrations"; do
|
||||||
|
printf ' %-42s %s\n' "$f" "OK"
|
||||||
|
done
|
||||||
|
printf ' %-42s %s\n' "迁移文件数量" "$(find "$SERVER_PRISMA/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||||
|
echo
|
||||||
|
echo "运行环境:"
|
||||||
|
printf ' %-42s %s\n' "node" "$("$NODE_BIN" -v)(要求 ≥ 20.19)"
|
||||||
|
printf ' %-42s %s\n' "npm" "$("$NPM_BIN" -v)"
|
||||||
|
printf ' %-42s %s\n' "systemd" "$(command -v systemctl >/dev/null 2>&1 && echo 可用 || echo '不可用(用 --no-service)')"
|
||||||
|
printf ' %-42s %s\n' "目标目录 ${TARGET_DIR}" "$([ -e "$TARGET_DIR" ] && echo '已存在(会就地升级,保留 .env)' || echo 尚未创建)"
|
||||||
|
printf ' %-42s %s\n' "端口 ${PORT} 上的既有服务" "$(probe_ready && echo '健康(就绪)' || echo '无响应')"
|
||||||
|
echo
|
||||||
|
log "--check 通过:构建产物与运行环境都满足,可以执行安装(sudo bash deploy/install-linux.sh)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 用户与目录
|
||||||
|
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||||
|
useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null \
|
||||||
|
|| useradd --system --shell /sbin/nologin "$SERVICE_USER"
|
||||||
|
log "已创建系统用户 ${SERVICE_USER}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${TARGET_DIR}/server" "${TARGET_DIR}/web" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
|
||||||
|
log "安装目录:${TARGET_DIR}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 拷贝产物
|
||||||
|
rm -rf "${TARGET_DIR}/server/dist"
|
||||||
|
cp -R "packages/server/dist" "${TARGET_DIR}/server/dist"
|
||||||
|
rm -rf "${TARGET_DIR}/server/prisma"
|
||||||
|
cp -R "$SERVER_PRISMA" "${TARGET_DIR}/server/prisma"
|
||||||
|
# 运行时不读 schema,但保留一份便于排查
|
||||||
|
[ -f packages/server/prisma/schema.prisma ] && cp packages/server/prisma/schema.prisma "${TARGET_DIR}/server/prisma/schema.prisma"
|
||||||
|
|
||||||
|
rm -rf "${TARGET_DIR}/web"
|
||||||
|
cp -R "packages/web-admin/dist" "${TARGET_DIR}/web"
|
||||||
|
|
||||||
|
cp "$RUNTIME_PKG" "${TARGET_DIR}/package.json"
|
||||||
|
cp -R deploy/classhelper.service "${TARGET_DIR}/classhelper.service.template"
|
||||||
|
|
||||||
|
# 生产依赖(与 Dockerfile 的 runtime 阶段同一份清单)
|
||||||
|
if [ ! -d "${TARGET_DIR}/node_modules" ] || [ "${FORCE_NPM_INSTALL:-0}" = "1" ]; then
|
||||||
|
log "安装生产依赖(npm install --omit=dev,约 1~3 分钟)..."
|
||||||
|
( cd "$TARGET_DIR" && "$NPM_BIN" install --omit=dev --no-audit --no-fund )
|
||||||
|
else
|
||||||
|
log "复用已有 node_modules(如需强制重装:FORCE_NPM_INSTALL=1 再跑一次)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# workspace 共享包(后端 import '@classhelper/shared'):拷进 node_modules 里
|
||||||
|
mkdir -p "${TARGET_DIR}/node_modules/@classhelper/shared"
|
||||||
|
rm -rf "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
|
||||||
|
cp -R "packages/shared/dist" "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
|
||||||
|
cp packages/shared/package.json "${TARGET_DIR}/node_modules/@classhelper/shared/package.json"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- .env(已存在则不覆盖)
|
||||||
|
ENV_FILE="${TARGET_DIR}/.env"
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
log "检测到已有 .env:保留现有配置(含 JWT_SECRET),只补齐缺失项"
|
||||||
|
else
|
||||||
|
JWT_SECRET="$("$NODE_BIN" -e 'console.log(require("crypto").randomBytes(48).toString("hex"))')"
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
# 班级小助手服务端(install-linux.sh 生成,请妥善保管)
|
||||||
|
# 修改后重启服务生效:systemctl restart ${SERVICE_NAME}
|
||||||
|
|
||||||
|
NODE_ENV=production
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=${PORT}
|
||||||
|
|
||||||
|
# 数据库:SQLite 文件放在安装根目录的 data 下(备份只需复制该目录)
|
||||||
|
DATABASE_PROVIDER=sqlite
|
||||||
|
DATABASE_URL="file:../data/classhelper.db"
|
||||||
|
# 空库首启动自动执行随包迁移并创建管理员
|
||||||
|
AUTO_MIGRATE=true
|
||||||
|
|
||||||
|
# 令牌签名密钥(随机生成;更换后所有用户需重新登录)
|
||||||
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# 允许的跨域来源:走域名/反代时建议改成具体来源,例如 https://class.example.com
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
# 前面有 Nginx 等反向代理时设为 1,限流才会按真实客户端 IP 统计
|
||||||
|
TRUST_PROXY=1
|
||||||
|
|
||||||
|
RATE_LIMIT_ENABLED=true
|
||||||
|
STARTUP_DB_CHECK=true
|
||||||
|
PID_FILE=../data/server.pid
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=10
|
||||||
|
DEFAULT_STUDENT_PASSWORD=123456
|
||||||
|
DEFAULT_CLASS_PASSWORD=123456
|
||||||
|
DEFAULT_TEACHER_PASSWORD=123456
|
||||||
|
TERM_START_DATE=
|
||||||
|
EOF
|
||||||
|
log "已生成 ${ENV_FILE}(含随机 JWT_SECRET)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# data/logs 必须可写;程序文件只读即可
|
||||||
|
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
|
||||||
|
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/node_modules"
|
||||||
|
chown "${SERVICE_USER}:${SERVICE_USER}" "$ENV_FILE"
|
||||||
|
chown -R root:root "${TARGET_DIR}/server" "${TARGET_DIR}/web"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- systemd
|
||||||
|
if [ "$WITH_SERVICE" = "1" ]; then
|
||||||
|
command -v systemctl >/dev/null 2>&1 || die "本机没有 systemd;请加 --no-service 自行托管进程"
|
||||||
|
|
||||||
|
sed -e "s#__NODE__#${NODE_BIN}#g" \
|
||||||
|
-e "s#__DIR__#${TARGET_DIR}#g" \
|
||||||
|
-e "s#__USER__#${SERVICE_USER}#g" \
|
||||||
|
deploy/classhelper.service > "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME" >/dev/null
|
||||||
|
systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
log "等待服务就绪..."
|
||||||
|
ready=0
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if probe_ready; then
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ready" != "1" ]; then
|
||||||
|
warn "60 秒内没通过 /healthz,最近日志:"
|
||||||
|
journalctl -u "$SERVICE_NAME" -n 50 --no-pager || true
|
||||||
|
die "启动失败,请按上面的日志排查(常见:端口被占用、数据目录权限、JWT_SECRET 未生成)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "服务已就绪 ✅"
|
||||||
|
else
|
||||||
|
log "已跳过 systemd(--no-service)。手动启动:"
|
||||||
|
echo " sudo -u ${SERVICE_USER} sh -c 'cd ${TARGET_DIR} && ${NODE_BIN} server/dist/index.js'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 结果
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
------------------------------------------------------------------
|
||||||
|
安装完成
|
||||||
|
访问管理端:http://<服务器IP>:${PORT}/
|
||||||
|
健康探针: http://<服务器IP>:${PORT}/healthz · /readyz
|
||||||
|
配置文件: ${ENV_FILE}
|
||||||
|
数据目录: ${TARGET_DIR}/data(备份只需复制这里)
|
||||||
|
查看日志: journalctl -u ${SERVICE_NAME} -f
|
||||||
|
重启服务: systemctl restart ${SERVICE_NAME}
|
||||||
|
|
||||||
|
首次启动会自动建表并创建管理员:admin / admin123(登录后请立即在右上角「修改密码」改掉)。
|
||||||
|
公网部署请在前面加 Nginx + HTTPS(deploy/nginx.conf 可直接用),并把 .env 里的
|
||||||
|
TRUST_PROXY 设为 1、CORS_ORIGIN 收敛到你的域名。
|
||||||
|
学生端客户端登录时填的「服务器地址」就是 https://你的域名(或 http://IP:${PORT})。
|
||||||
|
------------------------------------------------------------------
|
||||||
|
EOF
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"xlsx": "0.18.5",
|
||||||
"zod": "^4.6.2"
|
"zod": "^4.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
本文档覆盖三种上线形态,按适用场景选择:
|
本文档覆盖三种上线形态,按适用场景选择:
|
||||||
|
|
||||||
| 形态 | 适用场景 | 产物 | 本机验证情况 |
|
| 形态 | 适用场景 | 产物 | 本机验证情况 |
|
||||||
| --------------------- | -------------------------------------------- | ------------------------------------------------------ | -------------------------------------- |
|
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
|
||||||
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
||||||
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
|
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
|
||||||
| **C. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(54 项端到端) |
|
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd + SQLite) | `deploy/install-linux.sh` + `deploy/classhelper.service` | ⚠️ 脚本已提供,本机无 Linux 未实测 |
|
||||||
|
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,7 +127,153 @@ docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、手动部署(形态 C)
|
## 三、Linux 原生部署(形态 C,systemd + SQLite)
|
||||||
|
|
||||||
|
适用:Ubuntu / Debian / CentOS / Rocky 等有 systemd 的服务器,单机跑一套(SQLite),
|
||||||
|
用 Nginx 挂 HTTPS 给校内网/公网访问。
|
||||||
|
|
||||||
|
### 3.1 一键脚本(推荐)
|
||||||
|
|
||||||
|
在**仓库根目录**先构建,然后执行脚本(需要 root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 构建(Node ≥ 20.19,推荐 22 LTS;目标机没有 pnpm 时可在别的机器上构建后拷仓库过来)
|
||||||
|
corepack enable && corepack prepare pnpm@11.8.0 --activate
|
||||||
|
pnpm install
|
||||||
|
pnpm build:shared
|
||||||
|
pnpm --filter @classhelper/server build
|
||||||
|
pnpm --filter @classhelper/web-admin build
|
||||||
|
|
||||||
|
# 2) 安装成 systemd 服务
|
||||||
|
sudo bash deploy/install-linux.sh # 默认端口 4000、目录 /opt/classhelper
|
||||||
|
# 常用变体:
|
||||||
|
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node/端口探针
|
||||||
|
# sudo bash deploy/install-linux.sh --port 8080
|
||||||
|
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
|
||||||
|
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,自己托管进程
|
||||||
|
# sudo bash deploy/install-linux.sh --uninstall # 停服务并移除 unit(保留 data 与 .env)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 建议先跑一次 `--check`:它只读地校验"构建产物齐不齐、Node 版本够不够、目标端口上有没有旧实例",
|
||||||
|
> 不需要 root,也不会改任何东西。输出示例见 `deploy/install-linux.sh` 头部注释。
|
||||||
|
|
||||||
|
脚本做的事:建系统用户 `classhelper` → 把后端产物、`prisma/migrations`、Web 管理端拷到
|
||||||
|
`/opt/classhelper` → `npm install --omit=dev`(清单就是 `deploy/package.runtime.json`,与
|
||||||
|
容器运行时同一份)→ 生成 `.env`(随机 `JWT_SECRET`、`AUTO_MIGRATE=true`)→ 装 systemd unit →
|
||||||
|
`enable --now` → 轮询 `/healthz` 就绪后打印地址与初始管理员。
|
||||||
|
|
||||||
|
安装后的目录(与已验证的 Windows 免安装目录同构,因此 `.env` 里的相对路径语义一致):
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/classhelper/
|
||||||
|
├── .env 全部配置(权限 600)
|
||||||
|
├── server/dist 后端程序(入口 index.js)
|
||||||
|
├── server/prisma 迁移 SQL(首启动自动执行)
|
||||||
|
├── web/ Web 管理端(后端托管,单端口)
|
||||||
|
├── node_modules/ 仅生产依赖
|
||||||
|
├── data/ classhelper.db + server.pid(备份只复制这里)
|
||||||
|
└── logs/
|
||||||
|
```
|
||||||
|
|
||||||
|
首次启动会自动建表并创建管理员 `admin / admin123`(**登录后立刻改密码**)。
|
||||||
|
|
||||||
|
### 3.2 手动部署(不想用脚本 / 没有 systemd)
|
||||||
|
|
||||||
|
等价的手工步骤,便于排查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --shell /usr/sbin/nologin classhelper
|
||||||
|
sudo mkdir -p /opt/classhelper/{server,web,data,logs}
|
||||||
|
|
||||||
|
# 程序产物
|
||||||
|
sudo cp -R packages/server/dist /opt/classhelper/server/dist
|
||||||
|
sudo cp -R packages/server/prisma /opt/classhelper/server/prisma
|
||||||
|
sudo cp -R packages/web-admin/dist /opt/classhelper/web
|
||||||
|
sudo cp deploy/package.runtime.json /opt/classhelper/package.json
|
||||||
|
|
||||||
|
# 生产依赖 + 工作区共享包
|
||||||
|
cd /opt/classhelper && sudo npm install --omit=dev --no-audit --no-fund
|
||||||
|
sudo mkdir -p /opt/classhelper/node_modules/@classhelper/shared
|
||||||
|
sudo cp -R packages/shared/dist /opt/classhelper/node_modules/@classhelper/shared/dist
|
||||||
|
sudo cp packages/shared/package.json /opt/classhelper/node_modules/@classhelper/shared/package.json
|
||||||
|
|
||||||
|
# 配置(参考脚本生成的 .env,务必自己生成 JWT_SECRET)
|
||||||
|
sudo tee /opt/classhelper/.env >/dev/null <<'EOF'
|
||||||
|
NODE_ENV=production
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=4000
|
||||||
|
DATABASE_PROVIDER=sqlite
|
||||||
|
DATABASE_URL="file:../data/classhelper.db"
|
||||||
|
AUTO_MIGRATE=true
|
||||||
|
JWT_SECRET=换成 openssl rand -hex 48 的输出
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
CORS_ORIGIN=https://class.example.com
|
||||||
|
TRUST_PROXY=1
|
||||||
|
RATE_LIMIT_ENABLED=true
|
||||||
|
STARTUP_DB_CHECK=true
|
||||||
|
PID_FILE=../data/server.pid
|
||||||
|
LOG_LEVEL=info
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo chown -R classhelper:classhelper /opt/classhelper/{data,logs,node_modules} /opt/classhelper/.env
|
||||||
|
sudo chmod 600 /opt/classhelper/.env
|
||||||
|
|
||||||
|
# systemd
|
||||||
|
sudo sed -e 's#__NODE__#/usr/bin/node#' -e 's#__DIR__#/opt/classhelper#' -e 's#__USER__#classhelper#' \
|
||||||
|
deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
|
||||||
|
systemctl status classhelper --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以用 PM2 之类托管(`pm2 start /opt/classhelper/server/dist/index.js --name classhelper`),
|
||||||
|
但 systemd 更省心:`Restart=always`、日志进 journald、开机自启一次配好。
|
||||||
|
|
||||||
|
### 3.3 Nginx + HTTPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
|
||||||
|
sudo nano /etc/nginx/conf.d/classhelper.conf # 改 server_name 与证书路径
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy/nginx.conf` 已经处理了最容易踩的两点:`/socket.io/` 的 WebSocket 升级头,
|
||||||
|
以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合 `.env` 里 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。
|
||||||
|
用 certbot 签证书:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d class.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 升级 / 备份 / 排障
|
||||||
|
|
||||||
|
升级(不会动 `.env`,JWT 密钥不变,学生端不用重新登录):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull && pnpm install
|
||||||
|
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build
|
||||||
|
sudo bash deploy/install-linux.sh # 覆盖程序文件 + 自动补迁移(AUTO_MIGRATE)
|
||||||
|
journalctl -u classhelper -n 30 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
| 事项 | 命令 |
|
||||||
|
| -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 备份 | `systemctl stop classhelper && cp -a /opt/classhelper/data /root/classhelper-data-$(date +%F)`(然后启动) |
|
||||||
|
| 恢复 | 停服务 → 用备份覆盖 `data/` → 启动 |
|
||||||
|
| 看日志 | `journalctl -u classhelper -f` |
|
||||||
|
| 重启 | `systemctl restart classhelper`(改 `.env` 后必须重启) |
|
||||||
|
| 探针 | `curl -s localhost:4000/healthz` / `/readyz`(就绪 200,数据库不可用 503) |
|
||||||
|
| 换端口 | 改 `.env` 的 `PORT` → 重启;同时改 Nginx 的 `upstream` |
|
||||||
|
| 换数据库 | 见 `docs/mysql.md`:切 provider + 装 `@prisma/adapter-mariadb` + `npx prisma migrate deploy` |
|
||||||
|
|
||||||
|
> 注意:本机(开发环境)是 Windows 且无 Docker/WSL 发行版,因此 **3.1 的脚本没有在 Linux 实机跑过**。
|
||||||
|
> 它复用的两套东西都已验证:Windows 免安装目录(同样的目录结构、`.env` 相对路径与
|
||||||
|
> `AUTO_MIGRATE` 首启动行为)和 `deploy/Dockerfile` 的 runtime 阶段(同样的依赖清单与产物布局)。
|
||||||
|
> 首次在服务器上执行后请按 3.4 的探针 + Web 登录 + `pnpm verify:e2e`(把 `VERIFY_BASE_URL` 指向服务器)确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、手动部署(形态 D)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -140,7 +287,7 @@ pnpm dist:server -- --reuse-deps # 得到 release-server/classhelper-server
|
|||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
start.cmd :: Windows
|
start.cmd :: Windows
|
||||||
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node)
|
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node,见上面的形态 C)
|
||||||
```
|
```
|
||||||
|
|
||||||
数据库初始化二选一:
|
数据库初始化二选一:
|
||||||
@@ -150,7 +297,7 @@ start.cmd :: Windows
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、环境变量清单
|
## 五、环境变量清单
|
||||||
|
|
||||||
| 变量 | 默认 | 说明 |
|
| 变量 | 默认 | 说明 |
|
||||||
| --------------------------------------------------- | ---------------------- | --------------------------------------------------------- |
|
| --------------------------------------------------- | ---------------------- | --------------------------------------------------------- |
|
||||||
@@ -168,15 +315,17 @@ start.cmd :: Windows
|
|||||||
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
|
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
|
||||||
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
|
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
|
||||||
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
|
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
|
||||||
| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增学生的默认密码 |
|
| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增/重置学生账号的默认密码 |
|
||||||
|
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 |
|
||||||
|
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
|
||||||
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
|
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
|
||||||
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
|
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、运维
|
## 六、运维
|
||||||
|
|
||||||
### 5.1 探针与监控
|
### 6.1 探针与监控
|
||||||
|
|
||||||
| 端点 | 用途 |
|
| 端点 | 用途 |
|
||||||
| ------------- | ---------------------------------------------------- |
|
| ------------- | ---------------------------------------------------- |
|
||||||
@@ -184,22 +333,22 @@ start.cmd :: Windows
|
|||||||
| `/readyz` | 就绪探针:数据库可连才 200,否则 503(负载均衡摘流) |
|
| `/readyz` | 就绪探针:数据库可连才 200,否则 503(负载均衡摘流) |
|
||||||
| `/api/health` | 详情:环境、数据库、已挂载模块、运行时长 |
|
| `/api/health` | 详情:环境、数据库、已挂载模块、运行时长 |
|
||||||
|
|
||||||
### 5.2 日志
|
### 6.2 日志
|
||||||
|
|
||||||
- 输出到 stdout/stderr,安装包形态由启停脚本重定向到 `logs\server.log`(UTF-8)。
|
- 输出到 stdout/stderr,安装包形态由启停脚本重定向到 `logs\server.log`(UTF-8)。
|
||||||
- 访问日志含方法、路径、状态码与耗时;5xx 记 error、4xx 记 warn。
|
- 访问日志含方法、路径、状态码与耗时;5xx 记 error、4xx 记 warn。
|
||||||
- 容器形态建议交给 Docker 日志驱动或采集到 ELK/Loki。
|
- 容器形态建议交给 Docker 日志驱动或采集到 ELK/Loki。
|
||||||
|
|
||||||
### 5.3 备份与恢复
|
### 6.3 备份与恢复
|
||||||
|
|
||||||
| 形态 | 备份 | 恢复 |
|
| 形态 | 备份 | 恢复 |
|
||||||
| ------ | ------------------------------------------------------ | ------------------------ |
|
| ------ | ---------------------------------------------------------------------- | ------------------------ |
|
||||||
| SQLite | 停止服务后复制 `data/classhelper.db` | 替换该文件后启动 |
|
| SQLite | 停止服务后复制 `data/classhelper.db`(Linux:`/opt/classhelper/data`) | 替换该文件后启动 |
|
||||||
| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` |
|
| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` |
|
||||||
|
|
||||||
建议:每日自动备份 + 保留 7 份;变更 `JWT_SECRET` 会导致所有用户重新登录,请勿在无必要时更换。
|
建议:每日自动备份 + 保留 7 份;变更 `JWT_SECRET` 会导致所有用户重新登录,请勿在无必要时更换。
|
||||||
|
|
||||||
### 5.4 安全清单(上线前逐项确认)
|
### 6.4 安全清单(上线前逐项确认)
|
||||||
|
|
||||||
- [ ] 修改 `admin` 与教师账号的默认密码
|
- [ ] 修改 `admin` 与教师账号的默认密码
|
||||||
- [ ] `JWT_SECRET` 使用随机值(≥48 字节),且妥善保管
|
- [ ] `JWT_SECRET` 使用随机值(≥48 字节),且妥善保管
|
||||||
@@ -212,7 +361,7 @@ start.cmd :: Windows
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、故障排查
|
## 七、故障排查
|
||||||
|
|
||||||
| 现象 | 排查方向 |
|
| 现象 | 排查方向 |
|
||||||
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -226,10 +375,14 @@ start.cmd :: Windows
|
|||||||
| 升级后需要重新登录 | 说明 `.env` 被覆盖(JWT 密钥变化);覆盖安装应保留 `.env`,请检查安装目录权限 |
|
| 升级后需要重新登录 | 说明 `.env` 被覆盖(JWT 密钥变化);覆盖安装应保留 `.env`,请检查安装目录权限 |
|
||||||
| 报 `does not provide an export named ...` | 安装包内的 `node_modules/@classhelper/shared/dist` 是旧构建(打包机没有先构建 shared)。重新执行 `pnpm dist:server`,该脚本会先构建 shared/server/web 并校验产物新鲜度 |
|
| 报 `does not provide an export named ...` | 安装包内的 `node_modules/@classhelper/shared/dist` 是旧构建(打包机没有先构建 shared)。重新执行 `pnpm dist:server`,该脚本会先构建 shared/server/web 并校验产物新鲜度 |
|
||||||
| 报 `node.exe` 不是有效应用 / 服务启动即退出 | 安装包内置的 `node.exe` 被误打成 Electron 可执行文件(打包机在 Electron 宿主里跑 pnpm 时会取到 `process.execPath`)。已由 `scripts/lib/node-runtime.mjs` 修正;可用 `install\node.exe -v` 确认能打印 Node 版本 |
|
| 报 `node.exe` 不是有效应用 / 服务启动即退出 | 安装包内置的 `node.exe` 被误打成 Electron 可执行文件(打包机在 Electron 宿主里跑 pnpm 时会取到 `process.execPath`)。已由 `scripts/lib/node-runtime.mjs` 修正;可用 `install\node.exe -v` 确认能打印 Node 版本 |
|
||||||
|
| **Linux**:`systemctl start` 后立刻退出 | `journalctl -u classhelper -n 50`;常见原因:`.env` 里 `JWT_SECRET` 为空/太短、`node` 路径在 unit 里不对(脚本用 `command -v node` 的结果)、`data/` 不可写(`chown classhelper:classhelper`) |
|
||||||
|
| **Linux**:403/500 且日志提示 `EACCES` | `ProtectSystem=full` 下服务只能写 `ReadWritePaths`;若自定义了数据目录,记得同步改 unit 里的这两项 |
|
||||||
|
| **Linux**:`npm install` 报网络/代理错误 | 目标机需要能访问 npm registry;离线环境可在别的机器 `npm install --omit=dev` 后把整个 `node_modules` 一起拷过去 |
|
||||||
|
| **Linux**:SQLite 提示 `database is locked` | 同一数据库文件只允许一个服务实例;确认没有重复启动(`systemctl status` + `pgrep -af 'server/dist/index.js'`),需要并发就换 MySQL |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、性能与容量参考
|
## 八、性能与容量参考
|
||||||
|
|
||||||
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。
|
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。
|
||||||
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
|
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,593 @@
|
|||||||
|
# WinIsland 视觉设计系统(Design Token 规格)
|
||||||
|
|
||||||
|
来源:`D:\classhelper\.cache\winisland-ref\WinIsland-master`(只读 checkout)。
|
||||||
|
所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。
|
||||||
|
|
||||||
|
关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**,
|
||||||
|
下文数值默认 scale = 1.0。文字颜色在渲染入口被硬编码为 `Color::WHITE`(`src/core/render.rs`
|
||||||
|
`draw_expanded_layer` / `draw_compact_layer` 传 `text_color: Color::WHITE`, `text_color_sec: Color::WHITE`),
|
||||||
|
所有"次级/次要"文字靠 **alpha** 或 **灰度通道值** 表达,而不是换颜色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 几何(几何 token)
|
||||||
|
|
||||||
|
### 1.1 两个状态的尺寸
|
||||||
|
|
||||||
|
| 项 | compact / mini | expanded | 来源 |
|
||||||
|
| -------- | ------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- |
|
||||||
|
| 宽 | `base_width = 120.0` | `expanded_width = 360.0` | `src/core/config.rs` `AppConfig::default()` |
|
||||||
|
| 高 | `base_height = 27.0` | `expanded_height = 200.0` | 同上 |
|
||||||
|
| 圆角 r | `h / 2`(=13.5)→ 胶囊/体育场形 | `min(48*scale, w/2, h/2)` = **48.0** | `src/window/app/frame.rs` `default_target_r`;`src/utils/shape.rs` `expanded_island_radius()` |
|
||||||
|
| 全局缩放 | `compact_scale = 1.0` | `expanded_scale = 1.0` | `src/core/config.rs` |
|
||||||
|
|
||||||
|
圆角函数原文(`src/utils/shape.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) fn expanded_island_radius(w: f32, h: f32, scale: f32) -> f32 {
|
||||||
|
(48.0 * scale).min(w / 2.0).min(h / 2.0).max(0.0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
compact 圆角:`IslandSprings::new` 里 `r = (base_height * compact_scale) / 2.0`(`src/window/app.rs`);
|
||||||
|
展开时 `default_target_r = compact_content_h / 2.0`(`src/window/app/frame.rs` `update_spring_targets`)。
|
||||||
|
|
||||||
|
### 1.2 compact 宽度的动态规则(不是固定 120)
|
||||||
|
|
||||||
|
`src/window/app/layout.rs` `compute_lyric_target_width()`:
|
||||||
|
|
||||||
|
- 无媒体 / 不在播放态:宽 = `base_width` = **120**
|
||||||
|
- 有媒体但无歌词:宽 = `base_width + 35.0` = **155**
|
||||||
|
- 有歌词(不滚动):`w = 60.0 + 文本宽`,再 `clamp(base_width+35, MAX_LYRIC_WIDTH)`
|
||||||
|
- `min_w = base_width + 35.0`、`MAX_LYRIC_WIDTH = 700.0`(`src/core/config.rs`)
|
||||||
|
- 有歌词(滚动模式,`lyrics_scroll = true`):`natural_w = 60.0 + text_w`,超过 `lyrics_scroll_max_width`(默认
|
||||||
|
`300.0`,`default_lyrics_scroll_max_width`)则固定为该上限;内部可用文本宽 = `(fixed_w - 59.0) * compact_scale`
|
||||||
|
- 二级歌词存在时:高度 = `max(base_height*scale, mini_lyric_pair_height)`,
|
||||||
|
其中 `lyric_pair_height = 主字号*(1 + 0.85 + 0.18) + 8*scale`
|
||||||
|
(`SECONDARY_LYRIC_SCALE = 0.85`、`LYRIC_PAIR_GAP_SCALE = 0.18` @ `src/core/render/mini.rs`)
|
||||||
|
→ 默认字号 12 时 = 12×2.03 + 8 = **32.36 px 高**,r ≈ 16.18
|
||||||
|
|
||||||
|
### 1.3 挂件(compact widget)导致的最小宽度
|
||||||
|
|
||||||
|
`src/ui/widget/compact/mod.rs`:
|
||||||
|
|
||||||
|
- `CONTENT_EDGE_INSET = 9.0`、`CONTENT_GAP = 7.0`
|
||||||
|
- `widget_width()`:`Time = 48.0`、`ResourceUsage = 132.0`
|
||||||
|
- 三组对齐宽度:`aligned_layout_width = 9*2 + left + center + right + 7*(占用组数-1)`
|
||||||
|
- 单侧扩展宽:`extension_width = CONTENT_EDGE_INSET + strip_width + CONTENT_GAP`
|
||||||
|
(例:左 Time → 9+48+7 = **64**;ResourceUsage → 9+132+7 = **148**)
|
||||||
|
- 最终 compact 宽 = `max(布局宽, base_width)`
|
||||||
|
|
||||||
|
### 1.4 compact overlay(通知 / 音量)会替换 base 尺寸
|
||||||
|
|
||||||
|
| overlay | 宽 | 高 | 圆角 | 来源 |
|
||||||
|
| ------- | ------------------------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 通知 | `max(base_width, 330.0) * scale` = **330** | `max(base_height, 82.0) * scale` = **82** | `h/2` = **41** | `src/ui/compact/notification.rs` `target_size()`;r 取自 `src/window/app/frame.rs`(`size.height/2.0`) |
|
||||||
|
| 音量 | `(base_width + 72.0) * scale` = **192** | `(base_height + 10.0) * scale` = **37** | `h/2` = **18.5** | `src/ui/compact/volume.rs` `target_size()` |
|
||||||
|
|
||||||
|
### 1.5 隐藏态尺寸
|
||||||
|
|
||||||
|
- `hidden_width` 默认 `5.0`(`default_hidden_width` @ `src/core/config.rs`),
|
||||||
|
- `MIN_HIDDEN_WIDTH = 0.0`、`MAX_HIDDEN_WIDTH = 400.0`
|
||||||
|
- 隐藏时高度收缩到 `hidden_width * compact_scale`,并保留 `hidden_reveal_h = 1.0`(1px 可点击边);
|
||||||
|
位移距离 `hide_distance = concealed_height + TOP_OFFSET`(`src/window/app/layout.rs`)
|
||||||
|
|
||||||
|
### 1.6 锚点(两种状态之间什么不动)
|
||||||
|
|
||||||
|
定位全部来自 `src/window/app/layout.rs` `compute_island_layout()` / `compute_window_position()`:
|
||||||
|
|
||||||
|
- **顶部居中停靠(默认)**:`island_y = TOP_OFFSET = 10.0`(**顶边固定**),
|
||||||
|
`offset_x = (os_w - w) / 2.0`(**水平中心固定**)
|
||||||
|
- **底部停靠**:`island_y = os_h - TOP_OFFSET - h`(**底边固定**,向上生长)
|
||||||
|
- **左/右停靠**:`offset_x = TOP_OFFSET` 或 `os_w - TOP_OFFSET - w`(对应侧边固定)
|
||||||
|
- `TOP_OFFSET: i32 = 10`(`src/core/config.rs`)= 岛顶边距屏幕顶 10 px
|
||||||
|
- 状态切换由三个弹簧同时插值:`w/h/r` 参数为 `stiffness = 0.10, damping = 0.68`(`src/window/app/frame.rs`),
|
||||||
|
页面横移 `view` 用 `0.12 / 0.68`;隐藏用 `(0.12, 0.70)` 隐藏 / `(0.08, 0.78)` 显示
|
||||||
|
- **compact 内容不跟随变大的岛做垂直居中重排**:mini 内容画在 `stable_island_y`(= 顶边)加上 `base_h` 带内居中
|
||||||
|
(`src/core/render/mini.rs`:封面 `y = stable_offset_y + (base_h - size)/2`,歌词 `center_y = stable_offset_y + base_h/2`)。
|
||||||
|
效果=岛向下长大,胶囊内容始终贴在顶部那条带里。
|
||||||
|
- 交叉淡出:`expanded_alpha = expansion_progress² * (1-hide_progress)`,
|
||||||
|
`mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1-hide_progress)`
|
||||||
|
(`MINI_FADE_RATE = 1.5` @ `src/core/render.rs`)→ 即进度 0~0.667 之间完成交叉淡出
|
||||||
|
|
||||||
|
### 1.7 有没有"挖孔/刘海"造型?
|
||||||
|
|
||||||
|
**没有。** 代码里没有 `SetWindowRgn` / `CreateRoundRectRgn` / notch / cutout 相关调用(已全仓 grep 确认),
|
||||||
|
也没有"顶部贴边 + 上方两角变直角"的特例分支。造型是:
|
||||||
|
|
||||||
|
- 一个**悬浮胶囊**(compact,r = h/2),**距屏幕顶 10 px**;
|
||||||
|
- 展开后是**四角一致的连续超椭圆**(squircle),r 最大 48。
|
||||||
|
|
||||||
|
圆角实现不是普通圆弧,而是 Flutter `RoundedSuperellipse` 的移植(`src/utils/shape.rs`,
|
||||||
|
注释标明 "Rounded-superellipse geometry adapted from Flutter under the BSD-3-Clause license"):
|
||||||
|
|
||||||
|
- `superellipse_parameters()`:11 项查表 `PARAMETERS`(如 `(2.0, 1.13276676) … (6.43023796, 2.98020421)`),
|
||||||
|
ratio > 5 时线性外推 `exponent = 6.43023796 + 1.559599389*(ratio-5.0)`、`factor = 2.98020421 + 0.522807185*(ratio-5.0)`
|
||||||
|
- `Octant::new()`:13 项 `BEZIER_FACTORS`(`(0.7078, 8.3194) … (0.9705, 1.2880)`),用 conic + cubic 拼四分之一角
|
||||||
|
- 路径缓存 `PATH_CACHE_CAPACITY = 64`
|
||||||
|
- **Web 复刻提示**:CSS `border-radius` 是正圆角,观感会偏"软";要还原请用 SVG path 或
|
||||||
|
`corner-shape: squircle`(若可用)/ `clip-path` 近似。**未找到**等价的简单圆角公式——真实几何就是上面那张表。
|
||||||
|
- **本仓库的落地实现**(`packages/desktop-client/src/island/squircle.ts`):不查表,用单参数超椭圆近似
|
||||||
|
`point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`,`p = 2/n`、`n = 4.2`,
|
||||||
|
四角分别以 `TR(w-r,r) → BR(w-r,h-r) → BL(r,h-r) → TL(r,r)`、起始角 `-π/2, 0, π/2, π` 各扫 `π/2`,
|
||||||
|
每角 14 段。**四个角必须各用自己的圆心 + 角度参数**:早期版本四个角共用同一个"偏移向量",
|
||||||
|
左上/右下被切成缺一块(用户反馈的"左上角和右下角怎么缺一块 / 圆角边缘割裂"),
|
||||||
|
现已由 `verify:desktop` 的「连续圆角四角一致」断言(四角沿对角线边界步进两两一致 + 路径绕行 \|winding\|≈2π)长期守住。
|
||||||
|
|
||||||
|
### 1.8 窗口留白 / 裁切
|
||||||
|
|
||||||
|
- `PADDING: f32 = 80.0`(`src/core/config.rs`);窗口尺寸 = `(岛宽 + 80, 岛高 + 80)`
|
||||||
|
(`src/window/app/layout.rs` `required_window_size()`)→ **岛四周各 40 px 透明留白**,供阴影/动画使用
|
||||||
|
- 绘制时 `canvas.clip_path(island_path, ClipOp::Intersect)`,所有内容裁在岛形内;
|
||||||
|
1px 描边在 `canvas.restore()` **之后**绘制(`src/core/render.rs` `draw_island()`)
|
||||||
|
- 挂件网格额外内缩,避免内容进圆角(`src/ui/widget/expanded/mod.rs` `widget_grid_layout`):
|
||||||
|
```rust
|
||||||
|
corner_inset = radius * (1.0 - FRAC_1_SQRT_2); // 48 * 0.29289 = 14.06
|
||||||
|
inset = (corner_inset + 8.0*scale).max(24.0*scale); // = 24.0(scale=1)
|
||||||
|
gap = 7.0 * scale;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.9 expanded 内容网格
|
||||||
|
|
||||||
|
- `WIDGET_GRID_COLS = 6`、`WIDGET_GRID_ROWS = 3`、`WIDGET_GRID_SLOTS = 18`(`src/core/config.rs`)
|
||||||
|
- 槽位宽 = `(inner_w - 7*5) / 6`,高 = `(inner_h - 7*2) / 3`;
|
||||||
|
用 360×200、inset 24 计算:inner = 312×152 → **slot ≈ 46.17 × 46.0**(`src/ui/widget/expanded/mod.rs`)
|
||||||
|
- 组件占格 `WidgetKind::span()`:`Clock = (2,1)`、`ResourceUsage = (2,1)`、`Calendar = (2,2)`、`Settings = (1,1)`
|
||||||
|
(`src/core/config.rs`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配色(颜色 token)
|
||||||
|
|
||||||
|
### 2.1 岛背景(`src/core/render/background.rs` `draw_background()`)
|
||||||
|
|
||||||
|
| 样式 | 颜色值 | 说明 |
|
||||||
|
| --------------------------- | -------------------------------------------------------------- | -------------------- |
|
||||||
|
| `default`(`SOLID_STYLE`) | `Color::BLACK` = **#000000**,完全不透明 | `bg_color` |
|
||||||
|
| `glass` + host backdrop | `Color::from_argb(150, 10, 10, 14)` = **rgba(10,10,14,0.588)** | `draw_host_glass()` |
|
||||||
|
| `glass` 无 backdrop(回退) | `Color::from_argb(205, 32, 32, 36)` = **rgba(32,32,36,0.804)** | `fallback_color` |
|
||||||
|
| `dynamic` 底色 | `Color::from_rgb(32, 32, 36)` = **#202024**,不透明 | `draw_effect_base()` |
|
||||||
|
| `dynamic` 叠加层 | `Color::from_argb(120, 20, 20, 24)` = **rgba(20,20,24,0.471)** | 盖在旋转模糊封面之上 |
|
||||||
|
| `dynamic` 无封面回退 | 同 glass 分支(150/10,10,14 或 205/32,32,36) | |
|
||||||
|
|
||||||
|
`dynamic` 封面动效参数(同一函数):
|
||||||
|
|
||||||
|
- 集显:`rotate_speed = 0.015 rad/s, drift_speed_x = 0.075, drift_speed_y = 0.06, drift_amp_x = 10.0, drift_amp_y = 7.5`
|
||||||
|
- 独显:`rotate_speed = 0.03, drift_speed_x = 0.15, drift_speed_y = 0.12, drift_amp_x = 20.0, drift_amp_y = 15.0`
|
||||||
|
- 旋转矩形边长 `side_len = diagonal * 1.3`
|
||||||
|
|
||||||
|
**亚克力/backdrop 真实实现**(`src/window/d3d.rs` + `src/utils/win32.rs`):
|
||||||
|
Windows 11 `DWMWA_USE_HOSTBACKDROPBRUSH`,失败时退回 `SetWindowCompositionAttribute`
|
||||||
|
(`AccentPolicy { state: 5, flags: 0, gradient_color: 0 }`,state 5 = host backdrop);
|
||||||
|
再用 `Compositor::CreateHostBackdropBrush()` + `RoundedRectangleGeometry` 裁圆角
|
||||||
|
(`HOST_BACKDROP_INSET = 1.0`,几何 = `w-2, h-2, radius-1`)。
|
||||||
|
**未找到**任何模糊半径数值——模糊由系统 HostBackdropBrush 决定。
|
||||||
|
|
||||||
|
### 2.2 描边(`src/core/render.rs` `draw_island_border()`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
BORDER_WIDTH = 1.0; BORDER_INSET = BORDER_WIDTH / 2.0; // 0.5
|
||||||
|
SOLID_STYLE = "default"; SOLID_BORDER_ALPHA = 30; EFFECT_BORDER_ALPHA = 40;
|
||||||
|
```
|
||||||
|
|
||||||
|
- 颜色 = **纯白**,alpha:
|
||||||
|
- `default` 样式:`rgba(255,255,255, 30/255=0.118)`,**并再乘 `(1 - expansion_progress)`**(展开后描边消失)
|
||||||
|
- `glass` / `dynamic`:`rgba(255,255,255, 40/255=0.157)`,不随展开衰减
|
||||||
|
- 描边矩形 = `(x+0.5, y+0.5, w-1, h-1)`,圆角 `r-0.5`
|
||||||
|
|
||||||
|
### 2.3 阴影(`src/core/render.rs` `draw_expanded_shadow()`,仅展开时)
|
||||||
|
|
||||||
|
- 颜色 `Color::from_argb((28.0 * opacity) as u8, 0,0,0)` = **rgba(0,0,0,0.11)**
|
||||||
|
- `opacity = expansion_progress² * (1 - hide_progress)`,`opacity <= 0.01` 直接跳过
|
||||||
|
- 偏移 `offset_y = 2.0 * scale`;模糊 `sigma = min(3.0*scale, margin/3.0)`(margin = 到画布边界的余量)
|
||||||
|
- 用 `ClipOp::Difference` 只画在岛形之外
|
||||||
|
- **未找到内阴影**(没有任何 inner shadow 实现)
|
||||||
|
|
||||||
|
### 2.4 分隔线
|
||||||
|
|
||||||
|
| 位置 | 颜色 | 尺寸 | 来源 |
|
||||||
|
| -------------------- | -------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------- |
|
||||||
|
| compact 挂件之间 | `rgba(255,255,255, 0.14*alpha)` | 宽 `max(scale,1)`,从 `top+25%h` 到 `bottom-25%h` | `src/ui/widget/compact/mod.rs` `draw_separator()` |
|
||||||
|
| compact 资源占用内部 | `rgba(255,255,255, 0.10*alpha)` | 宽 `max(0.75*scale, 0.75)`,半高 `DIVIDER_HALF_HEIGHT = 6.0*scale` | `src/ui/widget/compact/resource_usage.rs` |
|
||||||
|
| expanded 资源环分隔 | `rgba(白, 0.09*alpha)` | 宽 `scale.max(0.75)`,从 `y+0.2h` 到 `y+0.8h` | `src/ui/widget/expanded/resource_usage.rs` |
|
||||||
|
| 设置窗口分隔线 | `rgba(255,255,255,26)` 暗 / `rgba(20,28,40,18)` 亮 | — | `src/utils/color.rs` |
|
||||||
|
|
||||||
|
### 2.5 文字颜色与不透明度分层
|
||||||
|
|
||||||
|
主文字一律 **#FFFFFF**;次级用 alpha:
|
||||||
|
|
||||||
|
| 元素 | 有效颜色 | 来源 |
|
||||||
|
| ------------------ | ------------------------------------------------------------ | ---------------------------------------------------- |
|
||||||
|
| 通知 app 名 | `rgba(255,255,255, 0.65*alpha)` | `src/ui/compact/notification.rs` |
|
||||||
|
| 通知标题 | `rgba(255,255,255, alpha)` | 同上 |
|
||||||
|
| 通知详情 | `rgba(255,255,255, 0.72*alpha)` | 同上 |
|
||||||
|
| 音量标签 | `rgba(255,255,255, 0.9*alpha)` | `src/ui/compact/volume.rs` |
|
||||||
|
| expanded 歌手 | `rgba(白, alpha*0.6)` | `src/ui/expanded/music_view.rs` `draw_track_text()` |
|
||||||
|
| 迷你歌词主行 | 白 alpha | `src/core/render/mini.rs` |
|
||||||
|
| 迷你歌词次行 | `rgb(176,176,176)`(`SECONDARY_LYRIC_CHANNEL = 176`) | 同上 |
|
||||||
|
| 逐字歌词"未唱"部分 | `rgb(190,190,190)`(`PENDING_LYRIC_CHANNEL = 190`) | 同上 |
|
||||||
|
| 进度条时间 | 白,`PROGRESS_TIME_IDLE_ALPHA = 0.5` → hover 1.0(线性插值) | `src/ui/expanded/music_view.rs` |
|
||||||
|
| 挂件时钟 | 白 alpha(bold) | `src/ui/widget/expanded/time.rs` |
|
||||||
|
| 挂件日历 月 | `0.78*alpha` | `src/ui/widget/expanded/calendar.rs` |
|
||||||
|
| 挂件日历 日 | `1.0*alpha` | 同上 |
|
||||||
|
| 挂件日历 星期 | `0.62*alpha` | 同上 |
|
||||||
|
| 挂件资源 数值 | `1.0*alpha`(bold) | `src/ui/widget/expanded/resource_usage.rs` |
|
||||||
|
| 挂件资源 标签 | `0.58*alpha` | 同上 |
|
||||||
|
| 封面占位块 | `rgba(白, 0.15*alpha)` | `src/ui/expanded/music_view.rs` `draw_placeholder()` |
|
||||||
|
|
||||||
|
### 2.6 强调色 / 状态色
|
||||||
|
|
||||||
|
岛内(`src/ui/widget/resource_usage.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
CPU_COLOR = rgb(50, 190, 246) // #32BEF6
|
||||||
|
RAM_COLOR = rgb(175, 82, 222) // #AF52DE
|
||||||
|
WARNING_COLOR = rgb(255, 159, 10) // #FF9F0A
|
||||||
|
CRITICAL_COLOR = rgb(255, 69, 58) // #FF453A
|
||||||
|
```
|
||||||
|
|
||||||
|
阈值曲线 `usage_color(base, usage)`:
|
||||||
|
|
||||||
|
- `usage <= 0.75` → base
|
||||||
|
- `0.75 < usage <= 0.9` → base ↔ WARNING 线性混合,`t = (usage-0.75)/0.15`
|
||||||
|
- `0.9 < usage` → WARNING ↔ CRITICAL 线性混合,`t = (usage-0.9)/0.1`
|
||||||
|
采样间隔 `SAMPLE_INTERVAL = 1s`;无数值时显示 `—`。
|
||||||
|
|
||||||
|
进度条/音量条(`src/ui/expanded/music_view.rs`、`src/ui/compact/volume.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
PROGRESS_TRACK_ALPHA = 0.15; // 轨道 rgba(白, 0.15*alpha)
|
||||||
|
PROGRESS_TIME_IDLE_ALPHA = 0.5; // 时间文字静止透明度
|
||||||
|
// 音量轨道 rgba(255,255,255, 0.28*alpha),填充纯白 alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
音量的"填充"就是**白色**(不是强调色),图标仍是白色。
|
||||||
|
|
||||||
|
可视化条颜色**来自专辑封面**(`src/ui/expanded/music_view/palette.rs`):
|
||||||
|
|
||||||
|
- 把封面画到 8×8 的 GPU surface(`PALETTE_SAMPLE_SIZE = 8`)读回求平均 RGB
|
||||||
|
- `primary = brighten(avg, 1.3)`、`secondary = brighten(avg, 1.5)`
|
||||||
|
- `brighten` 用 BT.601 亮度 `0.299R + 0.587G + 0.114B`;若 < 80 则整体加 `80 - brightness`(抬到最低亮度 80)
|
||||||
|
- 调色板 = `[primary, secondary, primary]`,缓存上限 50 条
|
||||||
|
- 无封面默认值(`default_media_palette`):`rgb(180,180,180)` + `rgb(100,100,100)`
|
||||||
|
- 采样完全失败的兜底:`rgb(200,200,200)`
|
||||||
|
|
||||||
|
### 2.7 不同状态是否不同配色?
|
||||||
|
|
||||||
|
- **同一套**:底 + 白字 + alpha 分层;通知 / 音量 / 音乐 / 挂件没有各自的独立配色,
|
||||||
|
视觉区分靠 **尺寸(330×82 / 192×37 / 120×27)** 与 **内容排版**,而不是换色。
|
||||||
|
- 差异只在两处:**可视化条用封面取色**(2.6),**资源占用用 CPU/RAM/警告/危险四色**(2.6)。
|
||||||
|
- 岛背景三样式(default/glass/dynamic)才是真正的配色分支(2.1)。
|
||||||
|
|
||||||
|
### 2.8 设置窗口色板(另一套,`src/utils/color.rs`,供参考)
|
||||||
|
|
||||||
|
暗色主题 `dark_settings_theme()`:
|
||||||
|
|
||||||
|
```
|
||||||
|
win_bg #1C1C1E sidebar_bg #242426 group_bg #2C2C2E card_highlight #48484A
|
||||||
|
accent #0A84FF text_pri #F5F5F7 text_sec #AEAEB2 disabled #636366
|
||||||
|
danger #E6372D toggle_on #30D158 toggle_off #636366
|
||||||
|
sidebar_hover rgba(255,255,255,20) separator rgba(255,255,255,26)
|
||||||
|
popup_bg #323234 popup_border rgba(255,255,255,40) popup_shadow rgba(0,0,0,60)
|
||||||
|
popup_separator rgba(255,255,255,30) control_bg #3A3A3C control_hover #48484A
|
||||||
|
control_disabled #303032 control_border rgba(255,255,255,36)
|
||||||
|
group_border rgba(255,255,255,24) shadow rgba(0,0,0,45) scrollbar rgba(255,255,255,60)
|
||||||
|
```
|
||||||
|
|
||||||
|
亮色主题 `light_settings_theme()`:
|
||||||
|
|
||||||
|
```
|
||||||
|
win_bg #F4F5F7 sidebar_bg #FAFAFC group_bg #FFFFFF card_highlight #EFF2F7
|
||||||
|
text_pri #1F2228 text_sec #5B626E disabled #9297A1 accent #0067C0
|
||||||
|
danger #C42B1C toggle_on #0078D4 toggle_off #B0B5BE
|
||||||
|
selection_bg #E0EEFA selection_text #005599
|
||||||
|
sidebar_hover rgba(20,32,48,14) separator rgba(24,32,44,18)
|
||||||
|
popup_bg #FFFFFF popup_border rgba(20,28,40,24) popup_shadow rgba(20,28,40,20)
|
||||||
|
control_bg #F7F8FA control_hover #EBEFF4 control_disabled #F1F2F5
|
||||||
|
control_border rgba(20,28,40,24) group_border rgba(20,28,40,14)
|
||||||
|
shadow rgba(20,28,40,20) scrollbar rgba(52,58,68,52)
|
||||||
|
```
|
||||||
|
|
||||||
|
设置窗口另有"岛预览"配色(与真实渲染略有差异,`src/utils/settings_ui/renderer/widget_preview.rs`):
|
||||||
|
glass 预览 `rgba(24,24,28,220)`;dynamic 预览为线性渐变 **`rgb(18,12,36)` → `rgb(8,24,48)`**(左上→右下);
|
||||||
|
default 预览 `rgb(10,10,10)`;预览描边 alpha `52`(glass) / `38`(其他);预览阴影 `rgba(0,0,0,72)`,偏移 +4。
|
||||||
|
→ 若前端需要一张**静态渐变**替代 `dynamic`,这两个色值就是现成可抄的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 排版(字体 token)
|
||||||
|
|
||||||
|
### 3.1 字体族(`src/utils/font.rs`)
|
||||||
|
|
||||||
|
1. 用户自定义字体:`config.custom_font_path` → `FontMgr::new_from_data()`(优先于全部)
|
||||||
|
2. ASCII 文本:`"Microsoft YaHei"` → `"Segoe UI"` → 系统默认(`legacy_make_typeface`)
|
||||||
|
3. 非 ASCII:逐字符匹配 `match_family_style_character("", style, &["zh-CN","ja-JP","en-US"], ch)`,
|
||||||
|
再依次尝试 `"Segoe UI Emoji"` → `"Microsoft YaHei"` → `"Segoe UI Symbol"` → `"Segoe UI"`
|
||||||
|
4. 合成粗体:`needs_synthetic_bold()` = 请求 `weight >= 600` 且字体本身 `weight < 600` → `font.set_embolden(true)`
|
||||||
|
5. 亚像素渲染始终开启 `font.set_subpixel(true)`
|
||||||
|
|
||||||
|
字重只有两档:`FontStyle::bold()`(700)与 `FontStyle::normal()`(400);
|
||||||
|
滚动文本判定粗体用 `style.weight() >= 700`(`src/utils/scroll.rs`)。
|
||||||
|
|
||||||
|
**字距 / 行高**:代码里**未找到** letter-spacing 或 line-height 参数;
|
||||||
|
行距全部通过显式 y 偏移实现(见下表)。文本绘制走 Skia `draw_str`,
|
||||||
|
大字文本(标题/歌手)`render_as_paths = true`(`src/utils/scroll.rs` 调用点)。
|
||||||
|
|
||||||
|
### 3.2 字号表(默认 `config.font_size = 0.0` 时用内置值;>0 时用户值参与计算)
|
||||||
|
|
||||||
|
| 用途 | 字号 | 字重 | 说明 / 来源 |
|
||||||
|
| -------------------- | -------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| mini 歌词主行 | `font_size*0.8*scale` 或 **12.0\*scale** | normal | `src/core/render/mini.rs` `lyric_font_size()` |
|
||||||
|
| mini 歌词次行 | 主字号 × `SECONDARY_LYRIC_SCALE = 0.85` | normal | 同上 |
|
||||||
|
| mini 歌词对高度 | `主*(1+0.85+0.18) + 8*scale` | — | `lyric_pair_height()` |
|
||||||
|
| mini 插件文字(主) | `font_size*0.7*scale` 或 **11.0\*scale** | **bold** | `PLUGIN_FONT_SCALE = 0.7`、`DEFAULT_PLUGIN_FONT_SIZE = 11.0` |
|
||||||
|
| mini 插件文字(次) | 主 × `0.8` | normal | `PLUGIN_SECONDARY_FONT_SCALE = 0.8`,行距 `font_size*1.3`(`PLUGIN_SECONDARY_LINE_SPACING = 1.3`) |
|
||||||
|
| expanded 曲名 | `font_size*scale` 或 **15.0\*scale** | **bold** | `src/ui/expanded/music_view.rs` |
|
||||||
|
| expanded 歌手 | 同上 **15.0\*scale** | normal | 基线 = 曲名基线 + `22.0*scale` |
|
||||||
|
| 进度时间 | `font_size*0.67*scale` 或 **10.0\*scale** | normal | `PROGRESS_TIME_FONT_SCALE = 0.67`、`DEFAULT_PROGRESS_TIME_FONT_SIZE = 10.0` |
|
||||||
|
| 通知 app 名 | **11.0\*scale** | normal | `src/ui/compact/notification.rs` |
|
||||||
|
| 通知标题 | **13.0\*scale** | **bold** | 同上 |
|
||||||
|
| 通知详情 | **11.0\*scale** | normal | 行间距 `DETAIL_LINE_GAP = 21.0*scale` |
|
||||||
|
| 音量标签 | **12.0\*scale** | normal | `src/ui/compact/volume.rs` |
|
||||||
|
| compact 时钟挂件 | `clamp(h*0.43, 10, 12)` | **bold** | `src/ui/widget/compact/time.rs` |
|
||||||
|
| compact 资源—标签 | `METRIC_LABEL_SIZE = 7.0*scale` | **bold** | `src/ui/widget/compact/resource_usage.rs` |
|
||||||
|
| compact 资源—数值 | `METRIC_VALUE_SIZE = 10.0*scale` | **bold** | 同上 |
|
||||||
|
| expanded 时钟挂件 | `min(h*0.60, w*0.31)`,最小 `13.0*scale` | **bold** | `src/ui/widget/expanded/time.rs` |
|
||||||
|
| expanded 日历—月 | `clamp(h*0.14, 9, 15)*scale` | **bold** | `src/ui/widget/expanded/calendar.rs` |
|
||||||
|
| expanded 日历—日 | `min(h*0.53, w*0.70)`,最小 `26.0*scale` | **bold** | 同上 |
|
||||||
|
| expanded 日历—星期 | `clamp(h*0.12, 8, 12)*scale` | normal | 同上 |
|
||||||
|
| expanded 资源—圆环值 | `clamp(d*0.27, 7, 10.5*scale)`,d = 圆环直径 | **bold** | `src/ui/widget/expanded/resource_usage.rs` |
|
||||||
|
| expanded 资源—标签 | `clamp(h*0.12, 6, 8*scale)` | **bold** | 同上 |
|
||||||
|
| 设置窗口—行标签/值 | **13.0** | normal | `src/utils/settings_ui/renderer/items.rs` |
|
||||||
|
| 设置窗口—页签标签 | **11.5** | normal(选中为 bold) | `src/window/settings/renderer.rs` |
|
||||||
|
| 设置窗口—弹窗项 | **12.0** | normal | 同上 |
|
||||||
|
| 设置窗口—Markdown | `BODY_SIZE = 12.0`,`LINE_HEIGHT = 18.0` | — | `src/window/settings/pages/plugins/markdown.rs` |
|
||||||
|
|
||||||
|
字体基线规则(值得照抄):
|
||||||
|
|
||||||
|
- 居中对齐用 **字形包围盒** 居中:`baseline_y = bounds.top + (bounds.h - glyph.h)/2 - glyph.top`
|
||||||
|
(`src/ui/widget/expanded/mod.rs` `draw_widget_text_centered()`)
|
||||||
|
- 时间文字基线 = 进度条中心 + `0.35*字号`(`PROGRESS_TIME_BASELINE_SCALE = 0.35`)
|
||||||
|
- 迷你歌词单行时 `primary_y = center_y + size/3`;双行时按堆叠高度对齐(见 5.1)
|
||||||
|
|
||||||
|
### 3.3 是否随窗口尺寸 / 用户设置缩放
|
||||||
|
|
||||||
|
- 随 `compact_scale` / `expanded_scale` 缩放(乘在所有尺寸上)
|
||||||
|
- 字号受 `config.font_size`(默认 0 = 用内置值)影响:只有 mini 歌词、mini 插件、expanded 曲名/歌手、进度时间用它
|
||||||
|
- 挂件字号是**几何驱动**的(按自身矩形宽高 clamp),与 `font_size` 无关
|
||||||
|
- 设置窗口有自己的 `settings_theme`(`system` / 暗 / 亮)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 层级与效果
|
||||||
|
|
||||||
|
### 4.1 圆角
|
||||||
|
|
||||||
|
见 1.7。总结:连续超椭圆(squircle),compact 满圆角(h/2),expanded 最大 48。
|
||||||
|
挂件卡片 `widget_corner_radius = min(12.0*scale, min(w,h)/2)`(`src/ui/widget/expanded/mod.rs`)。
|
||||||
|
迷你封面 `MINI_COVER_RADIUS = 5.0`,expanded 封面 `16.0*scale`,通知图标 `11.0*scale`。
|
||||||
|
|
||||||
|
### 4.2 阴影 / 发光
|
||||||
|
|
||||||
|
- **外阴影**:仅 expanded,`rgba(0,0,0,0.11)`,y+2,blur σ = `min(3*scale, margin/3)`(见 2.3)
|
||||||
|
- **内阴影**:**未找到**
|
||||||
|
- **发光**:只出现在资源占用圆环——`glow` 圆半径 `diameter*0.57`,颜色 `accent`,alpha `0.07*alpha`
|
||||||
|
(`src/ui/widget/expanded/resource_usage.rs`)
|
||||||
|
- 设置窗口:窗口阴影 `rgba(0,0,0,90)`、弹出菜单阴影 `theme.popup_shadow` + 偏移 +2(`src/window/settings/renderer.rs`);
|
||||||
|
拖拽幽灵阴影 `rgba(0,0,0,90)`,偏移 +4
|
||||||
|
|
||||||
|
### 4.3 模糊半径(全部数值)
|
||||||
|
|
||||||
|
| 场景 | 参数 | 来源 |
|
||||||
|
| ----------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||||
|
| 运动模糊(整体) | 横向 = ` | w速度 | *0.3 + | view速度 | *w*0.4`,纵向 = ` | h速度 | *0.3`;上限集显 `(4.0, 3.5)`、独显 `(12.0, 10.0)`;仅在 σ>0.1 时 `saveLayer` | `src/utils/blur.rs` `calculate_blur_sigmas()`、`src/core/render.rs` `MIN_BLUR_SIGMA = 0.1` |
|
||||||
|
| 封面翻转 | `σ = (1- | cos | )^0.6 * 8.0 * scale`,x 方向再 ×0.3 | `src/ui/expanded/music_view.rs` |
|
||||||
|
| 暂停按钮速度模糊 | ` | 弹簧速度 | * 16.0 * scale`,上限 `2.5*scale` | `PAUSE_CONTROL_BLUR_SCALE = 16.0`、`PAUSE_CONTROL_MAX_BLUR = 2.5` |
|
||||||
|
| 快进/快退按钮 | `(1 - t/0.3).max(0) * 6.0 * scale`,x×0.3 | 同文件 `draw_skip_button()` |
|
||||||
|
| 歌词切换(Blur 模式) | `σ = 6.0*scale`,x 压缩 `LYRIC_TRANSITION_BLUR_X_SCALE = 0.25`,位移 `LYRIC_TRANSITION_OFFSET = 10.0*scale` | `src/core/render/mini.rs` |
|
||||||
|
| dynamic 背景封面 | 先降采样到 **64×64**,再 `image_filters::blur((8.0, 8.0), TileMode::Clamp)` | `src/utils/backdrop.rs` |
|
||||||
|
| host backdrop(亚克力) | **未找到数值**(由系统 HostBackdropBrush 决定,仅 `HOST_BACKDROP_INSET = 1.0`) | `src/window/d3d.rs` |
|
||||||
|
|
||||||
|
### 4.4 不透明度分层与可见性阈值
|
||||||
|
|
||||||
|
```
|
||||||
|
MIN_VISIBLE_OPACITY = 0.01 // 低于此不绘制
|
||||||
|
MIN_BLUR_SIGMA = 0.1
|
||||||
|
MINI_FADE_RATE = 1.5 // mini 淡出速率
|
||||||
|
COLLAPSED_VISUALIZER_HEIGHT_SCALE = 0.45 // 收起时可视化高度 = 45%
|
||||||
|
```
|
||||||
|
|
||||||
|
- `expanded_alpha = expansion_progress² * (1 - hide_progress)`(平方 → 展开"迟入场")
|
||||||
|
- `mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1 - hide_progress)`
|
||||||
|
- 可视化高度比例 `visualizer_height_scale = 0.45 + 0.55 * expansion_progress`
|
||||||
|
- 歌词额外淡出 `LYRIC_EXPANSION_FADE_RATE = 2.5`(`src/core/render/mini.rs`)
|
||||||
|
- 展开页横向滑动:`page_shift = view_offset * current_w`;
|
||||||
|
音乐页 `translate(-page_shift)`,挂件页 `translate(current_w - page_shift)`
|
||||||
|
(`src/core/render/expanded.rs`);`view` 弹簧 `0.12/0.68`
|
||||||
|
|
||||||
|
### 4.5 卡片四周留白 / 裁切
|
||||||
|
|
||||||
|
见 1.8:窗口 = 岛 + 80(每侧 40 透明);内容 `clip_path(Intersect)`;
|
||||||
|
描边在裁切外画;挂件网格 inset 24。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 布局骨架
|
||||||
|
|
||||||
|
### 5.1 compact(mini)120×27
|
||||||
|
|
||||||
|
`src/core/render/mini.rs`:
|
||||||
|
|
||||||
|
```
|
||||||
|
MIN_MINI_CONTENT_WIDTH = 45.0 // 可用宽小于此不画内容
|
||||||
|
MINI_COVER_SIZE = 18.0 // 封面尺寸
|
||||||
|
MINI_COVER_LEFT_INSET = 10.0 // 封面左边距
|
||||||
|
MINI_COVER_RADIUS = 5.0 // 封面圆角
|
||||||
|
MINI_VISUALIZER_RIGHT_INSET = 17.0
|
||||||
|
MINI_VISUALIZER_WIDTH_SCALE = 0.55
|
||||||
|
MINI_VISUALIZER_SMOOTHING = (0.6, 0.08) // (上升, 下降)
|
||||||
|
LYRIC_LEFT_INSET = 30.0
|
||||||
|
LYRIC_RIGHT_INSET = 29.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- **左**:18×18 封面,x = 10,y 垂直居中(`(base_h - 18)/2 = 4.5`),圆角 5,中心裁剪
|
||||||
|
- **中**:歌词,可用区 x ∈ [30, w-29],垂直居中;主行居中(滚动模式改为左对齐并从 x=30 起滚动),
|
||||||
|
次行始终水平居中;主行单行时 `y = center_y + size/3`,双行时
|
||||||
|
`stack_h = size + size*0.85 + size*0.18`,主行 `y = center_y - stack_h/2 + size*0.8`,
|
||||||
|
次行 `y = center_y + stack_h/2 - secondary*0.2`
|
||||||
|
- **右**:可视化条,中心 x = `w - 17`,中心 y = `base_h/2`
|
||||||
|
- 无媒体时改由 compact 挂件条接管:Left / Center / Right 三组;
|
||||||
|
有 mini 内容时只画 Left 与(Center+Right)两组并贴两端,并在内容两侧画竖分隔线
|
||||||
|
(`src/ui/widget/compact/mod.rs` `draw()`)
|
||||||
|
- 挂件宽:时间 **48**、资源 **132**;边缘 inset **9**;组内间距 **7**
|
||||||
|
|
||||||
|
### 5.2 expanded 音乐页 360×200
|
||||||
|
|
||||||
|
`src/ui/expanded/music_view.rs` 常量全表:
|
||||||
|
|
||||||
|
```
|
||||||
|
CONTENT_PADDING = 24.0 // 四周内边距
|
||||||
|
COVER_SIZE = 64.0 // 封面 64×64,圆角 16.0*scale,位置 (24,24)
|
||||||
|
TRACK_TEXT_GAP = 18.0 // 封面右到文字左
|
||||||
|
TRACK_TEXT_RIGHT_INSET = 70.0 // 文字右侧预留(给可视化条)
|
||||||
|
TRACK_TITLE_BASELINE_OFFSET= 26.0 // 曲名基线 y = 24 + 26 = 50
|
||||||
|
PROGRESS_TOP_GAP = 24.0 // 进度条中心 y = 24 + 64 + 24 = 112
|
||||||
|
PROGRESS_TIME_WIDTH = 28.0 // 两端时间文字预留宽
|
||||||
|
PROGRESS_TIME_GAP = 4.0
|
||||||
|
PROGRESS_BAR_HEIGHT = 6.5 // 常态条高
|
||||||
|
PROGRESS_BAR_HOVER_GROWTH = 3.5 // hover 增至 10.0
|
||||||
|
PLAYBACK_CONTROLS_TOP_GAP = 42.0 // 控制行中心 y = 112 + bar_h/2 + 42 ≈ 157.25
|
||||||
|
SKIP_BUTTON_GAP = 75.0 // 上一首/下一首中心距播放键 75
|
||||||
|
PAGE_ARROW_RIGHT_INSET = 7.5 // 右侧翻页条 x = w - 7.5,y = h/2
|
||||||
|
PAGE_ARROW_FADE_RATE = 5.0
|
||||||
|
COLLAPSED_VISUALIZER_INSET = 17.0 // 视觉条右侧 inset 随展开 17 → 37
|
||||||
|
EXPANDED_VISUALIZER_INSET = 37.0
|
||||||
|
VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
|
||||||
|
```
|
||||||
|
|
||||||
|
- **左**:64 封面;**中**:曲名(15 bold)/歌手(15, α0.6) 两行,x = 24+64+18 = **106**;**右**:可视化条
|
||||||
|
- 进度条:左右各内缩 24,两端 28 宽时间 + 4 间距;条圆角 = bar_h/2;
|
||||||
|
填充宽度 `max(bar_total_w*progress, bar_h)`;填充只圆左端(RRect 左上/左下半径 = bar_h/2,右侧 0)
|
||||||
|
- 控制行中心 x = 180;播放键在中心、上一首 105、下一首 255
|
||||||
|
- 点击热区(`src/ui/expanded/music_view/controls.rs`):播放 **40×40**,上/下一首 **36×36**,
|
||||||
|
进度条热区高 **16.0\*scale**,封面整块可双击(激活媒体 App)
|
||||||
|
- 动画参数:`PROGRESS_SMOOTHING = 0.15`、`PROGRESS_HOVER_SMOOTHING = 0.18`、
|
||||||
|
`PROGRESS_HOVER_SNAP_THRESHOLD = 0.005`、`PROGRESS_START_THRESHOLD = 0.02`、`PROGRESS_JUMP_THRESHOLD = 0.3`、
|
||||||
|
`PLAY_STATE_RESPONSE = 0.18`、`SKIP_ANIMATION_DURATION_SECS = 0.5`、封面翻转 0.6s、
|
||||||
|
`PAUSE_CONTROL_STIFFNESS = 0.18`、`PAUSE_CONTROL_DAMPING = 0.64`、`PAUSE_CONTROL_MIN_SCALE = 0.8`、
|
||||||
|
`PAUSE_CONTROL_MAX_SCALE = 1.03`、`PAUSE_CONTROL_PRESS_VELOCITY = -0.18`
|
||||||
|
- 播放/暂停过渡:`cover_scale = 0.85 + 0.15*pause_t`、`cover_brightness = 0.75 + 0.25*pause_t`
|
||||||
|
(即暂停时封面缩小到 85% 并压暗到 75%),图标用 `smoothstep` 交叉淡出
|
||||||
|
|
||||||
|
### 5.3 expanded 挂件页(同 360×200)
|
||||||
|
|
||||||
|
- 6×3 网格,inset 24,gap 7(见 1.9)
|
||||||
|
- 卡片:背景 `rgba(28,28,30, 0.05*alpha)`,描边 `1px rgba(255,255,255, 0.16*alpha)`,圆角 `min(12*scale, min(w,h)/2)`
|
||||||
|
- 左侧翻页条 x = **7.5**,y = `h/2`(3×14 圆角矩形,α0.4);右侧同(x = w-7.5)
|
||||||
|
- 内置挂件各视图排布:
|
||||||
|
- **时钟**:整卡居中,字号 `min(h*0.6, w*0.31)`(≥13),bold,`HH:MM` 格式
|
||||||
|
- **日历**:月 `y+0.08h..0.26h`(bold,α0.78)/日 `y+0.27h..0.75h`(bold,字号 `min(0.53h,0.70w)`,≥26)/
|
||||||
|
星期 `y+0.78h..0.92h`(normal,α0.62)
|
||||||
|
- **资源占用(2×1)**:中间竖分隔(α0.09,`y+0.2h..0.8h`);两个圆环各占一半:
|
||||||
|
环心 `(center_x, top+0.43h)`,直径 `min(0.58h, 0.62w)`(≥20),描边 `min(3*scale, d*0.12)` 圆头,
|
||||||
|
底环 α0.13,进度弧从 **-90°** 起扫 `usage*360°`(α0.92),值文字 bold 居中,标签在 `top+0.76h`(α0.58)
|
||||||
|
- **设置(1×1)**:整卡居中齿轮图标,`icon_scale = min(w,h)*0.38/24`,α0.72
|
||||||
|
|
||||||
|
### 5.4 compact 各业务视图差异
|
||||||
|
|
||||||
|
| 视图 | 尺寸 | 排布 |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 时间挂件 | 48×27 | 居中 `HH:MM`,字号 `clamp(0.43h, 10..12)` bold,基线 `center_y + size*0.36` |
|
||||||
|
| 资源挂件 | 132×27 | 两个 metric 各 `(w-8)/2` 宽;标签 7px bold 在左(accent α0.78),数值 10px bold 在右(白); |
|
||||||
|
| 文字基线 `center_y + scale`;下方 2px 进度轨(y = 基线+5),轨 α0.13,填充 accent α0.9;中间分隔 α0.1,半高 6 |
|
||||||
|
| 通知 | 330×82 | 图标 42×42 圆角 11 @ x+18 垂直居中;文本左 = 有图标 `x+72` / 无图标 `x+20`;右内缩 18; |
|
||||||
|
| app 名基线 `top+22`(11px α0.65);标题 13px bold,基线 `top+34`(无 app 名)/ `top+43`(有 app 名)/ 垂直居中 `top+(h+13)/2`(两者皆无);详情 11px α0.72 在标题下 21px |
|
||||||
|
| 音量 | 192×37 | 喇叭图标 20px,中心 `x = left+21`;标签 12px @ `left+37`(α0.9);轨道从 `label_x + label_w + 26` 到 `right-14`,高 4 圆角 2,轨 α0.28,填充白 α1.0 |
|
||||||
|
|
||||||
|
### 5.5 动画时长(通知 / 音量)
|
||||||
|
|
||||||
|
- 通知:`DISPLAY_DURATION = 5s`,`ENTER_DURATION = 220ms`(`ease_out_cubic = 1-(1-t)³`),
|
||||||
|
`FADE_DURATION = 280ms`;入场位移 `(1-enter)*7.0*scale`(从上滑下 7px)
|
||||||
|
- 音量:`DISPLAY_DURATION = 1600ms`,`FADE_DURATION = 240ms`,
|
||||||
|
`VOLUME_CHANGE_THRESHOLD = 0.002`(音量变化阈值,低于此不显示)
|
||||||
|
- 歌词切换进度用 `smoothstep`:`p*p*(3-2p)`(`src/core/render/mini.rs`)
|
||||||
|
- 长文本跑马灯(`src/utils/scroll.rs`):先停 **2.0s**,再以 **35.0\*scale px/s** 滚动,
|
||||||
|
循环间隔 `full_w + 50*scale`,裁剪区 `(x, y-size*1.2, max_w, size*1.5)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 图标
|
||||||
|
|
||||||
|
**体系**:全部是 Skia 内的 **SVG path 数据 + 填充(`PaintStyle::Fill`)**,没有线性描边图标体系,
|
||||||
|
也没有 icon font。颜色一律单色:由 `text_color`(= 白)+ alpha 决定;只有资源环用 accent 色。
|
||||||
|
**未找到**线宽(stroke-width)概念——唯一的描边图标是设置窗口的翻页箭头(`1.8px`, round cap/join)。
|
||||||
|
|
||||||
|
| 图标 | 路径坐标系 | 缩放 | 实际视觉尺寸 | 来源 |
|
||||||
|
| ---------------- | ------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| 播放 ▶ | 640 单位(128..544) | `0.083 * scale` | ≈ 34.5 × 37 px | `src/icons/controls.rs` `draw_play_button` |
|
||||||
|
| 暂停 ⏸ | 640 单位 | `0.083 * scale` | ≈ 34.5 × 37 px | `draw_pause_button` |
|
||||||
|
| 快退/快进三角 | 640 单位(中心 336,320) | `path_scale * global_scale`,静止 `0.050`(左)与 `0.055`(右);动画中 `0.050~0.055` | 约 21 × 22 px | `draw_control_triangle`,`src/ui/expanded/music_view.rs` `draw_skip_button`(中心 ±10.92*scale) |
|
||||||
|
| 音乐(封面占位) | 1024 单位 | `scale*1.8`,内部 `0.024` | 随卡片 | `src/icons/music.rs` |
|
||||||
|
| 设置齿轮 | 1024 单位 | `min(w,h)*0.38/24`,内部 `0.024` | 卡片 38% 短边 | `src/icons/settings.rs` |
|
||||||
|
| 音量(4 档) | 25.75 单位 | `size / 25.75`,偏移 `(-11.089286, -7.883929)` | 传 20.0*scale | `src/icons/volume.rs`;档位 `level<=0→静音, <=1/3→低, <=2/3→中, else→高` |
|
||||||
|
| 翻页指示条 | 不是 path | — | `3.0*scale × 14.0*scale` 圆角矩形,圆角 `2.0*scale`,α×0.4 | `src/icons/arrows.rs` |
|
||||||
|
|
||||||
|
其他图标使用点:
|
||||||
|
|
||||||
|
- 通知 App 图标:`AppDisplayInfo::GetLogo(Size{64,64})`,按 alpha>8 计算可见包围盒,等比缩放塞进 42×42,圆角 11 裁切
|
||||||
|
- 封面:64×64(expanded)与 18×18(mini),中心裁剪,`FilterMode::Linear + MipmapMode::Linear`
|
||||||
|
- 迷你封面圆角 5、expanded 封面圆角 16(连续超椭圆路径,不是普通 `clip_rrect`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 明确「未找到」的项
|
||||||
|
|
||||||
|
1. **刘海/挖孔造型**:不存在(无 `SetWindowRgn`、无顶部直角特例)
|
||||||
|
2. **内阴影 / inset shadow**:代码中无任何实现
|
||||||
|
3. **字距(letter-spacing)与行高倍数**:无;行距全靠显式 y 偏移
|
||||||
|
4. **亚克力的模糊半径/透明度数值**:由 Windows `HostBackdropBrush` 系统决定,代码只给 `HOST_BACKDROP_INSET = 1.0`
|
||||||
|
→ Electron 侧**没有**等价物:`setBackgroundMaterial('acrylic')` 是整窗系统材质(跟随应用主题、覆盖窗口矩形),
|
||||||
|
不能像 `DWMWA_USE_HOSTBACKDROPBRUSH` 那样只画在圆角形状里;本项目因此改用无 backdrop 的降级色(见 §8)
|
||||||
|
5. **强调色用于岛内 UI**:岛内几乎没有强调色(只有资源占用的 CPU/RAM/警告/危险色);
|
||||||
|
统一强调色 `#0A84FF` 仅存在于**设置窗口**(`COLOR_ACCENT`)
|
||||||
|
6. **岛自身的亮/暗主题**:岛屿永远是深底白字;明暗主题只在设置窗口
|
||||||
|
7. **固定渐变色背景**:真实 `dynamic` 背景是封面图,没有固定渐变;
|
||||||
|
仅设置页预览用 `rgb(18,12,36) → rgb(8,24,48)`(可当静态替代)
|
||||||
|
8. **CSS 等价的超椭圆圆角公式**:真实实现是 Flutter 超椭圆查表(`src/utils/shape.rs`),没有简单的 border-radius 参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 给 Web/Electron 复刻的映射速查
|
||||||
|
|
||||||
|
- 岛容器:定位在屏幕顶上 `top: 10px`、水平居中,尺寸与圆角用 spring 插值
|
||||||
|
(`cubic-bezier` 或 JS 弹簧,参考 `stiffness 0.10 / damping 0.68`);`overflow: hidden` 做裁切
|
||||||
|
- compact:`120×27`(有歌词时宽度跟着文本走,上限 700;只有媒体无歌词时 155),`border-radius: 13.5px`(胶囊)
|
||||||
|
- expanded:`360×200`,`border-radius: 48px`(想要 iOS 味请用 SVG 超椭圆路径)
|
||||||
|
- 背景:`#000` 不透明;"glass" **不要**照搬 WinIsland 的 host backdrop —— Electron 的
|
||||||
|
`setBackgroundMaterial()` 是"整窗系统材质",覆盖范围是窗口矩形而不是卡片形状,且明暗跟随应用主题,
|
||||||
|
在浅色主题下会给卡片垫出一圈浅色面板(详见 `packages/desktop-client/src/main/island.ts`);
|
||||||
|
我们改用 WinIsland 自己的**无 backdrop 降级色** `rgba(32,32,36,.804)` 画在卡片上;
|
||||||
|
"dynamic" 用封面模糊层 + `rgba(20,20,24,.471)` 叠加
|
||||||
|
- 描边:`1px solid rgba(255,255,255,.118)`(纯色样式,展开时淡出)/ `.157`(glass、dynamic)
|
||||||
|
- 阴影:仅展开 `0 2px σ rgba(0,0,0,.11)`(σ ≈ 3px)
|
||||||
|
- 文字:`-apple-system / "Microsoft YaHei" / "Segoe UI"`,白 + alpha 分层(.65 / .72 / .9 / .6 / .5 / .58 / .62)
|
||||||
|
- 图标:内联 SVG,`fill: currentColor`,播放/暂停按 640 坐标系、尺寸 ≈ 34.5×37
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:班级小助手实际采纳了哪些(本次重写对照表)
|
||||||
|
|
||||||
|
| WinIsland 做法 | 出处 | 我们的落地 | 验证 |
|
||||||
|
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| **窗口一次建大包围盒,形变发生在窗口内** | `window/app/layout.rs` `required_window_size()` + `PADDING=80` | 窗口固定为「最大形态 + `ISLAND_SHADOW_PAD(10)`」,开合期间**一帧都不 setBounds** | 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种) |
|
||||||
|
| 尺寸/圆角由弹簧驱动 | `utils/physics.rs` + `app.rs` `IslandSprings`(w/h/r = `0.10/0.68`) | `src/island/spring.ts` 同参数同更新式(dt 以帧计、`damping^dt`、换向保留 35% 动量 + 20% 距离上限),并加**零过冲钳制** | 岛中心/边缘波动 0.00px、无过冲 |
|
||||||
|
| 连续圆角(超椭圆) | `utils/shape.rs` `continuous_rounded_rect_path` / `expanded_island_radius = min(48*scale, w/2, h/2)` | `src/island/squircle.ts` 超椭圆角路径;展开半径 `min(48×圆角/20, w/2, h/2)` | 形状 bbox 与卡片框一致(±2px) |
|
||||||
|
| 内容交叉淡入 `expanded=progress²`、`mini=1-1.5×progress` | `core/render.rs`(`MINI_FADE_RATE=1.5`) | 两层常驻、透明度按同一公式 | 截图与 DOM 断言 |
|
||||||
|
| 描边 1px 白 alpha 30(纯色)/ 40(glass) | `core/render.rs` `BORDER_*_ALPHA` | `--wn-border` 取同值(`.118` / `.157`) | 计算样式断言 |
|
||||||
|
| 展开投影 `rgba(0,0,0,.11)`、y+2、σ=3 | `draw_expanded_shadow` | `drop-shadow(0 2px 3px rgba(0,0,0,.11))` | 截图 |
|
||||||
|
| 纯黑底 / 亚克力(HostBackdropBrush) | `background.rs` + `win32.rs` `DWMWA_USE_HOSTBACKDROPBRUSH` | 纯黑底 / **glass 用 CSS 半透明深色卡**(WinIsland 无 backdrop 降级色 `rgba(32,32,36,.804)`);**不调用** `setBackgroundMaterial()` | 计算样式 + 「卡片外圈透出桌面」整屏截图像素断言 |
|
||||||
|
| `set_cursor_hittest` 命中控制 | `frame.rs`(`set_cursor_hittest`) | 默认 `setIgnoreMouseEvents(true,{forward:true})`,渲染层命中测试后切换 | 岛内 true / 岛外 false |
|
||||||
|
| 空闲细缝 `hidden_width = 5` | `core/config.rs` | 6px 宽竖条(窗口固定,不再受 Windows 最小窗口高度限制) | 空闲 `6x22` → 来消息 `268x44` |
|
||||||
|
| 6 个停靠位 `DockPosition` | `core/config.rs` | 顶/底 × 左/中/右 6 锚点,岛(而非窗口)精确落在锚点 | 6 锚点坐标断言 |
|
||||||
|
| `font_size` = 基础字号 × 各文本系数 | `core/render/mini.rs`、`ui/expanded/music_view.rs` | 基础字号 11–20px × 系数表(标题 1.08/正文 0.95/次要 0.78/徽标与按钮 0.76/胶囊 0.92、0.74) | 13→19px 全部文本倍率 1.462 |
|
||||||
|
| 未采纳:音乐/歌词/小组件/音量浮窗 | `ui/expanded/**`、`ui/widget/**` | 业务不同(我们做通知/作业/叫人/紧急) | — |
|
||||||
@@ -26,6 +26,8 @@ export default tseslint.config(
|
|||||||
'**/dist-electron/**',
|
'**/dist-electron/**',
|
||||||
'**/release/**',
|
'**/release/**',
|
||||||
'**/release-server/**',
|
'**/release-server/**',
|
||||||
|
// 交付产物归集目录(pnpm dist:* 的输出,见 AGENTS.md §4):里面是构建后的压缩 JS,不该进 lint
|
||||||
|
'**/releases/**',
|
||||||
'**/build/**',
|
'**/build/**',
|
||||||
'**/coverage/**',
|
'**/coverage/**',
|
||||||
'**/src/generated/**',
|
'**/src/generated/**',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev",
|
"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": "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",
|
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
|
||||||
|
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
|
||||||
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
|
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
@@ -30,11 +31,15 @@
|
|||||||
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
|
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
|
||||||
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
|
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
|
||||||
"verify:web": "node scripts/ui-smoke/run.mjs",
|
"verify:web": "node scripts/ui-smoke/run.mjs",
|
||||||
|
"verify:packaged": "node scripts/verify-packaged.mjs",
|
||||||
|
"verify:classisland": "pnpm --filter @classhelper/server verify:classisland",
|
||||||
|
"verify:classisland-plugin": "node packages/classisland-plugin/scripts/verify.mjs",
|
||||||
"icons": "node scripts/generate-icons.mjs",
|
"icons": "node scripts/generate-icons.mjs",
|
||||||
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
|
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
|
||||||
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
|
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
|
||||||
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
|
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
|
||||||
"dist:all": "pnpm dist:server && pnpm dist:win"
|
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
|
||||||
|
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# .NET 构建产物(与仓库根的 .gitignore 分开:根 .gitignore 是 GBK 编码,apply_patch 读不了)
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
cipx/
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!--
|
||||||
|
ClassIsland 2.0 / 2.1.0.x 面向 net8.0;2.1.1 起改为 net10.0。
|
||||||
|
本插件按 net8.0 交付,因此 SDK 版本固定到最后一个 net8.0 的版本(2.1.0.1),
|
||||||
|
这样 ClassIsland 2.0.x 与 2.1.0.x 都能加载(apiVersion 见 manifest.yml)。
|
||||||
|
-->
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
<RootNamespace>ClassHelper.ClassIslandPlugin</RootNamespace>
|
||||||
|
<AssemblyName>ClassHelper.ClassIslandPlugin</AssemblyName>
|
||||||
|
<ClassIslandPluginSdkVersion>2.1.0.1</ClassIslandPluginSdkVersion>
|
||||||
|
<!-- 插件由 ClassIsland 加载,不需要生成自带运行时(ExcludeAssets 见下) -->
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<!-- 设置页是 .axaml:需要让 Avalonia 的默认项把 **/*.axaml 收成 AvaloniaXaml 并编译 -->
|
||||||
|
<EnableDefaultItems>true</EnableDefaultItems>
|
||||||
|
<!-- 绑定走运行时解析:插件与主程序各自编译,编译期绑定会因看不到对方的类型而失败 -->
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 运行时与原生资源由 ClassIsland 本体提供,插件只编译期依赖 -->
|
||||||
|
<PackageReference Include="ClassIsland.PluginSdk" Version="$(ClassIslandPluginSdkVersion)">
|
||||||
|
<ExcludeAssets>runtime;native</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="manifest.yml">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="README.md">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="icon.png">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"ClassIsland 插件": {
|
||||||
|
"commandName": "Executable",
|
||||||
|
"executablePath": "$(ClassIsland_DebugBinaryFile)",
|
||||||
|
"commandLineArgs": "-epp $(TargetDir)",
|
||||||
|
"workingDirectory": "$(ClassIsland_DebugBinaryDirectory)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# 班级小助手联动插件
|
||||||
|
|
||||||
|
把 [ClassIsland](https://classisland.tech) 与 [班级小助手](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||||
|
让班级小助手**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||||
|
|
||||||
|
## 它能做什么
|
||||||
|
|
||||||
|
| 方向 | 说明 |
|
||||||
|
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| ClassIsland → 班级小助手 | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||||
|
| 班级小助手 → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||||
|
| 班级小助手 → ClassIsland(可选) | 把班级小助手上排好的课表**镜像**回 ClassIsland(新建 `班级小助手-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
1. 把插件放进 ClassIsland 的插件目录,二选一:
|
||||||
|
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
|
||||||
|
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
|
||||||
|
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
|
||||||
|
2. 重启 ClassIsland,在「设置 → 插件」里确认「班级小助手联动」已启用。
|
||||||
|
3. 打开「设置 → 班级小助手联动」,填入:
|
||||||
|
- **服务器地址**:班级小助手后端地址,例如 `http://127.0.0.1:4000`;
|
||||||
|
- **设备令牌**:在班级小助手 Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
||||||
|
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
|
||||||
|
|
||||||
|
## 配置项(全部在「设置 → 班级小助手联动」里)
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| 服务器地址 | 班级小助手后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||||
|
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
|
||||||
|
| 上报课表到班级小助手 | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||||
|
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
|
||||||
|
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
|
||||||
|
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
|
||||||
|
| 接收班级小助手提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||||
|
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
|
||||||
|
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
|
||||||
|
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
|
||||||
|
| 显示调试信息 | 展开设备标识、与服务端的时钟差、配置目录等,排查「为什么同步不上」时打开 |
|
||||||
|
|
||||||
|
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
|
||||||
|
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
|
||||||
|
|
||||||
|
> **上课时段策略(与 ClassHelper 客户端同一套规则)**:
|
||||||
|
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
|
||||||
|
> 暂存期间不回执,服务端会保留这条待提醒。
|
||||||
|
|
||||||
|
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
|
||||||
|
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
|
||||||
|
> 不依赖班级小助手那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
|
||||||
|
|
||||||
|
> **两个方向各自需要的开关**:
|
||||||
|
>
|
||||||
|
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
|
||||||
|
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开;
|
||||||
|
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败;
|
||||||
|
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
|
||||||
|
|
||||||
|
> **提醒到底会不会弹,取决于两个开关:**
|
||||||
|
>
|
||||||
|
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
||||||
|
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
|
||||||
|
> 2. 本插件的「接收班级小助手提醒」关掉时,即使推过来也不弹。
|
||||||
|
>
|
||||||
|
> 两者都开着,提醒才会在 ClassIsland 上出现。
|
||||||
|
|
||||||
|
## 工作原理(为什么这么写)
|
||||||
|
|
||||||
|
- **不用跨进程 IPC,插件内直接读课程服务**:ClassIsland 的 IPC 暴露的是缓存数据,不足以还原课表;
|
||||||
|
插件直接读 `ILessonsService`(当前科目 / 状态 / 时间点)与 `IProfileService`(档案里的课表、时间表、科目),
|
||||||
|
拿到的才是「教室此刻真实的状态」。
|
||||||
|
- **「上报即拉取」,不引入 Socket 依赖**:每次上报的返回值里会顺带带一条「尚未确认的提醒」,
|
||||||
|
插件收到就交给提醒提供方显示;应用启动时额外拉一次 `pending`,
|
||||||
|
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
|
||||||
|
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
|
||||||
|
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
|
||||||
|
「班级小助手提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||||
|
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
|
||||||
|
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
|
||||||
|
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
|
||||||
|
**第 i 个 `TimeType == 0`(上课)的时间点**一一对应,
|
||||||
|
因此镜像接口为每条课目带上 `startTime` / `endTime`,插件据此重建时间表
|
||||||
|
(课间点从服务端的时间表里按起始时刻对齐)。少一个时间点,整份课表就会错位。
|
||||||
|
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
|
||||||
|
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
|
||||||
|
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
|
||||||
|
- **非破坏性**:镜像只创建 / 更新名字以 `班级小助手-` 开头的课表与时间表;
|
||||||
|
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
|
||||||
|
|
||||||
|
## 上机联调记录(ClassIsland 2.1.0.1 实测)
|
||||||
|
|
||||||
|
下面几条都是把插件装进真实 ClassIsland 跑出来的问题与结论,改动理由就写在这里,避免以后被“顺手优化”回去:
|
||||||
|
|
||||||
|
1. **提醒内容必须在 UI 线程构造并显示。** 上报回调在线程池上,而提醒模板里的图标是 Avalonia 对象
|
||||||
|
(`LucideIconSource → IconSource → AvaloniaObject` 的构造函数会 `Dispatcher.VerifyAccess()`),
|
||||||
|
在线程池上直接构造会抛 `Call from invalid thread`,表现就是“ClassHelper 客户端收到了、ClassIsland 毫无反应”。
|
||||||
|
现在统一用 `Dispatcher.UIThread.Post` 切回 UI 线程。
|
||||||
|
2. **同一条提醒在播放期间要按 id 去重。** 服务端在收到回执前会一直把提醒算作“待弹出”,
|
||||||
|
而轮询间隔(默认 10 秒)可能小于提醒时长(最长 120 秒)—— 不去重就会一遍遍重复弹。
|
||||||
|
只有播完(`Completed`/`Canceled`)才把 id 从“正在播放”里移除并回执。
|
||||||
|
3. **不要自己拼图标串。** 双图标遮罩模板的默认图标由模板自己创建(`new LucideIconSource("\ue0ff")` 这种**字形字符**),
|
||||||
|
自己传 `"fluent(...)"` 之类既可能格式不对、也可能字形不存在。紧急提醒改用 ClassIsland 自带的**滚动文本**正文来表达。
|
||||||
|
4. **`Subject.Fallback` 是占位符(名字就是 `???`)。** 没有课表时 ClassIsland 会把它挂到
|
||||||
|
`CurrentSubject`/`NextClassSubject` 上,直接上报会让 Web 端显示“正在上 ???”,因此上报前归一化成“没有科目”。
|
||||||
|
|
||||||
|
实测截图(真机弹出提醒的遮罩):[普通提醒](../../docs/screenshots/classisland/02-normal-mask.png)、
|
||||||
|
[紧急提醒](../../docs/screenshots/classisland/01-urgent-mask.png)。
|
||||||
|
|
||||||
|
## 构建与打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build:classisland-plugin # Release 构建(产物:packages/classisland-plugin/bin/Release)
|
||||||
|
pnpm dist:classisland-plugin # 追加打包 .cipx 并归集到 releases/classisland-plugin/
|
||||||
|
pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性 / 注册完整性 / DTO ↔ 服务端契约)
|
||||||
|
```
|
||||||
|
|
||||||
|
构建脚本会把 NuGet 缓存与临时目录指到仓库内的 `.cache/`(避免写满 C 盘),
|
||||||
|
并在打包后自检「清单 / 程序集名 / 版本号」三者一致。
|
||||||
|
|
||||||
|
## 开发提示
|
||||||
|
|
||||||
|
- 需要 **.NET 8 SDK**;SDK 包固定 `ClassIsland.PluginSdk 2.1.0.1`(2.1.1 起改为 net10.0,
|
||||||
|
本插件按 net8.0 交付,因此 2.0.x / 2.1.0.x 都能加载,见 `manifest.yml` 的 `apiVersion`)。
|
||||||
|
- 设置页是 Avalonia 的 `.axaml`(继承 `SettingsPageBase`);绑定走运行时解析
|
||||||
|
(`AvaloniaUseCompiledBindingsByDefault=false`),因为插件与主程序各自编译,编译期绑定看不到对方类型。
|
||||||
|
- **开发机没有安装 ClassIsland 本体**,所以插件只能做到编译 + 打包 + 静态校验;
|
||||||
|
运行期行为需要在装了 ClassIsland 的机器上按上面的「安装」步骤实测。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是班级小助手的标准格式)**
|
||||||
|
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
|
||||||
|
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
|
||||||
|
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
|
||||||
|
如果仍然报 404,请依次检查:
|
||||||
|
|
||||||
|
1. 服务器地址是否写成了别的端口 / 别的机器(提示里会带上实际请求的完整 URL,照着核对最快);
|
||||||
|
2. 该地址在浏览器里打开 `<服务器地址>/api/health` 是否返回 `{"success":true,...}`;
|
||||||
|
3. 反向代理(Nginx 等)是否把 `/api/integrations` 也转发到了后端。
|
||||||
|
|
||||||
|
**上报失败,提示「设备令牌无效」**
|
||||||
|
在 Web 端重置该设备的令牌,然后把新令牌填回插件设置(旧令牌会立即失效)。
|
||||||
|
|
||||||
|
**发布提醒后 ClassIsland 没反应,或者要等一会儿才弹**
|
||||||
|
插件不持有长连接,提醒是**轮询取回**的:默认最多 10 秒(设置页「提醒轮询间隔」可调到 5 秒)。超过这个时间还没弹,按下面逐条排查。
|
||||||
|
|
||||||
|
**ClassIsland 上完全不弹提醒**
|
||||||
|
|
||||||
|
1. 确认插件设置里打开了「接收提醒」;
|
||||||
|
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
|
||||||
|
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
|
||||||
|
|
||||||
|
**课表上报了但班级小助手上没变化**
|
||||||
|
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
|
||||||
|
|
||||||
|
**镜像开关打开了,但 ClassIsland 的课表没变**
|
||||||
|
镜像需要 Web 端该设备开启「镜像课表」且班级里已有课表;插件只在**上报时**顺路拉取镜像,
|
||||||
|
所以打开开关后点一下「立即上报」或等一个上报间隔即可。镜像不会覆盖老师原有的课表。
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
与班级小助手仓库一致。
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
# 班级小助手 ClassIsland 联动插件 —— 清单文件
|
||||||
|
#
|
||||||
|
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
|
||||||
|
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
|
||||||
|
|
||||||
|
id: classhelper.classisland.bridge
|
||||||
|
name: 班级小助手联动
|
||||||
|
description: |-
|
||||||
|
把 ClassIsland 的课表与上课状态同步到班级小助手,并在 ClassIsland 上显示老师下发的提醒。
|
||||||
|
功能:
|
||||||
|
1. 上报当前课表与节次时间(班级小助手 Web 端与桌面客户端据此显示课程);
|
||||||
|
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
|
||||||
|
3. 接收老师在班级小助手上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||||
|
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||||
|
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||||
|
url: https://github.com/LoveChengke/ClassHelper
|
||||||
|
version: 0.1.0.0
|
||||||
|
apiVersion: 2.0.0.0
|
||||||
|
author: 班级小助手
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
|
||||||
|
* node scripts/build.mjs --cipx # 追加打包 .cipx(ClassIsland 插件包)并归集到 releases/classisland-plugin/
|
||||||
|
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
|
||||||
|
*
|
||||||
|
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
|
||||||
|
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
|
||||||
|
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
|
||||||
|
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
|
||||||
|
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
|
||||||
|
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
|
||||||
|
*/
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const repoRoot = path.resolve(pluginDir, '..', '..');
|
||||||
|
const cacheDir = path.join(repoRoot, '.cache');
|
||||||
|
const releasesDir = path.join(repoRoot, 'releases', 'classisland-plugin');
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const withCipx = argv.includes('--cipx');
|
||||||
|
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
|
||||||
|
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
|
||||||
|
|
||||||
|
const log = (message) => console.log(`[classisland-plugin] ${message}`);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 1. 环境准备 */
|
||||||
|
|
||||||
|
const env = { ...process.env };
|
||||||
|
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
|
||||||
|
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
|
||||||
|
}
|
||||||
|
const setDefault = (key, value) => {
|
||||||
|
if (!env[key]) env[key] = value;
|
||||||
|
};
|
||||||
|
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
|
||||||
|
setDefault('TEMP', path.join(cacheDir, 'tmp'));
|
||||||
|
setDefault('TMP', env.TEMP);
|
||||||
|
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
|
||||||
|
env.DOTNET_NOLOGO = '1';
|
||||||
|
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
|
||||||
|
|
||||||
|
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
|
||||||
|
function resolveExecutable(envKey, names, fallbacks) {
|
||||||
|
const candidates = [];
|
||||||
|
if (env[envKey]) candidates.push(env[envKey]);
|
||||||
|
const finder = process.platform === 'win32' ? 'where' : 'which';
|
||||||
|
for (const name of names) {
|
||||||
|
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
|
||||||
|
if (found.status === 0) {
|
||||||
|
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.push(...fallbacks);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate) continue;
|
||||||
|
try {
|
||||||
|
if (fs.statSync(candidate).isFile()) return candidate;
|
||||||
|
} catch {
|
||||||
|
/* 继续试下一个 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotnet = resolveExecutable(
|
||||||
|
'DOTNET_EXE',
|
||||||
|
['dotnet'],
|
||||||
|
process.platform === 'win32'
|
||||||
|
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
|
||||||
|
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
|
||||||
|
);
|
||||||
|
if (!dotnet) {
|
||||||
|
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const powerShell = resolveExecutable(
|
||||||
|
'POWERSHELL_EXE',
|
||||||
|
['pwsh', 'powershell'],
|
||||||
|
process.platform === 'win32'
|
||||||
|
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 2. 构建 */
|
||||||
|
|
||||||
|
const buildArgs = ['build', '-c', 'Release'];
|
||||||
|
if (withCipx) {
|
||||||
|
buildArgs.push('-p:CreateCipx=true');
|
||||||
|
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
|
||||||
|
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
|
||||||
|
}
|
||||||
|
buildArgs.push(...passthrough);
|
||||||
|
|
||||||
|
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
|
||||||
|
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
|
||||||
|
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
|
||||||
|
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 3. 产物自检 */
|
||||||
|
|
||||||
|
const outputDir = path.join(pluginDir, 'bin', 'Release');
|
||||||
|
const manifestPath = path.join(outputDir, 'manifest.yml');
|
||||||
|
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
|
||||||
|
|
||||||
|
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
|
||||||
|
if (!fs.existsSync(required)) {
|
||||||
|
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
||||||
|
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
|
||||||
|
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
|
||||||
|
if (entrance !== path.basename(assemblyPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
|
||||||
|
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
|
||||||
|
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
|
||||||
|
if (!manifestVersion || manifestVersion !== codeVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 4. 打包归集 */
|
||||||
|
|
||||||
|
if (!withCipx) {
|
||||||
|
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cipxDir = path.join(pluginDir, 'cipx');
|
||||||
|
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
|
||||||
|
if (!fs.existsSync(cipxFile)) {
|
||||||
|
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库)
|
||||||
|
fs.rmSync(releasesDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(releasesDir, { recursive: true });
|
||||||
|
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile)));
|
||||||
|
|
||||||
|
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
|
||||||
|
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
|
||||||
|
fs.mkdirSync(portableDir, { recursive: true });
|
||||||
|
for (const name of fs.readdirSync(outputDir)) {
|
||||||
|
const from = path.join(outputDir, name);
|
||||||
|
if (!fs.statSync(from).isFile()) continue;
|
||||||
|
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
|
||||||
|
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fs.copyFileSync(from, path.join(portableDir, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of fs.readdirSync(cipxDir)) {
|
||||||
|
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`);
|
||||||
|
for (const file of fs.readdirSync(releasesDir)) {
|
||||||
|
log(` ${file}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* ClassIsland 联动插件静态校验:`pnpm verify:classisland-plugin`
|
||||||
|
*
|
||||||
|
* 为什么是"静态"校验:插件要跑起来必须有 ClassIsland 本体,而本机(以及 CI)都没有,
|
||||||
|
* 所以这里校验"装不上 / 对不上"的那几类问题 —— 它们不需要运行就能发现:
|
||||||
|
*
|
||||||
|
* 1. 清单三件套一致性:manifest.yml ↔ csproj ↔ 代码里的版本号;
|
||||||
|
* 2. 注册完整性:插件入口、提醒提供方、设置页是否都挂上了(挂漏了功能会"静默消失");
|
||||||
|
* 3. **接口契约**:C# DTO 的 JSON 字段名 ↔ 服务端路由 / 请求字段,
|
||||||
|
* 这类错误(比如 dayOfWeek 写成 dayofweek)在运行时只会表现为"上报成功但课表是空的",
|
||||||
|
* 最难排查,所以专门盯住。
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const repoRoot = path.resolve(pluginDir, '..', '..');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const check = (name, ok, detail = '') => results.push({ name, ok: Boolean(ok), detail });
|
||||||
|
const read = (relative) => fs.readFileSync(path.join(pluginDir, relative), 'utf8');
|
||||||
|
const readRepo = (relative) => fs.readFileSync(path.join(repoRoot, relative), 'utf8');
|
||||||
|
|
||||||
|
const manifest = read('manifest.yml');
|
||||||
|
const csproj = read('ClassHelper.ClassIslandPlugin.csproj');
|
||||||
|
const pluginSource = read('src/Plugin.cs');
|
||||||
|
const providerSource = read('src/Services/ClassHelperNotificationProvider.cs');
|
||||||
|
const bridgeSource = read('src/Services/BridgeService.cs');
|
||||||
|
const clientSource = read('src/Interop/ClassHelperClient.cs');
|
||||||
|
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
|
||||||
|
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
|
||||||
|
const mapperSource = read('src/Services/ScheduleMapper.cs');
|
||||||
|
const settingsSource = read('src/Models/PluginSettings.cs');
|
||||||
|
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
|
||||||
|
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
|
||||||
|
const serverService = readRepo('packages/server/src/modules/integrations/integrations.service.ts');
|
||||||
|
const sharedTypes = readRepo('packages/shared/src/types.ts');
|
||||||
|
const sharedConstants = readRepo('packages/shared/src/constants.ts');
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 1. 清单一致性 */
|
||||||
|
|
||||||
|
/** 取 YAML 里某个顶层字段的值(prettier 会把字符串统一成单引号,两种引号都要能剥掉) */
|
||||||
|
function field(name) {
|
||||||
|
const raw = new RegExp('^' + name + ':\\s*(.+?)\\s*$', 'm').exec(manifest)?.[1];
|
||||||
|
return raw?.replace(/^['"]|['"]$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
check('manifest.yml 有插件 id', Boolean(field('id')));
|
||||||
|
check('manifest.yml 声明了入口程序集', Boolean(field('entranceAssembly')));
|
||||||
|
check('manifest.yml 声明了 apiVersion', /\d+\.\d+\.\d+\.\d+/.test(field('apiVersion') ?? ''));
|
||||||
|
|
||||||
|
const assemblyName = /<AssemblyName>([^<]+)<\/AssemblyName>/.exec(csproj)?.[1];
|
||||||
|
check(
|
||||||
|
'entranceAssembly 与 csproj 的 AssemblyName 一致',
|
||||||
|
field('entranceAssembly') === assemblyName + '.dll',
|
||||||
|
'entranceAssembly=' + field('entranceAssembly') + ' AssemblyName=' + assemblyName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifestVersion = field('version');
|
||||||
|
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
|
||||||
|
check(
|
||||||
|
'插件版本号在清单与代码里一致',
|
||||||
|
Boolean(manifestVersion) && manifestVersion === codeVersion,
|
||||||
|
'manifest=' + manifestVersion + ' code=' + codeVersion,
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'图标与自述文件按清单约定命名(icon.png / README.md)',
|
||||||
|
fs.existsSync(path.join(pluginDir, 'icon.png')) && fs.existsSync(path.join(pluginDir, 'README.md')),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 2. 注册完整性 */
|
||||||
|
|
||||||
|
check(
|
||||||
|
'插件入口带 [PluginEntrance] 且继承 PluginBase',
|
||||||
|
/\[PluginEntrance\]/.test(pluginSource) && /:\s*PluginBase/.test(pluginSource),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'初始化时注册了提醒提供方',
|
||||||
|
/AddNotificationProvider<\s*ClassHelperNotificationProvider\s*>/.test(pluginSource),
|
||||||
|
);
|
||||||
|
check('初始化时注册了设置页', /AddSettingsPage<\s*BridgeSettingsPage\s*>/.test(pluginSource));
|
||||||
|
check('初始化时把联动服务注册为单例', /AddSingleton<\s*BridgeService\s*>/.test(pluginSource));
|
||||||
|
check('应用启动后才接课程服务(不能更早)', /AppStarted/.test(pluginSource));
|
||||||
|
check(
|
||||||
|
'设置以 JSON 持久化到插件配置目录',
|
||||||
|
/PluginConfigFolder/.test(pluginSource) && /Settings\.json/.test(pluginSource),
|
||||||
|
);
|
||||||
|
|
||||||
|
const providerGuid = /ProviderGuidValue = "([0-9A-Fa-f-]{36})"/.exec(providerSource)?.[1];
|
||||||
|
check('提醒提供方声明了合法 GUID', Boolean(providerGuid));
|
||||||
|
check(
|
||||||
|
'提醒提供方属性引用同一个 GUID 常量',
|
||||||
|
/NotificationProviderInfo\(\s*ProviderGuidValue/.test(providerSource),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'提醒提供方会在播完后回执给服务端',
|
||||||
|
/Completed/.test(providerSource) && /AckAsync/.test(providerSource),
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
'设置页带 [SettingsPageInfo] 且声明了页面 id',
|
||||||
|
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"班级小助手联动"/.test(settingsPageSource),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'设置页 XAML 的 x:Class 与代码后置类一致',
|
||||||
|
settingsPageXaml.includes('x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"') &&
|
||||||
|
/namespace ClassHelper\.ClassIslandPlugin\.Views;/.test(settingsPageSource),
|
||||||
|
);
|
||||||
|
check('设置页 XAML 继承 ClassIsland 的设置页基类', /SettingsPageBase/.test(settingsPageXaml));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 3. 接口契约 */
|
||||||
|
|
||||||
|
// 3.1 路由:插件调用的路径必须都在服务端注册过
|
||||||
|
const clientPaths = [...clientSource.matchAll(/"(\/classisland\/[a-z-]+)"/g)].map((item) => item[1]);
|
||||||
|
const registeredPaths = new Set(
|
||||||
|
[...serverModule.matchAll(/router\.(?:get|post|patch|delete)\(\s*'(\/classisland\/[a-z-]+)'/g)].map(
|
||||||
|
(item) => item[1],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const route of [
|
||||||
|
'/classisland/report',
|
||||||
|
'/classisland/pending',
|
||||||
|
'/classisland/ack',
|
||||||
|
'/classisland/class-plan',
|
||||||
|
]) {
|
||||||
|
check('服务端注册了 ' + route, registeredPaths.has(route));
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
'插件调用的路由都存在于服务端',
|
||||||
|
clientPaths.length > 0 && clientPaths.every((item) => registeredPaths.has(item)),
|
||||||
|
'插件调用=[' + clientPaths.join(',') + ']',
|
||||||
|
);
|
||||||
|
check('插件把接口前缀拼成 /api/integrations', clientSource.includes('/api/integrations'));
|
||||||
|
check('插件用设备令牌头鉴权', clientSource.includes('X-ClassIsland-Token'));
|
||||||
|
|
||||||
|
// 3.2 上报请求字段:服务端 zod schema 里出现的字段名,插件必须真的会发
|
||||||
|
const reportBlock = /export const classIslandReportSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||||||
|
const requestFields = [
|
||||||
|
'pluginVersion',
|
||||||
|
'classIslandVersion',
|
||||||
|
'state',
|
||||||
|
'schedule',
|
||||||
|
'timeLayout',
|
||||||
|
'inClass',
|
||||||
|
'subject',
|
||||||
|
'nextSubject',
|
||||||
|
'timeState',
|
||||||
|
'periodStart',
|
||||||
|
'periodEnd',
|
||||||
|
'classPlanLoaded',
|
||||||
|
'clientTime',
|
||||||
|
'mode',
|
||||||
|
'entries',
|
||||||
|
'items',
|
||||||
|
];
|
||||||
|
const missingRequestFields = requestFields.filter(
|
||||||
|
(name) => reportBlock.includes(name) && !new RegExp('\\b' + name + '\\b').test(clientSource),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'上报请求字段都出现在插件 DTO 里',
|
||||||
|
missingRequestFields.length === 0,
|
||||||
|
missingRequestFields.length > 0 ? '缺少=' + missingRequestFields.join(',') : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3.3 课表条目字段:这些是"上报成功但课表为空"的典型来源
|
||||||
|
const entrySchema = /export const reportScheduleEntrySchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||||||
|
const entryFields = [
|
||||||
|
'dayOfWeek',
|
||||||
|
'startTime',
|
||||||
|
'endTime',
|
||||||
|
'subject',
|
||||||
|
'teacherName',
|
||||||
|
'weekParity',
|
||||||
|
'weekCountDiv',
|
||||||
|
'weekCountDivTotal',
|
||||||
|
'planName',
|
||||||
|
];
|
||||||
|
const csharpJsonNames = new Set(
|
||||||
|
[...clientSource.matchAll(/\[JsonPropertyName\("([^"]+)"\)\]/g)].map((item) => item[1]),
|
||||||
|
);
|
||||||
|
const missingEntryFields = entryFields.filter(
|
||||||
|
(name) => entrySchema.includes(name) && !csharpJsonNames.has(name),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'课表条目字段服务端 zod 与插件 DTO 对齐',
|
||||||
|
missingEntryFields.length === 0,
|
||||||
|
missingEntryFields.length > 0 ? '插件缺少=' + missingEntryFields.join(',') : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3.4 插件 DTO 里的 JSON 字段名必须能在共享契约里找到(防错拼)
|
||||||
|
const knownLocalOnly = new Set(['data', 'success', 'message', 'code']);
|
||||||
|
const unknownJsonNames = [...csharpJsonNames].filter((name) => {
|
||||||
|
if (knownLocalOnly.has(name)) return false;
|
||||||
|
return !new RegExp('\\b' + name + '\\b').test(sharedTypes);
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'插件 DTO 的 JSON 字段名都能在 @classhelper/shared 里找到',
|
||||||
|
unknownJsonNames.length === 0,
|
||||||
|
unknownJsonNames.length > 0 ? '疑似拼写错误=' + unknownJsonNames.join(',') : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3.5 课表镜像:服务端给了 Classes[i] 对齐所需的时间,插件必须读
|
||||||
|
for (const name of ['startTime', 'endTime', 'timeLayouts', 'profileName', 'weekDay', 'weekCountDivTotal']) {
|
||||||
|
check('镜像契约字段 ' + name + ' 在插件侧存在', csharpJsonNames.has(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.6 提醒下发字段(Web 端 → 服务端 → 插件)
|
||||||
|
const notifySchema = /export const sendNotificationSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||||||
|
for (const name of [
|
||||||
|
'classId',
|
||||||
|
'title',
|
||||||
|
'content',
|
||||||
|
'durationSeconds',
|
||||||
|
'speech',
|
||||||
|
'speechContent',
|
||||||
|
'saveToNotifications',
|
||||||
|
'priority',
|
||||||
|
]) {
|
||||||
|
check('下发提醒字段 ' + name + ' 在服务端 schema 里', notifySchema.includes(name));
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
'提醒时长有上界(避免老师发出永不消失的提醒)',
|
||||||
|
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(serverSchemas) &&
|
||||||
|
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(sharedConstants),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3.7 实时事件:Web 端订阅的事件名必须在共享契约里声明过
|
||||||
|
check(
|
||||||
|
'共享契约声明了 classisland 实时事件',
|
||||||
|
sharedTypes.includes("'classisland:state'") && sharedTypes.includes("'classisland:notification'"),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'服务端广播事件名取自共享常量(不是手写字符串)',
|
||||||
|
/SOCKET_EVENTS\.classislandState/.test(serverService),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3.8 网络层:教室机器(以及开发机)常配了系统代理,而 Windows 的代理设置默认连 127.0.0.1
|
||||||
|
// 也会走代理,表现为"填了令牌立刻 HTTP 404"(其实是代理自己的错误页,跟本服务无关)。
|
||||||
|
// 插件必须对本地/内网地址绕开系统代理,否则内网部署必然失败。
|
||||||
|
check(
|
||||||
|
'HttpClient 显式配置代理(不裸用默认行为)',
|
||||||
|
/SocketsHttpHandler\s*\{[^}]*Proxy\s*=/.test(clientSource),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'本地/内网地址绕开系统代理',
|
||||||
|
/class LocalAwareProxy/.test(clientSource) && /IsBypassed\(Uri host\)/.test(clientSource),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'代理判定覆盖回环与内网网段',
|
||||||
|
/IsLoopback/.test(clientSource) &&
|
||||||
|
/10\.0\.0\.0\/8/.test(clientSource) &&
|
||||||
|
/192\.168\.0\.0\/16/.test(clientSource),
|
||||||
|
);
|
||||||
|
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
||||||
|
|
||||||
|
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
|
||||||
|
check(
|
||||||
|
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
|
||||||
|
providerSource.includes('Dispatcher.UIThread.Post'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'同一条提醒在播放期间按 id 去重(否则轮询会让它反复弹)',
|
||||||
|
providerSource.includes('_playing') && providerSource.includes('HashSet<string>'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'不再自造图标串(用模板默认图标,紧急用滚动文本表达)',
|
||||||
|
// 不再给 CreateTwoIconsMask 传 leftIcon(自造图标串会踩格式/字形坑)
|
||||||
|
!providerSource.includes('leftIcon') && providerSource.includes('CreateRollingTextContent'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
|
||||||
|
mapperSource.includes('Subject.Fallback'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'上课时段暂存非主动通知(与客户端灵动岛同一套规则)',
|
||||||
|
providerSource.includes('_deferred') &&
|
||||||
|
providerSource.includes('IsImmediate') &&
|
||||||
|
providerSource.includes('FlushDeferred') &&
|
||||||
|
providerSource.includes('OnBreakingTime'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'主动通知(紧急 / 叫人)上课时段也立即弹',
|
||||||
|
providerSource.includes("\"call\"") && providerSource.includes('notification.Urgent'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
|
||||||
|
bridgeSource.includes('上报课表到班级小助手') && bridgeSource.includes('开关已关闭'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||||||
|
settingsSource.includes('NotificationPollSeconds') &&
|
||||||
|
bridgeSource.includes('_notifyTimer') &&
|
||||||
|
bridgeSource.includes('NotificationPollSeconds'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 结果 */
|
||||||
|
|
||||||
|
const failed = results.filter((item) => !item.ok);
|
||||||
|
for (const item of results) {
|
||||||
|
console.log((item.ok ? '√ ' : '× ') + item.name + (item.detail ? ' (' + item.detail + ')' : ''));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
console.log(
|
||||||
|
'ClassIsland 联动插件静态校验:' + (results.length - failed.length) + '/' + results.length + ' 项通过',
|
||||||
|
);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.log('未通过:\n - ' + failed.map((item) => item.name).join('\n - '));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Models;
|
||||||
|
|
||||||
|
namespace ClassHelper.ClassIslandPlugin.Interop;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 服务端契约(与 packages/shared 保持一致) */
|
||||||
|
|
||||||
|
/// <summary>插件上报的课表条目。</summary>
|
||||||
|
public class ScheduleEntryDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("dayOfWeek")] public int DayOfWeek { get; set; }
|
||||||
|
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
|
||||||
|
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
||||||
|
/// <summary>ALL / ODD / EVEN;留空则由服务端按 weekCountDiv 推导。</summary>
|
||||||
|
[JsonPropertyName("weekParity")] public string? WeekParity { get; set; }
|
||||||
|
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
|
||||||
|
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
|
||||||
|
[JsonPropertyName("planName")] public string? PlanName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>插件上报的节次时间。</summary>
|
||||||
|
public class TimeLayoutItemDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("index")] public int Index { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||||
|
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
||||||
|
/// <summary>class / break / divider / action</summary>
|
||||||
|
[JsonPropertyName("type")] public string Type { get; set; } = "class";
|
||||||
|
[JsonPropertyName("skipped")] public bool Skipped { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>本机运行状态快照。</summary>
|
||||||
|
public class StateDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("inClass")] public bool InClass { get; set; }
|
||||||
|
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||||||
|
[JsonPropertyName("nextSubject")] public string? NextSubject { get; set; }
|
||||||
|
[JsonPropertyName("timeState")] public string? TimeState { get; set; }
|
||||||
|
[JsonPropertyName("periodStart")] public string? PeriodStart { get; set; }
|
||||||
|
[JsonPropertyName("periodEnd")] public string? PeriodEnd { get; set; }
|
||||||
|
[JsonPropertyName("week")] public int? Week { get; set; }
|
||||||
|
[JsonPropertyName("classPlanLoaded")] public bool ClassPlanLoaded { get; set; }
|
||||||
|
[JsonPropertyName("clientTime")] public string ClientTime { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SchedulePayloadDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("mode")] public string Mode { get; set; } = "merge";
|
||||||
|
[JsonPropertyName("entries")] public List<ScheduleEntryDto> Entries { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TimeLayoutPayloadDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = "ClassIsland 时间表";
|
||||||
|
[JsonPropertyName("mode")] public string Mode { get; set; } = "replace";
|
||||||
|
[JsonPropertyName("items")] public List<TimeLayoutItemDto> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReportRequestDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
|
||||||
|
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
|
||||||
|
[JsonPropertyName("state")] public StateDto? State { get; set; }
|
||||||
|
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
|
||||||
|
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReportSettingsDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
|
||||||
|
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReportResultDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("scheduleApplied")] public bool ScheduleApplied { get; set; }
|
||||||
|
[JsonPropertyName("scheduleCreated")] public int ScheduleCreated { get; set; }
|
||||||
|
[JsonPropertyName("scheduleUpdated")] public int ScheduleUpdated { get; set; }
|
||||||
|
[JsonPropertyName("courses")] public List<string> Courses { get; set; } = new();
|
||||||
|
[JsonPropertyName("timeLayoutApplied")] public bool TimeLayoutApplied { get; set; }
|
||||||
|
[JsonPropertyName("week")] public int Week { get; set; }
|
||||||
|
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("settings")] public ReportSettingsDto? Settings { get; set; }
|
||||||
|
[JsonPropertyName("pendingNotification")] public PushNotificationDto? PendingNotification { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>服务端下发的提醒。</summary>
|
||||||
|
public class PushNotificationDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
|
[JsonPropertyName("title")] public string Title { get; set; } = "";
|
||||||
|
[JsonPropertyName("content")] public string Content { get; set; } = "";
|
||||||
|
[JsonPropertyName("durationSeconds")] public int DurationSeconds { get; set; } = 8;
|
||||||
|
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
|
||||||
|
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
|
||||||
|
[JsonPropertyName("urgent")] public bool Urgent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>提醒类型:notification(默认)/ call(叫人,算"主动通知",上课时段也立刻弹)</summary>
|
||||||
|
[JsonPropertyName("kind")] public string? Kind { get; set; }
|
||||||
|
[JsonPropertyName("classId")] public string ClassId { get; set; } = "";
|
||||||
|
[JsonPropertyName("className")] public string? ClassName { get; set; }
|
||||||
|
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PendingResultDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("notifications")] public List<PushNotificationDto> Notifications { get; set; } = new();
|
||||||
|
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("week")] public int Week { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 课表镜像(服务端 → ClassIsland) */
|
||||||
|
|
||||||
|
public class ClassPlanEntryMirrorDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("weekDay")] public int WeekDay { get; set; }
|
||||||
|
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
|
||||||
|
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
|
||||||
|
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
|
||||||
|
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
||||||
|
/// <summary>起止时间(HH:mm)。Classes[i] 必须与第 i 个上课时间点对齐,靠它重建时间表。</summary>
|
||||||
|
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("timeLayoutId")] public string TimeLayoutId { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TimeLayoutMirrorDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||||
|
[JsonPropertyName("layouts")] public List<TimeLayoutItemMirrorDto> Layouts { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TimeLayoutItemMirrorDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
||||||
|
[JsonPropertyName("timeType")] public int TimeType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClassPlanMirrorDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("entries")] public List<ClassPlanEntryMirrorDto> Entries { get; set; } = new();
|
||||||
|
[JsonPropertyName("timeLayouts")] public List<TimeLayoutMirrorDto> TimeLayouts { get; set; } = new();
|
||||||
|
[JsonPropertyName("profileName")] public string ProfileName { get; set; } = "";
|
||||||
|
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PullResultDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
|
||||||
|
[JsonPropertyName("week")] public int Week { get; set; }
|
||||||
|
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 统一响应体 */
|
||||||
|
|
||||||
|
internal class ApiEnvelope<T>
|
||||||
|
{
|
||||||
|
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||||
|
[JsonPropertyName("data")] public T? Data { get; set; }
|
||||||
|
[JsonPropertyName("message")] public string Message { get; set; } = "";
|
||||||
|
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
||||||
|
public class ApiCallResult
|
||||||
|
{
|
||||||
|
public bool Ok { get; init; }
|
||||||
|
public string Message { get; init; } = "";
|
||||||
|
public bool NetworkError { get; init; }
|
||||||
|
|
||||||
|
public static ApiCallResult Success(string message) => new() { Ok = true, Message = message };
|
||||||
|
public static ApiCallResult Failure(string message) => new() { Ok = false, Message = message };
|
||||||
|
public static ApiCallResult Offline(string message) => new() { Ok = false, Message = message, NetworkError = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 班级小助手后端客户端。
|
||||||
|
///
|
||||||
|
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
|
||||||
|
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
|
||||||
|
/// (见 https://docs.classisland.tech/dev/plugins/basics.html 的程序集隔离说明)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClassHelperClient : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 单次请求超时。教室机器可能是内网慢链路,但也不该卡住上报线程太久。
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 与班级小助手通信的 HttpClient。
|
||||||
|
///
|
||||||
|
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
|
||||||
|
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
|
||||||
|
/// 那个 404 其实是**代理自己的错误页**,跟本服务毫无关系。
|
||||||
|
/// 因此这里用 <see cref="LocalAwareProxy"/>:本地/内网地址直连,公网地址照旧走系统代理。
|
||||||
|
/// </summary>
|
||||||
|
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
|
||||||
|
{
|
||||||
|
Timeout = Timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
public string LastError { get; private set; } = "";
|
||||||
|
public DateTimeOffset? LastSuccessAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 包装系统代理:**本地/内网地址标记为"绕过"**,其余交给系统代理决定。
|
||||||
|
///
|
||||||
|
/// 判定为本地/内网的情形:localhost、回环地址、IPv4/IPv6 私有段与链路本地段,
|
||||||
|
/// 以及不含点的主机名(`server` / `classhelper` 这类校内机器名,DNS 也可能解析不出来)。
|
||||||
|
/// 判定为公网时保持原行为,避免"云服务器 + 公司代理"这种场景被我们改坏。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class LocalAwareProxy : IWebProxy
|
||||||
|
{
|
||||||
|
private readonly IWebProxy _inner = HttpClient.DefaultProxy;
|
||||||
|
|
||||||
|
// 只读透传即可:HttpClient.DefaultProxy 是全局单例,改它的凭据会波及宿主进程里的其它请求
|
||||||
|
public ICredentials? Credentials { get; set; }
|
||||||
|
|
||||||
|
public Uri? GetProxy(Uri destination) => _inner.GetProxy(destination);
|
||||||
|
|
||||||
|
public bool IsBypassed(Uri host) => IsLocalHost(host) || _inner.IsBypassed(host);
|
||||||
|
|
||||||
|
/// <summary>该地址是否属于"不该走代理"的本地/内网范围。</summary>
|
||||||
|
internal static bool IsLocalHost(Uri uri)
|
||||||
|
{
|
||||||
|
var host = uri.Host;
|
||||||
|
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
// 不含点的主机名(校内机器名)走直连:解析成内网 IP 的可能性远大于公网
|
||||||
|
if (!host.Contains('.')) return true;
|
||||||
|
|
||||||
|
if (!IPAddress.TryParse(host, out var address)) return false;
|
||||||
|
if (IPAddress.IsLoopback(address)) return true;
|
||||||
|
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
var bytes = address.GetAddressBytes();
|
||||||
|
return bytes[0] == 10 // 10.0.0.0/8
|
||||||
|
|| (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) // 172.16.0.0/12
|
||||||
|
|| (bytes[0] == 192 && bytes[1] == 168) // 192.168.0.0/16
|
||||||
|
|| (bytes[0] == 169 && bytes[1] == 254); // 169.254.0.0/16(链路本地)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6:fc00::/7(唯一本地地址)与 fe80::/10(链路本地)
|
||||||
|
var first = address.GetAddressBytes()[0];
|
||||||
|
return (first & 0xFE) == 0xFC || (first & 0xC0) == 0x80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>拼接 API 地址,容忍用户填了带/不带末尾斜杠、甚至带 /api 后缀的地址。</summary>
|
||||||
|
private static string BuildUrl(string serverUrl, string path)
|
||||||
|
{
|
||||||
|
var baseUrl = (serverUrl ?? "").Trim().TrimEnd('/');
|
||||||
|
if (baseUrl.EndsWith("/api", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
baseUrl = baseUrl[..^4];
|
||||||
|
}
|
||||||
|
return $"{baseUrl}/api/integrations{path}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage BuildRequest(HttpMethod method, string serverUrl, string path, string token)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(method, BuildUrl(serverUrl, path));
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("X-ClassIsland-Token", token);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApiEnvelope<T>?> SendAsync<T>(
|
||||||
|
HttpMethod method,
|
||||||
|
string serverUrl,
|
||||||
|
string path,
|
||||||
|
string token,
|
||||||
|
object? body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var request = BuildRequest(method, serverUrl, path, token);
|
||||||
|
if (body is not null)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(body, JsonOptions);
|
||||||
|
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
ApiEnvelope<T>? envelope = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
envelope = JsonSerializer.Deserialize<ApiEnvelope<T>>(text, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// 非 JSON 响应(例如被网关拦截)——交给下面的兜底分支
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var message = envelope?.Message;
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
// 响应不是本服务的统一响应体:最常见的原因就是被系统代理 / 网关拦下来
|
||||||
|
// (典型表现:HTTP 404 且 body 是一张 HTML 错误页)。把 URL 一起报出来,
|
||||||
|
// 老师截图给我们就能一眼定位。
|
||||||
|
message = envelope is null
|
||||||
|
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||||
|
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" +
|
||||||
|
"或该系统代理是否拦截了内网请求"
|
||||||
|
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
|
||||||
|
}
|
||||||
|
LastError = message!;
|
||||||
|
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
|
||||||
|
}
|
||||||
|
|
||||||
|
LastError = "";
|
||||||
|
LastSuccessAt = DateTimeOffset.Now;
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
LastError = "请求超时(请检查服务器地址与网络)";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException exception)
|
||||||
|
{
|
||||||
|
LastError = $"无法连接班级小助手服务:{exception.Message}";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
LastError = exception.Message;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>上报状态 +(可选)课表。</summary>
|
||||||
|
public async Task<(ApiCallResult Result, ReportResultDto? Data)> ReportAsync(
|
||||||
|
PluginSettings settings,
|
||||||
|
ReportRequestDto payload,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var envelope = await SendAsync<ReportResultDto>(
|
||||||
|
HttpMethod.Post, settings.ServerUrl, "/classisland/report", settings.DeviceToken, payload, cancellationToken);
|
||||||
|
if (envelope is null)
|
||||||
|
{
|
||||||
|
return (ApiCallResult.Offline(LastError), null);
|
||||||
|
}
|
||||||
|
if (!envelope.Success || envelope.Data is null)
|
||||||
|
{
|
||||||
|
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
|
||||||
|
}
|
||||||
|
return (ApiCallResult.Success(envelope.Message), envelope.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>拉取尚未确认的下发提醒(插件启动/重连时补齐)。</summary>
|
||||||
|
public async Task<(ApiCallResult Result, List<PushNotificationDto> Items)> PendingAsync(
|
||||||
|
PluginSettings settings,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var envelope = await SendAsync<PendingResultDto>(
|
||||||
|
HttpMethod.Get, settings.ServerUrl, "/classisland/pending", settings.DeviceToken, null, cancellationToken);
|
||||||
|
if (envelope is null) return (ApiCallResult.Offline(LastError), new List<PushNotificationDto>());
|
||||||
|
if (!envelope.Success || envelope.Data is null)
|
||||||
|
{
|
||||||
|
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError),
|
||||||
|
new List<PushNotificationDto>());
|
||||||
|
}
|
||||||
|
return (ApiCallResult.Success(envelope.Message), envelope.Data.Notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>确认提醒已弹出(之后服务端不再补发)。</summary>
|
||||||
|
public async Task<ApiCallResult> AckAsync(PluginSettings settings, string id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var envelope = await SendAsync<JsonElement>(
|
||||||
|
HttpMethod.Post, settings.ServerUrl, "/classisland/ack", settings.DeviceToken, new { id }, cancellationToken);
|
||||||
|
if (envelope is null) return ApiCallResult.Offline(LastError);
|
||||||
|
return envelope.Success
|
||||||
|
? ApiCallResult.Success("已确认")
|
||||||
|
: ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>拉取班级课表(用于镜像回 ClassIsland)。未开启镜像时服务端返回 data=null。</summary>
|
||||||
|
public async Task<(ApiCallResult Result, ClassPlanMirrorDto? Plan)> PullClassPlanAsync(
|
||||||
|
PluginSettings settings,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var envelope = await SendAsync<PullResultDto>(
|
||||||
|
HttpMethod.Get, settings.ServerUrl, "/classisland/class-plan", settings.DeviceToken, null, cancellationToken);
|
||||||
|
if (envelope is null) return (ApiCallResult.Offline(LastError), null);
|
||||||
|
if (!envelope.Success)
|
||||||
|
{
|
||||||
|
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
|
||||||
|
}
|
||||||
|
if (envelope.Data?.ClassPlan is null)
|
||||||
|
{
|
||||||
|
return (ApiCallResult.Failure(envelope.Message.Length > 0
|
||||||
|
? envelope.Message
|
||||||
|
: "该设备未开启「把班级课表镜像到 ClassIsland」"), null);
|
||||||
|
}
|
||||||
|
return (ApiCallResult.Success(envelope.Message), envelope.Data.ClassPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _http.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace ClassHelper.ClassIslandPlugin.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件设置。
|
||||||
|
///
|
||||||
|
/// 存储由 ClassIsland 负责:插件入口把它保存在 <c>PluginConfigFolder/Settings.json</c>
|
||||||
|
/// (**不要**放在插件安装目录,那里不会随档案备份、升级时还会被清掉)。
|
||||||
|
/// 因此这里必须继承 <see cref="ObservableObject"/> 并让每个属性都走 <c>SetProperty</c>:
|
||||||
|
/// ClassIsland 通过监听 <c>PropertyChanged</c> 来即时保存。
|
||||||
|
/// </summary>
|
||||||
|
public class PluginSettings : ObservableObject
|
||||||
|
{
|
||||||
|
private string _serverUrl = "http://127.0.0.1:4000";
|
||||||
|
private string _deviceToken = "";
|
||||||
|
private string _deviceKey = "";
|
||||||
|
private bool _autoReport = true;
|
||||||
|
private int _reportIntervalSeconds = 60;
|
||||||
|
private bool _uploadSchedule = true;
|
||||||
|
private bool _mirrorSchedule = false;
|
||||||
|
private bool _receiveNotifications = true;
|
||||||
|
private int _notificationDurationSeconds = 8;
|
||||||
|
private int _notificationPollSeconds = 10;
|
||||||
|
private bool _speechEnabled = false;
|
||||||
|
private bool _showDebugInfo = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||||
|
/// </summary>
|
||||||
|
public string ServerUrl
|
||||||
|
{
|
||||||
|
get => _serverUrl;
|
||||||
|
set => SetProperty(ref _serverUrl, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。
|
||||||
|
/// </summary>
|
||||||
|
public string DeviceToken
|
||||||
|
{
|
||||||
|
get => _deviceToken;
|
||||||
|
set => SetProperty(ref _deviceToken, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本机标识(首次生成后固定):服务端据此把同一台机器识别为同一设备,重装插件不会产生重复设备。
|
||||||
|
/// </summary>
|
||||||
|
public string DeviceKey
|
||||||
|
{
|
||||||
|
get => _deviceKey;
|
||||||
|
set => SetProperty(ref _deviceKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否自动上报:启动后、课表变化时、以及每隔 <see cref="ReportIntervalSeconds"/> 秒各上报一次。
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoReport
|
||||||
|
{
|
||||||
|
get => _autoReport;
|
||||||
|
set => SetProperty(ref _autoReport, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定时上报间隔(秒),最小 15 秒。
|
||||||
|
/// </summary>
|
||||||
|
public int ReportIntervalSeconds
|
||||||
|
{
|
||||||
|
get => _reportIntervalSeconds;
|
||||||
|
set => SetProperty(ref _reportIntervalSeconds, Math.Clamp(value, 15, 3600));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否把 ClassIsland 的课表同步到班级小助手。
|
||||||
|
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。
|
||||||
|
/// </summary>
|
||||||
|
public bool UploadSchedule
|
||||||
|
{
|
||||||
|
get => _uploadSchedule;
|
||||||
|
set => SetProperty(ref _uploadSchedule, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否把班级小助手上排好的课表镜像回 ClassIsland
|
||||||
|
/// (新建一份名为「班级小助手-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||||
|
/// </summary>
|
||||||
|
public bool MirrorSchedule
|
||||||
|
{
|
||||||
|
get => _mirrorSchedule;
|
||||||
|
set => SetProperty(ref _mirrorSchedule, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否接收老师在班级小助手上发布的提醒,并在 ClassIsland 上弹出。
|
||||||
|
/// </summary>
|
||||||
|
public bool ReceiveNotifications
|
||||||
|
{
|
||||||
|
get => _receiveNotifications;
|
||||||
|
set => SetProperty(ref _receiveNotifications, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提醒轮询间隔(秒),最小 5 秒。
|
||||||
|
///
|
||||||
|
/// 插件与后端之间没有长连接(刻意不引入 Socket 依赖),提醒是"轮询取回"的,
|
||||||
|
/// 因此这个值就是**老师按下发送到教室弹出之间的最大延迟**。
|
||||||
|
/// 单独一个轻量轮询(只查待提醒,不上传课表)比把上报间隔改小划算得多。
|
||||||
|
/// </summary>
|
||||||
|
public int NotificationPollSeconds
|
||||||
|
{
|
||||||
|
get => _notificationPollSeconds;
|
||||||
|
set => SetProperty(ref _notificationPollSeconds, Math.Clamp(value, 5, 600));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 默认提醒显示时长(秒);老师在下发时可以单独指定更长的时长。
|
||||||
|
/// </summary>
|
||||||
|
public int NotificationDurationSeconds
|
||||||
|
{
|
||||||
|
get => _notificationDurationSeconds;
|
||||||
|
set => SetProperty(ref _notificationDurationSeconds, Math.Clamp(value, 1, 120));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提醒弹出时是否语音朗读(沿用 ClassIsland 的语音设置与语音提供方)。
|
||||||
|
/// </summary>
|
||||||
|
public bool SpeechEnabled
|
||||||
|
{
|
||||||
|
get => _speechEnabled;
|
||||||
|
set => SetProperty(ref _speechEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否在设置页显示调试信息(最后一次上报结果、课表条目数等),排查问题时打开。
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowDebugInfo
|
||||||
|
{
|
||||||
|
get => _showDebugInfo;
|
||||||
|
set => SetProperty(ref _showDebugInfo, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 兼容旧配置:反序列化后把 DeviceKey 补齐(首次运行时由服务生成)。
|
||||||
|
/// </summary>
|
||||||
|
public void Normalize()
|
||||||
|
{
|
||||||
|
ServerUrl = (ServerUrl ?? "").Trim().TrimEnd('/');
|
||||||
|
DeviceToken = (DeviceToken ?? "").Trim();
|
||||||
|
DeviceKey = (DeviceKey ?? "").Trim();
|
||||||
|
ReportIntervalSeconds = Math.Clamp(ReportIntervalSeconds, 15, 3600);
|
||||||
|
NotificationDurationSeconds = Math.Clamp(NotificationDurationSeconds, 1, 120);
|
||||||
|
NotificationPollSeconds = Math.Clamp(NotificationPollSeconds, 5, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
|
||||||
|
public bool IsConfigured =>
|
||||||
|
Uri.TryCreate(ServerUrl, UriKind.Absolute, out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Models;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Services;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Views;
|
||||||
|
using ClassIsland.Core;
|
||||||
|
using ClassIsland.Core.Abstractions;
|
||||||
|
using ClassIsland.Core.Abstractions.Services;
|
||||||
|
using ClassIsland.Core.Attributes;
|
||||||
|
using ClassIsland.Core.Extensions.Registry;
|
||||||
|
using ClassIsland.Shared;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClassHelper.ClassIslandPlugin;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件入口。
|
||||||
|
///
|
||||||
|
/// 职责只有三件:读配置 → 注册扩展(提醒提供方 / 设置页 / 联动服务)→ 在应用启动后接上课程服务。
|
||||||
|
/// 真正的联动逻辑都在 <see cref="BridgeService"/> 里,入口保持"看一眼就知道装了哪些东西"。
|
||||||
|
/// </summary>
|
||||||
|
[PluginEntrance]
|
||||||
|
public class Plugin : PluginBase
|
||||||
|
{
|
||||||
|
/// <summary>插件设置文件名。存在 <see cref="PluginBase.PluginConfigFolder"/> 下:随档案备份走,升级插件不丢。</summary>
|
||||||
|
private const string SettingsFileName = "Settings.json";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>插件设置。注册成单例后,设置页与 <see cref="BridgeService"/> 拿到的是同一个对象。</summary>
|
||||||
|
public PluginSettings Settings { get; private set; } = new();
|
||||||
|
|
||||||
|
/// <summary>联动只接一次(AppStarted 与"运行中启用"两条路都会走到这里)</summary>
|
||||||
|
private bool _bridgeStarted;
|
||||||
|
|
||||||
|
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||||
|
{
|
||||||
|
Settings = LoadSettings();
|
||||||
|
|
||||||
|
// 设置页与联动服务共用一个实例:设置页改了值,服务立刻能看到(不需要重启 ClassIsland)
|
||||||
|
services.AddSingleton(Settings);
|
||||||
|
services.AddSingleton<BridgeService>();
|
||||||
|
|
||||||
|
services.AddSettingsPage<BridgeSettingsPage>();
|
||||||
|
services.AddNotificationProvider<ClassHelperNotificationProvider>();
|
||||||
|
|
||||||
|
// 初始化阶段课程服务还没准备好(它在主机启动流程里才被创建),
|
||||||
|
// 因此这里先挂一个"应用已启动"的回调,等到那时再取 ILessonsService 并开始上报。
|
||||||
|
var application = AppBase.Current;
|
||||||
|
if (application is null)
|
||||||
|
{
|
||||||
|
Logger?.LogError("班级小助手联动:拿不到 ClassIsland 应用实例,联动未启动");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
application.AppStarted += (_, _) => StartBridge();
|
||||||
|
|
||||||
|
// 插件是"运行中才被启用"时,AppStarted 早就过去了 —— 主窗口已经存在就直接接一次
|
||||||
|
if (application.MainWindow is not null) StartBridge();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartBridge()
|
||||||
|
{
|
||||||
|
if (_bridgeStarted) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lessons = IAppHost.TryGetService<ILessonsService>();
|
||||||
|
if (lessons is null)
|
||||||
|
{
|
||||||
|
Logger?.LogError("班级小助手联动:拿不到课程服务,联动已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bridge = IAppHost.TryGetService<BridgeService>();
|
||||||
|
if (bridge is null)
|
||||||
|
{
|
||||||
|
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bridgeStarted = true;
|
||||||
|
bridge.Attach(lessons);
|
||||||
|
bridge.RestartTimer();
|
||||||
|
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
|
||||||
|
// 不这么做的话,最长要等一个上报间隔才会弹出来
|
||||||
|
_ = bridge.PullPendingAsync();
|
||||||
|
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// 联动失败绝不能把 ClassIsland 本体拖下水
|
||||||
|
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ILogger<Plugin>? Logger => IAppHost.TryGetService<ILogger<Plugin>>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 读取设置。首次运行时 <c>DeviceKey</c> 为空,这里补一个随机值并立即落盘 ——
|
||||||
|
/// 服务端用它把同一台机器识别为同一设备,重装插件不会堆出重复设备。
|
||||||
|
/// </summary>
|
||||||
|
private PluginSettings LoadSettings()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(PluginConfigFolder, SettingsFileName);
|
||||||
|
var settings = new PluginSettings();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var loaded = JsonSerializer.Deserialize<PluginSettings>(File.ReadAllText(path), JsonOptions);
|
||||||
|
if (loaded is not null) settings = loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
|
||||||
|
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||||
|
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
|
||||||
|
settings.Normalize();
|
||||||
|
if (needGenerateKey)
|
||||||
|
{
|
||||||
|
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车
|
||||||
|
var machine = Environment.MachineName.ToLowerInvariant();
|
||||||
|
settings.DeviceKey = $"{machine}-{Guid.NewGuid():N}"[..Math.Min(24, machine.Length + 33)];
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings = settings;
|
||||||
|
// 落盘:一是固定首次生成的 DeviceKey,二是把缺字段的旧配置补齐成完整文件
|
||||||
|
SaveSettings(settings, path);
|
||||||
|
// 设置变化时即时保存
|
||||||
|
settings.PropertyChanged += (_, _) => SaveSettings(settings, path);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveSettings(PluginSettings settings, string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var folder = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
|
||||||
|
File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonOptions));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||||
|
"班级小助手联动:保存设置失败({Path})", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Interop;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Models;
|
||||||
|
using ClassIsland.Core;
|
||||||
|
using ClassIsland.Core.Abstractions.Services;
|
||||||
|
using ClassIsland.Core.Models.Notification;
|
||||||
|
using ClassIsland.Shared;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 联动主循环。三件事:
|
||||||
|
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
|
||||||
|
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手;
|
||||||
|
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
|
||||||
|
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
|
||||||
|
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 ClassIsland 档案。
|
||||||
|
///
|
||||||
|
/// 为什么用"上报即拉取"而不是长连接:
|
||||||
|
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
|
||||||
|
/// 而教室机器的上报间隔本身就是心跳 —— 顺路把待提醒带回来,依赖更少、故障面更小。
|
||||||
|
///
|
||||||
|
/// 线程约定:HttpClient 回调在线程池上,因此所有跨线程可见的状态都用 <c>lock</c> 或
|
||||||
|
/// 赋值引用(<c>string</c> / <c>int</c>)保护,属性变化通过 <see cref="PropertyChanged"/> 通知设置页。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||||
|
{
|
||||||
|
private readonly PluginSettings _settings;
|
||||||
|
private readonly ILogger<BridgeService> _logger;
|
||||||
|
private readonly ClassHelperClient _client = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
private ILessonsService? _lessons;
|
||||||
|
private Timer? _timer;
|
||||||
|
private Timer? _notifyTimer;
|
||||||
|
private bool _disposed;
|
||||||
|
private bool _reporting;
|
||||||
|
private bool _pulling;
|
||||||
|
private bool _eventsHooked;
|
||||||
|
|
||||||
|
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
/// <summary>上报结果变化时触发(设置页据此刷新)。</summary>
|
||||||
|
public event EventHandler? StateChanged;
|
||||||
|
|
||||||
|
/// <summary>需要把提醒丢给提醒提供方去显示。</summary>
|
||||||
|
public event EventHandler<PushNotificationDto>? NotificationReceived;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 供设置页绑定的状态 */
|
||||||
|
|
||||||
|
private string _lastReportMessage = "尚未上报";
|
||||||
|
|
||||||
|
/// <summary>最近一次上报的结果描述。</summary>
|
||||||
|
public string LastReportMessage
|
||||||
|
{
|
||||||
|
get => _lastReportMessage;
|
||||||
|
private set => SetField(ref _lastReportMessage, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _lastReportOk;
|
||||||
|
public bool LastReportOk
|
||||||
|
{
|
||||||
|
get => _lastReportOk;
|
||||||
|
private set => SetField(ref _lastReportOk, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTimeOffset? _lastReportAt;
|
||||||
|
public DateTimeOffset? LastReportAt
|
||||||
|
{
|
||||||
|
get => _lastReportAt;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetField(ref _lastReportAt, value)) Raise(nameof(LastReportAtText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>供界面直接显示的"最后上报时间"。</summary>
|
||||||
|
public string LastReportAtText => LastReportAt is null
|
||||||
|
? "从未"
|
||||||
|
: LastReportAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private int _lastScheduleEntryCount;
|
||||||
|
/// <summary>最近一次上报里的课表条目数。</summary>
|
||||||
|
public int LastScheduleEntryCount
|
||||||
|
{
|
||||||
|
get => _lastScheduleEntryCount;
|
||||||
|
private set => SetField(ref _lastScheduleEntryCount, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _lastTimeLayoutItemCount;
|
||||||
|
/// <summary>最近一次上报里的节次数量。</summary>
|
||||||
|
public int LastTimeLayoutItemCount
|
||||||
|
{
|
||||||
|
get => _lastTimeLayoutItemCount;
|
||||||
|
private set => SetField(ref _lastTimeLayoutItemCount, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int? _lastServerWeek;
|
||||||
|
/// <summary>服务端返回的教学周。</summary>
|
||||||
|
public int? LastServerWeek
|
||||||
|
{
|
||||||
|
get => _lastServerWeek;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetField(ref _lastServerWeek, value)) Raise(nameof(ClientTimeOffsetText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _notificationCount;
|
||||||
|
/// <summary>已弹出并回执的提醒数量。</summary>
|
||||||
|
public int NotificationCount
|
||||||
|
{
|
||||||
|
get => _notificationCount;
|
||||||
|
private set => SetField(ref _notificationCount, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _busy;
|
||||||
|
/// <summary>是否正在上报("立即上报"按钮据此禁用)。</summary>
|
||||||
|
public bool Busy
|
||||||
|
{
|
||||||
|
get => _busy;
|
||||||
|
private set => SetField(ref _busy, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSpan? _serverTimeOffset;
|
||||||
|
/// <summary>服务端与本机的时钟差(诊断用:差得多说明教室机器时间不对)。</summary>
|
||||||
|
public string ClientTimeOffsetText => _serverTimeOffset is null
|
||||||
|
? "—"
|
||||||
|
: $"{_serverTimeOffset.Value.TotalSeconds:+0;-0;0} 秒";
|
||||||
|
|
||||||
|
/// <summary>当前配置是否完整(设置页据此提示"还没填令牌")。</summary>
|
||||||
|
public bool IsConfigured => _settings.IsConfigured;
|
||||||
|
|
||||||
|
/// <summary>是否已接入课程服务(拿到 ILessonsService 才算真正跑起来)。</summary>
|
||||||
|
public bool IsAttached
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _lessons is not null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 生命周期 */
|
||||||
|
|
||||||
|
/// <summary>接入课程服务并订阅课程事件(由插件入口在 AppStarted 时调用)。</summary>
|
||||||
|
public void Attach(ILessonsService lessons)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_lessons = lessons;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_eventsHooked)
|
||||||
|
{
|
||||||
|
// 上课 / 下课 / 放学 / 状态变化时立刻上报一次,不等到下一个定时点。
|
||||||
|
// 这些事件在 ClassIsland 的公开约定里是在**主线程**触发的,可以直接订阅。
|
||||||
|
lessons.OnClass += (_, _) => RequestReport("上课");
|
||||||
|
lessons.OnBreakingTime += (_, _) => RequestReport("下课");
|
||||||
|
lessons.OnAfterSchool += (_, _) => RequestReport("放学");
|
||||||
|
lessons.CurrentTimeStateChanged += (_, _) => RequestReport("状态变化");
|
||||||
|
_eventsHooked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Raise(nameof(IsAttached));
|
||||||
|
_logger.LogInformation("班级小助手联动:已订阅课程事件");
|
||||||
|
RequestReport("应用启动");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按设置重建定时器(设置页改间隔后调用)。</summary>
|
||||||
|
public void RestartTimer()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = null;
|
||||||
|
_notifyTimer?.Dispose();
|
||||||
|
_notifyTimer = null;
|
||||||
|
|
||||||
|
if (!_settings.IsConfigured) return;
|
||||||
|
|
||||||
|
if (_settings.AutoReport)
|
||||||
|
{
|
||||||
|
var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.ReportIntervalSeconds, 15, 3600));
|
||||||
|
_timer = new Timer(_ => RequestReport("定时"), null, interval, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提醒单独用一个轻量轮询(只查待提醒,不上传课表):
|
||||||
|
// 老师按下发送到教室弹出之间的延迟就是这个间隔,因此默认 10 秒,不跟随上报间隔。
|
||||||
|
if (_settings.ReceiveNotifications)
|
||||||
|
{
|
||||||
|
var poll = TimeSpan.FromSeconds(Math.Clamp(_settings.NotificationPollSeconds, 5, 600));
|
||||||
|
_notifyTimer = new Timer(_ => _ = PullPendingAsync(), null, poll, poll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。</summary>
|
||||||
|
public void RequestReport(string reason)
|
||||||
|
{
|
||||||
|
_ = ReportAsync(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行一次上报并返回是否真的发出去了。
|
||||||
|
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true)
|
||||||
|
{
|
||||||
|
if (_disposed) return false;
|
||||||
|
|
||||||
|
ILessonsService? lessons;
|
||||||
|
lock (_gate) lessons = _lessons;
|
||||||
|
if (lessons is null) return false;
|
||||||
|
|
||||||
|
if (!_settings.IsConfigured)
|
||||||
|
{
|
||||||
|
SetState(false, "尚未配置服务器地址或设备令牌", 0, 0, null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_reporting) return false;
|
||||||
|
_reporting = true;
|
||||||
|
}
|
||||||
|
Busy = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profile = ResolveProfile();
|
||||||
|
var request = new ReportRequestDto
|
||||||
|
{
|
||||||
|
PluginVersion = PluginVersion,
|
||||||
|
ClassIslandVersion = SafeAppVersion(),
|
||||||
|
State = BuildState(lessons),
|
||||||
|
};
|
||||||
|
|
||||||
|
var entryCount = 0;
|
||||||
|
var layoutCount = 0;
|
||||||
|
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
||||||
|
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
||||||
|
if (includeSchedule && !_settings.UploadSchedule)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" +
|
||||||
|
"需要把教室课表同步到班级小助手请打开该开关");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeSchedule && _settings.UploadSchedule && profile is not null)
|
||||||
|
{
|
||||||
|
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
||||||
|
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
|
||||||
|
entryCount = entries.Count;
|
||||||
|
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
||||||
|
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
|
||||||
|
// 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。
|
||||||
|
if (entries.Count == 0)
|
||||||
|
{
|
||||||
|
request.Schedule = null;
|
||||||
|
_logger.LogWarning(
|
||||||
|
"班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||||||
|
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份");
|
||||||
|
}
|
||||||
|
|
||||||
|
var layout = ResolvePrimaryTimeLayout(profile);
|
||||||
|
if (layout is not null)
|
||||||
|
{
|
||||||
|
var items = ScheduleMapper.MapTimeLayout(layout);
|
||||||
|
layoutCount = items.Count;
|
||||||
|
request.TimeLayout = new TimeLayoutPayloadDto
|
||||||
|
{
|
||||||
|
Name = string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
|
||||||
|
Mode = "replace",
|
||||||
|
Items = items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (!result.Ok || data is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("班级小助手联动上报失败({Reason}):{Message}", reason, result.Message);
|
||||||
|
SetState(false, result.Message, entryCount, layoutCount, null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LastServerWeek = data.Week;
|
||||||
|
UpdateClockOffset(data.ServerTime);
|
||||||
|
if (data.ScheduleApplied)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||||
|
data.ScheduleCreated, data.ScheduleUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail = entryCount > 0
|
||||||
|
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||||||
|
: _settings.UploadSchedule
|
||||||
|
? "仅状态(ClassIsland 里还没有启用的课表)"
|
||||||
|
: "仅状态(本机「上报课表到班级小助手」开关已关闭)";
|
||||||
|
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
||||||
|
entryCount, layoutCount, data.Week);
|
||||||
|
|
||||||
|
// 顺路把"还没弹出过的"提醒带回来
|
||||||
|
var pending = data.PendingNotification;
|
||||||
|
if (pending is not null && _settings.ReceiveNotifications)
|
||||||
|
{
|
||||||
|
NotificationReceived?.Invoke(this, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_settings.MirrorSchedule)
|
||||||
|
{
|
||||||
|
await MirrorScheduleAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Busy = false;
|
||||||
|
lock (_gate) _reporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 拉取本班尚未弹出过的提醒并逐条交给提醒提供方(用于"应用刚起来还没到第一个上报点"的场景)。
|
||||||
|
/// </summary>
|
||||||
|
public async Task PullPendingAsync()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
if (!_settings.IsConfigured || !_settings.ReceiveNotifications) return;
|
||||||
|
|
||||||
|
// 轮询与"上报顺路拉取"可能同时到达:同一时刻只放一个请求过去
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_pulling) return;
|
||||||
|
_pulling = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (!result.Ok)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("班级小助手联动:拉取待提醒失败({Message})", result.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
||||||
|
NotificationReceived?.Invoke(this, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_gate) _pulling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把服务端的一条提醒标记为已弹出。</summary>
|
||||||
|
public async Task AckAsync(string id)
|
||||||
|
{
|
||||||
|
var result = await _client.AckAsync(_settings, id, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (result.Ok)
|
||||||
|
{
|
||||||
|
NotificationCount += 1;
|
||||||
|
_logger.LogInformation("班级小助手联动:已确认提醒 {Id}", id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
|
||||||
|
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把班级小助手上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
||||||
|
public async Task MirrorScheduleAsync()
|
||||||
|
{
|
||||||
|
var profile = ResolveProfile();
|
||||||
|
if (profile is null) return;
|
||||||
|
|
||||||
|
var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (!result.Ok || plan is null)
|
||||||
|
{
|
||||||
|
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
|
||||||
|
_logger.LogInformation(
|
||||||
|
"班级小助手联动:跳过课表镜像({Message})。" +
|
||||||
|
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
|
||||||
|
result.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var applied = ClassPlanWriter.Apply(profile, plan);
|
||||||
|
if (!applied.Applied)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
||||||
|
applied.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
||||||
|
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
||||||
|
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
||||||
|
|
||||||
|
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
||||||
|
string.Join("、", applied.PlanNames.Select(name => $"「{name}」")));
|
||||||
|
foreach (var warning in applied.Warnings)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "班级小助手联动:课表镜像失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 内部实现 */
|
||||||
|
|
||||||
|
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||||
|
private const string PluginVersion = "0.1.0.0";
|
||||||
|
|
||||||
|
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||||
|
private static string SafeAppVersion()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return AppBase.AppVersion ?? "";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 取得当前状态快照。
|
||||||
|
/// <c>CurrentTimeLayoutItem</c> 在"没有课表"时是 <see cref="ClassIsland.Shared.Models.Profile.TimeLayoutItem.Empty"/>,
|
||||||
|
/// 语义上等价于"没有节次",因此要转成 null 而不是 00:00。
|
||||||
|
/// </summary>
|
||||||
|
private StateDto BuildState(ILessonsService lessons)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return ScheduleMapper.BuildState(lessons, LastServerWeek);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
|
||||||
|
_logger.LogWarning(exception, "班级小助手联动:读取课程状态失败,本次只上报心跳");
|
||||||
|
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。
|
||||||
|
///
|
||||||
|
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
|
||||||
|
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
|
||||||
|
/// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。
|
||||||
|
/// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。
|
||||||
|
/// </summary>
|
||||||
|
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
|
||||||
|
ClassIsland.Shared.Models.Profile.Profile profile)
|
||||||
|
{
|
||||||
|
if (profile.TimeLayouts.Count == 0) return null;
|
||||||
|
|
||||||
|
var best = profile.TimeLayouts
|
||||||
|
.Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0)))
|
||||||
|
.Where(item => item.ClassPoints > 0)
|
||||||
|
.OrderByDescending(item => item.ClassPoints)
|
||||||
|
.ToList();
|
||||||
|
if (best.Count > 0) return best[0].Value;
|
||||||
|
|
||||||
|
// 兜底:档案里被激活的时间表,最后才是任意一个
|
||||||
|
return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated)
|
||||||
|
?? profile.TimeLayouts.Values.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClassIsland.Shared.Models.Profile.ClassPlan? CurrentClassPlan(
|
||||||
|
ClassIsland.Shared.Models.Profile.Profile profile)
|
||||||
|
{
|
||||||
|
// 用课程服务拿"今天该上哪张课表"最准
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lessons = IAppHost.TryGetService<ILessonsService>();
|
||||||
|
return lessons?.CurrentClassPlan;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClassIsland.Shared.Models.Profile.Profile? ResolveProfile()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 档案由 ProfileService 持有;这里用 TryGetService 避免服务未注册时抛异常
|
||||||
|
return IAppHost.TryGetService<IProfileService>()?.Profile;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "班级小助手联动:读取档案失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>用服务端时间校准本机时钟差(只影响设置页展示,不改系统时间)。</summary>
|
||||||
|
private void UpdateClockOffset(string serverTime)
|
||||||
|
{
|
||||||
|
if (!DateTimeOffset.TryParse(serverTime, out var parsed)) return;
|
||||||
|
_serverTimeOffset = parsed - DateTimeOffset.Now;
|
||||||
|
Raise(nameof(ClientTimeOffsetText));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetState(bool ok, string message, int entryCount, int layoutCount, int? week)
|
||||||
|
{
|
||||||
|
LastReportOk = ok;
|
||||||
|
LastReportMessage = message;
|
||||||
|
LastReportAt = DateTimeOffset.Now;
|
||||||
|
LastScheduleEntryCount = entryCount;
|
||||||
|
LastTimeLayoutItemCount = layoutCount;
|
||||||
|
if (week.HasValue) LastServerWeek = week;
|
||||||
|
StateChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Raise(string propertyName) =>
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
|
||||||
|
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||||
|
field = value;
|
||||||
|
Raise(propertyName!);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = null;
|
||||||
|
_notifyTimer?.Dispose();
|
||||||
|
_notifyTimer = null;
|
||||||
|
}
|
||||||
|
_client.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Interop;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Models;
|
||||||
|
using ClassIsland.Core.Abstractions.Services;
|
||||||
|
using ClassIsland.Shared;
|
||||||
|
using ClassIsland.Shared.Enums;
|
||||||
|
using ClassIsland.Core.Abstractions.Services.NotificationProviders;
|
||||||
|
using ClassIsland.Core.Attributes;
|
||||||
|
using ClassIsland.Core.Models.Notification;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。
|
||||||
|
///
|
||||||
|
/// 与 <see cref="BridgeService"/> 的分工:
|
||||||
|
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
|
||||||
|
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
|
||||||
|
/// 全屏特效 / 语音 / 音效那一整套流程。
|
||||||
|
///
|
||||||
|
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里,
|
||||||
|
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
|
||||||
|
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
|
||||||
|
/// </summary>
|
||||||
|
// 第 3 个参数是 Fluent 图标字体里的字形字符(ClassIsland 内置提醒提供方用的也是字形字符,
|
||||||
|
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
|
||||||
|
[NotificationProviderInfo(
|
||||||
|
ProviderGuidValue,
|
||||||
|
"班级小助手提醒",
|
||||||
|
"\uE8BD",
|
||||||
|
"显示老师从班级小助手下发的提醒。")]
|
||||||
|
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 提醒提供方 GUID(全局唯一,别改:改了等于换了一个提供方,用户原有的提醒设置会失联)。
|
||||||
|
/// 名字带 Value 后缀是为了不和基类的 <see cref="NotificationProviderBase.ProviderGuid"/> 属性撞名。
|
||||||
|
/// </summary>
|
||||||
|
public const string ProviderGuidValue = "7C4B1F6E-2A85-4A1F-9E3C-5B7D0C1A9E42";
|
||||||
|
|
||||||
|
private readonly BridgeService _bridge;
|
||||||
|
private readonly PluginSettings _settings;
|
||||||
|
private readonly ILogger<ClassHelperNotificationProvider> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 正在播放(尚未回执)的提醒 id。
|
||||||
|
///
|
||||||
|
/// 服务端在提醒被回执前会**一直**把它算作"待弹出",而插件是按固定间隔轮询取回的,
|
||||||
|
/// 因此时长超过轮询间隔的提醒会被反复取到 —— 没有这层去重就会一遍遍重复弹。
|
||||||
|
/// 只有播完(或用户关掉)才从集合里移除,那一刻才回执。
|
||||||
|
/// </summary>
|
||||||
|
private readonly HashSet<string> _playing = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。
|
||||||
|
///
|
||||||
|
/// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。
|
||||||
|
/// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致,
|
||||||
|
/// 否则学生在客户端看不到、却在 ClassIsland 上被打断。
|
||||||
|
///
|
||||||
|
/// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。
|
||||||
|
/// </summary>
|
||||||
|
private readonly List<PushNotificationDto> _deferred = new();
|
||||||
|
|
||||||
|
private ILessonsService? _lessons;
|
||||||
|
|
||||||
|
public ClassHelperNotificationProvider(
|
||||||
|
BridgeService bridge,
|
||||||
|
PluginSettings settings,
|
||||||
|
ILogger<ClassHelperNotificationProvider> logger)
|
||||||
|
{
|
||||||
|
_bridge = bridge;
|
||||||
|
_settings = settings;
|
||||||
|
_logger = logger;
|
||||||
|
_bridge.NotificationReceived += OnNotificationReceived;
|
||||||
|
|
||||||
|
// 订阅课程事件:下课后把上课时段暂存的提醒补弹出来
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_lessons = IAppHost.TryGetService<ILessonsService>();
|
||||||
|
if (_lessons is not null)
|
||||||
|
{
|
||||||
|
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
|
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
|
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,上课暂存将退化为「立即弹出」");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
|
||||||
|
private bool IsInClass()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _lessons?.CurrentState == TimeState.OnClass;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 这条提醒是否必须立刻弹("主动通知"):
|
||||||
|
/// 紧急提醒,或叫人(老师正在等学生)。
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsImmediate(PushNotificationDto notification) =>
|
||||||
|
notification.Urgent || string.Equals(notification.Kind, "call", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>下课后补弹暂存的提醒(按进入顺序)</summary>
|
||||||
|
private void FlushDeferred()
|
||||||
|
{
|
||||||
|
if (IsInClass()) return; // 还在上课(例如只是状态在 OnClass 内部变化)
|
||||||
|
if (_deferred.Count == 0) return;
|
||||||
|
|
||||||
|
var pending = _deferred.ToList();
|
||||||
|
_deferred.Clear();
|
||||||
|
_logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
||||||
|
foreach (var item in pending) ShowOnUiThread(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNotificationReceived(object? sender, PushNotificationDto notification)
|
||||||
|
{
|
||||||
|
if (!_settings.ReceiveNotifications) return;
|
||||||
|
if (string.IsNullOrWhiteSpace(notification.Id)) return;
|
||||||
|
|
||||||
|
// 同一条提醒在播放期间会被后续轮询反复取回,这里按 id 去重(详见 _playing 的说明)
|
||||||
|
lock (_playing)
|
||||||
|
{
|
||||||
|
if (!_playing.Add(notification.Id))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须切回 UI 线程:提醒内容里的图标、以及提醒主机本身都是 Avalonia 的 UI 对象,
|
||||||
|
// 而本方法是从上报回调(线程池)里被调用的 —— 在线程池上直接显示会静默失败/抛异常。
|
||||||
|
Dispatcher.UIThread.Post(() => ShowOnUiThread(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowOnUiThread(PushNotificationDto notification)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 上课时段:非"主动通知"先收着(与客户端灵动岛同一套规则),下课后补弹
|
||||||
|
if (IsInClass() && !IsImmediate(notification))
|
||||||
|
{
|
||||||
|
_deferred.Add(notification);
|
||||||
|
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = BuildRequest(notification);
|
||||||
|
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
||||||
|
AckWhenFinished(request, notification);
|
||||||
|
ShowNotification(request);
|
||||||
|
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||||
|
notification.Title, notification.DurationSeconds);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
|
||||||
|
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
|
||||||
|
lock (_playing) _playing.Remove(notification.Id);
|
||||||
|
_logger.LogError(exception, "班级小助手联动:显示提醒「{Title}」失败", notification.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把服务端下发的提醒转成 ClassIsland 的提醒请求。
|
||||||
|
///
|
||||||
|
/// 时长口径:老师指定的时长给**正文**(正文才是持续显示的那一段),遮罩只停留几秒 ——
|
||||||
|
/// 遮罩占满整个时长会把正文压没,不如"遮罩几秒 + 正文 N 秒"清楚。
|
||||||
|
/// </summary>
|
||||||
|
private NotificationRequest BuildRequest(PushNotificationDto notification)
|
||||||
|
{
|
||||||
|
var seconds = Math.Clamp(
|
||||||
|
notification.DurationSeconds > 0 ? notification.DurationSeconds : _settings.NotificationDurationSeconds,
|
||||||
|
1,
|
||||||
|
120);
|
||||||
|
var duration = TimeSpan.FromSeconds(seconds);
|
||||||
|
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
|
||||||
|
|
||||||
|
var title = string.IsNullOrWhiteSpace(notification.Title) ? "班级小助手" : notification.Title;
|
||||||
|
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
|
||||||
|
|
||||||
|
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
|
||||||
|
var speech = _settings.SpeechEnabled ? notification.SpeechContent : null;
|
||||||
|
var speechEnabled = !string.IsNullOrWhiteSpace(speech);
|
||||||
|
|
||||||
|
// 遮罩用双图标模板的**默认图标**:这两个图标由模板自己创建(LucideIconSource),
|
||||||
|
// 自己塞图标串很容易踩到"图标表达式格式不对/字形不存在",没必要冒这个险。
|
||||||
|
var mask = NotificationContent.CreateTwoIconsMask(title, factory: content_ =>
|
||||||
|
{
|
||||||
|
content_.Duration = maskDuration;
|
||||||
|
content_.SpeechContent = speech ?? string.Empty;
|
||||||
|
// 没勾朗读时明确关掉,否则 ClassIsland 会按默认设置把标题也念出来
|
||||||
|
content_.IsSpeechEnabled = speechEnabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 正文:普通提醒用静态文本;紧急提醒改用**滚动文本**(ClassIsland 自带的抢注意力模板),
|
||||||
|
// 这样"紧急"在视觉上真的有区别,而不是只靠一个可能不存在的图标。
|
||||||
|
NotificationContent overlay = notification.Urgent
|
||||||
|
? NotificationContent.CreateRollingTextContent(content, factory: content_ =>
|
||||||
|
{
|
||||||
|
content_.Duration = duration;
|
||||||
|
content_.SpeechContent = speech ?? string.Empty;
|
||||||
|
content_.IsSpeechEnabled = speechEnabled;
|
||||||
|
})
|
||||||
|
: NotificationContent.CreateSimpleTextContent(content, factory: content_ =>
|
||||||
|
{
|
||||||
|
content_.Duration = duration;
|
||||||
|
content_.SpeechContent = speech ?? string.Empty;
|
||||||
|
content_.IsSpeechEnabled = speechEnabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</summary>
|
||||||
|
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
|
||||||
|
{
|
||||||
|
void Handler(object? sender, EventArgs args)
|
||||||
|
{
|
||||||
|
request.Completed -= Handler;
|
||||||
|
request.Canceled -= Handler;
|
||||||
|
|
||||||
|
// 播完再放行:即使回执失败(例如断网)也移除,让后续轮询能重新投递一次,
|
||||||
|
// 避免"回执丢了 + 不再重试"把提醒彻底吞掉
|
||||||
|
lock (_playing) _playing.Remove(notification.Id);
|
||||||
|
_ = _bridge.AckAsync(notification.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Completed += Handler;
|
||||||
|
request.Canceled += Handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Interop;
|
||||||
|
using ClassIsland.Shared.Models.Profile;
|
||||||
|
|
||||||
|
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。
|
||||||
|
///
|
||||||
|
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
|
||||||
|
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 7 天与单双周。
|
||||||
|
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
|
||||||
|
///
|
||||||
|
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
|
||||||
|
/// <c>TimeLayout.Layouts</c> 里第 i 个 <c>TimeType == 0</c>(上课)的点一一对应。
|
||||||
|
/// 与其去"匹配服务端时间表里的时间点"(一旦对不齐整份课表就错位),
|
||||||
|
/// 这里**按分组内的条目顺序直接重建时间点**:每条课目生成一个上课点,
|
||||||
|
/// 并在其结束后接上服务端时间表里同一起始时刻的课间点。这样索引关系天然成立。
|
||||||
|
///
|
||||||
|
/// **非破坏性**:
|
||||||
|
/// - 只创建/更新名字带 <see cref="PlanNamePrefix"/> 前缀的课表与时间表;
|
||||||
|
/// - 老师手动建的课表一律不删除、不改名(同名星期的冲突会在日志里警告);
|
||||||
|
/// - 重复镜像时原地更新,不会越积越多。
|
||||||
|
/// </summary>
|
||||||
|
public static class ClassPlanWriter
|
||||||
|
{
|
||||||
|
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
|
||||||
|
public const string PlanNamePrefix = "班级小助手-";
|
||||||
|
|
||||||
|
/// <summary>时间表名字后缀。</summary>
|
||||||
|
public const string TimeLayoutNameSuffix = "(班级小助手)";
|
||||||
|
|
||||||
|
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
|
||||||
|
public sealed class ApplyResult
|
||||||
|
{
|
||||||
|
public List<string> PlanNames { get; } = new();
|
||||||
|
public List<string> Warnings { get; } = new();
|
||||||
|
public bool Applied { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 应用服务端下发的课表。
|
||||||
|
/// </summary>
|
||||||
|
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source)
|
||||||
|
{
|
||||||
|
var result = new ApplyResult();
|
||||||
|
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
|
||||||
|
{
|
||||||
|
result.Warnings.Add("服务端返回的课表为空,未改动 ClassIsland 档案");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var layout = source.TimeLayouts[0];
|
||||||
|
|
||||||
|
// 1) 补齐科目(同名的复用,避免每次镜像堆出新的 Subject)
|
||||||
|
EnsureSubjects(profile, source);
|
||||||
|
|
||||||
|
// 2) 按(星期几 + 单双周)分组,每组一份课表
|
||||||
|
var groups = source.Entries
|
||||||
|
.GroupBy(entry => new
|
||||||
|
{
|
||||||
|
entry.WeekDay,
|
||||||
|
entry.WeekCountDiv,
|
||||||
|
entry.WeekCountDivTotal,
|
||||||
|
})
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
group.Key.WeekDay,
|
||||||
|
group.Key.WeekCountDiv,
|
||||||
|
group.Key.WeekCountDivTotal,
|
||||||
|
// 时间点顺序 = 课表顺序:`Classes[i]` 靠这个顺序与上课时间点对齐,
|
||||||
|
// 「HH:mm」是定长字符串,字典序就是时间序
|
||||||
|
Entries = group.OrderBy(entry => entry.StartTime, StringComparer.Ordinal).ToList(),
|
||||||
|
})
|
||||||
|
.OrderBy(group => group.WeekDay)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
|
||||||
|
group.WeekCountDivTotal, groups.Count);
|
||||||
|
var planId = FindPlanId(profile, planName);
|
||||||
|
var (timeLayoutId, timeLayout) = BuildTimeLayout(
|
||||||
|
profile, planName, group.Entries);
|
||||||
|
|
||||||
|
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
|
||||||
|
? existing
|
||||||
|
: new ClassPlan();
|
||||||
|
plan.Name = planName;
|
||||||
|
plan.TimeLayoutId = timeLayoutId;
|
||||||
|
plan.TimeRule = new TimeRule
|
||||||
|
{
|
||||||
|
WeekDay = group.WeekDay,
|
||||||
|
WeekCountDiv = group.WeekCountDiv,
|
||||||
|
WeekCountDivTotal = group.WeekCountDivTotal,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Classes[i] ↔ 第 i 个上课点:按分组内条目的时间顺序直接构造,索引天然一致
|
||||||
|
// ClassIsland 的 `ClassInfo.CurrentTimeLayoutItem` = CurrentTimeLayout 里第 Index 个上课点,
|
||||||
|
// 因此这里必须显式把 Index 与 CurrentTimeLayout 一起写上 —— 只在构造时依赖
|
||||||
|
// `RefreshClassesList()` 是不够的:更新已有课表时 `TimeLayouts` 引用没变,
|
||||||
|
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
|
||||||
|
var classes = new ObservableCollection<ClassInfo>();
|
||||||
|
var index = 0;
|
||||||
|
foreach (var entry in group.Entries)
|
||||||
|
{
|
||||||
|
var subjectId = profile.Subjects
|
||||||
|
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
|
||||||
|
classes.Add(new ClassInfo
|
||||||
|
{
|
||||||
|
Index = index++,
|
||||||
|
CurrentTimeLayout = timeLayout,
|
||||||
|
SubjectId = subjectId,
|
||||||
|
IsEnabled = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
plan.Classes = classes;
|
||||||
|
plan.IsEnabled = true;
|
||||||
|
profile.ClassPlans[planId] = plan;
|
||||||
|
result.PlanNames.Add(planName);
|
||||||
|
|
||||||
|
// 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表
|
||||||
|
var conflicts = profile.ClassPlans
|
||||||
|
.Where(pair => pair.Key != planId
|
||||||
|
&& pair.Value.IsEnabled
|
||||||
|
&& !pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)
|
||||||
|
&& pair.Value.TimeRule.WeekDay == group.WeekDay
|
||||||
|
&& pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal
|
||||||
|
&& pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv)
|
||||||
|
.Select(pair => pair.Value.Name)
|
||||||
|
.ToList();
|
||||||
|
if (conflicts.Count > 0)
|
||||||
|
{
|
||||||
|
result.Warnings.Add(
|
||||||
|
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
|
||||||
|
"两者重叠时以班级小助手同步的课表为准");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 停用上一次镜像留下来的、本次不再需要的课表(只动自己创建的)
|
||||||
|
var currentNames = new HashSet<string>(result.PlanNames);
|
||||||
|
foreach (var pair in profile.ClassPlans)
|
||||||
|
{
|
||||||
|
if (!pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)) continue;
|
||||||
|
if (currentNames.Contains(pair.Value.Name)) continue;
|
||||||
|
pair.Value.IsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Applied = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSubjects(Profile profile, ClassPlanMirrorDto source)
|
||||||
|
{
|
||||||
|
foreach (var name in source.Entries.Select(entry => entry.Subject).Distinct())
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
if (profile.Subjects.Any(pair => pair.Value.Name == name)) continue;
|
||||||
|
profile.Subjects[Guid.NewGuid()] = new Subject { Name = name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid FindPlanId(Profile profile, string planName)
|
||||||
|
{
|
||||||
|
var existing = profile.ClassPlans.FirstOrDefault(pair => pair.Value.Name == planName);
|
||||||
|
return existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
|
||||||
|
///
|
||||||
|
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
|
||||||
|
/// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表,
|
||||||
|
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
|
||||||
|
/// </summary>
|
||||||
|
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
|
||||||
|
Profile profile,
|
||||||
|
string planName,
|
||||||
|
IReadOnlyList<ClassPlanEntryMirrorDto> entries)
|
||||||
|
{
|
||||||
|
var layoutName = planName + TimeLayoutNameSuffix;
|
||||||
|
var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName);
|
||||||
|
var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
|
||||||
|
|
||||||
|
var layout = new TimeLayout { Name = layoutName, IsActivated = true };
|
||||||
|
for (var index = 0; index < entries.Count; index++)
|
||||||
|
{
|
||||||
|
var entry = entries[index];
|
||||||
|
layout.Layouts.Add(new TimeLayoutItem
|
||||||
|
{
|
||||||
|
TimeType = 0, // 上课
|
||||||
|
StartTime = ParseTime(entry.StartTime),
|
||||||
|
EndTime = ParseTime(entry.EndTime),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 课间:下一节开始晚于本节结束才有空隙;最后一节之后(放学)不插
|
||||||
|
if (index + 1 >= entries.Count) continue;
|
||||||
|
var next = entries[index + 1];
|
||||||
|
var breakStart = ParseTime(entry.EndTime);
|
||||||
|
var breakEnd = ParseTime(next.StartTime);
|
||||||
|
if (breakEnd <= breakStart) continue;
|
||||||
|
|
||||||
|
layout.Layouts.Add(new TimeLayoutItem
|
||||||
|
{
|
||||||
|
TimeType = 1, // 课间
|
||||||
|
StartTime = breakStart,
|
||||||
|
EndTime = breakEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.TimeLayouts[id] = layout;
|
||||||
|
return (id, layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 课表名:单组时用「班级小助手-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildPlanName(
|
||||||
|
string profileName,
|
||||||
|
int weekDay,
|
||||||
|
int weekCountDiv,
|
||||||
|
int weekCountDivTotal,
|
||||||
|
int groupCount)
|
||||||
|
{
|
||||||
|
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : profileName.Trim();
|
||||||
|
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
baseName = PlanNamePrefix + baseName;
|
||||||
|
}
|
||||||
|
if (groupCount <= 1) return baseName;
|
||||||
|
|
||||||
|
var weekday = weekDay switch
|
||||||
|
{
|
||||||
|
0 => "周日",
|
||||||
|
1 => "周一",
|
||||||
|
2 => "周二",
|
||||||
|
3 => "周三",
|
||||||
|
4 => "周四",
|
||||||
|
5 => "周五",
|
||||||
|
6 => "周六",
|
||||||
|
_ => $"星期{weekDay}",
|
||||||
|
};
|
||||||
|
var parity = weekCountDivTotal <= 1
|
||||||
|
? "每周"
|
||||||
|
: $"第{weekCountDiv}/{weekCountDivTotal}周";
|
||||||
|
return $"{baseName} · {weekday} · {parity}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"HH:mm" / "HH:mm:ss" → TimeSpan(解析失败回退到 0 点)。</summary>
|
||||||
|
private static TimeSpan ParseTime(string value)
|
||||||
|
{
|
||||||
|
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Interop;
|
||||||
|
using ClassIsland.Core.Abstractions.Services;
|
||||||
|
using ClassIsland.Shared;
|
||||||
|
using ClassIsland.Shared.Enums;
|
||||||
|
using ClassIsland.Shared.Models.Profile;
|
||||||
|
|
||||||
|
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 课表 / 状态 → 上报载荷 的换算层。
|
||||||
|
///
|
||||||
|
/// 这里集中全部"ClassIsland 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着
|
||||||
|
/// ClassIsland 的模型变化而修改:
|
||||||
|
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
|
||||||
|
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
|
||||||
|
/// - 节次:ClassIsland <c>TimeLayoutItem.TimeType</c> 0=上课 1=课间 2=分割线 3=行动。
|
||||||
|
/// </summary>
|
||||||
|
public static class ScheduleMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ClassIsland 的 WeekDay(0=周日…6=周六)→ 本系统 dayOfWeek(1=周一…7=周日)。
|
||||||
|
/// </summary>
|
||||||
|
public static int ToServerDayOfWeek(int weekDay)
|
||||||
|
{
|
||||||
|
var normalized = ((weekDay % 7) + 7) % 7;
|
||||||
|
return normalized == 0 ? 7 : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ClassIsland 的 WeekCountDiv/Total → 本系统单双周。
|
||||||
|
/// 与 <c>packages/shared/src/utils.ts</c> 的 <c>weekParityFromDiv</c> 完全一致:
|
||||||
|
/// 3 周及以上的循环本系统无法表达,降级为「每周」。
|
||||||
|
/// </summary>
|
||||||
|
public static string ToWeekParity(int weekCountDiv, int weekCountDivTotal)
|
||||||
|
{
|
||||||
|
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return "ALL";
|
||||||
|
var position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
|
||||||
|
if (weekCountDivTotal == 2) return position == 1 ? "ODD" : "EVEN";
|
||||||
|
return "ALL";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ClassIsland TimeType → 本系统节次类型。</summary>
|
||||||
|
public static string ToTimeLayoutType(int timeType) => timeType switch
|
||||||
|
{
|
||||||
|
1 => "break",
|
||||||
|
2 => "divider",
|
||||||
|
3 => "action",
|
||||||
|
_ => "class",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>TimeSpan → "HH:mm"(跨天的时间点做钳制,避免出现 24:xx)。</summary>
|
||||||
|
public static string ToHhMm(TimeSpan time)
|
||||||
|
{
|
||||||
|
var totalMinutes = (int)Math.Round(time.TotalMinutes);
|
||||||
|
totalMinutes = Math.Clamp(totalMinutes, 0, 24 * 60 - 1);
|
||||||
|
return $"{totalMinutes / 60:D2}:{totalMinutes % 60:D2}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
|
||||||
|
///
|
||||||
|
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
|
||||||
|
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,
|
||||||
|
/// 课间 / 分割线 / 行动不占 Classes 的位置。因此这里必须按 TimeType 过滤后再配对,
|
||||||
|
/// 否则整张课表会错位。
|
||||||
|
/// </summary>
|
||||||
|
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
|
||||||
|
{
|
||||||
|
warning = null;
|
||||||
|
var entries = new List<ScheduleEntryDto>();
|
||||||
|
|
||||||
|
var timeLayout = ResolveTimeLayout(plan, profile);
|
||||||
|
if (timeLayout is null)
|
||||||
|
{
|
||||||
|
warning = $"课表「{plan.Name}」找不到对应的时间表,已跳过";
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致
|
||||||
|
var classPoints = timeLayout.Layouts.Where(item => item.TimeType == 0).ToList();
|
||||||
|
var validPoints = plan.ValidTimeLayoutItems.Count > 0
|
||||||
|
? plan.ValidTimeLayoutItems.Where(item => item.TimeType == 0).ToList()
|
||||||
|
: classPoints;
|
||||||
|
|
||||||
|
var weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
|
||||||
|
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
||||||
|
if (plan.TimeRule.WeekCountDivTotal > 2)
|
||||||
|
{
|
||||||
|
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理";
|
||||||
|
}
|
||||||
|
|
||||||
|
var classes = plan.Classes;
|
||||||
|
var count = Math.Min(classes.Count, validPoints.Count);
|
||||||
|
for (var index = 0; index < count; index++)
|
||||||
|
{
|
||||||
|
var info = classes[index];
|
||||||
|
var point = validPoints[index];
|
||||||
|
if (info is null) continue;
|
||||||
|
|
||||||
|
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
||||||
|
var subject = ResolveSubjectName(info.SubjectId, profile);
|
||||||
|
if (string.IsNullOrWhiteSpace(subject)) continue;
|
||||||
|
|
||||||
|
entries.Add(new ScheduleEntryDto
|
||||||
|
{
|
||||||
|
DayOfWeek = weekDay,
|
||||||
|
StartTime = ToHhMm(point.StartTime),
|
||||||
|
EndTime = ToHhMm(point.EndTime),
|
||||||
|
Subject = subject,
|
||||||
|
TeacherName = ResolveSubjectTeacher(info.SubjectId, profile),
|
||||||
|
WeekParity = parity,
|
||||||
|
WeekCountDiv = plan.TimeRule.WeekCountDiv,
|
||||||
|
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
|
||||||
|
PlanName = plan.Name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
|
||||||
|
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
|
||||||
|
{
|
||||||
|
var items = new List<TimeLayoutItemDto>();
|
||||||
|
// ClassIsland 的上课时间点没有独立名称,这里按"它之前有几个上课点"编出"第 N 节"
|
||||||
|
var classPointIndex = 0;
|
||||||
|
for (var index = 0; index < layout.Layouts.Count; index++)
|
||||||
|
{
|
||||||
|
var point = layout.Layouts[index];
|
||||||
|
items.Add(new TimeLayoutItemDto
|
||||||
|
{
|
||||||
|
Index = index + 1,
|
||||||
|
Name = BuildTimePointName(point, ref classPointIndex),
|
||||||
|
StartTime = ToHhMm(point.StartTime),
|
||||||
|
EndTime = ToHhMm(point.EndTime),
|
||||||
|
Type = ToTimeLayoutType(point.TimeType),
|
||||||
|
Skipped = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节次名称:ClassIsland 只有课间有名字(BreakName),上课时间点没有独立名称,
|
||||||
|
/// 因此按类型生成"第 N 节 / 课间 / 分割线",保证本系统的时间轴可读。
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildTimePointName(TimeLayoutItem point, ref int classPointIndex)
|
||||||
|
{
|
||||||
|
switch (point.TimeType)
|
||||||
|
{
|
||||||
|
case 1:
|
||||||
|
return string.IsNullOrWhiteSpace(point.BreakName) ? "课间" : point.BreakName!;
|
||||||
|
case 2:
|
||||||
|
return "分割线";
|
||||||
|
case 3:
|
||||||
|
return "行动";
|
||||||
|
default:
|
||||||
|
classPointIndex++;
|
||||||
|
return $"第 {classPointIndex} 节";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取出课表使用的时间表:优先按 TimeLayoutId,其次用档案里第一个。</summary>
|
||||||
|
public static TimeLayout? ResolveTimeLayout(ClassPlan plan, Profile profile)
|
||||||
|
{
|
||||||
|
if (profile.TimeLayouts.Count == 0) return null;
|
||||||
|
if (plan.TimeLayoutId != Guid.Empty && profile.TimeLayouts.TryGetValue(plan.TimeLayoutId, out var byId))
|
||||||
|
{
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
return plan.TimeLayout ?? profile.TimeLayouts.Values.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>科目名(找不到时返回空字符串,由调用方决定是否跳过)。</summary>
|
||||||
|
public static string? ResolveSubjectName(Guid subjectId, Profile profile)
|
||||||
|
{
|
||||||
|
if (subjectId == Guid.Empty) return null;
|
||||||
|
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.Name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>科目任课老师(ClassIsland 在 Subject 上记录 TeacherName)。</summary>
|
||||||
|
public static string? ResolveSubjectTeacher(Guid subjectId, Profile profile)
|
||||||
|
{
|
||||||
|
if (subjectId == Guid.Empty) return null;
|
||||||
|
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.TeacherName : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收集档案里今天(按当前周次与星期)应该上的课表,
|
||||||
|
/// 用于"只上报今天"这类轻量场景;当前实现为上报全部课表(服务端按周次并存)。
|
||||||
|
/// </summary>
|
||||||
|
public static List<ScheduleEntryDto> MapAllClassPlans(Profile profile, out List<string> warnings)
|
||||||
|
{
|
||||||
|
warnings = new List<string>();
|
||||||
|
var all = new List<ScheduleEntryDto>();
|
||||||
|
foreach (var plan in profile.ClassPlans.Values)
|
||||||
|
{
|
||||||
|
if (!plan.IsEnabled) continue;
|
||||||
|
if (plan.IsOverlay) continue; // 临时层课表不下发,避免把临时调整固化到班级课表
|
||||||
|
var entries = MapClassPlan(plan, profile, out var warning);
|
||||||
|
if (!string.IsNullOrWhiteSpace(warning) && !warnings.Contains(warning!))
|
||||||
|
{
|
||||||
|
warnings.Add(warning!);
|
||||||
|
}
|
||||||
|
all.AddRange(entries);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 科目名归一化。
|
||||||
|
///
|
||||||
|
/// 没有课表 / 没有当前科目时,ClassIsland 会把 <see cref="Subject.Fallback"/> 挂到
|
||||||
|
/// `CurrentSubject` / `NextClassSubject` 上(见 LessonsService:
|
||||||
|
/// `CurrentSubject = currentSubject ?? Subject.Fallback`),而它的名字就是占位符本身。
|
||||||
|
/// 直接上报会让 Web 端显示成"正在上 ???",因此这里统一折算成"没有科目"。
|
||||||
|
/// </summary>
|
||||||
|
private static string? NormalizeSubjectName(Subject? subject)
|
||||||
|
{
|
||||||
|
if (subject is null) return null;
|
||||||
|
if (ReferenceEquals(subject, Subject.Fallback)) return null;
|
||||||
|
|
||||||
|
var name = subject.Name?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
// 兜底:万一拿到的是另一个同名占位符实例(不是同一个引用)
|
||||||
|
if (string.Equals(name, Subject.Fallback.Name, StringComparison.Ordinal)) return null;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>当前状态快照:服务端据此在 Web 端展示"现在上什么课"。</summary>
|
||||||
|
public static StateDto BuildState(ILessonsService lessons, int? currentWeek)
|
||||||
|
{
|
||||||
|
var current = lessons.CurrentTimeLayoutItem;
|
||||||
|
var state = lessons.CurrentState;
|
||||||
|
return new StateDto
|
||||||
|
{
|
||||||
|
InClass = state == TimeState.OnClass,
|
||||||
|
Subject = NormalizeSubjectName(lessons.CurrentSubject),
|
||||||
|
NextSubject = NormalizeSubjectName(lessons.NextClassSubject),
|
||||||
|
TimeState = state.ToString(),
|
||||||
|
PeriodStart = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.StartTime),
|
||||||
|
PeriodEnd = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.EndTime),
|
||||||
|
Week = currentWeek,
|
||||||
|
ClassPlanLoaded = lessons.IsClassPlanLoaded,
|
||||||
|
ClientTime = DateTimeOffset.Now.ToString("o"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<controls:SettingsPageBase x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:local="clr-namespace:ClassHelper.ClassIslandPlugin.Views"
|
||||||
|
xmlns:controls="clr-namespace:ClassIsland.Core.Abstractions.Controls;assembly=ClassIsland.Core"
|
||||||
|
xmlns:ci="clr-namespace:ClassIsland.Core.Controls;assembly=ClassIsland.Core"
|
||||||
|
xmlns:fa="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignHeight="450" d:DesignWidth="800"
|
||||||
|
d:DataContext="{d:DesignInstance local:BridgeSettingsPage}"
|
||||||
|
Loaded="OnLoaded"
|
||||||
|
Unloaded="OnUnloaded">
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Classes="settings-container animated-intro" Spacing="0">
|
||||||
|
<!-- 连接 -->
|
||||||
|
<TextBlock Text="连接班级小助手"
|
||||||
|
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||||
|
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||||
|
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
||||||
|
|
||||||
|
<TextBox Watermark="http://127.0.0.1:4000"
|
||||||
|
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
|
||||||
|
<TextBox Margin="0 8 0 0"
|
||||||
|
Watermark="chci_..."
|
||||||
|
PasswordChar="●"
|
||||||
|
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
|
||||||
|
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||||
|
<Button Content="检查连接" Command="{Binding TestCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||||
|
<Button Content="拉取待提醒" Command="{Binding PullCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||||
|
<Button Content="重建档案课表" Command="{Binding MirrorCommand}"
|
||||||
|
IsEnabled="{Binding Settings.MirrorSchedule}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 状态 -->
|
||||||
|
<Border Margin="0 16 0 0" Padding="12" CornerRadius="4"
|
||||||
|
Background="{DynamicResource SystemFillColorNeutralBackgroundBrush}">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{Binding StatusText}" TextWrapping="Wrap" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding Bridge.LastReportMessage}" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="{Binding DetailText}" TextWrapping="Wrap" Opacity="0.8" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 同步 -->
|
||||||
|
<TextBlock Margin="0 24 0 0" Text="同步内容"
|
||||||
|
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||||
|
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||||
|
Text="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||||
|
|
||||||
|
<fa:SettingsExpander Header="上报课表到班级小助手"
|
||||||
|
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。">
|
||||||
|
<fa:SettingsExpander.IconSource>
|
||||||
|
<ci:FluentIconSource Glyph="" />
|
||||||
|
</fa:SettingsExpander.IconSource>
|
||||||
|
<fa:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding Settings.UploadSchedule, Mode=TwoWay}" />
|
||||||
|
</fa:SettingsExpander.Footer>
|
||||||
|
</fa:SettingsExpander>
|
||||||
|
|
||||||
|
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
|
||||||
|
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。">
|
||||||
|
<fa:SettingsExpander.IconSource>
|
||||||
|
<ci:FluentIconSource Glyph="" />
|
||||||
|
</fa:SettingsExpander.IconSource>
|
||||||
|
<fa:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding Settings.MirrorSchedule, Mode=TwoWay}" />
|
||||||
|
</fa:SettingsExpander.Footer>
|
||||||
|
</fa:SettingsExpander>
|
||||||
|
|
||||||
|
<fa:SettingsExpander Header="自动上报"
|
||||||
|
Description="应用启动、课表变化以及每隔一段时间各上报一次;关闭后只能用上面的「立即上报」手动触发。">
|
||||||
|
<fa:SettingsExpander.IconSource>
|
||||||
|
<ci:FluentIconSource Glyph="" />
|
||||||
|
</fa:SettingsExpander.IconSource>
|
||||||
|
<fa:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding Settings.AutoReport, Mode=TwoWay}" />
|
||||||
|
</fa:SettingsExpander.Footer>
|
||||||
|
</fa:SettingsExpander>
|
||||||
|
|
||||||
|
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="上报间隔(秒)" />
|
||||||
|
<TextBlock Text="最小 15 秒;教室机器建议 60 秒。" Opacity="0.7" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<NumericUpDown Grid.Column="1" Width="140"
|
||||||
|
Minimum="15" Maximum="3600" Increment="15"
|
||||||
|
FormatString="0"
|
||||||
|
IsEnabled="{Binding Settings.AutoReport}"
|
||||||
|
Value="{Binding Settings.ReportIntervalSeconds, Mode=TwoWay}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 提醒 -->
|
||||||
|
<TextBlock Margin="0 24 0 0" Text="提醒"
|
||||||
|
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||||
|
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||||
|
Text="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||||
|
|
||||||
|
<fa:SettingsExpander Header="接收班级小助手提醒"
|
||||||
|
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||||
|
<fa:SettingsExpander.IconSource>
|
||||||
|
<ci:FluentIconSource Glyph="" />
|
||||||
|
</fa:SettingsExpander.IconSource>
|
||||||
|
<fa:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding Settings.ReceiveNotifications, Mode=TwoWay}" />
|
||||||
|
</fa:SettingsExpander.Footer>
|
||||||
|
</fa:SettingsExpander>
|
||||||
|
|
||||||
|
<fa:SettingsExpander Header="语音朗读"
|
||||||
|
Description="提醒弹出时按 ClassIsland 的语音设置为全机朗读提醒内容。">
|
||||||
|
<fa:SettingsExpander.IconSource>
|
||||||
|
<ci:FluentIconSource Glyph="" />
|
||||||
|
</fa:SettingsExpander.IconSource>
|
||||||
|
<fa:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding Settings.SpeechEnabled, Mode=TwoWay}" />
|
||||||
|
</fa:SettingsExpander.Footer>
|
||||||
|
</fa:SettingsExpander>
|
||||||
|
|
||||||
|
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="默认显示时长(秒)" />
|
||||||
|
<TextBlock Text="老师在下发单条提醒时可以另外指定更长的时长。" Opacity="0.7" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<NumericUpDown Grid.Column="1" Width="140"
|
||||||
|
Minimum="1" Maximum="120" Increment="1"
|
||||||
|
FormatString="0"
|
||||||
|
Value="{Binding Settings.NotificationDurationSeconds, Mode=TwoWay}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="提醒轮询间隔(秒)" />
|
||||||
|
<TextBlock Text="老师按下发送到教室弹出之间的最大延迟(插件不持有长连接,是轮询取回提醒)。默认 10 秒。"
|
||||||
|
Opacity="0.7" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<NumericUpDown Grid.Column="1" Width="140"
|
||||||
|
Minimum="5" Maximum="600" Increment="5"
|
||||||
|
FormatString="0"
|
||||||
|
IsEnabled="{Binding Settings.ReceiveNotifications}"
|
||||||
|
Value="{Binding Settings.NotificationPollSeconds, Mode=TwoWay}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 调试 -->
|
||||||
|
<TextBlock Margin="0 24 0 0" Text="调试"
|
||||||
|
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||||
|
<fa:SettingsExpander Header="显示调试信息"
|
||||||
|
Description="显示设备标识、时钟差等细节,排查「为什么同步不上」时打开。">
|
||||||
|
<fa:SettingsExpander.IconSource>
|
||||||
|
<ci:FluentIconSource Glyph="" />
|
||||||
|
</fa:SettingsExpander.IconSource>
|
||||||
|
<fa:SettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding Settings.ShowDebugInfo, Mode=TwoWay}" />
|
||||||
|
</fa:SettingsExpander.Footer>
|
||||||
|
</fa:SettingsExpander>
|
||||||
|
|
||||||
|
<StackPanel Margin="0 8 0 0" Spacing="2" IsVisible="{Binding Settings.ShowDebugInfo}">
|
||||||
|
<TextBlock Text="{Binding DebugText}" TextWrapping="Wrap" FontFamily="Consolas,monospace" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
|
||||||
|
Text="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</controls:SettingsPageBase>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Models;
|
||||||
|
using ClassHelper.ClassIslandPlugin.Services;
|
||||||
|
using ClassIsland.Core.Abstractions.Controls;
|
||||||
|
using ClassIsland.Core.Attributes;
|
||||||
|
using ClassIsland.Core.Enums.SettingsWindow;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClassHelper.ClassIslandPlugin.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 「班级小助手联动」设置页。
|
||||||
|
///
|
||||||
|
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
|
||||||
|
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
|
||||||
|
/// 不需要再套一层 ViewModel,改配置项时也少一处"两边同步"的机会。
|
||||||
|
/// </summary>
|
||||||
|
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
|
||||||
|
[SettingsPageInfo(
|
||||||
|
"classhelper.classisland.bridge.settings",
|
||||||
|
"班级小助手联动",
|
||||||
|
"\uE71B",
|
||||||
|
"\uE71B",
|
||||||
|
SettingsPageCategory.External)]
|
||||||
|
public partial class BridgeSettingsPage : SettingsPageBase, INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public BridgeSettingsPage(PluginSettings settings, BridgeService bridge)
|
||||||
|
{
|
||||||
|
Settings = settings;
|
||||||
|
Bridge = bridge;
|
||||||
|
|
||||||
|
ReportCommand = new AsyncRelayCommand(ReportAsync);
|
||||||
|
TestCommand = new AsyncRelayCommand(TestAsync);
|
||||||
|
PullCommand = new AsyncRelayCommand(PullAsync);
|
||||||
|
MirrorCommand = new AsyncRelayCommand(MirrorAsync);
|
||||||
|
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = this;
|
||||||
|
Raise(nameof(StatusText));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginSettings Settings { get; }
|
||||||
|
|
||||||
|
public BridgeService Bridge { get; }
|
||||||
|
|
||||||
|
public IAsyncRelayCommand ReportCommand { get; }
|
||||||
|
public IAsyncRelayCommand TestCommand { get; }
|
||||||
|
public IAsyncRelayCommand PullCommand { get; }
|
||||||
|
public IAsyncRelayCommand MirrorCommand { get; }
|
||||||
|
|
||||||
|
private PropertyChangedEventHandler? _propertyChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 显式实现 <see cref="INotifyPropertyChanged"/>:<c>AvaloniaObject</c> 自带一个同名但类型不同的事件,
|
||||||
|
/// 直接声明会遮蔽基类成员。显式实现既不影响 Avalonia 的绑定查找(它按接口取),也不会有歧义。
|
||||||
|
/// </summary>
|
||||||
|
event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
|
||||||
|
{
|
||||||
|
add => _propertyChanged += value;
|
||||||
|
remove => _propertyChanged -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 绑定用的展示文本 */
|
||||||
|
|
||||||
|
/// <summary>一句话结论:还没配 / 已配置 / 已连接 / 连接异常。</summary>
|
||||||
|
public string StatusText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!Settings.IsConfigured) return "尚未配置:请填写服务器地址与设备令牌";
|
||||||
|
if (!Bridge.IsAttached) return "已填写配置,等待 ClassIsland 课程服务就绪";
|
||||||
|
return Bridge.LastReportOk ? "已连接" : "连接异常";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DetailText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var week = Bridge.LastServerWeek?.ToString() ?? "—";
|
||||||
|
return $"最后上报:{Bridge.LastReportAtText} 教学周:{week} " +
|
||||||
|
$"课表 {Bridge.LastScheduleEntryCount} 节 / 节次 {Bridge.LastTimeLayoutItemCount} 条 " +
|
||||||
|
$"已弹出提醒:{Bridge.NotificationCount} 条";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>调试信息(设置页里打开"显示调试信息"才可见)。</summary>
|
||||||
|
public string DebugText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
$"服务器地址:{(string.IsNullOrWhiteSpace(Settings.ServerUrl) ? "(未填)" : Settings.ServerUrl)}",
|
||||||
|
$"设备令牌:{(string.IsNullOrWhiteSpace(Settings.DeviceToken) ? "(未填)" : Mask(Settings.DeviceToken))}",
|
||||||
|
$"设备标识:{(string.IsNullOrWhiteSpace(Settings.DeviceKey) ? "(未生成)" : Settings.DeviceKey)}",
|
||||||
|
$"课程服务:{(Bridge.IsAttached ? "已接入" : "未接入")}",
|
||||||
|
$"与服务端时钟差:{Bridge.ClientTimeOffsetText}",
|
||||||
|
$"ClassIsland 版本:{SafeVersion()}",
|
||||||
|
$"配置目录:{PluginConfigFolder}",
|
||||||
|
};
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>设置页所在的插件配置目录(从插件入口拿;拿不到时留空)。</summary>
|
||||||
|
private string PluginConfigFolder =>
|
||||||
|
ClassIsland.Shared.IAppHost.TryGetService<ClassIsland.Core.Abstractions.PluginBase>()?.PluginConfigFolder ?? "—";
|
||||||
|
|
||||||
|
private static string SafeVersion()
|
||||||
|
{
|
||||||
|
try { return ClassIsland.Core.AppBase.AppVersion ?? "—"; }
|
||||||
|
catch { return "—"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>令牌只露头尾,避免设置页截图时把可用凭证泄露出去。</summary>
|
||||||
|
private static string Mask(string token) =>
|
||||||
|
token.Length <= 14 ? token : $"{token[..12]}…{token[^4..]}";
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 交互 */
|
||||||
|
|
||||||
|
private async Task ReportAsync()
|
||||||
|
{
|
||||||
|
await Bridge.ReportAsync("手动").ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>不带课表上报一次:用来快速验证"地址 + 令牌"是否可用,不会因为课表为空而出错。</summary>
|
||||||
|
private async Task TestAsync()
|
||||||
|
{
|
||||||
|
await Bridge.ReportAsync("连接检查", includeSchedule: false).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PullAsync()
|
||||||
|
{
|
||||||
|
await Bridge.PullPendingAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MirrorAsync()
|
||||||
|
{
|
||||||
|
await Bridge.MirrorScheduleAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs args)
|
||||||
|
{
|
||||||
|
// 影响上报链路的改动需要立刻重建定时器;纯界面项(如"显示调试信息")不用管
|
||||||
|
if (args.PropertyName is nameof(PluginSettings.ServerUrl)
|
||||||
|
or nameof(PluginSettings.DeviceToken)
|
||||||
|
or nameof(PluginSettings.AutoReport)
|
||||||
|
or nameof(PluginSettings.ReportIntervalSeconds)
|
||||||
|
or nameof(PluginSettings.ReceiveNotifications)
|
||||||
|
or nameof(PluginSettings.NotificationPollSeconds))
|
||||||
|
{
|
||||||
|
Bridge.RestartTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Raise(nameof(StatusText));
|
||||||
|
Raise(nameof(DebugText));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBridgeChanged(object? sender, PropertyChangedEventArgs args)
|
||||||
|
{
|
||||||
|
// Bridge 的属性变化可能来自线程池(HttpClient 回调),绑定必须在 UI 线程上更新
|
||||||
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
{
|
||||||
|
RaiseStateProperties();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(RaiseStateProperties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseStateProperties()
|
||||||
|
{
|
||||||
|
Raise(nameof(StatusText));
|
||||||
|
Raise(nameof(DetailText));
|
||||||
|
Raise(nameof(DebugText));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLoaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||||
|
{
|
||||||
|
// 改完地址/令牌/间隔要重建定时器,否则"改了设置但上报节奏还是旧的"
|
||||||
|
Settings.PropertyChanged += OnSettingsChanged;
|
||||||
|
// 运行状态(上一次上报结果 / 待弹出提醒数)变了要刷新状态卡片
|
||||||
|
Bridge.PropertyChanged += OnBridgeChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUnloaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||||
|
{
|
||||||
|
Settings.PropertyChanged -= OnSettingsChanged;
|
||||||
|
Bridge.PropertyChanged -= OnBridgeChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Raise([CallerMemberName] string? propertyName = null) =>
|
||||||
|
_propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 264 KiB |
@@ -26,9 +26,15 @@ if (missing.length > 0) {
|
|||||||
|
|
||||||
console.log('[smoke] 启动 Electron 冒烟验证...\n');
|
console.log('[smoke] 启动 Electron 冒烟验证...\n');
|
||||||
|
|
||||||
// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)
|
// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)。
|
||||||
const profileDir = path.join(repoRoot, '.cache', 'desktop-smoke-profile');
|
// 目录按进程号区分:上一次冒烟若被强杀,目录可能仍被句柄占用(rmSync 会 ENOTEMPTY),
|
||||||
fs.rmSync(profileDir, { recursive: true, force: true });
|
// 用独立目录可以彻底避免互相干扰。
|
||||||
|
const profileDir = path.join(repoRoot, '.cache', `desktop-smoke-profile-${process.pid}`);
|
||||||
|
try {
|
||||||
|
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[smoke] 复用/清理配置目录失败(忽略):${error?.message ?? error}`);
|
||||||
|
}
|
||||||
console.log(`[smoke] 独立配置目录:${profileDir}`);
|
console.log(`[smoke] 独立配置目录:${profileDir}`);
|
||||||
|
|
||||||
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
|
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
|
||||||
@@ -51,6 +57,109 @@ if (online === undefined) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 准备一个已知可用的班级账号(学生端已改为班级码 + 班级密码登录):
|
||||||
|
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
|
||||||
|
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
|
||||||
|
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
|
||||||
|
*/
|
||||||
|
async function provisionClassAccount() {
|
||||||
|
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
|
||||||
|
const username = process.env.ELECTRON_SMOKE_ADMIN ?? 'admin';
|
||||||
|
const adminPassword = process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
|
||||||
|
const password = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456';
|
||||||
|
const CANDIDATES = [...new Set([password, process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456'])];
|
||||||
|
const tryLogin = async (code, candidate) => {
|
||||||
|
const response = await fetch(`${base}/auth/class-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, password: candidate }),
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const loginResponse = await fetch(`${base}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password: adminPassword }),
|
||||||
|
});
|
||||||
|
const login = await loginResponse.json();
|
||||||
|
const token = login?.data?.token;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||||
|
const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders });
|
||||||
|
const classes = (await classesResponse.json())?.data ?? [];
|
||||||
|
const target = classes[0];
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
// 冒烟**永不修改班级码**(班级码是给学生用的,不应被测试工具改写):
|
||||||
|
// 只有"现有班级码 + 已知密码全部登录失败"时才重置密码,班级码原样保留。
|
||||||
|
const code = target.code;
|
||||||
|
if (!code) return null;
|
||||||
|
|
||||||
|
for (const candidate of CANDIDATES) {
|
||||||
|
if (await tryLogin(code, candidate)) return { code, password: candidate, className: target.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchResponse = await fetch(`${base}/classes/${target.id}/class-account`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: authHeaders,
|
||||||
|
body: JSON.stringify({ code, password }),
|
||||||
|
});
|
||||||
|
if (!patchResponse.ok) return null;
|
||||||
|
|
||||||
|
const patched = (await patchResponse.json())?.data;
|
||||||
|
// 记下"需要恢复",脚本收尾时改回种子默认密码(已知密码都不通时无法得知原密码)
|
||||||
|
patchedAccount = { id: target.id, code: patched?.code ?? code, base, authHeaders };
|
||||||
|
console.log(
|
||||||
|
`[smoke] 已知密码均不可用,已把班级 ${target.name} 的密码临时重置为 ${password}` +
|
||||||
|
`(结束时恢复为 ${CANDIDATES[1]})`,
|
||||||
|
);
|
||||||
|
return { code: patched?.code ?? code, password, className: target.name };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冒烟临时重置过的班级账号(结束时恢复) */
|
||||||
|
let patchedAccount = null;
|
||||||
|
|
||||||
|
/** 恢复被冒烟临时改过的班级密码(失败只告警,不影响冒烟结论) */
|
||||||
|
async function restoreClassAccount() {
|
||||||
|
if (!patchedAccount) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${patchedAccount.base}/classes/${patchedAccount.id}/class-account`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: patchedAccount.authHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: patchedAccount.code,
|
||||||
|
password: process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
response.ok
|
||||||
|
? '[smoke] 已恢复班级密码为种子默认 123456(冒烟不留副作用)'
|
||||||
|
: `[smoke] [WARN] 班级密码恢复失败:status=${response.status},请到后台重新设置`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[smoke] [WARN] 班级密码恢复失败:${error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let classCredentials = null;
|
||||||
|
if (online === '1') {
|
||||||
|
classCredentials = await provisionClassAccount();
|
||||||
|
console.log(
|
||||||
|
classCredentials
|
||||||
|
? `[smoke] 班级账号就绪:${classCredentials.code}(${classCredentials.className})`
|
||||||
|
: '[smoke] 未能准备班级账号,联网集成将使用种子默认凭据',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动一次冒烟(返回退出码) */
|
||||||
|
function launchSmoke(extraEnv) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
const child = spawn(electronPath, ['.'], {
|
const child = spawn(electronPath, ['.'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
@@ -58,10 +167,65 @@ const child = spawn(electronPath, ['.'], {
|
|||||||
ELECTRON_SMOKE_TEST: '1',
|
ELECTRON_SMOKE_TEST: '1',
|
||||||
ELECTRON_SMOKE_ONLINE: online,
|
ELECTRON_SMOKE_ONLINE: online,
|
||||||
ELECTRON_SMOKE_PROFILE: profileDir,
|
ELECTRON_SMOKE_PROFILE: profileDir,
|
||||||
|
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
|
||||||
|
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
|
||||||
|
...extraEnv,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
child.on('exit', (code) => resolve(code ?? 1));
|
||||||
child.on('exit', (code) => {
|
|
||||||
console.log(`\n[smoke] Electron 退出,code=${code ?? 1}`);
|
|
||||||
process.exit(code ?? 1);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await launchSmoke();
|
||||||
|
console.log(`\n[smoke] Electron 退出,code=${code}`);
|
||||||
|
await restoreClassAccount();
|
||||||
|
if (code !== 0) process.exit(code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需求 5 的"退出不留残留"实测(在进程真正退出之后做,才有意义):
|
||||||
|
* 1) 配置目录(含 Chromium 的 SingletonLock / IndexedDB 文件锁)必须能被删除
|
||||||
|
* —— 删不掉说明还有句柄被占用,即存在残留进程;
|
||||||
|
* 2) 用同一个配置目录再启动一次:单实例锁与端口/句柄都已释放才会真正跑起来
|
||||||
|
* (若还有残留进程占着锁,第二次启动会静默退出且不产出结果文件)。
|
||||||
|
*/
|
||||||
|
let lockReleased = true;
|
||||||
|
try {
|
||||||
|
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
lockReleased = false;
|
||||||
|
console.error(`[smoke] [FAIL] 退出后配置文件锁未释放:${error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relaunchResult = path.join(repoRoot, '.cache', `desktop-smoke-relaunch-${process.pid}.json`);
|
||||||
|
try {
|
||||||
|
fs.rmSync(relaunchResult, { force: true });
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
|
||||||
|
const relaunchCode = await launchSmoke({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: relaunchResult });
|
||||||
|
|
||||||
|
let relaunchPassed = 0;
|
||||||
|
let relaunchTotal = 0;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fs.readFileSync(relaunchResult, 'utf8'));
|
||||||
|
relaunchPassed = payload.passed ?? 0;
|
||||||
|
relaunchTotal = payload.total ?? 0;
|
||||||
|
} catch {
|
||||||
|
relaunchPassed = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourcesOk =
|
||||||
|
lockReleased && relaunchCode === 0 && relaunchTotal > 0 && relaunchPassed === relaunchTotal;
|
||||||
|
console.log(
|
||||||
|
resourcesOk
|
||||||
|
? `[smoke] [PASS] 退出后无残留:配置文件锁已释放,二次启动成功(${relaunchPassed}/${relaunchTotal})`
|
||||||
|
: `[smoke] [FAIL] 退出后可能仍有残留:文件锁释放=${lockReleased} 二次启动 code=${relaunchCode} 结果=${relaunchPassed}/${relaunchTotal}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
|
||||||
|
} catch {
|
||||||
|
// 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放)
|
||||||
|
}
|
||||||
|
process.exit(resourcesOk ? 0 : 1);
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* WinIsland 同款弹簧(照搬 `src/utils/physics.rs` 与 `src/window/app/frame.rs` 的参数与更新式)。
|
||||||
|
*
|
||||||
|
* 原始实现(Rust,每帧调用一次,`dt` 以**帧**为单位):
|
||||||
|
* ```text
|
||||||
|
* force = (target - value) * stiffness * dt
|
||||||
|
* velocity = (velocity + force) * damping^dt
|
||||||
|
* value += velocity * dt
|
||||||
|
* ```
|
||||||
|
* 主岛尺寸用的参数:`stiffness = 0.10`、`damping = 0.68`(阻尼比 ζ≈0.977,接近临界阻尼,
|
||||||
|
* 理论过冲 ~1e-6,肉眼不可见)。换向时还有两个限制(`redirect_velocity_towards`):
|
||||||
|
* 反向动量只保留 35%、单帧位移不超过剩余距离的 20%。
|
||||||
|
*
|
||||||
|
* 我们在此基础上**额外加了零过冲钳制**(越过目标立刻吸附并清零速度):
|
||||||
|
* 即便某帧卡顿导致 dt 变大,也绝不会出现"超过目标再弹回来"的震动。
|
||||||
|
*/
|
||||||
|
export interface SpringOptions {
|
||||||
|
/** 刚度(WinIsland 主岛尺寸:0.10) */
|
||||||
|
stiffness?: number;
|
||||||
|
/** 速度保留率(WinIsland 主岛尺寸:0.68) */
|
||||||
|
damping?: number;
|
||||||
|
/** 吸附阈值(px) */
|
||||||
|
snapThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOMENTUM_RETENTION = 0.35;
|
||||||
|
const MAX_DISTANCE_RATIO = 0.2;
|
||||||
|
const MAX_DT = 6;
|
||||||
|
/** 一帧按 60fps 折算(与 WinIsland 的 dt 单位一致) */
|
||||||
|
const FRAME_MS = 1000 / 60;
|
||||||
|
|
||||||
|
export class SpringValue {
|
||||||
|
value: number;
|
||||||
|
velocity = 0;
|
||||||
|
target: number;
|
||||||
|
|
||||||
|
private readonly stiffness: number;
|
||||||
|
private readonly damping: number;
|
||||||
|
private readonly snapThreshold: number;
|
||||||
|
|
||||||
|
constructor(initial: number, options: SpringOptions = {}) {
|
||||||
|
this.value = initial;
|
||||||
|
this.target = initial;
|
||||||
|
this.stiffness = options.stiffness ?? 0.1;
|
||||||
|
this.damping = options.damping ?? 0.68;
|
||||||
|
this.snapThreshold = options.snapThreshold ?? 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTarget(target: number): void {
|
||||||
|
if (target === this.target) return;
|
||||||
|
this.redirectVelocity(target);
|
||||||
|
this.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 直接落位(关闭动画 / 首次同步时使用) */
|
||||||
|
snap(target: number): void {
|
||||||
|
this.target = target;
|
||||||
|
this.value = target;
|
||||||
|
this.velocity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get settled(): boolean {
|
||||||
|
return Math.abs(this.target - this.value) <= this.snapThreshold && Math.abs(this.velocity) <= 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 换向限速(WinIsland):反向时只保留 35% 动量,并把速度幅值限制在"剩余距离的 20%"以内,
|
||||||
|
* 保证不会因为方向切换产生突跳。
|
||||||
|
*/
|
||||||
|
private redirectVelocity(target: number): void {
|
||||||
|
const distance = target - this.value;
|
||||||
|
if (distance === 0) {
|
||||||
|
this.velocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sameDirection = Math.sign(distance) === Math.sign(this.velocity);
|
||||||
|
if (!sameDirection) this.velocity *= MOMENTUM_RETENTION;
|
||||||
|
const maxVelocity = Math.abs(distance) * MAX_DISTANCE_RATIO;
|
||||||
|
if (Math.abs(this.velocity) > maxVelocity) {
|
||||||
|
this.velocity = Math.sign(this.velocity) * maxVelocity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推进一帧;返回是否仍在运动 */
|
||||||
|
step(deltaMs: number): boolean {
|
||||||
|
const dt = Math.min(MAX_DT, Math.max(0.1, deltaMs / FRAME_MS));
|
||||||
|
const distance = this.target - this.value;
|
||||||
|
if (distance === 0 && Math.abs(this.velocity) <= 0.01) {
|
||||||
|
this.velocity = 0;
|
||||||
|
this.value = this.target;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const force = distance * this.stiffness * dt;
|
||||||
|
this.velocity = (this.velocity + force) * Math.pow(this.damping, dt);
|
||||||
|
|
||||||
|
const previous = this.value;
|
||||||
|
this.value += this.velocity * dt;
|
||||||
|
|
||||||
|
// 零过冲钳制:越过目标(或已足够接近)就吸附到目标并停止
|
||||||
|
const crossed = (this.target - previous) * (this.target - this.value) <= 0;
|
||||||
|
if (crossed || Math.abs(this.target - this.value) <= this.snapThreshold) {
|
||||||
|
this.value = this.target;
|
||||||
|
this.velocity = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(this.value) || !Number.isFinite(this.velocity)) {
|
||||||
|
this.value = this.target;
|
||||||
|
this.velocity = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 连续圆角(squircle / Apple 连续曲率圆角)路径生成。
|
||||||
|
*
|
||||||
|
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表构造
|
||||||
|
* `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡半径取
|
||||||
|
* `expanded_island_radius = min(48 * scale, w/2, h/2)`。
|
||||||
|
*
|
||||||
|
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
|
||||||
|
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
|
||||||
|
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
|
||||||
|
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、没有方向歧义。
|
||||||
|
*
|
||||||
|
* 早期版本用「相对角起点的偏移向量」拼角,而四个角需要的偏移向量**并不相同**
|
||||||
|
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`)——
|
||||||
|
* 于是左上与右下被画成了向内凹的形状,视觉上就是这两个角**缺一块**。
|
||||||
|
*
|
||||||
|
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),因此圆角半径恒定、不被拉伸。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 超椭圆指数:2 = 标准圆弧圆角,越大越"方"但曲率越连续(Apple 观感约 4~5) */
|
||||||
|
const SUPERELLIPSE_EXPONENT = 4.2;
|
||||||
|
|
||||||
|
/** 每个角的采样段数(越多越平滑;4 个角共 4x 段) */
|
||||||
|
const CORNER_SEGMENTS = 14;
|
||||||
|
|
||||||
|
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成连续圆角矩形路径。
|
||||||
|
*
|
||||||
|
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
|
||||||
|
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
|
||||||
|
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
|
||||||
|
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、不会出现方向歧义。
|
||||||
|
*
|
||||||
|
* 早期版本用「相对起点的偏移向量」拼角,而四个角需要的偏移向量并不相同
|
||||||
|
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`),
|
||||||
|
* 于是左上与右下被画成了"向内凹"的形状——视觉上就是这两个角**缺一块**。
|
||||||
|
*
|
||||||
|
* @param width 卡片宽度(px)
|
||||||
|
* @param height 卡片高度(px)
|
||||||
|
* @param radius 四角基准半径(px,会被裁剪到 min(w/2, h/2))
|
||||||
|
*/
|
||||||
|
export function squirclePath(width: number, height: number, radius: number): string {
|
||||||
|
const w = Math.max(1, Math.round(width * 100) / 100);
|
||||||
|
const h = Math.max(1, Math.round(height * 100) / 100);
|
||||||
|
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
|
||||||
|
|
||||||
|
if (r <= 0.5) return `M0,0 H${round(w)} V${round(h)} H0 Z`;
|
||||||
|
|
||||||
|
const p = 2 / SUPERELLIPSE_EXPONENT;
|
||||||
|
const centers = [
|
||||||
|
{ cx: w - r, cy: r }, // 右上
|
||||||
|
{ cx: w - r, cy: h - r }, // 右下
|
||||||
|
{ cx: r, cy: h - r }, // 左下
|
||||||
|
{ cx: r, cy: r }, // 左上
|
||||||
|
];
|
||||||
|
const startAngles = [-Math.PI / 2, 0, Math.PI / 2, Math.PI];
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let corner = 0; corner < 4; corner += 1) {
|
||||||
|
const center = centers[corner] as { cx: number; cy: number };
|
||||||
|
const startAngle = startAngles[corner] as number;
|
||||||
|
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
|
||||||
|
const angle = startAngle + (index / CORNER_SEGMENTS) * (Math.PI / 2);
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
const px = center.cx + r * Math.sign(cos) * Math.pow(Math.abs(cos), p);
|
||||||
|
const py = center.cy + r * Math.sign(sin) * Math.pow(Math.abs(sin), p);
|
||||||
|
parts.push(`${index === 0 && corner === 0 ? 'M' : 'L'}${round(px)},${round(py)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push('Z');
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展开卡半径(与 WinIsland 的 expanded_island_radius 同构:min(48*scale, w/2, h/2)) */
|
||||||
|
export function expandedRadius(width: number, height: number, radius: number): number {
|
||||||
|
return Math.max(0, Math.min(Math.max(radius, 24), width / 2, height / 2));
|
||||||
|
}
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { app, safeStorage } from 'electron';
|
import { app, safeStorage } from 'electron';
|
||||||
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
|
import {
|
||||||
import type { DesktopStoredConfig } from '../types/desktop.js';
|
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||||
|
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||||
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
|
DEFAULT_SERVER_URL,
|
||||||
|
HOMEWORK_PHRASE_DEFAULTS,
|
||||||
|
HOMEWORK_PHRASE_MAX_COUNT,
|
||||||
|
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||||
|
type ClassIslandNotificationChannel,
|
||||||
|
type IslandAppearance,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
|
||||||
|
|
||||||
|
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
|
||||||
|
const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
|
||||||
|
mode: 'board',
|
||||||
|
showTime: false,
|
||||||
|
fontSize: 15,
|
||||||
|
};
|
||||||
|
|
||||||
interface PersistedConfig {
|
interface PersistedConfig {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -10,6 +27,14 @@ interface PersistedConfig {
|
|||||||
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
|
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
|
||||||
token: string | null;
|
token: string | null;
|
||||||
tokenEncrypted: boolean;
|
tokenEncrypted: boolean;
|
||||||
|
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
||||||
|
island?: Partial<IslandAppearance>;
|
||||||
|
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
||||||
|
homeworkBoard?: Partial<HomeworkBoardSettings>;
|
||||||
|
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
|
||||||
|
notificationChannel?: ClassIslandNotificationChannel;
|
||||||
|
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
|
||||||
|
homeworkPhrases?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: PersistedConfig = {
|
const DEFAULT_CONFIG: PersistedConfig = {
|
||||||
@@ -17,8 +42,95 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
|||||||
username: '',
|
username: '',
|
||||||
token: null,
|
token: null,
|
||||||
tokenEncrypted: false,
|
tokenEncrypted: false,
|
||||||
|
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
||||||
|
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
||||||
|
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||||
|
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作业快捷短语的合法化:去空白、去重、限长限量。
|
||||||
|
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
|
||||||
|
*/
|
||||||
|
function normalizeHomeworkPhrases(input?: string[]): string[] {
|
||||||
|
if (!Array.isArray(input)) return [...HOMEWORK_PHRASE_DEFAULTS];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const raw of input) {
|
||||||
|
if (typeof raw !== 'string') continue;
|
||||||
|
const value = raw.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
|
||||||
|
if (!value || seen.has(value)) continue;
|
||||||
|
seen.add(value);
|
||||||
|
result.push(value);
|
||||||
|
if (result.length >= HOMEWORK_PHRASE_MAX_COUNT) break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
|
||||||
|
function normalizeNotificationChannel(
|
||||||
|
input?: ClassIslandNotificationChannel,
|
||||||
|
): ClassIslandNotificationChannel {
|
||||||
|
return CLASSISLAND_NOTIFICATION_CHANNELS.includes(input as ClassIslandNotificationChannel)
|
||||||
|
? (input as ClassIslandNotificationChannel)
|
||||||
|
: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
|
||||||
|
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
|
||||||
|
const mode = input?.mode === 'list' ? 'list' : 'board';
|
||||||
|
const rawFont =
|
||||||
|
typeof input?.fontSize === 'number' && Number.isFinite(input.fontSize)
|
||||||
|
? input.fontSize
|
||||||
|
: DEFAULT_HOMEWORK_BOARD.fontSize;
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
showTime: input?.showTime === true,
|
||||||
|
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 外观字段的合法区间校验(越界值直接夹紧,避免用户配置破坏灵动岛布局) */
|
||||||
|
function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandAppearance {
|
||||||
|
const clamp = (value: unknown, min: number, max: number, fallback: number): number => {
|
||||||
|
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(numeric * 100) / 100));
|
||||||
|
};
|
||||||
|
const positions: IslandAppearance['position'][] = [
|
||||||
|
'top-center',
|
||||||
|
'top-left',
|
||||||
|
'top-right',
|
||||||
|
'bottom-center',
|
||||||
|
'bottom-left',
|
||||||
|
'bottom-right',
|
||||||
|
];
|
||||||
|
const styles: IslandAppearance['style'][] = ['black', 'glass', 'tinted'];
|
||||||
|
return {
|
||||||
|
height: clamp(input?.height, 36, 72, DEFAULT_ISLAND_APPEARANCE.height),
|
||||||
|
width: clamp(input?.width, 220, 420, DEFAULT_ISLAND_APPEARANCE.width),
|
||||||
|
radius: clamp(input?.radius, 8, 32, DEFAULT_ISLAND_APPEARANCE.radius),
|
||||||
|
opacity: clamp(input?.opacity, 0.4, 1, DEFAULT_ISLAND_APPEARANCE.opacity),
|
||||||
|
accent:
|
||||||
|
typeof input?.accent === 'string' && /^#[0-9a-fA-F]{6}$/.test(input.accent)
|
||||||
|
? input.accent
|
||||||
|
: DEFAULT_ISLAND_APPEARANCE.accent,
|
||||||
|
fontSize: clamp(input?.fontSize, 11, 20, DEFAULT_ISLAND_APPEARANCE.fontSize),
|
||||||
|
animations: input?.animations !== false,
|
||||||
|
speed: clamp(input?.speed, 0.5, 2, DEFAULT_ISLAND_APPEARANCE.speed),
|
||||||
|
position: positions.includes(input?.position as IslandAppearance['position'])
|
||||||
|
? (input?.position as IslandAppearance['position'])
|
||||||
|
: DEFAULT_ISLAND_APPEARANCE.position,
|
||||||
|
alwaysOnTop: input?.alwaysOnTop !== false,
|
||||||
|
style: styles.includes(input?.style as IslandAppearance['style'])
|
||||||
|
? (input?.style as IslandAppearance['style'])
|
||||||
|
: DEFAULT_ISLAND_APPEARANCE.style,
|
||||||
|
idleSliver: input?.idleSliver === true,
|
||||||
|
marginX: clamp(input?.marginX, 0, 200, DEFAULT_ISLAND_APPEARANCE.marginX),
|
||||||
|
marginY: clamp(input?.marginY, 0, 160, DEFAULT_ISLAND_APPEARANCE.marginY),
|
||||||
|
followCursorDisplay: input?.followCursorDisplay === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function configFilePath(): string {
|
function configFilePath(): string {
|
||||||
return path.join(app.getPath('userData'), 'config.json');
|
return path.join(app.getPath('userData'), 'config.json');
|
||||||
}
|
}
|
||||||
@@ -57,6 +169,10 @@ function readPersisted(): PersistedConfig {
|
|||||||
username: typeof parsed.username === 'string' ? parsed.username : '',
|
username: typeof parsed.username === 'string' ? parsed.username : '',
|
||||||
token: typeof parsed.token === 'string' ? parsed.token : null,
|
token: typeof parsed.token === 'string' ? parsed.token : null,
|
||||||
tokenEncrypted: parsed.tokenEncrypted === true,
|
tokenEncrypted: parsed.tokenEncrypted === true,
|
||||||
|
island: normalizeIslandAppearance(parsed.island),
|
||||||
|
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
||||||
|
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
||||||
|
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_CONFIG };
|
return { ...DEFAULT_CONFIG };
|
||||||
@@ -76,6 +192,10 @@ export function getConfig(): DesktopStoredConfig {
|
|||||||
serverUrl: persisted.serverUrl,
|
serverUrl: persisted.serverUrl,
|
||||||
username: persisted.username,
|
username: persisted.username,
|
||||||
token: decryptToken(persisted),
|
token: decryptToken(persisted),
|
||||||
|
island: normalizeIslandAppearance(persisted.island),
|
||||||
|
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
||||||
|
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
||||||
|
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +209,21 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
|||||||
if (typeof patch.username === 'string') {
|
if (typeof patch.username === 'string') {
|
||||||
persisted.username = patch.username;
|
persisted.username = patch.username;
|
||||||
}
|
}
|
||||||
|
if (patch.island !== undefined) {
|
||||||
|
persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island });
|
||||||
|
}
|
||||||
|
if (patch.homeworkBoard !== undefined) {
|
||||||
|
persisted.homeworkBoard = normalizeHomeworkBoard({
|
||||||
|
...persisted.homeworkBoard,
|
||||||
|
...patch.homeworkBoard,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (patch.homeworkPhrases !== undefined) {
|
||||||
|
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
|
||||||
|
}
|
||||||
|
if (patch.notificationChannel !== undefined) {
|
||||||
|
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
||||||
|
}
|
||||||
if (patch.token !== undefined) {
|
if (patch.token !== undefined) {
|
||||||
const encrypted = encryptToken(patch.token);
|
const encrypted = encryptToken(patch.token);
|
||||||
persisted.token = encrypted.token;
|
persisted.token = encrypted.token;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { registerIpcHandlers } from './ipc.js';
|
|||||||
import { island, registerIslandIpc } from './island.js';
|
import { island, registerIslandIpc } from './island.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { runSmokeTest } from './smoke.js';
|
import { runSmokeTest } from './smoke.js';
|
||||||
|
import { createTray, destroyTray, isTrayReady } from './tray.js';
|
||||||
|
|
||||||
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
|
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
|
||||||
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
|
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
|
||||||
@@ -19,6 +20,9 @@ if (smokeProfile) app.setPath('userData', smokeProfile);
|
|||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
/** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */
|
||||||
|
let isQuitting = false;
|
||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
@@ -97,10 +101,34 @@ if (!gotLock) {
|
|||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
});
|
});
|
||||||
|
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
|
||||||
|
island.setMarkReadHandler((id) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
mainWindow.webContents.send('island:mark-read', id);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('灵动岛初始化失败(不影响主功能)', error);
|
logger.error('灵动岛初始化失败(不影响主功能)', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭主窗口 → 隐藏到系统托盘(需求 5),只有托盘"退出"才真正结束进程
|
||||||
|
// 注意:必须在冒烟验证之前挂载,否则 close() 会真的销毁窗口
|
||||||
|
mainWindow?.on('close', (event) => {
|
||||||
|
if (isQuitting) return;
|
||||||
|
event.preventDefault();
|
||||||
|
mainWindow?.hide();
|
||||||
|
logger.info('主窗口已隐藏到系统托盘(后台继续运行,左键托盘图标可恢复)');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
createTray({
|
||||||
|
onShow: () => showMainWindow(),
|
||||||
|
onHide: () => mainWindow?.hide(),
|
||||||
|
onQuit: () => quitApp(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (isSmokeTest) {
|
if (isSmokeTest) {
|
||||||
try {
|
try {
|
||||||
await runSmokeTest(mainWindow);
|
await runSmokeTest(mainWindow);
|
||||||
@@ -111,13 +139,63 @@ if (!gotLock) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
|
||||||
|
function showMainWindow(): void {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
mainWindow = createWindow();
|
||||||
|
void loadRenderer(mainWindow);
|
||||||
|
}
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出前的统一资源清理(需求 5:托盘 / 定时器 / 网络连接 / 窗口都不留残留)。
|
||||||
|
* 幂等:托盘退出、before-quit、will-quit 都可能触发。
|
||||||
|
*/
|
||||||
|
function shutdownResources(): string {
|
||||||
|
const done: string[] = [];
|
||||||
|
if (isTrayReady()) {
|
||||||
|
destroyTray();
|
||||||
|
done.push('托盘已销毁');
|
||||||
|
}
|
||||||
|
if (island.isReady()) {
|
||||||
island.destroy();
|
island.destroy();
|
||||||
if (process.platform !== 'darwin') app.quit();
|
done.push('灵动岛已销毁');
|
||||||
|
}
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!win.isDestroyed()) {
|
||||||
|
// 渲染进程随之结束:其中的 Socket.IO 连接、定时器、IndexedDB 句柄一并释放
|
||||||
|
win.destroy();
|
||||||
|
done.push('窗口已销毁');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return done.length > 0 ? done.join('、') : '无需要清理的资源';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真正退出:先清理资源,再 app.quit,并用 app.exit 兜底(确保不留进程) */
|
||||||
|
function quitApp(): void {
|
||||||
|
if (isQuitting) return;
|
||||||
|
isQuitting = true;
|
||||||
|
logger.info(`正在退出:${shutdownResources()}`);
|
||||||
|
app.quit();
|
||||||
|
setTimeout(() => app.exit(0), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// 有托盘常驻:窗口全关也不退出,继续后台接收通知
|
||||||
|
logger.info('所有窗口已关闭,程序继续在系统托盘后台运行');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
island.destroy();
|
isQuitting = true;
|
||||||
|
shutdownResources();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
const summary = shutdownResources();
|
||||||
|
logger.info(`应用退出清理:${summary}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('activate', async () => {
|
app.on('activate', async () => {
|
||||||
|
|||||||
@@ -1,80 +1,25 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { app } from 'electron';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主进程日志。
|
* 主进程日志(控制台输出)。
|
||||||
*
|
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
|
||||||
* 打包后的 GUI 进程没有可见的控制台,所以除了 console 输出,
|
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
|
||||||
* 关键信息还会追加写入 `%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();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Menu, Tray, app, nativeImage } from 'electron';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统托盘(需求 5):关闭主窗口不退出程序,而是隐藏到托盘后台运行;
|
||||||
|
* 托盘图标可恢复窗口,并提供"退出"入口(退出前统一清理资源)。
|
||||||
|
*/
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
|
||||||
|
export interface TrayHandlers {
|
||||||
|
onShow(): void;
|
||||||
|
onHide(): void;
|
||||||
|
onQuit(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 托盘图标:优先用打包进 dist/renderer 的 tray.png,其次资源目录 */
|
||||||
|
function resolveTrayIcon(): string | null {
|
||||||
|
const candidates = [
|
||||||
|
path.join(__dirname, '../renderer/tray.png'),
|
||||||
|
path.join(process.resourcesPath ?? '', 'tray.png'),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate && fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTray(handlers: TrayHandlers): Tray | null {
|
||||||
|
if (tray && !tray.isDestroyed()) return tray;
|
||||||
|
|
||||||
|
const iconPath = resolveTrayIcon();
|
||||||
|
const image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
|
||||||
|
tray = new Tray(image);
|
||||||
|
|
||||||
|
tray.setToolTip('班级小助手(后台运行中)');
|
||||||
|
tray.setContextMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{ label: '显示主窗口', click: () => handlers.onShow() },
|
||||||
|
{ label: '隐藏到托盘', click: () => handlers.onHide() },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '退出班级小助手', click: () => handlers.onQuit() },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// 左键单击/双击托盘图标:恢复窗口(用户习惯)
|
||||||
|
tray.on('click', () => handlers.onShow());
|
||||||
|
tray.on('double-click', () => handlers.onShow());
|
||||||
|
|
||||||
|
logger.info(iconPath ? `系统托盘已就绪:${iconPath}` : '系统托盘已就绪(未找到图标文件,使用空图标)');
|
||||||
|
return tray;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyTray(): void {
|
||||||
|
if (!tray) return;
|
||||||
|
try {
|
||||||
|
tray.destroy();
|
||||||
|
} catch {
|
||||||
|
/* 忽略销毁异常 */
|
||||||
|
}
|
||||||
|
tray = null;
|
||||||
|
logger.info('系统托盘已销毁');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrayReady(): boolean {
|
||||||
|
return Boolean(tray && !tray.isDestroyed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供"关于/设置"展示(也可用于冒烟断言) */
|
||||||
|
export function getTrayState(): { ready: boolean; hasIcon: boolean; appPath: string } {
|
||||||
|
return {
|
||||||
|
ready: isTrayReady(),
|
||||||
|
hasIcon: Boolean(resolveTrayIcon()),
|
||||||
|
appPath: app.getPath('exe'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import type { IslandNotification } from '@classhelper/shared';
|
|||||||
/**
|
/**
|
||||||
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
|
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
|
||||||
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
|
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
|
||||||
|
*
|
||||||
|
* 注意:contextBridge 在参数跨越"主世界 → 隔离世界"时就做结构化克隆,
|
||||||
|
* 因此**桥接层无法替调用方兜底**:传 Vue 响应式对象(Proxy)会在进入这里的函数体
|
||||||
|
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
|
||||||
*/
|
*/
|
||||||
const bridge: DesktopBridge = {
|
const bridge: DesktopBridge = {
|
||||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||||
@@ -29,6 +33,14 @@ const bridge: DesktopBridge = {
|
|||||||
ipcRenderer.send('island:class-state', payload);
|
ipcRenderer.send('island:class-state', payload);
|
||||||
},
|
},
|
||||||
islandGetState: () => ipcRenderer.invoke('island:get-state'),
|
islandGetState: () => ipcRenderer.invoke('island:get-state'),
|
||||||
|
islandSetAppearance: (appearance): void => {
|
||||||
|
ipcRenderer.send('island:set-appearance', appearance);
|
||||||
|
},
|
||||||
|
islandGetAppearance: () => ipcRenderer.invoke('island:get-appearance'),
|
||||||
|
/** 灵动岛点了"标为已读":主进程转交渲染进程同步通知中心 */
|
||||||
|
onIslandMarkRead: (handler: (id: string) => void): void => {
|
||||||
|
ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('desktop', bridge);
|
contextBridge.exposeInMainWorld('desktop', bridge);
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import type { IslandState } from '@classhelper/shared';
|
import type { IslandAppearance, IslandState } from '@classhelper/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 灵动岛窗口的预加载脚本。
|
* 灵动岛窗口的预加载脚本。
|
||||||
* 只暴露「订阅状态」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
|
* 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
|
||||||
*/
|
*/
|
||||||
export interface IslandBridge {
|
export interface IslandBridge {
|
||||||
onState(handler: (state: IslandState) => void): void;
|
onState(handler: (state: IslandState) => void): void;
|
||||||
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
|
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
|
||||||
getState(): Promise<IslandState>;
|
getState(): Promise<IslandState>;
|
||||||
|
/** 订阅个性化外观(设置页改动后实时生效) */
|
||||||
|
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
||||||
|
getAppearance(): Promise<IslandAppearance>;
|
||||||
|
/** 命中提示:指针在岛上 → 让窗口接收鼠标(只上报 true,关闭由主进程轮询决定) */
|
||||||
|
setInteractive(interactive: boolean): void;
|
||||||
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
|
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
|
||||||
|
alive(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridge: IslandBridge = {
|
const bridge: IslandBridge = {
|
||||||
@@ -19,6 +28,19 @@ const bridge: IslandBridge = {
|
|||||||
ipcRenderer.send('island:action', { action, id });
|
ipcRenderer.send('island:action', { action, id });
|
||||||
},
|
},
|
||||||
getState: () => ipcRenderer.invoke('island:get-state'),
|
getState: () => ipcRenderer.invoke('island:get-state'),
|
||||||
|
onAppearance: (handler) => {
|
||||||
|
ipcRenderer.on('island:appearance', (_event, appearance: IslandAppearance) => handler(appearance));
|
||||||
|
},
|
||||||
|
getAppearance: () => ipcRenderer.invoke('island:get-appearance'),
|
||||||
|
setInteractive: (interactive) => {
|
||||||
|
ipcRenderer.send('island:set-interactive', interactive);
|
||||||
|
},
|
||||||
|
setHitRect: (rect) => {
|
||||||
|
ipcRenderer.send('island:set-hit-rect', rect);
|
||||||
|
},
|
||||||
|
alive: () => {
|
||||||
|
ipcRenderer.send('island:alive');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('island', bridge);
|
contextBridge.exposeInMainWorld('island', bridge);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { startIslandBridge, stopIslandBridge } from './island/bridge.js';
|
import { startIslandBridge, stopIslandBridge, subscribeIslandMarkRead } 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 { useRealtimeStore } from './stores/realtime.js';
|
import { useRealtimeStore } from './stores/realtime.js';
|
||||||
@@ -25,6 +25,14 @@ onMounted(async () => {
|
|||||||
bootText.value = '正在恢复登录状态...';
|
bootText.value = '正在恢复登录状态...';
|
||||||
const session = await auth.restore();
|
const session = await auth.restore();
|
||||||
|
|
||||||
|
// 通知显示位置:先用本地配置兜底(断网也要按上次的选择决定本机弹不弹),
|
||||||
|
// 登录态就绪后再向服务器对齐一次(服务端才是单一事实来源)
|
||||||
|
const storedConfig = await window.desktop?.getConfig?.();
|
||||||
|
if (storedConfig?.notificationChannel) {
|
||||||
|
appStore.applyLocalNotificationChannel(storedConfig.notificationChannel);
|
||||||
|
}
|
||||||
|
void appStore.loadNotificationChannel(auth.classId);
|
||||||
|
|
||||||
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
|
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
|
||||||
void appStore.init(session.serverUrl);
|
void appStore.init(session.serverUrl);
|
||||||
|
|
||||||
@@ -33,6 +41,9 @@ onMounted(async () => {
|
|||||||
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
|
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
|
||||||
if (auth.token) startIslandBridge();
|
if (auth.token) startIslandBridge();
|
||||||
|
|
||||||
|
// 灵动岛"标为已读" → 同步通知中心(未读红点)
|
||||||
|
subscribeIslandMarkRead();
|
||||||
|
|
||||||
if (auth.isAuthenticated) await router.replace('/schedule');
|
if (auth.isAuthenticated) await router.replace('/schedule');
|
||||||
else await router.replace('/login');
|
else await router.replace('/login');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import {
|
|||||||
API_PATHS,
|
API_PATHS,
|
||||||
type ClassDetailDto,
|
type ClassDetailDto,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
|
type ClassIslandNotificationChannel,
|
||||||
|
type CourseDto,
|
||||||
|
type HomeworkDaysDto,
|
||||||
type DashboardSummary,
|
type DashboardSummary,
|
||||||
type GradeDto,
|
type GradeDto,
|
||||||
type HomeworkDto,
|
type HomeworkDto,
|
||||||
type HomeworkStatusDto,
|
type HomeworkStatusDto,
|
||||||
|
type HomeworkSubmissionsDto,
|
||||||
type LoginRequest,
|
type LoginRequest,
|
||||||
type LoginResponse,
|
type LoginResponse,
|
||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
@@ -17,7 +21,11 @@ import { api } from './http.js';
|
|||||||
|
|
||||||
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
|
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
|
/** 个人账号登录(教师/管理员/个人学生;客户端界面已不再展示,保留以兼容旧流程与排障) */
|
||||||
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
|
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
|
||||||
|
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */
|
||||||
|
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
|
||||||
|
api.post('/auth/class-login', payload),
|
||||||
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
|
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
|
||||||
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
|
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
|
||||||
};
|
};
|
||||||
@@ -27,6 +35,36 @@ export const classApi = {
|
|||||||
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
|
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知显示位置(both / client / classisland)。
|
||||||
|
*
|
||||||
|
* 由**这台教室机器自己**决定"提醒弹在 ClassHelper 还是 ClassIsland":
|
||||||
|
* 本地存一份用于本机的弹窗/上岛判断,同时写回班级记录,服务端据此决定要不要推 ClassIsland。
|
||||||
|
*/
|
||||||
|
export const classChannelApi = {
|
||||||
|
get: (classId: string): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
|
||||||
|
api.get(`${API_PATHS.classes}/${classId}/notification-channel`),
|
||||||
|
set: (
|
||||||
|
classId: string,
|
||||||
|
notificationChannel: ClassIslandNotificationChannel,
|
||||||
|
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
|
||||||
|
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 本班 ClassIsland 联动状态(教室客户端「设置 → ClassIsland 联动」展示) */
|
||||||
|
export const classIslandStatusApi = {
|
||||||
|
get: (
|
||||||
|
classId: string,
|
||||||
|
): Promise<{
|
||||||
|
connected: boolean;
|
||||||
|
deviceName: string | null;
|
||||||
|
deviceCount: number;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
pluginVersion: string | null;
|
||||||
|
classIslandVersion: string | null;
|
||||||
|
}> => api.get(`${API_PATHS.classes}/${classId}/classisland-status`),
|
||||||
|
};
|
||||||
|
|
||||||
export const scheduleApi = {
|
export const scheduleApi = {
|
||||||
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
||||||
api.get(API_PATHS.schedules, params),
|
api.get(API_PATHS.schedules, params),
|
||||||
@@ -34,12 +72,38 @@ export const scheduleApi = {
|
|||||||
api.get(`${API_PATHS.schedules}/grid`, params),
|
api.get(`${API_PATHS.schedules}/grid`, params),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const courseApi = {
|
||||||
|
list: (classId?: string): Promise<CourseDto[]> =>
|
||||||
|
api.get(API_PATHS.courses, classId ? { classId } : undefined),
|
||||||
|
};
|
||||||
|
|
||||||
export const homeworkApi = {
|
export const homeworkApi = {
|
||||||
list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise<HomeworkDto[]> =>
|
list: (params?: {
|
||||||
api.get(API_PATHS.homeworks, params),
|
classId?: string;
|
||||||
|
pendingOnly?: boolean;
|
||||||
|
courseId?: string;
|
||||||
|
/** 只看某一天(YYYY-MM-DD,按作业所属日期) */
|
||||||
|
date?: string;
|
||||||
|
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
|
||||||
|
/** 哪些天有作业(日期选择器高亮) */
|
||||||
|
days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise<HomeworkDaysDto> =>
|
||||||
|
api.get(`${API_PATHS.homeworks}/days`, params),
|
||||||
|
/** 在教室机器上直接录入作业(班级账号可调用;也可选所属日期) */
|
||||||
|
create: (payload: {
|
||||||
|
classId: string;
|
||||||
|
courseId?: string | null;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
assignDate?: string | null;
|
||||||
|
}): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
|
||||||
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
||||||
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
|
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
|
||||||
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
|
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
|
||||||
|
/** 提交名单 / 未交名单(教师与班级设备可用) */
|
||||||
|
submissions: (id: string): Promise<HomeworkSubmissionsDto> =>
|
||||||
|
api.get(`${API_PATHS.homeworks}/${id}/submissions`),
|
||||||
|
saveSubmissions: (id: string, notSubmittedUserIds: string[]): Promise<HomeworkSubmissionsDto> =>
|
||||||
|
api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedUserIds }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const notificationApi = {
|
export const notificationApi = {
|
||||||
@@ -57,5 +121,6 @@ export const gradeApi = {
|
|||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
|
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
|
||||||
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
|
term: (classId?: string): Promise<{ currentWeek: number; maxWeek: number }> =>
|
||||||
|
api.get('/dashboard/term', classId ? { classId } : undefined),
|
||||||
};
|
};
|
||||||
@@ -85,9 +85,14 @@ const EXPECTED_MENU: Array<{ label: string; path: string }> = [
|
|||||||
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||||
const { router } = await import('../router/index.js');
|
const { router } = await import('../router/index.js');
|
||||||
|
|
||||||
|
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
|
||||||
|
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
|
||||||
|
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
|
||||||
const findMenuItem = (label: string): HTMLElement | undefined =>
|
const findMenuItem = (label: string): HTMLElement | undefined =>
|
||||||
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
||||||
(element.textContent ?? '').trim().startsWith(label),
|
Array.from(element.querySelectorAll('span')).some(
|
||||||
|
(span) => (span.textContent ?? '').trim() === label,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!findMenuItem('课表')) {
|
if (!findMenuItem('课表')) {
|
||||||
@@ -123,11 +128,101 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自检临时造的作业/通知(收尾必须删掉,避免污染用户的库) */
|
||||||
|
let temporaryContent: { token: string; homeworks: string[]; notifications: string[] } | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端业务数据可能被管理员清空(用户明确要求过"删掉所有作业和通知"),
|
||||||
|
* 因此联网自检在列表为空时**自建**一条作业/通知来验证链路,收尾(sessionCleanup)删除。
|
||||||
|
*/
|
||||||
|
async function seedSmokeContent(
|
||||||
|
classId: string | null,
|
||||||
|
need: { homework: boolean; notification: boolean },
|
||||||
|
): Promise<SmokeCheckResult> {
|
||||||
|
if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' };
|
||||||
|
const serverUrl = 'http://127.0.0.1:4000';
|
||||||
|
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
|
||||||
|
|
||||||
|
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 token: string | undefined = login?.data?.token;
|
||||||
|
if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
|
||||||
|
|
||||||
|
const homeworks: string[] = [];
|
||||||
|
const notifications: string[] = [];
|
||||||
|
|
||||||
|
if (need.homework) {
|
||||||
|
const created = await fetch(`${serverUrl}/api/homeworks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
classId,
|
||||||
|
courseId: null,
|
||||||
|
title: `自动化验证作业 ${stamp}`,
|
||||||
|
content: '服务端没有作业数据时由冒烟自检临时创建,验证结束后立即删除,不会留在库里。',
|
||||||
|
}),
|
||||||
|
}).then((response) => response.json());
|
||||||
|
const id: string | undefined = created?.data?.id;
|
||||||
|
if (!id) return { ok: false, detail: `自建作业失败:${JSON.stringify(created).slice(0, 160)}` };
|
||||||
|
homeworks.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (need.notification) {
|
||||||
|
const created = await fetch(`${serverUrl}/api/notifications`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
classId,
|
||||||
|
title: `自动化验证通知 ${stamp}`,
|
||||||
|
content: '服务端没有通知数据时由冒烟自检临时创建,验证结束后立即删除,不会留在库里。',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
}),
|
||||||
|
}).then((response) => response.json());
|
||||||
|
const id: string | undefined = created?.data?.id;
|
||||||
|
if (!id) return { ok: false, detail: `自建通知失败:${JSON.stringify(created).slice(0, 160)}` };
|
||||||
|
notifications.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
temporaryContent = { token, homeworks, notifications };
|
||||||
|
return { ok: true, detail: `作业+${homeworks.length} 通知+${notifications.length}` };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, detail: `自建数据异常:${error instanceof Error ? error.message : String(error)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除自检临时造的作业/通知 */
|
||||||
|
async function cleanupTemporaryContent(): Promise<string> {
|
||||||
|
if (!temporaryContent) return '';
|
||||||
|
const { token, homeworks, notifications } = temporaryContent;
|
||||||
|
temporaryContent = null;
|
||||||
|
const serverUrl = 'http://127.0.0.1:4000';
|
||||||
|
const removals = [
|
||||||
|
...homeworks.map((id) => `/api/homeworks/${id}`),
|
||||||
|
...notifications.map((id) => `/api/notifications/${id}`),
|
||||||
|
];
|
||||||
|
let failed = 0;
|
||||||
|
for (const path of removals) {
|
||||||
|
const response = await fetch(`${serverUrl}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!response?.ok) failed += 1;
|
||||||
|
}
|
||||||
|
return `自检临时数据已清理 ${removals.length - failed}/${removals.length} 条`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
||||||
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
|
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
|
||||||
*/
|
*/
|
||||||
export async function onlineScenario(): Promise<SmokeCheckResult> {
|
export async function onlineScenario(credentials?: {
|
||||||
|
code: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<SmokeCheckResult> {
|
||||||
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
|
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
|
||||||
import('../stores/auth.js'),
|
import('../stores/auth.js'),
|
||||||
import('../stores/app.js'),
|
import('../stores/app.js'),
|
||||||
@@ -144,8 +239,14 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
|||||||
const detail: string[] = [];
|
const detail: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.login(serverUrl, 'student01', 'student123');
|
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入
|
||||||
|
const code = credentials?.code ?? 'G101';
|
||||||
|
const password = credentials?.password ?? '123456';
|
||||||
|
if (!credentials?.code) detail.push('未收到班级凭据,回退到种子班级码 G101');
|
||||||
|
|
||||||
|
await auth.login(serverUrl, code, password);
|
||||||
detail.push(`login=${auth.user?.name ?? '-'}`);
|
detail.push(`login=${auth.user?.name ?? '-'}`);
|
||||||
|
detail.push(`classSession=${auth.isClassSession}`);
|
||||||
|
|
||||||
// 进入主布局(后续的侧边栏点击测试依赖布局已挂载)
|
// 进入主布局(后续的侧边栏点击测试依赖布局已挂载)
|
||||||
const { router } = await import('../router/index.js');
|
const { router } = await import('../router/index.js');
|
||||||
@@ -156,12 +257,31 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
|||||||
await appStore.init(serverUrl);
|
await appStore.init(serverUrl);
|
||||||
detail.push(`reachable=${appStore.serverReachable}`);
|
detail.push(`reachable=${appStore.serverReachable}`);
|
||||||
|
|
||||||
const [classes, homeworks, notifications, grades] = await Promise.all([
|
const [classes, initialHomeworks, initialNotifications, grades] = await Promise.all([
|
||||||
api.classApi.list(),
|
api.classApi.list(),
|
||||||
api.homeworkApi.list(),
|
api.homeworkApi.list(),
|
||||||
api.notificationApi.list(),
|
api.notificationApi.list(),
|
||||||
api.gradeApi.my(),
|
api.gradeApi.my(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自检**不允许依赖演示种子数据**:管理员随时可能清空业务数据
|
||||||
|
* (例如"删掉服务端所有作业和通知"),此时这几条联网断言不能变成"数据被清空就失败"。
|
||||||
|
* 因此列表为空时用教师账号造一条**本次自检专用**的作业/通知,收尾在 sessionCleanup 里删除。
|
||||||
|
*/
|
||||||
|
let homeworks = initialHomeworks;
|
||||||
|
let notifications = initialNotifications;
|
||||||
|
if (homeworks.length === 0 || notifications.length === 0) {
|
||||||
|
const seeded = await seedSmokeContent(auth.classId ?? null, {
|
||||||
|
homework: homeworks.length === 0,
|
||||||
|
notification: notifications.length === 0,
|
||||||
|
});
|
||||||
|
detail.push(`自建数据(${seeded.detail})`);
|
||||||
|
if (seeded.ok) {
|
||||||
|
[homeworks, notifications] = await Promise.all([api.homeworkApi.list(), api.notificationApi.list()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const term = await api.dashboardApi.term();
|
const term = await api.dashboardApi.term();
|
||||||
const schedules = await api.scheduleApi.list({ week: term.currentWeek });
|
const schedules = await api.scheduleApi.list({ week: term.currentWeek });
|
||||||
|
|
||||||
@@ -218,11 +338,10 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
|||||||
/**
|
/**
|
||||||
* 冒烟自检 5(需后端在线):真实通知链路 → 灵动岛。
|
* 冒烟自检 5(需后端在线):真实通知链路 → 灵动岛。
|
||||||
*
|
*
|
||||||
* 前面的灵动岛用例是直接调 IPC 验证"状态机",这一条走**真机链路**:
|
* 前面的灵动岛用例直接调 IPC 验证"状态机",这一条走**真机链路**:
|
||||||
* 教师账号给"当前登录学生所在班级"发一条普通通知 → 服务端 Socket.IO 广播
|
* 教师账号给"当前登录学生所在班级"发一条通知 → 服务端 Socket.IO 广播 →
|
||||||
* → 客户端 realtime store → bridge → 主进程灵动岛。主进程随后断言灵动岛弹出胶囊。
|
* 客户端 realtime store → bridge → 主进程灵动岛。主进程随后断言灵动岛弹出胶囊,
|
||||||
*
|
* 并继续验证"在灵动岛点标为已读 → 通知中心同步为已读"。
|
||||||
* 返回临时信息(通知 id / 教师令牌 / 标题)供主进程断言与收尾清理。
|
|
||||||
*/
|
*/
|
||||||
export async function islandRealtimeScenario(): Promise<{
|
export async function islandRealtimeScenario(): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -297,16 +416,305 @@ export async function islandRealtimeCleanup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
|
/**
|
||||||
|
* 读取某条通知在本地通知中心里的已读状态。
|
||||||
|
* 用于验证"灵动岛点标为已读 → 通知中心也变成已读、未读数下降"。
|
||||||
|
*/
|
||||||
|
export async function islandReadState(notificationId: string): Promise<{
|
||||||
|
found: boolean;
|
||||||
|
read: boolean;
|
||||||
|
unreadCount: number;
|
||||||
|
title: string;
|
||||||
|
}> {
|
||||||
|
const { useNotificationStore } = await import('../stores/notifications.js');
|
||||||
|
const store = useNotificationStore();
|
||||||
|
if (store.items.length === 0) await store.load().catch(() => undefined);
|
||||||
|
const target = store.items.find((item) => item.id === notificationId);
|
||||||
|
return {
|
||||||
|
found: Boolean(target),
|
||||||
|
read: Boolean(target?.read),
|
||||||
|
unreadCount: store.unreadCount,
|
||||||
|
title: target?.title ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟自检 6(需后端在线):ClassIsland 风格"今天"时间轴。
|
||||||
|
*
|
||||||
|
* 造三节覆盖今天的课(已结束 / 正在上 / 即将开始),切到课表页读取 DOM,
|
||||||
|
* 断言:时间轴渲染出卡片、正在上的那张是"进行中"卡片并带倒计时与进度条、
|
||||||
|
* "已结束"/"下一节"标记存在、大时钟在走。用后删除探针课表。
|
||||||
|
*/
|
||||||
|
export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
||||||
|
const [{ useAuthStore }, { router }] = await Promise.all([
|
||||||
|
import('../stores/auth.js'),
|
||||||
|
import('../router/index.js'),
|
||||||
|
]);
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const serverUrl = 'http://127.0.0.1:4000';
|
||||||
|
const classId = auth.classId ?? null;
|
||||||
|
if (!classId) return { ok: false, detail: '当前学生没有班级,无法构造课表' };
|
||||||
|
|
||||||
|
const toHHmm = (minutes: number): string => {
|
||||||
|
const clamped = Math.min(23 * 60 + 59, Math.max(0, minutes));
|
||||||
|
return `${String(Math.floor(clamped / 60)).padStart(2, '0')}:${String(clamped % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const created: string[] = [];
|
||||||
|
const failures: string[] = [];
|
||||||
|
let teacherToken = '';
|
||||||
|
|
||||||
|
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());
|
||||||
|
teacherToken = login?.data?.token ?? '';
|
||||||
|
if (!teacherToken) return { ok: false, detail: '教师登录失败' };
|
||||||
|
|
||||||
|
const courses = await fetch(`${serverUrl}/api/courses?classId=${classId}`, {
|
||||||
|
headers: { authorization: `Bearer ${teacherToken}` },
|
||||||
|
}).then((response) => response.json());
|
||||||
|
const courseId = courses?.data?.[0]?.id;
|
||||||
|
if (!courseId) return { ok: false, detail: '该班级没有课程,无法构造课表' };
|
||||||
|
|
||||||
|
const nowMinutes = new Date().getHours() * 60 + new Date().getMinutes();
|
||||||
|
const dayOfWeek = new Date().getDay() === 0 ? 7 : new Date().getDay();
|
||||||
|
const probes = [
|
||||||
|
{ start: toHHmm(nowMinutes - 180), end: toHHmm(nowMinutes - 120), location: '时间轴自检 · 已结束' },
|
||||||
|
{ start: toHHmm(nowMinutes - 20), end: toHHmm(nowMinutes + 25), location: '时间轴自检 · 正在上课' },
|
||||||
|
{ start: toHHmm(nowMinutes + 60), end: toHHmm(nowMinutes + 105), location: '时间轴自检 · 下一节' },
|
||||||
|
// 深夜/凌晨时相对时间会越过 23:59 / 00:00,被夹紧后 start >= end(服务端 422)。
|
||||||
|
// 这种探针构造不出来就跳过,"下一节"的断言按实际创建数量放宽。
|
||||||
|
].filter((probe) => probe.start < probe.end);
|
||||||
|
for (const probe of probes) {
|
||||||
|
const response = await fetch(`${serverUrl}/api/schedules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
classId,
|
||||||
|
courseId,
|
||||||
|
dayOfWeek,
|
||||||
|
startTime: probe.start,
|
||||||
|
endTime: probe.end,
|
||||||
|
weekStart: 1,
|
||||||
|
weekEnd: 30,
|
||||||
|
location: probe.location,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
const id = payload?.data?.id;
|
||||||
|
if (id) created.push(id);
|
||||||
|
// 记录失败原因(限流 429 / 时间非法 400 等),否则只看到一个"2/3"没法排查
|
||||||
|
else {
|
||||||
|
failures.push(
|
||||||
|
`${probe.start}-${probe.end} status=${response.status} ${payload?.message ?? ''}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (created.length < probes.length) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: `探针课表创建失败(${created.length}/${probes.length})${failures.length > 0 ? `:${failures.join(';')}` : ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push('/schedule');
|
||||||
|
|
||||||
|
let dom: {
|
||||||
|
count: number;
|
||||||
|
hasCurrent: boolean;
|
||||||
|
currentText: string;
|
||||||
|
hasProgress: boolean;
|
||||||
|
clock: string;
|
||||||
|
headline: string;
|
||||||
|
tags: { state: string; text: string }[];
|
||||||
|
} | null = null;
|
||||||
|
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||||
|
const items = Array.from(document.querySelectorAll('.timeline-item'));
|
||||||
|
const current = document.querySelector('.timeline-item.is-current');
|
||||||
|
const snapshot = {
|
||||||
|
count: items.length,
|
||||||
|
hasCurrent: Boolean(current),
|
||||||
|
currentText: (current?.textContent ?? '').replace(/\s+/g, ' ').trim(),
|
||||||
|
hasProgress: Boolean(current?.querySelector('.lesson-progress')),
|
||||||
|
clock: document.querySelector('.clock-main')?.textContent?.trim() ?? '',
|
||||||
|
headline: document.querySelector('.status-title')?.textContent?.trim() ?? '',
|
||||||
|
tags: items.map((node) => ({
|
||||||
|
state: node.getAttribute('data-state') ?? '',
|
||||||
|
text: (node.textContent ?? '').replace(/\s+/g, ' ').trim(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (snapshot.hasCurrent && snapshot.count >= probes.length) {
|
||||||
|
dom = snapshot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dom) return { ok: false, detail: '时间轴未渲染出"进行中"卡片' };
|
||||||
|
|
||||||
|
const hasPast = dom.tags.some((tag) => tag.state === 'past' && tag.text.includes('已结束'));
|
||||||
|
const hasNext = dom.tags.some((tag) => tag.state === 'next' && tag.text.includes('下一节'));
|
||||||
|
const countdown = /距下课\s*\d+/.test(dom.currentText) || dom.currentText.includes('即将下课');
|
||||||
|
// 深夜/凌晨可能只构造出 2 个探针("下一节"跨过 23:59),此时不要求存在该卡片
|
||||||
|
const expectNextProbe = probes.length >= 3;
|
||||||
|
const ok =
|
||||||
|
dom.count >= probes.length &&
|
||||||
|
dom.hasCurrent &&
|
||||||
|
dom.hasProgress &&
|
||||||
|
countdown &&
|
||||||
|
hasPast &&
|
||||||
|
(!expectNextProbe || hasNext) &&
|
||||||
|
/^\d{2}:\d{2}$/.test(dom.clock);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
detail:
|
||||||
|
`卡片=${dom.count} 进行中=${dom.hasCurrent} 进度条=${dom.hasProgress} 倒计时=${countdown} ` +
|
||||||
|
`已结束=${hasPast} 下一节=${hasNext} 大时钟=${dom.clock} 摘要="${dom.headline}"` +
|
||||||
|
// 失败时能看出"到底渲染了哪几节、各自什么状态",否则只看到"已结束=false"无法定位
|
||||||
|
` 卡片状态=[${dom.tags.map((tag) => `${tag.state}:${tag.text.slice(0, 24)}`).join(' | ')}]`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, detail: `时间轴自检异常:${error instanceof Error ? error.message : String(error)}` };
|
||||||
|
} finally {
|
||||||
|
for (const id of created) {
|
||||||
|
await fetch(`${serverUrl}/api/schedules/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { authorization: `Bearer ${teacherToken}` },
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 冒烟自检 8(需后端在线):作业看板**全屏自适应**。
|
||||||
|
*
|
||||||
|
* 用户反馈"全屏模式没有自适应":点开全屏后卡片挤在左上角、内容显示不全。
|
||||||
|
* 这里走真实界面链路:进入作业页 → 切到「看板」→ 点「全屏看板」→
|
||||||
|
* 断言 ①布局宽度铺满视口(列数由算法注入 `--board-cols`)②缩放后可视底部不超出屏幕(无需滚动)。
|
||||||
|
*/
|
||||||
|
export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
|
||||||
|
const { router } = await import('../router/index.js');
|
||||||
|
try {
|
||||||
|
await router.push('/homeworks');
|
||||||
|
if (!(await waitUntil(() => Boolean(document.querySelector('.homework-page')), 6000))) {
|
||||||
|
return { ok: false, detail: '作业页未挂载' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须是「看板」模式(列表模式没有全屏看板入口)
|
||||||
|
const boardTab = Array.from(document.querySelectorAll('.el-radio-button')).find((node) =>
|
||||||
|
(node.textContent ?? '').includes('看板'),
|
||||||
|
);
|
||||||
|
if (!(boardTab instanceof HTMLElement)) return { ok: false, detail: '找不到「看板」切换按钮' };
|
||||||
|
boardTab.click();
|
||||||
|
await waitUntil(() => Boolean(document.querySelector('.board-config')), 3000);
|
||||||
|
|
||||||
|
// 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。
|
||||||
|
// 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。
|
||||||
|
if (!document.querySelector('.board-host')) {
|
||||||
|
const [{ homeworkApi }, { useAuthStore }] = await Promise.all([
|
||||||
|
import('../api/index.js'),
|
||||||
|
import('../stores/auth.js'),
|
||||||
|
]);
|
||||||
|
const classId = useAuthStore().classId ?? undefined;
|
||||||
|
const days = await homeworkApi.days({ classId }).catch(() => ({ days: [] }));
|
||||||
|
const latest = days.days.length > 0 ? days.days[days.days.length - 1].date : null;
|
||||||
|
if (latest) {
|
||||||
|
await router.push({ path: '/homeworks', query: { date: latest } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hasBoard = await waitUntil(() => Boolean(document.querySelector('.board-host')), 4000);
|
||||||
|
|
||||||
|
const fullscreenButton = Array.from(document.querySelectorAll('button')).find((node) =>
|
||||||
|
(node.textContent ?? '').includes('全屏看板'),
|
||||||
|
);
|
||||||
|
if (!(fullscreenButton instanceof HTMLElement)) return { ok: false, detail: '找不到「全屏看板」按钮' };
|
||||||
|
fullscreenButton.click();
|
||||||
|
|
||||||
|
const dialogReady = await waitUntil(
|
||||||
|
() => Boolean(document.querySelector('.board-fullscreen .board-lg')),
|
||||||
|
6000,
|
||||||
|
);
|
||||||
|
if (!dialogReady) return { ok: false, detail: '全屏看板弹窗未出现' };
|
||||||
|
|
||||||
|
// 等自适应轮询跑完(最多 20 次 × 60ms)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1600));
|
||||||
|
|
||||||
|
const board = document.querySelector('.board-fullscreen .board-lg');
|
||||||
|
const host = document.querySelector('.board-fullscreen .board-host-lg');
|
||||||
|
if (!(board instanceof HTMLElement) || !(host instanceof HTMLElement)) {
|
||||||
|
return { ok: false, detail: '全屏看板容器缺失' };
|
||||||
|
}
|
||||||
|
const rect = board.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const layoutWidth = board.offsetWidth;
|
||||||
|
const columns = Number(board.style.getPropertyValue('--board-cols') || 0);
|
||||||
|
const cards = board.querySelectorAll('.board-card').length;
|
||||||
|
const scale = Number((getComputedStyle(board).transform.match(/matrix\(([-\d.]+)/) ?? [])[1] ?? 1);
|
||||||
|
|
||||||
|
const fillsWidth = layoutWidth >= viewportWidth * 0.9;
|
||||||
|
const fitsScreen = rect.bottom <= viewportHeight + 2 && rect.width <= viewportWidth + 2;
|
||||||
|
|
||||||
|
// 第二阶段:模拟"科目很多"(真实班级看板常有 5~8 个科目)。
|
||||||
|
// 克隆卡片到 6 张 → 触发 resize 让自适应算法重算 → 断言列数自动变多且仍然铺满、不溢出。
|
||||||
|
let manyCards = '';
|
||||||
|
let manyOk = false;
|
||||||
|
const sample = board.querySelector('.board-card');
|
||||||
|
if (sample instanceof HTMLElement) {
|
||||||
|
while (board.querySelectorAll('.board-card').length < 6) {
|
||||||
|
const clone = sample.cloneNode(true) as HTMLElement;
|
||||||
|
clone.setAttribute('data-smoke-filler', '1');
|
||||||
|
board.appendChild(clone);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
const cardsMany = board.querySelectorAll('.board-card').length;
|
||||||
|
const columnsMany = Number(board.style.getPropertyValue('--board-cols') || 0);
|
||||||
|
const layoutWidthMany = board.offsetWidth;
|
||||||
|
const rectMany = board.getBoundingClientRect();
|
||||||
|
manyOk =
|
||||||
|
cardsMany === 6 &&
|
||||||
|
columnsMany >= 2 &&
|
||||||
|
columnsMany <= cardsMany &&
|
||||||
|
layoutWidthMany >= viewportWidth * 0.9 &&
|
||||||
|
rectMany.bottom <= viewportHeight + 2;
|
||||||
|
manyCards =
|
||||||
|
`多科目:卡片=${cardsMany} 列数=${columnsMany} 排版宽=${layoutWidthMany} ` +
|
||||||
|
`可视底=${Math.round(rectMany.bottom)} 通过=${manyOk}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeButton = document.querySelector('.board-fullscreen .el-dialog__headerbtn');
|
||||||
|
if (closeButton instanceof HTMLElement) closeButton.click();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: hasBoard && cards > 0 && columns >= 1 && columns <= cards && fillsWidth && fitsScreen && manyOk,
|
||||||
|
detail:
|
||||||
|
`卡片=${cards} 列数=${columns} 缩放=${Number.isFinite(scale) ? scale.toFixed(3) : '-'} ` +
|
||||||
|
`排版宽=${layoutWidth}(视口宽=${viewportWidth})可视底=${Math.round(rect.bottom)}(视口高=${viewportHeight}) ` +
|
||||||
|
`铺满宽=${fillsWidth} 不超出屏幕=${fitsScreen};${manyCards}`,
|
||||||
|
};
|
||||||
|
} 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([
|
||||||
import('../stores/auth.js'),
|
import('../stores/auth.js'),
|
||||||
import('../stores/realtime.js'),
|
import('../stores/realtime.js'),
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
|
const cleaned = await cleanupTemporaryContent();
|
||||||
useRealtimeStore().disconnect();
|
useRealtimeStore().disconnect();
|
||||||
await useAuthStore().logout();
|
await useAuthStore().logout();
|
||||||
return { ok: true, detail: '已断开实时通道并清理登录态' };
|
return { ok: true, detail: `已断开实时通道并清理登录态${cleaned ? `;${cleaned}` : ''}` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
||||||
}
|
}
|
||||||
@@ -321,6 +729,9 @@ export function registerSmokeHooks(): void {
|
|||||||
onlineScenario,
|
onlineScenario,
|
||||||
islandRealtimeScenario,
|
islandRealtimeScenario,
|
||||||
islandRealtimeCleanup,
|
islandRealtimeCleanup,
|
||||||
|
islandReadState,
|
||||||
|
scheduleTimelineSelfTest,
|
||||||
|
homeworkBoardSelfTest,
|
||||||
sessionCleanup,
|
sessionCleanup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
resolveClassStatus,
|
resolveClassStatus,
|
||||||
type ClassPeriod,
|
type ClassPeriod,
|
||||||
|
type HomeworkDto,
|
||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
@@ -9,6 +10,7 @@ import { scheduleApi } from '../api/index.js';
|
|||||||
import { fetchWithCache } from '../cache/index.js';
|
import { fetchWithCache } from '../cache/index.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 { useRealtimeStore } from '../stores/realtime.js';
|
import { useRealtimeStore } from '../stores/realtime.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,11 +84,70 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
|
|||||||
createdAt: notification.createdAt,
|
createdAt: notification.createdAt,
|
||||||
courseName: currentPeriod?.courseName ?? null,
|
courseName: currentPeriod?.courseName ?? null,
|
||||||
teacherName: notification.creator?.name ?? null,
|
teacherName: notification.creator?.name ?? null,
|
||||||
|
kind: 'notification',
|
||||||
},
|
},
|
||||||
context: getIslandClassContext(),
|
context: getIslandClassContext(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
|
||||||
|
export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
||||||
|
window.desktop?.islandPush({
|
||||||
|
notification: {
|
||||||
|
id: `homework-${homework.id}`,
|
||||||
|
title: homework.title,
|
||||||
|
content: homework.content?.trim() || '(老师没有填写作业说明)',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
createdAt: homework.createdAt,
|
||||||
|
courseName: homework.course?.name ?? null,
|
||||||
|
teacherName: homework.creator?.name ?? null,
|
||||||
|
kind: 'homework',
|
||||||
|
},
|
||||||
|
context: getIslandClassContext(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
|
||||||
|
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
|
||||||
|
* 两种都带"叫人"类型(徽标/「收到」按钮/更大的展开卡)。
|
||||||
|
*/
|
||||||
|
export function pushCallToIsland(call: NotificationDto): void {
|
||||||
|
window.desktop?.islandPush({
|
||||||
|
notification: {
|
||||||
|
id: call.id,
|
||||||
|
title: call.title,
|
||||||
|
content: call.content?.trim() || '老师正在等你,请尽快前往。',
|
||||||
|
priority: call.priority,
|
||||||
|
createdAt: call.createdAt,
|
||||||
|
courseName: null,
|
||||||
|
teacherName: call.creator?.name ?? null,
|
||||||
|
kind: 'call',
|
||||||
|
subtitle: '请尽快前往,收到后点「收到」',
|
||||||
|
},
|
||||||
|
context: getIslandClassContext(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
|
||||||
|
* 由 App.vue 在挂载时订阅一次。
|
||||||
|
*/
|
||||||
|
export function subscribeIslandMarkRead(): void {
|
||||||
|
window.desktop?.onIslandMarkRead?.((id) => {
|
||||||
|
void (async () => {
|
||||||
|
const store = useNotificationStore();
|
||||||
|
try {
|
||||||
|
await store.markRead(id);
|
||||||
|
} catch {
|
||||||
|
// 离线或通知已被删除:本地先记为已读,等下次同步纠正
|
||||||
|
const target = store.items.find((item) => item.id === id);
|
||||||
|
if (target) target.read = true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function startIslandBridge(): void {
|
export function startIslandBridge(): void {
|
||||||
if (tickTimer) return;
|
if (tickTimer) return;
|
||||||
|
|
||||||
|
|||||||
@@ -97,20 +97,26 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-container class="layout">
|
<el-container class="layout">
|
||||||
<el-aside width="190px" class="aside">
|
<el-aside width="200px" class="aside ch-nav">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<el-icon :size="20"><School /></el-icon>
|
<span class="brand-logo">
|
||||||
<span>班级小助手</span>
|
<el-icon :size="16"><School /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="brand-text">班级小助手</span>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
|
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||||
|
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
||||||
|
<span class="menu-icon-slot">
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
<el-badge
|
<el-badge
|
||||||
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
||||||
:value="notifications.unreadCount"
|
:value="notifications.unreadCount"
|
||||||
|
:max="99"
|
||||||
class="menu-badge"
|
class="menu-badge"
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
<div class="aside-footer">
|
<div class="aside-footer">
|
||||||
@@ -170,11 +176,10 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.layout {
|
.layout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
background: var(--ch-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside {
|
.aside {
|
||||||
background: var(--ch-sidebar);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -182,53 +187,97 @@ onUnmounted(() => {
|
|||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
height: 54px;
|
height: 52px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
font-size: 14px;
|
||||||
|
color: var(--ch-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
|
||||||
|
.brand-logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
--el-menu-text-color: #cbd5e1;
|
--el-menu-bg-color: transparent;
|
||||||
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
|
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
|
||||||
--el-menu-active-color: #fff;
|
--el-menu-text-color: var(--ch-text);
|
||||||
|
--el-menu-active-color: var(--ch-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu :deep(.el-menu-item) {
|
/* 图标槽:作为未读红点的定位父级(红点在图标右上角) */
|
||||||
margin: 2px 8px;
|
.menu-icon-slot {
|
||||||
border-radius: 6px;
|
position: relative;
|
||||||
}
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
.menu :deep(.el-menu-item.is-active) {
|
justify-content: center;
|
||||||
background: #409eff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 未读红点:定位到图标"右上角外侧"。
|
||||||
|
* el-badge 默认把角标中线压在包裹元素右上角(translateY(-50%) translateX(100%)),
|
||||||
|
* 这里显式覆盖成相对图标槽的负 top / 负 right,保证红点在图标右上方而不是右侧或下方。
|
||||||
|
*/
|
||||||
.menu-badge {
|
.menu-badge {
|
||||||
margin-left: 10px;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-badge :deep(.el-badge__content) {
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
right: -11px;
|
||||||
|
transform: none;
|
||||||
|
height: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-footer {
|
.aside-footer {
|
||||||
padding: 10px 16px 14px;
|
padding: 10px 16px 14px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid var(--ch-divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-meta {
|
.aside-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8fa3b8;
|
color: var(--ch-text-tertiary);
|
||||||
line-height: 1.8;
|
line-height: 1.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: #fff;
|
height: 52px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
border-bottom: 1px solid var(--ch-border);
|
border-bottom: 1px solid var(--ch-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -240,15 +289,22 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.server-url {
|
.server-url {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: var(--ch-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-chip {
|
.user-chip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--ch-radius-control);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
|
import {
|
||||||
|
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||||
|
STORAGE_KEYS,
|
||||||
|
formatDate,
|
||||||
|
type ClassIslandNotificationChannel,
|
||||||
|
} from '@classhelper/shared';
|
||||||
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
|
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
|
||||||
|
import { classChannelApi } from '../api/index.js';
|
||||||
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
|
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
|
||||||
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||||||
|
|
||||||
@@ -19,6 +25,13 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
const currentWeek = ref(1);
|
const currentWeek = ref(1);
|
||||||
const maxWeek = ref(20);
|
const maxWeek = ref(20);
|
||||||
const initialized = ref(false);
|
const initialized = ref(false);
|
||||||
|
/**
|
||||||
|
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
||||||
|
*
|
||||||
|
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择
|
||||||
|
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。
|
||||||
|
*/
|
||||||
|
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL);
|
||||||
|
|
||||||
const offline = computed(() => !serverReachable.value);
|
const offline = computed(() => !serverReachable.value);
|
||||||
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
|
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
|
||||||
@@ -108,6 +121,53 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
void refreshCacheStats();
|
void refreshCacheStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 通知显示位置 */
|
||||||
|
|
||||||
|
/** 启动时用本地配置兜底(此时可能还没登录/断网) */
|
||||||
|
function applyLocalNotificationChannel(value: ClassIslandNotificationChannel): void {
|
||||||
|
notificationChannel.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向服务器对齐显示位置。
|
||||||
|
* 登录后调用:以服务端为准(多台机器/老师改过之后,本地缓存可能已经过期)。
|
||||||
|
*/
|
||||||
|
async function loadNotificationChannel(classId: string | null): Promise<void> {
|
||||||
|
if (!classId) return;
|
||||||
|
try {
|
||||||
|
const result = await classChannelApi.get(classId);
|
||||||
|
notificationChannel.value = result.notificationChannel;
|
||||||
|
await window.desktop?.saveConfig({ notificationChannel: result.notificationChannel });
|
||||||
|
} catch {
|
||||||
|
// 离线/无权限:保留本地值,不影响展示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改显示位置:先落本地(立刻生效),再写回班级记录。
|
||||||
|
* 服务端写失败时返回 false,由设置页提示"已保存在本机,联网后请重试"。
|
||||||
|
*/
|
||||||
|
async function setNotificationChannel(
|
||||||
|
classId: string | null,
|
||||||
|
value: ClassIslandNotificationChannel,
|
||||||
|
): Promise<boolean> {
|
||||||
|
notificationChannel.value = value;
|
||||||
|
await window.desktop?.saveConfig({ notificationChannel: value });
|
||||||
|
if (!classId) return false;
|
||||||
|
try {
|
||||||
|
const result = await classChannelApi.set(classId, value);
|
||||||
|
notificationChannel.value = result.notificationChannel;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本次提醒是否要在本机弹(弹窗 + 灵动岛);"只在 ClassIsland 上弹"时为 false */
|
||||||
|
function shouldPopupLocally(): boolean {
|
||||||
|
return notificationChannel.value !== 'classisland';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverUrl,
|
serverUrl,
|
||||||
serverReachable,
|
serverReachable,
|
||||||
@@ -119,9 +179,14 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
currentWeek,
|
currentWeek,
|
||||||
maxWeek,
|
maxWeek,
|
||||||
initialized,
|
initialized,
|
||||||
|
notificationChannel,
|
||||||
init,
|
init,
|
||||||
ping,
|
ping,
|
||||||
applyServerUrl,
|
applyServerUrl,
|
||||||
|
applyLocalNotificationChannel,
|
||||||
|
loadNotificationChannel,
|
||||||
|
setNotificationChannel,
|
||||||
|
shouldPopupLocally,
|
||||||
refreshCacheStats,
|
refreshCacheStats,
|
||||||
clearCache,
|
clearCache,
|
||||||
markSynced,
|
markSynced,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { STORAGE_KEYS, type LoginResponse, type StudentDto } from '@classhelper/shared';
|
import { STORAGE_KEYS, type LoginResponse, type SessionUser } from '@classhelper/shared';
|
||||||
import { authApi } from '../api/index.js';
|
import { authApi } from '../api/index.js';
|
||||||
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
|
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
|
||||||
import { cacheGet, cacheSet } from '../cache/db.js';
|
import { cacheGet, cacheSet } from '../cache/db.js';
|
||||||
@@ -15,13 +15,17 @@ const PROFILE_KEY = 'me';
|
|||||||
*/
|
*/
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(null);
|
const token = ref<string | null>(null);
|
||||||
const user = ref<StudentDto | null>(null);
|
const user = ref<SessionUser | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const offlineSession = ref(false);
|
const offlineSession = ref(false);
|
||||||
|
|
||||||
const isAuthenticated = computed(() => Boolean(token.value));
|
const isAuthenticated = computed(() => Boolean(token.value));
|
||||||
const displayName = computed(() => user.value?.name ?? '未登录');
|
const displayName = computed(() => user.value?.name ?? '未登录');
|
||||||
const classId = computed(() => user.value?.classId ?? null);
|
const classId = computed(() => user.value?.classId ?? null);
|
||||||
|
/** true 表示当前是「班级账号(班级设备)」会话:个人数据按全班读写 */
|
||||||
|
const isClassSession = computed(() => user.value?.classSession === true);
|
||||||
|
/** 班级码(登录账号,便于设置页展示) */
|
||||||
|
const classCode = computed(() => user.value?.classCode ?? '');
|
||||||
|
|
||||||
setTokenProvider(() => token.value);
|
setTokenProvider(() => token.value);
|
||||||
|
|
||||||
@@ -39,20 +43,26 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
await window.desktop.saveConfig({ serverUrl, username, token: nextToken });
|
await window.desktop.saveConfig({ serverUrl, username, token: nextToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录:写主进程配置 + 缓存资料 */
|
/**
|
||||||
async function login(serverUrl: string, username: string, password: string): Promise<void> {
|
* 班级账号登录(学生端主入口):班级码 + 班级密码。
|
||||||
|
*
|
||||||
|
* 需求 1:学生端以「班级」为主体,个人学生不再是登录主体;
|
||||||
|
* 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。
|
||||||
|
* 保存的 username 字段记录班级码,便于下次预填。
|
||||||
|
*/
|
||||||
|
async function login(serverUrl: string, code: string, password: string): Promise<void> {
|
||||||
const normalized = normalizeServerUrl(serverUrl);
|
const normalized = normalizeServerUrl(serverUrl);
|
||||||
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||||
|
|
||||||
setApiBaseUrl(normalized);
|
setApiBaseUrl(normalized);
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const payload: LoginResponse = await authApi.login({ username, password });
|
const payload: LoginResponse = await authApi.classLogin({ code, password });
|
||||||
applyToken(payload.token);
|
applyToken(payload.token);
|
||||||
user.value = payload.user as StudentDto;
|
user.value = payload.user;
|
||||||
offlineSession.value = false;
|
offlineSession.value = false;
|
||||||
await cacheSet('profile', PROFILE_KEY, user.value);
|
await cacheSet('profile', PROFILE_KEY, user.value);
|
||||||
await persistSession(normalized, username, payload.token);
|
await persistSession(normalized, code, payload.token);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -80,7 +90,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedProfile = await cacheGet<StudentDto>('profile', PROFILE_KEY).catch(() => null);
|
const cachedProfile = await cacheGet<SessionUser>('profile', PROFILE_KEY).catch(() => null);
|
||||||
if (cachedProfile) user.value = cachedProfile.value;
|
if (cachedProfile) user.value = cachedProfile.value;
|
||||||
|
|
||||||
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
|
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
|
||||||
@@ -122,6 +132,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
displayName,
|
displayName,
|
||||||
classId,
|
classId,
|
||||||
|
isClassSession,
|
||||||
|
classCode,
|
||||||
login,
|
login,
|
||||||
restore,
|
restore,
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
type ServerToClientEvents,
|
type ServerToClientEvents,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { socketUrlOf } from '../config.js';
|
import { socketUrlOf } from '../config.js';
|
||||||
import { pushNotificationToIsland } from '../island/bridge.js';
|
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
|
||||||
|
import { useAppStore } from './app.js';
|
||||||
|
|
||||||
type RealtimeEventName = keyof ServerToClientEvents;
|
type RealtimeEventName = keyof ServerToClientEvents;
|
||||||
type EventHandler = (payload: unknown) => void;
|
type EventHandler = (payload: unknown) => void;
|
||||||
@@ -22,6 +23,18 @@ type EventHandler = (payload: unknown) => void;
|
|||||||
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
|
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
|
||||||
*/
|
*/
|
||||||
export const useRealtimeStore = defineStore('realtime', () => {
|
export const useRealtimeStore = defineStore('realtime', () => {
|
||||||
|
const app = useAppStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本机要不要弹这次提醒。
|
||||||
|
*
|
||||||
|
* 教室在设置页选了「只在 ClassIsland 上弹」时,客户端仍然把通知收进通知中心(列表里有记录、
|
||||||
|
* 未读红点照常),但**不再弹窗、也不上岛** —— 否则老师会看到两个地方同时炸出来。
|
||||||
|
*/
|
||||||
|
function popupLocally(): boolean {
|
||||||
|
return app.shouldPopupLocally();
|
||||||
|
}
|
||||||
|
|
||||||
const connected = ref(false);
|
const connected = ref(false);
|
||||||
const connecting = ref(false);
|
const connecting = ref(false);
|
||||||
const lastEventName = ref<string | null>(null);
|
const lastEventName = ref<string | null>(null);
|
||||||
@@ -65,6 +78,7 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
|||||||
|
|
||||||
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
|
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
|
||||||
mark(SOCKET_EVENTS.notificationNew);
|
mark(SOCKET_EVENTS.notificationNew);
|
||||||
|
if (popupLocally()) {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
|
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
|
||||||
type: payload.priority === 'URGENT' ? 'error' : 'info',
|
type: payload.priority === 'URGENT' ? 'error' : 'info',
|
||||||
@@ -73,15 +87,33 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
|||||||
});
|
});
|
||||||
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
|
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
|
||||||
pushNotificationToIsland(payload);
|
pushNotificationToIsland(payload);
|
||||||
|
}
|
||||||
dispatch(SOCKET_EVENTS.notificationNew, payload);
|
dispatch(SOCKET_EVENTS.notificationNew, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
|
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
|
||||||
mark(SOCKET_EVENTS.homeworkNew);
|
mark(SOCKET_EVENTS.homeworkNew);
|
||||||
ElMessage.success(`新作业:${payload.title}`);
|
ElMessage.success(`新作业:${payload.title}`);
|
||||||
|
// 作业发布也上岛:胶囊提示"新作业",点击展开看作业要求
|
||||||
|
pushHomeworkToIsland(payload);
|
||||||
dispatch(SOCKET_EVENTS.homeworkNew, payload);
|
dispatch(SOCKET_EVENTS.homeworkNew, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
|
||||||
|
mark(SOCKET_EVENTS.callNew);
|
||||||
|
if (popupLocally()) {
|
||||||
|
ElMessage({
|
||||||
|
message: payload.title,
|
||||||
|
type: 'warning',
|
||||||
|
duration: 8000,
|
||||||
|
showClose: true,
|
||||||
|
});
|
||||||
|
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
|
||||||
|
pushCallToIsland(payload);
|
||||||
|
}
|
||||||
|
dispatch(SOCKET_EVENTS.callNew, payload);
|
||||||
|
});
|
||||||
|
|
||||||
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto & { deleted?: boolean }) => {
|
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto & { deleted?: boolean }) => {
|
||||||
mark(SOCKET_EVENTS.homeworkUpdated);
|
mark(SOCKET_EVENTS.homeworkUpdated);
|
||||||
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
|
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
|
||||||
|
|||||||
@@ -1,7 +1,65 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
|
||||||
|
*
|
||||||
|
* 参考 ClassIsland(Windows 课件表应用)的观感:
|
||||||
|
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
||||||
|
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
|
||||||
|
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
|
||||||
|
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
|
||||||
|
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--ch-bg: #f5f7fa;
|
/* Fluent 强调色 */
|
||||||
--ch-border: #e4e7ed;
|
--ch-accent: #0f6cbd;
|
||||||
--ch-sidebar: #1f2d3d;
|
--ch-accent-hover: #115ea3;
|
||||||
|
--ch-accent-soft: rgba(15, 108, 189, 0.08);
|
||||||
|
--ch-accent-soft-strong: rgba(15, 108, 189, 0.14);
|
||||||
|
|
||||||
|
/* 层次底色(Mica / Layer) */
|
||||||
|
--ch-bg: #f3f3f3;
|
||||||
|
--ch-layer: rgba(255, 255, 255, 0.7);
|
||||||
|
--ch-layer-solid: #ffffff;
|
||||||
|
--ch-layer-alt: rgba(249, 249, 249, 0.5);
|
||||||
|
--ch-card-subtle: rgba(255, 255, 255, 0.5);
|
||||||
|
|
||||||
|
/* 描边(Fluent 用半透明黑,避免生硬的灰线) */
|
||||||
|
--ch-border: rgba(0, 0, 0, 0.06);
|
||||||
|
--ch-border-strong: rgba(0, 0, 0, 0.1);
|
||||||
|
--ch-divider: rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
/* 文本 */
|
||||||
|
--ch-text: #1b1b1b;
|
||||||
|
--ch-text-secondary: #5c5c5c;
|
||||||
|
--ch-text-tertiary: #8a8a8a;
|
||||||
|
|
||||||
|
/* 圆角与阴影 */
|
||||||
|
--ch-radius-card: 8px;
|
||||||
|
--ch-radius-control: 4px;
|
||||||
|
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
/* 兼容旧变量名 */
|
||||||
|
--ch-sidebar: #ffffff;
|
||||||
|
|
||||||
|
/* Element Plus 变量对齐 Fluent */
|
||||||
|
--el-color-primary: #0f6cbd;
|
||||||
|
--el-color-primary-light-3: #4d94d1;
|
||||||
|
--el-color-primary-light-5: #7fb1de;
|
||||||
|
--el-color-primary-light-7: #b3d0ec;
|
||||||
|
--el-color-primary-light-8: #d0e2f4;
|
||||||
|
--el-color-primary-light-9: #e7f0f9;
|
||||||
|
--el-color-primary-dark-2: #115ea3;
|
||||||
|
--el-border-radius-base: 4px;
|
||||||
|
--el-border-radius-small: 3px;
|
||||||
|
--el-text-color-primary: #1b1b1b;
|
||||||
|
--el-text-color-regular: #3a3a3a;
|
||||||
|
--el-text-color-secondary: #5c5c5c;
|
||||||
|
--el-border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
--el-border-color-light: rgba(0, 0, 0, 0.06);
|
||||||
|
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
|
||||||
|
--el-fill-color-blank: transparent;
|
||||||
|
--el-font-size-base: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -17,11 +75,15 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
font-family:
|
||||||
|
'Segoe UI Variable Text', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
|
||||||
|
'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
background: var(--ch-bg);
|
background: var(--ch-bg);
|
||||||
color: #303133;
|
color: var(--ch-text);
|
||||||
/* 桌面端禁用整页选中,更接近原生应用体验 */
|
/* 桌面端禁用整页选中,更接近原生应用体验 */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
@@ -29,6 +91,8 @@ textarea {
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 页面骨架 */
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
padding: 16px 20px 24px;
|
padding: 16px 20px 24px;
|
||||||
}
|
}
|
||||||
@@ -39,25 +103,26 @@ textarea {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
color: #909399;
|
color: var(--ch-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +135,161 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: #909399;
|
color: var(--ch-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.offline-banner {
|
.offline-banner {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 卡片 / 分组 */
|
||||||
|
|
||||||
|
/* 亚克力卡片:半透明底 + 极细描边 + 极轻阴影(ClassIsland 的内容卡片观感) */
|
||||||
|
.el-card {
|
||||||
|
border-radius: var(--ch-radius-card) !important;
|
||||||
|
border: 1px solid var(--ch-border) !important;
|
||||||
|
background: var(--ch-layer) !important;
|
||||||
|
backdrop-filter: blur(20px) saturate(1.1);
|
||||||
|
box-shadow: var(--ch-shadow-card) !important;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card:hover {
|
||||||
|
border-color: var(--ch-border-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card__header {
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
border-bottom: 1px solid var(--ch-divider) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card__body {
|
||||||
|
padding: 14px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 控件 */
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
border-radius: var(--ch-radius-control) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--default {
|
||||||
|
background: rgba(255, 255, 255, 0.7) !important;
|
||||||
|
border-color: var(--ch-border-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--default:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 108, 189, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper,
|
||||||
|
.el-textarea__inner,
|
||||||
|
.el-select__wrapper {
|
||||||
|
border-radius: var(--ch-radius-control) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.7) !important;
|
||||||
|
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper.is-focus,
|
||||||
|
.el-select__wrapper.is-focused {
|
||||||
|
box-shadow: 0 0 0 1px var(--ch-accent) inset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tag {
|
||||||
|
border-radius: var(--ch-radius-control) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog,
|
||||||
|
.el-drawer {
|
||||||
|
border-radius: var(--ch-radius-card) !important;
|
||||||
|
background: var(--ch-layer-solid) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box {
|
||||||
|
border-radius: var(--ch-radius-card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-popper,
|
||||||
|
.el-dropdown__popper {
|
||||||
|
border-radius: var(--ch-radius-card) !important;
|
||||||
|
box-shadow: var(--ch-shadow-flyout) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 表格 */
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
--el-table-border-color: var(--ch-divider);
|
||||||
|
--el-table-header-bg-color: transparent;
|
||||||
|
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
background: transparent !important;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table td.el-table__cell,
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
border-bottom: 1px solid var(--ch-divider) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table__inner-wrapper::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
|
||||||
|
|
||||||
|
.ch-nav {
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.2);
|
||||||
|
border-right: 1px solid var(--ch-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-nav .el-menu-item {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
margin: 2px 8px;
|
||||||
|
border-radius: var(--ch-radius-control);
|
||||||
|
color: var(--ch-text);
|
||||||
|
font-weight: 500;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-nav .el-menu-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
|
||||||
|
.ch-nav .el-menu-item.is-active {
|
||||||
|
background: var(--ch-accent-soft);
|
||||||
|
color: var(--ch-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-nav .el-menu-item.is-active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--ch-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 课表时间轴 */
|
||||||
|
|
||||||
.timetable-cell {
|
.timetable-cell {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
}
|
}
|
||||||
@@ -87,7 +300,7 @@ textarea {
|
|||||||
|
|
||||||
.timetable-meta {
|
.timetable-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: var(--ch-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.unread-dot {
|
.unread-dot {
|
||||||
@@ -95,19 +308,27 @@ textarea {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f56c6c;
|
background: #d13438;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条美化(Windows 默认滚动条较粗) */
|
/* ---------------------------------------------------------------- 滚动条 */
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 10px;
|
||||||
height: 8px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #c8ccd4;
|
background: rgba(0, 0, 0, 0.18);
|
||||||
border-radius: 4px;
|
border: 3px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import {
|
|||||||
import { gradeApi } from '../api/index.js';
|
import { gradeApi } from '../api/index.js';
|
||||||
import { fetchWithCache } from '../cache/index.js';
|
import { fetchWithCache } from '../cache/index.js';
|
||||||
import { useAppStore } from '../stores/app.js';
|
import { useAppStore } from '../stores/app.js';
|
||||||
|
import { useAuthStore } from '../stores/auth.js';
|
||||||
import { useRealtimeStore } from '../stores/realtime.js';
|
import { useRealtimeStore } from '../stores/realtime.js';
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
|
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */
|
||||||
|
const auth = useAuthStore();
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -130,9 +133,10 @@ onUnmounted(() => {
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">我的成绩</h2>
|
<h2 class="page-title">{{ auth.isClassSession ? '本班成绩' : '我的成绩' }}</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
|
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
|
||||||
|
<span v-if="auth.isClassSession">· 班级账号:显示全班成绩总览</span>
|
||||||
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const auth = useAuthStore();
|
|||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' });
|
const form = reactive({ serverUrl: DEFAULT_SERVER, code: '', password: '' });
|
||||||
const testing = ref(false);
|
const testing = ref(false);
|
||||||
const testResult = ref<'ok' | 'fail' | null>(null);
|
const testResult = ref<'ok' | 'fail' | null>(null);
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ const rules: FormRules = {
|
|||||||
trigger: 'blur',
|
trigger: 'blur',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
code: [{ required: true, message: '请输入班级码', trigger: 'blur' }],
|
||||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ onMounted(async () => {
|
|||||||
if (window.desktop) {
|
if (window.desktop) {
|
||||||
const config = await window.desktop.getConfig();
|
const config = await window.desktop.getConfig();
|
||||||
if (config.serverUrl) form.serverUrl = config.serverUrl;
|
if (config.serverUrl) form.serverUrl = config.serverUrl;
|
||||||
if (config.username) form.username = config.username;
|
if (config.username) form.code = config.username;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,10 +66,12 @@ async function submit(): Promise<void> {
|
|||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.login(form.serverUrl, form.username.trim(), form.password);
|
await auth.login(form.serverUrl, form.code.trim(), form.password);
|
||||||
await appStore.init(form.serverUrl);
|
await appStore.init(form.serverUrl);
|
||||||
|
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
|
||||||
|
await appStore.loadNotificationChannel(auth.classId);
|
||||||
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
||||||
ElMessage.success(`欢迎回来,${auth.displayName}`);
|
ElMessage.success(`已进入 ${auth.displayName}`);
|
||||||
await router.replace('/schedule');
|
await router.replace('/schedule');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '登录失败');
|
ElMessage.error(error instanceof Error ? error.message : '登录失败');
|
||||||
@@ -92,7 +94,7 @@ async function enterOffline(): Promise<void> {
|
|||||||
<el-icon :size="30" color="#409eff"><School /></el-icon>
|
<el-icon :size="30" color="#409eff"><School /></el-icon>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="login-title">班级小助手</h1>
|
<h1 class="login-title">班级小助手</h1>
|
||||||
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p>
|
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,19 +107,19 @@ async function enterOffline(): Promise<void> {
|
|||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item label="班级码" prop="code">
|
||||||
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit">
|
<el-input v-model="form.code" placeholder="请输入班级码" clearable @keyup.enter="submit">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><School /></el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="密码" prop="password">
|
<el-form-item label="班级密码" prop="password">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="密码"
|
placeholder="班级密码"
|
||||||
show-password
|
show-password
|
||||||
@keyup.enter="submit"
|
@keyup.enter="submit"
|
||||||
>
|
>
|
||||||
@@ -156,9 +158,9 @@ async function enterOffline(): Promise<void> {
|
|||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<p class="login-tip">
|
<p class="login-tip">
|
||||||
演示账号:student01 / student123(高一(1)班)
|
班级码与班级密码由管理员在 Web 管理端「班级管理 → 班级账号」中设置或重置。
|
||||||
<br />
|
<br />
|
||||||
数据来源:教师或管理员在 Web 管理端发布的内容。
|
登录后本机即代表整个班级:作业完成、通知已读都会按全班记录。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import {
|
|||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
WEEKDAY_LABELS,
|
WEEKDAY_LABELS,
|
||||||
|
WEEK_PARITY_LABELS,
|
||||||
buildWeekOptions,
|
buildWeekOptions,
|
||||||
formatWeekRange,
|
formatWeekRange,
|
||||||
|
resolveClassStatus,
|
||||||
|
timeToMinutes,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
type ScheduleWeekView,
|
type ScheduleWeekView,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
@@ -26,6 +29,118 @@ const week = ref(appStore.currentWeek);
|
|||||||
const fromCache = ref(false);
|
const fromCache = ref(false);
|
||||||
const updatedAt = ref<number | null>(null);
|
const updatedAt = ref<number | null>(null);
|
||||||
const weekOptions = ref<number[]>(buildWeekOptions(20));
|
const weekOptions = ref<number[]>(buildWeekOptions(20));
|
||||||
|
/** 时间轴 / 周视图 切换(默认时间轴,对齐 ClassIsland 的"今天"观感) */
|
||||||
|
const activeTab = ref<'today' | 'week'>('today');
|
||||||
|
|
||||||
|
/** 当前时刻(每秒刷新,用于大时钟与"进行中/倒计时") */
|
||||||
|
const now = ref(new Date());
|
||||||
|
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const todayWeekday = computed(() => {
|
||||||
|
const day = now.value.getDay();
|
||||||
|
return day === 0 ? 7 : day;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clockText = computed(() =>
|
||||||
|
now.value.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }),
|
||||||
|
);
|
||||||
|
const clockSeconds = computed(() => String(now.value.getSeconds()).padStart(2, '0'));
|
||||||
|
const dateText = computed(() =>
|
||||||
|
now.value.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||||
|
);
|
||||||
|
const weekdayText = computed(() => WEEKDAY_LABELS[todayWeekday.value] ?? '');
|
||||||
|
|
||||||
|
/** 查看的是否为当前教学周(只有当前周才计算"进行中") */
|
||||||
|
const isCurrentWeek = computed(() => week.value === appStore.currentWeek);
|
||||||
|
|
||||||
|
const todayItems = computed(() =>
|
||||||
|
[...items.value]
|
||||||
|
.filter((item) => item.dayOfWeek === todayWeekday.value)
|
||||||
|
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const classStatus = computed(() =>
|
||||||
|
isCurrentWeek.value
|
||||||
|
? resolveClassStatus(todayItems.value, now.value, week.value)
|
||||||
|
: { inClass: false, current: null, next: null },
|
||||||
|
);
|
||||||
|
|
||||||
|
type LessonState = 'past' | 'current' | 'next' | 'upcoming';
|
||||||
|
|
||||||
|
interface TimelineLesson {
|
||||||
|
id: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
course: string;
|
||||||
|
location: string;
|
||||||
|
weekRange: string;
|
||||||
|
/** 单双周标签('单周' / '双周';每周的课为空串) */
|
||||||
|
parityLabel: string;
|
||||||
|
state: LessonState;
|
||||||
|
/** 进行中:距下课剩余;下一节:距上课剩余(分钟) */
|
||||||
|
remainMinutes: number;
|
||||||
|
/** 进行中:本节课进度 0~100 */
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = computed<TimelineLesson[]>(() =>
|
||||||
|
todayItems.value.map((item) => {
|
||||||
|
const start = timeToMinutes(item.startTime);
|
||||||
|
const end = timeToMinutes(item.endTime);
|
||||||
|
const nowMinutes = now.value.getHours() * 60 + now.value.getMinutes();
|
||||||
|
const isCurrent =
|
||||||
|
isCurrentWeek.value && classStatus.value.current?.scheduleId === item.id && nowMinutes < end;
|
||||||
|
const isNext = isCurrentWeek.value && classStatus.value.next?.scheduleId === item.id;
|
||||||
|
const state: LessonState = isCurrent
|
||||||
|
? 'current'
|
||||||
|
: nowMinutes >= end
|
||||||
|
? 'past'
|
||||||
|
: isNext
|
||||||
|
? 'next'
|
||||||
|
: 'upcoming';
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
start: item.startTime,
|
||||||
|
end: item.endTime,
|
||||||
|
course: item.course?.name ?? '课程',
|
||||||
|
location: item.location ?? '未填地点',
|
||||||
|
weekRange: formatWeekRange(item.weekStart, item.weekEnd),
|
||||||
|
/** 单双周标签(每周的课不显示) */
|
||||||
|
parityLabel: (item.weekParity ?? 'ALL') === 'ALL' ? '' : WEEK_PARITY_LABELS[item.weekParity],
|
||||||
|
state,
|
||||||
|
remainMinutes: state === 'current' ? end - nowMinutes : start - nowMinutes,
|
||||||
|
progress: state === 'current' ? Math.round(((nowMinutes - start) / Math.max(1, end - start)) * 100) : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 头部摘要:正在上课 / 下一节 */
|
||||||
|
const headline = computed(() => {
|
||||||
|
const status = classStatus.value;
|
||||||
|
if (status.current) {
|
||||||
|
return {
|
||||||
|
title: `正在上 ${status.current.courseName}`,
|
||||||
|
detail: `${status.current.startTime}-${status.current.endTime}${status.current.location ? ` · ${status.current.location}` : ''}`,
|
||||||
|
remain: timeline.value.find((item) => item.state === 'current')?.remainMinutes ?? 0,
|
||||||
|
kind: 'current' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status.next) {
|
||||||
|
const remain = timeline.value.find((item) => item.state === 'next')?.remainMinutes ?? 0;
|
||||||
|
return {
|
||||||
|
title: `下一节 ${status.next.courseName}`,
|
||||||
|
detail: `${status.next.startTime} 开始${status.next.location ? ` · ${status.next.location}` : ''}`,
|
||||||
|
remain,
|
||||||
|
kind: 'next' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: todayItems.value.length > 0 ? '今天的课都上完了' : '今天没有课程安排',
|
||||||
|
detail: todayItems.value.length > 0 ? '好好休息一下,别忘了复习' : '可以看看作业和通知',
|
||||||
|
remain: 0,
|
||||||
|
kind: 'idle' as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const periods = computed(() => {
|
const periods = computed(() => {
|
||||||
const buckets = new Map<string, ScheduleDto[]>();
|
const buckets = new Map<string, ScheduleDto[]>();
|
||||||
@@ -47,15 +162,17 @@ function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): Schedul
|
|||||||
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
|
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);
|
const courseCount = computed(() => new Set(items.value.map((item) => item.courseId)).size);
|
||||||
|
|
||||||
|
function remainText(minutes: number): string {
|
||||||
|
if (minutes <= 0) return '即将下课';
|
||||||
|
if (minutes < 60) return `${minutes} 分钟`;
|
||||||
|
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTerm(): Promise<void> {
|
async function loadTerm(): Promise<void> {
|
||||||
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
|
// 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周)
|
||||||
|
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(auth.classId ?? undefined), {
|
||||||
currentWeek: 1,
|
currentWeek: 1,
|
||||||
maxWeek: 20,
|
maxWeek: 20,
|
||||||
});
|
});
|
||||||
@@ -110,11 +227,16 @@ function onRecovered(): void {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadTerm();
|
await loadTerm();
|
||||||
await loadSchedules();
|
await loadSchedules();
|
||||||
|
clockTimer = setInterval(() => {
|
||||||
|
now.value = new Date();
|
||||||
|
}, 1000);
|
||||||
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
||||||
appStore.onServerRecovered(onRecovered);
|
appStore.onServerRecovered(onRecovered);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (clockTimer) clearInterval(clockTimer);
|
||||||
|
clockTimer = null;
|
||||||
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
|
||||||
appStore.offServerRecovered(onRecovered);
|
appStore.offServerRecovered(onRecovered);
|
||||||
});
|
});
|
||||||
@@ -126,26 +248,119 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">我的课表</h2>
|
<h2 class="page-title">我的课表</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
第 {{ week }} 周 · 共 {{ items.length }} 节课 · {{ courseCount }} 门课程
|
第 {{ week }} 周 · 今天 {{ todayItems.length }} 节 · 本周共 {{ items.length }} 节 ·
|
||||||
<el-tag v-if="fromCache" class="mt-12" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
{{ courseCount }} 门课程
|
||||||
|
<el-tag v-if="fromCache" class="ml-8" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)">上一周</el-button>
|
<el-select v-model="week" style="width: 118px" @change="loadSchedules">
|
||||||
<el-select v-model="week" style="width: 120px" @change="loadSchedules">
|
|
||||||
<el-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
|
<el-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)" />
|
||||||
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
|
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
|
||||||
下一周
|
<el-icon><ArrowRight /></el-icon>
|
||||||
<el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
|
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card v-loading="loading" shadow="never">
|
<el-tabs v-model="activeTab" class="schedule-tabs">
|
||||||
|
<!-- ---------------------------------------------------- 今天(时间轴) -->
|
||||||
|
<el-tab-pane label="今天" name="today">
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero-clock">
|
||||||
|
<span class="clock-main">{{ clockText }}</span>
|
||||||
|
<span class="clock-seconds">{{ clockSeconds }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<div class="hero-date">{{ dateText }} · {{ weekdayText }}</div>
|
||||||
|
<div class="hero-week">第 {{ week }} 教学周{{ isCurrentWeek ? '' : '(查看中)' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-status" :class="`status-${headline.kind}`">
|
||||||
|
<div class="status-title">{{ headline.title }}</div>
|
||||||
|
<div class="status-detail">{{ headline.detail }}</div>
|
||||||
|
<div v-if="headline.kind !== 'idle'" class="status-remain">
|
||||||
|
{{ headline.kind === 'current' ? '距下课' : '距上课' }} {{ remainText(headline.remain) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card v-loading="loading" shadow="never" class="timeline-card">
|
||||||
|
<el-empty v-if="timeline.length === 0" description="今天没有课程安排" :image-size="90" />
|
||||||
|
<ul v-else class="timeline">
|
||||||
|
<li
|
||||||
|
v-for="lesson in timeline"
|
||||||
|
:key="lesson.id"
|
||||||
|
class="timeline-item"
|
||||||
|
:class="`is-${lesson.state}`"
|
||||||
|
:data-state="lesson.state"
|
||||||
|
>
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-start">{{ lesson.start }}</span>
|
||||||
|
<span class="time-end">{{ lesson.end }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-rail" aria-hidden="true"><span class="rail-dot"></span></div>
|
||||||
|
<div class="timeline-card-inner">
|
||||||
|
<div class="lesson-head">
|
||||||
|
<span class="lesson-course">{{ lesson.course }}</span>
|
||||||
|
<el-tag v-if="lesson.state === 'current'" size="small" effect="dark" class="lesson-tag">
|
||||||
|
正在上课
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-else-if="lesson.state === 'next'"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
effect="plain"
|
||||||
|
class="lesson-tag"
|
||||||
|
>
|
||||||
|
下一节 · {{ remainText(lesson.remainMinutes) }}后
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-else-if="lesson.state === 'past'"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="lesson-tag"
|
||||||
|
>
|
||||||
|
已结束
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-meta">
|
||||||
|
<span>{{ lesson.location }}</span>
|
||||||
|
<span class="lesson-sep">·</span>
|
||||||
|
<span>{{ lesson.weekRange }}</span>
|
||||||
|
<!-- 单双周:只有单周/双周上的课才打标签,避免"每周"的课被噪音刷屏 -->
|
||||||
|
<template v-if="lesson.parityLabel">
|
||||||
|
<span class="lesson-sep">·</span>
|
||||||
|
<el-tag size="small" effect="plain" class="lesson-parity">
|
||||||
|
{{ lesson.parityLabel }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="lesson.state === 'current'">
|
||||||
|
<span class="lesson-sep">·</span>
|
||||||
|
<span class="lesson-remain">距下课 {{ remainText(lesson.remainMinutes) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
v-if="lesson.state === 'current'"
|
||||||
|
class="lesson-progress"
|
||||||
|
:percentage="lesson.progress"
|
||||||
|
:stroke-width="4"
|
||||||
|
:show-text="false"
|
||||||
|
color="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ---------------------------------------------------- 本周(周视图) -->
|
||||||
|
<el-tab-pane label="本周" name="week">
|
||||||
|
<el-card v-loading="loading" shadow="never" class="table-card">
|
||||||
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
|
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
|
||||||
<el-table v-else :data="periods" border size="small" :cell-class-name="() => ''">
|
<el-table v-else :data="periods" border size="small">
|
||||||
<el-table-column label="节次" width="110" fixed>
|
<el-table-column label="节次" width="110" fixed>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div>{{ row.start }}</div>
|
<div>{{ row.start }}</div>
|
||||||
@@ -168,5 +383,289 @@ onUnmounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.schedule-tabs :deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 顶部概览卡(ClassIsland 的"当前课"卡片) */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--ch-border);
|
||||||
|
border-radius: var(--ch-radius-card);
|
||||||
|
background: var(--ch-layer);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.1);
|
||||||
|
box-shadow: var(--ch-shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-clock {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-main {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-seconds {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ch-text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-right: 18px;
|
||||||
|
border-right: 1px solid var(--ch-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-date {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-week {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-status {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--ch-radius-control);
|
||||||
|
background: var(--ch-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-status.status-current {
|
||||||
|
background: linear-gradient(135deg, var(--ch-accent) 0%, #2b7fc4 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-status.status-idle {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-detail {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-remain {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-status.status-current .status-detail,
|
||||||
|
.hero-status.status-current .status-remain {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 时间轴 */
|
||||||
|
|
||||||
|
.timeline-card :deep(.el-card__body) {
|
||||||
|
padding: 8px 16px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time {
|
||||||
|
flex: 0 0 66px;
|
||||||
|
padding-top: 10px;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-start {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-end {
|
||||||
|
display: block;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ch-text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 竖向轨道 + 节点:ClassIsland 时间轴的标志性观感 */
|
||||||
|
.timeline-rail {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-rail::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: -16px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--ch-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child .timeline-rail::before {
|
||||||
|
bottom: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-dot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 14px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid var(--ch-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.is-current .rail-dot {
|
||||||
|
border-color: var(--ch-accent);
|
||||||
|
background: var(--ch-accent);
|
||||||
|
box-shadow: 0 0 0 4px var(--ch-accent-soft-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.is-past .rail-dot {
|
||||||
|
border-color: var(--ch-border);
|
||||||
|
background: var(--ch-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-card-inner {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--ch-border);
|
||||||
|
border-radius: var(--ch-radius-card);
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:hover .timeline-card-inner {
|
||||||
|
border-color: var(--ch-border-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 正在进行:强调色实底(ClassIsland 用它突出"当前课") */
|
||||||
|
.timeline-item.is-current .timeline-card-inner {
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--ch-accent) 0%, #2b7fc4 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 108, 189, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.is-past .timeline-card-inner {
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
color: var(--ch-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-course {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-tag {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-meta {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.is-current .lesson-meta {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.is-past .lesson-meta {
|
||||||
|
color: var(--ch-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-sep {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单双周标签:比正文小一号,颜色跟随主题色,避免抢标题 */
|
||||||
|
.lesson-parity {
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ch-accent, #409eff);
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-remain {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-progress {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-progress :deep(.el-progress-bar__outer) {
|
||||||
|
background: rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.hero {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta {
|
||||||
|
border-right: none;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, 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 { PRIORITY_LABELS, formatDate, type IslandState } from '@classhelper/shared';
|
import {
|
||||||
|
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||||
|
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||||
|
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||||
|
HOMEWORK_PHRASE_DEFAULTS,
|
||||||
|
HOMEWORK_PHRASE_MAX_COUNT,
|
||||||
|
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||||
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
|
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||||
|
ISLAND_POSITION_LABELS,
|
||||||
|
ISLAND_STYLE_LABELS,
|
||||||
|
ISLAND_STYLES,
|
||||||
|
ISLAND_POSITIONS,
|
||||||
|
formatDate,
|
||||||
|
type ClassIslandNotificationChannel,
|
||||||
|
type IslandAppearance,
|
||||||
|
type IslandPosition,
|
||||||
|
type IslandStyle,
|
||||||
|
} from '@classhelper/shared';
|
||||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||||
|
import { classIslandStatusApi } from '../api/index.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';
|
||||||
@@ -103,75 +121,211 @@ 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;
|
*
|
||||||
|
* - both:ClassHelper 客户端(弹窗 + 灵动岛)与 ClassIsland 都弹(默认)
|
||||||
|
* - client:只弹 ClassHelper 客户端
|
||||||
|
* - classisland:只弹 ClassIsland(客户端只进通知中心,不弹窗、不上岛)
|
||||||
|
*
|
||||||
|
* 值会落本地配置(本机立刻生效)并写回班级记录(服务端据此决定要不要推 ClassIsland)。
|
||||||
|
*/
|
||||||
|
const channelSaving = ref(false);
|
||||||
|
/**
|
||||||
|
* 本班有没有接入 ClassIsland 设备。
|
||||||
|
*
|
||||||
|
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
|
||||||
|
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||||
|
*/
|
||||||
|
const classIslandConnected = ref(false);
|
||||||
|
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
|
||||||
|
const classIslandStatus = ref<{
|
||||||
|
connected: boolean;
|
||||||
|
deviceName: string | null;
|
||||||
|
deviceCount: number;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
pluginVersion: string | null;
|
||||||
|
classIslandVersion: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
/** 主进程侧灵动岛状态(隐藏/胶囊/详情 + 待发条数 + 是否上课) */
|
const classIslandStatusText = computed(() => {
|
||||||
async function refreshIslandState(): Promise<void> {
|
const status = classIslandStatus.value;
|
||||||
islandClassContext.value = getIslandClassContext();
|
if (!status || !status.connected) return '未接入';
|
||||||
if (!window.desktop?.islandGetState) return;
|
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
|
||||||
islandState.value = await window.desktop.islandGetState();
|
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
|
||||||
}
|
|
||||||
|
|
||||||
const islandModeLabel = computed(() => {
|
|
||||||
switch (islandState.value?.mode) {
|
|
||||||
case 'pill':
|
|
||||||
return '新消息胶囊';
|
|
||||||
case 'expanded':
|
|
||||||
return '展开详情';
|
|
||||||
case 'hidden':
|
|
||||||
return '隐藏(无通知时不显示)';
|
|
||||||
default:
|
|
||||||
return '未知';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const islandHint = computed(() => {
|
const channelOptions = computed(() =>
|
||||||
const state = islandState.value;
|
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
||||||
if (!state) return '灵动岛尚未就绪';
|
value,
|
||||||
if (islandClassContext.value.inClass) {
|
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
|
||||||
return state.active?.priority === 'URGENT'
|
disabled: value === 'classisland' && !classIslandConnected.value,
|
||||||
? '上课中:正在显示紧急通知'
|
})),
|
||||||
: '上课中:通知会暂存,下课后自动弹出(紧急通知除外)';
|
);
|
||||||
}
|
|
||||||
if (state.queued.length > 0) return `有 ${state.queued.length} 条通知待查看`;
|
|
||||||
return '收到通知时会在这里浮出胶囊';
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 本地推一条测试通知,立刻确认灵动岛能出现(走主进程,不依赖服务器) */
|
async function loadClassChannel(): Promise<void> {
|
||||||
function testIsland(): void {
|
const classId = auth.classId;
|
||||||
if (!window.desktop?.islandPush) {
|
if (!classId) return;
|
||||||
ElMessage.error('当前环境不支持灵动岛');
|
await appStore.loadNotificationChannel(classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
|
||||||
|
channelSaving.value = true;
|
||||||
|
try {
|
||||||
|
const saved = await appStore.setNotificationChannel(auth.classId, value);
|
||||||
|
if (saved) ElMessage.success('提醒显示位置已更新');
|
||||||
|
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
|
||||||
|
} finally {
|
||||||
|
channelSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 作业录入快捷短语 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
|
||||||
|
*
|
||||||
|
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
|
||||||
|
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
|
||||||
|
*/
|
||||||
|
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
|
||||||
|
const phraseInput = ref('');
|
||||||
|
const savingPhrases = ref(false);
|
||||||
|
|
||||||
|
async function loadPhrases(): Promise<void> {
|
||||||
|
const config = await window.desktop?.getConfig?.();
|
||||||
|
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePhrases(): Promise<void> {
|
||||||
|
if (!window.desktop?.saveConfig) return;
|
||||||
|
savingPhrases.value = true;
|
||||||
|
try {
|
||||||
|
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
|
||||||
|
phrases.value = [...saved.homeworkPhrases];
|
||||||
|
} finally {
|
||||||
|
savingPhrases.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPhrase(): Promise<void> {
|
||||||
|
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
|
||||||
|
if (!value) return;
|
||||||
|
if (phrases.value.includes(value)) {
|
||||||
|
ElMessage.warning('这条短语已经有了');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.desktop.islandPush({
|
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
|
||||||
|
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
phrases.value = [...phrases.value, value];
|
||||||
|
phraseInput.value = '';
|
||||||
|
await savePhrases();
|
||||||
|
ElMessage.success('已添加');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePhrase(value: string): Promise<void> {
|
||||||
|
phrases.value = phrases.value.filter((item) => item !== value);
|
||||||
|
await savePhrases();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPhrases(): Promise<void> {
|
||||||
|
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
|
||||||
|
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
|
||||||
|
await savePhrases();
|
||||||
|
ElMessage.success('已恢复默认');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 灵动岛个性化 */
|
||||||
|
|
||||||
|
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左右边距是否有效:只在停靠左 / 右时生效。
|
||||||
|
*
|
||||||
|
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
|
||||||
|
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
|
||||||
|
*/
|
||||||
|
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
|
||||||
|
const savingIsland = ref(false);
|
||||||
|
|
||||||
|
const positionOptions = ISLAND_POSITIONS.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: ISLAND_POSITION_LABELS[value],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const styleOptions = ISLAND_STYLES.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: ISLAND_STYLE_LABELS[value],
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 读取当前生效的外观(主进程为单一事实来源) */
|
||||||
|
async function loadIslandAppearance(): Promise<void> {
|
||||||
|
if (!window.desktop?.islandGetAppearance) return;
|
||||||
|
island.value = await window.desktop.islandGetAppearance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
|
||||||
|
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
|
||||||
|
*
|
||||||
|
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
|
||||||
|
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
|
||||||
|
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
|
||||||
|
*/
|
||||||
|
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
|
||||||
|
island.value = { ...island.value, ...patch };
|
||||||
|
window.desktop?.islandSetAppearance({ ...island.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveIslandAppearance(): Promise<void> {
|
||||||
|
if (!window.desktop?.saveConfig) return;
|
||||||
|
savingIsland.value = true;
|
||||||
|
try {
|
||||||
|
await window.desktop.saveConfig({ island: { ...island.value } });
|
||||||
|
ElMessage.success('个性化设置已保存');
|
||||||
|
} finally {
|
||||||
|
savingIsland.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetIslandAppearance(): Promise<void> {
|
||||||
|
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
|
||||||
|
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||||
|
await saveIslandAppearance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
|
||||||
|
function testIsland(): void {
|
||||||
|
window.desktop?.islandPush?.({
|
||||||
notification: {
|
notification: {
|
||||||
id: `local-test-${Date.now()}`,
|
id: `appearance-test-${Date.now()}`,
|
||||||
title: '灵动岛自检通知',
|
title: '灵动岛外观预览',
|
||||||
content: '如果你看到这条胶囊/卡片,说明灵动岛工作正常;点击它可以展开查看详情。',
|
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
|
||||||
priority: 'NORMAL',
|
priority: 'NORMAL',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
courseName: null,
|
courseName: null,
|
||||||
teacherName: '本地自检',
|
teacherName: '本地预览',
|
||||||
},
|
},
|
||||||
context: islandClassContext.value,
|
context: { inClass: false, currentPeriodEnd: null, preview: true },
|
||||||
});
|
});
|
||||||
void refreshIslandState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadAppInfo();
|
await loadAppInfo();
|
||||||
await appStore.refreshCacheStats();
|
await appStore.refreshCacheStats();
|
||||||
await refreshIslandState();
|
await loadIslandAppearance();
|
||||||
islandTimer = setInterval(() => void refreshIslandState(), 2000);
|
await loadClassChannel();
|
||||||
});
|
await loadPhrases();
|
||||||
|
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
||||||
onUnmounted(() => {
|
if (auth.classId) {
|
||||||
if (islandTimer) clearInterval(islandTimer);
|
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
||||||
islandTimer = null;
|
classIslandStatus.value = status;
|
||||||
|
classIslandConnected.value = status?.connected === true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -219,31 +373,246 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<el-card shadow="never" class="mt-12">
|
<el-card shadow="never" class="mt-12">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>灵动岛(桌面通知浮窗)</span>
|
<span>灵动岛 · 个性化</span>
|
||||||
</template>
|
</template>
|
||||||
<el-descriptions :column="1" border size="small">
|
<el-form label-width="96px" label-position="left">
|
||||||
<el-descriptions-item label="当前形态">
|
<el-form-item :label="`高度 ${island.height}px`">
|
||||||
<el-tag :type="islandState?.mode === 'hidden' ? 'info' : 'success'" size="small">
|
<el-slider
|
||||||
{{ islandModeLabel }}
|
:model-value="island.height"
|
||||||
</el-tag>
|
:min="RANGES.height.min"
|
||||||
<el-tag class="ml-8" :type="islandClassContext.inClass ? 'warning' : 'success'" size="small">
|
:max="RANGES.height.max"
|
||||||
{{ islandClassContext.inClass ? '上课中(普通通知暂存)' : '非上课时段' }}
|
:step="1"
|
||||||
</el-tag>
|
@input="(value: number) => applyIslandAppearance({ height: value })"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="`宽度 ${island.width}px`">
|
||||||
|
<el-slider
|
||||||
|
:model-value="island.width"
|
||||||
|
:min="RANGES.width.min"
|
||||||
|
:max="RANGES.width.max"
|
||||||
|
:step="2"
|
||||||
|
@input="(value: number) => applyIslandAppearance({ width: value })"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="`圆角 ${island.radius}px`">
|
||||||
|
<el-slider
|
||||||
|
:model-value="island.radius"
|
||||||
|
:min="RANGES.radius.min"
|
||||||
|
:max="RANGES.radius.max"
|
||||||
|
:step="1"
|
||||||
|
@input="(value: number) => applyIslandAppearance({ radius: value })"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
|
||||||
|
<el-slider
|
||||||
|
:model-value="island.opacity"
|
||||||
|
:min="RANGES.opacity.min"
|
||||||
|
:max="RANGES.opacity.max"
|
||||||
|
:step="0.05"
|
||||||
|
@input="(value: number) => applyIslandAppearance({ opacity: value })"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="`字号 ${island.fontSize}px`">
|
||||||
|
<el-slider
|
||||||
|
:model-value="island.fontSize"
|
||||||
|
:min="RANGES.fontSize.min"
|
||||||
|
:max="RANGES.fontSize.max"
|
||||||
|
:step="1"
|
||||||
|
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
|
||||||
|
<el-slider
|
||||||
|
:model-value="island.speed"
|
||||||
|
:min="RANGES.speed.min"
|
||||||
|
:max="RANGES.speed.max"
|
||||||
|
:step="0.1"
|
||||||
|
@input="(value: number) => applyIslandAppearance({ speed: value })"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="主题色">
|
||||||
|
<el-color-picker
|
||||||
|
:model-value="island.accent"
|
||||||
|
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
|
||||||
|
/>
|
||||||
|
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="视觉风格">
|
||||||
|
<el-select
|
||||||
|
:model-value="island.style"
|
||||||
|
style="width: 180px"
|
||||||
|
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in styleOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="显示位置">
|
||||||
|
<el-select
|
||||||
|
:model-value="island.position"
|
||||||
|
style="width: 180px"
|
||||||
|
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in positionOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="`左右边距 ${island.marginX}px`">
|
||||||
|
<el-slider
|
||||||
|
:model-value="island.marginX"
|
||||||
|
:min="RANGES.marginX.min"
|
||||||
|
:max="RANGES.marginX.max"
|
||||||
|
:step="2"
|
||||||
|
:disabled="!marginXApplies"
|
||||||
|
@input="(value: number) => applyIslandAppearance({ marginX: value })"
|
||||||
|
/>
|
||||||
|
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
|
||||||
|
<span v-else class="text-muted ml-8">
|
||||||
|
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
|
||||||
|
</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="`上下边距 ${island.marginY}px`">
|
||||||
|
<el-slider
|
||||||
|
:model-value="island.marginY"
|
||||||
|
:min="RANGES.marginY.min"
|
||||||
|
:max="RANGES.marginY.max"
|
||||||
|
:step="2"
|
||||||
|
@input="(value: number) => applyIslandAppearance({ marginY: value })"
|
||||||
|
/>
|
||||||
|
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="跟随鼠标屏幕">
|
||||||
|
<el-switch
|
||||||
|
:model-value="island.followCursorDisplay"
|
||||||
|
@change="
|
||||||
|
(value: boolean | string | number) =>
|
||||||
|
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="动画">
|
||||||
|
<el-switch
|
||||||
|
:model-value="island.animations"
|
||||||
|
@change="
|
||||||
|
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="始终置顶">
|
||||||
|
<el-switch
|
||||||
|
:model-value="island.alwaysOnTop"
|
||||||
|
@change="
|
||||||
|
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="空闲细缝">
|
||||||
|
<el-switch
|
||||||
|
:model-value="island.idleSliver"
|
||||||
|
@change="
|
||||||
|
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
|
||||||
|
保存设置
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="testIsland">预览效果</el-button>
|
||||||
|
<el-button @click="resetIslandAppearance">恢复默认</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
class="mt-12"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>ClassIsland 联动</span></template>
|
||||||
|
<el-descriptions :column="1" border size="small" class="mb-12">
|
||||||
|
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="插件 / ClassIsland">
|
||||||
|
{{ classIslandStatus?.pluginVersion ?? '—' }} /
|
||||||
|
{{ classIslandStatus?.classIslandVersion ?? '—' }}
|
||||||
</el-descriptions-item>
|
</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>
|
</el-descriptions>
|
||||||
|
<p class="text-muted">
|
||||||
|
<strong>通知模式</strong>
|
||||||
|
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||||
|
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
|
||||||
|
</p>
|
||||||
|
<el-radio-group
|
||||||
|
:model-value="appStore.notificationChannel"
|
||||||
|
:disabled="channelSaving"
|
||||||
|
class="channel-group"
|
||||||
|
@change="
|
||||||
|
(value: string | number | boolean | undefined) =>
|
||||||
|
applyChannel(value as ClassIslandNotificationChannel)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<el-radio
|
||||||
|
v-for="item in channelOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<p class="text-muted channel-hint">
|
||||||
|
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
|
||||||
|
<template v-if="!classIslandConnected">
|
||||||
|
<br />
|
||||||
|
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
|
||||||
|
ClassIsland 上弹")。
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>作业录入</span></template>
|
||||||
|
<p class="text-muted">
|
||||||
|
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
|
||||||
|
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
|
||||||
|
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
|
||||||
|
</p>
|
||||||
|
<div class="phrase-row">
|
||||||
|
<el-tag
|
||||||
|
v-for="item in phrases"
|
||||||
|
:key="item"
|
||||||
|
closable
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
@close="removePhrase(item)"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
|
||||||
|
</div>
|
||||||
<div class="toolbar mt-12">
|
<div class="toolbar mt-12">
|
||||||
<el-button type="primary" @click="testIsland">测试灵动岛</el-button>
|
<el-input
|
||||||
<el-button @click="refreshIslandState">刷新状态</el-button>
|
v-model="phraseInput"
|
||||||
|
placeholder="新增短语,如 大本"
|
||||||
|
style="width: 200px"
|
||||||
|
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
|
||||||
|
@keyup.enter="addPhrase"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
|
||||||
|
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -251,7 +620,12 @@ onUnmounted(() => {
|
|||||||
<template #header><span>账号信息</span></template>
|
<template #header><span>账号信息</span></template>
|
||||||
<el-descriptions :column="1" border size="small">
|
<el-descriptions :column="1" border size="small">
|
||||||
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
|
<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.isClassSession ? '班级码' : '用户名'">
|
||||||
|
{{ auth.user?.username ?? '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="登录方式">
|
||||||
|
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '个人学生账号' }}
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</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-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
@@ -302,6 +676,26 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.channel-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.ml-8 {
|
.ml-8 {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,27 @@
|
|||||||
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
|
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
|
||||||
* 渲染进程只依赖这些最小 API,不接触 Node.js。
|
* 渲染进程只依赖这些最小 API,不接触 Node.js。
|
||||||
*/
|
*/
|
||||||
import type { IslandNotification, IslandState } from '@classhelper/shared';
|
import type {
|
||||||
|
ClassIslandNotificationChannel,
|
||||||
|
IslandAppearance,
|
||||||
|
IslandNotification,
|
||||||
|
IslandState,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
|
||||||
|
/** 作业页展示偏好(看板 / 列表,看板外观) */
|
||||||
|
export interface HomeworkBoardSettings {
|
||||||
|
/** 展示模式:board = 按科目卡片看板,list = 默认表格 */
|
||||||
|
mode: 'board' | 'list';
|
||||||
|
/** 看板条目是否显示时间(同时也控制"今日作业看板"标题栏中间的当天时间) */
|
||||||
|
showTime: boolean;
|
||||||
|
/** 看板字号(px) */
|
||||||
|
fontSize: number;
|
||||||
|
/**
|
||||||
|
* @deprecated 已由「按天查看 + 日期选择器」取代(作业页不再有"只看今天"开关)。
|
||||||
|
* 保留字段只为兼容旧配置文件,读写都忽略它。
|
||||||
|
*/
|
||||||
|
todayOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopStoredConfig {
|
export interface DesktopStoredConfig {
|
||||||
/** 后端服务地址,例如 http://127.0.0.1:4000 */
|
/** 后端服务地址,例如 http://127.0.0.1:4000 */
|
||||||
@@ -11,6 +31,22 @@ export interface DesktopStoredConfig {
|
|||||||
username: string;
|
username: string;
|
||||||
/** 登录令牌(主进程用 safeStorage 加密后落盘,读回时自动解密) */
|
/** 登录令牌(主进程用 safeStorage 加密后落盘,读回时自动解密) */
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
/** 个性化设置:灵动岛外观(高度/宽度/圆角/透明度/主题色/字号/动画/位置/置顶) */
|
||||||
|
island: IslandAppearance;
|
||||||
|
/** 作业页展示偏好(看板/列表、显示时间、看板字号) */
|
||||||
|
homeworkBoard: HomeworkBoardSettings;
|
||||||
|
/**
|
||||||
|
* 作业录入的快捷短语(客户端「设置 → 作业录入」里增删,默认见 shared 的 HOMEWORK_PHRASE_DEFAULTS)。
|
||||||
|
* 录入作业时点一下就追加到标题/内容里,少打字(例如 P、大本、背诵)。
|
||||||
|
*/
|
||||||
|
homeworkPhrases: string[];
|
||||||
|
/**
|
||||||
|
* 通知显示位置(both / client / classisland)。
|
||||||
|
*
|
||||||
|
* 这台教室机器"提醒到底弹在哪个端"由它决定:值同时存本地(决定本机要不要弹窗/上岛)
|
||||||
|
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
|
||||||
|
*/
|
||||||
|
notificationChannel: ClassIslandNotificationChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopAppInfo {
|
export interface DesktopAppInfo {
|
||||||
@@ -31,6 +67,8 @@ export interface IslandPushContext {
|
|||||||
/** 当前这节课的结束时间(HH:mm) */
|
/** 当前这节课的结束时间(HH:mm) */
|
||||||
currentPeriodEnd?: string | null;
|
currentPeriodEnd?: string | null;
|
||||||
week?: number;
|
week?: number;
|
||||||
|
/** 设置页"预览效果":直接展开示例岛、失焦不收起、30 秒后自行消失 */
|
||||||
|
preview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IslandClassStatePayload {
|
export interface IslandClassStatePayload {
|
||||||
@@ -43,25 +81,58 @@ export interface DesktopBridge {
|
|||||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
||||||
smokeTest: boolean;
|
smokeTest: boolean;
|
||||||
getConfig(): Promise<DesktopStoredConfig>;
|
getConfig(): Promise<DesktopStoredConfig>;
|
||||||
|
/**
|
||||||
|
* 局部更新配置。
|
||||||
|
*
|
||||||
|
* **必须传纯数据(普通对象 / 基本类型)**:传 Vue 的 `ref.value` / `reactive()` 对象
|
||||||
|
* 会在 contextBridge 跨隔离世界时被结构化克隆拒绝,抛
|
||||||
|
* `Error: An object could not be cloned.`,而且调用点看起来"只是没生效"。
|
||||||
|
* 页面里请写成 `saveConfig({ island: { ...island.value } })` 这种展开后的字面量。
|
||||||
|
*/
|
||||||
saveConfig(patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig>;
|
saveConfig(patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig>;
|
||||||
clearConfig(): Promise<DesktopStoredConfig>;
|
clearConfig(): Promise<DesktopStoredConfig>;
|
||||||
getAppInfo(): Promise<DesktopAppInfo>;
|
getAppInfo(): Promise<DesktopAppInfo>;
|
||||||
openExternal(url: string): Promise<boolean>;
|
openExternal(url: string): Promise<boolean>;
|
||||||
|
|
||||||
/* 灵动岛 */
|
/* 灵动岛 */
|
||||||
/** 把一条通知投递到灵动岛 */
|
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
|
||||||
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
|
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
|
||||||
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
|
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
|
||||||
islandSetClassState(payload: IslandClassStatePayload): void;
|
islandSetClassState(payload: IslandClassStatePayload): void;
|
||||||
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
|
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
|
||||||
islandGetState(): Promise<IslandState>;
|
islandGetState(): Promise<IslandState>;
|
||||||
|
/**
|
||||||
|
* 应用灵动岛外观设置(实时生效)。
|
||||||
|
*
|
||||||
|
* 同 saveConfig:**必须是纯数据**。设置页曾把 `island`(`ref`)的响应式 Proxy 直接传进来,
|
||||||
|
* 结果 contextBridge 抛 `An object could not be cloned.`,主进程收不到任何东西 ——
|
||||||
|
* 现象就是"拖了滑块、数字在变,灵动岛毫无反应,保存也不生效"。
|
||||||
|
*/
|
||||||
|
islandSetAppearance(appearance: Partial<IslandAppearance>): void;
|
||||||
|
/** 读取当前生效的外观设置 */
|
||||||
|
islandGetAppearance(): Promise<IslandAppearance>;
|
||||||
|
/** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */
|
||||||
|
onIslandMarkRead(handler: (id: string) => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
|
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
|
||||||
export interface IslandRendererBridge {
|
export interface IslandRendererBridge {
|
||||||
onState(handler: (state: IslandState) => void): void;
|
onState(handler: (state: IslandState) => void): void;
|
||||||
|
/** 订阅外观设置(CSS 变量实时生效) */
|
||||||
|
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
||||||
|
getAppearance(): Promise<IslandAppearance>;
|
||||||
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
|
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
|
||||||
getState(): Promise<IslandState>;
|
getState(): Promise<IslandState>;
|
||||||
|
/**
|
||||||
|
* 命中提示:指针在岛体上时应让固定大窗口接收鼠标(比主进程 60ms 轮询更快)。
|
||||||
|
* **只会上报 true**:关闭命中(穿透)一律由主进程按真实光标决定,
|
||||||
|
* 两边都下发会让窗口在"接收/穿透"之间抖动(用户反馈的"点了没反应")。
|
||||||
|
*/
|
||||||
|
setInteractive(interactive: boolean): void;
|
||||||
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
|
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
|
||||||
|
alive(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -75,7 +146,10 @@ declare global {
|
|||||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
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(credentials?: {
|
||||||
|
code: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<{ ok: boolean; detail: string }>;
|
||||||
/** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */
|
/** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */
|
||||||
islandRealtimeScenario(): Promise<{
|
islandRealtimeScenario(): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -90,6 +164,17 @@ declare global {
|
|||||||
notificationId: string,
|
notificationId: string,
|
||||||
teacherToken: string,
|
teacherToken: string,
|
||||||
): Promise<{ ok: boolean; detail: string }>;
|
): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
/** 读取某条通知在通知中心里的已读状态(验证灵动岛"标为已读"链路) */
|
||||||
|
islandReadState(notificationId: string): Promise<{
|
||||||
|
found: boolean;
|
||||||
|
read: boolean;
|
||||||
|
unreadCount: number;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
/** ClassIsland 风格「今天」时间轴自检(造课 → 读 DOM → 清理) */
|
||||||
|
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
/** 作业看板全屏自适应自检(切看板 → 打开全屏 → 量尺寸) */
|
||||||
|
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"db:seed": "prisma db seed",
|
"db:seed": "prisma db seed",
|
||||||
"db:reset": "prisma migrate reset --force",
|
"db:reset": "prisma migrate reset --force",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"verify:e2e": "node scripts/verify-e2e.mjs"
|
"verify:e2e": "node scripts/verify-e2e.mjs",
|
||||||
|
"verify:classisland": "node scripts/verify-classisland.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@classhelper/shared": "workspace:*",
|
"@classhelper/shared": "workspace:*",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"xlsx": "0.18.5",
|
||||||
"zod": "^4.6.2"
|
"zod": "^4.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TimeLayout" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"classId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL DEFAULT 'classisland',
|
||||||
|
"items" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "TimeLayout_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TimeLayout_classId_idx" ON "TimeLayout"("classId");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 班级账号(班级码 + 班级密码):学生端以「班级」为主体登录
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- 1) code 是新增的必填唯一列。SQLite 无法直接给已有表加 "NOT NULL 且无默认值" 的列,
|
||||||
|
-- 因此先带默认值 '' 加上,再用 rowid 回填一版可用且唯一的班级码,最后建唯一索引。
|
||||||
|
-- 2) passwordHash 允许为 NULL:尚未设置班级密码的班级不允许班级登录,
|
||||||
|
-- 管理员可在「班级管理」里设置或重置(bcrypt 哈希由服务端生成)。
|
||||||
|
-- 3) 迁移逐条执行且忽略"已存在"错误,因此本文件重复执行是安全的。
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Class" ADD COLUMN "code" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Class" ADD COLUMN "passwordHash" TEXT;
|
||||||
|
|
||||||
|
-- Backfill:老数据按 rowid 生成 C00001 形式的班级码(管理员可随时改)
|
||||||
|
UPDATE "Class" SET "code" = 'C' || substr('00000' || CAST(rowid AS TEXT), -5) WHERE "code" = '';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 课表支持单双周:新增 weekParity(ALL=每周 / ODD=单周 / EVEN=双周)
|
||||||
|
-- 旧数据默认为 'ALL',行为与升级前完全一致(向后兼容)。
|
||||||
|
|
||||||
|
ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 班级教学周数(班主任可调):默认 20 周,旧数据行为不变(原来固定按 20 周展示,见 seed/导入默认值)
|
||||||
|
ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- ClassIsland 联动:设备接入 + 提醒下发
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- 1) IntegrationDevice 保存「班级 ↔ 一台装有联动插件的机器」的绑定关系,
|
||||||
|
-- 令牌只存 sha256 哈希,明文仅在创建/重置接口返回一次。
|
||||||
|
-- 2) 同一班级内 deviceKey 唯一:插件重装后带同一机器码会重绑原记录,避免设备列表堆重复项。
|
||||||
|
-- 3) ClassIslandPush 落库是为了「设备离线时通知不丢」:插件重连后拉取未确认的一条立即弹出。
|
||||||
|
-- 4) 本文件重复执行是安全的(逐条执行、忽略"已存在"错误)。
|
||||||
|
|
||||||
|
CREATE TABLE "IntegrationDevice" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"classId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"deviceKey" TEXT NOT NULL,
|
||||||
|
"mode" TEXT NOT NULL DEFAULT 'plugin',
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"tokenHint" TEXT NOT NULL,
|
||||||
|
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"classIslandVersion" TEXT,
|
||||||
|
"pluginVersion" TEXT,
|
||||||
|
"lastSeenAt" DATETIME,
|
||||||
|
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"currentSubject" TEXT,
|
||||||
|
"currentTimeState" TEXT,
|
||||||
|
"currentPeriodStart" TEXT,
|
||||||
|
"currentPeriodEnd" TEXT,
|
||||||
|
"nextSubject" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
|
||||||
|
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
|
||||||
|
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
|
||||||
|
|
||||||
|
CREATE TABLE "ClassIslandPush" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"classId" TEXT NOT NULL,
|
||||||
|
"deviceId" TEXT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"durationSeconds" INTEGER NOT NULL DEFAULT 8,
|
||||||
|
"speechContent" TEXT,
|
||||||
|
"urgent" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdBy" TEXT,
|
||||||
|
"notificationId" TEXT,
|
||||||
|
"ackedAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "ClassIslandPush_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "ClassIslandPush_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "IntegrationDevice" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "ClassIslandPush_classId_createdAt_idx" ON "ClassIslandPush"("classId", "createdAt");
|
||||||
|
CREATE INDEX "ClassIslandPush_deviceId_ackedAt_idx" ON "ClassIslandPush"("deviceId", "ackedAt");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 通知显示位置(由教室的班级客户端设置):both / client / classisland
|
||||||
|
-- 旧数据默认 both:行为与之前一致(客户端照常收,同时推给 ClassIsland)
|
||||||
|
ALTER TABLE "Class" ADD COLUMN "notificationChannel" TEXT NOT NULL DEFAULT 'both';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 作业所属日期(YYYY-MM-DD,本地日期):客户端/Web 的按天查看与日期高亮都用它
|
||||||
|
ALTER TABLE "Homework" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- 回填历史数据:按 UTC+8 把 createdAt 折算成当天(本产品面向国内教室,服务器在 CST)
|
||||||
|
UPDATE "Homework" SET "assignDate" = date("createdAt", '+8 hours') WHERE "assignDate" = '';
|
||||||
|
|
||||||
|
CREATE INDEX "Homework_classId_assignDate_idx" ON "Homework"("classId", "assignDate");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立即弹出)
|
||||||
|
ALTER TABLE "ClassIslandPush" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'notification';
|
||||||
@@ -54,6 +54,15 @@ model Class {
|
|||||||
name String
|
name String
|
||||||
grade String
|
grade String
|
||||||
teacherId String
|
teacherId String
|
||||||
|
/// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写)
|
||||||
|
code String @unique
|
||||||
|
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
|
||||||
|
passwordHash String?
|
||||||
|
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
|
||||||
|
termWeeks Int @default(20)
|
||||||
|
/// 通知显示位置(由教室的班级客户端在设置页里选):
|
||||||
|
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
|
||||||
|
notificationChannel String @default("both")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -66,6 +75,9 @@ model Class {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
grades Grade[]
|
grades Grade[]
|
||||||
enrollments Enrollment[]
|
enrollments Enrollment[]
|
||||||
|
timeLayouts TimeLayout[]
|
||||||
|
integrationDevices IntegrationDevice[]
|
||||||
|
classIslandPushes ClassIslandPush[]
|
||||||
|
|
||||||
@@index([teacherId])
|
@@index([teacherId])
|
||||||
}
|
}
|
||||||
@@ -132,6 +144,8 @@ model Schedule {
|
|||||||
weekStart Int @default(1)
|
weekStart Int @default(1)
|
||||||
/// 学期结束周次(含)
|
/// 学期结束周次(含)
|
||||||
weekEnd Int @default(20)
|
weekEnd Int @default(20)
|
||||||
|
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
|
||||||
|
weekParity String @default("ALL")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -149,7 +163,11 @@ model Homework {
|
|||||||
courseId String?
|
courseId String?
|
||||||
title String
|
title String
|
||||||
content String
|
content String
|
||||||
|
/// 作业所属日期(YYYY-MM-DD,本地日期,不带时区):客户端/Web 的按天查看与日期高亮都用它
|
||||||
|
assignDate String @default("")
|
||||||
attachmentUrl String?
|
attachmentUrl String?
|
||||||
|
/// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。
|
||||||
|
/// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。
|
||||||
dueAt DateTime?
|
dueAt DateTime?
|
||||||
createdBy String
|
createdBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -161,6 +179,7 @@ model Homework {
|
|||||||
statuses HomeworkStatus[]
|
statuses HomeworkStatus[]
|
||||||
|
|
||||||
@@index([classId, createdAt])
|
@@index([classId, createdAt])
|
||||||
|
@@index([classId, assignDate])
|
||||||
@@index([courseId])
|
@@index([courseId])
|
||||||
@@index([dueAt])
|
@@index([dueAt])
|
||||||
}
|
}
|
||||||
@@ -212,6 +231,24 @@ model NotificationRead {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// 课表时间配置(ClassIsland 同款 JSON 导入后的节次时间表)
|
||||||
|
model TimeLayout {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
classId String
|
||||||
|
name String
|
||||||
|
/// 来源:classisland | manual
|
||||||
|
source String @default("classisland")
|
||||||
|
/// 节次数组(JSON 字符串):[{ index, name, startTime, endTime, type, skipped }]
|
||||||
|
items String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([classId])
|
||||||
|
}
|
||||||
|
|
||||||
/// 成绩
|
/// 成绩
|
||||||
model Grade {
|
model Grade {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -233,3 +270,82 @@ model Grade {
|
|||||||
@@index([classId, examName])
|
@@index([classId, examName])
|
||||||
@@index([courseId])
|
@@index([courseId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
|
||||||
|
///
|
||||||
|
/// 说明:
|
||||||
|
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
|
||||||
|
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
|
||||||
|
/// 避免设备列表里堆满重复项;
|
||||||
|
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
|
||||||
|
model IntegrationDevice {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
classId String
|
||||||
|
/// 设备显示名(默认取机器名)
|
||||||
|
name String
|
||||||
|
/// 设备唯一标识(插件上报的机器码)
|
||||||
|
deviceKey String
|
||||||
|
/// 联动方式:plugin=实时插件联动 / import=仅一次性导入
|
||||||
|
mode String @default("plugin")
|
||||||
|
/// 是否接入(关闭后插件上报与下发一律 403)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
/// 设备令牌的 sha256(明文只在创建/重置时返回一次)
|
||||||
|
tokenHash String
|
||||||
|
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
|
||||||
|
tokenHint String
|
||||||
|
/// 是否允许插件把 ClassIsland 的课表回传到本服务
|
||||||
|
syncScheduleToServer Boolean @default(true)
|
||||||
|
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
|
||||||
|
mirrorScheduleToClassIsland Boolean @default(false)
|
||||||
|
classIslandVersion String?
|
||||||
|
pluginVersion String?
|
||||||
|
lastSeenAt DateTime?
|
||||||
|
/// 以下为最后一次上报的快照
|
||||||
|
classPlanLoaded Boolean @default(false)
|
||||||
|
currentSubject String?
|
||||||
|
currentTimeState String?
|
||||||
|
currentPeriodStart String?
|
||||||
|
currentPeriodEnd String?
|
||||||
|
nextSubject String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||||
|
pushes ClassIslandPush[]
|
||||||
|
|
||||||
|
@@unique([classId, deviceKey])
|
||||||
|
@@index([classId])
|
||||||
|
@@index([tokenHash])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下发给 ClassIsland 的提醒:教师发通知 → 学生机器全屏弹出。
|
||||||
|
/// 落库是为了「设备离线时通知不丢」——插件重连后拉取未确认的一条立即弹出。
|
||||||
|
model ClassIslandPush {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
classId String
|
||||||
|
deviceId String?
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
/// 显示时长(秒):映射到 ClassIsland 的 NotificationContent.Duration
|
||||||
|
durationSeconds Int @default(8)
|
||||||
|
/// 语音朗读内容;null = 不朗读
|
||||||
|
speechContent String?
|
||||||
|
urgent Boolean @default(false)
|
||||||
|
/// 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立刻弹)
|
||||||
|
kind String @default("notification")
|
||||||
|
/// 发起人的用户 id(教师/管理员)
|
||||||
|
createdBy String?
|
||||||
|
/// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
|
||||||
|
notificationId String?
|
||||||
|
/// 插件确认弹出的时间(确认后不再重复下发)
|
||||||
|
ackedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 过期时间:超过后不再补发(避免学生几天后突然弹出旧提醒)
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||||
|
device IntegrationDevice? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([classId, createdAt])
|
||||||
|
@@index([deviceId, ackedAt])
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
|
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
|
||||||
* student01..student15 / student123
|
* student01..student15 / student123
|
||||||
*/
|
*/
|
||||||
|
import { SUBJECT_CATALOG } from '@classhelper/shared';
|
||||||
import { disconnectPrisma, prisma } from '../src/lib/db.js';
|
import { disconnectPrisma, prisma } from '../src/lib/db.js';
|
||||||
import { logger } from '../src/lib/logger.js';
|
import { logger } from '../src/lib/logger.js';
|
||||||
import { hashPassword } from '../src/lib/password.js';
|
import { hashPassword } from '../src/lib/password.js';
|
||||||
@@ -33,7 +34,8 @@ const STUDENT_NAMES = [
|
|||||||
'胡一鸣',
|
'胡一鸣',
|
||||||
];
|
];
|
||||||
|
|
||||||
const COURSE_NAMES = ['语文', '数学', '英语', '物理', '化学'];
|
// 课程目录:与 shared 的 SUBJECT_CATALOG 保持一致(演示数据覆盖全部常见科目)
|
||||||
|
const COURSE_NAMES = [...SUBJECT_CATALOG];
|
||||||
|
|
||||||
/** 每天的上课节次模板(dayOfWeek 1-5) */
|
/** 每天的上课节次模板(dayOfWeek 1-5) */
|
||||||
const PERIOD_TEMPLATE = [
|
const PERIOD_TEMPLATE = [
|
||||||
@@ -84,10 +86,12 @@ async function main(): Promise<void> {
|
|||||||
const random = createRandom(20260901);
|
const random = createRandom(20260901);
|
||||||
|
|
||||||
// 同一角色共用密码,只需各哈希一次
|
// 同一角色共用密码,只需各哈希一次
|
||||||
const [adminHash, teacherHash, studentHash] = await Promise.all([
|
const [adminHash, teacherHash, studentHash, classPasswordHash] = await Promise.all([
|
||||||
hashPassword('admin123'),
|
hashPassword('admin123'),
|
||||||
hashPassword('teacher123'),
|
hashPassword('teacher123'),
|
||||||
hashPassword('student123'),
|
hashPassword('student123'),
|
||||||
|
// 班级账号默认密码(学生端「班级登录」)
|
||||||
|
hashPassword('123456'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const admin = await prisma.user.create({
|
const admin = await prisma.user.create({
|
||||||
@@ -101,16 +105,23 @@ async function main(): Promise<void> {
|
|||||||
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
|
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
|
||||||
const classSeeds = [
|
const classSeeds = [
|
||||||
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id },
|
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
|
||||||
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id },
|
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
|
||||||
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id },
|
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const classes = [];
|
const classes = [];
|
||||||
for (const [index, seed] of classSeeds.entries()) {
|
for (const [index, seed] of classSeeds.entries()) {
|
||||||
const created = await prisma.class.create({
|
const created = await prisma.class.create({
|
||||||
data: { name: seed.name, grade: seed.grade, teacherId: seed.teacherId },
|
data: {
|
||||||
|
name: seed.name,
|
||||||
|
grade: seed.grade,
|
||||||
|
teacherId: seed.teacherId,
|
||||||
|
code: seed.code,
|
||||||
|
passwordHash: classPasswordHash,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
|
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
|
||||||
}
|
}
|
||||||
@@ -160,6 +171,8 @@ async function main(): Promise<void> {
|
|||||||
if (!course) continue;
|
if (!course) continue;
|
||||||
// 前 2 节限制在第 1 周(演示按周筛选)
|
// 前 2 节限制在第 1 周(演示按周筛选)
|
||||||
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
|
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
|
||||||
|
// 周二第 3 节演示"单双周":单周上次课表里的科目、双周上一门别的科目
|
||||||
|
const paritySlot = dayOfWeek === 2 && periodIndex === 2;
|
||||||
await prisma.schedule.create({
|
await prisma.schedule.create({
|
||||||
data: {
|
data: {
|
||||||
classId: targetClass.id,
|
classId: targetClass.id,
|
||||||
@@ -170,44 +183,60 @@ async function main(): Promise<void> {
|
|||||||
location: targetClass.room,
|
location: targetClass.room,
|
||||||
weekStart: 1,
|
weekStart: 1,
|
||||||
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
|
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
|
||||||
|
weekParity: paritySlot ? 'ODD' : 'ALL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (paritySlot) {
|
||||||
|
// 双周同一节换另一门课,用于演示"单双周课表"(第 1 周单周、第 2 周双周)
|
||||||
|
const evenCourse = classCourses[(dayOfWeek + periodIndex + 2) % classCourses.length];
|
||||||
|
if (evenCourse) {
|
||||||
|
await prisma.schedule.create({
|
||||||
|
data: {
|
||||||
|
classId: targetClass.id,
|
||||||
|
courseId: evenCourse.id,
|
||||||
|
dayOfWeek,
|
||||||
|
startTime: period.startTime,
|
||||||
|
endTime: period.endTime,
|
||||||
|
location: targetClass.room,
|
||||||
|
weekStart: 1,
|
||||||
|
weekEnd: MAX_TERM_WEEK,
|
||||||
|
weekParity: 'EVEN',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 作业
|
// ---------------------------------------------------------------- 作业
|
||||||
|
// 注:作业"截止时间"功能已下线,种子数据不再写入 dueAt(库字段保留以兼容已装库)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const day = 86_400_000;
|
const day = 86_400_000;
|
||||||
const homeworkSeeds = [
|
const homeworkSeeds = [
|
||||||
{
|
{
|
||||||
title: '《劝学》全文背诵',
|
title: '《劝学》全文背诵',
|
||||||
content: '背诵《劝学》并录制音频上传到班级群,注意断句。',
|
content: '背诵《劝学》并录制音频上传到班级群,注意断句。',
|
||||||
dueInDays: 3,
|
|
||||||
courseIndex: 0,
|
courseIndex: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '数学必修一 3.2 习题',
|
title: '数学必修一 3.2 习题',
|
||||||
content: '完成课本 P78 习题 3.2 全部题目,第 12 题选做。',
|
content: '完成课本 P78 习题 3.2 全部题目,第 12 题选做。',
|
||||||
dueInDays: 1,
|
|
||||||
courseIndex: 1,
|
courseIndex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '英语周记一篇',
|
title: '英语周记一篇',
|
||||||
content: '以"My School Life"为题写一篇 150 词左右的英语周记。',
|
content: '以"My School Life"为题写一篇 150 词左右的英语周记。',
|
||||||
dueInDays: 5,
|
|
||||||
courseIndex: 2,
|
courseIndex: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '物理实验报告',
|
title: '物理实验报告',
|
||||||
content: '整理"探究匀变速直线运动"实验数据,撰写实验报告。',
|
content: '整理"探究匀变速直线运动"实验数据,撰写实验报告。',
|
||||||
dueInDays: -2,
|
|
||||||
courseIndex: 3,
|
courseIndex: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '化学方程式默写',
|
title: '化学方程式默写',
|
||||||
content: '默写第一至第三章全部化学方程式,家长签字。',
|
content: '默写第一至第三章全部化学方程式,家长签字。',
|
||||||
dueInDays: -1,
|
|
||||||
courseIndex: 4,
|
courseIndex: 4,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -225,7 +254,6 @@ async function main(): Promise<void> {
|
|||||||
content: seed.content,
|
content: seed.content,
|
||||||
attachmentUrl:
|
attachmentUrl:
|
||||||
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
|
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
|
||||||
dueAt: new Date(now + seed.dueInDays * day),
|
|
||||||
createdBy: targetClass.teacherId,
|
createdBy: targetClass.teacherId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表);
|
||||||
|
* 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist**
|
||||||
|
* 部署,就需要显式跑一次本脚本。
|
||||||
|
*
|
||||||
|
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
|
||||||
|
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
|
||||||
|
*
|
||||||
|
* node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js"
|
||||||
|
*
|
||||||
|
* 环境变量(与服务端一致):
|
||||||
|
* DATABASE_PROVIDER=sqlite
|
||||||
|
* DATABASE_URL="file:<绝对路径>/classhelper.db"
|
||||||
|
*
|
||||||
|
* 幂等:已存在的列会跳过。
|
||||||
|
*/
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
|
|
||||||
|
const dbEntry = process.argv[2];
|
||||||
|
if (!dbEntry) {
|
||||||
|
console.error('用法:node apply-column-migrations.cjs <已编译的 lib/db.js 绝对路径>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 需要保证存在的列(新增迁移时在这里追加一行) */
|
||||||
|
const COLUMNS = [
|
||||||
|
{
|
||||||
|
table: 'Schedule',
|
||||||
|
column: 'weekParity',
|
||||||
|
sql: `ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL'`,
|
||||||
|
note: '课表单双周(ALL/ODD/EVEN)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'Class',
|
||||||
|
column: 'termWeeks',
|
||||||
|
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`,
|
||||||
|
note: '班级教学周数(班主任可调,默认 20)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let prisma;
|
||||||
|
try {
|
||||||
|
const module = await import(pathToFileURL(dbEntry).href);
|
||||||
|
prisma = module.prisma ?? module.default?.prisma;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[migrate] 无法加载服务端的 Prisma 客户端:',
|
||||||
|
error && error.message ? error.message : error,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!prisma) {
|
||||||
|
console.error('[migrate] lib/db.js 里没有导出 prisma');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const item of COLUMNS) {
|
||||||
|
const columns = await prisma.$queryRawUnsafe(`PRAGMA table_info("${item.table}")`);
|
||||||
|
if (columns.some((column) => column.name === item.column)) {
|
||||||
|
console.log(`[migrate] ${item.table}.${item.column} 已存在(跳过)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.$executeRawUnsafe(item.sql);
|
||||||
|
console.log(`[migrate] 已新增 ${item.table}.${item.column} —— ${item.note}`);
|
||||||
|
}
|
||||||
|
for (const table of [...new Set(COLUMNS.map((item) => item.table))]) {
|
||||||
|
const after = await prisma.$queryRawUnsafe(`PRAGMA table_info("${table}")`);
|
||||||
|
console.log(`[migrate] ${table} 列:${after.map((column) => column.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[migrate] 失败:', error && error.message ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect?.();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ClassIsland 联动验证(插件侧接口 + 下发链路)。
|
||||||
|
*
|
||||||
|
* 覆盖范围:
|
||||||
|
* 1) 设备令牌鉴权:无令牌 / 错令牌一律 401,令牌作废后立即失效
|
||||||
|
* 2) 插件上报 → 服务端写库(课表 + 节次时间 + 状态快照)幂等
|
||||||
|
* 3) 教师下发提醒 → 落库 → 插件 pending 拉到 → ack 后不再补发
|
||||||
|
* 4) 停用设备后插件上报被 403 拦下
|
||||||
|
* 5) 课表镜像拉取(mirrorScheduleToClassIsland)返回 ClassIsland 口径的数据
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* 1) 另开终端启动后端:pnpm dev:server
|
||||||
|
* 2) 执行:node packages/server/scripts/verify-classisland.mjs
|
||||||
|
*
|
||||||
|
* 环境变量:
|
||||||
|
* VERIFY_BASE_URL 默认 http://127.0.0.1:4000(脚本直连,不受系统代理影响)
|
||||||
|
*
|
||||||
|
* 注意:本脚本只操作「新建设备 + 该设备对应班级」的数据,
|
||||||
|
* 结束时会把新建的设备删除;上报的课表会写进该班(与导入模块语义一致,可被覆盖)。
|
||||||
|
*/
|
||||||
|
const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function record(name, ok, detail = '') {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? '\u2713' : '\u2717'} ${name}${detail ? ` (${detail})` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, { method = 'GET', token, deviceToken, body } = {}) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (deviceToken) headers['X-ClassIsland-Token'] = deviceToken;
|
||||||
|
const response = await fetch(`${BASE_URL}/api${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
return { status: response.status, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 造一份插件会上报的课表(周一/周二,含单双周) */
|
||||||
|
function sampleSchedule() {
|
||||||
|
return {
|
||||||
|
mode: 'merge',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
startTime: '08:00',
|
||||||
|
endTime: '08:45',
|
||||||
|
subject: '语文',
|
||||||
|
teacherName: '王老师',
|
||||||
|
weekParity: 'ALL',
|
||||||
|
weekCountDiv: 0,
|
||||||
|
weekCountDivTotal: 0,
|
||||||
|
planName: '周一课表',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
startTime: '08:55',
|
||||||
|
endTime: '09:40',
|
||||||
|
subject: '数学',
|
||||||
|
teacherName: '李老师',
|
||||||
|
weekParity: 'ODD',
|
||||||
|
weekCountDiv: 1,
|
||||||
|
weekCountDivTotal: 2,
|
||||||
|
planName: '周一课表',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dayOfWeek: 2,
|
||||||
|
startTime: '08:00',
|
||||||
|
endTime: '08:45',
|
||||||
|
subject: '英语',
|
||||||
|
teacherName: '张老师',
|
||||||
|
weekParity: 'ALL',
|
||||||
|
weekCountDiv: 0,
|
||||||
|
weekCountDivTotal: 0,
|
||||||
|
planName: '周二课表',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleTimeLayout() {
|
||||||
|
return {
|
||||||
|
name: 'ClassIsland 验证时间表',
|
||||||
|
mode: 'replace',
|
||||||
|
items: [
|
||||||
|
{ index: 1, name: '第 1 节', startTime: '08:00', endTime: '08:45', type: 'class', skipped: false },
|
||||||
|
{ index: 2, name: '课间', startTime: '08:45', endTime: '08:55', type: 'break', skipped: false },
|
||||||
|
{ index: 3, name: '第 2 节', startTime: '08:55', endTime: '09:40', type: 'class', skipped: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按候选密码依次登录,返回可用 token(不同环境的默认密码可能不同) */
|
||||||
|
async function loginWithCandidates(username, passwords) {
|
||||||
|
for (const password of passwords) {
|
||||||
|
const result = await api('/auth/login', { method: 'POST', body: { username, password } });
|
||||||
|
const token = result.payload?.data?.token;
|
||||||
|
if (token) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
|
||||||
|
|
||||||
|
const health = await api('/health');
|
||||||
|
if (health.status !== 200) {
|
||||||
|
console.log('后端未启动,请先运行 pnpm dev:server\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const modules = health.payload?.data?.modules ?? [];
|
||||||
|
record('integrations 模块已挂载', modules.includes('integrations'), modules.join(', '));
|
||||||
|
if (!modules.includes('integrations')) process.exit(1);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 登录
|
||||||
|
// 演示库里的教师密码由 .env 的 DEFAULT_TEACHER_PASSWORD 决定(种子脚本用它建号),
|
||||||
|
// 因此这里按候选列表尝试,避免「换了环境就整脚本失败」。
|
||||||
|
const teacherToken = await loginWithCandidates('teacher1', ['teacher123', '123456']);
|
||||||
|
record('教师登录 teacher1', Boolean(teacherToken));
|
||||||
|
if (!teacherToken) process.exit(1);
|
||||||
|
|
||||||
|
// 管理员用于取班级码(只有管理员/班主任能看到班级码),学生隔离用例需要它
|
||||||
|
const adminToken = await loginWithCandidates('admin', ['admin123']);
|
||||||
|
|
||||||
|
const classes = await api('/classes', { token: teacherToken });
|
||||||
|
const target = (classes.payload?.data ?? [])[0];
|
||||||
|
record('取到可管理的班级', Boolean(target?.id), target ? `${target.name} (${target.id})` : '无');
|
||||||
|
if (!target?.id) process.exit(1);
|
||||||
|
|
||||||
|
// 叫人用例需要一位学生;班级账号用来验证"教室客户端自己选显示位置"
|
||||||
|
const students = await api(`/classes/${target.id}/students`, { token: teacherToken });
|
||||||
|
const firstStudent = (students.payload?.data ?? [])[0];
|
||||||
|
|
||||||
|
/** 用班级码 + 班级密码登录,拿班级账号 token(教室机器的身份) */
|
||||||
|
async function classLoginToken() {
|
||||||
|
if (adminToken) {
|
||||||
|
const detail = await api(`/classes/${target.id}`, { token: adminToken });
|
||||||
|
const code = detail.payload?.data?.code;
|
||||||
|
if (code) {
|
||||||
|
for (const password of ['123456', 'class123']) {
|
||||||
|
const result = await api('/auth/class-login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { code, password },
|
||||||
|
});
|
||||||
|
const token = result.payload?.data?.token;
|
||||||
|
if (token) return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 1. 新建设备
|
||||||
|
const created = await api('/integrations/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { classId: target.id, name: '验证用设备', syncScheduleToServer: true },
|
||||||
|
});
|
||||||
|
const deviceToken = created.payload?.data?.token;
|
||||||
|
const deviceId = created.payload?.data?.device?.id;
|
||||||
|
record(
|
||||||
|
'新建设备并返回设备令牌',
|
||||||
|
created.status === 201 && typeof deviceToken === 'string' && deviceToken.startsWith('chci_'),
|
||||||
|
`status=${created.status} hint=${created.payload?.data?.device?.tokenHint ?? '-'}`,
|
||||||
|
);
|
||||||
|
if (!deviceToken || !deviceId) process.exit(1);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 2. 令牌鉴权
|
||||||
|
const noToken = await api('/integrations/classisland/report', { method: 'POST', body: {} });
|
||||||
|
record('无令牌上报被拒(401)', noToken.status === 401, `status=${noToken.status}`);
|
||||||
|
const badToken = await api('/integrations/classisland/report', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken: 'chci_deadbeef',
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
record('错误令牌上报被拒(401)', badToken.status === 401, `status=${badToken.status}`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
|
||||||
|
const reportBody = {
|
||||||
|
pluginVersion: '0.1.0.0',
|
||||||
|
classIslandVersion: '2.1.0.0',
|
||||||
|
state: {
|
||||||
|
inClass: true,
|
||||||
|
subject: '语文',
|
||||||
|
nextSubject: '数学',
|
||||||
|
timeState: 'OnClass',
|
||||||
|
periodStart: '08:00',
|
||||||
|
periodEnd: '08:45',
|
||||||
|
week: 1,
|
||||||
|
classPlanLoaded: true,
|
||||||
|
clientTime: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
schedule: sampleSchedule(),
|
||||||
|
timeLayout: sampleTimeLayout(),
|
||||||
|
};
|
||||||
|
const report1 = await api('/integrations/classisland/report', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: reportBody,
|
||||||
|
});
|
||||||
|
const r1 = report1.payload?.data;
|
||||||
|
record(
|
||||||
|
'插件上报课表写入成功',
|
||||||
|
report1.status === 200 && r1?.scheduleApplied === true && r1.scheduleCreated + r1.scheduleUpdated === 3,
|
||||||
|
`新增=${r1?.scheduleCreated} 更新=${r1?.scheduleUpdated} 周=${r1?.week}`,
|
||||||
|
);
|
||||||
|
record('插件上报节次时间写入成功', r1?.timeLayoutApplied === true, `applied=${r1?.timeLayoutApplied}`);
|
||||||
|
record('上报回传服务端周次与时间', typeof r1?.serverTime === 'string' && Number.isInteger(r1?.week));
|
||||||
|
|
||||||
|
// 幂等:重复上报不应产生重复行
|
||||||
|
const report2 = await api('/integrations/classisland/report', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: reportBody,
|
||||||
|
});
|
||||||
|
const r2 = report2.payload?.data;
|
||||||
|
record(
|
||||||
|
'重复上报幂等(不新增、只更新)',
|
||||||
|
r2?.scheduleCreated === 0 && r2?.scheduleUpdated === 3,
|
||||||
|
`新增=${r2?.scheduleCreated} 更新=${r2?.scheduleUpdated}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设备列表里的状态快照
|
||||||
|
const devices = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
|
||||||
|
const listed = (devices.payload?.data ?? []).find((item) => item.id === deviceId);
|
||||||
|
record(
|
||||||
|
'设备状态快照已更新(Web 端可展示)',
|
||||||
|
Boolean(listed?.lastSeenAt) &&
|
||||||
|
listed?.currentSubject === '语文' &&
|
||||||
|
listed?.currentTimeState === 'OnClass',
|
||||||
|
`科目=${listed?.currentSubject} 状态=${listed?.currentTimeState} ClassIsland=${listed?.classIslandVersion}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 4. 下发提醒
|
||||||
|
const notify = await api('/integrations/classisland/notify', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId: target.id,
|
||||||
|
title: '请到讲台领资料',
|
||||||
|
content: '下课后来讲台领取期中复习资料',
|
||||||
|
durationSeconds: 12,
|
||||||
|
speech: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const n1 = notify.payload?.data;
|
||||||
|
record(
|
||||||
|
'教师下发提醒(落库 + 标记设备数)',
|
||||||
|
notify.status === 200 && n1?.targetCount >= 1 && Boolean(n1?.notificationId),
|
||||||
|
`设备数=${n1?.targetCount} 通知id=${n1?.notificationId ? '有' : '无'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending = await api('/integrations/classisland/pending', { deviceToken });
|
||||||
|
const items = pending.payload?.data?.notifications ?? [];
|
||||||
|
const picked = items.find((item) => item.title === '请到讲台领资料');
|
||||||
|
record(
|
||||||
|
'插件拉取到待提醒(离线补齐链路可用)',
|
||||||
|
pending.status === 200 && Boolean(picked),
|
||||||
|
`待提醒数=${items.length} 时长=${picked?.durationSeconds}s 朗读=${Boolean(picked?.speechContent)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked) {
|
||||||
|
const ack = await api('/integrations/classisland/ack', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: { id: picked.id },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'插件确认提醒已弹出',
|
||||||
|
ack.status === 200 && ack.payload?.data?.acked === true,
|
||||||
|
`status=${ack.status}`,
|
||||||
|
);
|
||||||
|
const pending2 = await api('/integrations/classisland/pending', { deviceToken });
|
||||||
|
const again = (pending2.payload?.data?.notifications ?? []).some((item) => item.id === picked.id);
|
||||||
|
record('确认后不再补发(避免重复弹窗)', !again, `仍在待提醒中=${again}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 4.5 其他发布入口也会联动 ClassIsland
|
||||||
|
// 需求:"在其他页面发通知也可以联动 ClassIsland"。通知发布页 / 叫人走的是各自的接口,
|
||||||
|
// 这里逐个验证它们真的会生成待弹出提醒(而不是只有联动页那个专用接口才联动)。
|
||||||
|
const classChannel = await api(`/classes/${target.id}/notification-channel`, { token: teacherToken });
|
||||||
|
record(
|
||||||
|
'班级有"通知显示位置"(默认两端都弹)',
|
||||||
|
classChannel.status === 200 && classChannel.payload?.data?.notificationChannel === 'both',
|
||||||
|
`channel=${classChannel.payload?.data?.notificationChannel}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const published = await api('/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { classId: target.id, title: '通知页联动验证', content: '从通知发布页发的通知', priority: 'NORMAL' },
|
||||||
|
});
|
||||||
|
const pendingAfterPublish = await api('/integrations/classisland/pending', { deviceToken });
|
||||||
|
const publishPush = (pendingAfterPublish.payload?.data?.notifications ?? []).find(
|
||||||
|
(item) => item.title === '通知页联动验证',
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'通知发布页发的通知也会推送到 ClassIsland',
|
||||||
|
published.status === 201 && Boolean(publishPush),
|
||||||
|
`status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
|
||||||
|
);
|
||||||
|
// kind 决定插件"上课时段弹不弹":通知类会被暂存,叫人属于主动通知要立刻弹
|
||||||
|
record(
|
||||||
|
'通知类推送的 kind=notification(上课时段插件会暂存)',
|
||||||
|
publishPush?.kind === 'notification',
|
||||||
|
`kind=${publishPush?.kind}`,
|
||||||
|
);
|
||||||
|
if (publishPush) {
|
||||||
|
await api('/integrations/classisland/ack', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: { id: publishPush.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const call = await api('/calls', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { classId: target.id, studentId: firstStudent?.id, message: '叫人联动验证' },
|
||||||
|
});
|
||||||
|
const pendingAfterCall = await api('/integrations/classisland/pending', { deviceToken });
|
||||||
|
const callPush = (pendingAfterCall.payload?.data?.notifications ?? []).find(
|
||||||
|
(item) => item.content === '叫人联动验证',
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'叫人也会推送到 ClassIsland',
|
||||||
|
Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
|
||||||
|
firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'叫人推送的 kind=call(算主动通知,上课时段也立刻弹)',
|
||||||
|
callPush?.kind === 'call',
|
||||||
|
`kind=${callPush?.kind}`,
|
||||||
|
);
|
||||||
|
if (callPush) {
|
||||||
|
await api('/integrations/classisland/ack', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: { id: callPush.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 教室在客户端里选"只在 ClassHelper 客户端显示"后,任何入口都不该再打扰 ClassIsland
|
||||||
|
const setClient = await api(`/classes/${target.id}/notification-channel`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { notificationChannel: 'client' },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'可把"通知显示位置"改成只在客户端显示',
|
||||||
|
setClient.status === 200 && setClient.payload?.data?.notificationChannel === 'client',
|
||||||
|
`channel=${setClient.payload?.data?.notificationChannel}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutedPublish = await api('/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { classId: target.id, title: '不该上 ClassIsland 的通知', content: '教室选择了只在客户端显示' },
|
||||||
|
});
|
||||||
|
const mutedNotify = await api('/integrations/classisland/notify', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { classId: target.id, title: '联动页也不该下发', content: '同样被教室的设置拦下' },
|
||||||
|
});
|
||||||
|
const pendingMuted = await api('/integrations/classisland/pending', { deviceToken });
|
||||||
|
const mutedItems = pendingMuted.payload?.data?.notifications ?? [];
|
||||||
|
record(
|
||||||
|
'显示位置=client 时通知不再推送 ClassIsland',
|
||||||
|
mutedPublish.status === 201 && !mutedItems.some((item) => item.title === '不该上 ClassIsland 的通知'),
|
||||||
|
`待提醒=${mutedItems.length}`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'显示位置=client 时联动页的下发也会被跳过(并说明原因)',
|
||||||
|
mutedNotify.status === 200 &&
|
||||||
|
mutedNotify.payload?.data?.skipped === 'channel-client' &&
|
||||||
|
String(mutedNotify.payload?.message ?? '').includes('只在 ClassHelper 客户端显示'),
|
||||||
|
`skipped=${mutedNotify.payload?.data?.skipped} message=${mutedNotify.payload?.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 班级账号(教室机器)自己就能读写这项设置 —— 需求就是"由 ClassIsland 之外的客户端自己选"
|
||||||
|
const classSessionToken = await classLoginToken();
|
||||||
|
const readByClass = await api(`/classes/${target.id}/notification-channel`, {
|
||||||
|
token: classSessionToken,
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'班级账号可读取通知显示位置',
|
||||||
|
readByClass.status === 200 && readByClass.payload?.data?.notificationChannel === 'client',
|
||||||
|
`channel=${readByClass.payload?.data?.notificationChannel}`,
|
||||||
|
);
|
||||||
|
const setByClass = await api(`/classes/${target.id}/notification-channel`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: classSessionToken,
|
||||||
|
body: { notificationChannel: 'both' },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'班级账号可修改通知显示位置(由教室客户端自己选)',
|
||||||
|
setByClass.status === 200 && setByClass.payload?.data?.notificationChannel === 'both',
|
||||||
|
`channel=${setByClass.payload?.data?.notificationChannel}`,
|
||||||
|
);
|
||||||
|
const badChannel = await api(`/classes/${target.id}/notification-channel`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { notificationChannel: 'nope' },
|
||||||
|
});
|
||||||
|
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 5. 课表镜像(默认关闭 → 打开 → 拉取)
|
||||||
|
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||||
|
record(
|
||||||
|
'镜像开关关闭时不下发课表',
|
||||||
|
mirrorOff.status === 200 && mirrorOff.payload?.data === null,
|
||||||
|
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const enabled = await api(`/integrations/devices/${deviceId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { mirrorScheduleToClassIsland: true },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'打开「镜像课表到 ClassIsland」',
|
||||||
|
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
|
||||||
|
`status=${enabled.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mirror = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||||
|
const plan = mirror.payload?.data?.classPlan;
|
||||||
|
const weekDays = (plan?.entries ?? []).map((item) => item.weekDay);
|
||||||
|
record(
|
||||||
|
'拉取课表为 ClassIsland 口径(0=周日 … 6=周六)',
|
||||||
|
mirror.status === 200 &&
|
||||||
|
Array.isArray(plan?.entries) &&
|
||||||
|
plan.entries.length >= 3 &&
|
||||||
|
weekDays.every((day) => day >= 0 && day <= 6),
|
||||||
|
`条目=${plan?.entries?.length} weekDay=[${weekDays.join(',')}] 档案名=${plan?.profileName}`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'单双周换算为 WeekCountDiv/Total',
|
||||||
|
(plan?.entries ?? []).some((item) => item.weekCountDivTotal === 2),
|
||||||
|
(plan?.entries ?? [])
|
||||||
|
.map((item) => `${item.subject}:${item.weekCountDiv}/${item.weekCountDivTotal}`)
|
||||||
|
.join(' '),
|
||||||
|
);
|
||||||
|
// ClassInfo.Index 与"第 i 个上课时间点"一一对应,因此镜像条目必须带起止时间,
|
||||||
|
// 否则插件只能按数组顺序硬排,一旦某天少一节整份课表就错位(这是最容易漏的一处契约)
|
||||||
|
const timeOk = (plan?.entries ?? []).every(
|
||||||
|
(item) => /^\d{2}:\d{2}$/.test(item.startTime ?? '') && /^\d{2}:\d{2}$/.test(item.endTime ?? ''),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'镜像条目带起止时间(插件据此重建上课时间点)',
|
||||||
|
(plan?.entries ?? []).length > 0 && timeOk,
|
||||||
|
(plan?.entries ?? [])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => `${item.subject} ${item.startTime}-${item.endTime}`)
|
||||||
|
.join(' | '),
|
||||||
|
);
|
||||||
|
const classPoints = (plan?.timeLayouts?.[0]?.layouts ?? []).filter((item) => item.timeType === 0);
|
||||||
|
record(
|
||||||
|
'镜像时间表含上课时间点(与课表条目对得上)',
|
||||||
|
classPoints.length > 0 && (plan?.entries ?? []).length >= classPoints.length / 2,
|
||||||
|
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 6. 停用设备
|
||||||
|
await api(`/integrations/devices/${deviceId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { enabled: false },
|
||||||
|
});
|
||||||
|
const blocked = await api('/integrations/classisland/report', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: { state: { inClass: false, clientTime: new Date().toISOString() } },
|
||||||
|
});
|
||||||
|
record('停用设备后插件上报被拒(403)', blocked.status === 403, `status=${blocked.status}`);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 7. 权限隔离
|
||||||
|
// 班级码只有管理员/班主任可见,班级密码也是默认值,因此这里按管理员信息补齐
|
||||||
|
let classCode = target.code ?? null;
|
||||||
|
if (!classCode && adminToken) {
|
||||||
|
const all = await api('/classes', { token: adminToken });
|
||||||
|
classCode = (all.payload?.data ?? []).find((item) => item.id === target.id)?.code ?? null;
|
||||||
|
}
|
||||||
|
const studentLogin = await api('/auth/class-login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { code: classCode ?? '', password: '123456' },
|
||||||
|
});
|
||||||
|
const studentToken = studentLogin.payload?.data?.token;
|
||||||
|
if (studentToken) {
|
||||||
|
const forbidden = await api('/integrations/devices', { token: studentToken });
|
||||||
|
record('学生无法查看联动设备列表(403)', forbidden.status === 403, `status=${forbidden.status}`);
|
||||||
|
} else {
|
||||||
|
record('班级账号登录(跳过学生隔离用例)', false, `status=${studentLogin.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 清理
|
||||||
|
const removed = await api(`/integrations/devices/${deviceId}`, { method: 'DELETE', token: teacherToken });
|
||||||
|
record('删除验证设备(清理)', removed.status === 200, `status=${removed.status}`);
|
||||||
|
|
||||||
|
const failed = results.filter((item) => !item.ok);
|
||||||
|
console.log(`\n=== 结果:${results.length - failed.length}/${results.length} 通过 ===`);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.log('失败用例:');
|
||||||
|
for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('ClassIsland 联动链路全部通过。\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('验证脚本异常:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -28,6 +28,10 @@ const envSchema = z.object({
|
|||||||
CORS_ORIGIN: z.string().default('*'),
|
CORS_ORIGIN: z.string().default('*'),
|
||||||
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
||||||
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
||||||
|
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
|
||||||
|
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
|
||||||
|
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
|
||||||
|
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
|
||||||
TERM_START_DATE: z.string().default(''),
|
TERM_START_DATE: z.string().default(''),
|
||||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
|
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
|
||||||
@@ -139,6 +143,10 @@ export const env = {
|
|||||||
bcryptRounds: raw.BCRYPT_ROUNDS,
|
bcryptRounds: raw.BCRYPT_ROUNDS,
|
||||||
/** 新增学生时未指定密码时的初始密码 */
|
/** 新增学生时未指定密码时的初始密码 */
|
||||||
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
||||||
|
/** 新建班级时的默认班级密码(班级账号登录) */
|
||||||
|
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
|
||||||
|
/** 新建/重置教师账号时的初始密码 */
|
||||||
|
defaultTeacherPassword: raw.DEFAULT_TEACHER_PASSWORD,
|
||||||
termStartDate: raw.TERM_START_DATE,
|
termStartDate: raw.TERM_START_DATE,
|
||||||
logLevel: raw.LOG_LEVEL,
|
logLevel: raw.LOG_LEVEL,
|
||||||
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
|
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
|
import {
|
||||||
|
canManageGrades,
|
||||||
|
canManageSchedule,
|
||||||
|
canPublishContent,
|
||||||
|
canManageClasses,
|
||||||
|
canAssignTeachers,
|
||||||
|
canManageRoster,
|
||||||
|
resolveClassRole,
|
||||||
|
type ClassRole,
|
||||||
|
} from '@classhelper/shared';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { ApiError } from './http.js';
|
import { ApiError } from './http.js';
|
||||||
import type { TokenPayload } from './jwt.js';
|
import type { TokenPayload } from './jwt.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 权限(RBAC)规则:
|
* 权限(RBAC)规则:
|
||||||
* - ADMIN :可访问全部班级
|
* - ADMIN :全部班级;班级增删改、人员分配、成绩、课表
|
||||||
* - TEACHER :仅可访问自己创建(teacherId)或被分配(ClassTeacher)的班级
|
* - HEAD :班主任(Class.teacherId)—— 本班课表 + 作业/通知/叫人
|
||||||
* - STUDENT :仅可查看自己所在班级的数据,且无任何写权限
|
* - SUBJECT :科任老师(ClassTeacher)—— 仅作业/通知/叫人
|
||||||
|
* - STUDENT :只读;班级账号(classSession)以班级为单位读写自己的数据
|
||||||
|
*
|
||||||
|
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function isAdmin(user: TokenPayload): boolean {
|
export function isAdmin(user: TokenPayload): boolean {
|
||||||
@@ -82,6 +95,91 @@ export async function assertClassWritable(user: TokenPayload, classId: string):
|
|||||||
await assertClassAccess(user, classId);
|
await assertClassAccess(user, classId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 班级内角色 */
|
||||||
|
|
||||||
|
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / STUDENT / NONE) */
|
||||||
|
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
|
||||||
|
if (user.role === 'ADMIN') return 'ADMIN';
|
||||||
|
if (user.role === 'STUDENT') return canAccessClass(user, classId).then((ok) => (ok ? 'STUDENT' : 'NONE'));
|
||||||
|
|
||||||
|
const record = await prisma.class.findUnique({
|
||||||
|
where: { id: classId },
|
||||||
|
select: {
|
||||||
|
teacherId: true,
|
||||||
|
teachers: { where: { teacherId: user.sub }, select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!record) return 'NONE';
|
||||||
|
return resolveClassRole({
|
||||||
|
role: user.role,
|
||||||
|
isHeadTeacher: record.teacherId === user.sub,
|
||||||
|
isSubjectTeacher: record.teachers.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级本身(增删改)与人员分配:仅管理员 */
|
||||||
|
export function assertCanManageClasses(user: TokenPayload): void {
|
||||||
|
if (!canManageClasses(user.role)) {
|
||||||
|
throw ApiError.forbidden('只有管理员可以创建、修改或删除班级');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班主任 / 科任老师分配:仅管理员 */
|
||||||
|
export function assertCanAssignTeachers(user: TokenPayload): void {
|
||||||
|
if (!canAssignTeachers(user.role)) {
|
||||||
|
throw ApiError.forbidden('只有管理员可以分配班主任与科任老师');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学生名单管理:仅管理员 */
|
||||||
|
export function assertCanManageRoster(user: TokenPayload): void {
|
||||||
|
if (!canManageRoster(user.role)) {
|
||||||
|
throw ApiError.forbidden('只有管理员可以管理学生名单与账号');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 课表管理(含时间配置导入):管理员或本班班主任 */
|
||||||
|
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
|
||||||
|
const classRole = await resolveUserClassRole(user, classId);
|
||||||
|
if (!canManageSchedule(classRole)) {
|
||||||
|
throw ApiError.forbidden(
|
||||||
|
classRole === 'SUBJECT' ? '科任老师不能管理课表,请联系班主任或管理员' : '无权管理该班级的课表',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作业 / 通知 / 叫人:管理员、班主任、科任老师 */
|
||||||
|
export async function assertCanPublishContent(user: TokenPayload, classId: string): Promise<void> {
|
||||||
|
await assertClassAccess(user, classId);
|
||||||
|
const classRole = await resolveUserClassRole(user, classId);
|
||||||
|
if (!canPublishContent(classRole)) {
|
||||||
|
throw ApiError.forbidden('当前账号没有发布作业/通知或叫人的权限');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成绩录入 / 修改 / 导入:管理员,或**本班班主任**(需求 6"老师端可用且不越权")。
|
||||||
|
* 科任老师仍然被拒(需求 7:科任只有作业 / 叫人 / 通知)。
|
||||||
|
*
|
||||||
|
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
|
||||||
|
*/
|
||||||
|
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
|
||||||
|
if (!classId) {
|
||||||
|
// 没有班级上下文(例如下载空白模板):只有管理员可以执行
|
||||||
|
if (user.role !== 'ADMIN') throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classRole = await resolveUserClassRole(user, classId);
|
||||||
|
if (canManageGrades(classRole)) return;
|
||||||
|
|
||||||
|
throw ApiError.forbidden(
|
||||||
|
classRole === 'SUBJECT'
|
||||||
|
? '科任老师不能录入或导入成绩,请联系班主任或管理员'
|
||||||
|
: '只能录入或导入自己担任班主任的班级的成绩',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 学生的主班级,未分配班级时抛 403 */
|
/** 学生的主班级,未分配班级时抛 403 */
|
||||||
export function requireStudentClassId(user: TokenPayload): string {
|
export function requireStudentClassId(user: TokenPayload): string {
|
||||||
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
|
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { prisma } from './db.js';
|
||||||
|
import { ApiError } from './http.js';
|
||||||
|
import { hashPassword, verifyPassword } from './password.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级账号(学生端「班级」主体):
|
||||||
|
* - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt);
|
||||||
|
* - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT;
|
||||||
|
* - 管理员可在「班级管理」里修改班级码、重置班级密码。
|
||||||
|
*
|
||||||
|
* 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */
|
||||||
|
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
const CODE_PATTERN = /^[A-Za-z0-9]{4,16}$/;
|
||||||
|
|
||||||
|
/** 归一化班级码:去空格 + 转大写(登录时用户输入大小写都可) */
|
||||||
|
export function normalizeClassCode(input: string): string {
|
||||||
|
return input.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidClassCode(input: string): boolean {
|
||||||
|
return CODE_PATTERN.test(input.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 随机生成一个未被占用的班级码 */
|
||||||
|
export async function generateClassCode(prefix = ''): Promise<string> {
|
||||||
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||||
|
const length = Math.max(4, 6 - prefix.length);
|
||||||
|
let body = '';
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
body += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
|
||||||
|
}
|
||||||
|
const code = `${prefix}${body}`.toUpperCase();
|
||||||
|
const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
||||||
|
if (!exists) return code;
|
||||||
|
}
|
||||||
|
throw new ApiError(500, 'INTERNAL_ERROR', '班级码生成失败,请重试或手动指定班级码');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建班级时调用:生成班级码并写入默认班级密码 */
|
||||||
|
export async function buildClassAccountCreateData(
|
||||||
|
defaultPassword: string,
|
||||||
|
requestedCode?: string,
|
||||||
|
): Promise<{ code: string; passwordHash: string }> {
|
||||||
|
const code = requestedCode ? normalizeClassCode(requestedCode) : await generateClassCode();
|
||||||
|
if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字');
|
||||||
|
const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
||||||
|
if (taken) throw ApiError.badRequest(`班级码 ${code} 已被占用,请更换`);
|
||||||
|
return { code, passwordHash: await hashPassword(defaultPassword) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员:修改班级码 / 重置班级密码 */
|
||||||
|
export async function updateClassAccount(
|
||||||
|
classId: string,
|
||||||
|
input: { code?: string; password?: string },
|
||||||
|
): Promise<{ code: string; hasPassword: boolean }> {
|
||||||
|
const current = await prisma.class.findUnique({ where: { id: classId }, select: { id: true, code: true } });
|
||||||
|
if (!current) throw ApiError.notFound('班级不存在');
|
||||||
|
|
||||||
|
const data: { code?: string; passwordHash?: string } = {};
|
||||||
|
|
||||||
|
if (input.code !== undefined) {
|
||||||
|
const code = normalizeClassCode(input.code);
|
||||||
|
if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字');
|
||||||
|
if (code !== current.code) {
|
||||||
|
const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
||||||
|
if (taken) throw ApiError.badRequest(`班级码 ${code} 已被其他班级使用`);
|
||||||
|
data.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.password !== undefined && input.password !== '') {
|
||||||
|
if (input.password.length < 6) throw ApiError.badRequest('班级密码至少 6 位');
|
||||||
|
data.passwordHash = await hashPassword(input.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
throw ApiError.badRequest('没有需要更新的内容(班级码或班级密码)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.class.update({
|
||||||
|
where: { id: classId },
|
||||||
|
data,
|
||||||
|
select: { code: true, passwordHash: true },
|
||||||
|
});
|
||||||
|
return { code: updated.code, hasPassword: Boolean(updated.passwordHash) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */
|
||||||
|
export async function findClassForLogin(code: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
grade: string;
|
||||||
|
code: string;
|
||||||
|
passwordHash: string | null;
|
||||||
|
}> {
|
||||||
|
const normalized = normalizeClassCode(code);
|
||||||
|
const record = await prisma.class.findUnique({
|
||||||
|
where: { code: normalized },
|
||||||
|
select: { id: true, name: true, grade: true, code: true, passwordHash: true },
|
||||||
|
});
|
||||||
|
if (!record) throw ApiError.unauthorized('班级码或班级密码错误');
|
||||||
|
if (!record.passwordHash) {
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
'CLASS_PASSWORD_NOT_SET',
|
||||||
|
'该班级尚未设置班级密码,请联系管理员在「班级管理」中设置',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyClassPassword(plain: string, passwordHash: string): Promise<boolean> {
|
||||||
|
return verifyPassword(plain, passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级账号自助修改班级密码 */
|
||||||
|
export async function changeClassPassword(
|
||||||
|
classId: string,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const record = await prisma.class.findUnique({ where: { id: classId }, select: { passwordHash: true } });
|
||||||
|
if (!record) throw ApiError.notFound('班级不存在');
|
||||||
|
if (record.passwordHash && !(await verifyPassword(currentPassword, record.passwordHash))) {
|
||||||
|
throw ApiError.badRequest('当前班级密码不正确');
|
||||||
|
}
|
||||||
|
await prisma.class.update({
|
||||||
|
where: { id: classId },
|
||||||
|
data: { passwordHash: await hashPassword(newPassword) },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ import { hashPassword } from './password.js';
|
|||||||
/**
|
/**
|
||||||
* 首次启动时的数据库初始化(SQLite/libSQL)。
|
* 首次启动时的数据库初始化(SQLite/libSQL)。
|
||||||
*
|
*
|
||||||
* 场景:安装程序首次运行、Docker 挂载了空数据卷、用户手动删除了数据库文件。
|
* 场景:安装程序首次运行、覆盖安装升级、Docker 挂载了空数据卷、用户手动删除了数据库文件。
|
||||||
* 做法:若目标库中还没有业务表,就把随包发布的 Prisma 迁移 SQL 依次执行一遍。
|
* 做法:以 `_ch_migrations` 账本表记录已执行的迁移;启动时按时间戳顺序补跑未记录的迁移。
|
||||||
|
* 全新库会跑全部迁移,旧库只补跑新增迁移(已存在的表/索引自动跳过)。
|
||||||
*
|
*
|
||||||
* 说明:
|
* 说明:
|
||||||
* - 只处理 SQLite(本项目打包安装的默认形态);
|
* - 只处理 SQLite(本项目打包安装的默认形态);
|
||||||
@@ -58,6 +59,103 @@ async function schemaExists(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移账本表名。
|
||||||
|
*
|
||||||
|
* 老版本安装(1.0 之前)只区分「空库 / 非空库」,因此升级安装时不会补建新表。
|
||||||
|
* 现在用一张账本表记录已执行过的迁移目录名,启动时只补跑未记录的迁移,
|
||||||
|
* 这样「覆盖安装升级」也能自动获得新表/新索引。
|
||||||
|
*/
|
||||||
|
const MIGRATION_LEDGER = '_ch_migrations';
|
||||||
|
|
||||||
|
/** 这些错误表示对象已存在,属于「旧版本已经建过」的正常情况,可安全忽略 */
|
||||||
|
const IGNORABLE_SQL_ERROR = /already exists|duplicate column name|duplicate index/i;
|
||||||
|
|
||||||
|
/** 拆分 migration.sql 为独立语句(去掉注释行,过滤空语句) */
|
||||||
|
function splitStatements(sql: string): string[] {
|
||||||
|
return sql
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => !line.trim().startsWith('--'))
|
||||||
|
.join('\n')
|
||||||
|
.split(';')
|
||||||
|
.map((statement) => statement.trim())
|
||||||
|
.filter((statement) => statement.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确保账本表存在,返回已记录的迁移名 */
|
||||||
|
async function loadAppliedMigrations(client: {
|
||||||
|
execute: (sql: string) => Promise<unknown>;
|
||||||
|
}): Promise<Set<string>> {
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
|
||||||
|
`SELECT name FROM "${MIGRATION_LEDGER}"`,
|
||||||
|
);
|
||||||
|
return new Set(rows.map((row) => row.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationOutcome {
|
||||||
|
file: string;
|
||||||
|
applied: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行单个迁移文件:逐条语句执行,已存在的对象跳过,其余错误上抛 */
|
||||||
|
async function applyMigration(
|
||||||
|
client: { execute: (sql: string) => Promise<unknown> },
|
||||||
|
file: string,
|
||||||
|
): Promise<MigrationOutcome> {
|
||||||
|
const sql = fs.readFileSync(file, 'utf8');
|
||||||
|
const outcome: MigrationOutcome = { file, applied: 0, skipped: 0 };
|
||||||
|
|
||||||
|
for (const statement of splitStatements(sql)) {
|
||||||
|
try {
|
||||||
|
await client.execute(statement);
|
||||||
|
outcome.applied += 1;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (IGNORABLE_SQL_ERROR.test(message)) {
|
||||||
|
outcome.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`迁移 ${path.basename(path.dirname(file))} 执行失败:${message}\nSQL: ${statement.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.execute({
|
||||||
|
// libsql 的 execute 支持参数化对象形式
|
||||||
|
sql: `INSERT OR REPLACE INTO "${MIGRATION_LEDGER}" ("name", "appliedAt") VALUES (?, CURRENT_TIMESTAMP)`,
|
||||||
|
args: [path.basename(path.dirname(file))],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算待执行的迁移文件 */
|
||||||
|
export async function listPendingMigrations(): Promise<string[]> {
|
||||||
|
const files = listMigrationFiles();
|
||||||
|
if (files.length === 0) return [];
|
||||||
|
|
||||||
|
const client = createClient({ url: env.databaseUrl });
|
||||||
|
try {
|
||||||
|
const applied = await loadAppliedMigrations(
|
||||||
|
client as unknown as {
|
||||||
|
execute: (sql: string) => Promise<unknown>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return files.filter((file) => !applied.has(path.basename(path.dirname(file))));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('读取迁移账本失败,按全部待执行处理', error);
|
||||||
|
return files;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface BootstrapResult {
|
export interface BootstrapResult {
|
||||||
applied: number;
|
applied: number;
|
||||||
skipped: boolean;
|
skipped: boolean;
|
||||||
@@ -95,8 +193,12 @@ export async function ensureInitialAdmin(): Promise<{ created: boolean; username
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确保数据库结构存在。
|
* 确保数据库结构存在(增量迁移)。
|
||||||
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。
|
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。
|
||||||
|
*
|
||||||
|
* - 全新数据库:依次执行全部迁移;
|
||||||
|
* - 旧版本升级:只补跑账本里没有记录的迁移(已存在的对象自动跳过);
|
||||||
|
* - 已是最新:直接返回,不做任何写操作。
|
||||||
*/
|
*/
|
||||||
export async function ensureSchema(): Promise<BootstrapResult> {
|
export async function ensureSchema(): Promise<BootstrapResult> {
|
||||||
if (env.databaseProvider !== 'sqlite') {
|
if (env.databaseProvider !== 'sqlite') {
|
||||||
@@ -107,27 +209,46 @@ export async function ensureSchema(): Promise<BootstrapResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await schemaExists()) {
|
|
||||||
return { applied: 0, skipped: true, detail: '数据库结构已存在,跳过初始化' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = listMigrationFiles();
|
const files = listMigrationFiles();
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' };
|
return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const freshInstall = !(await schemaExists());
|
||||||
const client = createClient({ url: env.databaseUrl });
|
const client = createClient({ url: env.databaseUrl });
|
||||||
let applied = 0;
|
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
const applied = await loadAppliedMigrations(
|
||||||
const sql = fs.readFileSync(file, 'utf8');
|
client as unknown as {
|
||||||
await client.executeMultiple(sql);
|
execute: (sql: string) => Promise<unknown>;
|
||||||
applied += 1;
|
},
|
||||||
logger.info(`已应用迁移:${path.basename(path.dirname(file))}`);
|
);
|
||||||
|
const pending = files.filter((file) => !applied.has(path.basename(path.dirname(file))));
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
return { applied: 0, skipped: true, detail: '数据库结构已是最新,无需迁移' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reused = 0;
|
||||||
|
for (const file of pending) {
|
||||||
|
const outcome = await applyMigration(
|
||||||
|
client as unknown as { execute: (sql: string) => Promise<unknown> },
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
reused += outcome.skipped;
|
||||||
|
logger.info(
|
||||||
|
`已应用迁移:${path.basename(path.dirname(file))}` +
|
||||||
|
(outcome.skipped > 0 ? `(跳过 ${outcome.skipped} 个已存在对象)` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = freshInstall ? '全新安装' : '升级安装';
|
||||||
|
return {
|
||||||
|
applied: pending.length,
|
||||||
|
skipped: false,
|
||||||
|
detail: `${mode}:已应用 ${pending.length} 个迁移文件${reused > 0 ? `,跳过 ${reused} 个已存在对象` : ''}`,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` };
|
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,14 @@ export type ApiErrorCode =
|
|||||||
| 'TOO_MANY_REQUESTS'
|
| 'TOO_MANY_REQUESTS'
|
||||||
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
|
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
|
||||||
| 'URGENT_DURING_CLASS'
|
| 'URGENT_DURING_CLASS'
|
||||||
|
/** 导入:空文件 / 文件过大 / 格式错误 / 内容不合法 / 没有有效数据 */
|
||||||
|
| 'IMPORT_EMPTY_FILE'
|
||||||
|
| 'IMPORT_TOO_LARGE'
|
||||||
|
| 'IMPORT_FORMAT_INVALID'
|
||||||
|
| 'IMPORT_INVALID'
|
||||||
|
| 'IMPORT_EMPTY'
|
||||||
|
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
|
||||||
|
| 'CLASS_PASSWORD_NOT_SET'
|
||||||
| 'INTERNAL_ERROR';
|
| 'INTERNAL_ERROR';
|
||||||
|
|
||||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export interface TokenPayload {
|
|||||||
name: string;
|
name: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
classId: string | null;
|
classId: string | null;
|
||||||
|
/** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */
|
||||||
|
classSession?: boolean;
|
||||||
|
/** 班级账号的班级码(便于日志/审计) */
|
||||||
|
classCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signToken(payload: TokenPayload): string {
|
export function signToken(payload: TokenPayload): string {
|
||||||
@@ -37,6 +41,8 @@ export function verifyToken(token: string): TokenPayload {
|
|||||||
name: String(record.name ?? ''),
|
name: String(record.name ?? ''),
|
||||||
role,
|
role,
|
||||||
classId: record.classId ? String(record.classId) : null,
|
classId: record.classId ? String(record.classId) : null,
|
||||||
|
classSession: record.classSession === true,
|
||||||
|
classCode: record.classCode ? String(record.classCode) : undefined,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
throw ApiError.unauthorized('登录状态已失效,请重新登录');
|
throw ApiError.unauthorized('登录状态已失效,请重新登录');
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { dayKeyLocal } from '@classhelper/shared';
|
||||||
import type {
|
import type {
|
||||||
ClassDto,
|
ClassDto,
|
||||||
|
ClassIslandNotificationChannel,
|
||||||
ClassTeacherBrief,
|
ClassTeacherBrief,
|
||||||
CourseBrief,
|
CourseBrief,
|
||||||
CourseDto,
|
CourseDto,
|
||||||
@@ -25,6 +27,16 @@ import type {
|
|||||||
const toIso = (value: Date): string => value.toISOString();
|
const toIso = (value: Date): string => value.toISOString();
|
||||||
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
|
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "个人记录"归属范围解析:
|
||||||
|
* - 传入 userIds(班级账号 = 全班学生)时优先使用;
|
||||||
|
* - 否则退化为单个 userId(普通学生账号 = 自己)。
|
||||||
|
*/
|
||||||
|
function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] {
|
||||||
|
if (options.userIds && options.userIds.length > 0) return options.userIds;
|
||||||
|
return options.userId ? [options.userId] : [];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserLike {
|
export interface UserLike {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -83,6 +95,10 @@ export interface ClassLike {
|
|||||||
name: string;
|
name: string;
|
||||||
grade: string;
|
grade: string;
|
||||||
teacherId: string;
|
teacherId: string;
|
||||||
|
/** 本学期教学周数(班主任可调,默认 20) */
|
||||||
|
termWeeks?: number;
|
||||||
|
/** 通知显示位置:both / client / classisland(由教室的班级客户端设置) */
|
||||||
|
notificationChannel?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
teacher?: TeacherBriefLike | null;
|
teacher?: TeacherBriefLike | null;
|
||||||
_count?: {
|
_count?: {
|
||||||
@@ -94,12 +110,19 @@ export interface ClassLike {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 通知显示位置:库里存字符串,出参收敛成联合类型(非法值一律按默认 both 处理) */
|
||||||
|
export function toNotificationChannel(value: string | null | undefined): ClassIslandNotificationChannel {
|
||||||
|
return value === 'client' || value === 'classisland' ? value : 'both';
|
||||||
|
}
|
||||||
|
|
||||||
export function toClassDto(item: ClassLike): ClassDto {
|
export function toClassDto(item: ClassLike): ClassDto {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
grade: item.grade,
|
grade: item.grade,
|
||||||
teacherId: item.teacherId,
|
teacherId: item.teacherId,
|
||||||
|
termWeeks: item.termWeeks ?? 20,
|
||||||
|
notificationChannel: toNotificationChannel(item.notificationChannel),
|
||||||
createdAt: toIso(item.createdAt),
|
createdAt: toIso(item.createdAt),
|
||||||
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
||||||
studentCount: item._count?.students ?? undefined,
|
studentCount: item._count?.students ?? undefined,
|
||||||
@@ -143,6 +166,7 @@ export interface ScheduleLike {
|
|||||||
location: string | null;
|
location: string | null;
|
||||||
weekStart: number;
|
weekStart: number;
|
||||||
weekEnd: number;
|
weekEnd: number;
|
||||||
|
weekParity: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
course?: CourseBriefLike | null;
|
course?: CourseBriefLike | null;
|
||||||
}
|
}
|
||||||
@@ -158,6 +182,7 @@ export function toScheduleDto(item: ScheduleLike): ScheduleDto {
|
|||||||
location: item.location,
|
location: item.location,
|
||||||
weekStart: item.weekStart,
|
weekStart: item.weekStart,
|
||||||
weekEnd: item.weekEnd,
|
weekEnd: item.weekEnd,
|
||||||
|
weekParity: (item.weekParity ?? 'ALL') as ScheduleDto['weekParity'],
|
||||||
createdAt: toIso(item.createdAt),
|
createdAt: toIso(item.createdAt),
|
||||||
course: item.course ? toCourseBrief(item.course) : null,
|
course: item.course ? toCourseBrief(item.course) : null,
|
||||||
};
|
};
|
||||||
@@ -180,7 +205,8 @@ export interface HomeworkLike {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
attachmentUrl: string | null;
|
attachmentUrl: string | null;
|
||||||
dueAt: Date | null;
|
/** 作业所属日期(YYYY-MM-DD,本地日期);老库可能为空串 */
|
||||||
|
assignDate?: string | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
course?: CourseBriefLike | null;
|
course?: CourseBriefLike | null;
|
||||||
@@ -200,14 +226,16 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
|
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
|
||||||
|
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态
|
||||||
* @param options.withStatus 是否统计完成人数(教师视角)
|
* @param options.withStatus 是否统计完成人数(教师视角)
|
||||||
*/
|
*/
|
||||||
export function toHomeworkDto(
|
export function toHomeworkDto(
|
||||||
item: HomeworkLike,
|
item: HomeworkLike,
|
||||||
options: { userId?: string | null; withStatus?: boolean } = {},
|
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||||
): HomeworkDto {
|
): HomeworkDto {
|
||||||
const statuses = item.statuses ?? [];
|
const statuses = item.statuses ?? [];
|
||||||
const own = options.userId ? statuses.find((status) => status.userId === options.userId) : undefined;
|
const pool = resolvePool(options);
|
||||||
|
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -216,9 +244,10 @@ export function toHomeworkDto(
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
content: item.content,
|
content: item.content,
|
||||||
attachmentUrl: item.attachmentUrl,
|
attachmentUrl: item.attachmentUrl,
|
||||||
dueAt: toIsoOrNull(item.dueAt),
|
|
||||||
createdBy: item.createdBy,
|
createdBy: item.createdBy,
|
||||||
createdAt: toIso(item.createdAt),
|
createdAt: toIso(item.createdAt),
|
||||||
|
// 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值
|
||||||
|
assignDate: item.assignDate || dayKeyLocal(item.createdAt),
|
||||||
course: item.course ? toCourseBrief(item.course) : null,
|
course: item.course ? toCourseBrief(item.course) : null,
|
||||||
creator: item.creator ? toTeacherBrief(item.creator) : null,
|
creator: item.creator ? toTeacherBrief(item.creator) : null,
|
||||||
completed: own ? own.completed : undefined,
|
completed: own ? own.completed : undefined,
|
||||||
@@ -248,12 +277,18 @@ export interface NotificationLike {
|
|||||||
reads?: NotificationReadLike[];
|
reads?: NotificationReadLike[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param options.userId 指定当前用户(普通学生账号)
|
||||||
|
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态
|
||||||
|
* @param options.withStatus 是否统计已读人数(教师视角)
|
||||||
|
*/
|
||||||
export function toNotificationDto(
|
export function toNotificationDto(
|
||||||
item: NotificationLike,
|
item: NotificationLike,
|
||||||
options: { userId?: string | null; withStatus?: boolean } = {},
|
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||||
): NotificationDto {
|
): NotificationDto {
|
||||||
const reads = item.reads ?? [];
|
const reads = item.reads ?? [];
|
||||||
const own = options.userId ? reads.find((read) => read.userId === options.userId) : undefined;
|
const pool = resolvePool(options);
|
||||||
|
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { prisma } from './db.js';
|
||||||
|
import type { TokenPayload } from './jwt.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话主体解析:普通账号 vs 班级账号(班级设备)。
|
||||||
|
*
|
||||||
|
* 背景(需求 1):学生端以「班级」为主体登录,个人学生不再作为登录入口。
|
||||||
|
* 班级账号的 JWT 形如:
|
||||||
|
* { sub: <classId>, username: <班级码>, name: <班级名>, role: 'STUDENT',
|
||||||
|
* classId: <classId>, classSession: true }
|
||||||
|
*
|
||||||
|
* 语义(由用户确认):
|
||||||
|
* - **班级设备代全班操作**:勾选作业完成、标记通知已读,都会写到该班全体学生的记录上,
|
||||||
|
* 因此教师端的"完成人数/已读人数"统计依然准确;
|
||||||
|
* - 读状态判定使用 `userId in 全班学生`:任一条记录存在即视为该班设备读过/完成过;
|
||||||
|
* - 个人学生账号(student01…)保持完全兼容,此时"全班"范围退化为"只有自己"。
|
||||||
|
*
|
||||||
|
* 这样所有个人数据的读写都收敛到 `resolvePersonalIds()` 一个入口,
|
||||||
|
* 既不需要改数据库结构,也不会让旧的个人账号行为发生变化。
|
||||||
|
*/
|
||||||
|
export function isClassSession(user: TokenPayload): boolean {
|
||||||
|
return user.role === 'STUDENT' && user.classSession === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级账号对应的班级 id */
|
||||||
|
export function classSessionClassId(user: TokenPayload): string {
|
||||||
|
return user.classId ?? user.sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该班级的全部学生 id(班级账号的"全班"范围) */
|
||||||
|
export async function listClassStudentIds(classId: string): Promise<string[]> {
|
||||||
|
const students = await prisma.user.findMany({
|
||||||
|
where: { classId, role: 'STUDENT' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return students.map((item) => item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前会话的"个人记录主体"列表:
|
||||||
|
* - 普通学生:自己
|
||||||
|
* - 班级账号:全班学生
|
||||||
|
* - 教师/管理员:自己(教师侧的完成情况统计走 withStatus,不依赖这个列表)
|
||||||
|
*/
|
||||||
|
export async function resolvePersonalIds(user: TokenPayload): Promise<string[]> {
|
||||||
|
if (isClassSession(user)) return listClassStudentIds(classSessionClassId(user));
|
||||||
|
return [user.sub];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prisma 过滤片段:`{ userId: { in: ids } }` */
|
||||||
|
export function personalIdWhere(ids: string[]): { userId: { in: string[] } } {
|
||||||
|
return { userId: { in: ids } };
|
||||||
|
}
|
||||||
@@ -15,6 +15,31 @@ export function authenticate(): RequestHandler {
|
|||||||
if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
|
if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
|
||||||
|
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
|
|
||||||
|
// 班级账号(班级设备):主体是班级而不是某个学生账号,回查 Class 表
|
||||||
|
if (payload.classSession) {
|
||||||
|
const record = await prisma.class.findUnique({
|
||||||
|
where: { id: payload.classId ?? payload.sub },
|
||||||
|
select: { id: true, name: true, code: true },
|
||||||
|
});
|
||||||
|
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
|
||||||
|
|
||||||
|
req.auth = {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
sub: record.id,
|
||||||
|
username: record.code,
|
||||||
|
name: record.name,
|
||||||
|
role: 'STUDENT',
|
||||||
|
classId: record.id,
|
||||||
|
classSession: true,
|
||||||
|
classCode: record.code,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
select: { id: true, username: true, name: true, role: true, classId: true },
|
select: { id: true, username: true, name: true, role: true, classId: true },
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
|
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
|
||||||
|
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
|
||||||
|
export function validatedBody<T>(req: Request): T {
|
||||||
|
// validate() 会把解析结果写回 req.body
|
||||||
|
return req.body as T;
|
||||||
|
}
|
||||||
|
|
||||||
export function validatedQuery<T>(req: Request): T {
|
export function validatedQuery<T>(req: Request): T {
|
||||||
return (req.validatedQuery ?? {}) as T;
|
return (req.validatedQuery ?? {}) as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,30 +6,43 @@ import { validate } from '../../middleware/validate.js';
|
|||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
changePasswordSchema,
|
changePasswordSchema,
|
||||||
|
classLoginSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
type ChangePasswordInput,
|
type ChangePasswordInput,
|
||||||
|
type ClassLoginInput,
|
||||||
type LoginInput,
|
type LoginInput,
|
||||||
} from './auth.schemas.js';
|
} from './auth.schemas.js';
|
||||||
import * as authService from './auth.service.js';
|
import * as authService from './auth.service.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
/** POST /api/auth/login - 登录(公开) */
|
/** POST /api/auth/login - 登录(公开):教师 / 管理员 / 个人学生账号 */
|
||||||
router.post('/login', validate({ body: loginSchema }), async (req, res) => {
|
router.post('/login', validate({ body: loginSchema }), async (req, res) => {
|
||||||
const result: LoginResponse = await authService.login(req.body as LoginInput);
|
const result: LoginResponse = await authService.login(req.body as LoginInput);
|
||||||
sendOk(res, result, '登录成功');
|
sendOk(res, result, '登录成功');
|
||||||
});
|
});
|
||||||
|
|
||||||
/** GET /api/auth/me - 当前用户信息 */
|
/**
|
||||||
router.get('/me', authenticate(), async (req, res) => {
|
* POST /api/auth/class-login - 班级账号登录(公开,学生端主入口)
|
||||||
const user = getAuthUser(req);
|
*
|
||||||
sendOk(res, await authService.getProfile(user.sub), '获取当前用户成功');
|
* 班级码 + 班级密码 → 以「班级」为主体的 JWT(个人数据按全班读写)。
|
||||||
|
* 个人学生账号仍可用 /auth/login 登录(向后兼容,客户端已不再展示该入口)。
|
||||||
|
*/
|
||||||
|
router.post('/class-login', validate({ body: classLoginSchema }), async (req, res) => {
|
||||||
|
const result: LoginResponse = await authService.classLogin(req.body as ClassLoginInput);
|
||||||
|
sendOk(res, result, '班级登录成功');
|
||||||
});
|
});
|
||||||
|
|
||||||
/** PATCH /api/auth/password - 修改密码 */
|
/** GET /api/auth/me - 当前用户信息(班级账号返回班级信息) */
|
||||||
|
router.get('/me', authenticate(), async (req, res) => {
|
||||||
|
const user = getAuthUser(req);
|
||||||
|
sendOk(res, await authService.getProfile(user), '获取当前用户成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PATCH /api/auth/password - 修改密码(班级账号修改的是班级密码) */
|
||||||
router.patch('/password', authenticate(), validate({ body: changePasswordSchema }), async (req, res) => {
|
router.patch('/password', authenticate(), validate({ body: changePasswordSchema }), async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
await authService.changePassword(user.sub, req.body as ChangePasswordInput);
|
await authService.changePassword(user, req.body as ChangePasswordInput);
|
||||||
sendOk(res, { changed: true }, '密码修改成功,请重新登录');
|
sendOk(res, { changed: true }, '密码修改成功,请重新登录');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ export const loginSchema = z.object({
|
|||||||
password: z.string().min(1, '请输入密码').max(128),
|
password: z.string().min(1, '请输入密码').max(128),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 班级账号登录(学生端):班级码 + 班级密码 */
|
||||||
|
export const classLoginSchema = z.object({
|
||||||
|
code: z.string().trim().min(4, '请输入班级码').max(16),
|
||||||
|
password: z.string().min(1, '请输入班级密码').max(128),
|
||||||
|
});
|
||||||
|
|
||||||
export const changePasswordSchema = z.object({
|
export const changePasswordSchema = z.object({
|
||||||
currentPassword: z.string().min(1, '请输入当前密码'),
|
currentPassword: z.string().min(1, '请输入当前密码'),
|
||||||
newPassword: z.string().min(6, '新密码至少 6 位').max(128),
|
newPassword: z.string().min(6, '新密码至少 6 位').max(128),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LoginInput = z.infer<typeof loginSchema>;
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
export type ClassLoginInput = z.infer<typeof classLoginSchema>;
|
||||||
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|
||||||