Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad22df4fd | ||
|
|
572295c297 | ||
|
|
391e2c1591 | ||
|
|
20fcca0388 | ||
|
|
3880eee08e | ||
|
|
d27f9a4b9e | ||
|
|
7ea0e0f7c9 | ||
|
|
9c678b3730 |
No files matched your search
@@ -41,3 +41,10 @@ Thumbs.db
|
|||||||
|
|
||||||
# 参考用的第三方源码包(设计 token 已提取到 docs/winisland-design-tokens.md)
|
# 参考用的第三方源码包(设计 token 已提取到 docs/winisland-design-tokens.md)
|
||||||
WinIsland-master.zip
|
WinIsland-master.zip
|
||||||
|
releases/
|
||||||
|
|
||||||
|
# AI 浼氳瘽浜х墿锛圸Code 璁″垝/閰嶇疆锛屼笉灞炰簬浜у搧浠g爜锛�
|
||||||
|
.zcode/
|
||||||
|
.zcodeignore
|
||||||
|
packages/server/prisma/backups/
|
||||||
|
packages/server/prisma/database-settings.json
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
# AGENTS.md — 班级小助手(Class Helper)
|
# AGENTS.md — 班级小助手(Class Helper)
|
||||||
|
|
||||||
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
|
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
|
||||||
|
文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 这是什么
|
## 1. 这是什么
|
||||||
|
|
||||||
班级信息管理系统,**pnpm monorepo**,一份代码产出三个交付物:
|
班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物:
|
||||||
|
|
||||||
| 交付物 | 位置 | 形态 |
|
| 交付物 | 位置 | 形态 |
|
||||||
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
|
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
|
||||||
| 后端服务 | `packages/server` | Express + Prisma + Socket.IO,`/api` 前缀 |
|
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
|
||||||
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用) |
|
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
|
||||||
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
|
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
|
||||||
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
||||||
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
|
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
|
||||||
@@ -44,82 +45,103 @@ ClassIsland(教室机器)──► 班级小助手联动插件 ──► /ap
|
|||||||
package.json 根脚本(dev / build / db:* / verify:* / dist:* / icons)
|
package.json 根脚本(dev / build / db:* / verify:* / dist:* / icons)
|
||||||
pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白名单,勿随手删条目)
|
pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白名单,勿随手删条目)
|
||||||
tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
|
tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
|
||||||
eslint.config.mjs ESLint 扁平配置(TS + Vue)
|
eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/
|
||||||
packages/shared/src/ types / constants / permissions / utils
|
.prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格
|
||||||
|
build/icon.{ico,png} 应用图标(由 pnpm icons 生成)
|
||||||
|
scripts/
|
||||||
|
use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema)
|
||||||
|
generate-icons.mjs Electron 渲染 SVG → PNG/ICO
|
||||||
|
icons/render.cjs 图标渲染(CommonJS)
|
||||||
|
dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-server/)
|
||||||
|
nsis/server-installer.nsi 服务端 NSIS 安装脚本
|
||||||
|
verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟
|
||||||
|
ui-smoke/{run,main.cjs,live-probe.mjs} Web 管理端真实点击回归(Electron 驱动)
|
||||||
|
lib/{electron-env,node-runtime}.mjs 启动 Electron / 定位真实 node.exe
|
||||||
|
deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / install-linux.sh
|
||||||
|
classhelper.service / package.runtime.json / .env{,.sqlite}.example
|
||||||
|
docs/ production.md(生产部署)/ mysql.md(MySQL 切换)/ winisland-design-tokens.md
|
||||||
|
screenshots/{island,client,web-mobile,classisland}/
|
||||||
|
packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts
|
||||||
packages/server/
|
packages/server/
|
||||||
prisma/schema.prisma 数据模型(sqlite;另有 schema.mysql.prisma)
|
prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
|
||||||
prisma/migrations/ 迁移历史(已生成并应用)
|
prisma/migrations/ 10 个迁移(init / time_layout / class_account / schedule_week_parity /
|
||||||
|
class_term_weeks / classisland_integration / class_notification_channel /
|
||||||
|
homework_assign_date / push_kind / schedule_source)
|
||||||
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
|
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
|
||||||
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
|
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
|
||||||
|
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,实测 173 项)
|
||||||
|
scripts/verify-classisland.mjs 联动链路验收(实测 42 项)
|
||||||
|
scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用)
|
||||||
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
|
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
|
||||||
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
|
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
|
||||||
src/config/env.ts zod 环境变量校验 + 生产自检
|
src/config/env.ts zod 环境变量校验 + 生产自检
|
||||||
src/lib/ db / jwt / access(RBAC) / http / mappers / term / web-static
|
src/lib/ access(RBAC) class-account db db-bootstrap http jwt logger mappers
|
||||||
src/middleware/ auth / validate / error / security
|
password schemas session term web-static
|
||||||
|
src/middleware/ auth validate error security
|
||||||
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
|
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
|
||||||
src/modules/ 功能模块 + registry.ts(模块注册表)
|
src/modules/ 13 个功能模块 + registry.ts + module.types.ts
|
||||||
scripts/verify-e2e.mjs 后端端到端验收(163 项)
|
packages/web-admin/src/ api stores router layouts views(11) components(4) composables styles config.ts
|
||||||
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/
|
packages/desktop-client/
|
||||||
src/main/ 主进程:窗口 / 单实例 / IPC 配置 / 托盘 / 灵动岛 / smoke
|
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / logger / smoke
|
||||||
src/preload/ contextBridge 白名单桥(**不暴露 ipcRenderer 本体**)
|
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
|
||||||
src/island/ 灵动岛独立透明置顶窗口的渲染进程
|
src/island/ 灵动岛渲染进程:IslandApp.vue / spring.ts / squircle.ts / main.ts
|
||||||
src/renderer/ 学生端渲染进程:api / stores / cache(IndexedDB) / views
|
src/renderer/ 学生端渲染进程:api / cache(IndexedDB) / stores / views / island / router
|
||||||
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
|
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
|
||||||
scripts/ build-main / dev / smoke / dist-win
|
scripts/ build-main / dev / smoke / dist-win
|
||||||
scripts/ use-database.mjs / generate-icons.mjs / dist-server.mjs / ui-smoke/
|
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
|
||||||
deploy/ Dockerfile / docker-compose.yml / nginx.conf / install-linux.sh
|
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.0.0.0
|
||||||
docs/ production.md(生产部署)/ mysql.md / winisland-design-tokens.md
|
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
|
||||||
README.md 完整产品与交付说明(改动交付形态时同步更新)
|
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
|
||||||
|
src/Models/PluginSettings.cs
|
||||||
|
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(静态契约校验,实测 68 项)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 本机环境(已配置好,可直接开工)
|
## 3. 本机环境(2026-09-30 实测)
|
||||||
|
|
||||||
| 项 | 状态 |
|
| 项 | 状态 |
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Node | v22.15.0(要求 ≥ 20.19,Prisma 7 硬性要求) |
|
| Node | **v24.19.0**(`D:\nodejs\node.exe`;要求 ≥ 20.19,Prisma 7 硬性要求) |
|
||||||
| pnpm | 11.19.0(`packageManager` 锁定 11.8.0) |
|
| pnpm | 11.8.0(`packageManager` 锁定 11.8.0),宿主就是上面这个 node |
|
||||||
| npm registry | `https://registry.npmmirror.com/`(全局 pnpm 配置;仓库内无 `.npmrc`) |
|
| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
|
||||||
| 依赖 | `pnpm install` 已完成(707 包) |
|
| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
|
||||||
| 后端 `.env` | 已由 `.env.example` 生成 `packages/server/.env`(**gitignored**),`JWT_SECRET` 为本机随机值 |
|
| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
|
||||||
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
|
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
|
||||||
| 数据库 | SQLite `packages/server/prisma/dev.db`,5 个迁移已应用、种子已写入 |
|
| 数据库 | SQLite `packages/server/prisma/dev.db`:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) |
|
||||||
| Electron 二进制 | 已下载(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`) |
|
| `shared/dist` | 已构建(`pnpm build:shared`)—— **它是三端与 seed 的前置产物,缺了会 `ERR_MODULE_NOT_FOUND`** |
|
||||||
| .NET SDK | 8.0.303(构建 ClassIsland 插件用;插件与 pnpm workspace 无关,不参与 `pnpm install`) |
|
| Electron 二进制 | 已手动装好(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`,246 MB) |
|
||||||
| 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>`),因此插件可以真跑起来验证 |
|
| .NET SDK | ✅ **8.0.425**(`C:\Program Files\dotnet\sdk`,`dotnet --list-sdks` 有输出)⇒ 插件可编译/打包;**别再照旧文档说"没装"**(`dotnet` 在 PATH 里,`--list-sdks` 才是权威判据) |
|
||||||
|
| ClassIsland | **2.1.0.1,装在 `D:\Classisland`**(启动器 `D:\Classisland\ClassIsland.exe`,实际进程 `D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe`) |
|
||||||
|
| ClassIsland 数据 | 数据根 **`D:\Classisland\data`**:`Config/`、`Profiles/`、`Plugins/`(当前为空,尚未装本插件)、`Logs/`、`Cache/`、`Settings.json` |
|
||||||
|
| 桌面会话 | 有(`ClassIsland.Desktop.exe` 跑在 session 1);**Electron GUI 在 AI 会话里也能跑**(需先修完整性标签,见 §7 第 7 条) |
|
||||||
|
|
||||||
### 从零重建环境
|
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
|
||||||
|
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
|
||||||
|
|
||||||
|
### 从零重建环境(本机实测可行的顺序)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install # 需要下载 Electron 时加 NODE_OPTIONS=--use-system-ca
|
||||||
|
|
||||||
# 后端环境变量(.env 不入库,必须自己生成)
|
# 后端环境变量(.env 不入库,必须自己生成)
|
||||||
cp packages/server/.env.example packages/server/.env # 然后至少改 JWT_SECRET
|
cp packages/server/.env.example packages/server/.env # 至少改 JWT_SECRET;TERM_START_DATE 建议 2026-09-07
|
||||||
|
|
||||||
pnpm db:generate
|
pnpm db:generate # 生成 Prisma Client
|
||||||
pnpm db:deploy # 应用迁移(见 §7 第 6 条:不要用 db:migrate)
|
pnpm --filter @classhelper/server db:deploy # 应用迁移(根 scripts 里没有 db:deploy,见 §7)
|
||||||
|
pnpm build:shared # 必须先构建 shared,否则 seed / 服务端启动会失败
|
||||||
pnpm db:seed # 写演示数据
|
pnpm db:seed # 写演示数据
|
||||||
|
|
||||||
pnpm build:shared # 三端都依赖 shared 的 dist,先构建它
|
# Electron 二进制不会随 pnpm install 下载,需要手动补:
|
||||||
```
|
# 工作目录 node_modules/.pnpm/electron@44.3.0/node_modules/electron
|
||||||
|
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js
|
||||||
|
|
||||||
Electron 二进制**不会**随 `pnpm install` 自动下载(本机 postinstall 被跳过),需要手动补:
|
# ClassIsland 插件(.NET 8 SDK 已装:8.0.425)
|
||||||
|
pnpm build:classisland-plugin
|
||||||
```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` 产出)
|
### 演示账号(`pnpm db:seed` 产出)
|
||||||
@@ -127,16 +149,21 @@ node install.js # 工作目录:node_modules/.pnpm/electron@44.3.0/node_modul
|
|||||||
| 角色 | 用户名 | 密码 |
|
| 角色 | 用户名 | 密码 |
|
||||||
| -------------- | ------------------------------- | ------------ |
|
| -------------- | ------------------------------- | ------------ |
|
||||||
| 管理员 | `admin` | `admin123` |
|
| 管理员 | `admin` | `admin123` |
|
||||||
| 班主任 | `teacher1` / `teacher2` | `teacher123` |
|
| 教师 | `teacher1` / `teacher2` | `teacher123` |
|
||||||
| 学生个人号 | `student01` … `student15` | `student123` |
|
| 学生端班级账号 | 班级码 `G101` / `G102` / `G203` | `123456` |
|
||||||
| 学生端班级账号 | 班级码 `G101` / `G102` / `G103` | `123456` |
|
|
||||||
|
> 密码是**种子脚本里硬编码**的(`admin123` / `teacher123` / 班级 `123456`);
|
||||||
|
> `.env` 的 `DEFAULT_CLASS_PASSWORD` 只影响**新建**班级时的初始班级密码。
|
||||||
|
> **学生没有个人账号**(2026-10-01 清理):`student01…15` 只是名单记录(成绩/未交/叫人/已读按名单),
|
||||||
|
> `User.passwordHash` 为空串、不能登录,也没有任何密码接口;学生端统一用班级码 + 班级密码。
|
||||||
|
> 班级码 `G203` 是「高二(3)班」,不是 `G103`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 常用命令
|
## 4. 常用命令
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| --------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
|
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
|
||||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||||
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
|
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||||
@@ -147,19 +174,20 @@ node install.js # 工作目录:node_modules/.pnpm/electron@44.3.0/node_modul
|
|||||||
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` |
|
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` |
|
||||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||||
| `pnpm lint` / `pnpm lint:fix` | ESLint |
|
| `pnpm lint` / `pnpm lint:fix` | ESLint |
|
||||||
| `pnpm format` / `pnpm format:check` | Prettier |
|
| `pnpm format` / `pnpm format:check` | Prettier(`format:check` 在 master 基线上本就失败,见 §7 第 21 条) |
|
||||||
| `pnpm db:generate` | 生成 Prisma Client |
|
| `pnpm db:generate` | 生成 Prisma Client |
|
||||||
| `pnpm db:deploy` | 应用已有迁移(**本机推荐**) |
|
| `pnpm db:migrate` | `prisma migrate dev`(本机不可靠,别用;见 §7) |
|
||||||
|
| `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**本机推荐**;根 scripts 里没有 `db:deploy`) |
|
||||||
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
|
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
|
||||||
| `pnpm db:studio` | Prisma Studio |
|
| `pnpm db:studio` | Prisma Studio |
|
||||||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider(配合 `docs/mysql.md`) |
|
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/mysql.md`) |
|
||||||
| `pnpm verify:e2e` | 后端端到端验收 163 项(**需后端已启动**) |
|
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-09-30 实测 173 项全过) |
|
||||||
| `pnpm verify:web` | Web 管理端真实点击回归 27 项(需后端已启动且 Web 产物已构建) |
|
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
|
||||||
| `pnpm verify:desktop` | 客户端冒烟 77+ 项(Electron,无人工点击) |
|
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
|
||||||
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟 |
|
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
|
||||||
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约,需后端已启动) |
|
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 42 项) |
|
||||||
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) |
|
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) |
|
||||||
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 两者 |
|
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
|
||||||
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
|
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
|
||||||
|
|
||||||
### 端口
|
### 端口
|
||||||
@@ -170,29 +198,22 @@ node install.js # 工作目录:node_modules/.pnpm/electron@44.3.0/node_modul
|
|||||||
| 5173 | Web 管理端开发服务器(`/api`、`/socket.io` 代理到 4000) |
|
| 5173 | Web 管理端开发服务器(`/api`、`/socket.io` 代理到 4000) |
|
||||||
| 5174 | 桌面客户端渲染进程开发服务器 |
|
| 5174 | 桌面客户端渲染进程开发服务器 |
|
||||||
|
|
||||||
### 交付产物归集(本机约定:`releases/`)
|
### 交付产物归集
|
||||||
|
|
||||||
打包产物统一归集到仓库根的 `releases/`(已被忽略,不入库):
|
打包脚本各自输出到固定目录,**归集到仓库根 `releases/` 是本仓库的人工约定**(该目录 gitignore + eslint ignore):
|
||||||
|
|
||||||
```
|
```
|
||||||
releases/
|
release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序)
|
||||||
client/ win-unpacked/ 免安装目录(verify:packaged 直接指它)
|
packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe)
|
||||||
班级小助手-<版本>-x64-setup.exe 学生端 NSIS 安装包
|
releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/)
|
||||||
班级小助手-<版本>-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
|
```bash
|
||||||
# 客户端:electron-builder 支持直接指定输出目录(dist-win.mjs 会把额外参数透传给它)
|
# 客户端:electron-builder 支持指定输出目录(dist-win.mjs 会把额外参数透传)
|
||||||
pnpm build:desktop
|
|
||||||
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
|
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
|
||||||
|
|
||||||
# 服务端:脚本输出路径写死在 release-server/,打完需要自己移进 releases/server/
|
# 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/
|
||||||
pnpm dist:server # 首次会跑 46s 左右的 npm install;重试可加 --reuse-deps 跳过
|
pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过
|
||||||
|
|
||||||
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
|
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
|
||||||
pnpm dist:classisland-plugin
|
pnpm dist:classisland-plugin
|
||||||
@@ -203,29 +224,36 @@ pnpm dist:classisland-plugin
|
|||||||
## 5. 架构硬约定(改代码前必须知道)
|
## 5. 架构硬约定(改代码前必须知道)
|
||||||
|
|
||||||
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
|
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
|
||||||
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。新增/下线功能**只改
|
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 13 个:`auth / classes / courses / schedules /
|
||||||
`src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
|
homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard`。
|
||||||
|
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
|
||||||
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
|
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
|
||||||
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
|
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
|
||||||
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
|
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
|
||||||
`@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/ STUDENT / NONE`,
|
`@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/ STUDENT / NONE`,
|
||||||
权限判 `canManageSchedule` / `canPublishContent` / `canManageRoster` 等,**不要在页面里另写一套规则**。
|
权限判 `canManageSchedule` / `canPublishContent` / `canManageRoster` / `canManageGrades` 等
|
||||||
|
(人类可读矩阵是 `PERMISSION_MATRIX`,README 直接引用它),**不要在页面里另写一套规则**。
|
||||||
4. **学生端主体是「班级账号」。** 班级码 + 班级密码登录后,`classId` 即身份;该设备的「已读 / 完成」
|
4. **学生端主体是「班级账号」。** 班级码 + 班级密码登录后,`classId` 即身份;该设备的「已读 / 完成」
|
||||||
代全班操作,个人学生登录已停用。API 里对此有专门分支,别按个人账号语义改。
|
代全班操作,个人学生登录已停用。API 里对此有专门分支,别按个人账号语义改。
|
||||||
5. **实时推送只走房间。** 服务端 `src/realtime/`(`socket.ts` 房间 `class:{classId}`、`user:{id}`;`bus.ts` 事件总线),
|
5. **实时推送只走房间。** 服务端 `src/realtime/`(`SOCKET_ROOMS`:`class:{classId}`、`user:{id}`、
|
||||||
客户端 `renderer/stores/realtime.ts` 订阅。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、
|
`teacher:{id}`、`role:{role}`、`students`、`teachers`;`bus.ts` 事件总线),客户端
|
||||||
客户端订阅(灵动岛也依赖它)。
|
`renderer/stores/realtime.ts` 订阅。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、
|
||||||
|
客户端订阅(灵动岛也依赖它)。事件名一律取自共享常量 `SOCKET_EVENTS`,不要手写字符串。
|
||||||
6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`,
|
6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`,
|
||||||
改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。
|
改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。
|
||||||
7. **数据模型跨库通用。** `schema.prisma` 只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、
|
7. **数据模型跨库通用。** `schema.prisma` 只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、
|
||||||
不用 `@db.*`;角色与优先级存字符串并在 API 层用 zod 校验(这样 SQLite / MySQL 行为一致)。
|
不用 `@db.*`;角色与优先级存字符串并在 API 层用 zod 校验(这样 SQLite / MySQL 行为一致)。
|
||||||
改模型要同步 `schema.mysql.prisma` 和 `prisma/migrations/`。
|
**只有一份 `schema.prisma`**:切库是改写它的 `provider`(`pnpm db:switch:mysql`),没有 `schema.mysql.prisma`。
|
||||||
|
改模型要同步 `prisma/migrations/`(本机用 `pnpm --filter @classhelper/server db:deploy` 应用)。
|
||||||
8. **周次口径唯一。** 当前教学周 = `shared.resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)`,
|
8. **周次口径唯一。** 当前教学周 = `shared.resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)`,
|
||||||
`MAX_TERM_WEEK = 30`,班级还有自己的 `Class.termWeeks`(默认 20)。别在别处重新实现周次换算。
|
`MAX_TERM_WEEK = 30`,班级还有自己的 `Class.termWeeks`(默认 20,**上限也是 30**)。别在别处重新实现周次换算。
|
||||||
|
周次上限三处必须一致:`MAX_TERM_WEEK`、`lib/schemas.ts` 的 `weekNumberSchema`、`classes.schemas.ts` 的 `termWeeks`
|
||||||
|
(曾经班级能设到 40 而课表只到 30,等于对外承诺了系统兑现不了的学期长度)。
|
||||||
|
`Class.termWeeks` 只有**管理员**能改(`assertCanManageClasses`),班主任不行。
|
||||||
9. **Electron 安全边界。** `src/preload/` 只通过 contextBridge 暴露白名单方法,**不要暴露 `ipcRenderer` 本体**;
|
9. **Electron 安全边界。** `src/preload/` 只通过 contextBridge 暴露白名单方法,**不要暴露 `ipcRenderer` 本体**;
|
||||||
主进程 ↔ 渲染进程的类型统一写在 `src/types/desktop.d.ts`。
|
主进程 ↔ 渲染进程的类型统一写在 `src/types/desktop.d.ts`。
|
||||||
|
|
||||||
**跨桥接只能传纯数据。** contextBridge 在参数跨越"主窗口 → 隔离世界"时就做结构化克隆,
|
**跨桥接只能传纯数据。** contextBridge 在参数跨越「主窗口 → 隔离世界」时就做结构化克隆,
|
||||||
传 Vue 的 `ref.value` / `reactive()` 对象(Proxy)会抛 `An object could not be cloned.`——
|
传 Vue 的 `ref.value` / `reactive()` 对象(Proxy)会抛 `An object could not be cloned.`——
|
||||||
而且它在**进入 preload 函数体之前**就失败了,所以桥接层**无法兜底**,只能由调用方展开成
|
而且它在**进入 preload 函数体之前**就失败了,所以桥接层**无法兜底**,只能由调用方展开成
|
||||||
字面量再传(`saveConfig({ island: { ...island.value } })`)。这类失败是"半静默"的:
|
字面量再传(`saveConfig({ island: { ...island.value } })`)。这类失败是"半静默"的:
|
||||||
@@ -235,18 +263,15 @@ pnpm dist:classisland-plugin
|
|||||||
10. **客户端离线优先。** 渲染进程数据先落 IndexedDB(`renderer/cache/`),断网回退缓存并在顶部提示离线;
|
10. **客户端离线优先。** 渲染进程数据先落 IndexedDB(`renderer/cache/`),断网回退缓存并在顶部提示离线;
|
||||||
新增页面/数据源要按这个模式接。
|
新增页面/数据源要按这个模式接。
|
||||||
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
|
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
|
||||||
(`--ch-radius-*` / `--ch-shadow-*`),组件里**不要写死数值**;Element Plus 通过覆盖它的 CSS 变量对齐。
|
(`--ch-radius-xl/lg/md/sm/pill`、`--ch-shadow-sm/md`),组件里**不要写死数值**;Element Plus 通过覆盖它的
|
||||||
手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card` 约定。
|
CSS 变量对齐。手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card`。
|
||||||
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256)
|
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256,
|
||||||
调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。新增插件接口时,
|
明文前缀为 `chci_`)调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。
|
||||||
路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO
|
新增插件接口时,路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO
|
||||||
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
|
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
|
||||||
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里
|
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里
|
||||||
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
|
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
|
||||||
而且表现为"课表看着有内容、每节课都对不上",极难排查。
|
而且表现为"课表看着有内容、每节课都对不上",极难排查。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
|
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
|
||||||
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
|
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
|
||||||
否则又会出现「某个页面发的通知不联动 ClassIsland」。
|
否则又会出现「某个页面发的通知不联动 ClassIsland」。
|
||||||
@@ -254,15 +279,14 @@ pnpm dist:classisland-plugin
|
|||||||
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
|
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
|
||||||
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
|
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
|
||||||
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
|
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
|
||||||
|
|
||||||
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
|
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
|
||||||
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
|
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
|
||||||
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
|
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
|
||||||
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课),
|
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课),
|
||||||
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
|
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
|
||||||
服务端 `synthesizeTimeLayout` 同口径。
|
服务端合成节次时间表同口径。
|
||||||
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
|
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
|
||||||
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);`urgent` 同样立刻弹。
|
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);紧急(`URGENT`)同样立刻弹。
|
||||||
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
|
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
|
||||||
客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。
|
客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。
|
||||||
19. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期
|
19. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期
|
||||||
@@ -282,12 +306,271 @@ pnpm dist:classisland-plugin
|
|||||||
回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它**整屏截图**取样
|
回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它**整屏截图**取样
|
||||||
(`webContents.capturePage()` 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因),
|
(`webContents.capturePage()` 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因),
|
||||||
并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。
|
并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。
|
||||||
|
22. **灵动岛位置由「屏幕工作区 + 边距」算,默认边距 8px(`DEFAULT_ISLAND_APPEARANCE.marginX/marginY`)**:
|
||||||
|
改默认值会直接改外观,`verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,一动就红。
|
||||||
|
新增边距类设置请只改"可调范围",别动默认值(夹紧规则收口在主进程 `normalizeIslandAppearance`)。
|
||||||
|
23. **课表行有"来源"字段 `Schedule.source`**(`manual` / `classisland`)。插件每次全量上报时,
|
||||||
|
服务端只清理「`source='classisland'` 且本次未再上报」的行 —— 这样老师在 ClassIsland 里删掉/挪动一节
|
||||||
|
能真正同步下来(原先 merge 只 upsert,服务端会永远留着幽灵课),**老师在小助手这边手排的课一律不删**。
|
||||||
|
因此:**新增写课表的入口要显式定 source**(Web 端录入/导入保持默认 `manual`,只有插件上报用 `classisland`),
|
||||||
|
否则要么幽灵课清不掉,要么手排的课被误删。回归用例见 `verify:classisland` 的「幽灵行清理」两条。
|
||||||
|
**`source` 只在 create 时确定,update 一律保持原值。** merge 的去重键是「星期 + 开始时间 + 单双周」,
|
||||||
|
老师在 Web 端手排的同一时间槽会被上报"收养";若收养时把来源改成 `classisland`,
|
||||||
|
下一次清理就会把这行手排的课删掉 —— 这个坑踩过一次:种子课表被删掉 2 行,
|
||||||
|
`verify:e2e` 的「上课状态接口」用例(AGENTS.md §7 已列为脆弱用例)随即报红。
|
||||||
|
同步时**不要**覆盖 `weekStart/weekEnd`:插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。
|
||||||
|
24. **两个登录入口都必须挂 `loginRateLimiter()`**(`app.ts` 用数组形式同时挂 `/auth/login` 与 `/auth/class-login`)。
|
||||||
|
班级密码默认是 `123456`、班级码是固定字符集,班级登录曾是唯一没有暴力破解防护的入口。
|
||||||
|
另外 `auth.service.login` 里**角色判定必须在密码校验之前**:否则"密码错(401) / 密码对但学生账号已停用(403)"
|
||||||
|
的差异会把学生账号密码变成可离线验证的预言机。
|
||||||
|
25. **凡是会渲染成链接的字段都要校验 scheme。** `Homework.attachmentUrl` 在服务端用
|
||||||
|
`attachmentUrlSchema`(只允许 `http(s)://` 或 `/` 开头的站内相对路径),Web 端渲染前再兜一层
|
||||||
|
(`HomeworksView.vue` 的 `attachmentHref`)。放行 `javascript:` / `data:text/html` 就是一条存储型 XSS。
|
||||||
|
26. **桌面端必须保留 CSP**(`index.html` / `island.html` 的 `meta http-equiv="Content-Security-Policy"`)。
|
||||||
|
渲染进程通过 preload 能拿到 `getConfig()` 的**明文 token**(代表全班身份),CSP 是唯一能拦住
|
||||||
|
"加载并执行远程脚本"的一层。配套的两条导航防线也别删:主窗口 `isAllowedNavigation`(按同源/同一文件精确比对,
|
||||||
|
不要退回 `startsWith`)、灵动岛窗口的 `setWindowOpenHandler` + `will-navigate` 全拒。
|
||||||
|
|
||||||
|
27. **桌面端产物里不得出现任何凭据,构建有门禁。** `dist/main/index.js` 与 `dist/renderer/assets/*.js`
|
||||||
|
会被 electron-builder 打进 `app.asar` 发给每台学生机,而 asar 可直接解包
|
||||||
|
(历史问题:冒烟/自检代码里写死了 `teacher1/teacher123`,`grep` 产物即命中)。
|
||||||
|
冒烟与自检用的账号口令、班级码/班级密码**一律通过环境变量注入**
|
||||||
|
(`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `ELECTRON_SMOKE_CLASS_CODE` / `..._CLASS_PASSWORD`):
|
||||||
|
preload 读环境变量 → `window.desktop.smokeCredentials` → `setSmokeTeacherCredentials()`,
|
||||||
|
由 `scripts/smoke.mjs` 与 `scripts/verify-packaged.mjs` 提供(那两个脚本不进安装包)。
|
||||||
|
**把静态 import 改成 `await import('./smoke.js')` 解决不了这个问题**:esbuild 在
|
||||||
|
`format:'cjs'` + `bundle:true` 下不做代码分割,动态 import 同样被内联进同一个产物(已实测)。
|
||||||
|
`pnpm build:desktop` 结尾会跑 `check-bundle-secrets`(`packages/desktop-client/scripts/check-bundle-secrets.mjs`),
|
||||||
|
产物里出现种子口令/把种子班级码当凭据的写法即构建失败 —— 别把这条门禁摘掉。
|
||||||
|
28. **表格导入的体积上限是"请求体 12MB ↔ 文件 8MB"**:文件以 base64 放进 JSON(约 4/3 倍),
|
||||||
|
因此 `express.json({ limit: '12mb' })` 必须留够余量,否则声明了 8MB 却连 1.5MB 的文件都传不进来。
|
||||||
|
超限由 `middleware/error.ts` 映射成 413 `IMPORT_TOO_LARGE`(**不要**让它落进 500 兜底分支:
|
||||||
|
原先用户看到的是一句英文的「服务器内部错误:request entity too large」)。
|
||||||
|
29. **删除类接口的护栏要与 schema 的级联关系对齐。** `deleteTeacher` 的检查项必须覆盖
|
||||||
|
`Class.teacherId` / `Course.teacherId` / `ClassTeacher.teacherId` / `Homework.createdBy` /
|
||||||
|
`Notification.createdBy` 五项(后两项曾经漏检,会静默删掉该教师在各班发过的作业与通知;
|
||||||
|
回归用例见 `verify:e2e` 的「删除教师护栏覆盖"发布过作业/通知"的账号」)。
|
||||||
|
**改 schema 时先看哪些外键指向 `User` 且是 Cascade,再回来核对这个清单。**
|
||||||
|
30. **灵动岛在触摸屏上必须走「触摸模式」(`touchMode`)。** 打开命中的两条链路
|
||||||
|
(渲染进程的 mousemove 转发、主进程读 `screen.getCursorScreenPoint()`)**都以"鼠标指针移动"
|
||||||
|
为前提**,而手指触摸既不产生 mousemove、也不移动系统光标 ⇒ 窗口永远停在
|
||||||
|
`setIgnoreMouseEvents(true)` 的穿透态,**手指点不开岛**(用户反馈:希沃白板)。
|
||||||
|
触摸屏上唯一的解法是"触摸点处窗口可命中":Electron 没有 `SetWindowRgn`,
|
||||||
|
所以渲染进程按 `navigator.maxTouchPoints > 0` 上报(`island:set-touch-mode`)后,
|
||||||
|
主进程把窗口**贴合岛体**(`windowBox` = 当前形态 + 2×阴影留白)并**始终接收输入**
|
||||||
|
(`setInteractive` 在触摸模式下把入参强制为 true)。三条护栏别动:
|
||||||
|
|
||||||
|
- **变小要等岛真的收小**(`cardFitsFormSize` + 渲染进程上报的岛体矩形)—— 立刻缩小会把形变中的卡片裁掉;
|
||||||
|
- `expectedHitRect` 必须按**窗口实际尺寸**算偏移(触摸模式下窗口不再是固定包围盒);
|
||||||
|
- 失焦宽限期用 `BLUR_GRACE_TOUCH_MS`(1200ms):触摸没有光标,`isCursorOnIsland()` 恒为 false,
|
||||||
|
而实测假失焦出现在 70~520ms(跨过 500ms 边界 ⇒ 会把刚展开的岛缩回胶囊)。
|
||||||
|
非触摸屏机器**完全不走这条路**(窗口仍是固定大包围盒 + 按光标命中),
|
||||||
|
`verify:desktop` 会先 `setTouchMode(false)` 再跑既有的光标注入用例,末尾单独断言触摸模式。
|
||||||
|
|
||||||
|
31. **"改密码"的入口只剩 Web 管理端两处,客户端一个都没有。** 教师/管理员改本人密码走
|
||||||
|
Web 顶栏下拉(`PATCH /api/auth/password`);班级密码由管理员在「班级管理 → 修改班级账号」
|
||||||
|
(`PATCH /classes/:id/class-account`)维护;教师设指定新密码用
|
||||||
|
`POST /teachers/:id/reset-password`(可选 `newPassword`,留空 = 默认初始密码)。
|
||||||
|
**2026-10-01 起学生个人账号整体清理**:学生只是名单(成绩/未交/叫人/已读按名单记录),
|
||||||
|
`POST /students/:id/reset-password` 已删除(调用返回 404,e2e 有负断言)、新建学生不设密码
|
||||||
|
(`User.passwordHash` 空串占位,`STUDENT` 的 403 登录判定在密码校验之前)、
|
||||||
|
`DEFAULT_STUDENT_PASSWORD` 已删;客户端设置页的「修改密码」按钮**是按用户要求主动移除的**
|
||||||
|
(防教室机器误改班级密码)——别当成"丢入口"的回归补回来。
|
||||||
|
32. **灵动岛卡片的投影(CSS `filter`)必须常驻,形态切换只能改参数。**
|
||||||
|
不要把 `filter: drop-shadow(...)` 只挂在 `.island-card.expanded` 上:**给元素增删 CSS filter**
|
||||||
|
会让 Chromium 新建/销毁它的渲染表面(effect node),首帧的合成可能早于新表面栅格化完成,
|
||||||
|
那一帧被当成空内容画出去 —— 而**卡片的底色恰好画在这个被过滤的元素上**(`.shape path` 的 `fill`),
|
||||||
|
于是开/合的那一瞬间底座会闪掉一帧、只剩未过滤的文字层(用户反馈的"开合时一瞬间的闪动")。
|
||||||
|
正确写法见 `IslandApp.vue` 的 `.shape path`:filter 常驻,胶囊态用 `0 0 0` 的退化投影
|
||||||
|
(硬轮廓正好压在路径底下,肉眼等同无投影,与 WinIsland「只有展开态有投影」的观感一致)。
|
||||||
|
回归用例:`verify:desktop` / `verify:packaged` 的「灵动岛任意形态的投影都常驻」。
|
||||||
|
|
||||||
|
33. **多条通知时展开是「竖排列表」,卡片高度由主进程与渲染进程**各算一次同一个数**。**
|
||||||
|
`@classhelper/shared` 的 `islandListLayout()`(度量表 `ISLAND_LIST_METRICS`)是唯一算法:
|
||||||
|
默认只显示 `defaultVisibleRows`(3)条 + `展开更多(还有 N 条)`,按重要程度排序
|
||||||
|
(`PRIORITY_RANK`,同级按时间新的在前);点"展开更多"后铺到 `maxCardHeight` 为止,
|
||||||
|
仍然放不下的改成「更多请前往应用内操作」——`maxCardHeight` 是主进程按工作区/任务栏算出的可用高度,
|
||||||
|
随状态下发。两条硬约束:
|
||||||
|
- **窗口包围盒必须跟着列表长高**(`IslandApp.vue` 的列表比任何普通形态都高,窗口不够高会把底部按钮裁掉);
|
||||||
|
长高发生在卡片形变**之前**(`setState` 里先 `relayout()` 再 `emit()`),用户看不到这一步;
|
||||||
|
- 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量**一一对应**,
|
||||||
|
改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。
|
||||||
|
回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。
|
||||||
|
写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**,
|
||||||
|
否则量到的是列表高度。
|
||||||
|
|
||||||
|
34. **"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。**
|
||||||
|
收起一律是**主进程单向**的:`handleAction()` 收到 `dismiss` / `dismiss-all` / `mark-read` /
|
||||||
|
`mark-all-read` → `dismissActive()`(还有下一条则退胶囊)或 `clearPendingForClose()`
|
||||||
|
→ `hide()` → `fadeOut()`(每帧 `setOpacity(-0.2)`,到 0.02 后 `win.hide()`)。
|
||||||
|
状态在**同一帧就写成** `hidden` / 清空,**不等渲染进程任何回执** —— 调用方与冒烟按"同步语义"断言
|
||||||
|
(`island.ts` 的 `clearPendingForClose()` 注释:「状态同步落地(渲染进程随之收起卡片,窗口由 `hide()` 淡出)」)。
|
||||||
|
渲染进程那侧只有本地弹簧形变(`spring.ts` 的 `targetSize/progress`),是纯视觉动画,不参与淡出时机。
|
||||||
|
|
||||||
|
**曾经有过一层自研的"收回快照"动画,已按用户要求整体删除。** 它叫 `closing` / `closingSnapshot`:
|
||||||
|
点"知道了"后把卡片按"胶囊摘要"收回再淡出,是自研的形变状态机,**实测在触摸屏上会露出旧帧/重影**
|
||||||
|
(触摸模式窗口贴合岛体、卡片形变时窗口跟着动),用户明确要求"动画就用仓库里那套",于是整段删掉 ——
|
||||||
|
现状见 `IslandApp.vue` 顶部注释。**别照着一份不存在的握手机制去改代码。**
|
||||||
|
(`smoke.ts` 里"收回动画是渲染进程收敛上报 → 主进程淡出"只是一句过时注释,代码里没有这条链路。)
|
||||||
|
|
||||||
|
多条通知时这两个按钮都是**整批**操作:`dismiss-all` / `mark-all-read`(作业卡的合成 id
|
||||||
|
`homework-<id>` 要在主进程按 kind 过滤掉,否则通知中心会去查一条不存在的通知 ——
|
||||||
|
见 `handleAction()` 里 `mark-all-read` 分支的 `.filter((item) => item.kind !== 'homework')`)。
|
||||||
|
回归用例:`verify:desktop` 的「多条通知点「知道了」:整批关闭(状态同步清空,窗口淡出隐藏)」。
|
||||||
|
|
||||||
|
排查"收回时闪一下"这类反馈时注意:**先看是不是自动化验证实例的叠影**(见第 41、42 条),
|
||||||
|
别往这套已经不存在的握手机制上找原因。
|
||||||
|
|
||||||
|
35. **本机录入的作业不再上灵动岛。** 服务端是**先广播 `homework:new`、后回响应**的,
|
||||||
|
等响应拿到 id 再登记就晚了(事件可能已经到了、岛已经弹出来了)——所以
|
||||||
|
`renderer/island/bridge.ts` 在**发请求之前**按内容登记指纹(班级 + 日期 + 标题 + 正文),
|
||||||
|
回执拿到 id 后再补记一次,投递前用 id / 指纹双重判定。改作业录入入口时别忘了在请求前调
|
||||||
|
`markHomeworkCreatedLocally()`。回归用例:「本机录入的作业不再上灵动岛」。
|
||||||
|
|
||||||
|
36. **触摸模式下收回动画期间,窗口必须一动不动**(`syncTouchLayout()` 开头 `if (this.closing) return;`)。
|
||||||
|
触摸模式窗口是"贴合岛体"的,而收回时卡片正在缩小:若跟着缩窗口,**中心停靠下窗口 x 还要右移**,
|
||||||
|
"让出去"的那块区域会留着上一帧(DWM 还没重画桌面)——同一帧里能看到"旧展开卡的左半截 + 新胶囊"
|
||||||
|
两份画面(用户拍照反馈的"收回一瞬间闪一下")。窗口尺寸一律等**隐藏之后**再对齐
|
||||||
|
(`syncWindow` 里"不可见时对齐包围盒"那条,不可见时改尺寸不会有残影)。
|
||||||
|
回归用例:「触摸模式收回:动画期间窗口不动」逐帧采样窗口矩形(只在"窗口可见 / 正在收回"时追究,
|
||||||
|
收完隐藏后对齐尺寸是正常的);「收回动画期间卡片不被窗口裁切」。
|
||||||
|
另:`cardFitsFormSize()` 的判据里**不能**再塞 `closing` —— 那条早退已经把收回整个排除了,
|
||||||
|
早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断");
|
||||||
|
而"隐藏态改边距不生效"那条修法仍要保留:`this.win.isVisible() && (mode !== 'hidden' || idleSliver)`。
|
||||||
|
|
||||||
|
37. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动;
|
||||||
|
胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
|
||||||
|
(用户反馈的"新消息那行字往右跳一下再缩回")。所以 `IslandApp.vue` 的胶囊层里套了一层
|
||||||
|
`.pill-inner`,**固定成胶囊自己的几何**(`width: var(--island-w)`),并按停靠方式对齐
|
||||||
|
(`data-anchor` = left/center/right → `justify-content` flex-start/center/flex-end):
|
||||||
|
左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。
|
||||||
|
回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。
|
||||||
|
|
||||||
|
38. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
|
||||||
|
`verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端
|
||||||
|
默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为
|
||||||
|
**"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。
|
||||||
|
实测踩过:用户连续两条反馈("收回去的时候闪一下/下半被截断")其实都是这个叠影造成的。
|
||||||
|
所以 `runIslandChecks` 开头把验证实例的岛做成**一眼可辨**:
|
||||||
|
`island.setAppearance({ accent: '#e91e8c', style: 'tinted' })`(洋红 = 验证实例;跑完不必还原,
|
||||||
|
验证用的是独立 profile),并且全程 `island.setTestInputPassthrough(true)` 让真实点击穿透。
|
||||||
|
**只改颜色、不要改停靠位置**:命中/几何断言都是按默认位置写的,把岛挪到角落会把
|
||||||
|
「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。
|
||||||
|
另外:冒烟会**真发通知**给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/
|
||||||
|
岛上会短暂出现几条「张老师 · _自检_」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`),
|
||||||
|
若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。
|
||||||
|
|
||||||
|
39. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟,
|
||||||
|
而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着,
|
||||||
|
他点到的是**冒烟这个窗口**:实测冒烟收到了人发出的 `collapse` / `mark-read`,把「点胶囊展开」
|
||||||
|
「点空白处收起」等用例整片弄红。开关只忽略**真实**输入:`cursorOverride`(`setHitTestCursor`)
|
||||||
|
一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」
|
||||||
|
这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。
|
||||||
|
|
||||||
|
40. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读
|
||||||
|
一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件
|
||||||
|
无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watch `route.query.date`
|
||||||
|
(`HomeworkView.vue`,与 `selectDate` 的 `router.replace` 形成环时靠"值相同就跳过"防死循环)。
|
||||||
|
同理,冒烟里"切到有作业的日期"的判据是**看板里没有 `.board-card`**,不是"没有 `.board-host`"
|
||||||
|
(空看板照样渲染容器,拿容器当判据兜底永远不会触发)。
|
||||||
|
|
||||||
|
41. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true`
|
||||||
|
(上课时段普通通知会被**正确地**暂存到下课、紧急通知不带确认会被 409 `URGENT_DURING_CLASS` 拒掉 ——
|
||||||
|
两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律**轮询**等待,
|
||||||
|
不要写死 sleep:`dismiss` 现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空
|
||||||
|
(「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。
|
||||||
|
|
||||||
|
42. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境):
|
||||||
|
① **后端起没起** —— 客户端配置指向 `127.0.0.1:4000`,后端不在就进离线模式、数据全空,
|
||||||
|
观感就是"软件坏了";② **托盘单实例锁** —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被
|
||||||
|
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
|
||||||
|
完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。
|
||||||
|
|
||||||
|
43. **Web 端新手引导(`el-tour`)在自动化里的判据有三条坑**(2026-10-02 实测,`verify:web`
|
||||||
|
「首次登录展示新手引导且可跳过」踩过):
|
||||||
|
① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立,
|
||||||
|
急着找 `.el-tour__closebtn` 会拿到 null;出现判据要等 **× 按钮渲染出来**(等 10s 不为过)。
|
||||||
|
② **关闭后内容元素残留** —— 和 el-dialog 一样(本表第 22 条),点 × 生效后 `.el-tour` 仍在 DOM
|
||||||
|
(父级 `.el-popper` 被隐藏,computed display 仍是 block);**关闭判据是 `.el-tour__mask` 卸载**,
|
||||||
|
不是 `.el-tour` 消失。跳过 / 走完都会写 `classhelper.onboarding`(版本号),可顺带断言。
|
||||||
|
③ **全高元素(侧边栏)作锚点必须显式 `placement: 'right'`** —— 默认 bottom 会把卡片推到
|
||||||
|
视口外(表现为"遮罩挖了洞、卡片不见了")。
|
||||||
|
另外 `ui-smoke` 管理员用例里的 `localStorage.clear()` 会把「已看过」标记一并清掉,管理员登录后
|
||||||
|
引导会再次弹出 —— 该用例里必须先跳过它再继续,别只处理首次登录那一次。
|
||||||
|
|
||||||
|
44. **数据库管理模块(服务端)** 见 §5 第 36 条;做"切库/备份/迁移"类改动前先读那一条,
|
||||||
|
那里写清了快照格式、外键陷阱、切换为什么必须走子进程、以及打包为何要带上 prisma CLI + tsc。
|
||||||
|
|
||||||
|
45. **客户端设置页已拆成子路由**(2026-10-02):`/settings/{general,appearance,island,reminder,account,about}`,
|
||||||
|
`/settings` 重定向到 `/settings/general`;旧的单一 `views/SettingsView.vue` **已删除**。
|
||||||
|
改设置相关冒烟用例时必须改到对应子路由(`smoke.ts` 里 `location.hash = '#/settings'` 已全部改掉),
|
||||||
|
否则会停在通用页、找不到灵动岛滑块或「ClassIsland 联动」卡片而误报失败。
|
||||||
|
|
||||||
|
46. **深色主题的实现边界**:颜色一律走 `--ch-*` / `--el-*` 变量(`styles/index.css` 的 `:root`
|
||||||
|
是浅色值、`html.dark` 是深色值),**组件里不要写死颜色**。已知的固定深色区域(灵动岛、
|
||||||
|
登录页、App 启动过渡页、全屏作业看板)刻意不随主题 —— 改它们会被当成回归。
|
||||||
|
主题状态在 `stores/ui.ts`(持久化到主进程配置 `theme`),主窗口 `backgroundColor` 也按主题设置
|
||||||
|
(深色下若仍用浅色底会"先白后黑"闪一下)。
|
||||||
|
|
||||||
|
47. **数据库管理模块(`/api/database/*`,仅 ADMIN)——改它之前必须知道的四件事**:
|
||||||
|
① **一切都建立在 `lib/snapshot.ts` 的 JSON 快照上**(备份 / 导入导出 / 跨库迁移共用一种格式,
|
||||||
|
14 张表按拓扑序导出、恢复时临时关外键检查、分批 `createMany`)。恢复时**必须**先关外键检查:
|
||||||
|
schema 里 `User.classId → Class` 与 `Class.teacherId → User` **互相引用**,任何排序都会被一侧卡死。
|
||||||
|
② **一键切换必须走子进程**:运行中的服务进程缓存的是旧方言的 Prisma Client,改完 provider 后
|
||||||
|
只有**新进程**才能加载重新生成的客户端 —— 因此 `generate` / `db push` / 写数据分别用
|
||||||
|
`spawn(process.execPath, ...)` 拉起(数据写入走 `dist/tools/apply-snapshot.js`)。
|
||||||
|
**顺序不能换**:备份 → 导出 → 测试连接 → 校验目标库为空 → 改 schema → generate → 编译 → db push → 写数据 → 改 .env;
|
||||||
|
`.env` 只在**全部成功后**才改写,中途失败回滚 `schema.prisma`(现有库不受影响)。
|
||||||
|
③ **`prisma generate` 的产物是 `.ts`**(`prisma-client` 生成器),所以:dev 由 tsx 直接跑 src;
|
||||||
|
**打包形态必须再跑一次 `tsc -p tsconfig.generate.json`** 把新客户端编进 `dist/generated`,
|
||||||
|
生产包因此要随包带上 `prisma` CLI、`typescript` 与 `schema.prisma`/`prisma.config.ts`
|
||||||
|
(`scripts/dist-server.mjs` 的 `buildRuntime` 已拷;安装包 ~34MB → ~71MB)。
|
||||||
|
④ **主库只支持 SQLite / MySQL**:Redis 等键值库 Prisma 不支持(无法建表/迁移),
|
||||||
|
schema 层直接 422 拒绝;这是产品口径,别"顺手"加上。
|
||||||
|
e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空),**不真跑切换**(会改写 .env);
|
||||||
|
真实切换用子进程链路单独实测(快照 728 行逐表一致)。切换后**必须重启服务端**才生效。
|
||||||
|
|
||||||
|
48. **客户端主窗口初始尺寸与 ClassIsland 对齐(1242×582)**。这个数不是拍的:用
|
||||||
|
`GetWindowRect` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` 量运行中的
|
||||||
|
`ClassIsland.Desktop` 主窗口(实测可视区 1242×582,屏幕工作区 1600×852、DPI 100%)。
|
||||||
|
`main/index.ts` 的 `createWindow` 按它设 width/height + `center: true`,
|
||||||
|
**`minHeight` 必须 ≤ 582**(否则窗口达不到目标高度,用户会看到"设置不了那个大小")。
|
||||||
|
冒烟断言:「初始窗口尺寸与 ClassIsland 对齐(1242x582)」(`win.getSize()`),改尺寸即红。
|
||||||
|
|
||||||
|
49. **侧栏(分组折叠菜单)的三条硬要求**,都是用户报过的问题固化的:
|
||||||
|
① **折叠态文字必须 `display: none`** —— Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 +
|
||||||
|
overflow",外层只要有缩进/margin/transition 干扰,文字就会以"0 宽溢出"留在画面上
|
||||||
|
(用户截图里的半截字)。规则写在 `styles/index.css` 的 `.ch-nav .el-menu--collapse ... > span:not(.menu-icon-slot)`。
|
||||||
|
② **折叠态图标必须与顶部汉堡按钮同一竖列**(冒烟断言中心差 ≤ 2px)—— 一个居中、一个偏左就会
|
||||||
|
看起来"没对齐"。汉堡在 `.brand` 里也是居中的,两边都在 64/2 = 32px。
|
||||||
|
③ **菜单区必须 `min-height: 0` + `overflow-y: auto`** —— 否则菜单项多时会把底部
|
||||||
|
「第 N 周 / 最近同步」挤出窗口(窗口 582px 高、两组全展开时必然发生)。footer 是 aside 的
|
||||||
|
兄弟 flex 子项,菜单区滚动后它恒定可见(冒烟断言「侧栏底部可见」)。
|
||||||
|
另:分组**默认只展开当前路由所在分组**(ClassIsland 同款,也让小窗口侧栏不溢出);
|
||||||
|
`el-sub-menu__title` 的点击是 **toggle**,自动化里要先判"子项是否可见"再决定点不点标题
|
||||||
|
(盲点会把已展开的组收起来,后续点不到子项)。
|
||||||
|
|
||||||
|
50. **深色主题:渲染进程里除固定深色区域外,颜色一律走 `--ch-*` 变量。**
|
||||||
|
§5 第 46 条讲了机制,这里补具体踩坑:课表页时间轴卡片曾写死 `rgba(255, 255, 255, 0.6)`,
|
||||||
|
深色主题下变成"浅灰卡 + 近白字"几乎不可读(用户截图)。自查命令:
|
||||||
|
`grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer`,
|
||||||
|
除**明确固定深色**的区域(全屏作业看板、登录页渐变、App 启动过渡页、灵动岛自身、
|
||||||
|
以及 accent 渐变底上的白字)外,都应该是变量。
|
||||||
|
|
||||||
|
51. **跑 `verify:desktop` / `verify:packaged` 若大面积报"教师登录失败",先核对本机 teacher1 的密码**:
|
||||||
|
本机实测是 **`123456`**,不是种子默认的 `teacher123` —— 因为「修改密码」弹窗留空会落到
|
||||||
|
`DEFAULT_TEACHER_PASSWORD`(=123456),某次人工/脚本操作就会把演示账号改成它。
|
||||||
|
处理方式是用环境变量传实际凭据(脚本原生支持,**不要去改数据**):
|
||||||
|
`set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 6. 代码风格
|
## 6. 代码风格
|
||||||
|
|
||||||
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
||||||
(写 `./foo.js`,即使源文件是 `foo.ts`)。`strict` 开启。
|
(写 `./foo.js`,即使源文件是 `foo.ts`)。`strict` 开启。
|
||||||
- **Prettier**:单引号、`printWidth: 110`、`trailingComma: all`、`arrowParens: always`、行尾 LF、缩进 2 空格。
|
- **Prettier**(`.prettierrc.json`):单引号、`printWidth: 110`、`trailingComma: all`、`arrowParens: always`、
|
||||||
|
行尾 LF、缩进 2 空格。
|
||||||
- **ESLint**(`eslint.config.mjs`):`no-explicit-any` 为 warn;未使用变量为 warn,可用 `_` 前缀豁免;
|
- **ESLint**(`eslint.config.mjs`):`no-explicit-any` 为 warn;未使用变量为 warn,可用 `_` 前缀豁免;
|
||||||
`consistent-type-imports` 建议用 `import type`。`.cjs`(Electron 图标渲染、UI 冒烟)允许 `require`。
|
`consistent-type-imports` 建议用 `import type`。`.cjs`(Electron 图标渲染、UI 冒烟)允许 `require`。
|
||||||
- **Vue**:视图命名 `XxxView.vue`,组件多单词;路由入口用 `meta.roles` 控制(如 `['ADMIN']`),
|
- **Vue**:视图命名 `XxxView.vue`,组件多单词;路由入口用 `meta.roles` 控制(如 `['ADMIN']`),
|
||||||
@@ -309,77 +592,155 @@ pnpm dist:classisland-plugin
|
|||||||
教室机器上表现为"填好令牌后立刻 HTTP 404"(其实是代理的错误页)。插件里
|
教室机器上表现为"填好令牌后立刻 HTTP 404"(其实是代理的错误页)。插件里
|
||||||
`ClassHelperClient` 用 `LocalAwareProxy` 对本地/内网地址强制直连,
|
`ClassHelperClient` 用 `LocalAwareProxy` 对本地/内网地址强制直连,
|
||||||
`verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
|
`verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
|
||||||
2. **`pnpm` 由 Electron 宿主承载**(`pnpm config list` 里 `userAgent` 是 `node/v24.19.0`,而 `node -v` 是 v22.15.0)。
|
2. **`db:deploy` 只在 `packages/server` 包里,根 `package.json` 没有这个脚本。**
|
||||||
个别情况下依赖的 `.bin` 不会进子进程 PATH,`pnpm run <script>` 报「不是内部或外部命令」。
|
根上跑 `pnpm db:deploy` 会报 `Command "db:deploy" not found`(还会附带一句莫名其妙的
|
||||||
规避:直接用 Node 调入口,例如 `node packages/server/node_modules/prisma/build/index.js generate`
|
`文件名、目录名或卷标语法不正确。`)。正确写法:`pnpm --filter @classhelper/server db:deploy`。
|
||||||
(`prisma.config.ts` 里的 seed 命令已按这个思路写成 `node ./node_modules/tsx/dist/cli.mjs`)。
|
根上只有 `db:generate / db:migrate / db:seed / db:reset / db:studio`。
|
||||||
3. **启动 Electron 的脚本必须清掉 `ELECTRON_RUN_AS_NODE`**,且不要 `import 'electron'` 取可执行文件路径。
|
3. **`pnpm db:migrate`(= `prisma migrate dev`)在本机不可靠**:首次报 `Schema engine error:`(空消息),
|
||||||
`scripts/lib/electron-env.mjs`、`scripts/lib/node-runtime.mjs` 已封装,新脚本请复用。
|
数据库建好后再跑会**长时间挂起**(无输出,需手动结束进程)。日常用 `db:deploy`;
|
||||||
4. **Electron 二进制要手动装**(见 §3),否则 `pnpm dev:desktop` / `verify:desktop` / `dist:win` 全部失败。
|
要写种子直接 `pnpm db:seed`,或 `node ./node_modules/tsx/dist/cli.mjs prisma/seed.ts`(cwd 为 `packages/server`)。
|
||||||
5. **依赖二进制下载需要 `NODE_OPTIONS=--use-system-ca`**(本机 TLS 中间人证书),否则报
|
4. **先 `pnpm build:shared`,否则后面全崩**:`packages/shared/dist` 不存在时,`pnpm db:seed` 与任何
|
||||||
`unable to verify the first certificate`。
|
消费 `@classhelper/shared` 的进程都会 `ERR_MODULE_NOT_FOUND`(报错指向
|
||||||
6. **`pnpm db:migrate`(= `prisma migrate dev`)在本机不可靠**:首次报 `Schema engine error:`(空消息),
|
`packages/server/node_modules/@classhelper/shared/dist/index.js`)。`pnpm dev*` / `build` / `typecheck`
|
||||||
数据库建好后再跑会**长时间挂起**(无输出,需手动结束进程)。
|
脚本已内联 `build:shared`,单独跑 `db:seed` 时记得自己先构建。
|
||||||
日常用 **`pnpm db:deploy`**;要写种子直接 `node ./node_modules/tsx/dist/cli.mjs prisma/seed.ts`(cwd 为 `packages/server`)。
|
5. **Electron 二进制要手动装**:`pnpm install` 实测**不会**下载它(`allowBuilds` 里虽然写了 `electron: true`,
|
||||||
7. **后端启动时才发现 Web 产物**:`packages/web-admin/dist` 不存在就跳过静态托管,`http://127.0.0.1:4000/`
|
但 postinstall 没跑;日志里只看到 esbuild / prisma)。缺失时 `pnpm dev:desktop` / `verify:desktop` /
|
||||||
只会返回服务首页(文案「Web 管理端未随本服务托管」)。要跑 `verify:web`,先 `pnpm build` 再重启后端。
|
`verify:web` / `dist:win` 全部失败。装法(本机已装好):
|
||||||
8. **原生模块按 Electron ABI 构建**:本项目因此用 libSQL 适配器(`@libsql/win32-x64-msvc` 预编译),
|
|
||||||
不要为了图快换回 `better-sqlite3` 之类需要 node-gyp 的方案。
|
```bash
|
||||||
9. **Prisma 版本锁死 7.10.0**:npm `latest` 已指向 8.x-rc;生成器是 `prisma-client`,输出到
|
cd node_modules/.pnpm/electron@44.3.0/node_modules/electron
|
||||||
`src/generated/prisma`(gitignored),连接串在 `prisma.config.ts`,并且**必须走 driver adapter**。
|
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ NODE_OPTIONS=--use-system-ca node install.js
|
||||||
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 临时文件;
|
6. **"AI 会话里启动 Electron GUI 会崩"是一个误诊 —— 真凶是下一条的 Low 完整性标签。**
|
||||||
本机 `C:` 只剩约 100 MB 时会失败(免安装目录其实已经生成好了,只是最后一步打不出安装包)。
|
2026-09-30 实测:把 Electron 发行目录的标签从 Low 改成 Medium 后,同一个 `electron.exe`
|
||||||
规避:把临时目录指到空闲的盘再重试,并加 `--reuse-deps` 跳过依赖安装:
|
在 AI 会话里**正常启动**(窗口标题 `Electron`、userData 目录正常创建),
|
||||||
`$env:TEMP='F:\ClassHelper\.cache\tmp'; $env:TMP=$env:TEMP; node scripts/dist-server.mjs --reuse-deps`。
|
`pnpm verify:packaged` 也能完整跑完并出结果(86/90、77/78)。
|
||||||
12. **仓库根 `.gitignore` 是 GBK 编码**(含一段乱码注释),`apply_patch` 与 Prettier 都读不了它。
|
⇒ `verify:desktop` / `verify:web` / `verify:packaged` / `dist:win` 在 AI 会话里**都能跑**;
|
||||||
要补忽略规则请写 `.git/info/exclude`(本地生效、不入库),别试图整文件重写。
|
一旦它们又"启动即崩",先按第 7 条查完整性标签,别急着甩锅给"会话环境"。
|
||||||
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore,用来忽略 bin/obj/cipx。)
|
7. **交付物被打了 Low 完整性标签 ⇒ 客户端装不上、便捷版双击没反应、应用 0x80000003 秒崩。**
|
||||||
13. **`dotnet` 会把 NuGet 缓存与临时文件写进 C 盘**(`%USERPROFILE%\.nuget`、`%TEMP%`),
|
症状极易误判:`electron.exe --version` 都不打印就退出(0x80000003 STATUS_BREAKPOINT),
|
||||||
C 盘紧张时直接构建失败。`pnpm build:classisland-plugin` 已把 `NUGET_PACKAGES/TEMP/TMP/DOTNET_CLI_HOME`
|
加 `--no-sandbox` 变成 0xC0000005,任何日志都写不出来(崩在日志初始化之前),
|
||||||
指到仓库内 `.cache/`;手敲 `dotnet build` 时请自己带上这几个变量。
|
但 `ELECTRON_RUN_AS_NODE=1` 跑同一个 exe 完全正常、Edge 等系统 Chromium 也正常。
|
||||||
14. **`.cipx` 打包最后一步要 PowerShell**:SDK 的 `CreateCipx` 目标默认调 `pwsh`,
|
根因:**本仓库目录树被宿主沙箱打上了 `Mandatory Label\Low Mandatory Level:(OI)(CI)`(可继承)**,
|
||||||
|
于是仓库内构建出的一切都继承 Low 标签。后果是双重的:
|
||||||
|
- Windows 会把带 Low 标签的 exe **降级到 Low 完整性运行**:`setup.exe` 写不进 `%LOCALAPPDATA%`
|
||||||
|
(报"拒绝访问"→ 用户看到"装不上")、便捷版解不出 `%TEMP%`(→ 双击没反应);
|
||||||
|
- 即便装上了,落在 Low 标签目录里的应用文件也**读不了** —— Chromium 的 GPU/渲染子进程跑在
|
||||||
|
AppContainer(更低完整性)里,按"不能向上读"直接秒崩。
|
||||||
|
|
||||||
|
判据与修法(都是实测过的):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签
|
||||||
|
icacls packages\desktop-client\release\win-unpacked\班级小助手.exe | findstr Mandatory
|
||||||
|
# 修法:把交付物改成 Medium。dist-win.mjs 收尾会自动做,手工补救用:
|
||||||
|
icacls packages\desktop-client\release /setintegritylevel Medium /T /C
|
||||||
|
# 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去
|
||||||
|
icacls packages\desktop-client\release /setintegritylevel "(OI)(CI)Low"
|
||||||
|
```
|
||||||
|
|
||||||
|
连带的三条经验:**归档内部的载荷标签无所谓**(装到仓库外得到的是默认 Medium 文件),
|
||||||
|
所以修标签必须在**打包完成之后**做,打包前改 appOutDir 反而会让 7za 读不到而失败;
|
||||||
|
**别把安装测试装进仓库树**(`install-test` 之类),那里的文件又会继承 Low;
|
||||||
|
**覆盖已存在的产物会被沙箱 ACL 拒绝**(`Can't open output file` / `拒绝访问`),
|
||||||
|
`dist-win.mjs` 因此会先清掉上一轮的 `*.exe / *.7z / *.blockmap` 再打包。
|
||||||
|
|
||||||
|
8. **.NET 8 SDK 已装(8.0.425)**,`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin` 都能跑
|
||||||
|
(2026-09-30 实测:`-t:Rebuild` 全量编译 8.8s,**1 个警告 0 个错误**;`.cipx` 打包成功,
|
||||||
|
包内 DLL 的 sha256 与 `bin/Release` 产物一致)。脚本会自动把
|
||||||
|
`NUGET_PACKAGES / TEMP / TMP / DOTNET_CLI_HOME` 指到仓库内 `.cache/`(`dotnet` 默认往 C 盘写,
|
||||||
|
C 盘紧张时会构建失败);手敲 `dotnet build` 时请自己带上这几个变量。
|
||||||
|
|
||||||
|
两个容易自我欺骗的地方:
|
||||||
|
- **光看输出会误判"编译通过"**:源码没变时 `dotnet build` 是**空转**(打印
|
||||||
|
`所有项目均是最新的,无法还原` + `已成功生成 / 0 警告 0 错误`),`CoreCompile` 根本没执行 ——
|
||||||
|
那行 `ClassHelper.ClassIslandPlugin -> …dll` 是拷贝回执,不是编译证据。
|
||||||
|
要证明"真的编译过",用 `node packages/classisland-plugin/scripts/build.mjs -t:Rebuild -v:n`
|
||||||
|
看有没有 `csc.dll` 调用,并核对 DLL 的 mtime 晚于所有 `.cs`。
|
||||||
|
- **那 1 个警告是预期的,别去"修"**:`AVLN3001: XAML resource
|
||||||
|
"…/BridgeSettingsPage.axaml" won't be reachable via runtime loader, as no public constructor was found`
|
||||||
|
—— 设置页只有 `BridgeSettingsPage(PluginSettings, BridgeService)` 这个 DI 构造函数、
|
||||||
|
没有公开无参构造,Avalonia 于是警告它无法走**运行时**加载器;本插件走的是编译期 XAML +
|
||||||
|
ClassIsland 的 DI 实例化,加个无参构造反而会把 DI 弄坏。
|
||||||
|
|
||||||
|
9. **`.cipx` 打包最后一步要 PowerShell**:SDK 的 `CreateCipx` 目标默认调 `pwsh`,
|
||||||
本机 PATH 里没有 pwsh 时会失败。构建脚本会探测并把结果透传给 MSBuild
|
本机 PATH 里没有 pwsh 时会失败。构建脚本会探测并把结果透传给 MSBuild
|
||||||
(`-p:PowershellBinaryName=…`),直接跑 `dotnet build -p:CreateCipx=true` 则要自己加。
|
(`-p:PowershellBinaryName=…`),直接跑 `dotnet build -p:CreateCipx=true` 则要自己加。
|
||||||
15. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
|
10. **仓库根 `.gitignore` 的第 42 行是 GBK 编码**(整文件 CRLF,只有那一行是 GBK 注释),
|
||||||
|
`apply_patch` 与 Prettier 都读不了它,按 UTF-8 读会看到 `\uFFFD`。要补忽略规则**只能用追加的方式**
|
||||||
|
(`printf 'xxx\n' >> .gitignore`,原有字节一个都别动),别试图整文件重写;临时性的可写
|
||||||
|
`.git/info/exclude`(本地生效、不入库)。
|
||||||
|
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore。)
|
||||||
|
11. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
|
||||||
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
|
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
|
||||||
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉)。
|
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉;`release-server/` 同理)。
|
||||||
|
**它一度只在 ESLint 里被忽略、`.gitignore` 里漏了**(后者只有单数的 `release/` 和 `release-server/`):
|
||||||
|
归集一次客户端产物就有 225MB 的 EXE 变成未跟踪文件,`git add -A` 会直接把安装包提交入库。
|
||||||
|
2026-09-30 已在 `.gitignore` 末尾补上 `releases/`。
|
||||||
|
12. **ClassIsland 的数据根是 `D:\Classisland\data`**(**不是**旧文档写的 `F:\data`,F 盘已不存在):
|
||||||
|
- 插件 DLL 覆盖到 `D:\Classisland\data\Plugins\classhelper.classisland.bridge\`(**ClassIsland 运行时会锁住
|
||||||
|
DLL,必须先退出它再覆盖**:`Get-Process ClassIsland.Desktop | Stop-Process -Force`);
|
||||||
|
- 插件配置在 `D:\Classisland\data\Config\Plugins\<id>\Settings.json`,**ClassIsland 运行中改会被内存里的
|
||||||
|
副本随时回写覆盖**,要改请趁它退出时改;该文件里存着这台机器的服务器地址与设备令牌,**别覆盖**;
|
||||||
|
- 改完 DLL 或配置都要重启 ClassIsland,否则跑的还是旧副本。
|
||||||
|
13. **读 ClassIsland 控制台日志要用 GBK**:`.NET` 的控制台输出是**系统 ANSI(GBK)**编码,
|
||||||
|
用 `-Encoding UTF8` 读会变成乱码、**中文关键字一条都搜不到**(只能搜到 ASCII 的类名)。
|
||||||
|
正确姿势:`[System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::GetEncoding(936))`;
|
||||||
|
日志文件被运行中的进程占用时先 `Copy-Item` 一份再读。
|
||||||
|
14. **服务端改了代码要重启才生效**(`tsx src/index.ts` 不热载,`pnpm dev:server` 才有 watch)。
|
||||||
|
验证脚本报「某个断言突然不对」时,先确认后端跑的是不是最新代码 —— 这个坑踩过一次。
|
||||||
|
15. **后端启动时才发现 Web 产物**:`packages/web-admin/dist` 不存在就跳过静态托管,`http://127.0.0.1:4000/`
|
||||||
|
只会返回服务首页(文案「Web 管理端未随本服务托管」)。要跑 `verify:web`,先 `pnpm build` 再重启后端。
|
||||||
|
16. **打包版服务端有生产自检**:`NODE_ENV=production` 时拒绝 `dev.db`("生产环境仍在使用 dev.db")、
|
||||||
|
拒绝短 `JWT_SECRET` 与示例密钥;想用演示数据跑打包版就把库**复制**成 `data/classhelper.db` 再启动。
|
||||||
|
17. **makensis 建不了临时文件时,NSIS 那一步会失败(`dist:server` 与 `dist:win` 都会踩)。** 两种根因、
|
||||||
|
同一个排查动作 —— 先把 `TEMP` 指到仓库内一个**真实存在的 Windows 绝对路径**再重试:
|
||||||
|
- `pnpm dist:server` 报 `Internal compiler error #12345: error creating mmap the size of N`:模板用了
|
||||||
|
`SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件,盘紧就失败
|
||||||
|
(免安装目录其实已生成好,只是最后一步打不出安装包)。加 `--reuse-deps` 还能跳过依赖安装。
|
||||||
|
- `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**,
|
||||||
|
而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成
|
||||||
|
`D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。
|
||||||
|
`win-unpacked` 与 `release/*.nsis.7z` 此时都已生成,**只需重跑 electron-builder,不必重建前端**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .cache/tmp
|
||||||
|
TEMP='D:\classhelper\.cache\tmp' TMP='D:\classhelper\.cache\tmp' \
|
||||||
|
node packages/desktop-client/scripts/dist-win.mjs
|
||||||
|
```
|
||||||
|
18. **依赖二进制下载需要 `NODE_OPTIONS=--use-system-ca`**(本机 TLS 中间人证书),否则报
|
||||||
|
`unable to verify the first certificate`。
|
||||||
|
19. **Prisma 版本锁死 7.10.0**:npm `latest` 已指向 8.x;生成器是 `prisma-client`,输出到
|
||||||
|
`src/generated/prisma`(gitignored),连接串在 `prisma.config.ts`,并且**必须走 driver adapter**
|
||||||
|
(`@prisma/adapter-libsql` + 预编译的 `@libsql/win32-x64-msvc`,避开 electron ABI 的原生编译坑)。
|
||||||
|
20. **服务端安装包里的 `.cmd` 提示是英文**:cmd.exe 按 GBK 解析脚本,中文会乱码,属有意为之
|
||||||
|
(中文在安装向导与 `README.txt`)。
|
||||||
|
21. **`pnpm format:check` 在 master 基线上本来就是失败的**(含 `tsconfig.base.json` 与 `scripts/*`),
|
||||||
|
不要拿它当门禁、也不要为了让它变绿去批量格式化。只检查你改过的文件:
|
||||||
|
`pnpm exec prettier --check <file>`。
|
||||||
|
**注意它会因为 CRLF 而"全面误报"**:`core.autocrlf=true` 让 git 里存 LF、工作区签出成 CRLF,
|
||||||
|
而 Prettier 配的是 `endOfLine: lf`,于是几乎每个文件都被标红。要判断是不是**你的**代码不合风格,
|
||||||
|
用 `sed 's/\r$//' <file> | pnpm exec prettier --check --stdin-filepath <file>`。
|
||||||
|
22. **写冒烟断言时别用"元素还在不在"判断 Element Plus 的弹框关没关。** `el-dialog` 关闭后
|
||||||
|
**DOM 会留在文档里**(Element Plus 只把 `.el-overlay` 置成 `display: none`),
|
||||||
|
`document.querySelector('.el-dialog')` 恒为真 —— `verify:desktop` / `verify:packaged` 里
|
||||||
|
「设置页『修改密码』对话框取消后未关闭」就是这么误报出来的。正确判据:
|
||||||
|
`getComputedStyle(dialog.closest('.el-overlay')).display !== 'none'`。
|
||||||
|
同理,断言岛体尺寸别写死"胶囊尺寸 + 留白":那一刻是胶囊还是卡片取决于前面用例留下的状态,
|
||||||
|
应改成**与形态无关的不变量**(如"窗口 = 卡片实测尺寸 + 2×阴影留白")。
|
||||||
|
|
||||||
### 已知脆弱用例(不是环境问题,别去"修环境")
|
### 已知脆弱用例(不是环境问题,别去"修环境")
|
||||||
|
|
||||||
| 用例 | 触发条件 |
|
| 用例 | 触发条件 |
|
||||||
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `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: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:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
|
||||||
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
|
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
|
||||||
|
| 岛交互 / 岛动画 / 岛截图留档这几个用例 | **AI 会话里会间歇性失败**:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
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. 交付前检查
|
## 8. 交付前检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -387,33 +748,33 @@ pnpm lint # ESLint(0 error 为底线)
|
|||||||
pnpm typecheck # 全仓库类型检查
|
pnpm typecheck # 全仓库类型检查
|
||||||
|
|
||||||
pnpm dev:server # 另开终端
|
pnpm dev:server # 另开终端
|
||||||
pnpm verify:e2e # 后端 163 项
|
pnpm verify:e2e # 后端端到端(2026-09-30 实测 173 项全过)
|
||||||
pnpm verify:web # Web 29 项(其中 1 项是"已知脆弱用例",见上表)
|
pnpm verify:classisland # ClassIsland 联动链路(实测 42 项)
|
||||||
pnpm verify:classisland # ClassIsland 联动链路 24 项
|
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 68 项)
|
||||||
pnpm verify:classisland-plugin # 插件静态契约 54 项(不需要后端)
|
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
|
||||||
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟
|
pnpm build && pnpm verify:web # Web 端真实点击回归(同上)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`、`ClassIsland 联动插件静态校验:N/N 项通过`),
|
||||||
|
文档不要写死容易过期的总数。
|
||||||
|
|
||||||
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
|
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
|
||||||
并**在真机上过一遍**(本机就有 ClassIsland,见 §3):
|
本机已装 ClassIsland 2.1.0.1(`D:\Classisland`)与 .NET 8 SDK(8.0.425),因此**真机验证是可行的**
|
||||||
|
(直接 `pnpm build:classisland-plugin` 编译,再按 §7 第 12 条部署):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
|
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
|
||||||
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
||||||
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll F:\data\Plugins\classhelper.classisland.bridge\ -Force
|
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
|
||||||
Start-Process F:\classisland\ClassIsland.Desktop.exe -WorkingDirectory F:\classisland
|
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
|
||||||
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看启动时控制台/日志里依次出现:
|
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看日志里依次出现:
|
||||||
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
|
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
|
||||||
```
|
```
|
||||||
|
|
||||||
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 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 dist:all`(或 `dist:server` + `dist:win`),
|
||||||
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
||||||
开发产物通过不等于用户机器上的副本通过。
|
开发产物通过不等于用户机器上的副本通过。
|
||||||
|
|
||||||
自测本地接口记得 `-NoProxy`(见 §7 第 1 条)。
|
自测本地接口记得绕过代理(见 §7 第 1 条)。
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
SERVER_PORT=4000
|
SERVER_PORT=4000
|
||||||
|
|
||||||
# 应用版本(镜像标签)
|
# 应用版本(镜像标签)
|
||||||
APP_VERSION=0.1.0
|
APP_VERSION=1.0.0
|
||||||
|
|
||||||
# MySQL 配置
|
# MySQL 配置
|
||||||
MYSQL_ROOT_PASSWORD=请改成强密码_root
|
MYSQL_ROOT_PASSWORD=请改成强密码_root
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 班级小助手 · Docker 单容器(SQLite)环境变量
|
||||||
|
# 复制为 deploy/.env.sqlite 后填写:cp deploy/.env.sqlite.example deploy/.env.sqlite
|
||||||
|
# 配合 deploy/docker-compose.sqlite.yml 使用
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 服务端对外端口(宿主机)。注意与同机其他服务错开:80/443 常被反代占用
|
||||||
|
SERVER_PORT=4000
|
||||||
|
|
||||||
|
# 应用版本(镜像标签)
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
|
||||||
|
# JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词)
|
||||||
|
# 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# 令牌有效期
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# 允许的跨域来源;用域名访问时建议写成具体来源,例如 https://class.example.com
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# 首次启动创建的管理员账号(**部署后请立即登录修改密码**)
|
||||||
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
INITIAL_ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# 各类账号的默认初始密码(新增/重置时使用)
|
||||||
|
DEFAULT_STUDENT_PASSWORD=123456
|
||||||
|
DEFAULT_CLASS_PASSWORD=123456
|
||||||
|
DEFAULT_TEACHER_PASSWORD=123456
|
||||||
|
|
||||||
|
# 第 1 教学周的周一(用于"当前周次")
|
||||||
|
TERM_START_DATE=2026-08-31
|
||||||
|
|
||||||
|
# 日志级别:debug | info | warn | error
|
||||||
|
LOG_LEVEL=info
|
||||||
+7
-2
@@ -5,13 +5,13 @@
|
|||||||
# runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小
|
# runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小
|
||||||
#
|
#
|
||||||
# 构建(在仓库根目录执行):
|
# 构建(在仓库根目录执行):
|
||||||
# docker build -f deploy/Dockerfile -t classhelper-server:0.1.0 .
|
# docker build -f deploy/Dockerfile -t classhelper-server:1.0.0 .
|
||||||
#
|
#
|
||||||
# 运行(SQLite + 数据卷):
|
# 运行(SQLite + 数据卷):
|
||||||
# docker run -d --name classhelper -p 4000:4000 \
|
# docker run -d --name classhelper -p 4000:4000 \
|
||||||
# -e JWT_SECRET=<48字节随机串> \
|
# -e JWT_SECRET=<48字节随机串> \
|
||||||
# -v classhelper-data:/app/data \
|
# -v classhelper-data:/app/data \
|
||||||
# classhelper-server:0.1.0
|
# classhelper-server:1.0.0
|
||||||
#
|
#
|
||||||
# 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代)
|
# 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代)
|
||||||
|
|
||||||
@@ -33,7 +33,12 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
|
|||||||
# 再拷贝源码并构建(只构建服务端与 Web 管理端,桌面客户端不参与)
|
# 再拷贝源码并构建(只构建服务端与 Web 管理端,桌面客户端不参与)
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
# 必须先生成 Prisma Client:`pnpm install --ignore-scripts` 跳过了 postinstall,
|
||||||
|
# 而生成产物 `packages/server/src/generated/prisma` 是 gitignored 的(clone 下来没有),
|
||||||
|
# 少了这一步下面的 tsc 会报 "Cannot find module '../generated/prisma/client.js'"。
|
||||||
RUN pnpm build:shared \
|
RUN pnpm build:shared \
|
||||||
|
&& pnpm --filter @classhelper/server run db:generate \
|
||||||
&& pnpm --filter @classhelper/server build \
|
&& pnpm --filter @classhelper/server build \
|
||||||
&& pnpm --filter @classhelper/web-admin build
|
&& pnpm --filter @classhelper/web-admin build
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 班级小助手 · Docker 单容器部署(SQLite + 数据卷)
|
||||||
|
#
|
||||||
|
# 适用:云服务器 / 与别的服务共用一台机器。相比 deploy/docker-compose.yml(MySQL 变体)
|
||||||
|
# 少跑一个数据库容器,内存占用小很多;数据落在 Docker 卷 classhelper-data 里。
|
||||||
|
#
|
||||||
|
# 使用:
|
||||||
|
# 1) cp deploy/.env.sqlite.example deploy/.env.sqlite 并填 JWT_SECRET / 管理员密码
|
||||||
|
# (生成密钥:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))")
|
||||||
|
# 2) docker compose -f deploy/docker-compose.sqlite.yml --env-file deploy/.env.sqlite up -d --build
|
||||||
|
# 3) 访问:http://<服务器IP>:${SERVER_PORT}/
|
||||||
|
#
|
||||||
|
# 首次启动(AUTO_MIGRATE=true)会自动执行 prisma/migrations 里的建表语句并创建管理员账号,
|
||||||
|
# 见 packages/server/src/lib/db-bootstrap.ts("Docker 挂载空数据卷"就是它的目标场景之一)。
|
||||||
|
#
|
||||||
|
# 备份:docker run --rm -v classhelper-sqlite_classhelper-data:/data -v "$PWD":/backup alpine \
|
||||||
|
# tar czf /backup/classhelper-db.tar.gz -C /data .
|
||||||
|
|
||||||
|
name: classhelper-sqlite
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
target: runtime
|
||||||
|
image: classhelper-server:${APP_VERSION:-1.0.0}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 4000
|
||||||
|
DATABASE_PROVIDER: sqlite
|
||||||
|
# 必须写绝对路径:容器里 serverRoot 是 /app/server,相对路径会解析到别处
|
||||||
|
DATABASE_URL: file:/app/data/classhelper.db
|
||||||
|
# 首启动自动建表 + 建管理员;生产自检会拒绝 dev.db 与过短的 JWT_SECRET
|
||||||
|
AUTO_MIGRATE: 'true'
|
||||||
|
STARTUP_DB_CHECK: 'true'
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?请在 deploy/.env.sqlite 中设置(≥32 位随机串)}
|
||||||
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||||
|
TRUST_PROXY: '1'
|
||||||
|
RATE_LIMIT_ENABLED: 'true'
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
TERM_START_DATE: ${TERM_START_DATE:-}
|
||||||
|
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
||||||
|
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:
|
||||||
|
- '${SERVER_PORT:-4000}:4000'
|
||||||
|
volumes:
|
||||||
|
- classhelper-data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
classhelper-data:
|
||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-0.1.0}
|
image: classhelper-server:${APP_VERSION:-1.0.0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "classhelper-server-runtime",
|
"name": "classhelper-server-runtime",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "班级小助手服务端运行时(安装包 / 容器内置)",
|
"description": "班级小助手服务端运行时(安装包 / 容器内置)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
"@prisma/adapter-libsql": "7.10.0",
|
"@prisma/adapter-libsql": "7.10.0",
|
||||||
|
"@prisma/adapter-mariadb": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"compression": "^1.8.2",
|
"compression": "^1.8.2",
|
||||||
@@ -16,7 +17,9 @@
|
|||||||
"express-rate-limit": "^8.7.0",
|
"express-rate-limit": "^8.7.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"prisma": "7.10.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
"xlsx": "0.18.5",
|
"xlsx": "0.18.5",
|
||||||
"zod": "^4.6.2"
|
"zod": "^4.6.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-9
@@ -19,28 +19,62 @@ MySQL 兼容方式设计,可平滑切换到云端 MySQL / MariaDB 统一存储
|
|||||||
| 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 |
|
| 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 |
|
||||||
| 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 |
|
| 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 |
|
||||||
|
|
||||||
## 切换步骤
|
## 切换到 MySQL
|
||||||
|
|
||||||
### 1. 修改 provider
|
### 方式一:Web 管理端「数据库管理 → 一键切换」(推荐,v1.0.0 新增)
|
||||||
|
|
||||||
|
管理员在 Web 管理端打开 **数据库管理** 页(`/database`,仅 ADMIN 可见),在「一键切换数据库」卡片里:
|
||||||
|
|
||||||
|
1. 选目标类型(SQLite ⇄ MySQL)并填**空库**连接串(有表的库会被拒绝,防误覆盖);
|
||||||
|
2. 输入「确认迁移」→ 点「开始迁移」。
|
||||||
|
|
||||||
|
界面会自动依次执行(带步骤进度条):
|
||||||
|
|
||||||
|
1. **备份当前数据库**(gzip JSON 快照存 `<数据目录>/backups/`,失败可回滚);
|
||||||
|
2. 导出当前数据为快照;
|
||||||
|
3. 测试目标库连接;
|
||||||
|
4. 检查目标库为空;
|
||||||
|
5. 改写 `schema.prisma` 的 provider;
|
||||||
|
6. 子进程 `prisma generate` 生成新方言的 Prisma 客户端;
|
||||||
|
7. (打包形态)把生成的 `.ts` 客户端编译进 `dist/generated`;
|
||||||
|
8. 子进程 `prisma db push` 在目标库建表;
|
||||||
|
9. 子进程 `dist/tools/apply-snapshot.js` 把快照写入目标库;
|
||||||
|
10. 全部成功后改写 `.env` 的 `DATABASE_PROVIDER` / `DATABASE_URL`。
|
||||||
|
|
||||||
|
**完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库。
|
||||||
|
中途任一步失败会**回滚 `schema.prisma`**,`.env` 不被改写 —— 现有数据库与配置保持可用。
|
||||||
|
|
||||||
|
> 同一页还提供:连接状态检测(版本 / 体积 / 各表行数)、连接测试、手动与**定时备份**、
|
||||||
|
> 备份恢复、快照导出导入、SQLite 数据库文件下载。备份与导入导出共用同一种 JSON 快照格式,
|
||||||
|
> 因此**任意备份都能恢复回任意一种受支持的数据库**。
|
||||||
|
>
|
||||||
|
> 只支持 SQLite 与 MySQL 两种主库:Redis 等键值库不是 Prisma 支持的主数据库(无法建表/迁移),
|
||||||
|
> 接口层直接拒绝。
|
||||||
|
|
||||||
|
### 方式二:命令行手工切换
|
||||||
|
|
||||||
|
#### 1. 修改 provider
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 自动把 prisma/schema.prisma 的 provider 改成 mysql
|
# 自动把 prisma/schema.prisma 的 provider 改成 mysql
|
||||||
node scripts/use-database.mjs mysql
|
node scripts/use-database.mjs mysql
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 安装 MySQL 驱动适配器
|
#### 2. MySQL 驱动适配器(自 v1.0.0 起已随包内置)
|
||||||
|
|
||||||
Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb`
|
Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb`
|
||||||
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。
|
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。**它已在 `packages/server/package.json` 的
|
||||||
|
依赖里**(数据库管理模块的连接测试/迁移需要),因此无需再手动安装:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 仅当从旧版本升级、依赖里还没有时才需要
|
||||||
pnpm --filter @classhelper/server add @prisma/adapter-mariadb
|
pnpm --filter @classhelper/server add @prisma/adapter-mariadb
|
||||||
```
|
```
|
||||||
|
|
||||||
`packages/server/src/lib/db.ts` 已经内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
|
`packages/server/src/lib/db.ts` 内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
|
||||||
因此只需要安装依赖 + 配置环境变量,服务端代码无需改动。
|
服务端代码无需改动。
|
||||||
|
|
||||||
### 3. 配置环境变量
|
#### 3. 配置环境变量
|
||||||
|
|
||||||
编辑 `packages/server/.env`:
|
编辑 `packages/server/.env`:
|
||||||
|
|
||||||
@@ -52,7 +86,7 @@ DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper"
|
|||||||
> 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址;
|
> 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址;
|
||||||
> 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。
|
> 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。
|
||||||
|
|
||||||
### 4. 重建迁移并生成客户端
|
#### 4. 重建迁移并生成客户端
|
||||||
|
|
||||||
SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**:
|
SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**:
|
||||||
|
|
||||||
@@ -71,7 +105,11 @@ pnpm db:seed
|
|||||||
pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql
|
pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 回到 SQLite
|
> 只搬数据、不要演示数据时,用界面的「下载快照 / 导入快照」更省事:
|
||||||
|
> 旧库导出 JSON → 切库建表 → 新库导入,一份快照也可用 CLI 迁移:
|
||||||
|
> `node server/dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json`
|
||||||
|
|
||||||
|
#### 5. 回到 SQLite
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/use-database.mjs sqlite
|
node scripts/use-database.mjs sqlite
|
||||||
@@ -80,6 +118,8 @@ rm -rf packages/server/prisma/migrations # 若之前已切换成 MySQL 迁移
|
|||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 用界面「一键切换」回 SQLite 时,目标库同样必须是空文件:删除旧 `.db` 或指定一个新路径。
|
||||||
|
|
||||||
## 生产部署注意事项
|
## 生产部署注意事项
|
||||||
|
|
||||||
1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置,
|
1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置,
|
||||||
@@ -88,3 +128,7 @@ pnpm db:migrate
|
|||||||
三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。
|
三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。
|
||||||
3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。
|
3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。
|
||||||
4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。
|
4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。
|
||||||
|
界面「定时备份」把快照写进 `<数据目录>/backups/`(MySQL 模式下即安装根 `data/backups/`),
|
||||||
|
**服务端进程不运行时不会补跑**;关键环境建议同时用数据库自带的备份策略。
|
||||||
|
5. **安装包体积**:切换链路需要随包内置 `prisma` CLI 与 `typescript` 编译器(Prisma 7 生成器产出
|
||||||
|
`.ts`,切换后需在目标机编译进 `dist/generated`),服务端安装包因此约 34MB → 71MB。
|
||||||
+24
-2
@@ -25,7 +25,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
|||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
| ----------------------------------------------------- | ----------------------------------------------- |
|
| ----------------------------------------------------- | ----------------------------------------------- |
|
||||||
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
||||||
| `release-server/班级小助手服务端-0.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) |
|
| `release-server/班级小助手服务端-1.0.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) |
|
||||||
|
|
||||||
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
||||||
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
||||||
@@ -35,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
|||||||
双击安装程序即可,**无需管理员权限**(当前用户级安装):
|
双击安装程序即可,**无需管理员权限**(当前用户级安装):
|
||||||
|
|
||||||
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
|
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
|
||||||
- 静默安装:`"班级小助手服务端-0.1.0-x64-setup.exe" /S`
|
- 静默安装:`"班级小助手服务端-1.0.0-x64-setup.exe" /S`
|
||||||
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
||||||
- 桌面:`班级小助手服务端.lnk`(打开管理端)
|
- 桌面:`班级小助手服务端.lnk`(打开管理端)
|
||||||
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
|
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
|
||||||
@@ -125,6 +125,28 @@ docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
|
|||||||
- 反向代理上启用 HTTPS:见 `deploy/nginx.conf`,并在服务端环境变量中设置
|
- 反向代理上启用 HTTPS:见 `deploy/nginx.conf`,并在服务端环境变量中设置
|
||||||
`TRUST_PROXY=1`、`CORS_ORIGIN=https://你的域名`。
|
`TRUST_PROXY=1`、`CORS_ORIGIN=https://你的域名`。
|
||||||
|
|
||||||
|
### 2.1 单容器变体(SQLite,推荐给内存小的机器 / 与别的服务共用一台机器)
|
||||||
|
|
||||||
|
`deploy/docker-compose.sqlite.yml` 只跑**服务端一个容器**,不额外起 MySQL:数据落在 Docker 卷
|
||||||
|
`classhelper-data` 的 `/app/data/classhelper.db`,首次启动由 `AUTO_MIGRATE=true` 自动建表并创建管理员
|
||||||
|
(`packages/server/src/lib/db-bootstrap.ts` 的"空数据卷"分支)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy/.env.sqlite.example deploy/.env.sqlite # 填 JWT_SECRET、管理员密码、端口
|
||||||
|
docker compose -f deploy/docker-compose.sqlite.yml --env-file deploy/.env.sqlite up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
适用场景与注意点:
|
||||||
|
|
||||||
|
- 机器内存紧张(比如 2G 小机器上还跑着别的服务)时,比 MySQL 变体少一个数据库进程;
|
||||||
|
- 端口用 `SERVER_PORT` 指定,**别抢占 80/443**(那通常是同机反代占用的);
|
||||||
|
- 生产自检会拒绝 `dev.db` 与过短的 `JWT_SECRET`,因此 `DATABASE_URL` 必须是绝对路径且不含 `dev.db`;
|
||||||
|
- 备份就是整个卷:`docker run --rm -v classhelper-sqlite_classhelper-data:/data -v "$PWD":/backup alpine tar czf /backup/classhelper-db.tar.gz -C /data .`
|
||||||
|
|
||||||
|
> `deploy/Dockerfile` 的构建阶段会先跑 `pnpm --filter @classhelper/server run db:generate`:
|
||||||
|
> 生成产物 `packages/server/src/generated/prisma` 是 gitignored 的,而 `--ignore-scripts` 跳过了
|
||||||
|
> postinstall,少了这一步 `tsc` 会直接编译失败。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、Linux 原生部署(形态 C,systemd + SQLite)
|
## 三、Linux 原生部署(形态 C,systemd + SQLite)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "class-helper",
|
"name": "class-helper",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ description: |-
|
|||||||
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||||
url: https://github.com/LoveChengke/ClassHelper
|
url: https://github.com/LoveChengke/ClassHelper
|
||||||
version: 0.1.0.0
|
version: 1.0.0.0
|
||||||
apiVersion: 2.0.0.0
|
apiVersion: 2.0.0.0
|
||||||
author: 班级小助手
|
author: 班级小助手
|
||||||
@@ -295,6 +295,57 @@ check(
|
|||||||
bridgeSource.includes('NotificationPollSeconds'),
|
bridgeSource.includes('NotificationPollSeconds'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 8. 回归护栏:本次修复过的坑 */
|
||||||
|
|
||||||
|
check(
|
||||||
|
'课表配对用原始 TimeType==0 列表,**不用** ValidTimeLayoutItems(后者会剔除被停用的节次,导致整份课表错位)',
|
||||||
|
// 只查代码形态(`.Count`),因为源码注释里特意点名了这个属性来说明"为什么不能用它"
|
||||||
|
mapperSource.includes('classPoints[index]') && !mapperSource.includes('.ValidTimeLayoutItems.Count'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'上报字段按服务端 zod 上限截断(一个超长名字会让整批上报 400,连状态与心跳一起丢掉)',
|
||||||
|
mapperSource.includes('Truncate(') &&
|
||||||
|
mapperSource.includes('SubjectMaxLength') &&
|
||||||
|
mapperSource.includes('PlanNameMaxLength'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'课表镜像在 UI 线程改写档案(线程池上改 Observable 集合会与落盘并发,可能写坏档案)',
|
||||||
|
bridgeSource.includes('Dispatcher.UIThread.InvokeAsync') && bridgeSource.includes('SaveProfile()'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'镜像内容未变化时跳过(否则每次上报都重建课表并全量落盘)',
|
||||||
|
bridgeSource.includes('_lastMirrorSignature') && bridgeSource.includes('MirrorSignature('),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'上报异常有 catch(fire-and-forget 的异常会被静默吞掉)',
|
||||||
|
bridgeSource.includes('catch (Exception exception)') && bridgeSource.includes('上报异常'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'镜像前过滤时间非法的条目(避免档案里出现 00:00 的课次,且必须与 Classes 同步过滤)',
|
||||||
|
read('src/Services/ClassPlanWriter.cs').includes('IsValidTime'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'上报体带上 deviceKey,且服务端 schema 也声明了该字段(回填机器码的契约两端一致)',
|
||||||
|
clientSource.includes('"deviceKey"') && serverSchemas.includes('deviceKey:'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
|
||||||
|
clientSource.includes('响应不是班级小助手的标准格式') &&
|
||||||
|
clientSource.includes('MaxResponseContentBufferSize'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'提醒提供方惰性获取课程服务并重试(拿不到服务时"上课暂存"会永久失效且静默)',
|
||||||
|
providerSource.includes('EnsureLessons') && providerSource.includes('_lessonsHooked'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'设置落盘先写临时文件再原子替换(逐字符写盘 + 截断式写入会留下坏 JSON)',
|
||||||
|
pluginSource.includes('File.Move(temp, path, overwrite: true)'),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'服务器地址只接受 http/https(裸 Uri.TryCreate 会放过 file:// / ftp://)',
|
||||||
|
settingsSource.includes('TryGetServerUri') && settingsSource.includes('UriSchemeHttp'),
|
||||||
|
);
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 结果 */
|
/* ------------------------------------------------------------ 结果 */
|
||||||
|
|
||||||
const failed = results.filter((item) => !item.ok);
|
const failed = results.filter((item) => !item.ok);
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ public class ReportRequestDto
|
|||||||
{
|
{
|
||||||
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
|
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
|
||||||
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
|
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
|
||||||
|
/// <summary>本机机器码:设备创建时服务端还不知道它,靠这里回填(见服务端 syncDeviceKey)。</summary>
|
||||||
|
[JsonPropertyName("deviceKey")] public string DeviceKey { get; set; } = "";
|
||||||
[JsonPropertyName("state")] public StateDto? State { get; set; }
|
[JsonPropertyName("state")] public StateDto? State { get; set; }
|
||||||
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
|
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
|
||||||
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
|
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
|
||||||
@@ -198,6 +200,12 @@ public sealed class ClassHelperClient : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 响应体上限(4MB)。默认是 2GB —— 服务器地址填错(指到一个会吐大页面的地址)时,
|
||||||
|
/// 一次请求就能在 12 秒内把内存吃光。
|
||||||
|
/// </summary>
|
||||||
|
private const long MaxResponseBytes = 4 * 1024 * 1024;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -216,6 +224,7 @@ public sealed class ClassHelperClient : IDisposable
|
|||||||
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
|
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
|
||||||
{
|
{
|
||||||
Timeout = Timeout,
|
Timeout = Timeout,
|
||||||
|
MaxResponseContentBufferSize = MaxResponseBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
public string LastError { get; private set; } = "";
|
public string LastError { get; private set; } = "";
|
||||||
@@ -334,6 +343,17 @@ public sealed class ClassHelperClient : IDisposable
|
|||||||
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
|
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (envelope is null)
|
||||||
|
{
|
||||||
|
// 2xx 但响应不是本服务的统一响应体(网关/反代的 HTML 错误页、地址指到了别的服务等)。
|
||||||
|
// 必须留下原因:否则调用方拿到的是"成功但无数据 + 空错误消息",
|
||||||
|
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
|
||||||
|
LastError =
|
||||||
|
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||||
|
"响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
||||||
|
return new ApiEnvelope<T> { Success = false, Message = LastError };
|
||||||
|
}
|
||||||
|
|
||||||
LastError = "";
|
LastError = "";
|
||||||
LastSuccessAt = DateTimeOffset.Now;
|
LastSuccessAt = DateTimeOffset.Now;
|
||||||
return envelope;
|
return envelope;
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ public class PluginSettings : ObservableObject
|
|||||||
public string DeviceToken
|
public string DeviceToken
|
||||||
{
|
{
|
||||||
get => _deviceToken;
|
get => _deviceToken;
|
||||||
set => SetProperty(ref _deviceToken, value);
|
// 在 setter 里就去掉首尾空白:粘贴令牌时带前导空格非常常见,
|
||||||
|
// 而 Normalize() 只在启动加载时跑一次 —— 运行期粘进来的脏值会让 IsConfigured
|
||||||
|
// 一直为 false,用户看着明明填好了却查不出原因。
|
||||||
|
set => SetProperty(ref _deviceToken, (value ?? "").Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -154,5 +157,20 @@ public class PluginSettings : ObservableObject
|
|||||||
|
|
||||||
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
|
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
|
||||||
public bool IsConfigured =>
|
public bool IsConfigured =>
|
||||||
Uri.TryCreate(ServerUrl, UriKind.Absolute, out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
|
TryGetServerUri(out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析服务器地址,**只接受 http/https 绝对地址**。
|
||||||
|
///
|
||||||
|
/// 不用裸的 `Uri.TryCreate(..., UriKind.Absolute)`:它会接受 `file://` / `ftp://`,
|
||||||
|
/// 那种地址会在发请求时抛一句难以理解的 NotSupportedException,用户根本猜不到是地址填错了。
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetServerUri(out Uri uri)
|
||||||
|
{
|
||||||
|
uri = null!;
|
||||||
|
if (!Uri.TryCreate((ServerUrl ?? "").Trim(), UriKind.Absolute, out var parsed)) return false;
|
||||||
|
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) return false;
|
||||||
|
uri = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -127,9 +127,15 @@ public class Plugin : PluginBase
|
|||||||
settings.Normalize();
|
settings.Normalize();
|
||||||
if (needGenerateKey)
|
if (needGenerateKey)
|
||||||
{
|
{
|
||||||
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车
|
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车。
|
||||||
|
// 注意长度:原先写的是 `[..Math.Min(24, machine.Length + 33)]`,而 machine.Length + 33
|
||||||
|
// 恒大于 24,所以实际**永远只取前 24 个字符** —— 机器名稍长就把随机段整个切掉,
|
||||||
|
// "同名机器不撞车"的保障就没了。这里改成"机器名按需截短 + 保留 8 位随机段"。
|
||||||
var machine = Environment.MachineName.ToLowerInvariant();
|
var machine = Environment.MachineName.ToLowerInvariant();
|
||||||
settings.DeviceKey = $"{machine}-{Guid.NewGuid():N}"[..Math.Min(24, machine.Length + 33)];
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var maxMachineLength = Math.Max(4, 24 - suffix.Length - 1);
|
||||||
|
if (machine.Length > maxMachineLength) machine = machine[..maxMachineLength];
|
||||||
|
settings.DeviceKey = $"{machine}-{suffix}";
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
@@ -146,7 +152,15 @@ public class Plugin : PluginBase
|
|||||||
{
|
{
|
||||||
var folder = Path.GetDirectoryName(path);
|
var folder = Path.GetDirectoryName(path);
|
||||||
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
|
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
|
||||||
File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonOptions));
|
// 先写临时文件再原子替换。
|
||||||
|
//
|
||||||
|
// 设置页的地址/令牌输入框是 TwoWay 绑定(逐字符提交),直接 `File.WriteAllText`
|
||||||
|
// 会就地截断原文件;一旦此刻进程被杀或断电,留下的是半截 JSON,而 LoadSettings 的
|
||||||
|
// catch 会**静默回退到默认设置** —— 用户看到的是"令牌和地址莫名其妙没了",
|
||||||
|
// 而且原文件已被覆盖,无从恢复。
|
||||||
|
var temp = path + ".tmp";
|
||||||
|
File.WriteAllText(temp, JsonSerializer.Serialize(settings, JsonOptions));
|
||||||
|
File.Move(temp, path, overwrite: true);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using Avalonia.Threading;
|
||||||
using ClassHelper.ClassIslandPlugin.Interop;
|
using ClassHelper.ClassIslandPlugin.Interop;
|
||||||
using ClassHelper.ClassIslandPlugin.Models;
|
using ClassHelper.ClassIslandPlugin.Models;
|
||||||
using ClassIsland.Core;
|
using ClassIsland.Core;
|
||||||
@@ -39,6 +41,11 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
private bool _reporting;
|
private bool _reporting;
|
||||||
private bool _pulling;
|
private bool _pulling;
|
||||||
private bool _eventsHooked;
|
private bool _eventsHooked;
|
||||||
|
/// <summary>
|
||||||
|
/// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘,
|
||||||
|
/// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。
|
||||||
|
/// </summary>
|
||||||
|
private volatile string? _lastMirrorSignature;
|
||||||
|
|
||||||
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
|
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
|
||||||
{
|
{
|
||||||
@@ -237,6 +244,8 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
{
|
{
|
||||||
PluginVersion = PluginVersion,
|
PluginVersion = PluginVersion,
|
||||||
ClassIslandVersion = SafeAppVersion(),
|
ClassIslandVersion = SafeAppVersion(),
|
||||||
|
// 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上
|
||||||
|
DeviceKey = _settings.DeviceKey,
|
||||||
State = BuildState(lessons),
|
State = BuildState(lessons),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,7 +283,9 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
layoutCount = items.Count;
|
layoutCount = items.Count;
|
||||||
request.TimeLayout = new TimeLayoutPayloadDto
|
request.TimeLayout = new TimeLayoutPayloadDto
|
||||||
{
|
{
|
||||||
Name = string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
|
Name = ScheduleMapper.Truncate(
|
||||||
|
string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
|
||||||
|
ScheduleMapper.PlanNameMaxLength)!,
|
||||||
Mode = "replace",
|
Mode = "replace",
|
||||||
Items = items,
|
Items = items,
|
||||||
};
|
};
|
||||||
@@ -321,6 +332,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// ReportAsync 是通过 `_ = ReportAsync(reason)` 这种 fire-and-forget 方式发起的
|
||||||
|
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
|
||||||
|
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
|
||||||
|
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
|
||||||
|
_logger.LogError(exception, "班级小助手联动:上报异常({Reason})", reason);
|
||||||
|
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Busy = false;
|
Busy = false;
|
||||||
@@ -397,23 +418,48 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要
|
||||||
|
// 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。
|
||||||
|
var signature = MirrorSignature(plan);
|
||||||
|
if (signature == _lastMirrorSignature)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
// **必须在 UI 线程上改写档案**:ClassIsland 的档案是 ObservableDictionary /
|
||||||
|
// ObservableCollection,落盘是"属性变化即整体序列化 + FileStream 就地截断"。
|
||||||
|
// 而本方法是从线程池线程继续执行的(ReportAsync 里 ConfigureAwait(false) 之后,
|
||||||
|
// 定时器回调本就在线程池上),在线程池上改集合会与 UI 线程的保存/绑定并发:
|
||||||
|
// 轻则 InvalidOperationException(Collection was modified),重则两次写入互相截断,
|
||||||
|
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
|
||||||
|
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
var applied = ClassPlanWriter.Apply(profile, plan);
|
var applied = ClassPlanWriter.Apply(profile, plan);
|
||||||
if (!applied.Applied)
|
if (!applied.Applied)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
return applied;
|
||||||
applied.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
||||||
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
||||||
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
||||||
|
return applied;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!outcome.Applied)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
||||||
|
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastMirrorSignature = signature;
|
||||||
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
||||||
string.Join("、", applied.PlanNames.Select(name => $"「{name}」")));
|
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
|
||||||
foreach (var warning in applied.Warnings)
|
foreach (var warning in outcome.Warnings)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
||||||
}
|
}
|
||||||
@@ -424,10 +470,32 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>镜像载荷的内容指纹(只用于"内容没变就跳过",不参与任何业务判断)</summary>
|
||||||
|
private static string MirrorSignature(ClassPlanMirrorDto plan)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
foreach (var entry in plan.Entries)
|
||||||
|
{
|
||||||
|
builder.Append(entry.WeekDay).Append('|').Append(entry.WeekCountDiv).Append('|')
|
||||||
|
.Append(entry.WeekCountDivTotal).Append('|').Append(entry.Subject).Append('|')
|
||||||
|
.Append(entry.StartTime).Append('|').Append(entry.EndTime).Append(';');
|
||||||
|
}
|
||||||
|
foreach (var layout in plan.TimeLayouts)
|
||||||
|
{
|
||||||
|
builder.Append('#').Append(layout.Id).Append(':');
|
||||||
|
foreach (var item in layout.Layouts)
|
||||||
|
{
|
||||||
|
builder.Append(item.StartTime).Append('-').Append(item.EndTime).Append('@')
|
||||||
|
.Append(item.TimeType).Append(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 内部实现 */
|
/* ---------------------------------------------------------------- 内部实现 */
|
||||||
|
|
||||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||||
private const string PluginVersion = "0.1.0.0";
|
private const string PluginVersion = "1.0.0.0";
|
||||||
|
|
||||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||||
private static string SafeAppVersion()
|
private static string SafeAppVersion()
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
|||||||
private readonly List<PushNotificationDto> _deferred = new();
|
private readonly List<PushNotificationDto> _deferred = new();
|
||||||
|
|
||||||
private ILessonsService? _lessons;
|
private ILessonsService? _lessons;
|
||||||
|
private bool _lessonsHooked;
|
||||||
|
|
||||||
public ClassHelperNotificationProvider(
|
public ClassHelperNotificationProvider(
|
||||||
BridgeService bridge,
|
BridgeService bridge,
|
||||||
@@ -74,26 +75,43 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_bridge.NotificationReceived += OnNotificationReceived;
|
_bridge.NotificationReceived += OnNotificationReceived;
|
||||||
|
|
||||||
// 订阅课程事件:下课后把上课时段暂存的提醒补弹出来
|
// 尽力而为地先订阅一次;拿不到也没关系,后面每次需要判定时都会重试
|
||||||
|
// (本类的构造函数可能在课程服务建好之前就被 DI 调用)
|
||||||
|
EnsureLessons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 惰性获取课程服务并**只订阅一次**课程事件(下课后补弹上课时段暂存的提醒)。
|
||||||
|
///
|
||||||
|
/// 为什么不能只在构造函数里订阅:提供方是随主机启动被构造的,而课程服务在启动流程里的
|
||||||
|
/// 创建时机并不固定。一旦构造函数里拿到 null,原先的实现既不重试、也不打日志,
|
||||||
|
/// 于是 `IsInClass()` 恒为 false —— "上课时段先暂存、下课再补弹"这条规则**永久失效**,
|
||||||
|
/// 老师在课上发的通知会直接全屏打断课堂,而且完全静默、无从排查。
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureLessons()
|
||||||
|
{
|
||||||
|
if (_lessonsHooked) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_lessons = IAppHost.TryGetService<ILessonsService>();
|
_lessons = IAppHost.TryGetService<ILessonsService>();
|
||||||
if (_lessons is not null)
|
if (_lessons is null) return;
|
||||||
{
|
|
||||||
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
}
|
_lessonsHooked = true;
|
||||||
|
_logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,上课暂存将退化为「立即弹出」");
|
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
|
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
|
||||||
private bool IsInClass()
|
private bool IsInClass()
|
||||||
{
|
{
|
||||||
|
EnsureLessons();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return _lessons?.CurrentState == TimeState.OnClass;
|
return _lessons?.CurrentState == TimeState.OnClass;
|
||||||
|
|||||||
@@ -79,9 +79,22 @@ public static class ClassPlanWriter
|
|||||||
{
|
{
|
||||||
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
|
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
|
||||||
group.WeekCountDivTotal, groups.Count);
|
group.WeekCountDivTotal, groups.Count);
|
||||||
|
|
||||||
|
// 时间非法的条目必须**在这里**就整批丢掉:`Classes` 与时间表里的上课点必须等长且同序,
|
||||||
|
// 只在一处跳过会让整份课表错位。原先 ParseTime 把无法解析的值悄悄变成 00:00,
|
||||||
|
// 于是档案里凭空出现 00:00-00:00 的课次(时间轴显示与当前节次判断都会乱),还没有任何日志。
|
||||||
|
var usable = group.Entries
|
||||||
|
.Where(entry => IsValidTime(entry.StartTime) && IsValidTime(entry.EndTime))
|
||||||
|
.ToList();
|
||||||
|
if (usable.Count < group.Entries.Count)
|
||||||
|
{
|
||||||
|
result.Warnings.Add(
|
||||||
|
$"「{planName}」有 {group.Entries.Count - usable.Count} 节的时间无法解析(需要 HH:mm),已跳过");
|
||||||
|
}
|
||||||
|
if (usable.Count == 0) continue;
|
||||||
|
|
||||||
var planId = FindPlanId(profile, planName);
|
var planId = FindPlanId(profile, planName);
|
||||||
var (timeLayoutId, timeLayout) = BuildTimeLayout(
|
var (timeLayoutId, timeLayout) = BuildTimeLayout(profile, planName, usable);
|
||||||
profile, planName, group.Entries);
|
|
||||||
|
|
||||||
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
|
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
|
||||||
? existing
|
? existing
|
||||||
@@ -102,7 +115,7 @@ public static class ClassPlanWriter
|
|||||||
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
|
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
|
||||||
var classes = new ObservableCollection<ClassInfo>();
|
var classes = new ObservableCollection<ClassInfo>();
|
||||||
var index = 0;
|
var index = 0;
|
||||||
foreach (var entry in group.Entries)
|
foreach (var entry in usable)
|
||||||
{
|
{
|
||||||
var subjectId = profile.Subjects
|
var subjectId = profile.Subjects
|
||||||
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
|
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
|
||||||
@@ -251,4 +264,19 @@ public static class ClassPlanWriter
|
|||||||
{
|
{
|
||||||
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
|
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 时间字符串是否可用(HH:mm / HH:mm:ss)。
|
||||||
|
///
|
||||||
|
/// 服务端输出侧没有校验 `Schedule.startTime`(老数据/异常数据可能是空串),
|
||||||
|
/// 而 <see cref="ParseTime"/> 会把非法值变成 0 点 —— 因此调用方必须先用本方法过滤,
|
||||||
|
/// 并把 Classes 与时间表**同时**按过滤后的列表构造,保证索引仍然一致。
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsValidTime(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||||
|
return TimeSpan.TryParse(value, out var parsed)
|
||||||
|
&& parsed >= TimeSpan.Zero
|
||||||
|
&& parsed < TimeSpan.FromDays(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -61,9 +61,16 @@ public static class ScheduleMapper
|
|||||||
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
|
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
|
||||||
///
|
///
|
||||||
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
|
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
|
||||||
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,
|
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,课间 / 分割线 / 行动不占位。
|
||||||
/// 课间 / 分割线 / 行动不占 Classes 的位置。因此这里必须按 TimeType 过滤后再配对,
|
///
|
||||||
/// 否则整张课表会错位。
|
/// **注意不要用 `plan.ValidTimeLayoutItems`**:它的口径与 `Classes` 不同 ——
|
||||||
|
/// `GetValidTimeLayoutItems()`(ClassIsland ClassPlan.cs)会正反两遍扫描,
|
||||||
|
/// 把 `ClassInfo.IsEnabled == false`(老师在课表编辑里"停用"这一节)的课**连同其后的课间**
|
||||||
|
/// 一起从结果里剔除;而 `RefreshClassesList()` 只按 `TimeType == 0` 的**总数**对齐
|
||||||
|
/// `Classes` 的长度,`ClassInfo.CurrentTimeLayoutItem` 也是按原始列表的第 `Index` 个取的。
|
||||||
|
/// 于是只要停用过任意一节,用 ValidTimeLayoutItems 配对就会让索引整体前移:
|
||||||
|
/// 第 i 节配上第 i+1 节的时间点、最后一节丢失,且 `Math.Min` 会把缺口静默吃掉 ——
|
||||||
|
/// 表现就是"课表看着有内容、每节课都对不上",极难排查。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
|
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
|
||||||
{
|
{
|
||||||
@@ -77,11 +84,8 @@ public static class ScheduleMapper
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致
|
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致(见上面注释)
|
||||||
var classPoints = timeLayout.Layouts.Where(item => item.TimeType == 0).ToList();
|
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 weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
|
||||||
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
||||||
@@ -91,12 +95,15 @@ public static class ScheduleMapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
var classes = plan.Classes;
|
var classes = plan.Classes;
|
||||||
var count = Math.Min(classes.Count, validPoints.Count);
|
var count = Math.Min(classes.Count, classPoints.Count);
|
||||||
for (var index = 0; index < count; index++)
|
for (var index = 0; index < count; index++)
|
||||||
{
|
{
|
||||||
var info = classes[index];
|
var info = classes[index];
|
||||||
var point = validPoints[index];
|
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
|
||||||
|
var point = classPoints[index];
|
||||||
if (info is null) continue;
|
if (info is null) continue;
|
||||||
|
// 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课)
|
||||||
|
if (!info.IsEnabled) continue;
|
||||||
|
|
||||||
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
||||||
var subject = ResolveSubjectName(info.SubjectId, profile);
|
var subject = ResolveSubjectName(info.SubjectId, profile);
|
||||||
@@ -107,18 +114,33 @@ public static class ScheduleMapper
|
|||||||
DayOfWeek = weekDay,
|
DayOfWeek = weekDay,
|
||||||
StartTime = ToHhMm(point.StartTime),
|
StartTime = ToHhMm(point.StartTime),
|
||||||
EndTime = ToHhMm(point.EndTime),
|
EndTime = ToHhMm(point.EndTime),
|
||||||
Subject = subject,
|
// 服务端对这几个字段有 zod 长度上限,且是**整批**校验:一个超长名字会让整次上报
|
||||||
TeacherName = ResolveSubjectTeacher(info.SubjectId, profile),
|
// 400,连"现在上什么课"的状态与心跳都不更新,Web 端把教室显示成离线。
|
||||||
|
Subject = Truncate(subject, SubjectMaxLength)!,
|
||||||
|
TeacherName = Truncate(ResolveSubjectTeacher(info.SubjectId, profile), NameMaxLength),
|
||||||
WeekParity = parity,
|
WeekParity = parity,
|
||||||
WeekCountDiv = plan.TimeRule.WeekCountDiv,
|
WeekCountDiv = plan.TimeRule.WeekCountDiv,
|
||||||
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
|
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
|
||||||
PlanName = plan.Name,
|
PlanName = Truncate(plan.Name, PlanNameMaxLength),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>服务端 zod 对各类名称的长度上限(超出会让整批上报 400,所以要截断) */
|
||||||
|
public const int SubjectMaxLength = 40;
|
||||||
|
public const int NameMaxLength = 40;
|
||||||
|
public const int PlanNameMaxLength = 60;
|
||||||
|
|
||||||
|
/// <summary>按服务端上限截断(null / 空串原样返回)</summary>
|
||||||
|
public static string? Truncate(string? value, int max)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return value;
|
||||||
|
var trimmed = value.Trim();
|
||||||
|
return trimmed.Length <= max ? trimmed : trimmed[..max];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
|
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
|
||||||
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
|
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
|
||||||
{
|
{
|
||||||
@@ -131,7 +153,8 @@ public static class ScheduleMapper
|
|||||||
items.Add(new TimeLayoutItemDto
|
items.Add(new TimeLayoutItemDto
|
||||||
{
|
{
|
||||||
Index = index + 1,
|
Index = index + 1,
|
||||||
Name = BuildTimePointName(point, ref classPointIndex),
|
// 节次名同样是服务端有上限的字段(max 40),超长会让整批上报失败
|
||||||
|
Name = Truncate(BuildTimePointName(point, ref classPointIndex), NameMaxLength)!,
|
||||||
StartTime = ToHhMm(point.StartTime),
|
StartTime = ToHhMm(point.StartTime),
|
||||||
EndTime = ToHhMm(point.EndTime),
|
EndTime = ToHhMm(point.EndTime),
|
||||||
Type = ToTimeLayoutType(point.TimeType),
|
Type = ToTimeLayoutType(point.TimeType),
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
Watermark="chci_..."
|
Watermark="chci_..."
|
||||||
PasswordChar="●"
|
PasswordChar="●"
|
||||||
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
|
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
|
||||||
|
<!-- 令牌是设备级长期凭证(只有管理员在 Web 端重置才会失效),而 ClassIsland 的插件配置
|
||||||
|
是明文 JSON 且会随档案备份/迁移一起被带走,所以这里明确提示用户别外传。 -->
|
||||||
|
<TextBlock Margin="0 6 0 0" TextWrapping="Wrap" Opacity="0.7"
|
||||||
|
Text="令牌是这台教室机器的长期凭证(仅管理员在 Web 端重置才会失效),会明文保存在本机配置文件里并随 ClassIsland 档案备份一起复制,请不要外传或提交到版本库。" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
|
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
|
||||||
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ directories:
|
|||||||
output: release
|
output: release
|
||||||
buildResources: build
|
buildResources: build
|
||||||
|
|
||||||
|
# 注意:这里**不要**加 afterPack 去改 appOutDir 的完整性标签。
|
||||||
|
# 归档内部的载荷标签不会被还原(装到正常位置拿到的是默认 Medium 文件),
|
||||||
|
# 而在打包前把 appOutDir 改成 Medium 会让 7za(自身带 Low 标签、进程被降到 Low)
|
||||||
|
# 因"不能向上读"而无法生成归档。交付物的标签统一在 scripts/dist-win.mjs 收尾时修正。
|
||||||
|
|
||||||
# 渲染进程产物已由 Vite 打包、主进程/preload 已由 esbuild 打包,
|
# 渲染进程产物已由 Vite 打包、主进程/preload 已由 esbuild 打包,
|
||||||
# 运行时不需要任何 node_modules(electron 模块由 Electron 运行时提供)。
|
# 运行时不需要任何 node_modules(electron 模块由 Electron 运行时提供)。
|
||||||
files:
|
files:
|
||||||
|
|||||||
@@ -3,7 +3,24 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- Electron 渲染进程安全策略:只允许加载本地资源与后端 HTTP/WS 连接 -->
|
<!--
|
||||||
|
内容安全策略(Electron 渲染进程)。
|
||||||
|
|
||||||
|
为什么必须有:渲染进程通过 preload 桥能拿到 `getConfig()` 返回的**明文登录令牌**
|
||||||
|
(代表该班全班身份),因此一旦出现一次注入(未来某个 v-html、某个依赖被投毒),
|
||||||
|
脚本就能把令牌发到外网。CSP 是唯一能拦住"加载并执行远程脚本"的那一层。
|
||||||
|
|
||||||
|
各指令口径:
|
||||||
|
- default-src/script-src 'self':只允许执行本应用自己的脚本(挡住远程脚本注入);
|
||||||
|
- style-src 需要 'unsafe-inline':Vue SFC 与 Element Plus 运行时会注入行内样式;
|
||||||
|
- connect-src 放开 http/https/ws/wss:服务器地址由用户在设置页填写(可能是内网 IP、域名、
|
||||||
|
HTTPS 反代),无法预先枚举,且连接本身不是"执行代码"的入口;
|
||||||
|
- object-src/frame-src/form-action 一律关掉:本应用不需要插件、内嵌页面与表单提交。
|
||||||
|
-->
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
|
||||||
|
/>
|
||||||
<title>班级小助手</title>
|
<title>班级小助手</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- 与主窗口同一套 CSP(理由见 index.html):灵动岛同样是持有 preload 桥的渲染进程 -->
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
|
||||||
|
/>
|
||||||
<title>班级小助手 · 灵动岛</title>
|
<title>班级小助手 · 灵动岛</title>
|
||||||
<style>
|
<style>
|
||||||
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
|
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/desktop-client",
|
"name": "@classhelper/desktop-client",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||||
"author": "Class Helper",
|
"author": "Class Helper",
|
||||||
"main": "dist/main/index.js",
|
"main": "dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "node scripts/dev.mjs",
|
||||||
"build": "npm run build:main && npm run build:renderer",
|
"build": "npm run build:main && npm run build:renderer && npm run check:secrets",
|
||||||
"build:main": "node scripts/build-main.mjs",
|
"build:main": "node scripts/build-main.mjs",
|
||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
|
"check:secrets": "node scripts/check-bundle-secrets.mjs",
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"smoke": "node scripts/smoke.mjs",
|
"smoke": "node scripts/smoke.mjs",
|
||||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 构建产物凭据自检:`dist` 里不允许出现种子账号 / 口令字面量。
|
||||||
|
*
|
||||||
|
* 为什么单独立一条门禁:`dist/main/index.js` 与 `dist/renderer/assets/*.js` 会被 electron-builder
|
||||||
|
* 打进 `app.asar`,随安装包发到每台学生机,而 **asar 可以直接解包**。
|
||||||
|
* 冒烟/自检代码里写死 `teacher1 / teacher123` 这类字面量,等于把教师账号随安装包一起发出去
|
||||||
|
* (历史上真的发生过:`grep teacher123 dist/main/index.js` 命中 3 次)。
|
||||||
|
*
|
||||||
|
* 凭据一律由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供
|
||||||
|
* (`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `ELECTRON_SMOKE_CLASS_CODE` / `..._CLASS_PASSWORD`),
|
||||||
|
* 那两个脚本不进安装包。
|
||||||
|
*
|
||||||
|
* 命中即构建失败(exit 1)。
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const distDir = path.join(root, 'dist');
|
||||||
|
|
||||||
|
/** 明确的种子口令:任何情况下都不该出现在产物里 */
|
||||||
|
const FORBIDDEN_STRINGS = ['teacher123', 'admin123', 'student123'];
|
||||||
|
|
||||||
|
/** 组合形态:把种子班级码 / 默认班级密码当凭据使用的写法 */
|
||||||
|
const FORBIDDEN_PATTERNS = [
|
||||||
|
{ label: '种子班级码当凭据', pattern: /["'](?:code|classCode)["']\s*[:=]\s*["']G(?:101|102|203)["']/ },
|
||||||
|
{
|
||||||
|
label: '默认班级密码当凭据',
|
||||||
|
pattern: /["'](?:password|classPassword)["']\s*[:=]\s*["']123456["']/,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 递归收集文件(跳过 sourcemap 与二进制) */
|
||||||
|
function collectFiles(dir) {
|
||||||
|
const found = [];
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
found.push(...collectFiles(full));
|
||||||
|
} else if (/\.(?:js|mjs|cjs|html|json|css)$/i.test(entry.name)) {
|
||||||
|
found.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(distDir)) {
|
||||||
|
console.log('[check-secrets] 未找到 dist(尚未构建),跳过凭据自检');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = [];
|
||||||
|
for (const file of collectFiles(distDir)) {
|
||||||
|
const content = fs.readFileSync(file, 'utf8');
|
||||||
|
const relative = path.relative(root, file);
|
||||||
|
for (const needle of FORBIDDEN_STRINGS) {
|
||||||
|
if (content.includes(needle)) hits.push(`${relative}:出现种子口令「${needle}」`);
|
||||||
|
}
|
||||||
|
for (const { label, pattern } of FORBIDDEN_PATTERNS) {
|
||||||
|
if (pattern.test(content)) hits.push(`${relative}:${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hits.length > 0) {
|
||||||
|
console.error('[check-secrets] 构建产物里出现了种子凭据(asar 可被解包,不能随包分发):');
|
||||||
|
for (const hit of hits) console.error(` - ${hit}`);
|
||||||
|
console.error(
|
||||||
|
'\n请把凭据改为从环境变量读取(见 scripts/smoke.mjs 的 ELECTRON_SMOKE_USER 等),不要写进源码。',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[check-secrets] 通过:${collectFiles(distDir).length} 个产物文件里没有任何种子凭据字面量`);
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
|
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
|
||||||
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked
|
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked
|
||||||
*/
|
*/
|
||||||
import { spawn } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -24,8 +24,17 @@ const args = process.argv.slice(2);
|
|||||||
const dirOnly = args.includes('--dir');
|
const dirOnly = args.includes('--dir');
|
||||||
const passthrough = args.filter((item) => item !== '--dir');
|
const passthrough = args.filter((item) => item !== '--dir');
|
||||||
|
|
||||||
|
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
|
||||||
|
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
|
||||||
|
// `!tempfile: Unable to create temporary file!`,NSIS 那一步就失败(磁盘并不紧张)。
|
||||||
|
// 统一指到仓库内的真实目录,避开宿主终端带来的环境差异。
|
||||||
|
const tmpDir = path.join(repoRoot, '.cache', 'tmp');
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
TEMP: tmpDir,
|
||||||
|
TMP: tmpDir,
|
||||||
ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/',
|
ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/',
|
||||||
ELECTRON_BUILDER_BINARIES_MIRROR:
|
ELECTRON_BUILDER_BINARIES_MIRROR:
|
||||||
process.env.ELECTRON_BUILDER_BINARIES_MIRROR ??
|
process.env.ELECTRON_BUILDER_BINARIES_MIRROR ??
|
||||||
@@ -60,11 +69,56 @@ console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
|
|||||||
const runtime = resolveNodeRuntime();
|
const runtime = resolveNodeRuntime();
|
||||||
console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
|
console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
|
||||||
|
|
||||||
|
// 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)),
|
||||||
|
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
|
||||||
|
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
|
||||||
|
const outputDir = path.join(root, 'release');
|
||||||
|
if (fs.existsSync(outputDir)) {
|
||||||
|
for (const name of fs.readdirSync(outputDir)) {
|
||||||
|
if (!/\.(exe|7z|blockmap|yml)$/i.test(name)) continue;
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(outputDir, name), { force: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[dist] 已清理上一轮的安装包 / 归档');
|
||||||
|
}
|
||||||
|
|
||||||
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
|
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把交付产物的完整性标签修正为 Medium(Windows)。
|
||||||
|
*
|
||||||
|
* 背景(踩过一次,代价是"客户端装不上、便捷版也双击没反应"):
|
||||||
|
* 本仓库目录树被宿主沙箱打上了 `Low Mandatory Level` 标签(可继承),
|
||||||
|
* 因此在仓库内构建出的 exe 自身也带 Low 标签。Windows 会把这类进程降到 Low 完整性,
|
||||||
|
* 于是 setup.exe 写不进 `%LOCALAPPDATA%`(报"拒绝访问"→ 装不上)、
|
||||||
|
* 便捷版解不出 `%TEMP%`(→ 起不来);即使装上了,Low 标签的应用文件也会让
|
||||||
|
* Chromium 的 AppContainer 子进程读不到(0x80000003 秒崩)。
|
||||||
|
*
|
||||||
|
* 要点:**只有交付物自身的标签要紧**。归档内部的载荷标签不会被还原
|
||||||
|
* (装到正常位置得到的是默认 Medium 文件),所以打包完成后再统一修正即可 ——
|
||||||
|
* 反过来说,不能在打包前把 appOutDir 改成 Medium:那样 7za(自身 Low)
|
||||||
|
* 会因"不能向上读/写"而无法生成归档。
|
||||||
|
*/
|
||||||
|
function normalizeIntegrityLabel(dir) {
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
|
||||||
|
try {
|
||||||
|
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
|
||||||
|
// 目录自身恢复为"继承父目录的 Low",保证下次构建时 Low 的 7za 仍能写入归档
|
||||||
|
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
|
||||||
|
console.log('[dist] 已修正交付物完整性标签为 Medium,目录恢复继承:', dir);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[dist] 完整性标签修正跳过:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
const output = path.join(root, 'release');
|
const output = path.join(root, 'release');
|
||||||
|
normalizeIntegrityLabel(output);
|
||||||
console.log('\n[dist] 打包完成,产物目录:', output);
|
console.log('\n[dist] 打包完成,产物目录:', output);
|
||||||
}
|
}
|
||||||
process.exit(code ?? 1);
|
process.exit(code ?? 1);
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ function launchSmoke(extraEnv) {
|
|||||||
ELECTRON_SMOKE_PROFILE: profileDir,
|
ELECTRON_SMOKE_PROFILE: profileDir,
|
||||||
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
|
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
|
||||||
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
|
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
|
||||||
|
// 教师凭据只从这里传入:主进程的冒烟代码从环境变量读,
|
||||||
|
// 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包)
|
||||||
|
ELECTRON_SMOKE_USER: process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
|
||||||
|
ELECTRON_SMOKE_PASSWORD: process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
|
||||||
...extraEnv,
|
...extraEnv,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
ISLAND_SHADOW_PAD,
|
ISLAND_SHADOW_PAD,
|
||||||
ISLAND_SIZE_DELTA,
|
ISLAND_SIZE_DELTA,
|
||||||
ISLAND_SLIVER_WIDTH,
|
ISLAND_SLIVER_WIDTH,
|
||||||
|
PRIORITY_RANK,
|
||||||
|
islandListLayout,
|
||||||
type IslandAppearance,
|
type IslandAppearance,
|
||||||
PRIORITY_LABELS,
|
PRIORITY_LABELS,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
type IslandListLayout,
|
||||||
type IslandNotification,
|
type IslandNotification,
|
||||||
type IslandNotificationKind,
|
type IslandNotificationKind,
|
||||||
type IslandState,
|
type IslandState,
|
||||||
@@ -39,9 +42,19 @@ const state = ref<IslandState>({
|
|||||||
inClass: false,
|
inClass: false,
|
||||||
currentPeriodEnd: null,
|
currentPeriodEnd: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
|
listExpanded: false,
|
||||||
|
maxCardHeight: 1024,
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染状态直接来自主进程(**与仓库版一致,不再有"收回"快照层**)。
|
||||||
|
*
|
||||||
|
* 早先这里有一层 `closing`/`closingSnapshot`:点"知道了 / 标为已读"后把卡片按"胶囊摘要"收回再淡出。
|
||||||
|
* 那套是自研的形变状态机,实测在触摸屏(窗口贴合岛体、形变时要动窗口)上会露出旧帧/重影,
|
||||||
|
* 用户明确要求"动画就用仓库里那套"—— 于是整段删掉:收起一律走主进程的 `hide()`(窗口淡出),
|
||||||
|
* 展开/收缩仍是弹簧形变(仓库机制,见 targetSize/progress)。
|
||||||
|
*/
|
||||||
const bridge = window.island;
|
const bridge = window.island;
|
||||||
|
|
||||||
const notification = computed<IslandNotification | null>(() => state.value.active);
|
const notification = computed<IslandNotification | null>(() => state.value.active);
|
||||||
@@ -52,6 +65,48 @@ const isCall = computed(() => kind.value === 'call');
|
|||||||
const isHomework = computed(() => kind.value === 'homework');
|
const isHomework = computed(() => kind.value === 'homework');
|
||||||
const queueCount = computed(() => state.value.queued.length);
|
const queueCount = computed(() => state.value.queued.length);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。
|
||||||
|
* 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。
|
||||||
|
*/
|
||||||
|
const pendingNotifications = computed<IslandNotification[]>(() => {
|
||||||
|
const list: IslandNotification[] = [];
|
||||||
|
if (state.value.active) list.push(state.value.active);
|
||||||
|
for (const item of state.value.queued) {
|
||||||
|
if (!list.some((existing) => existing.id === item.id)) list.push(item);
|
||||||
|
}
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
const rank = (PRIORITY_RANK[b.priority] ?? 0) - (PRIORITY_RANK[a.priority] ?? 0);
|
||||||
|
if (rank !== 0) return rank;
|
||||||
|
return String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingCount = computed(() => pendingNotifications.value.length);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多条消息时的**列表布局**:显示几条、要不要"展开更多"、卡片多高。
|
||||||
|
* 与主进程用同一个 `islandListLayout`(同一份度量)—— 主进程据此把窗口长高,两边必须算出一个数。
|
||||||
|
*/
|
||||||
|
const listLayout = computed<IslandListLayout | null>(() => {
|
||||||
|
if (mode.value !== 'expanded' || pendingCount.value < 2) return null;
|
||||||
|
return islandListLayout({
|
||||||
|
count: pendingCount.value,
|
||||||
|
fontSize: appearance.value.fontSize,
|
||||||
|
maxHeight: state.value.maxCardHeight,
|
||||||
|
expanded: state.value.listExpanded,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const isListMode = computed(() => listLayout.value !== null);
|
||||||
|
/** 列表里实际渲染出来的通知 */
|
||||||
|
const listItems = computed<IslandNotification[]>(() =>
|
||||||
|
listLayout.value ? pendingNotifications.value.slice(0, listLayout.value.rows) : [],
|
||||||
|
);
|
||||||
|
/** 没能展示出来的条数(点"展开更多"能看到一部分,剩下的只能去应用里看) */
|
||||||
|
const hiddenCount = computed(() => Math.max(0, pendingCount.value - listItems.value.length));
|
||||||
|
const listHint = computed<IslandListLayout['hint']>(() => listLayout.value?.hint ?? null);
|
||||||
|
|
||||||
/** 胶囊标题:按消息类型区分(新消息 / 新作业 / 叫人) */
|
/** 胶囊标题:按消息类型区分(新消息 / 新作业 / 叫人) */
|
||||||
const pillTitle = computed(() => {
|
const pillTitle = computed(() => {
|
||||||
if (isCall.value) return '老师叫你';
|
if (isCall.value) return '老师叫你';
|
||||||
@@ -72,23 +127,12 @@ const TYPE_LABELS: Record<IslandNotificationKind, string> = {
|
|||||||
/** 汇总展示顺序:叫人 > 作业 > 通知(与用户示例一致) */
|
/** 汇总展示顺序:叫人 > 作业 > 通知(与用户示例一致) */
|
||||||
const TYPE_ORDER: IslandNotificationKind[] = ['call', 'homework', 'notification'];
|
const TYPE_ORDER: IslandNotificationKind[] = ['call', 'homework', 'notification'];
|
||||||
|
|
||||||
const pendingNotifications = computed<IslandNotification[]>(() => {
|
|
||||||
const list: IslandNotification[] = [];
|
|
||||||
if (state.value.active) list.push(state.value.active);
|
|
||||||
for (const item of state.value.queued) {
|
|
||||||
if (!list.some((existing) => existing.id === item.id)) list.push(item);
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 这批未处理消息覆盖了哪些类型(按"叫人/作业/通知"顺序) */
|
/** 这批未处理消息覆盖了哪些类型(按"叫人/作业/通知"顺序) */
|
||||||
const pendingTypes = computed<string[]>(() => {
|
const pendingTypes = computed<string[]>(() => {
|
||||||
const kinds = new Set(pendingNotifications.value.map((item) => item.kind ?? 'notification'));
|
const kinds = new Set(pendingNotifications.value.map((item) => item.kind ?? 'notification'));
|
||||||
return TYPE_ORDER.filter((item) => kinds.has(item)).map((item) => TYPE_LABELS[item]);
|
return TYPE_ORDER.filter((item) => kinds.has(item)).map((item) => TYPE_LABELS[item]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingCount = computed(() => pendingNotifications.value.length);
|
|
||||||
|
|
||||||
/** 多条消息时把类型摊开:`新消息:叫人/作业/通知(共 3 条)` */
|
/** 多条消息时把类型摊开:`新消息:叫人/作业/通知(共 3 条)` */
|
||||||
const pillSummaryTitle = computed(() => {
|
const pillSummaryTitle = computed(() => {
|
||||||
if (pendingCount.value <= 1) return pillTitle.value;
|
if (pendingCount.value <= 1) return pillTitle.value;
|
||||||
@@ -122,6 +166,45 @@ const subtitle = computed(() => {
|
|||||||
/** 叫人消息下方的附加说明(例如"请到办公室找我") */
|
/** 叫人消息下方的附加说明(例如"请到办公室找我") */
|
||||||
const callHint = computed(() => notification.value?.subtitle ?? '');
|
const callHint = computed(() => notification.value?.subtitle ?? '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否显示「标为已读」。
|
||||||
|
*
|
||||||
|
* 作业卡片的 id 是本地合成的 `homework-<作业id>`(见 renderer/island/bridge.ts),
|
||||||
|
* 库里并没有这条通知,点它会让通知中心去调 `POST /notifications/homework-xxx/read` 并弹「通知不存在」。
|
||||||
|
* 因此作业卡不提供该按钮(它本来也不是"通知",用「知道了」收起即可)。
|
||||||
|
* 列表形态下只要**至少有一条真通知**就提供(作业行会被主进程按 kind 过滤掉)。
|
||||||
|
*/
|
||||||
|
const canMarkRead = computed(() => {
|
||||||
|
if (isListMode.value) {
|
||||||
|
return pendingNotifications.value.some((item) => item.kind !== 'homework');
|
||||||
|
}
|
||||||
|
return notification.value?.kind !== 'homework';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 列表里单行的类型(徽标/图标配色) */
|
||||||
|
function rowKind(item: IslandNotification): IslandNotificationKind {
|
||||||
|
return item.kind ?? 'notification';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowBadgeText(item: IslandNotification): string {
|
||||||
|
const itemKind = rowKind(item);
|
||||||
|
if (itemKind === 'call') return '叫人';
|
||||||
|
if (itemKind === 'homework') return '新作业';
|
||||||
|
return PRIORITY_LABELS[item.priority] ?? item.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowBadgeClass(item: IslandNotification): string {
|
||||||
|
const itemKind = rowKind(item);
|
||||||
|
if (itemKind === 'call') return 'badge-call';
|
||||||
|
if (itemKind === 'homework') return 'badge-homework';
|
||||||
|
return `badge-${item.priority.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowSubText(item: IslandNotification): string {
|
||||||
|
const teacher = item.teacherName ? `${item.teacherName} · ` : '';
|
||||||
|
return `${teacher}${formatDate(item.createdAt, true)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 下课补发时提示"其实上课期间就到了" */
|
/** 下课补发时提示"其实上课期间就到了" */
|
||||||
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
|
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
|
||||||
|
|
||||||
@@ -150,10 +233,20 @@ function onCardClick(event: MouseEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dismiss(): void {
|
function dismiss(): void {
|
||||||
bridge?.sendAction('dismiss');
|
// 多条消息时"知道了"是**整批**处理:卡片上就一排按钮,点它就是把这批都放下去
|
||||||
|
bridge?.sendAction(isListMode.value ? 'dismiss-all' : 'dismiss');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "展开更多":把列表铺到屏幕放得下的程度(主进程负责把窗口长高) */
|
||||||
|
function expandList(): void {
|
||||||
|
bridge?.sendAction('expand-list');
|
||||||
}
|
}
|
||||||
|
|
||||||
function markRead(): void {
|
function markRead(): void {
|
||||||
|
if (isListMode.value) {
|
||||||
|
bridge?.sendAction('mark-all-read');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (notification.value) bridge?.sendAction('mark-read', notification.value.id);
|
if (notification.value) bridge?.sendAction('mark-read', notification.value.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +277,9 @@ function targetSize(): { width: number; height: number } {
|
|||||||
return { width: ISLAND_SLIVER_WIDTH, height: Math.max(12, Math.min(28, height - 22)) };
|
return { width: ISLAND_SLIVER_WIDTH, height: Math.max(12, Math.min(28, height - 22)) };
|
||||||
}
|
}
|
||||||
if (mode.value !== 'expanded') return { width, height };
|
if (mode.value !== 'expanded') return { width, height };
|
||||||
|
// 多条消息的列表:高度由共享的列表布局算出(与主进程算的窗口包围盒同一个数)
|
||||||
|
const layout = listLayout.value;
|
||||||
|
if (layout) return { width: width + EXTRA.expanded.width, height: layout.height };
|
||||||
const extra = isCall.value ? EXTRA.call : isUrgent.value ? EXTRA.urgent : EXTRA.expanded;
|
const extra = isCall.value ? EXTRA.call : isUrgent.value ? EXTRA.urgent : EXTRA.expanded;
|
||||||
return { width: width + extra.width, height: height + extra.height };
|
return { width: width + extra.width, height: height + extra.height };
|
||||||
}
|
}
|
||||||
@@ -324,6 +420,12 @@ const expandedAlpha = computed(() => progress.value * progress.value);
|
|||||||
* 岛在**固定窗口**内的定位(照搬 WinIsland:窗口不动,岛在画布内居中/贴边形变)。
|
* 岛在**固定窗口**内的定位(照搬 WinIsland:窗口不动,岛在画布内居中/贴边形变)。
|
||||||
* 水平:居中锚点用 `left: 50% + translateX(-50%)`;左/右锚点贴边(留阴影余量)。
|
* 水平:居中锚点用 `left: 50% + translateX(-50%)`;左/右锚点贴边(留阴影余量)。
|
||||||
*/
|
*/
|
||||||
|
/** 水平锚点(供 CSS 决定胶囊内容贴哪条边:左贴左、右贴右、居中则居中对齐) */
|
||||||
|
const anchorName = computed<'left' | 'center' | 'right'>(() => {
|
||||||
|
const position = appearance.value.position;
|
||||||
|
return position.endsWith('left') ? 'left' : position.endsWith('right') ? 'right' : 'center';
|
||||||
|
});
|
||||||
|
|
||||||
const islandStyle = computed(() => {
|
const islandStyle = computed(() => {
|
||||||
const position = appearance.value.position;
|
const position = appearance.value.position;
|
||||||
const style: Record<string, string> = {
|
const style: Record<string, string> = {
|
||||||
@@ -484,6 +586,18 @@ function applyAppearance(next: IslandAppearance | null | undefined): void {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
|
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
|
||||||
|
/**
|
||||||
|
* 触摸屏上报:主进程据此把窗口改为**贴合岛体**并始终接收输入。
|
||||||
|
*
|
||||||
|
* 为什么必须报:触摸屏上"点不开"是机制性的 —— 打开命中的两条链路(本进程的 mousemove 转发、
|
||||||
|
* 主进程读系统光标)都以"鼠标指针移动"为前提,而手指触摸既不产生 mousemove、也不移动系统光标,
|
||||||
|
* 窗口会永远停在 `setIgnoreMouseEvents(true)` 的穿透态,手指点下去只落到桌面。
|
||||||
|
* Chromium 在带触摸数字化仪的机器上会把 `maxTouchPoints` 置为非 0,用它当判据。
|
||||||
|
*/
|
||||||
|
const reportTouchMode = (): void => bridge?.setTouchMode?.(true);
|
||||||
|
if ((navigator.maxTouchPoints ?? 0) > 0) reportTouchMode();
|
||||||
|
// 兜底:个别设备 maxTouchPoints 判定不到(或接的是外接触摸屏),真收到触摸事件时再补报一次
|
||||||
|
window.addEventListener('touchstart', reportTouchMode, { passive: true, once: true });
|
||||||
// 心跳:主进程据此判断本渲染进程是否还活着。一旦卡死/崩溃,主进程会隐藏这扇"幽灵窗口"
|
// 心跳:主进程据此判断本渲染进程是否还活着。一旦卡死/崩溃,主进程会隐藏这扇"幽灵窗口"
|
||||||
// 并重建(否则 Windows 会把最后一帧留在屏幕上:岛看着还在,怎么点都没反应)。
|
// 并重建(否则 Windows 会把最后一帧留在屏幕上:岛看着还在,怎么点都没反应)。
|
||||||
bridge?.alive?.();
|
bridge?.alive?.();
|
||||||
@@ -516,6 +630,7 @@ onMounted(async () => {
|
|||||||
<div
|
<div
|
||||||
class="island-card"
|
class="island-card"
|
||||||
:class="islandClass"
|
:class="islandClass"
|
||||||
|
:data-anchor="anchorName"
|
||||||
:data-style="appearance.style"
|
:data-style="appearance.style"
|
||||||
:style="islandStyle"
|
:style="islandStyle"
|
||||||
@click="mode === 'expanded' ? onCardClick($event) : expand()"
|
@click="mode === 'expanded' ? onCardClick($event) : expand()"
|
||||||
@@ -540,6 +655,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- 胶囊层(WinIsland compact):进度 > 2/3 时完全退净 -->
|
<!-- 胶囊层(WinIsland compact):进度 > 2/3 时完全退净 -->
|
||||||
<span class="layer pill-layer" :style="{ opacity: pillAlpha }">
|
<span class="layer pill-layer" :style="{ opacity: pillAlpha }">
|
||||||
|
<!--
|
||||||
|
胶囊内容固定成"胶囊自己的几何"(宽 = 外观宽度、贴合停靠边/中线):
|
||||||
|
卡片在开合时会变宽变窄,若让内容跟着卡片宽度走,文字会先按宽卡片铺开、
|
||||||
|
再随卡片变窄被省略号收回(用户反馈的"新消息那行字往右跳一下再缩回")。
|
||||||
|
-->
|
||||||
|
<span class="pill-inner">
|
||||||
<span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
|
<span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
|
||||||
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
|
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
@@ -583,11 +704,19 @@ onMounted(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- 展开层(WinIsland expanded):进度²淡入 -->
|
<!-- 展开层(WinIsland expanded):进度²淡入 -->
|
||||||
<span class="layer expanded-layer" :style="{ opacity: expandedAlpha }">
|
<span
|
||||||
|
class="layer expanded-layer"
|
||||||
|
:class="{ 'list-mode': isListMode }"
|
||||||
|
:style="{ opacity: expandedAlpha }"
|
||||||
|
>
|
||||||
<header class="head">
|
<header class="head">
|
||||||
<span class="head-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
|
<span
|
||||||
|
class="head-icon"
|
||||||
|
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, list: isListMode }"
|
||||||
|
>
|
||||||
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
|
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -608,11 +737,19 @@ onMounted(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div class="head-meta">
|
<div class="head-meta">
|
||||||
<span class="badge" :class="badgeClass">{{ badgeText }}</span>
|
<span class="badge" :class="isListMode ? 'badge-normal' : badgeClass">
|
||||||
|
{{ isListMode ? `共 ${pendingCount} 条` : badgeText }}
|
||||||
|
</span>
|
||||||
|
<template v-if="isListMode">
|
||||||
|
<span v-if="pendingTypes.length > 1" class="head-sub">{{ pendingTypes.join(' / ') }}</span>
|
||||||
|
<span v-else class="head-sub">按重要程度排列</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<span v-if="isCall" class="live-dot call">老师正在等你</span>
|
<span v-if="isCall" class="live-dot call">老师正在等你</span>
|
||||||
<span v-else-if="isUrgent" class="live-dot">需立即查看</span>
|
<span v-else-if="isUrgent" class="live-dot">需立即查看</span>
|
||||||
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
|
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
|
||||||
<span class="head-sub">{{ subtitle }}</span>
|
<span class="head-sub">{{ subtitle }}</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="icon-btn" title="收起" @click.stop="collapse">
|
<button type="button" class="icon-btn" title="收起" @click.stop="collapse">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -627,21 +764,75 @@ onMounted(async () => {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 多条消息:竖向排列,按重要程度排序,默认只显示前三条 -->
|
||||||
|
<template v-if="isListMode">
|
||||||
|
<h3 class="title">{{ pendingCount }} 条待处理通知</h3>
|
||||||
|
<div class="body list-body">
|
||||||
|
<button
|
||||||
|
v-for="item in listItems"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="list-row"
|
||||||
|
:title="`打开应用查看:${item.title}`"
|
||||||
|
@click.stop="openApp"
|
||||||
|
>
|
||||||
|
<span class="row-icon" :class="rowKind(item)">
|
||||||
|
<svg v-if="rowKind(item) === 'homework'" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="rowKind(item) === 'call'" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="row-text">
|
||||||
|
<span class="row-title">{{ item.title }}</span>
|
||||||
|
<span class="row-sub">{{ rowSubText(item) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-badge" :class="rowBadgeClass(item)">{{ rowBadgeText(item) }}</span>
|
||||||
|
</button>
|
||||||
|
<!-- 还有没显示出来的:能展开就"展开更多",屏幕到任务栏了就只能去应用里看 -->
|
||||||
|
<div v-if="listHint" class="list-hint">
|
||||||
|
<button v-if="listHint === 'more'" type="button" class="more-btn" @click.stop="expandList">
|
||||||
|
展开更多(还有 {{ hiddenCount }} 条)
|
||||||
|
</button>
|
||||||
|
<span v-else class="more-app">更多请前往应用内操作</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<h3 class="title">{{ notification?.title ?? '' }}</h3>
|
<h3 class="title">{{ notification?.title ?? '' }}</h3>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<p class="content-text">{{ notification?.content ?? '' }}</p>
|
<p class="content-text">{{ notification?.content ?? '' }}</p>
|
||||||
<p v-if="callHint" class="call-hint">{{ callHint }}</p>
|
<p v-if="callHint" class="call-hint">{{ callHint }}</p>
|
||||||
<p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p>
|
<p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 一排按钮(多条消息时也是这一排):展开态的操作都收在这里 -->
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
<span v-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
|
<span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span>
|
||||||
|
<span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
|
||||||
<span v-else class="more muted">来自班级小助手</span>
|
<span v-else class="more muted">来自班级小助手</span>
|
||||||
<span class="actions">
|
<span class="actions">
|
||||||
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
|
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
|
||||||
<button type="button" class="ghost-btn" @click.stop="markRead">标为已读</button>
|
<button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead">
|
||||||
|
标为已读
|
||||||
|
</button>
|
||||||
<button type="button" class="solid-btn" @click.stop="dismiss">
|
<button type="button" class="solid-btn" @click.stop="dismiss">
|
||||||
{{ isCall ? '收到' : '知道了' }}
|
{{ isCall && !isListMode ? '收到' : '知道了' }}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -743,6 +934,16 @@ body {
|
|||||||
fill: var(--wn-bg);
|
fill: var(--wn-bg);
|
||||||
stroke: var(--wn-border);
|
stroke: var(--wn-border);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
|
/*
|
||||||
|
投影**始终声明**,形态切换只改参数 —— 不要写成"只在 .expanded 上挂 filter"。
|
||||||
|
给元素加 / 去 CSS filter 会让 Chromium 新建或销毁它的渲染表面(effect node),
|
||||||
|
而首帧的合成可能发生在新表面栅格化完成之前,那一帧会被当作空内容画出去;
|
||||||
|
卡片的底色恰恰就画在这个被过滤的元素上(fill 在本元素),于是**开/合的那一瞬间
|
||||||
|
底座会闪掉一帧、只剩未过滤的文字层**(用户反馈的"开合时一瞬间的闪动")。
|
||||||
|
胶囊态用 0 0 0:硬轮廓投影正好压在路径底下,肉眼等同无投影,与 WinIsland
|
||||||
|
"只有展开态有投影"的观感一致。
|
||||||
|
*/
|
||||||
|
filter: drop-shadow(0 var(--card-shadow-y, 0px) var(--card-shadow-blur, 0px) rgba(0, 0, 0, 0.11));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */
|
/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */
|
||||||
@@ -767,9 +968,11 @@ body {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px) */
|
/* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px)。
|
||||||
|
只改参数、不动 filter 声明本身,理由见上面的 .shape path。 */
|
||||||
.island-card.expanded .shape path {
|
.island-card.expanded .shape path {
|
||||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.11));
|
--card-shadow-y: 2px;
|
||||||
|
--card-shadow-blur: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 状态色:只做"内层微染 + 描边",不外溢(保持桌面干净,也不会有光晕硬边) */
|
/* 状态色:只做"内层微染 + 描边",不外溢(保持桌面干净,也不会有光晕硬边) */
|
||||||
@@ -809,10 +1012,40 @@ body {
|
|||||||
.pill-layer {
|
.pill-layer {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
/* 水平对齐交给 .pill-inner(它固定为胶囊几何,且按停靠方式贴边/居中) */
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 胶囊内容盒:**固定为胶囊自己的宽高与内边距**,不随卡片形变重排。
|
||||||
|
*
|
||||||
|
* 为什么必须固定:卡片在开合时 268 ⇄ 424 变宽变窄,而中心停靠下卡片的左右边都会动。
|
||||||
|
* 内容若跟着卡片宽度走,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
|
||||||
|
* (用户反馈的"新消息那行字往右跳一下再缩回"),底部也会被收缩的形状裁掉一块。
|
||||||
|
* 固定成胶囊几何后:文字在整段动画里位置与省略号都不变,只是随卡片一起淡出/淡入。
|
||||||
|
*
|
||||||
|
* 贴哪条边与卡片停靠方式一致(左停靠贴左、右停靠贴右、居中则居中对齐)——
|
||||||
|
* 这样卡片变宽时内容也不会横移,静止态外观与之前完全一致。
|
||||||
|
*/
|
||||||
|
.pill-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
gap: calc(var(--island-font, 13px) * 0.62);
|
gap: calc(var(--island-font, 13px) * 0.62);
|
||||||
|
width: var(--island-w, 268px);
|
||||||
|
height: 100%;
|
||||||
padding: 0 calc(var(--island-font, 13px) * 0.78);
|
padding: 0 calc(var(--island-font, 13px) * 0.78);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.island-card[data-anchor='center'] .pill-layer {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-card[data-anchor='right'] .pill-layer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.pill-icon {
|
.pill-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -912,6 +1145,166 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 多条通知的列表形态。
|
||||||
|
*
|
||||||
|
* 这里的每个尺寸都必须与 @classhelper/shared 的 `ISLAND_LIST_METRICS` 一一对应:
|
||||||
|
* 主进程按那份度量算出**窗口包围盒**(列表比普通展开卡高,窗口不够高会把底部按钮裁掉),
|
||||||
|
* 渲染进程按同一份度量排布。改这里就必须改那里,否则会出现"最后一行被切掉"。
|
||||||
|
* 选择器一律写成 `.expanded-layer.list-mode ...`(比基类的单类选择器更具体),
|
||||||
|
* 免得被后面定义的 `.title` / `.body` 覆盖掉尺寸——那样高度就对不上算出来的值了。
|
||||||
|
*/
|
||||||
|
.expanded-layer.list-mode {
|
||||||
|
/* 高度已经算准,改 flex-start:居中排版一旦有像素级误差会把上下两端同时切掉 */
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-layer.list-mode .head {
|
||||||
|
height: calc(var(--island-font, 13px) * 1.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-layer.list-mode .title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: block;
|
||||||
|
height: calc(var(--island-font, 13px) * 1.4);
|
||||||
|
line-height: 1.4;
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-layer.list-mode .body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
gap: calc(var(--island-font, 13px) * 0.4);
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-icon.list {
|
||||||
|
background: color-mix(in srgb, var(--island-accent) 20%, transparent);
|
||||||
|
color: var(--island-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--island-font, 13px) * 0.55);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(var(--island-font, 13px) * 2.7);
|
||||||
|
padding: 0 calc(var(--island-font, 13px) * 0.6);
|
||||||
|
border: none;
|
||||||
|
border-radius: calc(var(--island-font, 13px) * 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
color: var(--wn-text-1);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row:hover {
|
||||||
|
background: var(--wn-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: calc(var(--island-font, 13px) * 1.3);
|
||||||
|
height: calc(var(--island-font, 13px) * 1.3);
|
||||||
|
border-radius: calc(var(--island-font, 13px) * 0.36);
|
||||||
|
background: var(--wn-surface);
|
||||||
|
color: var(--wn-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon svg {
|
||||||
|
width: calc(var(--island-font, 13px) * 0.86);
|
||||||
|
height: calc(var(--island-font, 13px) * 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon.call {
|
||||||
|
color: #ff9f0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon.homework {
|
||||||
|
color: var(--island-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title {
|
||||||
|
font-size: calc(var(--island-font, 13px) * 0.9);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-sub {
|
||||||
|
font-size: calc(var(--island-font, 13px) * 0.68);
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--wn-text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: calc(var(--island-font, 13px) * 1.25);
|
||||||
|
padding: 0 calc(var(--island-font, 13px) * 0.5);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: calc(var(--island-font, 13px) * 0.68);
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表底部的提示行:能展开时是按钮,屏幕到任务栏了就是一句"去应用里看" */
|
||||||
|
.list-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: calc(var(--island-font, 13px) * 2.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--wn-surface);
|
||||||
|
color: var(--wn-text-1);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: calc(var(--island-font, 13px) * 0.5);
|
||||||
|
font-size: calc(var(--island-font, 13px) * 0.76);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-btn:hover {
|
||||||
|
background: var(--wn-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-app {
|
||||||
|
font-size: calc(var(--island-font, 13px) * 0.76);
|
||||||
|
color: var(--wn-text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.head {
|
.head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ClassIslandNotificationChannel,
|
type ClassIslandNotificationChannel,
|
||||||
type IslandAppearance,
|
type IslandAppearance,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
|
import { logger } from './logger.js';
|
||||||
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
|
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
|
||||||
|
|
||||||
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
|
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
|
||||||
@@ -35,6 +36,12 @@ interface PersistedConfig {
|
|||||||
notificationChannel?: ClassIslandNotificationChannel;
|
notificationChannel?: ClassIslandNotificationChannel;
|
||||||
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
|
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
|
||||||
homeworkPhrases?: string[];
|
homeworkPhrases?: string[];
|
||||||
|
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
|
||||||
|
onboardingDone?: boolean;
|
||||||
|
/** 界面主题(向后兼容:旧配置没有该字段用浅色) */
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
/** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */
|
||||||
|
sidebarCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: PersistedConfig = {
|
const DEFAULT_CONFIG: PersistedConfig = {
|
||||||
@@ -46,8 +53,16 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
|||||||
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
||||||
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||||
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
|
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
|
||||||
|
onboardingDone: false,
|
||||||
|
theme: 'light',
|
||||||
|
sidebarCollapsed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 主题白名单:非法值一律回落浅色 */
|
||||||
|
function normalizeTheme(input?: 'light' | 'dark'): 'light' | 'dark' {
|
||||||
|
return input === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 作业快捷短语的合法化:去空白、去重、限长限量。
|
* 作业快捷短语的合法化:去空白、去重、限长限量。
|
||||||
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
|
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
|
||||||
@@ -142,8 +157,15 @@ function encryptToken(token: string | null): { token: string | null; tokenEncryp
|
|||||||
return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true };
|
return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 某些环境下 DPAPI 不可用,降级为明文存储(仅本地开发场景)
|
// 见下方降级分支
|
||||||
}
|
}
|
||||||
|
// 降级为明文存储(DPAPI 不可用,一般只在本地开发环境出现)。
|
||||||
|
// 必须留下日志:明文 token 代表该班的全班身份,任何能读用户目录的进程/备份/同步盘都能拿到,
|
||||||
|
// 静默降级会让"为什么换个环境就得重新登录/凭据是怎么泄露的"无从查起。
|
||||||
|
logger.warn(
|
||||||
|
`系统加密存储不可用,登录令牌将以**明文**写入配置(${configFilePath()});` +
|
||||||
|
'如需加密请确认 Windows 凭据保护(DPAPI)可用',
|
||||||
|
);
|
||||||
return { token, tokenEncrypted: false };
|
return { token, tokenEncrypted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +186,7 @@ function readPersisted(): PersistedConfig {
|
|||||||
return {
|
return {
|
||||||
serverUrl:
|
serverUrl:
|
||||||
typeof parsed.serverUrl === 'string' && parsed.serverUrl
|
typeof parsed.serverUrl === 'string' && parsed.serverUrl
|
||||||
? parsed.serverUrl
|
? (normalizeServerUrl(parsed.serverUrl) ?? DEFAULT_CONFIG.serverUrl)
|
||||||
: DEFAULT_CONFIG.serverUrl,
|
: DEFAULT_CONFIG.serverUrl,
|
||||||
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,
|
||||||
@@ -173,6 +195,9 @@ function readPersisted(): PersistedConfig {
|
|||||||
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
||||||
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
||||||
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
|
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
|
||||||
|
onboardingDone: parsed.onboardingDone === true,
|
||||||
|
theme: normalizeTheme(parsed.theme),
|
||||||
|
sidebarCollapsed: parsed.sidebarCollapsed === true,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_CONFIG };
|
return { ...DEFAULT_CONFIG };
|
||||||
@@ -185,6 +210,25 @@ function writePersisted(config: PersistedConfig): void {
|
|||||||
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
|
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务器地址只接受 http/https 绝对地址。
|
||||||
|
*
|
||||||
|
* 渲染进程的输入框走 `normalizeServerUrl` 校验过,但 IPC(`saveConfig`)与手工改过的
|
||||||
|
* config.json 都会绕过它,而该值随后会作为 axios baseURL 与 Socket.IO 地址使用 ——
|
||||||
|
* 因此必须在**主进程这一侧也收口**,非法值一律丢弃(保留原值)。
|
||||||
|
*/
|
||||||
|
function normalizeServerUrl(input: string): string | null {
|
||||||
|
const trimmed = input.trim().replace(/\/+$/, '');
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
return trimmed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 读取配置(token 已解密),供渲染进程使用 */
|
/** 读取配置(token 已解密),供渲染进程使用 */
|
||||||
export function getConfig(): DesktopStoredConfig {
|
export function getConfig(): DesktopStoredConfig {
|
||||||
const persisted = readPersisted();
|
const persisted = readPersisted();
|
||||||
@@ -196,6 +240,9 @@ export function getConfig(): DesktopStoredConfig {
|
|||||||
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
||||||
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
||||||
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
|
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
|
||||||
|
onboardingDone: persisted.onboardingDone === true,
|
||||||
|
theme: normalizeTheme(persisted.theme),
|
||||||
|
sidebarCollapsed: persisted.sidebarCollapsed === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +251,9 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
|||||||
const persisted = readPersisted();
|
const persisted = readPersisted();
|
||||||
|
|
||||||
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
|
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
|
||||||
persisted.serverUrl = patch.serverUrl.trim().replace(/\/+$/, '');
|
const normalized = normalizeServerUrl(patch.serverUrl);
|
||||||
|
if (normalized) persisted.serverUrl = normalized;
|
||||||
|
else logger.warn(`忽略非法的服务器地址(只接受 http/https):${patch.serverUrl}`);
|
||||||
}
|
}
|
||||||
if (typeof patch.username === 'string') {
|
if (typeof patch.username === 'string') {
|
||||||
persisted.username = patch.username;
|
persisted.username = patch.username;
|
||||||
@@ -224,6 +273,17 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
|||||||
if (patch.notificationChannel !== undefined) {
|
if (patch.notificationChannel !== undefined) {
|
||||||
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
||||||
}
|
}
|
||||||
|
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
|
||||||
|
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
|
||||||
|
if (patch.onboardingDone === true) {
|
||||||
|
persisted.onboardingDone = true;
|
||||||
|
}
|
||||||
|
if (patch.theme !== undefined) {
|
||||||
|
persisted.theme = normalizeTheme(patch.theme);
|
||||||
|
}
|
||||||
|
if (patch.sidebarCollapsed !== undefined) {
|
||||||
|
persisted.sidebarCollapsed = patch.sidebarCollapsed === true;
|
||||||
|
}
|
||||||
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;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
import { BrowserWindow, app, shell } from 'electron';
|
import { BrowserWindow, app, shell } from 'electron';
|
||||||
|
import { getConfig } from './config.js';
|
||||||
import { registerIpcHandlers } from './ipc.js';
|
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';
|
||||||
@@ -23,17 +25,45 @@ let mainWindow: BrowserWindow | null = null;
|
|||||||
/** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */
|
/** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
|
|
||||||
|
/** 生产环境下主窗口加载的本页地址(用于导航白名单的精确比对) */
|
||||||
|
const prodRendererUrl = pathToFileURL(path.join(currentDir, '../renderer/index.html')).href;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许窗口导航到这个地址。
|
||||||
|
*
|
||||||
|
* 为什么不能用 `url.startsWith(...)`:
|
||||||
|
* - 开发态 `url.startsWith(devServerUrl)` 没有主机边界,`http://localhost:5174.evil.com` 也会被放行;
|
||||||
|
* - 生产态 `url.startsWith('file://')` 等于放行**任意本地页面**。
|
||||||
|
* 而 preload 是挂在窗口上的(不是挂在 URL 上):一旦窗口被导航到别处,那个页面照样持有
|
||||||
|
* `window.desktop`(含 `getConfig` 返回的明文 token)。因此这里按"精确同源 / 精确同一文件"比对。
|
||||||
|
*/
|
||||||
|
function isAllowedNavigation(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const target = new URL(url);
|
||||||
|
if (devServerUrl) return target.origin === new URL(devServerUrl).origin;
|
||||||
|
target.hash = '';
|
||||||
|
target.search = '';
|
||||||
|
return target.href === prodRendererUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(): BrowserWindow {
|
||||||
|
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
|
||||||
|
const theme = getConfig().theme;
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1200,
|
// 初始尺寸与 ClassIsland 主窗口对齐(实测其可视区 1242x582),保持同一观感
|
||||||
height: 800,
|
width: 1242,
|
||||||
minWidth: 980,
|
height: 582,
|
||||||
minHeight: 640,
|
minWidth: 1000,
|
||||||
|
minHeight: 540,
|
||||||
|
center: true,
|
||||||
title: '班级小助手',
|
title: '班级小助手',
|
||||||
// 冒烟验证时不弹窗,避免打扰使用者
|
// 冒烟验证时不弹窗,避免打扰使用者
|
||||||
show: !isSmokeTest,
|
show: !isSmokeTest,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
backgroundColor: '#f5f7fa',
|
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(currentDir, '../preload/index.js'),
|
preload: path.join(currentDir, '../preload/index.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -49,11 +79,10 @@ function createWindow(): BrowserWindow {
|
|||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
});
|
});
|
||||||
win.webContents.on('will-navigate', (event, url) => {
|
win.webContents.on('will-navigate', (event, url) => {
|
||||||
const allowed = devServerUrl ? url.startsWith(devServerUrl) : url.startsWith('file://');
|
if (isAllowedNavigation(url)) return;
|
||||||
if (!allowed) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||||||
}
|
logger.warn(`主窗口拒绝了页面导航:${url}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
@@ -106,6 +135,11 @@ if (!gotLock) {
|
|||||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
mainWindow.webContents.send('island:mark-read', id);
|
mainWindow.webContents.send('island:mark-read', id);
|
||||||
});
|
});
|
||||||
|
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
|
||||||
|
island.setMarkAllReadHandler((ids) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
mainWindow.webContents.send('island:mark-all-read', ids);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('灵动岛初始化失败(不影响主功能)', error);
|
logger.error('灵动岛初始化失败(不影响主功能)', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function registerIpcHandlers(): void {
|
|||||||
node: process.versions.node ?? '',
|
node: process.versions.node ?? '',
|
||||||
platform: `${process.platform} ${process.arch}`,
|
platform: `${process.platform} ${process.arch}`,
|
||||||
userDataPath: app.getPath('userData'),
|
userDataPath: app.getPath('userData'),
|
||||||
|
configPath: getConfigPath(),
|
||||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
import { BrowserWindow, ipcMain, screen } from 'electron';
|
import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||||
import {
|
import {
|
||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
@@ -7,7 +8,10 @@ import {
|
|||||||
ISLAND_SIZE_DELTA,
|
ISLAND_SIZE_DELTA,
|
||||||
ISLAND_SLIVER_WIDTH,
|
ISLAND_SLIVER_WIDTH,
|
||||||
ISLAND_STYLES,
|
ISLAND_STYLES,
|
||||||
|
PRIORITY_RANK,
|
||||||
|
islandListLayout,
|
||||||
type IslandAppearance,
|
type IslandAppearance,
|
||||||
|
type IslandListLayout,
|
||||||
type IslandMode,
|
type IslandMode,
|
||||||
type IslandNotification,
|
type IslandNotification,
|
||||||
type IslandState,
|
type IslandState,
|
||||||
@@ -15,6 +19,26 @@ import {
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { getConfig } from './config.js';
|
import { getConfig } from './config.js';
|
||||||
|
|
||||||
|
/** 生产环境的灵动岛页面地址(导航白名单的精确比对用) */
|
||||||
|
const prodIslandUrl = pathToFileURL(path.join(__dirname, '../renderer/island.html')).href;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许灵动岛窗口导航到这个地址。
|
||||||
|
* 与主窗口同一套理由:preload 挂在窗口上而不是 URL 上,被导航走就等于把 `window.desktop`
|
||||||
|
* 交给了一个陌生页面;而灵动岛还是置顶窗口,陌生页面会停在屏幕最上层。
|
||||||
|
*/
|
||||||
|
function isAllowedIslandNavigation(url: string, devUrl: string | null): boolean {
|
||||||
|
try {
|
||||||
|
const target = new URL(url);
|
||||||
|
if (devUrl) return target.origin === new URL(devUrl).origin;
|
||||||
|
target.hash = '';
|
||||||
|
target.search = '';
|
||||||
|
return target.href === prodIslandUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 灵动岛(Dynamic Island)主进程控制器。
|
* 灵动岛(Dynamic Island)主进程控制器。
|
||||||
*
|
*
|
||||||
@@ -102,7 +126,18 @@ export interface IslandClassStatePayload {
|
|||||||
week?: number;
|
week?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IslandActionName = 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app';
|
type IslandActionName =
|
||||||
|
| 'expand'
|
||||||
|
| 'collapse'
|
||||||
|
/** 多条通知时点"展开更多":把列表铺到屏幕放得下的程度 */
|
||||||
|
| 'expand-list'
|
||||||
|
| 'dismiss'
|
||||||
|
/** 多条通知时点"知道了":整批关闭,但不改通知中心的已读状态 */
|
||||||
|
| 'dismiss-all'
|
||||||
|
| 'mark-read'
|
||||||
|
/** 多条通知时点"标为已读":整批已读并关闭 */
|
||||||
|
| 'mark-all-read'
|
||||||
|
| 'open-app';
|
||||||
|
|
||||||
interface IslandActionPayload {
|
interface IslandActionPayload {
|
||||||
action: IslandActionName;
|
action: IslandActionName;
|
||||||
@@ -127,12 +162,35 @@ class IslandController {
|
|||||||
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
|
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
|
||||||
private blurCollapseEnabled = true;
|
private blurCollapseEnabled = true;
|
||||||
private interactive = false;
|
private interactive = false;
|
||||||
|
/**
|
||||||
|
* 触摸模式:触摸屏机器上由渲染进程按 `navigator.maxTouchPoints` 上报(见 `setTouchMode`)。
|
||||||
|
*
|
||||||
|
* 为什么触摸屏必须单独一套:触摸屏上"点不动"是**机制性**的,不是偶发 —— 打开命中的两条链路
|
||||||
|
* (渲染进程的 mousemove 转发、主进程读系统光标位置)都以"鼠标指针移动"为前提,而手指触摸
|
||||||
|
* 既不产生 mousemove、也不移动系统光标。于是窗口永远停在 `setIgnoreMouseEvents(true)` 的
|
||||||
|
* 穿透态,手指点下去只落到桌面(用户反馈:希沃白板上触摸灵动岛无法展开)。
|
||||||
|
*
|
||||||
|
* 修法只有一条:**窗口在触摸点处必须可命中**。Electron 没有 SetWindowRgn 那种"窗口区域"能力,
|
||||||
|
* 所以改成在触摸模式下让窗口**贴合岛体**(岛体 + 阴影留白)并始终接收输入 —— 贴身后多出来的
|
||||||
|
* 只有投影所需的 10px 留白,不再吞掉桌面点击。
|
||||||
|
*/
|
||||||
|
private touchMode = false;
|
||||||
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
|
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
|
||||||
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
|
private hitRect: { x: number; y: number; width: number; height: number } | null = null;
|
||||||
/** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */
|
/** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */
|
||||||
private hitTimer: ReturnType<typeof setInterval> | null = null;
|
private hitTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
/** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */
|
/** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */
|
||||||
private cursorOverride: { x: number; y: number } | null = null;
|
private cursorOverride: { x: number; y: number } | null = null;
|
||||||
|
/**
|
||||||
|
* 冒烟/诊断用:让窗口**不接收真实鼠标**(真实点击穿透到桌面)。
|
||||||
|
*
|
||||||
|
* 为什么需要:自动化验证时长分钟计,而岛上那套命中逻辑是按"真实光标位置"开的 ——
|
||||||
|
* 若此时用户正在用同一块屏幕(他自己的客户端和冒烟窗口都在屏幕顶部居中、互相叠着),
|
||||||
|
* 他点到的是**冒烟这个窗口**,冒烟就会收到人的点击(实测:冒烟从未发出的 collapse / mark-read
|
||||||
|
* 混进了断言,把「点胶囊展开」「点空白处收起」等用例整片弄红)。
|
||||||
|
* 打开后所有命中请求一律按"穿透"处理;合成 DOM 点击(executeJavaScript)不受影响。
|
||||||
|
*/
|
||||||
|
private testInputPassthrough = false;
|
||||||
/** 上次按矩形立即校正命中的时刻(限流用) */
|
/** 上次按矩形立即校正命中的时刻(限流用) */
|
||||||
private lastHitSyncAt = 0;
|
private lastHitSyncAt = 0;
|
||||||
/** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */
|
/** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */
|
||||||
@@ -147,7 +205,10 @@ class IslandController {
|
|||||||
private recovering = false;
|
private recovering = false;
|
||||||
/** 最近的窗口重建时刻(限流用) */
|
/** 最近的窗口重建时刻(限流用) */
|
||||||
private recoverAt: number[] = [];
|
private recoverAt: number[] = [];
|
||||||
|
/** 上一次计算锚点所依据的显示器 id:用于发现"光标换屏 / 显示器插拔"并重排窗口 */
|
||||||
|
private anchorDisplayId: number | null = null;
|
||||||
|
/** 展开卡可用高度上限(CSS px):由工作区/停靠位置算出,随状态下发给渲染进程 */
|
||||||
|
private maxCardHeight = 1024;
|
||||||
private state: IslandState = {
|
private state: IslandState = {
|
||||||
mode: 'hidden',
|
mode: 'hidden',
|
||||||
active: null,
|
active: null,
|
||||||
@@ -155,6 +216,8 @@ class IslandController {
|
|||||||
inClass: false,
|
inClass: false,
|
||||||
currentPeriodEnd: null,
|
currentPeriodEnd: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
|
listExpanded: false,
|
||||||
|
maxCardHeight: 1024,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,6 +227,9 @@ class IslandController {
|
|||||||
/** 灵动岛点"标为已读"时交给外部(主窗口渲染进程)同步通知中心 */
|
/** 灵动岛点"标为已读"时交给外部(主窗口渲染进程)同步通知中心 */
|
||||||
private markReadHandler: ((id: string) => void) | null = null;
|
private markReadHandler: ((id: string) => void) | null = null;
|
||||||
|
|
||||||
|
/** 灵动岛一次"标为已读"要标记的多条通知(多条列表时的整批已读) */
|
||||||
|
private markAllReadHandler: ((ids: string[]) => void) | null = null;
|
||||||
|
|
||||||
setOpenAppHandler(handler: () => void): void {
|
setOpenAppHandler(handler: () => void): void {
|
||||||
this.openAppHandler = handler;
|
this.openAppHandler = handler;
|
||||||
}
|
}
|
||||||
@@ -172,6 +238,10 @@ class IslandController {
|
|||||||
this.markReadHandler = handler;
|
this.markReadHandler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMarkAllReadHandler(handler: (ids: string[]) => void): void {
|
||||||
|
this.markAllReadHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
async init(rendererUrl: string | null): Promise<void> {
|
async init(rendererUrl: string | null): Promise<void> {
|
||||||
this.rendererUrl = rendererUrl;
|
this.rendererUrl = rendererUrl;
|
||||||
// 恢复用户保存的个性化外观(尺寸/位置/透明度/动画…),失败则用默认值
|
// 恢复用户保存的个性化外观(尺寸/位置/透明度/动画…),失败则用默认值
|
||||||
@@ -181,6 +251,7 @@ class IslandController {
|
|||||||
this.appearance = { ...DEFAULT_ISLAND_APPEARANCE };
|
this.appearance = { ...DEFAULT_ISLAND_APPEARANCE };
|
||||||
}
|
}
|
||||||
recomputeSizes(this.appearance);
|
recomputeSizes(this.appearance);
|
||||||
|
this.refreshViewport();
|
||||||
if (this.win && !this.win.isDestroyed()) return;
|
if (this.win && !this.win.isDestroyed()) return;
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
@@ -219,6 +290,13 @@ class IslandController {
|
|||||||
|
|
||||||
win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
||||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||||
|
// 拒绝一切导航与新开窗口(理由见 isAllowedIslandNavigation)
|
||||||
|
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||||
|
win.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (isAllowedIslandNavigation(url, this.rendererUrl)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
logger.warn(`灵动岛拒绝了页面导航:${url}`);
|
||||||
|
});
|
||||||
// 注意:这里**不能**调用 `win.setBackgroundMaterial()`(亚克力/mica),
|
// 注意:这里**不能**调用 `win.setBackgroundMaterial()`(亚克力/mica),
|
||||||
// 见 `applyBackgroundMaterial` 移除处的说明。
|
// 见 `applyBackgroundMaterial` 移除处的说明。
|
||||||
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
|
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
|
||||||
@@ -254,7 +332,10 @@ class IslandController {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sinceActivated < BLUR_GRACE_MS) {
|
// 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
|
||||||
|
// 上面实测到的 70~520ms 抖动区间,否则手指点开后约 0.5s 会莫名缩回胶囊。
|
||||||
|
const grace = this.touchMode ? BLUR_GRACE_TOUCH_MS : BLUR_GRACE_MS;
|
||||||
|
if (sinceActivated < grace) {
|
||||||
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
|
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -360,13 +441,10 @@ class IslandController {
|
|||||||
): IslandNotification {
|
): IslandNotification {
|
||||||
if (!existing || existing.id !== incoming.id) return incoming;
|
if (!existing || existing.id !== incoming.id) return incoming;
|
||||||
const kind = existing.kind === 'call' || incoming.kind === 'call' ? 'call' : incoming.kind;
|
const kind = existing.kind === 'call' || incoming.kind === 'call' ? 'call' : incoming.kind;
|
||||||
const rank: Record<IslandNotification['priority'], number> = {
|
const priority =
|
||||||
LOW: 0,
|
(PRIORITY_RANK[incoming.priority] ?? 0) >= (PRIORITY_RANK[existing.priority] ?? 0)
|
||||||
NORMAL: 1,
|
? incoming.priority
|
||||||
HIGH: 2,
|
: existing.priority;
|
||||||
URGENT: 3,
|
|
||||||
};
|
|
||||||
const priority = rank[incoming.priority] >= rank[existing.priority] ? incoming.priority : existing.priority;
|
|
||||||
return { ...incoming, kind, priority };
|
return { ...incoming, kind, priority };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +454,61 @@ class IslandController {
|
|||||||
return this.state.queued.find((item) => item.id === id) ?? null;
|
return this.state.queued.find((item) => item.id === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」倒序。
|
||||||
|
*
|
||||||
|
* 排序口径与共享常量 `PRIORITY_RANK` 一致:紧急 > 重要 > 普通 > 低,同级按时间新的在前。
|
||||||
|
* 用户在展开卡里看到的就是这个顺序("默认按重要程度排列")。
|
||||||
|
*/
|
||||||
|
private pendingNotifications(): IslandNotification[] {
|
||||||
|
const merged: IslandNotification[] = [];
|
||||||
|
if (this.state.active) merged.push(this.state.active);
|
||||||
|
for (const item of this.state.queued) {
|
||||||
|
if (!merged.some((existing) => existing.id === item.id)) merged.push(item);
|
||||||
|
}
|
||||||
|
return merged.sort((a, b) => {
|
||||||
|
const rank = (PRIORITY_RANK[b.priority] ?? 0) - (PRIORITY_RANK[a.priority] ?? 0);
|
||||||
|
if (rank !== 0) return rank;
|
||||||
|
return String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多条通知时的列表布局(几条、要不要"展开更多"、卡片多高)。
|
||||||
|
* 只有展开态且待处理 ≥ 2 条时才有列表形态,单条通知仍然是原来那张详情卡。
|
||||||
|
*/
|
||||||
|
private listLayout(): IslandListLayout | null {
|
||||||
|
if (this.state.mode !== 'expanded') return null;
|
||||||
|
const count = this.pendingNotifications().length;
|
||||||
|
if (count < 2) return null;
|
||||||
|
return islandListLayout({
|
||||||
|
count,
|
||||||
|
fontSize: this.appearance.fontSize,
|
||||||
|
maxHeight: this.maxCardHeight,
|
||||||
|
expanded: this.state.listExpanded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展开卡在当前屏幕 / 停靠位置下的可用高度上限:
|
||||||
|
* 从"岛被锚定的那条边"量到屏幕工作区的另一端(也就是任务栏上方)。
|
||||||
|
*
|
||||||
|
* 用锚点 `vValue` 而不是窗口矩形来算:锚点与窗口尺寸无关,因此**列表长高后再算还是同一个数**
|
||||||
|
* (用窗口矩形会自引用:窗口高度取决于列表高度,列表高度又取决于可用高度)。
|
||||||
|
*/
|
||||||
|
private refreshViewport(): void {
|
||||||
|
const area = this.targetDisplay().workArea;
|
||||||
|
const anchor = this.resolveAnchor();
|
||||||
|
const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN);
|
||||||
|
const available =
|
||||||
|
anchor.vMode === 'bottom'
|
||||||
|
? anchor.vValue - (area.y + marginY)
|
||||||
|
: area.y + area.height - marginY - anchor.vValue;
|
||||||
|
// 下限兜底:屏幕再小也不能用 0 去算布局(否则连一行都排不出来)
|
||||||
|
this.maxCardHeight = Math.max(MIN_CARD_HEIGHT, Math.floor(available));
|
||||||
|
this.state.maxCardHeight = this.maxCardHeight;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收到一条消息。
|
* 收到一条消息。
|
||||||
* - 紧急(含紧急叫人):立刻展开(无视上课时段)
|
* - 紧急(含紧急叫人):立刻展开(无视上课时段)
|
||||||
@@ -384,6 +517,7 @@ class IslandController {
|
|||||||
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
|
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
|
||||||
*/
|
*/
|
||||||
pushNotification(raw: IslandNotification, context: IslandPushContext = {}): void {
|
pushNotification(raw: IslandNotification, context: IslandPushContext = {}): void {
|
||||||
|
this.state.listExpanded = false;
|
||||||
// 同一 id 的第二条链路(通知/叫人双播):合并而不是覆盖,保住"叫人/紧急"语义
|
// 同一 id 的第二条链路(通知/叫人双播):合并而不是覆盖,保住"叫人/紧急"语义
|
||||||
const notification = this.mergeNotification(this.findKnownNotification(raw.id), raw);
|
const notification = this.mergeNotification(this.findKnownNotification(raw.id), raw);
|
||||||
const inClass = context.inClass ?? this.state.inClass;
|
const inClass = context.inClass ?? this.state.inClass;
|
||||||
@@ -398,7 +532,7 @@ class IslandController {
|
|||||||
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
|
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
|
||||||
this.state.active = notification;
|
this.state.active = notification;
|
||||||
this.state.updatedAt = Date.now();
|
this.state.updatedAt = Date.now();
|
||||||
this.setState({ mode: 'expanded' });
|
this.setState({ mode: 'expanded', listExpanded: false });
|
||||||
this.previewTimer = setTimeout(() => {
|
this.previewTimer = setTimeout(() => {
|
||||||
this.previewTimer = null;
|
this.previewTimer = null;
|
||||||
this.endPreview(notification.id);
|
this.endPreview(notification.id);
|
||||||
@@ -561,7 +695,8 @@ class IslandController {
|
|||||||
// 记下这条 id,syncWindow 的上课守卫对它是放行的(否则展开后会被立刻隐藏,
|
// 记下这条 id,syncWindow 的上课守卫对它是放行的(否则展开后会被立刻隐藏,
|
||||||
// 表现为"点不开新作业"——用户反馈的联合通知场景)。
|
// 表现为"点不开新作业"——用户反馈的联合通知场景)。
|
||||||
this.explicitShowId = this.state.inClass ? active.id : null;
|
this.explicitShowId = this.state.inClass ? active.id : null;
|
||||||
this.setState({ mode: 'expanded' });
|
// 每次重新点开都从"只显示前三条"开始(上文的 setState 只在离开 expanded 时复位)
|
||||||
|
this.setState({ mode: 'expanded', listExpanded: false });
|
||||||
this.scheduleCollapse(
|
this.scheduleCollapse(
|
||||||
this.isImmediate(active)
|
this.isImmediate(active)
|
||||||
? active.kind === 'call'
|
? active.kind === 'call'
|
||||||
@@ -572,6 +707,18 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'expand-list': {
|
||||||
|
// 多条通知时点"展开更多":把列表铺到屏幕放得下的程度(窗口跟着长高,见 setState)
|
||||||
|
if (this.state.mode === 'hidden' || this.state.listExpanded) break;
|
||||||
|
const layout = this.listLayout();
|
||||||
|
if (!layout) break;
|
||||||
|
logger.info(
|
||||||
|
`灵动岛:展开更多(共 ${this.pendingNotifications().length} 条,显示 ${layout.rows} 条,提示=${layout.hint ?? '无'})`,
|
||||||
|
);
|
||||||
|
this.setState({ mode: 'expanded', listExpanded: true });
|
||||||
|
this.scheduleCollapse(TIMEOUTS.expanded);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'collapse':
|
case 'collapse':
|
||||||
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
|
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
|
||||||
// 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开)
|
// 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开)
|
||||||
@@ -589,6 +736,11 @@ class IslandController {
|
|||||||
case 'dismiss':
|
case 'dismiss':
|
||||||
this.dismissActive();
|
this.dismissActive();
|
||||||
break;
|
break;
|
||||||
|
case 'dismiss-all':
|
||||||
|
// 多条通知时点"知道了":整批关闭(通知中心仍是未读,学生回应用里再看)
|
||||||
|
logger.info(`灵动岛:整批知道了(${this.pendingNotifications().length} 条)`);
|
||||||
|
this.clearPendingForClose();
|
||||||
|
break;
|
||||||
case 'mark-read': {
|
case 'mark-read': {
|
||||||
const id = payload.id;
|
const id = payload.id;
|
||||||
const queue = this.state.queued.filter((item) => item.id !== id);
|
const queue = this.state.queued.filter((item) => item.id !== id);
|
||||||
@@ -604,6 +756,18 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'mark-all-read': {
|
||||||
|
// 多条通知时点"标为已读":**默认把这一批全部标记已读**再关闭。
|
||||||
|
// 作业卡的 id 是本地合成的(`homework-<id>`,库里没有这条通知),交给通知中心只会报"通知不存在",
|
||||||
|
// 因此这里先按 kind 过滤掉,单条模式的 canMarkRead 守卫同理。
|
||||||
|
const ids = this.pendingNotifications()
|
||||||
|
.filter((item) => item.kind !== 'homework')
|
||||||
|
.map((item) => item.id);
|
||||||
|
logger.info(`灵动岛:整批标为已读(${ids.length} 条)`);
|
||||||
|
if (ids.length > 0) this.markAllReadHandler?.(ids);
|
||||||
|
this.clearPendingForClose();
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'open-app':
|
case 'open-app':
|
||||||
this.openAppHandler?.();
|
this.openAppHandler?.();
|
||||||
break;
|
break;
|
||||||
@@ -676,6 +840,19 @@ class IslandController {
|
|||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整批清空并关闭("知道了 / 标为已读"的多条场景)。
|
||||||
|
* 状态同步落地(渲染进程随之收起卡片,窗口由 `hide()` 淡出)。
|
||||||
|
*/
|
||||||
|
private clearPendingForClose(): void {
|
||||||
|
this.clearTimers();
|
||||||
|
this.state.active = null;
|
||||||
|
this.state.queued = [];
|
||||||
|
this.state.listExpanded = false;
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
|
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
|
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
|
||||||
@@ -756,9 +933,7 @@ class IslandController {
|
|||||||
*/
|
*/
|
||||||
private resolveAnchor(): IslandAnchor {
|
private resolveAnchor(): IslandAnchor {
|
||||||
// 多显示器教室电脑:打开"跟随鼠标屏幕"后,岛出现在鼠标所在的那块屏(讲台切换投影时不用改设置)
|
// 多显示器教室电脑:打开"跟随鼠标屏幕"后,岛出现在鼠标所在的那块屏(讲台切换投影时不用改设置)
|
||||||
const display = this.appearance.followCursorDisplay
|
const display = this.targetDisplay();
|
||||||
? screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
|
||||||
: screen.getPrimaryDisplay();
|
|
||||||
const area = display.workArea;
|
const area = display.workArea;
|
||||||
const marginX = Math.round(this.appearance.marginX ?? ISLAND_MARGIN);
|
const marginX = Math.round(this.appearance.marginX ?? ISLAND_MARGIN);
|
||||||
const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN);
|
const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN);
|
||||||
@@ -782,9 +957,38 @@ class IslandController {
|
|||||||
return { hMode, hValue, vMode, vValue };
|
return { hMode, hValue, vMode, vValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前应该把岛放在哪块屏上:跟随鼠标时用光标所在屏,否则用主屏 */
|
||||||
|
private targetDisplay(): Electron.Display {
|
||||||
|
return this.appearance.followCursorDisplay
|
||||||
|
? screen.getDisplayNearestPoint(this.cursorOverride ?? screen.getCursorScreenPoint())
|
||||||
|
: screen.getPrimaryDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锚点所在屏幕变化时重排窗口("跟随鼠标屏幕"、主屏切换、显示器插拔都会走到这里)。
|
||||||
|
*
|
||||||
|
* 原先锚点只在 `setAppearance()` 里算一次,于是"跟随鼠标屏幕"实际上**只在改设置的那一瞬间**
|
||||||
|
* 生效:讲台从投影切回来之后,岛还留在原来那块屏上,直到用户再动一次外观设置。
|
||||||
|
* 这里挂在已有的 60ms 命中轮询上(不新增定时器),并且只在目标屏幕真的变了、且窗口可见时才动窗口。
|
||||||
|
*/
|
||||||
|
private syncAnchorDisplay(): void {
|
||||||
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
|
const displayId = this.targetDisplay().id;
|
||||||
|
if (this.anchorDisplayId === displayId) return;
|
||||||
|
this.anchorDisplayId = displayId;
|
||||||
|
// 换屏了:可用高度(工作区)也跟着换,列表"能放几行"要重算
|
||||||
|
this.refreshViewport();
|
||||||
|
if (!this.win.isVisible()) return;
|
||||||
|
this.relayout();
|
||||||
|
}
|
||||||
|
|
||||||
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
|
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
|
||||||
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
|
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
|
||||||
this.state = { ...this.state, ...patch, updatedAt: Date.now() };
|
this.state = { ...this.state, ...patch, updatedAt: Date.now() };
|
||||||
|
// 离开展开态就把"展开更多"复位:下次再点开时默认回到"只显示前三条"
|
||||||
|
if (this.state.mode !== 'expanded') this.state.listExpanded = false;
|
||||||
|
// 列表形态比普通展开卡高:窗口要**先**长高,卡片才开始形变,否则底部会被窗口裁掉
|
||||||
|
if (!options.skipWindow) this.relayout();
|
||||||
this.emit();
|
this.emit();
|
||||||
if (!options.skipWindow) this.syncWindow();
|
if (!options.skipWindow) this.syncWindow();
|
||||||
}
|
}
|
||||||
@@ -825,6 +1029,11 @@ class IslandController {
|
|||||||
|
|
||||||
// 展开态才允许获得焦点,这样点击屏幕其他位置会 blur → 自动收起
|
// 展开态才允许获得焦点,这样点击屏幕其他位置会 blur → 自动收起
|
||||||
this.applyFocusable();
|
this.applyFocusable();
|
||||||
|
// 窗口不可见时先把包围盒对齐到当前形态:列表形态留下的"高窗口"在这里收回去。
|
||||||
|
// 必须挑"不可见"的时刻做 —— 可见时改大小会把正在形变的卡片裁掉一截。
|
||||||
|
if (!this.win.isVisible()) this.applyWindowLayout();
|
||||||
|
// 触摸模式:形态变了,窗口要立刻跟着变大(缩小交给岛体矩形上报后判定,避免裁掉形变中的卡片)
|
||||||
|
this.syncTouchLayout();
|
||||||
|
|
||||||
if (this.state.mode === 'hidden') {
|
if (this.state.mode === 'hidden') {
|
||||||
// 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱;
|
// 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱;
|
||||||
@@ -857,9 +1066,69 @@ class IslandController {
|
|||||||
this.syncHitFromCursor();
|
this.syncHitFromCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口包围盒:
|
||||||
|
* - 普通(鼠标)模式:固定取"最大形态 + 2×阴影留白",**开合过程中一帧都不动**(照搬 WinIsland);
|
||||||
|
* 唯一的例外是**多条通知的列表**:它比任何普通形态都高,窗口必须当场长高,否则卡片底部
|
||||||
|
* (那排按钮)会被窗口裁掉。窗口是在卡片开始形变**之前**改的,用户看不到这一步。
|
||||||
|
* - 触摸模式:贴合当前形态(岛体 + 2×阴影留白),否则大包围盒的透明区会吞掉桌面的触摸。
|
||||||
|
*/
|
||||||
|
private windowBox(): { width: number; height: number } {
|
||||||
|
const size = this.formSize();
|
||||||
|
if (!this.touchMode) {
|
||||||
|
return {
|
||||||
|
width: Math.round(Math.max(BBOX_SIZE.width, size.width + ISLAND_SHADOW_PAD * 2)),
|
||||||
|
height: Math.round(Math.max(BBOX_SIZE.height, size.height + ISLAND_SHADOW_PAD * 2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: Math.round(size.width + ISLAND_SHADOW_PAD * 2),
|
||||||
|
height: Math.round(size.height + ISLAND_SHADOW_PAD * 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触摸模式下重排窗口(贴合岛体):
|
||||||
|
* **变大立刻生效**(否则形变中的卡片会被窗口裁掉一半);**变小要等岛真的收小**——
|
||||||
|
* 判据是渲染进程上报的真实岛体矩形已经装得下,见 `cardFitsFormSize`。
|
||||||
|
*/
|
||||||
|
private syncTouchLayout(): void {
|
||||||
|
if (!this.touchMode || !this.win || this.win.isDestroyed()) return;
|
||||||
|
const target = this.windowBox();
|
||||||
|
const current = this.win.getBounds();
|
||||||
|
const shrinking = target.width < current.width || target.height < current.height;
|
||||||
|
/**
|
||||||
|
* **窗口可见期间只增不减**(触摸模式)。
|
||||||
|
*
|
||||||
|
* 为什么:触摸模式下窗口是"贴合岛体"的(手指要能点到岛),所以形态变化时窗口本该跟着变
|
||||||
|
* —— 但**收小会让出一块区域**,Windows/DWM 在那一瞬间可能还留着上一帧的内容,
|
||||||
|
* 用户看到的就是"一缩回/一展开就闪一下、像旧画面残留在那里"(反复反馈、逐帧抓过的那一类)。
|
||||||
|
* 变大属于"只增不减",不会让出任何区域;所以:
|
||||||
|
* - 需要**变大**就立刻变大(否则形变中的卡片会被窗口裁掉);
|
||||||
|
* - 需要**变小**一律等窗口隐藏之后再收(`syncWindow` 里"不可见时对齐包围盒",
|
||||||
|
* 不可见时改尺寸不会有残影),这也顺带修掉"隐藏态改停靠/边距不生效"那条。
|
||||||
|
*/
|
||||||
|
if (shrinking && this.win.isVisible()) return;
|
||||||
|
this.applyWindowLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染进程上报的岛体是否已经收进"当前形态"的尺寸里(触摸模式下判断能否缩小窗口) */
|
||||||
|
private cardFitsFormSize(): boolean {
|
||||||
|
const rect = this.hitRect;
|
||||||
|
if (!rect) return false;
|
||||||
|
const size = this.formSize();
|
||||||
|
return rect.width <= size.width + 1 && rect.height <= size.height + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 外观 / 锚点变化后的窗口重排:普通模式按固定包围盒重排,触摸模式走贴合逻辑 */
|
||||||
|
private relayout(): void {
|
||||||
|
if (this.touchMode) this.syncTouchLayout();
|
||||||
|
else this.applyWindowLayout();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按当前外观把窗口放到锚点上(**只在尺寸/位置设置变化时调用**,开合时不调用)。
|
* 按当前外观把窗口放到锚点上(**只在尺寸/位置设置变化时调用**,开合时不调用)。
|
||||||
* 窗口尺寸 = 最大形态 + 2×阴影留白,因此任何形态的岛都能完整容纳。
|
* 尺寸见 `windowBox`:普通模式恒为"最大形态 + 阴影留白",触摸模式贴合当前形态。
|
||||||
*/
|
*/
|
||||||
private applyWindowLayout(): void {
|
private applyWindowLayout(): void {
|
||||||
if (!this.win || this.win.isDestroyed()) return;
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
@@ -876,10 +1145,11 @@ class IslandController {
|
|||||||
this.win.setBounds(next);
|
this.win.setBounds(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 固定包围盒窗口的矩形:以锚点定位,尺寸恒为"最大形态 + 阴影留白" */
|
/** 窗口矩形:以锚点定位(尺寸由 `windowBox` 决定,触摸模式随形态变化) */
|
||||||
private windowBounds(): Bounds {
|
private windowBounds(): Bounds {
|
||||||
const width = Math.round(BBOX_SIZE.width);
|
const box = this.windowBox();
|
||||||
const height = Math.round(BBOX_SIZE.height);
|
const width = box.width;
|
||||||
|
const height = box.height;
|
||||||
const anchor = this.resolveAnchor();
|
const anchor = this.resolveAnchor();
|
||||||
|
|
||||||
// 窗口比岛大 ISLAND_SHADOW_PAD(给投影留白),因此窗口要再偏移一个 pad,
|
// 窗口比岛大 ISLAND_SHADOW_PAD(给投影留白),因此窗口要再偏移一个 pad,
|
||||||
@@ -1005,8 +1275,16 @@ class IslandController {
|
|||||||
* 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。
|
* 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。
|
||||||
*/
|
*/
|
||||||
setAppearance(patch: Partial<IslandAppearance>): void {
|
setAppearance(patch: Partial<IslandAppearance>): void {
|
||||||
this.appearance = normalizeAppearance({ ...this.appearance, ...patch });
|
// 先丢掉显式 undefined 的键:设置页/冒烟脚本传的都是**部分补丁**,
|
||||||
|
// 一个残缺字段不该把对应外观重置成默认值(NaN 的兜底在 normalizeAppearance 里)
|
||||||
|
const clean: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(patch ?? {})) {
|
||||||
|
if (value !== undefined) clean[key] = value;
|
||||||
|
}
|
||||||
|
this.appearance = normalizeAppearance({ ...this.appearance, ...clean } as IslandAppearance);
|
||||||
recomputeSizes(this.appearance);
|
recomputeSizes(this.appearance);
|
||||||
|
// 字号/边距会影响"列表能放几行",可用高度也随停靠位置变化 —— 重算一次并随状态下发
|
||||||
|
this.refreshViewport();
|
||||||
|
|
||||||
if (this.win && !this.win.isDestroyed()) {
|
if (this.win && !this.win.isDestroyed()) {
|
||||||
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
||||||
@@ -1017,7 +1295,7 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
this.emitAppearance();
|
this.emitAppearance();
|
||||||
// 包围盒/锚点可能变化:窗口重排一次(**不是逐帧**,开合过程中绝不会再碰窗口)
|
// 包围盒/锚点可能变化:窗口重排一次(**不是逐帧**,开合过程中绝不会再碰窗口)
|
||||||
this.applyWindowLayout();
|
this.relayout();
|
||||||
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
|
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
|
||||||
if (this.state.mode !== 'hidden') this.syncWindow();
|
if (this.state.mode !== 'hidden') this.syncWindow();
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1039,10 +1317,17 @@ class IslandController {
|
|||||||
* "点开了再收起,就再也点不开了"。
|
* "点开了再收起,就再也点不开了"。
|
||||||
*/
|
*/
|
||||||
setInteractive(interactive: boolean, source = '未知'): void {
|
setInteractive(interactive: boolean, source = '未知'): void {
|
||||||
if (this.interactive === interactive) return;
|
// 触摸模式:窗口已贴合岛体,必须**始终**接收输入。触摸不会产生光标移动,按光标判定命中的
|
||||||
this.interactive = interactive;
|
// 那套逻辑在触摸屏上永远不会打开,一旦让它把命中关掉,手指就再也点不到("点不动")。
|
||||||
|
// 测试输入穿透:真实鼠标一律不接收(理由见 testInputPassthrough 字段);
|
||||||
|
// 但**注入光标**(冒烟用 setHitTestCursor 模拟指针在岛上/岛外)照常生效,
|
||||||
|
// 否则「光标轮询校正命中」「岛外穿透」这类断言全会被自己的开关弄红。
|
||||||
|
const passthrough = this.testInputPassthrough && this.cursorOverride === null;
|
||||||
|
const next = passthrough ? false : this.touchMode ? true : interactive;
|
||||||
|
if (this.interactive === next) return;
|
||||||
|
this.interactive = next;
|
||||||
logger.info(
|
logger.info(
|
||||||
`灵动岛:鼠标命中=${interactive}(来自${source})mode=${this.state.mode} ` +
|
`灵动岛:鼠标命中=${next}(请求=${interactive},来自${source})mode=${this.state.mode} ` +
|
||||||
`窗口可见=${this.win?.isVisible() ?? false} 岛体矩形=${
|
`窗口可见=${this.win?.isVisible() ?? false} 岛体矩形=${
|
||||||
this.hitRect ? `${Math.round(this.hitRect.width)}x${Math.round(this.hitRect.height)}` : '无'
|
this.hitRect ? `${Math.round(this.hitRect.width)}x${Math.round(this.hitRect.height)}` : '无'
|
||||||
}`,
|
}`,
|
||||||
@@ -1050,7 +1335,9 @@ class IslandController {
|
|||||||
if (!this.win || this.win.isDestroyed()) return;
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
// forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程,
|
// forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程,
|
||||||
// 这样渲染进程才能发现指针进入岛体并重新打开命中。
|
// 这样渲染进程才能发现指针进入岛体并重新打开命中。
|
||||||
this.win.setIgnoreMouseEvents(!interactive, { forward: true });
|
// 注意传的是 next 而不是入参:触摸模式下入参可能是 false(按光标判定),
|
||||||
|
// 直接用它会把窗口又打回穿透态 —— 那正是"手指点不开"的根因。
|
||||||
|
this.win.setIgnoreMouseEvents(!next, { forward: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */
|
/** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */
|
||||||
@@ -1062,6 +1349,9 @@ class IslandController {
|
|||||||
this.lastHitSyncAt = now;
|
this.lastHitSyncAt = now;
|
||||||
this.syncHitFromCursor();
|
this.syncHitFromCursor();
|
||||||
}
|
}
|
||||||
|
// 触摸模式:窗口要一直贴合岛体,而"收小"必须等岛真的收小之后再做(否则形变中的卡片被窗口裁掉)。
|
||||||
|
// 渲染进程每 ≥50ms 就会上报一次真实几何,正好拿来当这个判据。
|
||||||
|
this.syncTouchLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前岛体矩形(供冒烟验证) */
|
/** 当前岛体矩形(供冒烟验证) */
|
||||||
@@ -1069,6 +1359,42 @@ class IslandController {
|
|||||||
return this.hitRect ? { ...this.hitRect } : null;
|
return this.hitRect ? { ...this.hitRect } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置触摸模式(触摸屏机器由渲染进程上报,冒烟也可显式开关)。
|
||||||
|
*
|
||||||
|
* 开启后:窗口贴合岛体(见 `windowBox`)且不再穿透(见 `setInteractive`)——
|
||||||
|
* 这是让"手指按在岛上"能被 Windows 命中到本窗口的唯一办法(详见 `touchMode` 字段的注释)。
|
||||||
|
* 关闭后回到"固定大包围盒 + 按光标命中"的原行为,因此非触摸屏机器的表现完全不变。
|
||||||
|
*/
|
||||||
|
setTouchMode(enabled: boolean): void {
|
||||||
|
if (this.touchMode === enabled) return;
|
||||||
|
this.touchMode = enabled;
|
||||||
|
logger.info(
|
||||||
|
`灵动岛:触摸模式=${enabled}(窗口${enabled ? '贴合岛体并始终接收输入' : '回到固定包围盒穿透'})`,
|
||||||
|
);
|
||||||
|
// 直接重排到目标包围盒:开启时贴合岛体,关闭时回到"最大形态 + 阴影留白"的固定大包围盒
|
||||||
|
this.applyWindowLayout();
|
||||||
|
if (enabled) this.setInteractive(true, '触摸模式');
|
||||||
|
else this.syncHitFromCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否处于触摸模式(供冒烟验证) */
|
||||||
|
getTouchMode(): boolean {
|
||||||
|
return this.touchMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开关"测试输入穿透"(冒烟用,见 `testInputPassthrough`):
|
||||||
|
* 打开后真实鼠标点击穿透到桌面,不会污染自动化断言;关闭后立刻按真实光标位置恢复命中。
|
||||||
|
*/
|
||||||
|
setTestInputPassthrough(enabled: boolean): void {
|
||||||
|
if (this.testInputPassthrough === enabled) return;
|
||||||
|
this.testInputPassthrough = enabled;
|
||||||
|
logger.info(`灵动岛:测试输入穿透=${enabled}(供自动化验证不被并行人工操作打扰)`);
|
||||||
|
if (enabled) this.setInteractive(false, '测试输入穿透');
|
||||||
|
else this.syncHitFromCursor();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 诊断 / 冒烟专用:覆盖"光标位置"(屏幕坐标),让命中兜底轮询按指定坐标校正。
|
* 诊断 / 冒烟专用:覆盖"光标位置"(屏幕坐标),让命中兜底轮询按指定坐标校正。
|
||||||
*
|
*
|
||||||
@@ -1099,23 +1425,39 @@ class IslandController {
|
|||||||
return this.hitRect ?? this.expectedHitRect();
|
return this.hitRect ?? this.expectedHitRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按当前形态/停靠位置算出岛体目标矩形(窗口内 CSS px),仅作兜底 */
|
/** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */
|
||||||
private expectedHitRect(): { x: number; y: number; width: number; height: number } {
|
private formSize(): { width: number; height: number } {
|
||||||
let size = SIZES.pill;
|
// 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
|
||||||
|
const layout = this.listLayout();
|
||||||
|
if (layout) return { width: SIZES.expanded.width, height: layout.height };
|
||||||
if (this.state.mode === 'expanded') {
|
if (this.state.mode === 'expanded') {
|
||||||
size =
|
return this.state.active?.kind === 'call'
|
||||||
this.state.active?.kind === 'call'
|
|
||||||
? CALL_SIZE
|
? CALL_SIZE
|
||||||
: this.state.active?.priority === 'URGENT'
|
: this.state.active?.priority === 'URGENT'
|
||||||
? URGENT_SIZE
|
? URGENT_SIZE
|
||||||
: SIZES.expanded;
|
: SIZES.expanded;
|
||||||
} else if (this.state.mode === 'hidden' && this.appearance.idleSliver) {
|
|
||||||
size = SLIVER_SIZE;
|
|
||||||
}
|
}
|
||||||
|
if (this.state.mode === 'hidden' && this.appearance.idleSliver) return SLIVER_SIZE;
|
||||||
|
return SIZES.pill;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按当前形态/停靠位置算出岛体目标矩形(窗口内 CSS px),仅作兜底。
|
||||||
|
*
|
||||||
|
* 定位规则必须与渲染进程的 CSS 完全一致(`islandStyle`):左/右停靠贴边留 pad,
|
||||||
|
* 居中则水平居中;上/下停靠同理。这里用**窗口实际尺寸**而不是 BBOX_SIZE 计算 ——
|
||||||
|
* 触摸模式下窗口会贴合岛体,用固定的 BBOX_SIZE 会算出错误偏移。
|
||||||
|
*/
|
||||||
|
private expectedHitRect(): { x: number; y: number; width: number; height: number } {
|
||||||
|
const size = this.formSize();
|
||||||
const pad = ISLAND_SHADOW_PAD;
|
const pad = ISLAND_SHADOW_PAD;
|
||||||
const position = this.appearance.position;
|
const position = this.appearance.position;
|
||||||
const width = Math.round(BBOX_SIZE.width);
|
const bounds =
|
||||||
const height = Math.round(BBOX_SIZE.height);
|
this.win && !this.win.isDestroyed()
|
||||||
|
? this.win.getBounds()
|
||||||
|
: { width: Math.round(BBOX_SIZE.width), height: Math.round(BBOX_SIZE.height) };
|
||||||
|
const width = bounds.width;
|
||||||
|
const height = bounds.height;
|
||||||
const x = position.endsWith('left')
|
const x = position.endsWith('left')
|
||||||
? pad
|
? pad
|
||||||
: position.endsWith('right')
|
: position.endsWith('right')
|
||||||
@@ -1171,6 +1513,9 @@ class IslandController {
|
|||||||
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
|
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
|
||||||
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
|
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
|
||||||
this.hitTimer = setInterval(() => {
|
this.hitTimer = setInterval(() => {
|
||||||
|
// 先按目标屏幕校正窗口位置(换屏/插拔显示器),再算命中与心跳:
|
||||||
|
// 顺序不能反,否则命中判定会用上一块屏的窗口坐标
|
||||||
|
this.syncAnchorDisplay();
|
||||||
this.syncHitFromCursor();
|
this.syncHitFromCursor();
|
||||||
this.checkRendererAlive();
|
this.checkRendererAlive();
|
||||||
}, 60);
|
}, 60);
|
||||||
@@ -1325,24 +1670,36 @@ export function getSliverSize(): { width: number; height: number } {
|
|||||||
|
|
||||||
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
||||||
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||||
const clamp = (value: number, min: number, max: number): number =>
|
/**
|
||||||
Math.min(max, Math.max(min, Math.round(value * 100) / 100));
|
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
|
||||||
|
*
|
||||||
|
* 注意 `Math.max(min, NaN)` 仍是 NaN、`Math.min(max, NaN)` 也是 NaN —— 也就是说"只夹紧"
|
||||||
|
* 并不能防住 NaN。而 `setAppearance()` 的入参来自 IPC(`island:set-appearance` 只判了 `if (patch)`),
|
||||||
|
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
|
||||||
|
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
|
||||||
|
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
|
||||||
|
*/
|
||||||
|
const clamp = (value: number, min: number, max: number, fallback: number): number => {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
|
||||||
|
};
|
||||||
|
const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
|
||||||
return {
|
return {
|
||||||
height: clamp(input.height, 36, 72),
|
height: clamp(input.height, 36, 72, fallbackOf.height),
|
||||||
width: clamp(input.width, 220, 420),
|
width: clamp(input.width, 220, 420, fallbackOf.width),
|
||||||
radius: clamp(input.radius, 8, 32),
|
radius: clamp(input.radius, 8, 32, fallbackOf.radius),
|
||||||
opacity: clamp(input.opacity, 0.4, 1),
|
opacity: clamp(input.opacity, 0.4, 1, fallbackOf.opacity),
|
||||||
accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent,
|
accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent,
|
||||||
fontSize: clamp(input.fontSize, 11, 20),
|
fontSize: clamp(input.fontSize, 11, 20, fallbackOf.fontSize),
|
||||||
animations: input.animations !== false,
|
animations: input.animations !== false,
|
||||||
speed: clamp(input.speed, 0.5, 2),
|
speed: clamp(input.speed, 0.5, 2, fallbackOf.speed),
|
||||||
position: ISLAND_POSITIONS.includes(input.position) ? input.position : 'top-center',
|
position: ISLAND_POSITIONS.includes(input.position) ? input.position : 'top-center',
|
||||||
alwaysOnTop: input.alwaysOnTop !== false,
|
alwaysOnTop: input.alwaysOnTop !== false,
|
||||||
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
|
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
|
||||||
idleSliver: input.idleSliver === true,
|
idleSliver: input.idleSliver === true,
|
||||||
// 边距:0 表示贴着屏幕边缘,上限给到 200/160 是为了"多显示器 + 任务栏在侧面"这类布局
|
// 边距:0 表示贴着屏幕边缘,上限给到 200/160 是为了"多显示器 + 任务栏在侧面"这类布局
|
||||||
marginX: clamp(input.marginX ?? DEFAULT_ISLAND_APPEARANCE.marginX, 0, 200),
|
marginX: clamp(input.marginX ?? DEFAULT_ISLAND_APPEARANCE.marginX, 0, 200, fallbackOf.marginX),
|
||||||
marginY: clamp(input.marginY ?? DEFAULT_ISLAND_APPEARANCE.marginY, 0, 160),
|
marginY: clamp(input.marginY ?? DEFAULT_ISLAND_APPEARANCE.marginY, 0, 160, fallbackOf.marginY),
|
||||||
followCursorDisplay: input.followCursorDisplay === true,
|
followCursorDisplay: input.followCursorDisplay === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1370,9 +1727,26 @@ const ISLAND_MARGIN = 8;
|
|||||||
*/
|
*/
|
||||||
const BLUR_GRACE_MS = 500;
|
const BLUR_GRACE_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触摸屏上的失焦宽限期(触摸模式专用,见 `touchMode`)。
|
||||||
|
*
|
||||||
|
* 触摸没有光标,`isCursorOnIsland()` 这条"指针还在岛上就判定为激活抖动"的兜底恒为 false,
|
||||||
|
* 只剩宽限期这一层防线。而实测的假失焦出现在 focus 之后 70~520ms,正好跨过 500ms 的边界,
|
||||||
|
* 不放开的话手指点开后会偶发地立刻缩回胶囊。取 1200ms 给它 2 倍余量;
|
||||||
|
* 真正的"点屏幕别处收起"仍会在 1.2s 后生效。
|
||||||
|
*/
|
||||||
|
const BLUR_GRACE_TOUCH_MS = 1200;
|
||||||
|
|
||||||
/** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */
|
/** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */
|
||||||
const PREVIEW_DURATION_MS = 30_000;
|
const PREVIEW_DURATION_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展开卡可用高度的下限(CSS px):屏幕再矮也要留出这么多,
|
||||||
|
* 否则"列表能放几行"会算出负数。真正的工作区空间由 `refreshViewport` 算,这里只是防呆。
|
||||||
|
*/
|
||||||
|
const MIN_CARD_HEIGHT = 120;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
|
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
|
||||||
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
|
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
|
||||||
@@ -1446,6 +1820,12 @@ export function registerIslandIpc(): void {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 触摸屏上报(渲染进程按 navigator.maxTouchPoints 判定)→ 窗口改为贴合岛体并始终接收输入。
|
||||||
|
// 触摸屏上按光标判定命中的那套永远打不开命中,手指点不到岛(详见 IslandController.touchMode)。
|
||||||
|
ipcMain.on('island:set-touch-mode', (_event, enabled: boolean) => {
|
||||||
|
island.setTouchMode(enabled === true);
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
|
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
|
||||||
if (!payload?.action) return;
|
if (!payload?.action) return;
|
||||||
island.handleAction(payload);
|
island.handleAction(payload);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,17 @@ import type { IslandNotification } from '@classhelper/shared';
|
|||||||
*/
|
*/
|
||||||
const bridge: DesktopBridge = {
|
const bridge: DesktopBridge = {
|
||||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||||
|
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
|
||||||
|
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
|
||||||
|
smokeCredentials:
|
||||||
|
process.env.ELECTRON_SMOKE_TEST === '1'
|
||||||
|
? {
|
||||||
|
username: process.env.ELECTRON_SMOKE_USER ?? '',
|
||||||
|
password: process.env.ELECTRON_SMOKE_PASSWORD ?? '',
|
||||||
|
classCode: process.env.ELECTRON_SMOKE_CLASS_CODE ?? '',
|
||||||
|
classPassword: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
|
getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
|
||||||
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
|
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
|
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
|
||||||
@@ -41,6 +52,10 @@ const bridge: DesktopBridge = {
|
|||||||
onIslandMarkRead: (handler: (id: string) => void): void => {
|
onIslandMarkRead: (handler: (id: string) => void): void => {
|
||||||
ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id));
|
ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id));
|
||||||
},
|
},
|
||||||
|
/** 灵动岛点了"标为已读"(多条通知的整批):一次同步这一批 */
|
||||||
|
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
|
||||||
|
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('desktop', bridge);
|
contextBridge.exposeInMainWorld('desktop', bridge);
|
||||||
@@ -7,7 +7,18 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared';
|
|||||||
*/
|
*/
|
||||||
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'
|
||||||
|
| 'expand-list'
|
||||||
|
| 'collapse'
|
||||||
|
| 'dismiss'
|
||||||
|
| 'dismiss-all'
|
||||||
|
| 'mark-read'
|
||||||
|
| 'mark-all-read'
|
||||||
|
| 'open-app',
|
||||||
|
id?: string,
|
||||||
|
): void;
|
||||||
getState(): Promise<IslandState>;
|
getState(): Promise<IslandState>;
|
||||||
/** 订阅个性化外观(设置页改动后实时生效) */
|
/** 订阅个性化外观(设置页改动后实时生效) */
|
||||||
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
||||||
@@ -16,6 +27,12 @@ export interface IslandBridge {
|
|||||||
setInteractive(interactive: boolean): void;
|
setInteractive(interactive: boolean): void;
|
||||||
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
|
/**
|
||||||
|
* 上报"本机是触摸屏":主进程据此把窗口改为贴合岛体并始终接收输入。
|
||||||
|
* 触摸屏上按光标判定命中的那套永远打不开(触摸不产生 mousemove、也不动系统光标),
|
||||||
|
* 不上报的话手指点不到岛(希沃白板等触摸设备)。
|
||||||
|
*/
|
||||||
|
setTouchMode(enabled: boolean): void;
|
||||||
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
|
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
|
||||||
alive(): void;
|
alive(): void;
|
||||||
}
|
}
|
||||||
@@ -38,6 +55,9 @@ const bridge: IslandBridge = {
|
|||||||
setHitRect: (rect) => {
|
setHitRect: (rect) => {
|
||||||
ipcRenderer.send('island:set-hit-rect', rect);
|
ipcRenderer.send('island:set-hit-rect', rect);
|
||||||
},
|
},
|
||||||
|
setTouchMode: (enabled) => {
|
||||||
|
ipcRenderer.send('island:set-touch-mode', enabled);
|
||||||
|
},
|
||||||
alive: () => {
|
alive: () => {
|
||||||
ipcRenderer.send('island:alive');
|
ipcRenderer.send('island:alive');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
<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, subscribeIslandMarkRead } from './island/bridge.js';
|
import {
|
||||||
|
startIslandBridge,
|
||||||
|
stopIslandBridge,
|
||||||
|
subscribeIslandMarkAllRead,
|
||||||
|
subscribeIslandMarkRead,
|
||||||
|
} from './island/bridge.js';
|
||||||
|
import OnboardingWelcome from './components/OnboardingWelcome.vue';
|
||||||
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 { useOnboardingStore } from './stores/onboarding.js';
|
||||||
import { useRealtimeStore } from './stores/realtime.js';
|
import { useRealtimeStore } from './stores/realtime.js';
|
||||||
|
import { useUiStore } from './stores/ui.js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const onboarding = useOnboardingStore();
|
||||||
|
const ui = useUiStore();
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const booting = ref(true);
|
const booting = ref(true);
|
||||||
@@ -22,6 +32,10 @@ const bootText = ref('正在读取本地配置...');
|
|||||||
*/
|
*/
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
bootText.value = '正在读取本地配置...';
|
||||||
|
// 主题与侧栏折叠最先恢复:在首屏渲染前挂好 html.dark,避免"先白后黑"闪一下
|
||||||
|
await ui.init();
|
||||||
|
|
||||||
bootText.value = '正在恢复登录状态...';
|
bootText.value = '正在恢复登录状态...';
|
||||||
const session = await auth.restore();
|
const session = await auth.restore();
|
||||||
|
|
||||||
@@ -41,11 +55,23 @@ onMounted(async () => {
|
|||||||
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
|
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
|
||||||
if (auth.token) startIslandBridge();
|
if (auth.token) startIslandBridge();
|
||||||
|
|
||||||
// 灵动岛"标为已读" → 同步通知中心(未读红点)
|
// 灵动岛"标为已读" → 同步通知中心(未读红点);多条通知时是整批已读
|
||||||
subscribeIslandMarkRead();
|
subscribeIslandMarkRead();
|
||||||
|
subscribeIslandMarkAllRead();
|
||||||
|
|
||||||
if (auth.isAuthenticated) await router.replace('/schedule');
|
if (auth.isAuthenticated) await router.replace('/schedule');
|
||||||
else await router.replace('/login');
|
else await router.replace('/login');
|
||||||
|
|
||||||
|
// 初次启动引导:配置里没记「已看过」就自动弹一次(完成/跳过后由引导组件写入配置)。
|
||||||
|
// 稍等一拍再弹:让登录页/主界面先渲染完,避免和启动过渡的收尾抢帧。
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void window.desktop
|
||||||
|
?.getConfig()
|
||||||
|
.then((config) => {
|
||||||
|
if (config.onboardingDone !== true) onboarding.open();
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 400);
|
||||||
} finally {
|
} finally {
|
||||||
booting.value = false;
|
booting.value = false;
|
||||||
}
|
}
|
||||||
@@ -63,6 +89,8 @@ onUnmounted(() => {
|
|||||||
<p class="boot-text">{{ bootText }}</p>
|
<p class="boot-text">{{ bootText }}</p>
|
||||||
</div>
|
</div>
|
||||||
<router-view v-else />
|
<router-view v-else />
|
||||||
|
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
|
||||||
|
<OnboardingWelcome v-if="!booting" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -21,13 +21,22 @@ 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> =>
|
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
|
||||||
api.post('/auth/class-login', payload),
|
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),
|
||||||
|
/**
|
||||||
|
* 修改密码(设置页「账号信息」里的入口)。
|
||||||
|
* 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 ——
|
||||||
|
* 因此这台机器无论以哪种身份登录,改密入口都走这一条。
|
||||||
|
*/
|
||||||
|
changePassword: (payload: {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}): Promise<{ changed: boolean }> => api.patch(API_PATHS.auth.changePassword, payload),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const classApi = {
|
export const classApi = {
|
||||||
@@ -110,8 +119,10 @@ export const notificationApi = {
|
|||||||
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
|
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
|
||||||
api.get(API_PATHS.notifications, params),
|
api.get(API_PATHS.notifications, params),
|
||||||
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
|
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
|
||||||
|
// id 来自服务端或灵动岛(作业卡是本地合成的 `homework-…`),一律 encodeURIComponent:
|
||||||
|
// 否则含 `/` 或 `..` 的 id 会改变实际请求的路径
|
||||||
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> =>
|
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> =>
|
||||||
api.post(`${API_PATHS.notifications}/${id}/read`, {}),
|
api.post(`${API_PATHS.notifications}/${encodeURIComponent(id)}/read`, {}),
|
||||||
markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
|
markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+377
-17
@@ -6,6 +6,31 @@ export interface SmokeCheckResult {
|
|||||||
detail: string;
|
detail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟用的教师凭据:**由 preload 从环境变量注入,不写死在代码里**。
|
||||||
|
*
|
||||||
|
* 本文件会进渲染进程产物(`dist/renderer/assets/*.js`)→ `app.asar` → 每台学生机,
|
||||||
|
* 而 asar 可以直接解包。因此任何口令字面量都不能留在这里;
|
||||||
|
* 凭据由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供
|
||||||
|
* (见 `main/smoke.ts` 顶部关于 app.asar 的完整说明)。
|
||||||
|
*/
|
||||||
|
let smokeTeacherCredentials: { username: string; password: string } = { username: '', password: '' };
|
||||||
|
|
||||||
|
/** 由 main.ts 在注册冒烟钩子时注入(来源:ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD) */
|
||||||
|
export function setSmokeTeacherCredentials(next: { username: string; password: string }): void {
|
||||||
|
smokeTeacherCredentials = {
|
||||||
|
username: next?.username ?? '',
|
||||||
|
password: next?.password ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 教师登录请求体;未注入凭据时返回 null(调用方据此跳过并说明原因) */
|
||||||
|
function teacherLoginBody(): { username: string; password: string } | null {
|
||||||
|
return smokeTeacherCredentials.username && smokeTeacherCredentials.password
|
||||||
|
? { username: smokeTeacherCredentials.username, password: smokeTeacherCredentials.password }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。
|
* 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。
|
||||||
* 由主进程通过 webContents.executeJavaScript 调用。
|
* 由主进程通过 webContents.executeJavaScript 调用。
|
||||||
@@ -68,19 +93,26 @@ async function waitUntil(predicate: () => boolean, timeoutMs = 3000, intervalMs
|
|||||||
return predicate();
|
return predicate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配) */
|
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配;侧边栏是「分组 + 子菜单」结构) */
|
||||||
const EXPECTED_MENU: Array<{ label: string; path: string }> = [
|
const EXPECTED_MENU: Array<{ label: string; path: string }> = [
|
||||||
{ label: '课表', path: '/schedule' },
|
{ label: '课表', path: '/schedule' },
|
||||||
{ label: '作业', path: '/homeworks' },
|
{ label: '作业', path: '/homeworks' },
|
||||||
{ label: '通知', path: '/notifications' },
|
{ label: '通知', path: '/notifications' },
|
||||||
{ label: '成绩', path: '/grades' },
|
{ label: '成绩', path: '/grades' },
|
||||||
{ label: '设置', path: '/settings' },
|
/** 设置组的代表子项:设置入口改成分组后,用「通用」验证设置子页导航可用 */
|
||||||
|
{ label: '通用', path: '/settings/general' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项 */
|
||||||
|
const MENU_GROUPS: Array<{ title: string; labels: string[] }> = [
|
||||||
|
{ title: '学习', labels: ['课表', '作业', '通知', '成绩'] },
|
||||||
|
{ title: '设置', labels: ['通用', '外观', '灵动岛', '提醒', '账号', '关于'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 冒烟自检 3:侧边栏点击导航。
|
* 冒烟自检 3:侧边栏点击导航(分组结构版)。
|
||||||
* 直接对真实 DOM 触发 click,验证每次点击后路由与视图都发生切换 ——
|
* 先点分组标题展开,再逐个点击子项,验证每次点击后路由与视图都发生切换 ——
|
||||||
* 这是"点击左侧边栏没反应"这类问题的回归测试。
|
* 这是"点击左侧边栏没反应"这类问题的回归测试(侧边栏改为「学习/设置」分组后重写)。
|
||||||
*/
|
*/
|
||||||
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');
|
||||||
@@ -88,20 +120,43 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
|
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
|
||||||
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
|
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
|
||||||
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
|
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
|
||||||
const findMenuItem = (label: string): HTMLElement | undefined =>
|
const findByLabel = (selector: string, label: string): HTMLElement | undefined =>
|
||||||
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
(Array.from(document.querySelectorAll(selector)) as HTMLElement[]).find((element) =>
|
||||||
Array.from(element.querySelectorAll('span')).some(
|
Array.from(element.querySelectorAll('span')).some(
|
||||||
(span) => (span.textContent ?? '').trim() === label,
|
(span) => (span.textContent ?? '').trim() === label,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const findMenuItem = (label: string): HTMLElement | undefined => findByLabel('.el-menu-item', label);
|
||||||
|
const findGroupTitle = (title: string): HTMLElement | undefined =>
|
||||||
|
findByLabel('.el-sub-menu__title', title);
|
||||||
|
|
||||||
if (!findMenuItem('课表')) {
|
if (!findGroupTitle('学习') || !findGroupTitle('设置')) {
|
||||||
return { ok: false, detail: '侧边栏未渲染(当前不在主布局或未登录)' };
|
return { ok: false, detail: '侧边栏分组未渲染(当前不在主布局或未登录)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const visited: string[] = [];
|
const visited: string[] = [];
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
// 展开分组:默认只展开"当前路由所在分组",其它组需要先点开。
|
||||||
|
// 判据用"子项是否可见"而不是"盲点一次标题" —— 标题点击是 toggle,
|
||||||
|
// 对已经展开的组再点一下会把它**收起**,后续点不到子项。
|
||||||
|
const isVisible = (element: HTMLElement | undefined): boolean =>
|
||||||
|
Boolean(element && element.offsetParent !== null);
|
||||||
|
for (const group of MENU_GROUPS) {
|
||||||
|
const firstItem = findMenuItem(group.labels[0] ?? '');
|
||||||
|
if (!isVisible(firstItem)) {
|
||||||
|
findGroupTitle(group.title)?.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组结构自检:两个分组各自应包含全部子项
|
||||||
|
for (const group of MENU_GROUPS) {
|
||||||
|
for (const label of group.labels) {
|
||||||
|
if (!findMenuItem(label)) failures.push(`${group.title}组缺少子项 ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const { label, path } of EXPECTED_MENU) {
|
for (const { label, path } of EXPECTED_MENU) {
|
||||||
const element = findMenuItem(label);
|
const element = findMenuItem(label);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -144,10 +199,18 @@ async function seedSmokeContent(
|
|||||||
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
|
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 凭据由环境变量注入(理由见文件顶部):没给就明确跳过,而不是拿种子账号兜底
|
||||||
|
const loginBody = teacherLoginBody();
|
||||||
|
if (!loginBody) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法自建测试数据',
|
||||||
|
};
|
||||||
|
}
|
||||||
const login = await fetch(`${serverUrl}/api/auth/login`, {
|
const login = await fetch(`${serverUrl}/api/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
|
body: JSON.stringify(loginBody),
|
||||||
}).then((response) => response.json());
|
}).then((response) => response.json());
|
||||||
const token: string | undefined = login?.data?.token;
|
const token: string | undefined = login?.data?.token;
|
||||||
if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
|
if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
|
||||||
@@ -239,10 +302,18 @@ export async function onlineScenario(credentials?: {
|
|||||||
const detail: string[] = [];
|
const detail: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入
|
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
|
||||||
const code = credentials?.code ?? 'G101';
|
// 这里**不再回退到种子班级码**(G101/123456 也是真实可用的登录凭据,不能写进安装包):
|
||||||
const password = credentials?.password ?? '123456';
|
// 没有凭据就明确跳过,由 scripts/smoke.mjs 打印"未能准备班级账号"。
|
||||||
if (!credentials?.code) detail.push('未收到班级凭据,回退到种子班级码 G101');
|
const code = credentials?.code ?? '';
|
||||||
|
const password = credentials?.password ?? '';
|
||||||
|
if (!code) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail:
|
||||||
|
'未收到班级凭据(ELECTRON_SMOKE_CLASS_CODE / ELECTRON_SMOKE_CLASS_PASSWORD),已跳过联网集成自检',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await auth.login(serverUrl, code, password);
|
await auth.login(serverUrl, code, password);
|
||||||
detail.push(`login=${auth.user?.name ?? '-'}`);
|
detail.push(`login=${auth.user?.name ?? '-'}`);
|
||||||
@@ -363,10 +434,17 @@ export async function islandRealtimeScenario(): Promise<{
|
|||||||
|
|
||||||
const title = `灵动岛真机链路自检 ${Date.now()}`;
|
const title = `灵动岛真机链路自检 ${Date.now()}`;
|
||||||
try {
|
try {
|
||||||
|
const loginBody = teacherLoginBody();
|
||||||
|
if (!loginBody) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法投递通知',
|
||||||
|
};
|
||||||
|
}
|
||||||
const login = await fetch(`${serverUrl}/api/auth/login`, {
|
const login = await fetch(`${serverUrl}/api/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
|
body: JSON.stringify(loginBody),
|
||||||
}).then((response) => response.json());
|
}).then((response) => response.json());
|
||||||
const teacherToken: string | undefined = login?.data?.token;
|
const teacherToken: string | undefined = login?.data?.token;
|
||||||
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
|
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
|
||||||
@@ -378,6 +456,8 @@ export async function islandRealtimeScenario(): Promise<{
|
|||||||
classId,
|
classId,
|
||||||
title,
|
title,
|
||||||
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
|
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
|
||||||
|
// 普通优先级:冒烟客户端登录的是**临时班级**(无课表 → 判定不在上课),
|
||||||
|
// 普通通知也会立刻弹出胶囊,链路断言与真实上课时段无关。
|
||||||
priority: 'NORMAL',
|
priority: 'NORMAL',
|
||||||
}),
|
}),
|
||||||
}).then((response) => response.json());
|
}).then((response) => response.json());
|
||||||
@@ -465,10 +545,17 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
let teacherToken = '';
|
let teacherToken = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const loginBody = teacherLoginBody();
|
||||||
|
if (!loginBody) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法构造课表',
|
||||||
|
};
|
||||||
|
}
|
||||||
const login = await fetch(`${serverUrl}/api/auth/login`, {
|
const login = await fetch(`${serverUrl}/api/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
|
body: JSON.stringify(loginBody),
|
||||||
}).then((response) => response.json());
|
}).then((response) => response.json());
|
||||||
teacherToken = login?.data?.token ?? '';
|
teacherToken = login?.data?.token ?? '';
|
||||||
if (!teacherToken) return { ok: false, detail: '教师登录失败' };
|
if (!teacherToken) return { ok: false, detail: '教师登录失败' };
|
||||||
@@ -613,7 +700,9 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
|
|
||||||
// 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。
|
// 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。
|
||||||
// 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。
|
// 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。
|
||||||
if (!document.querySelector('.board-host')) {
|
// 注意判据是「看板里没有卡片」而不是「没有 board-host 容器」——空看板照样渲染容器,
|
||||||
|
// 拿容器当判据会导致切日期的兜底永远不触发(今天的日期没作业时看板必然为空)。
|
||||||
|
if (!document.querySelector('.board-card')) {
|
||||||
const [{ homeworkApi }, { useAuthStore }] = await Promise.all([
|
const [{ homeworkApi }, { useAuthStore }] = await Promise.all([
|
||||||
import('../api/index.js'),
|
import('../api/index.js'),
|
||||||
import('../stores/auth.js'),
|
import('../stores/auth.js'),
|
||||||
@@ -704,6 +793,121 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟自检(不需要后端):**本机录入的作业不再上灵动岛**(用户要求)。
|
||||||
|
*
|
||||||
|
* 场景:教室机器上录完作业,服务端照样广播 `homework:new`,但"自己通知自己"没有意义。
|
||||||
|
* 判定按**内容指纹 + id**(发起请求前就登记,避免"广播晚于响应"漏判),因此这里两条都要验:
|
||||||
|
* 1) 登记过的这条:投递后灵动岛状态不变;
|
||||||
|
* 2) 没登记过的同形作业(别班/别的标题):照常上岛。
|
||||||
|
*/
|
||||||
|
export async function islandHomeworkSuppressionCheck(): Promise<SmokeCheckResult> {
|
||||||
|
const [{ markHomeworkCreatedLocally, isLocalHomework, pushHomeworkToIsland }, { useAuthStore }] =
|
||||||
|
await Promise.all([import('../island/bridge.js'), import('../stores/auth.js')]);
|
||||||
|
try {
|
||||||
|
const classId = useAuthStore().classId ?? 'smoke-class';
|
||||||
|
const assignDate = '2030-01-01';
|
||||||
|
const draft = {
|
||||||
|
id: 'smoke-homework-suppressed',
|
||||||
|
classId,
|
||||||
|
title: '本机录入的作业',
|
||||||
|
content: '这条不应该弹上岛',
|
||||||
|
assignDate,
|
||||||
|
};
|
||||||
|
markHomeworkCreatedLocally(draft);
|
||||||
|
const recognized = isLocalHomework(draft);
|
||||||
|
// 同 id、同内容 → 必须认出来;换个标题 → 不算本机录入
|
||||||
|
const recognizedByFingerprint = isLocalHomework({ ...draft, id: '' });
|
||||||
|
const foreign = isLocalHomework({ ...draft, id: 'other', title: '别的作业' });
|
||||||
|
|
||||||
|
const before = await window.desktop?.islandGetState?.();
|
||||||
|
pushHomeworkToIsland({
|
||||||
|
...draft,
|
||||||
|
courseId: null,
|
||||||
|
attachmentUrl: null,
|
||||||
|
createdBy: '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
} as never);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||||
|
const after = await window.desktop?.islandGetState?.();
|
||||||
|
const untouched =
|
||||||
|
(before?.active?.id ?? null) === (after?.active?.id ?? null) &&
|
||||||
|
(before?.queued.length ?? 0) === (after?.queued.length ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: recognized && recognizedByFingerprint && !foreign && untouched,
|
||||||
|
detail:
|
||||||
|
`按 id 认出=${recognized} 按内容指纹认出=${recognizedByFingerprint} 别的作业误判=${foreign} ` +
|
||||||
|
`投递后灵动岛未变=${untouched}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: `本机作业不上岛自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟自检(不需要后端):**初次启动引导**。
|
||||||
|
*
|
||||||
|
* 冒烟每次都用全新配置目录(等于"首次启动"),因此引导必须自动弹出;
|
||||||
|
* 这里真实点击把它一步步走完,断言:遮罩关闭 + `onboardingDone` 已写入配置。
|
||||||
|
* 关闭判据用「元素从 DOM 消失」——引导是 v-if 自绘遮罩(特意不用 el-dialog,
|
||||||
|
* 避免"关闭后 DOM 残留在文档里"的误报,见 AGENTS §7 第 22 条)。
|
||||||
|
*/
|
||||||
|
export async function onboardingSelfTest(): Promise<SmokeCheckResult> {
|
||||||
|
try {
|
||||||
|
// 引导在启动后延迟 ~400ms 弹出,这里轮询等它出现
|
||||||
|
const appearDeadline = Date.now() + 5000;
|
||||||
|
while (Date.now() < appearDeadline && !document.querySelector('.onboarding-welcome')) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
if (!document.querySelector('.onboarding-welcome')) {
|
||||||
|
const config = await window.desktop?.getConfig?.();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: `初次启动引导未自动出现(onboardingDone=${config?.onboardingDone ?? '-'})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐页「下一步」到最后一页再点「开始使用」(按钮由 data-action 标识,不依赖文案)
|
||||||
|
const clicks: string[] = [];
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
const action = document.querySelector('.onboarding-welcome [data-action="finish"]')
|
||||||
|
? 'finish'
|
||||||
|
: 'next';
|
||||||
|
const button = document.querySelector<HTMLElement>(
|
||||||
|
`.onboarding-welcome [data-action="${action}"]`,
|
||||||
|
);
|
||||||
|
if (!button) break;
|
||||||
|
clicks.push(action);
|
||||||
|
button.click();
|
||||||
|
if (action === 'finish') break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
let closed = !document.querySelector('.onboarding-welcome');
|
||||||
|
const closeDeadline = Date.now() + 2000;
|
||||||
|
while (Date.now() < closeDeadline && !closed) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
closed = !document.querySelector('.onboarding-welcome');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await window.desktop?.getConfig?.();
|
||||||
|
const done = config?.onboardingDone === true;
|
||||||
|
return {
|
||||||
|
ok: closed && done,
|
||||||
|
detail: `步骤点击=[${clicks.join(',')}] 已关闭=${closed} onboardingDone=${config?.onboardingDone ?? '-'}`,
|
||||||
|
};
|
||||||
|
} 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([
|
||||||
@@ -720,6 +924,158 @@ export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟自检(不需要后端):主界面外观件 —— 主题切换与侧边栏折叠。
|
||||||
|
* 走**真实 DOM 点击**(顶栏日月按钮 / 汉堡按钮),验证:
|
||||||
|
* 1) html.dark 类与 store 同步翻转,且写入主进程配置;
|
||||||
|
* 2) 侧边栏折叠后 el-menu 进入 collapse 态、宽度收窄,配置同步;
|
||||||
|
* 结束时把主题还原为初始值(后续截图/几何用例依赖固定主题)。
|
||||||
|
*/
|
||||||
|
export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
|
||||||
|
try {
|
||||||
|
const { router } = await import('../router/index.js');
|
||||||
|
await router.push('/schedule');
|
||||||
|
if (!(await waitUntil(() => Boolean(document.querySelector('[data-test="theme-toggle"]')), 6000))) {
|
||||||
|
return { ok: false, detail: '主布局未渲染(找不到主题切换按钮)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickToggle = (selector: string): boolean => {
|
||||||
|
const node = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (!node) return false;
|
||||||
|
node.click();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) 主题:真实点击日月按钮 → html.dark 翻转 → 配置落盘
|
||||||
|
const initialDark = document.documentElement.classList.contains('dark');
|
||||||
|
if (!clickToggle('[data-test="theme-toggle"]')) return { ok: false, detail: '主题按钮点击失败' };
|
||||||
|
const flipped = await waitUntil(
|
||||||
|
() => document.documentElement.classList.contains('dark') !== initialDark,
|
||||||
|
2500,
|
||||||
|
);
|
||||||
|
const flippedDark = document.documentElement.classList.contains('dark');
|
||||||
|
const configAfterFlip = await window.desktop?.getConfig?.();
|
||||||
|
const themePersisted = configAfterFlip?.theme === (flippedDark ? 'dark' : 'light');
|
||||||
|
|
||||||
|
// 2) 侧边栏:点汉堡折叠 → collapse 态 + 宽度收窄 → 配置落盘 → 再展开还原
|
||||||
|
const asideBefore = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||||
|
const widthBefore = asideBefore?.offsetWidth ?? 0;
|
||||||
|
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
|
||||||
|
const collapsed = await waitUntil(() => Boolean(document.querySelector('.el-menu--collapse')), 2500);
|
||||||
|
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||||
|
const widthAfter = asideAfter?.offsetWidth ?? 0;
|
||||||
|
const configAfterCollapse = await window.desktop?.getConfig?.();
|
||||||
|
const collapsePersisted = configAfterCollapse?.sidebarCollapsed === true;
|
||||||
|
|
||||||
|
// 2.1) 折叠态的两条观感硬要求(用户反馈回归):
|
||||||
|
// a) 菜单里不允许残留任何**可见**的文字(Element Plus 靠宽度归零隐藏,一旦被外层样式
|
||||||
|
// 干扰就会留"半截字");
|
||||||
|
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px),否则看起来"没对齐"。
|
||||||
|
const centerXOf = (element: Element | null | undefined): number | null => {
|
||||||
|
if (!element) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0 ? rect.left + rect.width / 2 : null;
|
||||||
|
};
|
||||||
|
const leftoverText: string[] = [];
|
||||||
|
for (const node of Array.from(
|
||||||
|
document.querySelectorAll('.aside .el-menu-item > span, .aside .el-sub-menu__title > span'),
|
||||||
|
)) {
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
if (element.classList.contains('menu-icon-slot')) continue;
|
||||||
|
const text = (element.textContent ?? '').trim();
|
||||||
|
if (text && element.offsetParent !== null && element.getClientRects().length > 0) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
// 宽高都归零但 display 不为 none 的,正是"0 宽溢出"的残影来源
|
||||||
|
if (rect.width < 1) leftoverText.push(`0宽:${text.slice(0, 6)}`);
|
||||||
|
else leftoverText.push(`可见:${text.slice(0, 6)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toggleCenter = centerXOf(document.querySelector('[data-test="sidebar-toggle"]'));
|
||||||
|
const iconCenters = Array.from(
|
||||||
|
document.querySelectorAll('.aside .el-menu--collapse .el-sub-menu__title .el-icon'),
|
||||||
|
).map((node) => centerXOf(node));
|
||||||
|
const iconDeltas = iconCenters
|
||||||
|
.filter((value): value is number => value !== null && toggleCenter !== null)
|
||||||
|
.map((value) => Math.abs(value - (toggleCenter as number)));
|
||||||
|
const iconsAligned = iconDeltas.length > 0 && Math.max(...iconDeltas) <= 2;
|
||||||
|
|
||||||
|
clickToggle('[data-test="sidebar-toggle"]');
|
||||||
|
await waitUntil(() => !document.querySelector('.el-menu--collapse'), 2500);
|
||||||
|
|
||||||
|
// 3) 侧栏不溢出:菜单区自己滚动(.menu 有 min-height:0 + overflow-y:auto),
|
||||||
|
// 底部「第 N 周 / 最近同步」始终留在窗口内 —— 窗口默认高 582px,
|
||||||
|
// 在加这条结构性保证之前,两个分组全展开会把 footer 挤出屏幕(用户反馈)。
|
||||||
|
const footer = document.querySelector<HTMLElement>('.aside-footer');
|
||||||
|
const footerRect = footer?.getBoundingClientRect();
|
||||||
|
const footerVisible =
|
||||||
|
Boolean(footer && footer.offsetParent !== null) &&
|
||||||
|
Boolean(footerRect && footerRect.bottom <= window.innerHeight + 1);
|
||||||
|
|
||||||
|
// 3) 还原主题,避免影响后续用例
|
||||||
|
clickToggle('[data-test="theme-toggle"]');
|
||||||
|
await waitUntil(() => document.documentElement.classList.contains('dark') === initialDark, 2500);
|
||||||
|
|
||||||
|
const ok =
|
||||||
|
flipped &&
|
||||||
|
themePersisted &&
|
||||||
|
collapsed &&
|
||||||
|
collapsePersisted &&
|
||||||
|
widthAfter < widthBefore &&
|
||||||
|
widthAfter <= 80 &&
|
||||||
|
leftoverText.length === 0 &&
|
||||||
|
iconsAligned &&
|
||||||
|
footerVisible;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
detail:
|
||||||
|
`主题翻转=${flipped} 落盘=${themePersisted}(${configAfterFlip?.theme ?? '-'}) ` +
|
||||||
|
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 落盘=${collapsePersisted} ` +
|
||||||
|
`文字残影=${leftoverText.length === 0 ? '无' : leftoverText.join(',')} ` +
|
||||||
|
`图标与汉堡同列=${iconsAligned}(中心差=${iconDeltas.map((d) => d.toFixed(1)).join('/') || '-'},要求 ≤2px) ` +
|
||||||
|
`侧栏底部可见=${footerVisible}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: `外观件自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟自检(不需要后端):关于页(/settings/about)。
|
||||||
|
* 断言页面渲染出标题与「鸣谢 / 诊断信息」区块,且 getAppInfo 返回了新增的 configPath
|
||||||
|
* (诊断信息依赖它)。
|
||||||
|
*/
|
||||||
|
export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
|
||||||
|
try {
|
||||||
|
const { router } = await import('../router/index.js');
|
||||||
|
await router.push('/settings/about');
|
||||||
|
const rendered = await waitUntil(
|
||||||
|
() => (document.querySelector('.page-title')?.textContent ?? '').includes('关于'),
|
||||||
|
6000,
|
||||||
|
);
|
||||||
|
if (!rendered) return { ok: false, detail: '关于页未挂载' };
|
||||||
|
|
||||||
|
const text = document.body.innerText.replace(/\s+/g, ' ');
|
||||||
|
const hasThanks = text.includes('鸣谢');
|
||||||
|
const hasDiagnostics = text.includes('诊断信息');
|
||||||
|
const info = await window.desktop?.getAppInfo?.();
|
||||||
|
const hasConfigPath = Boolean(info?.configPath);
|
||||||
|
|
||||||
|
await router.push('/schedule');
|
||||||
|
return {
|
||||||
|
ok: hasThanks && hasDiagnostics && hasConfigPath,
|
||||||
|
detail: `鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
detail: `关于页自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 注册到 window,供主进程冒烟脚本调用 */
|
/** 注册到 window,供主进程冒烟脚本调用 */
|
||||||
export function registerSmokeHooks(): void {
|
export function registerSmokeHooks(): void {
|
||||||
window.__classhelperSmoke__ = {
|
window.__classhelperSmoke__ = {
|
||||||
@@ -730,6 +1086,10 @@ export function registerSmokeHooks(): void {
|
|||||||
islandRealtimeScenario,
|
islandRealtimeScenario,
|
||||||
islandRealtimeCleanup,
|
islandRealtimeCleanup,
|
||||||
islandReadState,
|
islandReadState,
|
||||||
|
islandHomeworkSuppressionCheck,
|
||||||
|
onboardingSelfTest,
|
||||||
|
layoutChromeSelfTest,
|
||||||
|
aboutPageSelfTest,
|
||||||
scheduleTimelineSelfTest,
|
scheduleTimelineSelfTest,
|
||||||
homeworkBoardSelfTest,
|
homeworkBoardSelfTest,
|
||||||
sessionCleanup,
|
sessionCleanup,
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useOnboardingStore } from '../stores/onboarding.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初次启动引导(六步向导)。
|
||||||
|
*
|
||||||
|
* - 首次启动自动弹出(App.vue 按 `onboardingDone` 判定);完成 / 跳过(右上角按钮 / Esc)
|
||||||
|
* 都算看过,由这里写入主进程配置,之后不再自动弹出。
|
||||||
|
* - 登录页与设置页的「使用引导」入口可随时重看(重看关闭时再写一次也无副作用)。
|
||||||
|
* - **用 v-if 自绘遮罩而不是 el-dialog**:关闭即从 DOM 卸载。冒烟断言"引导已关闭"时
|
||||||
|
* 不会踩 el-dialog「DOM 残留在文档里」的坑(AGENTS §7 第 22 条)。
|
||||||
|
* - 操作按钮用 `data-action` 标识(prev/next/finish/skip),冒烟点击不依赖按钮文案。
|
||||||
|
*/
|
||||||
|
const onboarding = useOnboardingStore();
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
icon: 'School',
|
||||||
|
title: '欢迎使用班级小助手',
|
||||||
|
lines: [
|
||||||
|
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
|
||||||
|
'第一次使用,先花半分钟看看怎么开始。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'Link',
|
||||||
|
title: '第一步 · 连接服务器',
|
||||||
|
lines: [
|
||||||
|
'在登录页填写老师或管理员提供的服务器地址(通常形如 http://192.168.x.x:4000)。',
|
||||||
|
'点「测试连接」确认可达;以后也能在「设置」里随时修改。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'Key',
|
||||||
|
title: '第二步 · 登录班级',
|
||||||
|
lines: [
|
||||||
|
'使用班级码 + 班级密码登录(向老师或管理员索取)。',
|
||||||
|
'登录后这台设备就代表全班:作业完成、通知已读都会按全班记录,不需要每个学生单独登录。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'Calendar',
|
||||||
|
title: '登录之后',
|
||||||
|
lines: [
|
||||||
|
'左侧菜单切换「课表 / 作业 / 通知 / 成绩」四个页面,作业支持看板全屏展示。',
|
||||||
|
'断网时自动显示最近同步的缓存数据,联网后自动更新。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'Bell',
|
||||||
|
title: '灵动岛提醒',
|
||||||
|
lines: [
|
||||||
|
'屏幕上方会有一条「灵动岛」浮窗:新通知、叫人、作业都会在那里弹出。',
|
||||||
|
'上课时段自动隐藏、下课自动弹出;外观与位置可在「设置 → 灵动岛」调整。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'CircleCheckFilled',
|
||||||
|
title: '准备好了',
|
||||||
|
lines: [
|
||||||
|
'按上面的步骤登录就可以开始使用了。',
|
||||||
|
'侧边栏「设置」分组里有通用、外观(深色模式)、灵动岛等页面;这份引导可以在登录页或「设置 → 通用 → 使用引导」随时重看。',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const index = ref(0);
|
||||||
|
const current = computed(() => steps[index.value] ?? steps[0]);
|
||||||
|
const isFirst = computed(() => index.value === 0);
|
||||||
|
const isLast = computed(() => index.value === steps.length - 1);
|
||||||
|
|
||||||
|
/** 每次打开都从第一步开始(重看入口也走完整流程) */
|
||||||
|
watch(
|
||||||
|
() => onboarding.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) index.value = 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function goNext(): void {
|
||||||
|
if (isLast.value) finish();
|
||||||
|
else index.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPrev(): void {
|
||||||
|
if (!isFirst.value) index.value -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成 / 跳过统一走这里:记「已看过」并关闭(重看时重复写一次无副作用) */
|
||||||
|
function finish(): void {
|
||||||
|
const bridge = window.desktop;
|
||||||
|
// 桥接不可用(浏览器调试)也要能正常关掉引导
|
||||||
|
if (!bridge) {
|
||||||
|
onboarding.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void bridge
|
||||||
|
.saveConfig({ onboardingDone: true })
|
||||||
|
.then(() => onboarding.close())
|
||||||
|
.catch(() => onboarding.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') finish();
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('keydown', onKeydown);
|
||||||
|
}
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="onboarding-fade">
|
||||||
|
<div
|
||||||
|
v-if="onboarding.visible"
|
||||||
|
class="onboarding-mask"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="初次启动引导"
|
||||||
|
>
|
||||||
|
<div class="onboarding-card onboarding-welcome">
|
||||||
|
<div class="onboarding-head">
|
||||||
|
<span class="onboarding-logo">
|
||||||
|
<el-icon :size="18"><School /></el-icon>
|
||||||
|
</span>
|
||||||
|
<div class="onboarding-heading">
|
||||||
|
<h2 class="onboarding-title">班级小助手</h2>
|
||||||
|
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
|
||||||
|
</div>
|
||||||
|
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-body">
|
||||||
|
<span class="onboarding-icon">
|
||||||
|
<el-icon :size="30"><component :is="current.icon" /></el-icon>
|
||||||
|
</span>
|
||||||
|
<h3 class="onboarding-step-title">{{ current.title }}</h3>
|
||||||
|
<p v-for="(line, lineIndex) in current.lines" :key="lineIndex" class="onboarding-line">
|
||||||
|
{{ line }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-foot">
|
||||||
|
<div class="onboarding-dots">
|
||||||
|
<span
|
||||||
|
v-for="(step, dotIndex) in steps"
|
||||||
|
:key="dotIndex"
|
||||||
|
class="dot"
|
||||||
|
:class="{ active: dotIndex === index }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-actions">
|
||||||
|
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
|
||||||
|
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
|
||||||
|
下一步
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.onboarding-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 30, 50, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card {
|
||||||
|
width: 520px;
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 86vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--ch-layer-solid);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
box-shadow: 0 24px 64px rgba(15, 30, 50, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-heading {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-sub {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ch-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 18px 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--ch-accent);
|
||||||
|
background: var(--ch-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-step-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-line {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--ch-text-regular, #606266);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--ch-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ch-border-strong);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.active {
|
||||||
|
width: 20px;
|
||||||
|
background: var(--ch-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进出场:淡入 + 轻微上浮,跟随系统"动画"开关无强依赖 */
|
||||||
|
.onboarding-fade-enter-active,
|
||||||
|
.onboarding-fade-leave-active {
|
||||||
|
transition: opacity 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-fade-enter-active .onboarding-card,
|
||||||
|
.onboarding-fade-leave-active .onboarding-card {
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-fade-enter-from,
|
||||||
|
.onboarding-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-fade-enter-from .onboarding-card,
|
||||||
|
.onboarding-fade-leave-to .onboarding-card {
|
||||||
|
transform: translateY(10px) scale(0.98);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -92,6 +92,11 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
|
|||||||
|
|
||||||
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
|
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
|
||||||
export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
||||||
|
// 本机刚录入的作业不再上岛:教室机器上录作业的人就是眼前这台设备的主人,
|
||||||
|
// 再弹一张"新作业"卡片纯属自己通知自己(用户反馈)。
|
||||||
|
if (isLocalHomework(homework)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.desktop?.islandPush({
|
window.desktop?.islandPush({
|
||||||
notification: {
|
notification: {
|
||||||
id: `homework-${homework.id}`,
|
id: `homework-${homework.id}`,
|
||||||
@@ -107,6 +112,62 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 本机录入的作业 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本机刚录入的作业指纹。
|
||||||
|
*
|
||||||
|
* 为什么按指纹而不只按 id:`homework:new` 是服务端**先广播、后回响应**的,
|
||||||
|
* 录入接口返回的 id 很可能比实时事件到得还晚 —— 只记 id 会漏掉"事件先到"的那一半,
|
||||||
|
* 于是本机录的作业照样弹上岛。因此在发请求**之前**就按内容记一份指纹,
|
||||||
|
* 回来后补记 id(覆盖事件晚到的情况)。指纹带 TTL,避免长期占用内存。
|
||||||
|
*/
|
||||||
|
const localHomeworkKeys = new Set<string>();
|
||||||
|
let localHomeworkKeysAt = 0;
|
||||||
|
const LOCAL_HOMEWORK_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
/** 作业内容指纹:同一班、同一天、同标题同正文 = 就是本机刚录的那条 */
|
||||||
|
function homeworkFingerprint(homework: {
|
||||||
|
classId?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
assignDate?: string | null;
|
||||||
|
}): string {
|
||||||
|
return [
|
||||||
|
homework.classId ?? '',
|
||||||
|
homework.assignDate ?? '',
|
||||||
|
(homework.title ?? '').trim(),
|
||||||
|
(homework.content ?? '').trim(),
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 录入前/后登记"这条作业是本机录的"(id 可后补) */
|
||||||
|
export function markHomeworkCreatedLocally(homework: {
|
||||||
|
id?: string | null;
|
||||||
|
classId?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
assignDate?: string | null;
|
||||||
|
}): void {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - localHomeworkKeysAt > LOCAL_HOMEWORK_TTL_MS) localHomeworkKeys.clear();
|
||||||
|
localHomeworkKeysAt = now;
|
||||||
|
localHomeworkKeys.add(homeworkFingerprint(homework));
|
||||||
|
if (homework.id) localHomeworkKeys.add(homework.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这条作业是不是本机刚录的(供投递前判定与冒烟验证) */
|
||||||
|
export function isLocalHomework(homework: {
|
||||||
|
id?: string | null;
|
||||||
|
classId?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
assignDate?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
if (homework.id && localHomeworkKeys.has(homework.id)) return true;
|
||||||
|
return localHomeworkKeys.has(homeworkFingerprint(homework));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
|
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
|
||||||
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
|
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
|
||||||
@@ -148,15 +209,40 @@ export function subscribeIslandMarkRead(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灵动岛点了"标为已读"(多条通知的整批)→ 把这一批都在通知中心里标记已读。
|
||||||
|
* 与单条版本同一套兜底:离线时先在本地记为已读。
|
||||||
|
*/
|
||||||
|
export function subscribeIslandMarkAllRead(): void {
|
||||||
|
window.desktop?.onIslandMarkAllRead?.((ids) => {
|
||||||
|
void (async () => {
|
||||||
|
const store = useNotificationStore();
|
||||||
|
await Promise.all(
|
||||||
|
(ids ?? []).map(async (id) => {
|
||||||
|
try {
|
||||||
|
await store.markRead(id);
|
||||||
|
} catch {
|
||||||
|
const target = store.items.find((item) => item.id === id);
|
||||||
|
if (target) target.read = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 具名的课表变更处理器:匿名函数在停止时无法 off 掉,会随"登出→登录"越挂越多 */
|
||||||
|
function onScheduleUpdated(): void {
|
||||||
|
void refreshIslandSchedules().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export function startIslandBridge(): void {
|
export function startIslandBridge(): void {
|
||||||
if (tickTimer) return;
|
if (tickTimer) return;
|
||||||
|
|
||||||
void refreshIslandSchedules().catch(() => undefined);
|
void refreshIslandSchedules().catch(() => undefined);
|
||||||
|
|
||||||
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
|
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
|
||||||
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, () => {
|
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
|
||||||
void refreshIslandSchedules().catch(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
|
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
|
||||||
refreshTimer = setInterval(() => {
|
refreshTimer = setInterval(() => {
|
||||||
@@ -164,9 +250,31 @@ export function startIslandBridge(): void {
|
|||||||
}, REFRESH_INTERVAL_MS);
|
}, REFRESH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止桥接:退出登录 / 切换账号时必须调用。
|
||||||
|
*
|
||||||
|
* 只清定时器是不够的:
|
||||||
|
* - 订阅要 off,否则每次"登出 → 登录"都会多挂一份监听;
|
||||||
|
* - 本地课表缓存要清空,否则登出后仍会按**上一个班**的课表继续给主进程下发"上课/下课"
|
||||||
|
* (灵动岛按陈旧课表隐藏或弹出),换班登录时第一轮刷新回来之前用的也是旧课表。
|
||||||
|
*/
|
||||||
export function stopIslandBridge(): void {
|
export function stopIslandBridge(): void {
|
||||||
if (tickTimer) clearInterval(tickTimer);
|
if (tickTimer) clearInterval(tickTimer);
|
||||||
if (refreshTimer) clearInterval(refreshTimer);
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
tickTimer = null;
|
tickTimer = null;
|
||||||
refreshTimer = null;
|
refreshTimer = null;
|
||||||
|
|
||||||
|
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
|
||||||
|
schedules = [];
|
||||||
|
currentPeriod = null;
|
||||||
|
inClass = false;
|
||||||
|
|
||||||
|
// 把"不在上课"同步给主进程:否则主进程还记着上一个会话的"上课中",
|
||||||
|
// 重新登录后若第一轮课表刷新失败(离线且无缓存),灵动岛会继续按上课态把通知压着不弹,
|
||||||
|
// 直到下一次刷新成功为止。这里主动对齐一次,代价是登出瞬间可能补弹一条此前被暂存的通知。
|
||||||
|
window.desktop?.islandSetClassState({
|
||||||
|
inClass: false,
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
week: useAppStore().currentWeek,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
|
||||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||||
|
import { startIslandBridge, stopIslandBridge } 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';
|
||||||
import { useRealtimeStore } from '../stores/realtime.js';
|
import { useRealtimeStore } from '../stores/realtime.js';
|
||||||
|
import { useUiStore } from '../stores/ui.js';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -14,18 +16,75 @@ const appStore = useAppStore();
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
|
const ui = useUiStore();
|
||||||
|
|
||||||
const menuItems = [
|
/**
|
||||||
|
* 主侧边栏:ClassIsland 式「分组 + 子菜单」。
|
||||||
|
* - 学习:课表 / 作业 / 通知 / 成绩
|
||||||
|
* - 设置:通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于(每个子项都是独立路由 /settings/*)
|
||||||
|
* 顶栏左侧汉堡按钮把整栏折叠成图标栏(状态持久化在主进程配置里)。
|
||||||
|
*/
|
||||||
|
const menuGroups = [
|
||||||
|
{
|
||||||
|
title: '学习',
|
||||||
|
icon: 'Reading',
|
||||||
|
items: [
|
||||||
{ path: '/schedule', title: '课表', icon: 'Calendar' },
|
{ path: '/schedule', title: '课表', icon: 'Calendar' },
|
||||||
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
|
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
|
||||||
{ path: '/notifications', title: '通知', icon: 'Bell' },
|
{ path: '/notifications', title: '通知', icon: 'Bell' },
|
||||||
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
||||||
{ path: '/settings', title: '设置', icon: 'Setting' },
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设置',
|
||||||
|
icon: 'Setting',
|
||||||
|
items: [
|
||||||
|
{ path: '/settings/general', title: '通用', icon: 'Tools' },
|
||||||
|
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
|
||||||
|
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
|
||||||
|
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
|
||||||
|
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
|
||||||
|
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 未读红点挂在「通知」项的图标右上角 */
|
||||||
|
function showBadge(path: string): boolean {
|
||||||
|
return path === '/notifications' && notifications.unreadCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||||
const activeMenu = computed(() => route.path);
|
const activeMenu = computed(() => route.path);
|
||||||
|
|
||||||
|
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
|
||||||
|
const activeGroupTitle = computed(() => {
|
||||||
|
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
|
||||||
|
return group?.title ?? '学习';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组展开控制:默认只展开**当前路由所在的分组**。
|
||||||
|
*
|
||||||
|
* 窗口默认高度与 ClassIsland 对齐(582px),两组全展开(10 个子项 + 2 个分组标题)
|
||||||
|
* 会超出可视高度、把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的
|
||||||
|
* 观感(它也只展开当前组),又让小窗口下的侧栏始终完整可见。
|
||||||
|
* 用户手动展开其它分组不受限制(可多个同时展开)。
|
||||||
|
*/
|
||||||
|
const menuRef = ref<MenuInstance>();
|
||||||
|
const openedGroups = ref<string[]>([activeGroupTitle.value]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
async (path) => {
|
||||||
|
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
|
||||||
|
if (!group) return;
|
||||||
|
// 展开当前组(不主动关闭用户已展开的其它组)
|
||||||
|
await nextTick();
|
||||||
|
menuRef.value?.open(group.title);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 菜单点击回调。
|
* 菜单点击回调。
|
||||||
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
|
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
|
||||||
@@ -43,11 +102,13 @@ function go(name: string): void {
|
|||||||
/** 开发期自检:菜单路径必须存在于路由表中 */
|
/** 开发期自检:菜单路径必须存在于路由表中 */
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
||||||
for (const item of menuItems) {
|
for (const group of menuGroups) {
|
||||||
|
for (const item of group.items) {
|
||||||
if (!knownPaths.has(item.path)) {
|
if (!knownPaths.has(item.path)) {
|
||||||
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionType = computed(() => {
|
const connectionType = computed(() => {
|
||||||
@@ -70,10 +131,14 @@ async function handleLogout(): Promise<void> {
|
|||||||
void router.push({ name: 'login' });
|
void router.push({ name: 'login' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通知实时推送:更新列表 + 红点 + 缓存 */
|
/**
|
||||||
|
* 通知实时推送:更新通知中心列表与红点。
|
||||||
|
*
|
||||||
|
* 这里**不再**额外弹一条"收到新通知"的 toast —— `stores/realtime.ts` 收到
|
||||||
|
* notification:new 时已经弹过带优先级与标题的那条,两条堆在屏幕上纯属噪音。
|
||||||
|
*/
|
||||||
function onNotification(payload: unknown): void {
|
function onNotification(payload: unknown): void {
|
||||||
void notifications.pushRealtime(payload as NotificationDto);
|
void notifications.pushRealtime(payload as NotificationDto);
|
||||||
ElMessage({ message: '收到新通知,点击「通知」查看', type: 'info', duration: 3000 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
|
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
|
||||||
@@ -87,39 +152,64 @@ onMounted(async () => {
|
|||||||
await notifications.refreshUnreadCount();
|
await notifications.refreshUnreadCount();
|
||||||
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
|
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
|
||||||
appStore.onServerRecovered(onRecovered);
|
appStore.onServerRecovered(onRecovered);
|
||||||
|
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
|
||||||
|
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
|
||||||
|
if (auth.token) startIslandBridge();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
|
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
|
||||||
appStore.offServerRecovered(onRecovered);
|
appStore.offServerRecovered(onRecovered);
|
||||||
|
stopIslandBridge();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-container class="layout">
|
<el-container class="layout">
|
||||||
<el-aside width="200px" class="aside ch-nav">
|
<el-aside :width="ui.sidebarCollapsed ? '64px' : '200px'" class="aside ch-nav">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-toggle"
|
||||||
|
aria-label="折叠或展开侧边栏"
|
||||||
|
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
|
||||||
|
data-test="sidebar-toggle"
|
||||||
|
@click="ui.toggleSidebar()"
|
||||||
|
>
|
||||||
|
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
|
||||||
|
</button>
|
||||||
|
<template v-if="!ui.sidebarCollapsed">
|
||||||
<span class="brand-logo">
|
<span class="brand-logo">
|
||||||
<el-icon :size="16"><School /></el-icon>
|
<el-icon :size="16"><School /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="brand-text">班级小助手</span>
|
<span class="brand-text">班级小助手</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
|
<el-menu
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
ref="menuRef"
|
||||||
|
:default-active="activeMenu"
|
||||||
|
:default-openeds="openedGroups"
|
||||||
|
:collapse="ui.sidebarCollapsed"
|
||||||
|
:collapse-width="64"
|
||||||
|
class="menu"
|
||||||
|
@select="handleMenuSelect"
|
||||||
|
>
|
||||||
|
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><component :is="group.icon" /></el-icon>
|
||||||
|
<span>{{ group.title }}</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
|
||||||
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
||||||
<span class="menu-icon-slot">
|
<span class="menu-icon-slot">
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
<el-badge
|
<el-badge v-if="showBadge(item.path)" :value="notifications.unreadCount" :max="99" class="menu-badge" />
|
||||||
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
|
||||||
:value="notifications.unreadCount"
|
|
||||||
:max="99"
|
|
||||||
class="menu-badge"
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
<div class="aside-footer">
|
<div v-if="!ui.sidebarCollapsed" class="aside-footer">
|
||||||
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
|
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
|
||||||
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
|
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,6 +223,19 @@ onUnmounted(() => {
|
|||||||
<span class="server-url">{{ appStore.serverUrl }}</span>
|
<span class="server-url">{{ appStore.serverUrl }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<!-- 黑夜/白天快捷切换:外观页里也有同样的设置,两处写同一份配置 -->
|
||||||
|
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
|
||||||
|
<el-button
|
||||||
|
class="theme-toggle"
|
||||||
|
text
|
||||||
|
circle
|
||||||
|
data-test="theme-toggle"
|
||||||
|
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
|
||||||
|
@click="ui.toggleTheme()"
|
||||||
|
>
|
||||||
|
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<span class="user-chip">
|
<span class="user-chip">
|
||||||
<el-icon><UserFilled /></el-icon>
|
<el-icon><UserFilled /></el-icon>
|
||||||
@@ -141,7 +244,7 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item @click="go('settings')">
|
<el-dropdown-item @click="go('settings-general')">
|
||||||
<el-icon><Setting /></el-icon>
|
<el-icon><Setting /></el-icon>
|
||||||
设置
|
设置
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
@@ -189,12 +292,38 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
padding: 0 16px;
|
padding: 0 10px 0 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--ch-text);
|
color: var(--ch-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
|
||||||
|
.nav-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--ch-radius-control);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:hover {
|
||||||
|
background: var(--ch-hover-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠态:侧栏只剩 64px,汉堡按钮居中 */
|
||||||
|
.aside.ch-nav:has(.el-menu--collapse) .brand {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
|
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -213,10 +342,14 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
/* min-height:0 是 flex 子项能滚动的关键:否则内容撑高、底部 footer 被挤出窗口 */
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
--el-menu-bg-color: transparent;
|
--el-menu-bg-color: transparent;
|
||||||
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
|
--el-menu-hover-bg-color: var(--ch-hover-soft);
|
||||||
--el-menu-text-color: var(--ch-text);
|
--el-menu-text-color: var(--ch-text);
|
||||||
--el-menu-active-color: var(--ch-text);
|
--el-menu-active-color: var(--ch-text);
|
||||||
}
|
}
|
||||||
@@ -270,7 +403,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.header {
|
.header {
|
||||||
height: 52px;
|
height: 52px;
|
||||||
background: rgba(255, 255, 255, 0.6);
|
background: var(--ch-header-bg);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
border-bottom: 1px solid var(--ch-border);
|
border-bottom: 1px solid var(--ch-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -304,7 +437,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-chip:hover {
|
.user-chip:hover {
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--ch-hover-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
margin-right: 2px;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import ElementPlus from 'element-plus';
|
|||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
||||||
import 'element-plus/dist/index.css';
|
import 'element-plus/dist/index.css';
|
||||||
|
// 深色主题:html.dark 下的 --el-* 变量(品牌覆盖在 styles/index.css 的 html.dark 块)
|
||||||
|
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||||
|
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router/index.js';
|
import router from './router/index.js';
|
||||||
import { registerSmokeHooks } from './cache/self-test.js';
|
import { registerSmokeHooks, setSmokeTeacherCredentials } from './cache/self-test.js';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
@@ -20,8 +22,13 @@ app.use(createPinia());
|
|||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(ElementPlus, { locale: zhCn });
|
app.use(ElementPlus, { locale: zhCn });
|
||||||
|
|
||||||
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)
|
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)。
|
||||||
|
// 教师凭据由 preload 从环境变量透传(**不写在代码里**:渲染产物会进 app.asar 发给学生机)。
|
||||||
if (window.desktop?.smokeTest) {
|
if (window.desktop?.smokeTest) {
|
||||||
|
setSmokeTeacherCredentials({
|
||||||
|
username: window.desktop.smokeCredentials?.username ?? '',
|
||||||
|
password: window.desktop.smokeCredentials?.password ?? '',
|
||||||
|
});
|
||||||
registerSmokeHooks();
|
registerSmokeHooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,46 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../views/GradeView.vue'),
|
component: () => import('../views/GradeView.vue'),
|
||||||
meta: { title: '成绩' },
|
meta: { title: '成绩' },
|
||||||
},
|
},
|
||||||
|
/* 设置:主侧边栏「设置」分组下的六个子页(ClassIsland 式 主菜单+子菜单) */
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'settings',
|
redirect: '/settings/general',
|
||||||
component: () => import('../views/SettingsView.vue'),
|
},
|
||||||
meta: { title: '设置' },
|
{
|
||||||
|
path: 'settings/general',
|
||||||
|
name: 'settings-general',
|
||||||
|
component: () => import('../views/settings/GeneralSettingsView.vue'),
|
||||||
|
meta: { title: '通用设置' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/appearance',
|
||||||
|
name: 'settings-appearance',
|
||||||
|
component: () => import('../views/settings/AppearanceView.vue'),
|
||||||
|
meta: { title: '外观' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/island',
|
||||||
|
name: 'settings-island',
|
||||||
|
component: () => import('../views/settings/IslandSettingsView.vue'),
|
||||||
|
meta: { title: '灵动岛' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/reminder',
|
||||||
|
name: 'settings-reminder',
|
||||||
|
component: () => import('../views/settings/ReminderSettingsView.vue'),
|
||||||
|
meta: { title: '提醒' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/account',
|
||||||
|
name: 'settings-account',
|
||||||
|
component: () => import('../views/settings/AccountSettingsView.vue'),
|
||||||
|
meta: { title: '账号' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/about',
|
||||||
|
name: 'settings-about',
|
||||||
|
component: () => import('../views/settings/AboutView.vue'),
|
||||||
|
meta: { title: '关于' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初次启动引导的显示状态。
|
||||||
|
*
|
||||||
|
* 首次启动由 App.vue 按「配置里是否记过 onboardingDone」自动弹出;
|
||||||
|
* 登录页与设置页的「使用引导」入口调用 open() 随时重看。
|
||||||
|
* 单独一个 store 而不是组件内部 ref:触发入口(登录页 / 设置页)与
|
||||||
|
* 渲染点(App.vue 顶层)分处两棵互不相干的子树。
|
||||||
|
*/
|
||||||
|
export const useOnboardingStore = defineStore('onboarding', () => {
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
function open(): void {
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visible, open, close };
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import type { DesktopStoredConfig } from '../../types/desktop.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 界面偏好(主题 / 侧边栏折叠)。
|
||||||
|
*
|
||||||
|
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
|
||||||
|
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
|
||||||
|
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
|
||||||
|
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const THEME_KEY = 'classhelper.theme';
|
||||||
|
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
|
||||||
|
|
||||||
|
function applyThemeClass(theme: 'light' | 'dark'): void {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUiStore = defineStore('ui', () => {
|
||||||
|
const theme = ref<'light' | 'dark'>('light');
|
||||||
|
const sidebarCollapsed = ref(false);
|
||||||
|
|
||||||
|
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
|
||||||
|
async function init(): Promise<void> {
|
||||||
|
if (window.desktop) {
|
||||||
|
const config = await window.desktop.getConfig().catch(() => null);
|
||||||
|
if (config) {
|
||||||
|
theme.value = config.theme === 'dark' ? 'dark' : 'light';
|
||||||
|
sidebarCollapsed.value = config.sidebarCollapsed === true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
|
||||||
|
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
|
||||||
|
}
|
||||||
|
applyThemeClass(theme.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换主题(外观页单选 / 顶栏快捷切换共用),写回配置 */
|
||||||
|
async function setTheme(next: 'light' | 'dark'): Promise<void> {
|
||||||
|
theme.value = next;
|
||||||
|
applyThemeClass(next);
|
||||||
|
if (window.desktop) {
|
||||||
|
await window.desktop.saveConfig({ theme: next }).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(THEME_KEY, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme(): void {
|
||||||
|
void setTheme(theme.value === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主侧边栏折叠/展开(汉堡按钮),写回配置 */
|
||||||
|
async function setSidebarCollapsed(collapsed: boolean): Promise<void> {
|
||||||
|
sidebarCollapsed.value = collapsed;
|
||||||
|
if (window.desktop) {
|
||||||
|
await window.desktop.saveConfig({ sidebarCollapsed: collapsed }).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar(): void {
|
||||||
|
void setSidebarCollapsed(!sidebarCollapsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 供类型检查使用(仅引用类型) */
|
||||||
|
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
|
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
|
||||||
*
|
*
|
||||||
* 参考 ClassIsland(Windows 课件表应用)的观感:
|
* 参考 ClassIsland(Windows 课件表应用)的观感:
|
||||||
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
||||||
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
|
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
|
||||||
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
|
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
|
||||||
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
|
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
|
||||||
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
|
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
|
||||||
|
*
|
||||||
|
* 主题机制:
|
||||||
|
* - 全部颜色走 --ch-* / --el-* 变量,:root 是浅色值;
|
||||||
|
* - 深色主题 = html 根元素挂 .dark 类(由 stores/ui.ts 按配置切换),
|
||||||
|
* Element Plus 的 dark/css-vars.css(main.ts 引入)提供 --el-* 深色默认,
|
||||||
|
* 下面的 html.dark 块覆盖品牌变量(特异性高于 :root,必定生效);
|
||||||
|
* - 组件里**不要写死颜色**,一律用变量——深色回归靠这条纪律守着。
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -33,6 +40,13 @@
|
|||||||
--ch-text-secondary: #5c5c5c;
|
--ch-text-secondary: #5c5c5c;
|
||||||
--ch-text-tertiary: #8a8a8a;
|
--ch-text-tertiary: #8a8a8a;
|
||||||
|
|
||||||
|
/* 交互态(悬停底色 / 侧栏与顶栏的亚克力底 / 滚动条) */
|
||||||
|
--ch-hover-soft: rgba(0, 0, 0, 0.04);
|
||||||
|
--ch-nav-bg: rgba(255, 255, 255, 0.65);
|
||||||
|
--ch-header-bg: rgba(255, 255, 255, 0.6);
|
||||||
|
--ch-scrollbar: rgba(0, 0, 0, 0.18);
|
||||||
|
--ch-scrollbar-hover: rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
/* 圆角与阴影 */
|
/* 圆角与阴影 */
|
||||||
--ch-radius-card: 8px;
|
--ch-radius-card: 8px;
|
||||||
--ch-radius-control: 4px;
|
--ch-radius-control: 4px;
|
||||||
@@ -62,6 +76,55 @@
|
|||||||
--el-font-size-base: 13px;
|
--el-font-size-base: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 深色主题 */
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--ch-accent: #5aabec;
|
||||||
|
--ch-accent-hover: #7cc0f4;
|
||||||
|
--ch-accent-soft: rgba(90, 171, 236, 0.16);
|
||||||
|
--ch-accent-soft-strong: rgba(90, 171, 236, 0.26);
|
||||||
|
|
||||||
|
--ch-bg: #161719;
|
||||||
|
--ch-layer: rgba(32, 33, 36, 0.72);
|
||||||
|
--ch-layer-solid: #1e1f23;
|
||||||
|
--ch-layer-alt: rgba(28, 29, 32, 0.5);
|
||||||
|
--ch-card-subtle: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
--ch-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--ch-border-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
--ch-divider: rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
--ch-text: #e8e8ea;
|
||||||
|
--ch-text-secondary: #b0b1b5;
|
||||||
|
--ch-text-tertiary: #7e7f85;
|
||||||
|
|
||||||
|
--ch-hover-soft: rgba(255, 255, 255, 0.07);
|
||||||
|
--ch-nav-bg: rgba(28, 29, 32, 0.65);
|
||||||
|
--ch-header-bg: rgba(30, 31, 34, 0.6);
|
||||||
|
--ch-scrollbar: rgba(255, 255, 255, 0.22);
|
||||||
|
--ch-scrollbar-hover: rgba(255, 255, 255, 0.36);
|
||||||
|
|
||||||
|
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.45);
|
||||||
|
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
--ch-sidebar: #1e1f23;
|
||||||
|
|
||||||
|
/* Element Plus 深色下的品牌色对齐(dark/css-vars.css 的默认蓝偏亮,这里对齐品牌) */
|
||||||
|
--el-color-primary: #4a9eff;
|
||||||
|
--el-color-primary-light-3: #337fd6;
|
||||||
|
--el-color-primary-light-5: #2563ab;
|
||||||
|
--el-color-primary-light-7: #1b4a82;
|
||||||
|
--el-color-primary-light-8: #163c6b;
|
||||||
|
--el-color-primary-light-9: #122f55;
|
||||||
|
--el-color-primary-dark-2: #6cb4ff;
|
||||||
|
--el-text-color-primary: #e8e8ea;
|
||||||
|
--el-text-color-regular: #c9cacd;
|
||||||
|
--el-text-color-secondary: #9a9b9f;
|
||||||
|
--el-border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
--el-border-color-light: rgba(255, 255, 255, 0.08);
|
||||||
|
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -179,12 +242,12 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-button--default {
|
.el-button--default {
|
||||||
background: rgba(255, 255, 255, 0.7) !important;
|
background: var(--ch-layer) !important;
|
||||||
border-color: var(--ch-border-strong) !important;
|
border-color: var(--ch-border-strong) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-button--default:hover {
|
.el-button--default:hover {
|
||||||
background: rgba(0, 0, 0, 0.04) !important;
|
background: var(--ch-hover-soft) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-button--primary {
|
.el-button--primary {
|
||||||
@@ -195,7 +258,7 @@ textarea {
|
|||||||
.el-textarea__inner,
|
.el-textarea__inner,
|
||||||
.el-select__wrapper {
|
.el-select__wrapper {
|
||||||
border-radius: var(--ch-radius-control) !important;
|
border-radius: var(--ch-radius-control) !important;
|
||||||
background: rgba(255, 255, 255, 0.7) !important;
|
background: var(--ch-layer) !important;
|
||||||
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
|
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +287,38 @@ textarea {
|
|||||||
box-shadow: var(--ch-shadow-flyout) !important;
|
box-shadow: var(--ch-shadow-flyout) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 关于页的折叠卡片:与 el-card 同一套观感(分割线代替卡片描边) */
|
||||||
|
.el-collapse {
|
||||||
|
--el-collapse-border-color: var(--ch-divider);
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-collapse-item__header {
|
||||||
|
background: var(--ch-layer) !important;
|
||||||
|
border: 1px solid var(--ch-border) !important;
|
||||||
|
border-radius: var(--ch-radius-card) !important;
|
||||||
|
padding: 0 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-collapse-item__wrap {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-collapse-item__content {
|
||||||
|
padding: 12px 6px 4px !important;
|
||||||
|
color: var(--ch-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 表格 */
|
/* ---------------------------------------------------------------- 表格 */
|
||||||
|
|
||||||
.el-table {
|
.el-table {
|
||||||
--el-table-border-color: var(--ch-divider);
|
--el-table-border-color: var(--ch-divider);
|
||||||
--el-table-header-bg-color: transparent;
|
--el-table-header-bg-color: transparent;
|
||||||
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
|
--el-table-row-hover-bg-color: var(--ch-hover-soft);
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +340,13 @@ textarea {
|
|||||||
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
|
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
|
||||||
|
|
||||||
.ch-nav {
|
.ch-nav {
|
||||||
background: rgba(255, 255, 255, 0.65);
|
background: var(--ch-nav-bg);
|
||||||
backdrop-filter: blur(24px) saturate(1.2);
|
backdrop-filter: blur(24px) saturate(1.2);
|
||||||
border-right: 1px solid var(--ch-border);
|
border-right: 1px solid var(--ch-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ch-nav .el-menu-item {
|
.ch-nav .el-menu-item,
|
||||||
|
.ch-nav .el-sub-menu__title {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
margin: 2px 8px;
|
margin: 2px 8px;
|
||||||
@@ -266,8 +356,19 @@ textarea {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ch-nav .el-menu-item:hover {
|
.ch-nav .el-menu-item:hover,
|
||||||
background: rgba(0, 0, 0, 0.04);
|
.ch-nav .el-sub-menu__title:hover {
|
||||||
|
background: var(--ch-hover-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组标题:比普通菜单项轻一档(分组是"容器",不是目的地) */
|
||||||
|
.ch-nav .el-sub-menu__title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-nav .el-sub-menu .el-menu {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
|
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
|
||||||
@@ -288,6 +389,58 @@ textarea {
|
|||||||
background: var(--ch-accent);
|
background: var(--ch-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 折叠态:图标居中(el-menu collapse 自带收窄,这里压掉分组缩进) */
|
||||||
|
.ch-nav .el-menu--collapse .el-menu-item,
|
||||||
|
.ch-nav .el-menu--collapse .el-sub-menu__title {
|
||||||
|
margin: 2px 8px;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 折叠态:把菜单文字**彻底不渲染**,只留图标槽。
|
||||||
|
*
|
||||||
|
* Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 + overflow",一旦外层样式
|
||||||
|
* (缩进、margin、transition)稍有干扰,文字就会以 0 宽溢出的形式留在画面上 ——
|
||||||
|
* 表现为折叠后菜单里残留"半截字"。这里直接 display:none,从根上消除残影。
|
||||||
|
* 图标槽(.menu-icon-slot)必须保留(它同时承载未读红点)。
|
||||||
|
*/
|
||||||
|
.ch-nav .el-menu--collapse .el-menu-item > span:not(.menu-icon-slot),
|
||||||
|
.ch-nav .el-menu--collapse .el-sub-menu__title > span:not(.menu-icon-slot) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠态:图标在 64px 栏内严格水平居中,且与顶部的汉堡按钮同一竖列(中心都在 32px) */
|
||||||
|
.ch-nav .el-menu--collapse .el-menu-item,
|
||||||
|
.ch-nav .el-menu--collapse .el-sub-menu__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 折叠态点分组图标会弹出子菜单浮层 —— 它被 teleport 到 body 下,
|
||||||
|
* 不受 .ch-nav 作用域影响,所以这些规则写在全局;不写的话深色主题下会是浅色浮层。
|
||||||
|
*/
|
||||||
|
.el-menu--popup {
|
||||||
|
background: var(--ch-layer-solid);
|
||||||
|
border: 1px solid var(--ch-border);
|
||||||
|
border-radius: var(--ch-radius-card);
|
||||||
|
box-shadow: var(--ch-shadow-flyout);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu--popup .el-menu-item {
|
||||||
|
color: var(--ch-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu--popup .el-menu-item:hover {
|
||||||
|
background: var(--ch-hover-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu--popup .el-menu-item.is-active {
|
||||||
|
background: var(--ch-accent-soft);
|
||||||
|
color: var(--ch-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 课表时间轴 */
|
/* ---------------------------------------------------------------- 课表时间轴 */
|
||||||
|
|
||||||
.timetable-cell {
|
.timetable-cell {
|
||||||
@@ -320,14 +473,14 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: var(--ch-scrollbar);
|
||||||
border: 3px solid transparent;
|
border: 3px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--ch-scrollbar-hover);
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: #fff;
|
background: var(--ch-layer-solid);
|
||||||
border: 1px solid var(--ch-border);
|
border: 1px solid var(--ch-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -234,7 +234,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #909399;
|
color: var(--ch-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
@@ -246,7 +246,7 @@ onUnmounted(() => {
|
|||||||
.stat-hint {
|
.stat-hint {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #c0c4cc;
|
color: var(--ch-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.level-list {
|
.level-list {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { courseApi, homeworkApi } from '../api/index.js';
|
import { courseApi, homeworkApi } from '../api/index.js';
|
||||||
import { fetchWithCache } from '../cache/index.js';
|
import { fetchWithCache } from '../cache/index.js';
|
||||||
|
import { markHomeworkCreatedLocally } 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';
|
||||||
@@ -80,6 +81,17 @@ async function selectDate(value: string | null): Promise<void> {
|
|||||||
await loadHomework();
|
await loadHomework();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 深链 ?date= 不止在首次挂载生效:已在作业页时(看板/列表之间切换、冒烟直接推 query),
|
||||||
|
// 路由 query 变了也要跟着切换日期 —— 组件被复用,没有这条监听就停在原日期。
|
||||||
|
watch(
|
||||||
|
() => route.query.date,
|
||||||
|
(value) => {
|
||||||
|
if (typeof value !== 'string' || !isDayKey(value) || value === selectedDate.value) return;
|
||||||
|
selectedDate.value = value;
|
||||||
|
void loadHomework();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** 前后一天(教室电脑上不用打开日历也能翻) */
|
/** 前后一天(教室电脑上不用打开日历也能翻) */
|
||||||
async function shiftDate(delta: number): Promise<void> {
|
async function shiftDate(delta: number): Promise<void> {
|
||||||
await selectDate(shiftDayKey(selectedDate.value, delta));
|
await selectDate(shiftDayKey(selectedDate.value, delta));
|
||||||
@@ -412,13 +424,19 @@ async function submitCreate(): Promise<void> {
|
|||||||
|
|
||||||
createSaving.value = true;
|
createSaving.value = true;
|
||||||
try {
|
try {
|
||||||
await homeworkApi.create({
|
// 先按内容登记"This 是本机录的":服务端是**先广播 homework:new、后回响应**的,
|
||||||
|
// 等响应拿到 id 再登记就晚了 —— 实时事件可能已经到了,岛已经弹出来了。
|
||||||
|
const draft = { classId, title, content, assignDate: selectedDate.value };
|
||||||
|
markHomeworkCreatedLocally(draft);
|
||||||
|
const created = await homeworkApi.create({
|
||||||
classId,
|
classId,
|
||||||
courseId: createForm.value.courseId || null,
|
courseId: createForm.value.courseId || null,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
assignDate: selectedDate.value,
|
assignDate: selectedDate.value,
|
||||||
});
|
});
|
||||||
|
// 回执里带上 id 再记一次(覆盖"广播晚于响应"的另一半)
|
||||||
|
if (created?.id) markHomeworkCreatedLocally({ ...draft, id: created.id });
|
||||||
ElMessage.success(`已录入到 ${selectedDate.value}`);
|
ElMessage.success(`已录入到 ${selectedDate.value}`);
|
||||||
createVisible.value = false;
|
createVisible.value = false;
|
||||||
await loadHomework();
|
await loadHomework();
|
||||||
@@ -898,7 +916,7 @@ onUnmounted(() => {
|
|||||||
.content-block {
|
.content-block {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
background: #fafafa;
|
background: var(--ch-layer-alt);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1130,7 +1148,7 @@ onUnmounted(() => {
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #6b7280;
|
color: var(--ch-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,7 +1176,7 @@ onUnmounted(() => {
|
|||||||
.submission-list {
|
.submission-list {
|
||||||
max-height: 46vh;
|
max-height: 46vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid var(--ch-divider);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
|||||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
import { pingHealth, setApiBaseUrl } from '../api/http.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 { useOnboardingStore } from '../stores/onboarding.js';
|
||||||
import { useRealtimeStore } from '../stores/realtime.js';
|
import { useRealtimeStore } from '../stores/realtime.js';
|
||||||
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const onboarding = useOnboardingStore();
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
@@ -134,6 +136,14 @@ async function enterOffline(): Promise<void> {
|
|||||||
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
|
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 重看初次启动引导:首次启动会自动弹出过一次,这里供随时回看 -->
|
||||||
|
<div class="login-guide">
|
||||||
|
<el-button link type="primary" @click="onboarding.open()">
|
||||||
|
<el-icon><Guide /></el-icon>
|
||||||
|
查看使用引导
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="testResult === 'ok'"
|
v-if="testResult === 'ok'"
|
||||||
class="mt-12"
|
class="mt-12"
|
||||||
@@ -177,7 +187,7 @@ async function enterOffline(): Promise<void> {
|
|||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: 420px;
|
width: 420px;
|
||||||
background: #fff;
|
background: var(--ch-layer-solid);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 28px 26px 20px;
|
padding: 28px 26px 20px;
|
||||||
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
|
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
|
||||||
@@ -198,7 +208,7 @@ async function enterOffline(): Promise<void> {
|
|||||||
.login-sub {
|
.login-sub {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: var(--ch-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-actions {
|
.login-actions {
|
||||||
@@ -210,6 +220,12 @@ async function enterOffline(): Promise<void> {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-guide {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.offline-button {
|
.offline-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -218,6 +234,6 @@ async function enterOffline(): Promise<void> {
|
|||||||
margin: 16px 0 0;
|
margin: 16px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: #c0c4cc;
|
color: var(--ch-text-tertiary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -165,11 +165,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notice-item:hover {
|
.notice-item:hover {
|
||||||
background: #f7f9fc;
|
background: var(--ch-hover-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-item.unread {
|
.notice-item.unread {
|
||||||
background: #f0f7ff;
|
background: var(--ch-accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-item:last-child {
|
.notice-item:last-child {
|
||||||
@@ -194,7 +194,7 @@ onUnmounted(() => {
|
|||||||
.notice-content {
|
.notice-content {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #606266;
|
color: var(--ch-text-secondary);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -220,7 +220,7 @@ onUnmounted(() => {
|
|||||||
.content-block {
|
.content-block {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
background: #fafafa;
|
background: var(--ch-layer-alt);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-status.status-idle {
|
.hero-status.status-idle {
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--ch-hover-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-title {
|
.status-title {
|
||||||
@@ -553,7 +553,7 @@ onUnmounted(() => {
|
|||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #fff;
|
background: var(--ch-layer-solid);
|
||||||
border: 2px solid var(--ch-border-strong);
|
border: 2px solid var(--ch-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +574,8 @@ onUnmounted(() => {
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid var(--ch-border);
|
border: 1px solid var(--ch-border);
|
||||||
border-radius: var(--ch-radius-card);
|
border-radius: var(--ch-radius-card);
|
||||||
background: rgba(255, 255, 255, 0.6);
|
/* 走主题变量:浅色是白玻璃、深色是深玻璃(写死白色会在深色主题下变成"浅灰卡 + 浅字"看不清) */
|
||||||
|
background: var(--ch-layer);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.15s ease,
|
border-color 0.15s ease,
|
||||||
background 0.15s ease,
|
background 0.15s ease,
|
||||||
@@ -595,7 +596,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item.is-past .timeline-card-inner {
|
.timeline-item.is-past .timeline-card-inner {
|
||||||
background: rgba(255, 255, 255, 0.35);
|
background: var(--ch-layer-alt);
|
||||||
color: var(--ch-text-tertiary);
|
color: var(--ch-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,702 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
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 { classIslandStatusApi } from '../api/index.js';
|
|
||||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
|
||||||
import { normalizeServerUrl } from '../config.js';
|
|
||||||
import { useAppStore } from '../stores/app.js';
|
|
||||||
import { useAuthStore } from '../stores/auth.js';
|
|
||||||
import { useNotificationStore } from '../stores/notifications.js';
|
|
||||||
import { useRealtimeStore } from '../stores/realtime.js';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const appStore = useAppStore();
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const realtime = useRealtimeStore();
|
|
||||||
const notifications = useNotificationStore();
|
|
||||||
|
|
||||||
const serverInput = ref(appStore.serverUrl);
|
|
||||||
const testing = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const appInfo = ref<DesktopAppInfo | null>(null);
|
|
||||||
|
|
||||||
const storeLabels: Record<string, string> = {
|
|
||||||
profile: '登录资料',
|
|
||||||
classes: '班级 / 学期',
|
|
||||||
schedules: '课表',
|
|
||||||
homeworks: '作业',
|
|
||||||
notifications: '通知',
|
|
||||||
grades: '成绩',
|
|
||||||
};
|
|
||||||
|
|
||||||
const cacheRows = computed(() =>
|
|
||||||
appStore.cacheStoreStats.map((item) => ({
|
|
||||||
label: storeLabels[item.store] ?? item.store,
|
|
||||||
store: item.store,
|
|
||||||
count: item.count,
|
|
||||||
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function loadAppInfo(): Promise<void> {
|
|
||||||
if (!window.desktop) return;
|
|
||||||
appInfo.value = await window.desktop.getAppInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveServer(): Promise<void> {
|
|
||||||
const normalized = normalizeServerUrl(serverInput.value);
|
|
||||||
if (!normalized) {
|
|
||||||
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await appStore.applyServerUrl(normalized);
|
|
||||||
serverInput.value = normalized;
|
|
||||||
ElMessage.success('服务器地址已保存');
|
|
||||||
await testConnection();
|
|
||||||
if (auth.token) realtime.connect(normalized, auth.token);
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testConnection(): Promise<void> {
|
|
||||||
testing.value = true;
|
|
||||||
try {
|
|
||||||
const result = await pingHealth();
|
|
||||||
if (result.ok) ElMessage.success('服务器连接正常');
|
|
||||||
else ElMessage.error('无法连接服务器,请检查地址与网络');
|
|
||||||
} finally {
|
|
||||||
testing.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function manualSync(): Promise<void> {
|
|
||||||
setApiBaseUrl(appStore.serverUrl);
|
|
||||||
await appStore.ping();
|
|
||||||
if (appStore.offline) {
|
|
||||||
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await notifications.load();
|
|
||||||
appStore.markSynced();
|
|
||||||
ElMessage.success('已同步最新数据');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearCache(): Promise<void> {
|
|
||||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
|
||||||
type: 'warning',
|
|
||||||
});
|
|
||||||
await appStore.clearCache();
|
|
||||||
ElMessage.success('离线缓存已清空');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
|
||||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
|
||||||
realtime.disconnect();
|
|
||||||
await auth.logout();
|
|
||||||
ElMessage.success('已退出登录');
|
|
||||||
await router.replace('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 通知显示位置 */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
|
||||||
*
|
|
||||||
* - 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(() => {
|
|
||||||
const status = classIslandStatus.value;
|
|
||||||
if (!status || !status.connected) return '未接入';
|
|
||||||
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
|
|
||||||
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const channelOptions = computed(() =>
|
|
||||||
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
|
||||||
value,
|
|
||||||
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
|
|
||||||
disabled: value === 'classisland' && !classIslandConnected.value,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function loadClassChannel(): Promise<void> {
|
|
||||||
const classId = auth.classId;
|
|
||||||
if (!classId) return;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
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: {
|
|
||||||
id: `appearance-test-${Date.now()}`,
|
|
||||||
title: '灵动岛外观预览',
|
|
||||||
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
|
|
||||||
priority: 'NORMAL',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
courseName: null,
|
|
||||||
teacherName: '本地预览',
|
|
||||||
},
|
|
||||||
context: { inClass: false, currentPeriodEnd: null, preview: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadAppInfo();
|
|
||||||
await appStore.refreshCacheStats();
|
|
||||||
await loadIslandAppearance();
|
|
||||||
await loadClassChannel();
|
|
||||||
await loadPhrases();
|
|
||||||
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
|
||||||
if (auth.classId) {
|
|
||||||
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
|
||||||
classIslandStatus.value = status;
|
|
||||||
classIslandConnected.value = status?.connected === true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<h2 class="page-title">设置</h2>
|
|
||||||
<p class="page-subtitle">服务器连接、账号信息与离线缓存管理</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-row :gutter="12">
|
|
||||||
<el-col :xs="24" :md="12">
|
|
||||||
<el-card shadow="never">
|
|
||||||
<template #header><span>服务器连接</span></template>
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="服务器地址">
|
|
||||||
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
|
|
||||||
<template #prefix>
|
|
||||||
<el-icon><Link /></el-icon>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<div class="toolbar">
|
|
||||||
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
|
|
||||||
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
|
|
||||||
<el-button @click="manualSync">立即同步</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-descriptions class="mt-16" :column="1" border size="small">
|
|
||||||
<el-descriptions-item label="当前状态">
|
|
||||||
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
|
|
||||||
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
|
||||||
</el-tag>
|
|
||||||
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
|
|
||||||
{{ realtime.statusText }}
|
|
||||||
</el-tag>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="mt-12">
|
|
||||||
<template #header>
|
|
||||||
<span>灵动岛 · 个性化</span>
|
|
||||||
</template>
|
|
||||||
<el-form label-width="96px" label-position="left">
|
|
||||||
<el-form-item :label="`高度 ${island.height}px`">
|
|
||||||
<el-slider
|
|
||||||
:model-value="island.height"
|
|
||||||
:min="RANGES.height.min"
|
|
||||||
:max="RANGES.height.max"
|
|
||||||
:step="1"
|
|
||||||
@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>
|
|
||||||
<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">
|
|
||||||
<el-input
|
|
||||||
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>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="mt-12">
|
|
||||||
<template #header><span>账号信息</span></template>
|
|
||||||
<el-descriptions :column="1" border size="small">
|
|
||||||
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item :label="auth.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?.grade ?? '-' }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
<el-button class="mt-16" type="danger" plain @click="logout">退出登录</el-button>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
|
|
||||||
<el-col :xs="24" :md="12">
|
|
||||||
<el-card shadow="never">
|
|
||||||
<template #header><span>离线缓存(IndexedDB)</span></template>
|
|
||||||
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
|
|
||||||
<el-table-column prop="label" label="数据类型" width="130" />
|
|
||||||
<el-table-column label="条目数" width="90">
|
|
||||||
<template #default="{ row }">{{ row.count }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="最近更新" min-width="150">
|
|
||||||
<template #default="{ row }">{{ row.updatedAt }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<div class="toolbar mt-12">
|
|
||||||
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
|
||||||
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
|
||||||
</div>
|
|
||||||
<el-alert
|
|
||||||
class="mt-12"
|
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
title="离线可用"
|
|
||||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
|
||||||
/>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="mt-12">
|
|
||||||
<template #header><span>客户端信息</span></template>
|
|
||||||
<el-descriptions :column="1" border size="small">
|
|
||||||
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="Electron">{{ appInfo?.electron ?? '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="Chromium">{{ appInfo?.chrome ?? '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="Node.js">{{ appInfo?.node ?? '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.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 {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import type { DesktopAppInfo } from '../../../types/desktop.js';
|
||||||
|
import { useAppStore } from '../../stores/app.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关于(ClassIsland 同款折叠卡片式):
|
||||||
|
* 应用信息(版本/运行时/常用链接)→ 诊断信息(排查问题用)→ 鸣谢。
|
||||||
|
* 所有外链走主进程 openExternal(只放行 http/https,见 main/ipc.ts 的白名单)。
|
||||||
|
*/
|
||||||
|
const appStore = useAppStore();
|
||||||
|
|
||||||
|
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||||
|
/** 折叠面板默认展开第一项(应用信息),诊断信息默认收起 */
|
||||||
|
const expandedNames = ref(['app']);
|
||||||
|
|
||||||
|
const PROJECT_URL = 'https://github.com/LoveChengke/ClassHelper';
|
||||||
|
const ISSUES_URL = 'https://github.com/LoveChengke/ClassHelper/issues';
|
||||||
|
|
||||||
|
const collapseItems = [
|
||||||
|
{ name: 'app', title: '应用信息', icon: 'School' },
|
||||||
|
{ name: 'diagnostics', title: '查看诊断信息', icon: 'Warning' },
|
||||||
|
{ name: 'thanks', title: '鸣谢', icon: 'Pointer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function openExternal(url: string): Promise<void> {
|
||||||
|
if (window.desktop?.openExternal) {
|
||||||
|
const handled = await window.desktop.openExternal(url);
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!window.desktop) return;
|
||||||
|
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">关于</h2>
|
||||||
|
<p class="page-subtitle">版本、诊断信息与鸣谢</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-collapse v-model="expandedNames" class="about-collapse">
|
||||||
|
<!-- 应用信息 -->
|
||||||
|
<el-collapse-item name="app">
|
||||||
|
<template #title>
|
||||||
|
<span class="about-title">
|
||||||
|
<el-icon><School /></el-icon>
|
||||||
|
{{ collapseItems[0].title }}
|
||||||
|
</span>
|
||||||
|
<span class="about-extra">
|
||||||
|
班级小助手 v{{ appInfo?.appVersion ?? '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div class="about-section">
|
||||||
|
<p class="about-copy">
|
||||||
|
Copyright © 2025-2026 班级小助手
|
||||||
|
<br />
|
||||||
|
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
|
||||||
|
</p>
|
||||||
|
<div class="about-links">
|
||||||
|
<span class="about-links-label">常用链接</span>
|
||||||
|
<el-button link type="primary" @click="openExternal(PROJECT_URL)">项目主页</el-button>
|
||||||
|
<el-button link type="primary" @click="openExternal(ISSUES_URL)">问题反馈</el-button>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="1" border size="small" class="mt-12">
|
||||||
|
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="运行时">
|
||||||
|
Electron {{ appInfo?.electron ?? '-' }} · Chromium {{ appInfo?.chrome ?? '-' }} · Node
|
||||||
|
{{ appInfo?.node ?? '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
|
||||||
|
<!-- 诊断信息 -->
|
||||||
|
<el-collapse-item name="diagnostics">
|
||||||
|
<template #title>
|
||||||
|
<span class="about-title">
|
||||||
|
<el-icon><Warning /></el-icon>
|
||||||
|
{{ collapseItems[1].title }}
|
||||||
|
</span>
|
||||||
|
<span class="about-extra">报障时请附带本页信息</span>
|
||||||
|
</template>
|
||||||
|
<div class="about-section">
|
||||||
|
<el-descriptions :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="服务器地址">{{ appStore.serverUrl }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="连接状态">
|
||||||
|
<el-tag size="small" :type="appStore.serverReachable ? 'success' : 'danger'">
|
||||||
|
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="缓存条目">{{ appStore.cacheItemCount }} 条</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="配置文件">{{ appInfo?.configPath ?? '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
|
||||||
|
<!-- 鸣谢 -->
|
||||||
|
<el-collapse-item name="thanks">
|
||||||
|
<template #title>
|
||||||
|
<span class="about-title">
|
||||||
|
<el-icon><Pointer /></el-icon>
|
||||||
|
{{ collapseItems[2].title }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div class="about-section">
|
||||||
|
<ul class="about-thanks">
|
||||||
|
<li><strong>ClassIsland</strong> —— 界面风格、设置页与「今天」时间轴的交互参考</li>
|
||||||
|
<li><strong>WinIsland</strong> —— 灵动岛的窗口形态与弹簧动画参考</li>
|
||||||
|
<li><strong>Vue / Element Plus / Prisma / Express / Socket.IO</strong> —— 本项目赖以构建的开源基石</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
|
||||||
|
<p class="about-quote">谨以此应用,献给朝夕相伴的班级与同窗;愿时光流转,情谊常在。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.about-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-extra {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ch-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-copy {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-links-label {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-thanks {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
line-height: 2;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-quote {
|
||||||
|
margin: 18px 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ch-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { useAuthStore } from '../../stores/auth.js';
|
||||||
|
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号设置:本机登录的班级账号信息 + 退出登录。
|
||||||
|
* 班级密码由管理员在 Web 管理端维护(本机按用户要求不提供改密入口)。
|
||||||
|
*/
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||||
|
realtime.disconnect();
|
||||||
|
await auth.logout();
|
||||||
|
ElMessage.success('已退出登录');
|
||||||
|
await router.replace('/login');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">账号</h2>
|
||||||
|
<p class="page-subtitle">本机登录的班级账号</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>账号信息</span></template>
|
||||||
|
<el-descriptions :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="auth.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?.grade ?? '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="toolbar mt-16">
|
||||||
|
<el-button type="danger" plain @click="logout">退出登录</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted mt-12">
|
||||||
|
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
|
||||||
|
班级密码重新登录。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useUiStore } from '../../stores/ui.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外观:黑夜 / 白天模式。
|
||||||
|
* 状态与持久化都在 stores/ui.ts(主进程配置 theme 字段),顶栏右上角的
|
||||||
|
* 日月按钮与本页写同一份配置,切换立即生效(html.dark + Element Plus dark css-vars)。
|
||||||
|
*/
|
||||||
|
const ui = useUiStore();
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'light', label: '浅色', desc: '明亮界面,适合白天与投影环境' },
|
||||||
|
{ value: 'dark', label: '深色', desc: '黑夜模式,适合晚自习与暗教室' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function selectTheme(value: 'light' | 'dark'): Promise<void> {
|
||||||
|
if (value === ui.theme) return;
|
||||||
|
await ui.setTheme(value);
|
||||||
|
ElMessage.success(value === 'dark' ? '已切换到深色模式' : '已切换到浅色模式');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">外观</h2>
|
||||||
|
<p class="page-subtitle">黑夜与白天模式,切换立即生效并自动保存</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>界面主题</span></template>
|
||||||
|
<div class="theme-options">
|
||||||
|
<button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="theme-option"
|
||||||
|
:class="[`theme-${option.value}`, { active: ui.theme === option.value }]"
|
||||||
|
:data-theme-option="option.value"
|
||||||
|
@click="selectTheme(option.value)"
|
||||||
|
>
|
||||||
|
<span class="theme-preview">
|
||||||
|
<span class="preview-side" />
|
||||||
|
<span class="preview-body">
|
||||||
|
<span class="preview-line" />
|
||||||
|
<span class="preview-line short" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="theme-name">
|
||||||
|
<el-icon><Sunny v-if="option.value === 'light'" /><Moon v-else /></el-icon>
|
||||||
|
{{ option.label }}
|
||||||
|
</span>
|
||||||
|
<span class="theme-desc">{{ option.desc }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
class="mt-12"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="顶栏右上角也有主题快捷切换按钮"
|
||||||
|
description="灵动岛始终为深色浮窗,不随主题变化;登录页与全屏作业看板为固定配色,亦不受影响。"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.theme-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option {
|
||||||
|
width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--ch-border-strong);
|
||||||
|
border-radius: var(--ch-radius-card);
|
||||||
|
background: var(--ch-layer);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ch-text);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option:hover {
|
||||||
|
border-color: var(--ch-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option.active {
|
||||||
|
border-color: var(--ch-accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--ch-accent) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题缩略预览:两块小色板直接表现各自配色 */
|
||||||
|
.theme-preview {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ch-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .theme-preview {
|
||||||
|
background: #f3f3f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .theme-preview {
|
||||||
|
background: #161719;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-side {
|
||||||
|
width: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .preview-side {
|
||||||
|
background: rgba(32, 33, 36, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-line {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 80%;
|
||||||
|
background: rgba(15, 108, 189, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-line.short {
|
||||||
|
width: 50%;
|
||||||
|
background: rgba(128, 128, 128, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ch-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import {
|
||||||
|
HOMEWORK_PHRASE_DEFAULTS,
|
||||||
|
HOMEWORK_PHRASE_MAX_COUNT,
|
||||||
|
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||||
|
formatDate,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
|
||||||
|
import { normalizeServerUrl } from '../../config.js';
|
||||||
|
import { useAppStore } from '../../stores/app.js';
|
||||||
|
import { useAuthStore } from '../../stores/auth.js';
|
||||||
|
import { useNotificationStore } from '../../stores/notifications.js';
|
||||||
|
import { useOnboardingStore } from '../../stores/onboarding.js';
|
||||||
|
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用设置:服务器连接、离线缓存与作业录入短语。
|
||||||
|
* (外观 / 灵动岛 / 提醒 / 账号 / 关于 各自是独立子页,见侧边栏「设置」分组。)
|
||||||
|
*/
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const onboarding = useOnboardingStore();
|
||||||
|
const realtime = useRealtimeStore();
|
||||||
|
const notifications = useNotificationStore();
|
||||||
|
|
||||||
|
const serverInput = ref(appStore.serverUrl);
|
||||||
|
const testing = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const storeLabels: Record<string, string> = {
|
||||||
|
profile: '登录资料',
|
||||||
|
classes: '班级 / 学期',
|
||||||
|
schedules: '课表',
|
||||||
|
homeworks: '作业',
|
||||||
|
notifications: '通知',
|
||||||
|
grades: '成绩',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheRows = computed(() =>
|
||||||
|
appStore.cacheStoreStats.map((item) => ({
|
||||||
|
label: storeLabels[item.store] ?? item.store,
|
||||||
|
store: item.store,
|
||||||
|
count: item.count,
|
||||||
|
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveServer(): Promise<void> {
|
||||||
|
const normalized = normalizeServerUrl(serverInput.value);
|
||||||
|
if (!normalized) {
|
||||||
|
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await appStore.applyServerUrl(normalized);
|
||||||
|
serverInput.value = normalized;
|
||||||
|
ElMessage.success('服务器地址已保存');
|
||||||
|
await testConnection();
|
||||||
|
if (auth.token) realtime.connect(normalized, auth.token);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection(): Promise<void> {
|
||||||
|
testing.value = true;
|
||||||
|
try {
|
||||||
|
const result = await pingHealth();
|
||||||
|
if (result.ok) ElMessage.success('服务器连接正常');
|
||||||
|
else ElMessage.error('无法连接服务器,请检查地址与网络');
|
||||||
|
} finally {
|
||||||
|
testing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualSync(): Promise<void> {
|
||||||
|
setApiBaseUrl(appStore.serverUrl);
|
||||||
|
await appStore.ping();
|
||||||
|
if (appStore.offline) {
|
||||||
|
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await notifications.load();
|
||||||
|
appStore.markSynced();
|
||||||
|
ElMessage.success('已同步最新数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCache(): Promise<void> {
|
||||||
|
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
await appStore.clearCache();
|
||||||
|
ElMessage.success('离线缓存已清空');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 作业录入快捷短语 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(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;
|
||||||
|
}
|
||||||
|
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('已恢复默认');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await appStore.refreshCacheStats();
|
||||||
|
await loadPhrases();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">通用</h2>
|
||||||
|
<p class="page-subtitle">服务器连接、离线缓存与作业录入</p>
|
||||||
|
</div>
|
||||||
|
<!-- 重看初次启动引导(首启时由 App.vue 自动弹出过一次) -->
|
||||||
|
<el-button @click="onboarding.open()">
|
||||||
|
<el-icon><Guide /></el-icon>
|
||||||
|
使用引导
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>服务器连接</span></template>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="服务器地址">
|
||||||
|
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Link /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
|
||||||
|
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
|
||||||
|
<el-button @click="manualSync">立即同步</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-descriptions class="mt-16" :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="当前状态">
|
||||||
|
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
|
||||||
|
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
|
||||||
|
{{ realtime.statusText }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>作业录入</span></template>
|
||||||
|
<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">
|
||||||
|
<el-input
|
||||||
|
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>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>离线缓存(IndexedDB)</span></template>
|
||||||
|
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
|
||||||
|
<el-table-column prop="label" label="数据类型" width="130" />
|
||||||
|
<el-table-column label="条目数" width="90">
|
||||||
|
<template #default="{ row }">{{ row.count }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近更新" min-width="150">
|
||||||
|
<template #default="{ row }">{{ row.updatedAt }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="toolbar mt-12">
|
||||||
|
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||||
|
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
class="mt-12"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="离线可用"
|
||||||
|
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.phrase-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-8 {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import {
|
||||||
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
|
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||||
|
ISLAND_POSITION_LABELS,
|
||||||
|
ISLAND_POSITIONS,
|
||||||
|
ISLAND_STYLE_LABELS,
|
||||||
|
ISLAND_STYLES,
|
||||||
|
type IslandAppearance,
|
||||||
|
type IslandPosition,
|
||||||
|
type IslandStyle,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 灵动岛个性化(原设置页「灵动岛 · 个性化」卡整块迁移)。
|
||||||
|
*
|
||||||
|
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
|
||||||
|
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
|
||||||
|
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
|
||||||
|
*/
|
||||||
|
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||||
|
const savingIsland = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左右边距是否有效:只在停靠左 / 右时生效。
|
||||||
|
*
|
||||||
|
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
|
||||||
|
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
|
||||||
|
*/
|
||||||
|
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘 */
|
||||||
|
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: {
|
||||||
|
id: `appearance-test-${Date.now()}`,
|
||||||
|
title: '灵动岛外观预览',
|
||||||
|
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
courseName: null,
|
||||||
|
teacherName: '本地预览',
|
||||||
|
},
|
||||||
|
context: { inClass: false, currentPeriodEnd: null, preview: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadIslandAppearance();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">灵动岛</h2>
|
||||||
|
<p class="page-subtitle">屏幕上方的消息浮窗:外观、停靠位置与动画</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>灵动岛 · 个性化</span></template>
|
||||||
|
<el-form label-width="96px" label-position="left">
|
||||||
|
<el-form-item :label="`高度 ${island.height}px`">
|
||||||
|
<el-slider
|
||||||
|
:model-value="island.height"
|
||||||
|
:min="RANGES.height.min"
|
||||||
|
:max="RANGES.height.max"
|
||||||
|
:step="1"
|
||||||
|
@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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ml-8 {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import {
|
||||||
|
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||||
|
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||||
|
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||||
|
formatDate,
|
||||||
|
type ClassIslandNotificationChannel,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
import { classIslandStatusApi } from '../../api/index.js';
|
||||||
|
import { useAppStore } from '../../stores/app.js';
|
||||||
|
import { useAuthStore } from '../../stores/auth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提醒设置:这台教室机器把提醒弹在哪个端(客户端 / ClassIsland / 两端都弹)。
|
||||||
|
* 值同时落本地配置与班级记录(服务端据此决定是否推送 ClassIsland)。
|
||||||
|
*/
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
const status = classIslandStatus.value;
|
||||||
|
if (!status || !status.connected) return '未接入';
|
||||||
|
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
|
||||||
|
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const channelOptions = computed(() =>
|
||||||
|
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
|
||||||
|
disabled: value === 'classisland' && !classIslandConnected.value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await appStore.loadNotificationChannel(auth.classId);
|
||||||
|
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
||||||
|
if (auth.classId) {
|
||||||
|
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
||||||
|
classIslandStatus.value = status;
|
||||||
|
classIslandConnected.value = status?.connected === true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">提醒</h2>
|
||||||
|
<p class="page-subtitle">老师发通知 / 叫人时,提醒弹在哪个端</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.channel-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+54
-1
@@ -47,6 +47,17 @@ export interface DesktopStoredConfig {
|
|||||||
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
|
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
|
||||||
*/
|
*/
|
||||||
notificationChannel: ClassIslandNotificationChannel;
|
notificationChannel: ClassIslandNotificationChannel;
|
||||||
|
/**
|
||||||
|
* 是否已看过「初次启动引导」。
|
||||||
|
*
|
||||||
|
* 首次启动自动弹出一次(完成或跳过即记为看过并写入配置,之后不再自动弹出);
|
||||||
|
* 登录页与设置页的「使用引导」入口可随时重看(重看不改变该值)。
|
||||||
|
*/
|
||||||
|
onboardingDone: boolean;
|
||||||
|
/** 界面主题:light=浅色(默认)/ dark=深色(黑夜模式) */
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
/** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopAppInfo {
|
export interface DesktopAppInfo {
|
||||||
@@ -56,6 +67,8 @@ export interface DesktopAppInfo {
|
|||||||
node: string;
|
node: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
|
/** 主进程配置文件(config.json)的绝对路径(关于页「诊断信息」展示) */
|
||||||
|
configPath: string;
|
||||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */
|
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */
|
||||||
smokeTest: boolean;
|
smokeTest: boolean;
|
||||||
}
|
}
|
||||||
@@ -80,6 +93,20 @@ export interface IslandClassStatePayload {
|
|||||||
export interface DesktopBridge {
|
export interface DesktopBridge {
|
||||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
||||||
smokeTest: boolean;
|
smokeTest: boolean;
|
||||||
|
/**
|
||||||
|
* 冒烟凭据(仅冒烟模式下发,否则 `undefined`)。
|
||||||
|
*
|
||||||
|
* 为什么要从环境变量绕一圈:这些凭据被渲染进程的冒烟钩子用来"造测试数据",
|
||||||
|
* 而渲染产物(`dist/renderer/assets/*.js`)会进 `app.asar` 发给每台学生机 —— asar 可直接解包,
|
||||||
|
* 把账号口令写在代码里等于随安装包一起发出去。凭据只由
|
||||||
|
* `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供(这两个脚本不进安装包)。
|
||||||
|
*/
|
||||||
|
smokeCredentials?: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
classCode: string;
|
||||||
|
classPassword: string;
|
||||||
|
};
|
||||||
getConfig(): Promise<DesktopStoredConfig>;
|
getConfig(): Promise<DesktopStoredConfig>;
|
||||||
/**
|
/**
|
||||||
* 局部更新配置。
|
* 局部更新配置。
|
||||||
@@ -113,6 +140,8 @@ export interface DesktopBridge {
|
|||||||
islandGetAppearance(): Promise<IslandAppearance>;
|
islandGetAppearance(): Promise<IslandAppearance>;
|
||||||
/** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */
|
/** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */
|
||||||
onIslandMarkRead(handler: (id: string) => void): void;
|
onIslandMarkRead(handler: (id: string) => void): void;
|
||||||
|
/** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */
|
||||||
|
onIslandMarkAllRead(handler: (ids: string[]) => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
|
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
|
||||||
@@ -121,7 +150,18 @@ export interface IslandRendererBridge {
|
|||||||
/** 订阅外观设置(CSS 变量实时生效) */
|
/** 订阅外观设置(CSS 变量实时生效) */
|
||||||
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
onAppearance(handler: (appearance: IslandAppearance) => void): void;
|
||||||
getAppearance(): Promise<IslandAppearance>;
|
getAppearance(): Promise<IslandAppearance>;
|
||||||
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
|
sendAction(
|
||||||
|
action:
|
||||||
|
| 'expand'
|
||||||
|
| 'expand-list'
|
||||||
|
| 'collapse'
|
||||||
|
| 'dismiss'
|
||||||
|
| 'dismiss-all'
|
||||||
|
| 'mark-read'
|
||||||
|
| 'mark-all-read'
|
||||||
|
| 'open-app',
|
||||||
|
id?: string,
|
||||||
|
): void;
|
||||||
getState(): Promise<IslandState>;
|
getState(): Promise<IslandState>;
|
||||||
/**
|
/**
|
||||||
* 命中提示:指针在岛体上时应让固定大窗口接收鼠标(比主进程 60ms 轮询更快)。
|
* 命中提示:指针在岛体上时应让固定大窗口接收鼠标(比主进程 60ms 轮询更快)。
|
||||||
@@ -131,6 +171,11 @@ export interface IslandRendererBridge {
|
|||||||
setInteractive(interactive: boolean): void;
|
setInteractive(interactive: boolean): void;
|
||||||
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
|
||||||
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
|
||||||
|
/**
|
||||||
|
* 上报"本机是触摸屏"(`navigator.maxTouchPoints > 0`):主进程据此让窗口贴合岛体并始终接收
|
||||||
|
* 输入,否则触摸屏上手指永远点不到岛(触摸不产生 mousemove、也不移动系统光标)。
|
||||||
|
*/
|
||||||
|
setTouchMode(enabled: boolean): void;
|
||||||
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
|
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
|
||||||
alive(): void;
|
alive(): void;
|
||||||
}
|
}
|
||||||
@@ -175,6 +220,14 @@ declare global {
|
|||||||
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
/** 作业看板全屏自适应自检(切看板 → 打开全屏 → 量尺寸) */
|
/** 作业看板全屏自适应自检(切看板 → 打开全屏 → 量尺寸) */
|
||||||
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
/** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */
|
||||||
|
islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
/** 初次启动引导自检(首启自动出现 → 真实点击走完 → 完成状态写入配置) */
|
||||||
|
onboardingSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
/** 主界面外观件自检(真实点击:主题切换 html.dark + 落盘、侧边栏折叠 + 落盘) */
|
||||||
|
layoutChromeSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
/** 关于页自检(/settings/about 渲染 + getAppInfo 含 configPath) */
|
||||||
|
aboutPageSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ CORS_ORIGIN=*
|
|||||||
BCRYPT_ROUNDS=10
|
BCRYPT_ROUNDS=10
|
||||||
|
|
||||||
# 教师新增学生账号时未指定密码所使用的初始密码
|
# 教师新增学生账号时未指定密码所使用的初始密码
|
||||||
DEFAULT_STUDENT_PASSWORD=123456
|
|
||||||
|
|
||||||
# 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算
|
# 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算
|
||||||
TERM_START_DATE=2026-02-23
|
TERM_START_DATE=2026-02-23
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/server",
|
"name": "@classhelper/server",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@classhelper/shared": "workspace:*",
|
"@classhelper/shared": "workspace:*",
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
"@prisma/adapter-libsql": "7.10.0",
|
"@prisma/adapter-libsql": "7.10.0",
|
||||||
|
"@prisma/adapter-mariadb": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"compression": "^1.8.2",
|
"compression": "^1.8.2",
|
||||||
@@ -34,7 +35,9 @@
|
|||||||
"express-rate-limit": "^8.7.0",
|
"express-rate-limit": "^8.7.0",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"prisma": "7.10.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
"xlsx": "0.18.5",
|
"xlsx": "0.18.5",
|
||||||
"zod": "^4.6.2"
|
"zod": "^4.6.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 课表行来源:manual=手工建立(Web 端录入 / JSON 导入)/ classisland=ClassIsland 插件上报同步。
|
||||||
|
--
|
||||||
|
-- 用途:插件每次全量上报课表时,只清理「source=classisland 且本次未再上报」的行。
|
||||||
|
-- 这样"老师在 ClassIsland 里删掉一节"能被真正同步下来(原先 merge 只 upsert,
|
||||||
|
-- 服务端会永远留着那行幽灵课),同时老师在小助手这边手排的课不受影响。
|
||||||
|
-- 存量数据默认按 manual 处理(保守:宁可留下也不误删)。
|
||||||
|
ALTER TABLE "Schedule" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual';
|
||||||
@@ -146,6 +146,11 @@ model Schedule {
|
|||||||
weekEnd Int @default(20)
|
weekEnd Int @default(20)
|
||||||
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
|
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
|
||||||
weekParity String @default("ALL")
|
weekParity String @default("ALL")
|
||||||
|
/// 该行的来源:manual=手工建立(Web 端录入 / ClassIsland JSON 导入)
|
||||||
|
/// / classisland=插件上报同步得到。
|
||||||
|
/// 插件每次全量上报时只清理「classisland 来源且本次未再上报」的行(解决"老师在 ClassIsland
|
||||||
|
/// 里删掉一节、服务端却永远留着"的幽灵课),老师手排的课一律不删。
|
||||||
|
source String @default("manual")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ async function main(): Promise<void> {
|
|||||||
const random = createRandom(20260901);
|
const random = createRandom(20260901);
|
||||||
|
|
||||||
// 同一角色共用密码,只需各哈希一次
|
// 同一角色共用密码,只需各哈希一次
|
||||||
const [adminHash, teacherHash, studentHash, classPasswordHash] = await Promise.all([
|
// 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录
|
||||||
|
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
|
||||||
hashPassword('admin123'),
|
hashPassword('admin123'),
|
||||||
hashPassword('teacher123'),
|
hashPassword('teacher123'),
|
||||||
hashPassword('student123'),
|
|
||||||
// 班级账号默认密码(学生端「班级登录」)
|
// 班级账号默认密码(学生端「班级登录」)
|
||||||
hashPassword('123456'),
|
hashPassword('123456'),
|
||||||
]);
|
]);
|
||||||
@@ -142,7 +142,8 @@ async function main(): Promise<void> {
|
|||||||
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
|
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
|
||||||
role: 'STUDENT',
|
role: 'STUDENT',
|
||||||
classId: targetClass.id,
|
classId: targetClass.id,
|
||||||
passwordHash: studentHash,
|
// 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到
|
||||||
|
passwordHash: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
students.push(student);
|
students.push(student);
|
||||||
@@ -376,10 +377,8 @@ async function main(): Promise<void> {
|
|||||||
console.log(`管理员 admin / admin123 (${admin.name})`);
|
console.log(`管理员 admin / admin123 (${admin.name})`);
|
||||||
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
|
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
|
||||||
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
|
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
|
||||||
console.log('学生 student01 / student123 (高一(1)班)');
|
console.log('学生端(班级账号):G101 / G102 / G203,密码 123456');
|
||||||
console.log('学生 student06 / student123 (高一(2)班)');
|
console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)');
|
||||||
console.log('学生 student11 / student123 (高二(3)班)');
|
|
||||||
console.log('学生 student15 / student123 (高二(3)班)');
|
|
||||||
console.log('==========================================\n');
|
console.log('==========================================\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ async function main() {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
|
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
|
||||||
const reportBody = {
|
const reportBody = {
|
||||||
pluginVersion: '0.1.0.0',
|
pluginVersion: '1.0.0.0',
|
||||||
classIslandVersion: '2.1.0.0',
|
classIslandVersion: '2.1.0.0',
|
||||||
state: {
|
state: {
|
||||||
inClass: true,
|
inClass: true,
|
||||||
@@ -466,6 +466,109 @@ async function main() {
|
|||||||
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
|
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 5.1 课表同步:幽灵行清理 + 机器码回填 */
|
||||||
|
// 老师在 Web 端手排的一节课(source=manual):插件同步时**绝不能**删它
|
||||||
|
const courseList = await api(`/courses?classId=${target.id}`, { token: teacherToken });
|
||||||
|
const courseId = (courseList.payload?.data ?? [])[0]?.id;
|
||||||
|
const manualCreated = await api('/schedules', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId: target.id,
|
||||||
|
courseId,
|
||||||
|
dayOfWeek: 7,
|
||||||
|
startTime: '21:00',
|
||||||
|
endTime: '21:45',
|
||||||
|
weekStart: 1,
|
||||||
|
weekEnd: 20,
|
||||||
|
weekParity: 'ALL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manualScheduleId = manualCreated.payload?.data?.id;
|
||||||
|
record(
|
||||||
|
'(准备)Web 端手排一节课(source=manual)',
|
||||||
|
manualCreated.status === 201 && Boolean(manualScheduleId),
|
||||||
|
`status=${manualCreated.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry = (startTime, endTime) => ({
|
||||||
|
dayOfWeek: 6,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
subject: '语文',
|
||||||
|
weekParity: 'ALL',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 该班课表里"周六 19:xx"的行(插件上报写入的那些) */
|
||||||
|
const listSaturday = async () => {
|
||||||
|
const list = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
|
||||||
|
return (list.payload?.data ?? []).filter(
|
||||||
|
(item) => item.dayOfWeek === 6 && item.startTime.startsWith('19:'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 第一次上报:两节(第二节随后会被"老师在 ClassIsland 里删掉")
|
||||||
|
const ghostReport = await api('/integrations/classisland/report', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: {
|
||||||
|
deviceKey: 'smoke-machine-01',
|
||||||
|
schedule: { mode: 'merge', entries: [entry('19:00', '19:45'), entry('19:50', '20:35')] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'插件上报两节课(其中一节随后会被删除)',
|
||||||
|
ghostReport.payload?.data?.scheduleApplied === true,
|
||||||
|
`status=${ghostReport.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const beforePrune = await listSaturday();
|
||||||
|
record('上报后两节都在库里', beforePrune.length === 2, `周六 19 点=${beforePrune.length} 节`);
|
||||||
|
|
||||||
|
// 第二次上报:只报第二节 → 第一节应当被清理。
|
||||||
|
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
|
||||||
|
// 学生端与 Web 端会一直显示一节实际上已经不上的课。
|
||||||
|
const pruneReport = await api('/integrations/classisland/report', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: {
|
||||||
|
deviceKey: 'smoke-machine-01',
|
||||||
|
schedule: { mode: 'merge', entries: [entry('19:50', '20:35')] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const afterPrune = await listSaturday();
|
||||||
|
record(
|
||||||
|
'插件侧删除的课被同步清理(不再留幽灵课)',
|
||||||
|
pruneReport.payload?.data?.scheduleApplied === true &&
|
||||||
|
afterPrune.length === 1 &&
|
||||||
|
afterPrune[0]?.startTime === '19:50',
|
||||||
|
`周六 19 点=${afterPrune.map((item) => item.startTime).join(',') || '空'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 只清理"插件同步出来的行":老师手排的课必须活着
|
||||||
|
const scheduleList = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
|
||||||
|
const manualAlive = (scheduleList.payload?.data ?? []).some((item) => item.id === manualScheduleId);
|
||||||
|
record('Web 端手排的课不被插件同步删除', manualAlive, `手排行 id=${manualScheduleId ?? '-'}`);
|
||||||
|
|
||||||
|
// 机器码回填:设备列表里不再是 pending-…
|
||||||
|
const deviceList = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
|
||||||
|
const backfilledKey =
|
||||||
|
(deviceList.payload?.data ?? []).find((item) => item.id === deviceId)?.deviceKey ?? '';
|
||||||
|
record(
|
||||||
|
'插件上报的机器码已回填到设备记录',
|
||||||
|
backfilledKey === 'smoke-machine-01',
|
||||||
|
`deviceKey=${backfilledKey}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清理:把手排的课与本次上报写进去的课都删掉,避免污染演示数据
|
||||||
|
if (manualScheduleId) {
|
||||||
|
await api(`/schedules/${manualScheduleId}`, { method: 'DELETE', token: teacherToken });
|
||||||
|
}
|
||||||
|
for (const row of await listSaturday()) {
|
||||||
|
await api(`/schedules/${row.id}`, { method: 'DELETE', token: teacherToken });
|
||||||
|
}
|
||||||
|
record('清理:本次验证写入的课表与手排课已删除', (await listSaturday()).length === 0, '周六 19 点已清空');
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 6. 停用设备
|
// ---------------------------------------------------------------- 6. 停用设备
|
||||||
await api(`/integrations/devices/${deviceId}`, {
|
await api(`/integrations/devices/${deviceId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -676,12 +676,13 @@ async function main() {
|
|||||||
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
|
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetResult = await api(`/students/${addedStudentId}/reset-password`, {
|
// 学生没有密码相关接口(个人学生账号已清理,重置密码入口已下线):这里改为断言接口确实不存在
|
||||||
|
const resetGone = await api(`/students/${addedStudentId}/reset-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: adminToken,
|
||||||
body: {},
|
body: {},
|
||||||
});
|
});
|
||||||
record('重置学生密码', resetResult.status === 200, `status=${resetResult.status}`);
|
record('学生重置密码接口已下线(404)', resetGone.status === 404, `status=${resetGone.status}`);
|
||||||
|
|
||||||
const renamed = await api(`/students/${addedStudentId}`, {
|
const renamed = await api(`/students/${addedStudentId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -1597,11 +1598,8 @@ async function main() {
|
|||||||
|
|
||||||
// 8.4 学生名单导入(仅管理员)+ 数据清理
|
// 8.4 学生名单导入(仅管理员)+ 数据清理
|
||||||
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
|
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
|
||||||
const studentsCsv = [
|
// 学生导入模板已无"初始密码"列(学生没有账号属性)
|
||||||
'用户名,姓名,初始密码',
|
const studentsCsv = ['用户名,姓名', `${importedUsername},导入测试生`, 'bad user,非法用户名'].join(NL);
|
||||||
`${importedUsername},导入测试生,imp123456`,
|
|
||||||
'bad user,非法用户名,imp123456',
|
|
||||||
].join(NL);
|
|
||||||
const studentsCommit = await api('/imports/table/commit', {
|
const studentsCommit = await api('/imports/table/commit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: adminToken,
|
||||||
@@ -1610,7 +1608,7 @@ async function main() {
|
|||||||
classId,
|
classId,
|
||||||
fileName: 'students.csv',
|
fileName: 'students.csv',
|
||||||
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
||||||
mapping: { username: '用户名', name: '姓名', password: '初始密码' },
|
mapping: { username: '用户名', name: '姓名' },
|
||||||
mode: 'append',
|
mode: 'append',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1740,7 +1738,7 @@ async function main() {
|
|||||||
classId,
|
classId,
|
||||||
fileName: 'students.csv',
|
fileName: 'students.csv',
|
||||||
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
||||||
mapping: { username: '用户名', name: '姓名', password: '初始密码' },
|
mapping: { username: '用户名', name: '姓名' },
|
||||||
mode: 'append',
|
mode: 'append',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2228,6 +2226,68 @@ async function main() {
|
|||||||
`班主任账号=${deleteHeadTeacher.status}(${deleteHeadTeacher.payload?.message ?? ''}) 冒烟账号=${deleteSmokeTeacher.status}`,
|
`班主任账号=${deleteHeadTeacher.status}(${deleteHeadTeacher.payload?.message ?? ''}) 冒烟账号=${deleteSmokeTeacher.status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 级联删除护栏:`Homework.createdBy` / `Notification.createdBy` 都是 ON DELETE CASCADE,
|
||||||
|
// 因此"已不在任何班级、名下无课程,但发布过内容"的账号也必须被拒 ——
|
||||||
|
// 否则"班主任转给别人 → 解除协作 → 删除账号"会静默清掉他在各班发过的作业与通知。
|
||||||
|
const cascadeTeacherUsername = `smoke_cas_${Date.now().toString().slice(-8)}`;
|
||||||
|
const cascadeTeacher = await api('/teachers', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { username: cascadeTeacherUsername, name: '冒烟级联教师', password: 'cascade123456' },
|
||||||
|
});
|
||||||
|
const cascadeTeacherId = cascadeTeacher.payload?.data?.id;
|
||||||
|
const cascadeTeacherLogin = await api('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username: cascadeTeacherUsername, password: 'cascade123456' },
|
||||||
|
});
|
||||||
|
const cascadeTeacherToken = cascadeTeacherLogin.payload?.data?.token;
|
||||||
|
|
||||||
|
// 1) 先让他成为该班科任(这样才有发布权限),发一条通知
|
||||||
|
await api(`/classes/${classId}/teachers`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { teacherId: cascadeTeacherId },
|
||||||
|
});
|
||||||
|
const cascadeNotification = await api('/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token: cascadeTeacherToken,
|
||||||
|
body: { classId, title: '级联删除回归', content: '用于验证删除教师不会连带删除已发布内容' },
|
||||||
|
});
|
||||||
|
const cascadeNotificationId = cascadeNotification.payload?.data?.id;
|
||||||
|
// 2) 解除班级分配:此时他"不在任何班级、名下无课程",但发布过内容
|
||||||
|
await api(`/classes/${classId}/teachers/${cascadeTeacherId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token: adminToken,
|
||||||
|
});
|
||||||
|
// 3) 删除账号应当被拒(409),且那条通知必须还在
|
||||||
|
const cascadeDelete = await api(`/teachers/${cascadeTeacherId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token: adminToken,
|
||||||
|
});
|
||||||
|
const afterCascadeList = await api(`/notifications?classId=${classId}`, { token: adminToken });
|
||||||
|
const notificationAlive = (afterCascadeList.payload?.data ?? []).some(
|
||||||
|
(item) => item.id === cascadeNotificationId,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'删除教师护栏覆盖"发布过作业/通知"的账号(不再级联删除内容)',
|
||||||
|
cascadeDelete.status === 409 && notificationAlive,
|
||||||
|
`删除=${cascadeDelete.status}(${cascadeDelete.payload?.message ?? ''}) 通知仍在=${notificationAlive}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清理:内容删掉后账号即可删除
|
||||||
|
if (cascadeNotificationId) {
|
||||||
|
await api(`/notifications/${cascadeNotificationId}`, { method: 'DELETE', token: adminToken });
|
||||||
|
}
|
||||||
|
const cascadeDeleteAfter = await api(`/teachers/${cascadeTeacherId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token: adminToken,
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'清理:内容删除后该教师账号可删除',
|
||||||
|
cascadeDeleteAfter.status === 200,
|
||||||
|
`status=${cascadeDeleteAfter.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 创建班级仅管理员 + 设置/更改班主任
|
// 创建班级仅管理员 + 设置/更改班主任
|
||||||
const teacherCreateClass = await api('/classes', {
|
const teacherCreateClass = await api('/classes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -2279,6 +2339,230 @@ async function main() {
|
|||||||
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
|
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
|
||||||
|
// ---------------------------------------------------------------- 数据库管理(仅管理员)
|
||||||
|
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
|
||||||
|
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
|
||||||
|
const dbStatus = await api('/database/status', { token: adminToken });
|
||||||
|
const dbStatusData = dbStatus.payload?.data ?? {};
|
||||||
|
record(
|
||||||
|
'数据库状态(管理员):已连接且返回 14 张表行数',
|
||||||
|
dbStatus.status === 200 &&
|
||||||
|
dbStatusData.connected === true &&
|
||||||
|
Array.isArray(dbStatusData.tables) &&
|
||||||
|
dbStatusData.tables.length === 14 &&
|
||||||
|
dbStatusData.tables.every((item) => item.count >= 0),
|
||||||
|
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
|
||||||
|
(dbStatusData.tables ?? []).length
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbStatusTeacher = await api('/database/status', { token: teacherToken });
|
||||||
|
const dbBackupTeacher = await api('/database/backups', { method: 'POST', token: teacherToken });
|
||||||
|
const dbScheduleTeacher = await api('/database/backup-schedule', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { enabled: true, intervalHours: 24, keepCount: 7 },
|
||||||
|
});
|
||||||
|
const dbSwitchTeacher = await api('/database/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: { provider: 'sqlite', url: 'file:./data/teacher-forbidden.db' },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'数据库管理越权(教师全部 403)',
|
||||||
|
dbStatusTeacher.status === 403 &&
|
||||||
|
dbBackupTeacher.status === 403 &&
|
||||||
|
dbScheduleTeacher.status === 403 &&
|
||||||
|
dbSwitchTeacher.status === 403,
|
||||||
|
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支)
|
||||||
|
let testOkResult;
|
||||||
|
let testOkName;
|
||||||
|
if (dbStatusData.provider === 'sqlite') {
|
||||||
|
testOkResult = await api('/database/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { provider: 'sqlite', url: 'file:./prisma/dev.db' },
|
||||||
|
});
|
||||||
|
testOkName = '连接测试(当前 SQLite 库可达)';
|
||||||
|
} else {
|
||||||
|
testOkResult = await api('/database/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { provider: 'mysql', url: 'mysql://root:wrong-password@127.0.0.1:1/none' },
|
||||||
|
});
|
||||||
|
testOkName = '连接测试(不可达 MySQL 返回 ok=false)';
|
||||||
|
}
|
||||||
|
record(
|
||||||
|
testOkName,
|
||||||
|
testOkResult.status === 200 &&
|
||||||
|
(dbStatusData.provider === 'sqlite'
|
||||||
|
? testOkResult.payload?.data?.ok === true
|
||||||
|
: testOkResult.payload?.data?.ok === false),
|
||||||
|
`ok=${testOkResult.payload?.data?.ok} 版本=${testOkResult.payload?.data?.version} 详情=${testOkResult.payload?.data?.error ?? testOkResult.payload?.data?.note ?? '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbTestRedis = await api('/database/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'Redis 作为主库被拒绝(schema 层面 422,产品口径:主库仅 SQLite/MySQL)',
|
||||||
|
dbTestRedis.status === 422,
|
||||||
|
`status=${dbTestRedis.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const badConnection = await api('/database/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { provider: 'mysql', url: 'mysql://root:wrong@127.0.0.1:1/none' },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'连接测试(不可达 MySQL → ok=false 且不抛 500)',
|
||||||
|
badConnection.status === 200 && badConnection.payload?.data?.ok === false,
|
||||||
|
`ok=${badConnection.payload?.data?.ok} error=${(badConnection.payload?.data?.error ?? '').slice(0, 60)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 切换校验分支:同库拒绝(真实切换不在此跑,避免改写 .env)
|
||||||
|
const switchSame =
|
||||||
|
dbStatusData.provider === 'sqlite'
|
||||||
|
? await api('/database/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked },
|
||||||
|
})
|
||||||
|
: { status: 0, payload: null };
|
||||||
|
const switchInvalid = await api('/database/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'一键切换校验分支(同库 400 / 非法 provider 422,不真跑切换)',
|
||||||
|
(dbStatusData.provider === 'sqlite' ? switchSame.status === 400 : true) && switchInvalid.status === 422,
|
||||||
|
`同库=${switchSame.status}(${dbStatusData.provider === 'sqlite' ? 'sqlite 当前库' : 'mysql 跳过'}) redis=${switchInvalid.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 备份 → 导出快照 → 造数据 → 快照导入 → 造数据 → 从备份恢复(三个恢复路径全验)
|
||||||
|
const dbBackup = await api('/database/backups', { method: 'POST', token: adminToken });
|
||||||
|
const backupName = dbBackup.payload?.data?.name ?? '';
|
||||||
|
record(
|
||||||
|
'手动备份(201,文件名符合约定格式)',
|
||||||
|
dbBackup.status === 201 && /^backup-manual-\d{8}-\d{6}\.json\.gz$/.test(backupName),
|
||||||
|
`name=${backupName} size=${dbBackup.payload?.data?.sizeBytes ?? '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbExport = await fetch(`${BASE_URL}/api/database/export`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
const snapshotJson = dbExport.status === 200 ? await dbExport.text() : '';
|
||||||
|
let snapshotParsed = null;
|
||||||
|
try {
|
||||||
|
snapshotParsed = JSON.parse(snapshotJson);
|
||||||
|
} catch {
|
||||||
|
snapshotParsed = null;
|
||||||
|
}
|
||||||
|
record(
|
||||||
|
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
|
||||||
|
dbExport.status === 200 &&
|
||||||
|
snapshotParsed?.format === 'classhelper-snapshot' &&
|
||||||
|
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
|
||||||
|
(snapshotParsed?.counts?.User ?? 0) > 0,
|
||||||
|
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
|
||||||
|
const dbRoundtripNotice = await api('/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { classId, title: '数据库roundtrip自检', content: '导入/恢复后应消失' },
|
||||||
|
});
|
||||||
|
const dbRoundtripNoticeId = dbRoundtripNotice.payload?.data?.id;
|
||||||
|
const dbImport = await api('/database/import', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { data: Buffer.from(snapshotJson, 'utf8').toString('base64') },
|
||||||
|
});
|
||||||
|
const afterImport = await api(`/notifications?classId=${classId}`, { token: adminToken });
|
||||||
|
const roundtripNoticeGone = !(afterImport.payload?.data ?? []).some(
|
||||||
|
(item) => item.id === dbRoundtripNoticeId,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'快照导入(整库覆盖:导入后临时通知消失)',
|
||||||
|
dbImport.status === 200 && roundtripNoticeGone,
|
||||||
|
`导入=${dbImport.status} 通知已清除=${roundtripNoticeGone} 行数=${JSON.stringify(dbImport.payload?.data?.counts ?? {}).slice(0, 80)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbRoundtripNotice2 = await api('/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token: adminToken,
|
||||||
|
body: { classId, title: '数据库roundtrip自检2', content: '备份恢复后应消失' },
|
||||||
|
});
|
||||||
|
const dbRoundtripNotice2Id = dbRoundtripNotice2.payload?.data?.id;
|
||||||
|
const dbRestore = await api(`/database/backups/${backupName}/restore`, { method: 'POST', token: adminToken });
|
||||||
|
const afterRestore = await api(`/notifications?classId=${classId}`, { token: adminToken });
|
||||||
|
const restoreNoticeGone = !(afterRestore.payload?.data ?? []).some(
|
||||||
|
(item) => item.id === dbRoundtripNotice2Id,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'从备份恢复(整库覆盖:恢复后临时通知消失)',
|
||||||
|
dbRestore.status === 200 && restoreNoticeGone,
|
||||||
|
`恢复=${dbRestore.status} 通知已清除=${restoreNoticeGone}`,
|
||||||
|
);
|
||||||
|
if (dbRoundtripNoticeId) {
|
||||||
|
await api(`/notifications/${dbRoundtripNoticeId}`, { method: 'DELETE', token: adminToken });
|
||||||
|
}
|
||||||
|
if (dbRoundtripNotice2Id) {
|
||||||
|
await api(`/notifications/${dbRoundtripNotice2Id}`, { method: 'DELETE', token: adminToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbBackupList = await api('/database/backups', { token: adminToken });
|
||||||
|
const dbBackupDelete = await api(`/database/backups/${backupName}`, { method: 'DELETE', token: adminToken });
|
||||||
|
const dbBackupListAfter = await api('/database/backups', { token: adminToken });
|
||||||
|
record(
|
||||||
|
'备份列表与删除(创建后可见,删除后消失)',
|
||||||
|
dbBackupList.status === 200 &&
|
||||||
|
(dbBackupList.payload?.data ?? []).some((item) => item.name === backupName) &&
|
||||||
|
dbBackupDelete.status === 200 &&
|
||||||
|
!(dbBackupListAfter.payload?.data ?? []).some((item) => item.name === backupName),
|
||||||
|
`删除前=${(dbBackupList.payload?.data ?? []).length} 删除后=${(dbBackupListAfter.payload?.data ?? []).length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbScheduleInvalid = await api('/database/backup-schedule', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: adminToken,
|
||||||
|
body: { enabled: true, intervalHours: 0, keepCount: 7 },
|
||||||
|
});
|
||||||
|
const dbScheduleSave = await api('/database/backup-schedule', {
|
||||||
|
method: 'PUT',
|
||||||
|
token: adminToken,
|
||||||
|
body: { enabled: false, intervalHours: 24, keepCount: 7 },
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'定时备份配置(非法间隔 422 / 合法保存并回读)',
|
||||||
|
dbScheduleInvalid.status === 422 &&
|
||||||
|
dbScheduleSave.status === 200 &&
|
||||||
|
dbScheduleSave.payload?.data?.enabled === false &&
|
||||||
|
dbScheduleSave.payload?.data?.intervalHours === 24,
|
||||||
|
`非法=${dbScheduleInvalid.status} 保存=${dbScheduleSave.status} enabled=${dbScheduleSave.payload?.data?.enabled}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dbStatusData.provider === 'sqlite') {
|
||||||
|
const sqliteFile = await fetch(`${BASE_URL}/api/database/sqlite-file`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
const sqliteBytes = sqliteFile.status === 200 ? (await sqliteFile.arrayBuffer()).byteLength : 0;
|
||||||
|
record(
|
||||||
|
'SQLite 数据文件下载(快捷导出 .db 文件)',
|
||||||
|
sqliteFile.status === 200 && sqliteBytes > 0,
|
||||||
|
`status=${sqliteFile.status} bytes=${sqliteBytes}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
|
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
|
||||||
const realtimeNoticeId = createdNotification.payload?.data?.id;
|
const realtimeNoticeId = createdNotification.payload?.data?.id;
|
||||||
if (realtimeNoticeId) {
|
if (realtimeNoticeId) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async function healthHandler(_req: Request, res: Response): Promise<void> {
|
|||||||
res,
|
res,
|
||||||
{
|
{
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
version: '0.1.0',
|
version: '1.0.0',
|
||||||
env: env.nodeEnv,
|
env: env.nodeEnv,
|
||||||
uptimeSeconds: Math.round(process.uptime()),
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
database: {
|
database: {
|
||||||
@@ -68,7 +68,7 @@ function createRootHandler(webDistDir: string | null) {
|
|||||||
res,
|
res,
|
||||||
{
|
{
|
||||||
service: 'class-helper-server',
|
service: 'class-helper-server',
|
||||||
version: '0.1.0',
|
version: '1.0.0',
|
||||||
environment: env.nodeEnv,
|
environment: env.nodeEnv,
|
||||||
apiPrefix: API_PREFIX,
|
apiPrefix: API_PREFIX,
|
||||||
socket: '/socket.io',
|
socket: '/socket.io',
|
||||||
@@ -115,7 +115,7 @@ function renderLandingPage(webAdminServed: boolean): string {
|
|||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
|
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
|
||||||
<p class="sub">版本 0.1.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
|
<p class="sub">版本 1.0.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
|
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
|
||||||
存活 /healthz · 就绪 /readyz
|
存活 /healthz · 就绪 /readyz
|
||||||
@@ -145,8 +145,15 @@ export function createApp(): Express {
|
|||||||
app.use(securityHeaders());
|
app.use(securityHeaders());
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
app.use(cors({ origin: env.corsOrigins, credentials: true }));
|
app.use(cors({ origin: env.corsOrigins, credentials: true }));
|
||||||
app.use(express.json({ limit: '2mb' }));
|
/**
|
||||||
app.use(express.urlencoded({ extended: true }));
|
* 请求体上限:表格导入是把文件以 **base64** 放进 JSON 的,base64 约为原文件的 4/3,
|
||||||
|
* 再算上 JSON 包装,8MB 的表格文件约需 11MB 请求体。
|
||||||
|
* 这里必须留够余量,否则「导入模板写的 8MB 上限」永远到不了(文件到 1.5MB 就被 body-parser 拦掉),
|
||||||
|
* 而且用户看到的是一句 500「服务器内部错误:request entity too large」——
|
||||||
|
* 与 `table-import.service.ts` 的 MAX_IMPORT_BYTES(8MB) 校验完全对不上。
|
||||||
|
*/
|
||||||
|
app.use(express.json({ limit: '12mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||||
app.use(requestLogger());
|
app.use(requestLogger());
|
||||||
|
|
||||||
// 探针(不参与限流)
|
// 探针(不参与限流)
|
||||||
@@ -173,7 +180,10 @@ export function createApp(): Express {
|
|||||||
|
|
||||||
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
|
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
|
||||||
app.use(API_PREFIX, apiRateLimiter());
|
app.use(API_PREFIX, apiRateLimiter());
|
||||||
app.use(`${API_PREFIX}/auth/login`, loginRateLimiter());
|
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端
|
||||||
|
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
|
||||||
|
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
|
||||||
|
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
|
||||||
|
|
||||||
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
|
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
|
||||||
for (const module of apiModules) {
|
for (const module of apiModules) {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const envSchema = z.object({
|
|||||||
JWT_EXPIRES_IN: z.string().default('7d'),
|
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||||
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_CLASS_PASSWORD: z.string().min(6).default('123456'),
|
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
|
||||||
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
|
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
|
||||||
@@ -109,6 +108,22 @@ if (isProduction) {
|
|||||||
'请修改 packages/server/.env 后重试。',
|
'请修改 packages/server/.env 后重试。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS 默认值告警(**不拦截启动**)。
|
||||||
|
*
|
||||||
|
* `CORS_ORIGIN=*` 与 `credentials: true` 同时开启时,cors 中间件会反射请求方 Origin 并带上
|
||||||
|
* `Access-Control-Allow-Credentials: true`。本项目的鉴权走 `Authorization` 头(不是 cookie),
|
||||||
|
* 第三方页面拿不到用户的 token,因此实际风险有限;但"允许任意站点带凭据访问"是没有必要的暴露面,
|
||||||
|
* 生产部署应填成实际域名。这里只告警:真要卡死会挡住"教室机 file:// 页面直连后端"这类合法部署
|
||||||
|
* (那类请求的 Origin 是 `file://`,无法提前枚举),代价不值得。
|
||||||
|
*/
|
||||||
|
if (raw.CORS_ORIGIN.trim() === '*') {
|
||||||
|
console.warn(
|
||||||
|
'⚠ CORS_ORIGIN=* 且已开启 credentials:生产环境建议改成具体来源(逗号分隔),' +
|
||||||
|
'例如 CORS_ORIGIN=https://your-domain.example(教室机 file:// 客户端不受影响)。',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** trust proxy 支持 false / 数字跳数 / loopback 等字符串形式 */
|
/** trust proxy 支持 false / 数字跳数 / loopback 等字符串形式 */
|
||||||
@@ -142,7 +157,6 @@ export const env = {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
bcryptRounds: raw.BCRYPT_ROUNDS,
|
bcryptRounds: raw.BCRYPT_ROUNDS,
|
||||||
/** 新增学生时未指定密码时的初始密码 */
|
/** 新增学生时未指定密码时的初始密码 */
|
||||||
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
|
||||||
/** 新建班级时的默认班级密码(班级账号登录) */
|
/** 新建班级时的默认班级密码(班级账号登录) */
|
||||||
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
|
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
|
||||||
/** 新建/重置教师账号时的初始密码 */
|
/** 新建/重置教师账号时的初始密码 */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
|
|||||||
import { logger } from './lib/logger.js';
|
import { logger } from './lib/logger.js';
|
||||||
import { resolveWebDistDir } from './lib/web-static.js';
|
import { resolveWebDistDir } from './lib/web-static.js';
|
||||||
import { initRealtime } from './realtime/socket.js';
|
import { initRealtime } from './realtime/socket.js';
|
||||||
|
import { startBackupScheduler } from './modules/database/database.service.js';
|
||||||
|
|
||||||
/** 数据库自检与可选的首次初始化 */
|
/** 数据库自检与可选的首次初始化 */
|
||||||
async function prepareDatabase(): Promise<void> {
|
async function prepareDatabase(): Promise<void> {
|
||||||
@@ -85,6 +86,8 @@ httpServer.listen(env.port, env.host, () => {
|
|||||||
}
|
}
|
||||||
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
|
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
|
||||||
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
|
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
|
||||||
|
// 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json)
|
||||||
|
startBackupScheduler();
|
||||||
});
|
});
|
||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomInt } from 'node:crypto';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { ApiError } from './http.js';
|
import { ApiError } from './http.js';
|
||||||
import { hashPassword, verifyPassword } from './password.js';
|
import { hashPassword, verifyPassword } from './password.js';
|
||||||
@@ -30,7 +31,9 @@ export async function generateClassCode(prefix = ''): Promise<string> {
|
|||||||
const length = Math.max(4, 6 - prefix.length);
|
const length = Math.max(4, 6 - prefix.length);
|
||||||
let body = '';
|
let body = '';
|
||||||
for (let index = 0; index < length; index += 1) {
|
for (let index = 0; index < length; index += 1) {
|
||||||
body += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
|
// 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴,
|
||||||
|
// 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差)
|
||||||
|
body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
|
||||||
}
|
}
|
||||||
const code = `${prefix}${body}`.toUpperCase();
|
const code = `${prefix}${body}`.toUpperCase();
|
||||||
const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
||||||
|
|||||||
@@ -3,27 +3,33 @@ import { PrismaClient } from '../generated/prisma/client.js';
|
|||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/** 本项目支持的主数据库类型(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
|
||||||
|
export type DatabaseProvider = 'sqlite' | 'mysql';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
|
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
|
||||||
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
|
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
|
||||||
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md)
|
* - MySQL -> @prisma/adapter-mariadb(依赖里常驻,切换数据库时直接可用)
|
||||||
*
|
*
|
||||||
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
|
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
|
||||||
* 也不会影响类型检查与 SQLite 模式的运行。
|
* 也不会影响类型检查与 SQLite 模式的运行。
|
||||||
|
*
|
||||||
|
* 该工厂独立于进程级单例:数据库管理模块的「连接测试」与跨库迁移的工具进程
|
||||||
|
* 都需要按任意 provider/url 构建临时客户端,而不是读 env 单例。
|
||||||
*/
|
*/
|
||||||
async function createAdapter(): Promise<unknown> {
|
export async function createPrismaClient(
|
||||||
if (env.databaseProvider === 'mysql') {
|
provider: DatabaseProvider,
|
||||||
|
url: string,
|
||||||
|
): Promise<PrismaClient> {
|
||||||
|
if (provider === 'mysql') {
|
||||||
const adapterModule = '@prisma/adapter-mariadb';
|
const adapterModule = '@prisma/adapter-mariadb';
|
||||||
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
|
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
|
||||||
logger.info('已加载 MySQL/MariaDB 驱动适配器');
|
return new PrismaClient({ adapter: new mod.PrismaMariaDb(url) as never });
|
||||||
return new mod.PrismaMariaDb(env.databaseUrl);
|
|
||||||
}
|
}
|
||||||
return new PrismaLibSql({ url: env.databaseUrl });
|
return new PrismaClient({ adapter: new PrismaLibSql({ url }) as never });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = await createAdapter();
|
export const prisma = await createPrismaClient(env.databaseProvider, env.databaseUrl);
|
||||||
|
|
||||||
export const prisma = new PrismaClient({ adapter: adapter as never });
|
|
||||||
|
|
||||||
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
|
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
|
||||||
|
|
||||||
|
|||||||
@@ -31,5 +31,24 @@ export const booleanFlagSchema = z
|
|||||||
/** 关键字查询 */
|
/** 关键字查询 */
|
||||||
export const keywordSchema = z.string().trim().max(64).optional();
|
export const keywordSchema = z.string().trim().max(64).optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件链接。
|
||||||
|
*
|
||||||
|
* 只允许 http(s) 绝对地址或站内相对路径:这一层是为**渲染端**兜底 ——
|
||||||
|
* 该字段最终会绑到前端的 `<a href>` / `el-link :href` 上,
|
||||||
|
* 若放行 `javascript:` / `data:text/html` 就会变成一条存储型 XSS 的注入点。
|
||||||
|
* 空串与 null 都表示"没有附件"。
|
||||||
|
*/
|
||||||
|
export const attachmentUrlSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(500)
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
value === '' || value.startsWith('/') || /^https?:\/\//i.test(value),
|
||||||
|
{ message: '附件链接需为 http(s):// 地址或站内相对路径' },
|
||||||
|
)
|
||||||
|
.nullish();
|
||||||
|
|
||||||
/** 周次:1..30 */
|
/** 周次:1..30 */
|
||||||
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
|
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { PrismaClient } from '../generated/prisma/client.js';
|
||||||
|
import type { DatabaseProvider } from './db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全库 JSON 快照:备份 / 恢复 / 导入导出 / 跨库迁移共用这一种格式。
|
||||||
|
*
|
||||||
|
* 为什么不用「复制数据库文件」或 mysqldump:
|
||||||
|
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
|
||||||
|
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
|
||||||
|
*
|
||||||
|
* 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用),
|
||||||
|
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
|
||||||
|
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
|
||||||
|
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
|
||||||
|
export const SNAPSHOT_VERSION = 1;
|
||||||
|
|
||||||
|
/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */
|
||||||
|
export const SNAPSHOT_TABLES = [
|
||||||
|
'User',
|
||||||
|
'Class',
|
||||||
|
'Course',
|
||||||
|
'ClassTeacher',
|
||||||
|
'Enrollment',
|
||||||
|
'Schedule',
|
||||||
|
'TimeLayout',
|
||||||
|
'Homework',
|
||||||
|
'HomeworkStatus',
|
||||||
|
'Notification',
|
||||||
|
'NotificationRead',
|
||||||
|
'Grade',
|
||||||
|
'IntegrationDevice',
|
||||||
|
'ClassIslandPush',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
|
||||||
|
|
||||||
|
export interface DatabaseSnapshot {
|
||||||
|
format: string;
|
||||||
|
version: number;
|
||||||
|
createdAt: string;
|
||||||
|
source: { provider: string };
|
||||||
|
/** 每张表的行数(导出时统计,恢复时校验总量用) */
|
||||||
|
counts: Record<string, number>;
|
||||||
|
/** 表名 → 行数组(DateTime 已是 ISO 字符串,Prisma 接受 ISO 字符串作为 DateTime 输入) */
|
||||||
|
data: Record<SnapshotTable, Record<string, unknown>[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型代理的最小接口(避免 any:Prisma 各模型的 delegate 形状一致) */
|
||||||
|
interface ModelDelegate {
|
||||||
|
findMany(): Promise<Record<string, unknown>[]>;
|
||||||
|
count(): Promise<number>;
|
||||||
|
createMany(args: { data: Record<string, unknown>[] }): Promise<unknown>;
|
||||||
|
deleteMany(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delegatesOf(prisma: PrismaClient): Record<SnapshotTable, ModelDelegate> {
|
||||||
|
return prisma as unknown as Record<SnapshotTable, ModelDelegate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单次 createMany 的批量大小(班级规模数据量小,小批次足够且省内存) */
|
||||||
|
const CHUNK_SIZE = 200;
|
||||||
|
|
||||||
|
/** 导出全库快照(行内 DateTime 为 Date 对象,JSON.stringify 时自动转 ISO) */
|
||||||
|
export async function exportSnapshot(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
provider: DatabaseProvider,
|
||||||
|
): Promise<DatabaseSnapshot> {
|
||||||
|
const delegates = delegatesOf(prisma);
|
||||||
|
const data = {} as Record<SnapshotTable, Record<string, unknown>[]>;
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const table of SNAPSHOT_TABLES) {
|
||||||
|
const rows = await delegates[table].findMany();
|
||||||
|
data[table] = rows;
|
||||||
|
counts[table] = rows.length;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
format: SNAPSHOT_FORMAT,
|
||||||
|
version: SNAPSHOT_VERSION,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
source: { provider },
|
||||||
|
counts,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快照结构校验:给出可读的失败原因("为什么恢复失败"比失败本身重要) */
|
||||||
|
export function assertValidSnapshot(value: unknown): asserts value is DatabaseSnapshot {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
throw new Error('快照内容不是合法的 JSON 对象');
|
||||||
|
}
|
||||||
|
const snapshot = value as Partial<DatabaseSnapshot>;
|
||||||
|
if (snapshot.format !== SNAPSHOT_FORMAT) {
|
||||||
|
throw new Error(`快照格式不正确(期望 ${SNAPSHOT_FORMAT},收到 ${String(snapshot.format ?? '未知')})`);
|
||||||
|
}
|
||||||
|
if (snapshot.version !== SNAPSHOT_VERSION) {
|
||||||
|
throw new Error(`快照版本不兼容(期望 v${SNAPSHOT_VERSION},收到 v${String(snapshot.version)})`);
|
||||||
|
}
|
||||||
|
if (typeof snapshot.data !== 'object' || snapshot.data === null) {
|
||||||
|
throw new Error('快照缺少 data 字段');
|
||||||
|
}
|
||||||
|
for (const table of SNAPSHOT_TABLES) {
|
||||||
|
const rows = (snapshot.data as Record<string, unknown>)[table];
|
||||||
|
if (!Array.isArray(rows)) {
|
||||||
|
throw new Error(`快照缺少表 ${table} 的数据`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise<void> {
|
||||||
|
const sql =
|
||||||
|
provider === 'mysql'
|
||||||
|
? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}`
|
||||||
|
: `PRAGMA foreign_keys=${enabled ? 'ON' : 'OFF'}`;
|
||||||
|
await prisma.$executeRawUnsafe(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把快照写入目标库(先清空再写入)。
|
||||||
|
* 用于:备份恢复、快照导入、跨库迁移的子进程。
|
||||||
|
*/
|
||||||
|
export async function restoreSnapshot(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
provider: DatabaseProvider,
|
||||||
|
snapshot: DatabaseSnapshot,
|
||||||
|
options: { clean?: boolean } = {},
|
||||||
|
): Promise<{ counts: Record<string, number> }> {
|
||||||
|
assertValidSnapshot(snapshot);
|
||||||
|
const delegates = delegatesOf(prisma);
|
||||||
|
const clean = options.clean !== false;
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
|
||||||
|
await setForeignKeys(prisma, provider, false);
|
||||||
|
try {
|
||||||
|
if (clean) {
|
||||||
|
// 反向清空,保证被引用的表最后清(外键关闭后只是防御性的,保持确定性)
|
||||||
|
for (const table of [...SNAPSHOT_TABLES].reverse()) {
|
||||||
|
await delegates[table].deleteMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const table of SNAPSHOT_TABLES) {
|
||||||
|
const rows = snapshot.data[table];
|
||||||
|
for (let index = 0; index < rows.length; index += CHUNK_SIZE) {
|
||||||
|
await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) });
|
||||||
|
}
|
||||||
|
counts[table] = rows.length;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await setForeignKeys(prisma, provider, true).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return { counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总快照总行数(列表展示 / 切换任务结果用) */
|
||||||
|
export function snapshotTotalRows(snapshot: DatabaseSnapshot): number {
|
||||||
|
return Object.values(snapshot.counts).reduce((sum, count) => sum + count, 0);
|
||||||
|
}
|
||||||
@@ -40,6 +40,24 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求体超限(body-parser 抛的 `entity.too.large`)。
|
||||||
|
*
|
||||||
|
* 必须单独处理:它带 `status: 413` 但不是 ApiError,落进下面的兜底分支会变成
|
||||||
|
* 「500 服务器内部错误:request entity too large」——既是错的状态码,也把英文原文抛给用户,
|
||||||
|
* 而用户真正需要知道的是"文件太大了、请拆分"(表格导入的 8MB 上限见 table-import.service)。
|
||||||
|
*/
|
||||||
|
if ((error as { type?: string }).type === 'entity.too.large') {
|
||||||
|
logger.warn(`请求体超过上限:${req.method} ${req.originalUrl}`);
|
||||||
|
sendError(
|
||||||
|
res,
|
||||||
|
413,
|
||||||
|
'上传内容过大:请把文件拆分后分批导入(表格文件上限 8MB)',
|
||||||
|
'IMPORT_TOO_LARGE',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Prisma 已知错误码(P2002 唯一约束 / P2025 记录不存在 / P2003 外键约束)
|
// Prisma 已知错误码(P2002 唯一约束 / P2025 记录不存在 / P2003 外键约束)
|
||||||
const prismaCode = (error as { code?: string }).code;
|
const prismaCode = (error as { code?: string }).code;
|
||||||
if (prismaCode === 'P2002') {
|
if (prismaCode === 'P2002') {
|
||||||
|
|||||||
@@ -19,14 +19,16 @@ export async function login(input: LoginInput): Promise<LoginResponse> {
|
|||||||
const user = await prisma.user.findUnique({ where: { username: input.username } });
|
const user = await prisma.user.findUnique({ where: { username: input.username } });
|
||||||
if (!user) throw ApiError.unauthorized('用户名或密码错误');
|
if (!user) throw ApiError.unauthorized('用户名或密码错误');
|
||||||
|
|
||||||
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
|
// 角色判定必须放在密码校验**之前**:否则"密码错"与"密码对但学生账号已停用"会给出
|
||||||
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
|
// 不同的响应(401 / 403),攻击者据此就能把学生账号的密码当成可离线验证的预言机。
|
||||||
|
// 文案与状态码保持不变(客户端按 403 提示"请用班级码登录")。
|
||||||
// 个人学生账号已停用登录入口(需求:以班级为单位,删掉学生用自己用户名登录)
|
|
||||||
if (user.role === 'STUDENT') {
|
if (user.role === 'STUDENT') {
|
||||||
throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)');
|
throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
|
||||||
|
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
|
||||||
|
|
||||||
const payload: TokenPayload = {
|
const payload: TokenPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CLASSISLAND_NOTIFICATION_CHANNELS } from '@classhelper/shared';
|
import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK } from '@classhelper/shared';
|
||||||
import { keywordSchema } from '../../lib/schemas.js';
|
import { keywordSchema } from '../../lib/schemas.js';
|
||||||
|
|
||||||
export const createClassSchema = z.object({
|
export const createClassSchema = z.object({
|
||||||
@@ -13,13 +13,19 @@ export const createClassSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
|
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/**
|
||||||
|
* 本学期教学周数。上限与课表周次口径统一取 MAX_TERM_WEEK(30):
|
||||||
|
* 周次选择器与 weekNumberSchema 都只到 30,允许更大的值只会让用户设出一个
|
||||||
|
* 系统兑现不了的学期长度(第 31 周之后永远为空)。
|
||||||
|
*/
|
||||||
|
termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateClassSchema = z.object({
|
export const updateClassSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(64).optional(),
|
name: z.string().trim().min(1).max(64).optional(),
|
||||||
grade: z.string().trim().min(1).max(32).optional(),
|
grade: z.string().trim().min(1).max(32).optional(),
|
||||||
/** 本学期教学周数(1~40,班主任可调) */
|
/** 本学期教学周数(1~30,管理员可调) */
|
||||||
termWeeks: z.coerce.number().int().min(1).max(40).optional(),
|
termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||||
@@ -43,7 +49,6 @@ export const listClassesQuerySchema = z.object({
|
|||||||
export const addStudentSchema = z.object({
|
export const addStudentSchema = z.object({
|
||||||
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
||||||
name: z.string().trim().min(1, '请输入姓名').max(32),
|
name: z.string().trim().min(1, '请输入姓名').max(32),
|
||||||
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const assignTeacherSchema = z.object({
|
export const assignTeacherSchema = z.object({
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
toStudentDto,
|
toStudentDto,
|
||||||
toTeacherBrief,
|
toTeacherBrief,
|
||||||
} from '../../lib/mappers.js';
|
} from '../../lib/mappers.js';
|
||||||
import { hashPassword } from '../../lib/password.js';
|
|
||||||
import { emitToClass } from '../../realtime/bus.js';
|
import { emitToClass } from '../../realtime/bus.js';
|
||||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||||
import type {
|
import type {
|
||||||
@@ -152,6 +151,8 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
|||||||
teacherId,
|
teacherId,
|
||||||
code: account.code,
|
code: account.code,
|
||||||
passwordHash: account.passwordHash,
|
passwordHash: account.passwordHash,
|
||||||
|
// 新建时就能指定学期周数(之前只有编辑接口支持,Web 端表单里填了也会被丢弃)
|
||||||
|
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
teacher: { select: { id: true, name: true, username: true } },
|
teacher: { select: { id: true, name: true, username: true } },
|
||||||
@@ -228,7 +229,7 @@ export async function updateClass(
|
|||||||
data: {
|
data: {
|
||||||
...(input.name ? { name: input.name } : {}),
|
...(input.name ? { name: input.name } : {}),
|
||||||
...(input.grade ? { grade: input.grade } : {}),
|
...(input.grade ? { grade: input.grade } : {}),
|
||||||
// 教学周数(班主任可调):课表周次选择与默认 weekEnd 都用它
|
// 教学周数(仅管理员可调,见 assertCanManageClasses):课表周次选择与默认 weekEnd 都用它
|
||||||
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -260,9 +261,9 @@ export async function listClassStudents(user: TokenPayload, classId: string): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加学生到班级。
|
* 添加学生到班级(名单实体)。
|
||||||
* 已存在的学生账号直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。
|
* 已存在的学生直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。
|
||||||
* 新账号使用 DEFAULT_STUDENT_PASSWORD 作为初始密码。
|
* 学生没有账号属性:不设密码、不能登录,学生端统一用班级码 + 班级密码。
|
||||||
*/
|
*/
|
||||||
export async function addStudent(
|
export async function addStudent(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
@@ -292,7 +293,8 @@ export async function addStudent(
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
role: 'STUDENT',
|
role: 'STUDENT',
|
||||||
classId,
|
classId,
|
||||||
passwordHash: await hashPassword(input.password ?? env.defaultStudentPassword),
|
// 空串占位:User.passwordHash 必填,但学生的 403 登录判定在密码校验之前,永远用不到
|
||||||
|
passwordHash: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
studentId = created.id;
|
studentId = created.id;
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ router.get('/summary', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* GET /api/dashboard/term?classId= - 学期信息(当前教学周 + 本班教学周数)
|
* GET /api/dashboard/term?classId= - 学期信息(当前教学周 + 本班教学周数)
|
||||||
*
|
*
|
||||||
* `maxWeek` 取**班级的教学周数**(`Class.termWeeks`,班主任可在班级设置里调,默认 20),
|
* `maxWeek` 取**班级的教学周数**(`Class.termWeeks`,管理员在班级设置里调,默认 20),
|
||||||
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
|
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
|
||||||
|
* 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。
|
||||||
*/
|
*/
|
||||||
router.get('/term', async (req, res) => {
|
router.get('/term', async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
|
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
|
||||||
import {
|
import {
|
||||||
|
assertClassAccess,
|
||||||
classScopeIdFilter,
|
classScopeIdFilter,
|
||||||
classScopeWhere,
|
classScopeWhere,
|
||||||
isStudent,
|
isStudent,
|
||||||
@@ -20,12 +21,14 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
|
|||||||
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
|
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* 本班教学周数(班主任可调,默认 20)。
|
* 本班教学周数(默认 20)。
|
||||||
* - 学生 / 班级会话:固定为自己所在班级;
|
* - 学生 / 班级会话:固定为自己所在班级;
|
||||||
* - 教师 / 管理员:优先用显式 classId(越权查不到时回退默认值);
|
* - 教师 / 管理员:优先用显式 classId,**并且必须通过班级访问校验**(与其他接口同口径,
|
||||||
|
* 否则任意登录用户都能拿 classId 探测别班的教学周数);
|
||||||
* - 都不传:全局默认(MAX_TERM_WEEK 兜底)。
|
* - 都不传:全局默认(MAX_TERM_WEEK 兜底)。
|
||||||
*/
|
*/
|
||||||
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
|
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
|
||||||
|
if (classId) await assertClassAccess(user, classId);
|
||||||
const targetClassId = classId ?? (isStudent(user) ? requireStudentClassId(user) : undefined);
|
const targetClassId = classId ?? (isStudent(user) ? requireStudentClassId(user) : undefined);
|
||||||
if (!targetClassId) return MAX_TERM_WEEK;
|
if (!targetClassId) return MAX_TERM_WEEK;
|
||||||
const record = await prisma.class.findUnique({
|
const record = await prisma.class.findUnique({
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||||
|
import { authenticate, requireRole } from '../../middleware/auth.js';
|
||||||
|
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||||
|
import { defineModule } from '../module.types.js';
|
||||||
|
import {
|
||||||
|
backupNameParamSchema,
|
||||||
|
backupScheduleSchema,
|
||||||
|
importSnapshotSchema,
|
||||||
|
jobIdParamSchema,
|
||||||
|
switchDatabaseSchema,
|
||||||
|
testConnectionSchema,
|
||||||
|
type BackupScheduleInput,
|
||||||
|
type ImportSnapshotInput,
|
||||||
|
type SwitchDatabaseInput,
|
||||||
|
type TestConnectionInput,
|
||||||
|
} from './database.schemas.js';
|
||||||
|
import * as databaseService from './database.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
|
||||||
|
*
|
||||||
|
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
|
||||||
|
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
|
||||||
|
*
|
||||||
|
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
|
||||||
|
* Redis 是键值库,不是 Prisma 支持的主数据库,因此不在此模块范围内。
|
||||||
|
*/
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate());
|
||||||
|
router.use(requireRole('ADMIN'));
|
||||||
|
|
||||||
|
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
|
||||||
|
router.get('/status', async (_req, res) => {
|
||||||
|
sendOk(res, await databaseService.getDatabaseStatus());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
|
||||||
|
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
|
||||||
|
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req));
|
||||||
|
sendOk(res, result, result.ok ? '连接成功' : '连接失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/database/backups - 备份列表 */
|
||||||
|
router.get('/backups', (_req, res) => {
|
||||||
|
sendOk(res, databaseService.listBackups());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/database/backups - 立即创建一份手动备份 */
|
||||||
|
router.post('/backups', async (_req, res) => {
|
||||||
|
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
|
||||||
|
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
|
||||||
|
const { name } = validatedParams<{ name: string }>(req);
|
||||||
|
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/database/backups/:name - 删除备份 */
|
||||||
|
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
|
||||||
|
const { name } = validatedParams<{ name: string }>(req);
|
||||||
|
databaseService.deleteBackup(name);
|
||||||
|
sendOk(res, { name }, '备份已删除');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/database/export - 下载当前库的 JSON 快照 */
|
||||||
|
router.get('/export', async (_req, res) => {
|
||||||
|
const json = await databaseService.exportSnapshotJson();
|
||||||
|
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.status(200).send(json);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
|
||||||
|
router.get('/sqlite-file', (_req, res) => {
|
||||||
|
const filePath = databaseService.sqliteFilePathForDownload();
|
||||||
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
|
||||||
|
res.status(200).send(fs.readFileSync(filePath));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
|
||||||
|
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
|
||||||
|
const { data } = validatedBody<ImportSnapshotInput>(req);
|
||||||
|
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/database/backup-schedule - 定时备份配置 */
|
||||||
|
router.get('/backup-schedule', (_req, res) => {
|
||||||
|
sendOk(res, databaseService.getBackupSchedule());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
|
||||||
|
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
|
||||||
|
sendOk(
|
||||||
|
res,
|
||||||
|
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
|
||||||
|
'定时备份配置已保存',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
|
||||||
|
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
|
||||||
|
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req));
|
||||||
|
sendCreated(res, { jobId: job.id }, '切换任务已启动');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
|
||||||
|
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
|
||||||
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
|
const job = databaseService.getSwitchJob(id);
|
||||||
|
sendOk(res, job, job ? 'ok' : '任务不存在');
|
||||||
|
});
|
||||||
|
|
||||||
|
export const databaseModule = defineModule({
|
||||||
|
name: 'database',
|
||||||
|
basePath: '/database',
|
||||||
|
router,
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** 本项目支持的主数据库类型。Redis 等键值库不是 Prisma 支持的主库,不提供切换。 */
|
||||||
|
export const databaseProviderSchema = z.enum(['sqlite', 'mysql']);
|
||||||
|
export type DatabaseProviderInput = z.infer<typeof databaseProviderSchema>;
|
||||||
|
|
||||||
|
/** 连接串与 provider 配套校验:SQLite 必须 file: 前缀,MySQL 必须 mysql:// 前缀 */
|
||||||
|
export const targetConnectionSchema = z
|
||||||
|
.object({
|
||||||
|
provider: databaseProviderSchema,
|
||||||
|
url: z.string().min(1, '请填写连接串'),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
const url = value.url.trim();
|
||||||
|
if (value.provider === 'sqlite' && !url.startsWith('file:')) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['url'],
|
||||||
|
message: 'SQLite 连接串必须以 file: 开头,例如 file:./data/classhelper.db',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.provider === 'mysql' && !/^mysqls?:\/\//.test(url)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['url'],
|
||||||
|
message: 'MySQL 连接串必须以 mysql:// 或 mysqls:// 开头',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TargetConnectionInput = z.infer<typeof targetConnectionSchema>;
|
||||||
|
|
||||||
|
/** POST /database/test-connection:测试任意目标库的连通性 */
|
||||||
|
export const testConnectionSchema = targetConnectionSchema;
|
||||||
|
export type TestConnectionInput = z.infer<typeof testConnectionSchema>;
|
||||||
|
|
||||||
|
/** POST /database/switch:一键切换数据库(含数据迁移) */
|
||||||
|
export const switchDatabaseSchema = targetConnectionSchema;
|
||||||
|
export type SwitchDatabaseInput = z.infer<typeof switchDatabaseSchema>;
|
||||||
|
|
||||||
|
/** POST /database/import:上传快照(base64,随 JSON 请求体,受 12MB 请求体限制) */
|
||||||
|
export const importSnapshotSchema = z.object({
|
||||||
|
data: z.string().min(1, '请提供快照内容(base64)'),
|
||||||
|
});
|
||||||
|
export type ImportSnapshotInput = z.infer<typeof importSnapshotSchema>;
|
||||||
|
|
||||||
|
/** PUT /database/backup-schedule:定时备份配置 */
|
||||||
|
export const backupScheduleSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
/** 间隔小时数(1~720,默认 24 = 每天一次) */
|
||||||
|
intervalHours: z.number().int().min(1).max(720),
|
||||||
|
/** 最多保留多少份自动备份(超出后删最旧的;手动备份不受影响) */
|
||||||
|
keepCount: z.number().int().min(1).max(100),
|
||||||
|
});
|
||||||
|
export type BackupScheduleInput = z.infer<typeof backupScheduleSchema>;
|
||||||
|
|
||||||
|
/** 路径参数:备份文件名(必须是本服务生成的固定格式,杜绝路径穿越) */
|
||||||
|
export const backupNameParamSchema = z.object({
|
||||||
|
name: z.string().regex(/^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/, '备份文件名不合法'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 路径参数:切换任务 id(本服务生成,字母数字与连字符) */
|
||||||
|
export const jobIdParamSchema = z.object({
|
||||||
|
id: z.string().regex(/^[0-9a-z-]{6,40}$/, '任务 id 不合法'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import zlib from 'node:zlib';
|
||||||
|
import { env } from '../../config/env.js';
|
||||||
|
import { createPrismaClient, prisma, type DatabaseProvider } from '../../lib/db.js';
|
||||||
|
import { logger } from '../../lib/logger.js';
|
||||||
|
import {
|
||||||
|
assertValidSnapshot,
|
||||||
|
exportSnapshot,
|
||||||
|
restoreSnapshot,
|
||||||
|
SNAPSHOT_TABLES,
|
||||||
|
type DatabaseSnapshot,
|
||||||
|
} from '../../lib/snapshot.js';
|
||||||
|
import { ApiError } from '../../lib/http.js';
|
||||||
|
import type {
|
||||||
|
BackupScheduleInput,
|
||||||
|
TargetConnectionInput,
|
||||||
|
} from './database.schemas.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库管理服务:状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
|
||||||
|
*
|
||||||
|
* 所有实现都基于 `lib/snapshot.ts` 的 JSON 快照:与 provider 无关,
|
||||||
|
* 因此备份可以恢复回任意一种受支持的数据库(备份即迁移的载体)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ================================================================ 路径 */
|
||||||
|
|
||||||
|
/** 数据目录:SQLite = 数据文件所在目录;MySQL = 安装根的 data 目录(与部署约定一致) */
|
||||||
|
function dataDir(): string {
|
||||||
|
if (env.databaseProvider === 'sqlite' && env.sqliteFilePath) {
|
||||||
|
return path.dirname(env.sqliteFilePath);
|
||||||
|
}
|
||||||
|
return path.join(env.serverRoot, '..', 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupDir(): string {
|
||||||
|
return path.join(dataDir(), 'backups');
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsFilePath(): string {
|
||||||
|
return path.join(dataDir(), 'database-settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBackupDir(): string {
|
||||||
|
const dir = backupDir();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================ 定时备份配置 */
|
||||||
|
|
||||||
|
interface BackupSchedule {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalHours: number;
|
||||||
|
keepCount: number;
|
||||||
|
lastAutoBackupAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCHEDULE: BackupSchedule = {
|
||||||
|
enabled: false,
|
||||||
|
intervalHours: 24,
|
||||||
|
keepCount: 7,
|
||||||
|
lastAutoBackupAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function readSchedule(): BackupSchedule {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(settingsFilePath(), 'utf8')) as Partial<BackupSchedule>;
|
||||||
|
return {
|
||||||
|
enabled: raw.enabled === true,
|
||||||
|
intervalHours:
|
||||||
|
typeof raw.intervalHours === 'number' && raw.intervalHours >= 1 ? Math.floor(raw.intervalHours) : DEFAULT_SCHEDULE.intervalHours,
|
||||||
|
keepCount: typeof raw.keepCount === 'number' && raw.keepCount >= 1 ? Math.floor(raw.keepCount) : DEFAULT_SCHEDULE.keepCount,
|
||||||
|
lastAutoBackupAt: typeof raw.lastAutoBackupAt === 'string' ? raw.lastAutoBackupAt : null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_SCHEDULE };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSchedule(schedule: BackupSchedule): void {
|
||||||
|
ensureBackupDir();
|
||||||
|
fs.writeFileSync(settingsFilePath(), `${JSON.stringify(schedule, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取定时备份配置(GET /backup-schedule) */
|
||||||
|
export function getBackupSchedule(): BackupSchedule {
|
||||||
|
return readSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新定时备份配置(PUT /backup-schedule),写盘后立即生效 */
|
||||||
|
export function saveSchedulePatch(patch: BackupScheduleInput): BackupSchedule {
|
||||||
|
const next: BackupSchedule = { ...readSchedule(), ...patch };
|
||||||
|
writeSchedule(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
let schedulerTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时备份调度:每 10 分钟检查一次,到点自动备份并清理旧备份。
|
||||||
|
* 依赖服务端进程常驻(进程不运行不会补跑);lastAutoBackupAt 持久化在配置文件里,
|
||||||
|
* 重启后不会立刻重复备份。
|
||||||
|
*/
|
||||||
|
export function startBackupScheduler(): void {
|
||||||
|
if (schedulerTimer) return;
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
if (isSwitchRunning()) return;
|
||||||
|
const schedule = readSchedule();
|
||||||
|
if (!schedule.enabled) return;
|
||||||
|
const last = schedule.lastAutoBackupAt ? Date.parse(schedule.lastAutoBackupAt) : 0;
|
||||||
|
const dueAt = (Number.isFinite(last) ? last : 0) + schedule.intervalHours * 3_600_000;
|
||||||
|
if (Date.now() < dueAt) return;
|
||||||
|
try {
|
||||||
|
await createBackup('auto');
|
||||||
|
writeSchedule({ ...schedule, lastAutoBackupAt: new Date().toISOString() });
|
||||||
|
logger.info('定时备份完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('定时备份失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
schedulerTimer = setInterval(() => void tick(), 10 * 60_000);
|
||||||
|
schedulerTimer.unref();
|
||||||
|
logger.info('定时备份调度已启动(每 10 分钟检查一次到点任务)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================ 备份 / 恢复 */
|
||||||
|
|
||||||
|
export interface BackupMeta {
|
||||||
|
name: string;
|
||||||
|
kind: 'manual' | 'auto';
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBackupName(name: string): boolean {
|
||||||
|
return /^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupPath(name: string): string {
|
||||||
|
if (!isBackupName(name)) {
|
||||||
|
throw ApiError.badRequest('备份文件名不合法');
|
||||||
|
}
|
||||||
|
const target = path.join(backupDir(), name);
|
||||||
|
if (path.dirname(target) !== backupDir()) {
|
||||||
|
throw ApiError.badRequest('备份文件名不合法');
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampSlug(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
||||||
|
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建一份备份(gzip 压缩的 JSON 快照)。kind 决定文件名前缀与自动清理策略。 */
|
||||||
|
export async function createBackup(kind: 'manual' | 'auto'): Promise<BackupMeta> {
|
||||||
|
assertNotSwitching();
|
||||||
|
const dir = ensureBackupDir();
|
||||||
|
const snapshot = await exportSnapshot(prisma, env.databaseProvider);
|
||||||
|
const payload = zlib.gzipSync(Buffer.from(JSON.stringify(snapshot), 'utf8'));
|
||||||
|
const name = `backup-${kind}-${timestampSlug()}.json.gz`;
|
||||||
|
fs.writeFileSync(path.join(dir, name), payload);
|
||||||
|
if (kind === 'auto') pruneAutoBackups();
|
||||||
|
const meta: BackupMeta = { name, kind, sizeBytes: payload.length, createdAt: snapshot.createdAt };
|
||||||
|
logger.info(`已创建${kind === 'auto' ? '定时' : '手动'}备份:${name}`);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动备份按 keepCount 清理最旧的(手动备份不受影响) */
|
||||||
|
function pruneAutoBackups(): void {
|
||||||
|
const schedule = readSchedule();
|
||||||
|
const autos = listBackups()
|
||||||
|
.filter((item) => item.kind === 'auto')
|
||||||
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
|
for (const stale of autos.slice(0, Math.max(0, autos.length - schedule.keepCount))) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(path.join(backupDir(), stale.name));
|
||||||
|
} catch {
|
||||||
|
// 清理失败不影响本次备份
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBackups(): BackupMeta[] {
|
||||||
|
const dir = backupDir();
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter(isBackupName)
|
||||||
|
.map((name) => {
|
||||||
|
const stat = fs.statSync(path.join(dir, name));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
kind: name.includes('-manual-') ? ('manual' as const) : ('auto' as const),
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
createdAt: stat.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBackupSnapshot(name: string): DatabaseSnapshot {
|
||||||
|
const file = backupPath(name);
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
throw ApiError.notFound('备份不存在(可能已被删除)');
|
||||||
|
}
|
||||||
|
const snapshot = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')) as DatabaseSnapshot;
|
||||||
|
assertValidSnapshot(snapshot);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从备份恢复(整库覆盖:先清空现有数据再写入备份内容) */
|
||||||
|
export async function restoreBackup(name: string): Promise<{ counts: Record<string, number> }> {
|
||||||
|
assertNotSwitching();
|
||||||
|
const snapshot = readBackupSnapshot(name);
|
||||||
|
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBackup(name: string): void {
|
||||||
|
const file = backupPath(name);
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
throw ApiError.notFound('备份不存在(可能已被删除)');
|
||||||
|
}
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================ 导入 / 导出 */
|
||||||
|
|
||||||
|
/** 导出当前库的快照 JSON(未压缩,供下载与跨库迁移使用) */
|
||||||
|
export async function exportSnapshotJson(): Promise<string> {
|
||||||
|
assertNotSwitching();
|
||||||
|
return JSON.stringify(await exportSnapshot(prisma, env.databaseProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 base64 快照导入(整库覆盖)。用于"快捷导入"。 */
|
||||||
|
export async function importSnapshotBase64(data: string): Promise<{ counts: Record<string, number> }> {
|
||||||
|
assertNotSwitching();
|
||||||
|
let snapshot: DatabaseSnapshot;
|
||||||
|
try {
|
||||||
|
const json = Buffer.from(data, 'base64').toString('utf8');
|
||||||
|
snapshot = JSON.parse(json) as DatabaseSnapshot;
|
||||||
|
} catch {
|
||||||
|
throw ApiError.badRequest('快照内容不是合法的 base64 JSON');
|
||||||
|
}
|
||||||
|
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQLite 数据文件下载(仅 SQLite 模式提供;MySQL 无"单文件"概念) */
|
||||||
|
export function sqliteFilePathForDownload(): string {
|
||||||
|
if (env.databaseProvider !== 'sqlite' || !env.sqliteFilePath || !fs.existsSync(env.sqliteFilePath)) {
|
||||||
|
throw ApiError.badRequest('当前不是 SQLite 数据库或数据文件不存在');
|
||||||
|
}
|
||||||
|
return env.sqliteFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================ 状态 / 连接测试 */
|
||||||
|
|
||||||
|
export interface TableCount {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function databaseVersion(provider: DatabaseProvider, client: typeof prisma): Promise<string> {
|
||||||
|
if (provider === 'mysql') {
|
||||||
|
const rows = (await client.$queryRawUnsafe('SELECT VERSION() AS version')) as { version?: string }[];
|
||||||
|
return rows[0]?.version ?? 'unknown';
|
||||||
|
}
|
||||||
|
const rows = (await client.$queryRawUnsafe('SELECT sqlite_version() AS version')) as {
|
||||||
|
version?: string;
|
||||||
|
}[];
|
||||||
|
return rows[0]?.version ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function databaseSizeBytes(provider: DatabaseProvider, client: typeof prisma): Promise<number | null> {
|
||||||
|
if (provider === 'sqlite') {
|
||||||
|
return env.sqliteFilePath && fs.existsSync(env.sqliteFilePath) ? fs.statSync(env.sqliteFilePath).size : 0;
|
||||||
|
}
|
||||||
|
const rows = (await client.$queryRawUnsafe(
|
||||||
|
'SELECT COALESCE(SUM(data_length + index_length), 0) AS size FROM information_schema.tables WHERE table_schema = DATABASE()',
|
||||||
|
)) as { size?: bigint | number }[];
|
||||||
|
const size = rows[0]?.size;
|
||||||
|
return size === undefined ? null : Number(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatabaseStatus {
|
||||||
|
provider: DatabaseProvider;
|
||||||
|
connected: boolean;
|
||||||
|
latencyMs: number | null;
|
||||||
|
version: string | null;
|
||||||
|
sizeBytes: number | null;
|
||||||
|
/** 当前连接串的脱敏展示(MySQL 隐藏密码) */
|
||||||
|
databaseUrlMasked: string;
|
||||||
|
/** SQLite 数据文件绝对路径(MySQL 为空) */
|
||||||
|
sqliteFilePath: string;
|
||||||
|
dataDir: string;
|
||||||
|
backupDir: string;
|
||||||
|
tables: TableCount[];
|
||||||
|
backups: ReturnType<typeof listBackups>;
|
||||||
|
schedule: BackupSchedule;
|
||||||
|
/** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */
|
||||||
|
switchRunning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接串脱敏:mysql://user:password@host → mysql://user:***@host */
|
||||||
|
function maskDatabaseUrl(provider: DatabaseProvider, url: string): string {
|
||||||
|
if (provider !== 'mysql') return url;
|
||||||
|
return url.replace(/(\/\/[^:/@]+:)[^@]*(@)/, '$1***$2');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDatabaseStatus(): Promise<DatabaseStatus> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let connected = false;
|
||||||
|
let version: string | null = null;
|
||||||
|
try {
|
||||||
|
version = await databaseVersion(env.databaseProvider, prisma);
|
||||||
|
connected = true;
|
||||||
|
} catch {
|
||||||
|
connected = false;
|
||||||
|
}
|
||||||
|
const latencyMs = connected ? Date.now() - startedAt : null;
|
||||||
|
const delegates = prisma as unknown as Record<
|
||||||
|
(typeof SNAPSHOT_TABLES)[number],
|
||||||
|
{ count(): Promise<number> }
|
||||||
|
>;
|
||||||
|
const tables: TableCount[] = connected
|
||||||
|
? await Promise.all(
|
||||||
|
SNAPSHOT_TABLES.map(async (name): Promise<TableCount> => {
|
||||||
|
try {
|
||||||
|
return { name, count: await delegates[name].count() };
|
||||||
|
} catch {
|
||||||
|
return { name, count: -1 };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
provider: env.databaseProvider,
|
||||||
|
connected,
|
||||||
|
latencyMs,
|
||||||
|
version,
|
||||||
|
sizeBytes: connected ? await databaseSizeBytes(env.databaseProvider, prisma).catch(() => null) : null,
|
||||||
|
databaseUrlMasked: maskDatabaseUrl(env.databaseProvider, env.databaseUrl),
|
||||||
|
sqliteFilePath: env.sqliteFilePath,
|
||||||
|
dataDir: dataDir(),
|
||||||
|
backupDir: backupDir(),
|
||||||
|
tables,
|
||||||
|
backups: listBackups(),
|
||||||
|
schedule: readSchedule(),
|
||||||
|
switchRunning: isSwitchRunning(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
provider: DatabaseProvider;
|
||||||
|
latencyMs: number | null;
|
||||||
|
version: string | null;
|
||||||
|
/** 新建 SQLite 库时目标文件还不存在,属于正常情况(切换时会自动建库) */
|
||||||
|
note?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试任意目标库的连通性(发起切换前的第一步) */
|
||||||
|
export async function testConnection(target: TargetConnectionInput): Promise<ConnectionTestResult> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let note: string | undefined;
|
||||||
|
let url = target.url.trim();
|
||||||
|
if (target.provider === 'sqlite') {
|
||||||
|
// 与 env.resolveSqliteUrl 同口径:相对路径以 serverRoot 为基准解析为绝对路径
|
||||||
|
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
|
||||||
|
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
|
||||||
|
if (!fs.existsSync(absolute)) {
|
||||||
|
const parent = path.dirname(absolute);
|
||||||
|
if (!fs.existsSync(parent)) {
|
||||||
|
return { ok: false, provider: target.provider, latencyMs: null, version: null, error: `目录不存在:${parent}` };
|
||||||
|
}
|
||||||
|
note = '目标文件不存在,属新库(切换时会自动建表)';
|
||||||
|
}
|
||||||
|
url = `file:${absolute.replace(/\\/g, '/')}`;
|
||||||
|
}
|
||||||
|
let client: Awaited<ReturnType<typeof createPrismaClient>> | null = null;
|
||||||
|
try {
|
||||||
|
client = await createPrismaClient(target.provider, url);
|
||||||
|
const version = await databaseVersion(target.provider, client);
|
||||||
|
return { ok: true, provider: target.provider, latencyMs: Date.now() - startedAt, version, note };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
provider: target.provider,
|
||||||
|
latencyMs: null,
|
||||||
|
version: null,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client?.$disconnect().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================ 一键切换 */
|
||||||
|
|
||||||
|
export interface SwitchStep {
|
||||||
|
name: string;
|
||||||
|
status: 'running' | 'done' | 'error';
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchJob {
|
||||||
|
id: string;
|
||||||
|
status: 'running' | 'done' | 'error';
|
||||||
|
target: { provider: DatabaseProvider; url: string };
|
||||||
|
steps: SwitchStep[];
|
||||||
|
error?: string;
|
||||||
|
/** 完成后必填:需要重启服务端才能让新数据库生效 */
|
||||||
|
restartRequired: boolean;
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
backupName?: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchJobs = new Map<string, SwitchJob>();
|
||||||
|
|
||||||
|
function isSwitchRunning(): boolean {
|
||||||
|
for (const job of switchJobs.values()) {
|
||||||
|
if (job.status === 'running') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNotSwitching(): void {
|
||||||
|
if (isSwitchRunning()) {
|
||||||
|
throw ApiError.conflict('正在执行数据库切换,请等待完成后再进行备份/导入/恢复');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 防止路径穿越:任务 id 只由本服务生成(时间戳 + 随机数) */
|
||||||
|
function newJobId(): string {
|
||||||
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSwitchJob(id: string): SwitchJob | null {
|
||||||
|
return switchJobs.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepOf(job: SwitchJob, name: string): SwitchStep {
|
||||||
|
let step = job.steps.find((item) => item.name === name);
|
||||||
|
if (!step) {
|
||||||
|
step = { name, status: 'running' };
|
||||||
|
job.steps.push(step);
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishStep(job: SwitchJob, name: string, detail?: string): void {
|
||||||
|
const step = stepOf(job, name);
|
||||||
|
step.status = 'done';
|
||||||
|
if (detail) step.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDetail(job: SwitchJob, name: string, detail: string): void {
|
||||||
|
const step = stepOf(job, name);
|
||||||
|
step.detail = `${step.detail ? `${step.detail}\n` : ''}${detail}`.slice(-4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验目标库必须是空库(防误覆盖):有表即拒绝 */
|
||||||
|
async function assertTargetEmpty(target: TargetConnectionInput): Promise<string> {
|
||||||
|
let url = target.url.trim();
|
||||||
|
let sqliteAbsolute: string | null = null;
|
||||||
|
if (target.provider === 'sqlite') {
|
||||||
|
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
|
||||||
|
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
|
||||||
|
sqliteAbsolute = absolute;
|
||||||
|
url = `file:${absolute.replace(/\\/g, '/')}`;
|
||||||
|
}
|
||||||
|
const client = await createPrismaClient(target.provider, url);
|
||||||
|
try {
|
||||||
|
const rows =
|
||||||
|
target.provider === 'sqlite'
|
||||||
|
? (await client.$queryRawUnsafe(
|
||||||
|
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||||
|
)) as { count?: bigint | number }[]
|
||||||
|
: (await client.$queryRawUnsafe(
|
||||||
|
'SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = DATABASE()',
|
||||||
|
)) as { count?: bigint | number }[];
|
||||||
|
const tableCount = Number(rows[0]?.count ?? 0);
|
||||||
|
if (tableCount > 0) {
|
||||||
|
throw ApiError.badRequest(
|
||||||
|
`目标数据库不是空库(已有 ${tableCount} 张表)。为防误覆盖,只允许迁移到空库;` +
|
||||||
|
'如需重用已有库,请先备份并清空它。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return target.provider === 'sqlite' ? `目标文件:${sqliteAbsolute}` : '目标 MySQL 库为空';
|
||||||
|
} finally {
|
||||||
|
await client.$disconnect().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA_FILE = path.join(env.serverRoot, 'prisma', 'schema.prisma');
|
||||||
|
|
||||||
|
function rewriteSchemaProvider(provider: DatabaseProvider): string {
|
||||||
|
const original = fs.readFileSync(SCHEMA_FILE, 'utf8');
|
||||||
|
const updated = original.replace(/provider\s*=\s*"(sqlite|mysql)"/, `provider = "${provider}"`);
|
||||||
|
if (updated === original) {
|
||||||
|
throw new Error('未能改写 schema.prisma 的 provider(未匹配到 provider 声明)');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(SCHEMA_FILE, updated, 'utf8');
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子进程运行 Prisma CLI(generate / db push)。连接串通过环境变量显式传入,不依赖 .env。 */
|
||||||
|
function runPrismaCli(
|
||||||
|
job: SwitchJob,
|
||||||
|
stepName: string,
|
||||||
|
args: string[],
|
||||||
|
target: TargetConnectionInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const candidates = [
|
||||||
|
path.join(env.serverRoot, 'node_modules', 'prisma', 'build', 'index.js'),
|
||||||
|
path.join(env.serverRoot, '..', 'node_modules', 'prisma', 'build', 'index.js'),
|
||||||
|
];
|
||||||
|
const cli = candidates.find((file) => fs.existsSync(file));
|
||||||
|
if (!cli) {
|
||||||
|
throw new Error('未找到 Prisma CLI(node_modules/prisma)。安装版请确认安装包完整。');
|
||||||
|
}
|
||||||
|
return runNodeTool(job, stepName, [cli, ...args], {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_PROVIDER: target.provider,
|
||||||
|
DATABASE_URL: target.url.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编译新生成的 Prisma 客户端(仅生产打包形态需要:dist 里跑的是编译产物) */
|
||||||
|
async function compileGeneratedClient(job: SwitchJob): Promise<void> {
|
||||||
|
// 开发模式(tsx 直接跑 src)不需要编译;打包形态(存在 dist/index.js)才需要
|
||||||
|
if (!fs.existsSync(path.join(env.serverRoot, 'dist', 'index.js'))) return;
|
||||||
|
const candidates = [
|
||||||
|
path.join(env.serverRoot, 'node_modules', 'typescript', 'lib', 'tsc.js'),
|
||||||
|
path.join(env.serverRoot, '..', 'node_modules', 'typescript', 'lib', 'tsc.js'),
|
||||||
|
];
|
||||||
|
const tsc = candidates.find((file) => fs.existsSync(file));
|
||||||
|
if (!tsc) {
|
||||||
|
throw new Error('未找到 TypeScript 编译器(node_modules/typescript)。安装包请确认完整。');
|
||||||
|
}
|
||||||
|
await runNodeTool(job, '编译新生成的 Prisma 客户端', [tsc, '-p', 'tsconfig.generate.json'], process.env);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */
|
||||||
|
function runApplySnapshotTool(job: SwitchJob, target: TargetConnectionInput, snapshotFile: string): Promise<Record<string, number>> {
|
||||||
|
const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js');
|
||||||
|
const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts');
|
||||||
|
const tsxCli = path.join(env.serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||||
|
let args: string[];
|
||||||
|
if (fs.existsSync(distTool)) {
|
||||||
|
args = [distTool];
|
||||||
|
} else if (fs.existsSync(tsxCli) && fs.existsSync(srcTool)) {
|
||||||
|
args = [tsxCli, srcTool];
|
||||||
|
} else {
|
||||||
|
throw new Error('未找到快照导入工具(dist/tools/apply-snapshot.js),请先构建服务端');
|
||||||
|
}
|
||||||
|
args.push('--provider', target.provider, '--url', target.url.trim(), '--file', snapshotFile);
|
||||||
|
const capture: { text: string } = { text: '' };
|
||||||
|
return runNodeTool(job, '迁移数据到目标库', args, process.env, capture).then(() => {
|
||||||
|
const lines = capture.text.trim().split(/\r?\n/).filter(Boolean);
|
||||||
|
const last = lines[lines.length - 1] ?? '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(last) as { ok?: boolean; counts?: Record<string, number>; error?: string };
|
||||||
|
if (!parsed.ok) throw new Error(parsed.error ?? '快照导入工具返回失败');
|
||||||
|
return parsed.counts ?? {};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`无法解析快照导入工具的输出:${error instanceof Error ? error.message : String(error)}(输出尾部:${last.slice(0, 400)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNodeTool(
|
||||||
|
job: SwitchJob,
|
||||||
|
stepName: string,
|
||||||
|
args: string[],
|
||||||
|
nodeEnv: NodeJS.ProcessEnv,
|
||||||
|
capture?: { text: string },
|
||||||
|
timeoutMs = 10 * 60_000,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, args, {
|
||||||
|
cwd: env.serverRoot,
|
||||||
|
env: nodeEnv,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
const collect = (chunk: Buffer | string): void => {
|
||||||
|
output += String(chunk);
|
||||||
|
if (output.length > 20_000) output = output.slice(-20_000);
|
||||||
|
if (capture) capture.text = output;
|
||||||
|
};
|
||||||
|
child.stdout?.on('data', collect);
|
||||||
|
child.stderr?.on('data', collect);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill();
|
||||||
|
reject(new Error(`${stepName} 超时(${Math.round(timeoutMs / 60_000)} 分钟)`));
|
||||||
|
}, timeoutMs);
|
||||||
|
child.on('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`${stepName} 启动失败:${error.message}`));
|
||||||
|
});
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code === 0) {
|
||||||
|
appendDetail(job, stepName, output.trim().split(/\r?\n/).slice(-6).join('\n'));
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tail = output.trim().split(/\r?\n/).slice(-10).join('\n');
|
||||||
|
reject(new Error(`${stepName} 失败(exit ${code ?? 'signal'}):${tail || '无输出'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换完成后改写 .env(DATABASE_PROVIDER / DATABASE_URL 两行) */
|
||||||
|
function updateEnvFiles(target: TargetConnectionInput): string {
|
||||||
|
const candidates = [path.join(env.serverRoot, '.env'), path.join(env.serverRoot, '..', '.env')];
|
||||||
|
const existing = candidates.filter((file) => fs.existsSync(file));
|
||||||
|
const files = existing.length > 0 ? existing : [candidates[0]];
|
||||||
|
const updated: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
const providerLine = `DATABASE_PROVIDER=${target.provider}`;
|
||||||
|
const urlLine = `DATABASE_URL="${target.url.trim()}"`;
|
||||||
|
if (/^DATABASE_PROVIDER=.*$/m.test(content)) {
|
||||||
|
content = content.replace(/^DATABASE_PROVIDER=.*$/m, providerLine);
|
||||||
|
} else {
|
||||||
|
content = `${content.trimEnd()}\n${providerLine}\n`;
|
||||||
|
}
|
||||||
|
if (/^DATABASE_URL=.*$/m.test(content)) {
|
||||||
|
content = content.replace(/^DATABASE_URL=.*$/m, urlLine);
|
||||||
|
} else {
|
||||||
|
content = `${content.trimEnd()}\n${urlLine}\n`;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(file, content, 'utf8');
|
||||||
|
updated.push(file);
|
||||||
|
}
|
||||||
|
return updated.join(' , ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起一键切换(异步任务)。流程与失败语义见模块注释:
|
||||||
|
* 数据写入全部成功后才改写 .env;中途失败回滚 schema.prisma,现有库不受影响。
|
||||||
|
*/
|
||||||
|
export function startSwitch(target: TargetConnectionInput): SwitchJob {
|
||||||
|
if (isSwitchRunning()) {
|
||||||
|
throw ApiError.conflict('已有切换任务正在进行,请等待完成');
|
||||||
|
}
|
||||||
|
if (target.provider === env.databaseProvider && target.url.trim() === env.databaseUrl) {
|
||||||
|
throw ApiError.badRequest('目标数据库与当前使用的数据库相同');
|
||||||
|
}
|
||||||
|
const job: SwitchJob = {
|
||||||
|
id: newJobId(),
|
||||||
|
status: 'running',
|
||||||
|
target: { provider: target.provider, url: target.url.trim() },
|
||||||
|
steps: [],
|
||||||
|
restartRequired: false,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
switchJobs.set(job.id, job);
|
||||||
|
// 不 await:HTTP 立即返回 jobId,前端轮询 GET /switch/jobs/:id
|
||||||
|
void runSwitch(job, target).catch(() => undefined);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise<void> {
|
||||||
|
let schemaOriginal: string | null = null;
|
||||||
|
let snapshotFile: string | null = null;
|
||||||
|
try {
|
||||||
|
// 1. 先备份当前库(切换失败时的兜底)
|
||||||
|
stepOf(job, '备份当前数据库');
|
||||||
|
const backup = await createBackup('manual');
|
||||||
|
job.backupName = backup.name;
|
||||||
|
finishStep(job, '备份当前数据库', `备份文件 ${backup.name}`);
|
||||||
|
|
||||||
|
// 2. 导出快照到临时文件(供子进程写入目标库)
|
||||||
|
stepOf(job, '导出当前数据');
|
||||||
|
fs.mkdirSync(backupDir(), { recursive: true });
|
||||||
|
snapshotFile = path.join(backupDir(), `switch-${job.id}.json`);
|
||||||
|
fs.writeFileSync(snapshotFile, await exportSnapshotJson(), 'utf8');
|
||||||
|
finishStep(job, '导出当前数据');
|
||||||
|
|
||||||
|
// 3. 测试目标连接
|
||||||
|
stepOf(job, '测试目标库连接');
|
||||||
|
const test = await testConnection(target);
|
||||||
|
if (!test.ok) {
|
||||||
|
throw new Error(test.error ?? '目标库连接失败');
|
||||||
|
}
|
||||||
|
finishStep(job, '测试目标库连接', test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`);
|
||||||
|
|
||||||
|
// 4. 目标库必须为空
|
||||||
|
stepOf(job, '检查目标库为空');
|
||||||
|
finishStep(job, '检查目标库为空', await assertTargetEmpty(target));
|
||||||
|
|
||||||
|
// 5. 改写 schema.prisma 的 provider(失败时回滚)
|
||||||
|
stepOf(job, '改写 schema.prisma');
|
||||||
|
schemaOriginal = rewriteSchemaProvider(target.provider);
|
||||||
|
finishStep(job, '改写 schema.prisma', `provider → ${target.provider}`);
|
||||||
|
|
||||||
|
// 6. 生成新方言的 Prisma 客户端
|
||||||
|
stepOf(job, '生成 Prisma 客户端');
|
||||||
|
await runPrismaCli(job, '生成 Prisma 客户端', ['generate'], target);
|
||||||
|
finishStep(job, '生成 Prisma 客户端');
|
||||||
|
|
||||||
|
// 7. 生产形态:把生成出来的 .ts 客户端编译进 dist(dev 由 tsx 直接跑 src,跳过)
|
||||||
|
await compileGeneratedClient(job);
|
||||||
|
|
||||||
|
// 8. 目标库建表(Prisma 7 的 db push 不再自动生成客户端,generate 是独立步骤 6)
|
||||||
|
stepOf(job, '在目标库建表');
|
||||||
|
await runPrismaCli(job, '在目标库建表', ['db', 'push', '--accept-data-loss'], target);
|
||||||
|
finishStep(job, '在目标库建表');
|
||||||
|
|
||||||
|
// 9. 迁移数据(独立子进程加载新方言客户端)
|
||||||
|
stepOf(job, '迁移数据到目标库');
|
||||||
|
const counts = await runApplySnapshotTool(job, target, snapshotFile);
|
||||||
|
job.counts = counts;
|
||||||
|
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
||||||
|
finishStep(job, '迁移数据到目标库', `共写入 ${total} 行`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
// 失败发生在哪一步,就把那一步标成 error(UI 上能直接看到卡在哪)
|
||||||
|
const running = [...job.steps].reverse().find((item) => item.status === 'running');
|
||||||
|
if (running) {
|
||||||
|
running.status = 'error';
|
||||||
|
running.detail = message.slice(0, 1000);
|
||||||
|
}
|
||||||
|
job.error = message;
|
||||||
|
job.status = 'error';
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
// 回滚 schema(.env 尚未改写,现有库不受影响)
|
||||||
|
if (schemaOriginal !== null) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(SCHEMA_FILE, schemaOriginal, 'utf8');
|
||||||
|
} catch (rollbackError) {
|
||||||
|
logger.error('回滚 schema.prisma 失败,请手工核对 provider 设置', rollbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.error(`数据库切换失败(job=${job.id}):${message}`);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (snapshotFile) fs.rmSync(snapshotFile, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 全部成功后才切换 .env(失败已在上面的 catch 里返回)
|
||||||
|
try {
|
||||||
|
stepOf(job, '写入 .env 配置');
|
||||||
|
const files = updateEnvFiles(target);
|
||||||
|
finishStep(job, '写入 .env 配置', files);
|
||||||
|
job.status = 'done';
|
||||||
|
job.restartRequired = true;
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
logger.info(`数据库切换任务完成(job=${job.id})→ ${target.provider},重启服务端后生效`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
job.error = `数据已迁移到目标库,但写入 .env 失败:${message}(请手工修改 .env 的 DATABASE_PROVIDER / DATABASE_URL 后重启)`;
|
||||||
|
job.status = 'error';
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isDayKey } from '@classhelper/shared';
|
import { isDayKey } from '@classhelper/shared';
|
||||||
import { booleanFlagSchema } from '../../lib/schemas.js';
|
import { attachmentUrlSchema, booleanFlagSchema } from '../../lib/schemas.js';
|
||||||
|
|
||||||
/** YYYY-MM-DD,且必须是真实存在的日期 */
|
/** YYYY-MM-DD,且必须是真实存在的日期 */
|
||||||
const dayKeySchema = z
|
const dayKeySchema = z
|
||||||
@@ -32,7 +32,7 @@ export const createHomeworkSchema = z.object({
|
|||||||
courseId: z.string().min(1).nullish(),
|
courseId: z.string().min(1).nullish(),
|
||||||
title: z.string().trim().min(1, '请输入作业标题').max(120),
|
title: z.string().trim().min(1, '请输入作业标题').max(120),
|
||||||
content: z.string().min(1, '请输入作业内容').max(5000),
|
content: z.string().min(1, '请输入作业内容').max(5000),
|
||||||
attachmentUrl: z.string().trim().max(500).nullish(),
|
attachmentUrl: attachmentUrlSchema,
|
||||||
/** 作业所属日期(YYYY-MM-DD):客户端/Web 录入时选,不传按服务器当天 */
|
/** 作业所属日期(YYYY-MM-DD):客户端/Web 录入时选,不传按服务器当天 */
|
||||||
assignDate: dayKeySchema.nullish(),
|
assignDate: dayKeySchema.nullish(),
|
||||||
});
|
});
|
||||||
@@ -43,7 +43,7 @@ export const updateHomeworkSchema = z.object({
|
|||||||
assignDate: dayKeySchema.optional(),
|
assignDate: dayKeySchema.optional(),
|
||||||
title: z.string().trim().min(1).max(120).optional(),
|
title: z.string().trim().min(1).max(120).optional(),
|
||||||
content: z.string().min(1).max(5000).optional(),
|
content: z.string().min(1).max(5000).optional(),
|
||||||
attachmentUrl: z.string().trim().max(500).nullish(),
|
attachmentUrl: attachmentUrlSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateHomeworkStatusSchema = z.object({
|
export const updateHomeworkStatusSchema = z.object({
|
||||||
|
|||||||
@@ -268,12 +268,16 @@ export async function updateHomeworkStatus(
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 教师 / 管理员分支:必须落到**该班学生**身上。旧实现允许不传 userId 并默默写到
|
||||||
|
// 操作者自己的账号上(教师时 user.sub 就是教师 id),那条记录既不在学生名单里,
|
||||||
|
// 又会被教师视角的 completedCount 计入,导致"完成人数 > 班级人数"。
|
||||||
const targetUserId = input.userId ?? user.sub;
|
const targetUserId = input.userId ?? user.sub;
|
||||||
if (input.userId) {
|
const target = await prisma.user.findUnique({
|
||||||
const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { classId: true } });
|
where: { id: targetUserId },
|
||||||
if (!target || target.classId !== homework.classId) {
|
select: { role: true, classId: true },
|
||||||
throw ApiError.badRequest('目标学生不属于该作业所在班级');
|
});
|
||||||
}
|
if (!target || target.role !== 'STUDENT' || target.classId !== homework.classId) {
|
||||||
|
throw ApiError.badRequest('请指定该作业所在班级的学生(userId),未指定时只能操作自己的账号');
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await prisma.homeworkStatus.upsert({
|
const status = await prisma.homeworkStatus.upsert({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { ApiError, sendOk } from '../../lib/http.js';
|
import { ApiError, sendOk } from '../../lib/http.js';
|
||||||
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||||
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
IMPORT_KINDS,
|
IMPORT_KINDS,
|
||||||
@@ -140,9 +141,10 @@ router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (r
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
|
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
|
||||||
router.delete('/time-layout/:id', async (req, res) => {
|
router.delete('/time-layout/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||||
await deleteTimeLayout(getAuthUser(req), req.params.id ?? '');
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
sendOk(res, { id: req.params.id }, '时间配置已删除');
|
await deleteTimeLayout(getAuthUser(req), id);
|
||||||
|
sendOk(res, { id }, '时间配置已删除');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { logger } from '../../lib/logger.js';
|
import { logger } from '../../lib/logger.js';
|
||||||
|
import { hashPassword } from '../../lib/password.js';
|
||||||
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
|
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
|
||||||
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
||||||
|
|
||||||
@@ -46,9 +46,9 @@ export const IMPORT_FIELDS: Record<
|
|||||||
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
|
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
|
||||||
],
|
],
|
||||||
students: [
|
students: [
|
||||||
|
// 学生只有名单没有账号(不能登录、无密码),模板不再有"初始密码"列
|
||||||
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
|
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
|
||||||
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
|
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
|
||||||
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
|
|
||||||
],
|
],
|
||||||
teachers: [
|
teachers: [
|
||||||
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
|
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
|
||||||
@@ -371,7 +371,6 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
|||||||
const rowNumber = index + 2;
|
const rowNumber = index + 2;
|
||||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||||
const password = cellValue(row, parsed.columns, input.mapping, 'password');
|
|
||||||
|
|
||||||
if (!username || !name) {
|
if (!username || !name) {
|
||||||
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
|
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
|
||||||
@@ -395,14 +394,14 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
|||||||
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
|
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} else {
|
} else {
|
||||||
const initialPassword = password || env.defaultStudentPassword;
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
name,
|
name,
|
||||||
role: 'STUDENT',
|
role: 'STUDENT',
|
||||||
classId: classId,
|
classId: classId,
|
||||||
passwordHash: await bcrypt.hash(initialPassword, 10),
|
// 学生不设密码(空串占位,登录 403 判定在密码校验之前),见"学生只有名单没有账号"
|
||||||
|
passwordHash: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
inserted += 1;
|
inserted += 1;
|
||||||
@@ -478,7 +477,7 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
|
|||||||
username,
|
username,
|
||||||
name,
|
name,
|
||||||
role: parseTeacherRole(roleCell) ?? 'TEACHER',
|
role: parseTeacherRole(roleCell) ?? 'TEACHER',
|
||||||
passwordHash: await bcrypt.hash(initialPassword, 10),
|
passwordHash: await hashPassword(initialPassword),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
inserted += 1;
|
inserted += 1;
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export const reportTimeLayoutItemSchema = z.object({
|
|||||||
export const classIslandReportSchema = z.object({
|
export const classIslandReportSchema = z.object({
|
||||||
pluginVersion: z.string().trim().max(40).optional(),
|
pluginVersion: z.string().trim().max(40).optional(),
|
||||||
classIslandVersion: z.string().trim().max(40).optional(),
|
classIslandVersion: z.string().trim().max(40).optional(),
|
||||||
|
/**
|
||||||
|
* 插件生成的机器码。设备创建时服务端还不知道它是谁,只能用 `pending-…` 占位,
|
||||||
|
* 因此插件每次上报都带上它,服务端据此回填,Web 端设备列表才能与教室机器对上
|
||||||
|
* (见 integrations.service 的 syncDeviceKey)。
|
||||||
|
*/
|
||||||
|
deviceKey: z.string().trim().max(64).optional(),
|
||||||
state: z
|
state: z
|
||||||
.object({
|
.object({
|
||||||
inClass: z.boolean().optional(),
|
inClass: z.boolean().optional(),
|
||||||
|
|||||||
@@ -422,6 +422,9 @@ export async function applyReport(
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||||
|
|
||||||
|
// 0) 机器码回填(设备创建时只能用 pending-… 占位,首次上报才知道它到底是哪台机器)
|
||||||
|
if (input.deviceKey) await syncDeviceKey(device, input.deviceKey);
|
||||||
|
|
||||||
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
||||||
const state = input.state;
|
const state = input.state;
|
||||||
await prisma.integrationDevice.update({
|
await prisma.integrationDevice.update({
|
||||||
@@ -518,6 +521,39 @@ export async function applyReport(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把插件上报的机器码回填到设备记录。
|
||||||
|
*
|
||||||
|
* `IntegrationDevice` 上有 `@@unique([classId, deviceKey])`,因此要避开两个坑:
|
||||||
|
* 1) 同一台机器在这个班已有另一条设备记录(例如管理员重复建过设备)时直接写会撞唯一约束 → 500,
|
||||||
|
* 这里改为记 warning 并保留原值;
|
||||||
|
* 2) 值没变时不必白写一次库。
|
||||||
|
*/
|
||||||
|
async function syncDeviceKey(device: DeviceContext, reportedKey: string): Promise<void> {
|
||||||
|
const key = reportedKey.trim();
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
const current = await prisma.integrationDevice.findUnique({
|
||||||
|
where: { id: device.deviceId },
|
||||||
|
select: { deviceKey: true },
|
||||||
|
});
|
||||||
|
if (!current || current.deviceKey === key) return;
|
||||||
|
|
||||||
|
const taken = await prisma.integrationDevice.findFirst({
|
||||||
|
where: { classId: device.classId, deviceKey: key, id: { not: device.deviceId } },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
if (taken) {
|
||||||
|
logger.warn(
|
||||||
|
`ClassIsland 上报的机器码「${key}」已被本班设备「${taken.name}」占用,本次不回填(设备=${device.name})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.integrationDevice.update({ where: { id: device.deviceId }, data: { deviceKey: key } });
|
||||||
|
logger.info(`ClassIsland 设备机器码已回填:${device.name} -> ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** 该设备/该班级最近一条尚未确认、未过期的下发提醒 */
|
/** 该设备/该班级最近一条尚未确认、未过期的下发提醒 */
|
||||||
async function pendingNotificationFor(
|
async function pendingNotificationFor(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ScheduleWriteResult {
|
|||||||
applied: boolean;
|
applied: boolean;
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
|
/** 被清理掉的旧同步行(老师已在 ClassIsland 里删除的课) */
|
||||||
|
removed: number;
|
||||||
courses: string[];
|
courses: string[];
|
||||||
weekStart: number;
|
weekStart: number;
|
||||||
weekEnd: number;
|
weekEnd: number;
|
||||||
@@ -46,6 +48,7 @@ export async function applyReportedSchedule(
|
|||||||
applied: false,
|
applied: false,
|
||||||
created: 0,
|
created: 0,
|
||||||
updated: 0,
|
updated: 0,
|
||||||
|
removed: 0,
|
||||||
courses: [],
|
courses: [],
|
||||||
weekStart: 1,
|
weekStart: 1,
|
||||||
weekEnd: 20,
|
weekEnd: 20,
|
||||||
@@ -91,6 +94,8 @@ export async function applyReportedSchedule(
|
|||||||
// 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」
|
// 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
/** 本轮上报覆盖到的行 id:不在其中的 classisland 来源行会被清理(见第 4 步) */
|
||||||
|
const keptIds = new Set<string>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const courseId = courseIdByName.get(entry.subject);
|
const courseId = courseIdByName.get(entry.subject);
|
||||||
if (!courseId) continue;
|
if (!courseId) continue;
|
||||||
@@ -102,8 +107,21 @@ export async function applyReportedSchedule(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await tx.schedule.update({
|
await tx.schedule.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { courseId, endTime: entry.endTime, weekStart, weekEnd },
|
data: {
|
||||||
|
courseId,
|
||||||
|
endTime: entry.endTime,
|
||||||
|
// 不覆盖 weekStart / weekEnd:插件上报契约里没有周次范围,
|
||||||
|
// 硬写"整学期"会把老师在 Web 端为选修/短期课设的「第 1~10 周」悄悄抹掉。
|
||||||
|
// 只在新建时给默认值(见下面 create)。
|
||||||
|
//
|
||||||
|
// 同样**不要**把 source 改成 'classisland':merge 的去重键是「星期 + 开始时间 + 单双周」,
|
||||||
|
// 老师在 Web 端手排的同一时间槽会被这里"收养"。一旦收养时改写来源,
|
||||||
|
// 后续那次"清理插件侧已删除的课"就会把这行老师手排的课一起删掉
|
||||||
|
// (真机上发生过:种子课表 93 行被删成 91 行,`verify:e2e` 的上课状态用例随之失败)。
|
||||||
|
// 因此来源只在 create 时确定,update 永远保持原值。
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
keptIds.add(existing.id);
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} else {
|
} else {
|
||||||
await tx.schedule.create({
|
await tx.schedule.create({
|
||||||
@@ -117,22 +135,53 @@ export async function applyReportedSchedule(
|
|||||||
weekStart,
|
weekStart,
|
||||||
weekEnd,
|
weekEnd,
|
||||||
weekParity,
|
weekParity,
|
||||||
|
source: 'classisland',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
created += 1;
|
created += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { created, updated, createdCourses, allCourses: [...courseIdByName.keys()] };
|
|
||||||
|
// 4) 清理"老师已在 ClassIsland 里删掉"的行。
|
||||||
|
//
|
||||||
|
// merge 模式原先只 upsert、从不删除:老师在 ClassIsland 删掉一节,或把 8:00 的课挪到 8:05,
|
||||||
|
// 服务端那行会永远留着(幽灵课 / 同一天两节重复课)。但也不能照 replace 那样整表清空 ——
|
||||||
|
// 那会连老师在 Web 端手排的课一起删。
|
||||||
|
// 因此只清理 `source='classisland'`(即由上报同步产生)且本轮未再出现的行。
|
||||||
|
let removed = 0;
|
||||||
|
if (mode === 'merge') {
|
||||||
|
const synced = await tx.schedule.findMany({
|
||||||
|
where: { classId, source: 'classisland' },
|
||||||
|
select: { id: true, dayOfWeek: true, startTime: true, weekParity: true },
|
||||||
|
});
|
||||||
|
const stale = synced.filter(
|
||||||
|
(row) =>
|
||||||
|
!keptIds.has(row.id) &&
|
||||||
|
!entries.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.dayOfWeek === row.dayOfWeek &&
|
||||||
|
entry.startTime === row.startTime &&
|
||||||
|
resolveParity(entry) === row.weekParity,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (stale.length > 0) {
|
||||||
|
await tx.schedule.deleteMany({ where: { id: { in: stale.map((row) => row.id) } } });
|
||||||
|
removed = stale.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, removed, createdCourses, allCourses: [...courseIdByName.keys()] };
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` +
|
`ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` +
|
||||||
`补建科目=[${result.createdCourses.join(',')}]`,
|
`清理=${result.removed} 补建科目=[${result.createdCourses.join(',')}]`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
applied: true,
|
applied: true,
|
||||||
created: result.created,
|
created: result.created,
|
||||||
updated: result.updated,
|
updated: result.updated,
|
||||||
|
removed: result.removed,
|
||||||
courses: result.allCourses,
|
courses: result.allCourses,
|
||||||
weekStart,
|
weekStart,
|
||||||
weekEnd,
|
weekEnd,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { authModule } from './auth/auth.module.js';
|
|||||||
import { callsModule } from './calls/calls.module.js';
|
import { callsModule } from './calls/calls.module.js';
|
||||||
import { classesModule } from './classes/classes.module.js';
|
import { classesModule } from './classes/classes.module.js';
|
||||||
import { coursesModule } from './courses/courses.module.js';
|
import { coursesModule } from './courses/courses.module.js';
|
||||||
|
import { databaseModule } from './database/database.module.js';
|
||||||
import { dashboardModule } from './dashboard/dashboard.module.js';
|
import { dashboardModule } from './dashboard/dashboard.module.js';
|
||||||
import { gradesModule } from './grades/grades.module.js';
|
import { gradesModule } from './grades/grades.module.js';
|
||||||
import { homeworksModule } from './homeworks/homeworks.module.js';
|
import { homeworksModule } from './homeworks/homeworks.module.js';
|
||||||
@@ -34,4 +35,5 @@ export const apiModules: ApiModule[] = [
|
|||||||
studentsModule,
|
studentsModule,
|
||||||
teachersModule,
|
teachersModule,
|
||||||
dashboardModule,
|
dashboardModule,
|
||||||
|
databaseModule,
|
||||||
].filter((module) => module.enabled !== false);
|
].filter((module) => module.enabled !== false);
|
||||||
@@ -7,18 +7,21 @@ import { defineModule } from '../module.types.js';
|
|||||||
import {
|
import {
|
||||||
createStudentSchema,
|
createStudentSchema,
|
||||||
listStudentsQuerySchema,
|
listStudentsQuerySchema,
|
||||||
resetPasswordSchema,
|
|
||||||
updateStudentSchema,
|
updateStudentSchema,
|
||||||
type CreateStudentInput,
|
type CreateStudentInput,
|
||||||
type ResetPasswordInput,
|
|
||||||
type UpdateStudentInput,
|
type UpdateStudentInput,
|
||||||
} from './students.schemas.js';
|
} from './students.schemas.js';
|
||||||
import * as studentService from './students.service.js';
|
import * as studentService from './students.service.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
|
|
||||||
/** GET /api/students?classId=&keyword= - 学生名单(教师/管理员) */
|
/**
|
||||||
|
* GET /api/students?classId=&keyword= - 学生名单
|
||||||
|
*
|
||||||
|
* 注意是**仅管理员**(不是教师/管理员):学生名单自带用户名,而学生初始密码是统一的默认值,
|
||||||
|
* 因此名单属于管理员专属数据;教师查看本班学生请用 `GET /api/classes/:id/students`
|
||||||
|
* (那条按班级权限校验,班级设备也能读)。
|
||||||
|
*/
|
||||||
router.get('/', requireRole('ADMIN'), validate({ query: listStudentsQuerySchema }), async (req, res) => {
|
router.get('/', requireRole('ADMIN'), validate({ query: listStudentsQuerySchema }), async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const query = validatedQuery<{ classId?: string; keyword?: string }>(req);
|
const query = validatedQuery<{ classId?: string; keyword?: string }>(req);
|
||||||
@@ -51,20 +54,7 @@ router.patch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** POST /api/students/:id/reset-password - 重置密码 */
|
/** DELETE /api/students - 删除学生名单记录 */
|
||||||
router.post(
|
|
||||||
'/:id/reset-password',
|
|
||||||
requireRole('ADMIN'),
|
|
||||||
validate({ params: idParamSchema, body: resetPasswordSchema }),
|
|
||||||
async (req, res) => {
|
|
||||||
const user = getAuthUser(req);
|
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
|
||||||
await studentService.resetPassword(user, id, req.body as ResetPasswordInput);
|
|
||||||
sendOk(res, { id }, '密码已重置');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** DELETE /api/students/:id - 删除学生账号 */
|
|
||||||
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ export const listStudentsQuerySchema = z.object({
|
|||||||
keyword: keywordSchema,
|
keyword: keywordSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 学生只有"名单"没有"账号":不设密码、不能登录(2026-10-01 起学生个人账号已全部清理,
|
||||||
|
// 学生端统一用班级码 + 班级密码),因此这里没有也不该有任何密码字段。
|
||||||
export const createStudentSchema = z.object({
|
export const createStudentSchema = z.object({
|
||||||
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
||||||
name: z.string().trim().min(1, '请输入姓名').max(32),
|
name: z.string().trim().min(1, '请输入姓名').max(32),
|
||||||
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
|
|
||||||
classId: z.string().min(1).nullish(),
|
classId: z.string().min(1).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,10 +20,5 @@ export const updateStudentSchema = z.object({
|
|||||||
classId: z.string().min(1).nullish(),
|
classId: z.string().min(1).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetPasswordSchema = z.object({
|
|
||||||
newPassword: z.string().min(6, '新密码至少 6 位').max(128).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CreateStudentInput = z.infer<typeof createStudentSchema>;
|
export type CreateStudentInput = z.infer<typeof createStudentSchema>;
|
||||||
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>;
|
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>;
|
||||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { StudentDto } from '@classhelper/shared';
|
import type { StudentDto } from '@classhelper/shared';
|
||||||
import { env } from '../../config/env.js';
|
|
||||||
import { assertCanManageRoster, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
|
import { assertCanManageRoster, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { toStudentDto } from '../../lib/mappers.js';
|
import { toStudentDto } from '../../lib/mappers.js';
|
||||||
import { hashPassword } from '../../lib/password.js';
|
import type { CreateStudentInput, UpdateStudentInput } from './students.schemas.js';
|
||||||
import type { CreateStudentInput, ResetPasswordInput, UpdateStudentInput } from './students.schemas.js';
|
|
||||||
|
|
||||||
export interface ListStudentOptions {
|
export interface ListStudentOptions {
|
||||||
classId?: string;
|
classId?: string;
|
||||||
@@ -35,18 +33,18 @@ export async function listStudents(user: TokenPayload, options: ListStudentOptio
|
|||||||
return students.map(toStudentDto);
|
return students.map(toStudentDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新建学生账号(可选直接分班) */
|
/** 新建学生(名单实体,可选直接分班)。学生不设密码:登录一律走班级账号,见 createStudentSchema 的说明 */
|
||||||
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
|
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
|
||||||
if (input.classId) assertCanManageRoster(user);
|
if (input.classId) assertCanManageRoster(user);
|
||||||
|
|
||||||
const passwordHash = await hashPassword(input.password ?? env.defaultStudentPassword);
|
|
||||||
const created = await prisma.user.create({
|
const created = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username: input.username,
|
username: input.username,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
role: 'STUDENT',
|
role: 'STUDENT',
|
||||||
classId: input.classId ?? null,
|
classId: input.classId ?? null,
|
||||||
passwordHash,
|
// User.passwordHash 是必填列,学生用空串占位(登录的 STUDENT 403 判定在密码校验之前,永远不会用到它)
|
||||||
|
passwordHash: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,24 +124,3 @@ export async function deleteStudent(user: TokenPayload, studentId: string): Prom
|
|||||||
|
|
||||||
await prisma.user.delete({ where: { id: studentId } });
|
await prisma.user.delete({ where: { id: studentId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置学生密码(未指定时使用默认初始密码) */
|
|
||||||
export async function resetPassword(
|
|
||||||
user: TokenPayload,
|
|
||||||
studentId: string,
|
|
||||||
input: ResetPasswordInput,
|
|
||||||
): Promise<void> {
|
|
||||||
const student = await prisma.user.findUnique({ where: { id: studentId } });
|
|
||||||
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
|
|
||||||
|
|
||||||
if (student.classId) {
|
|
||||||
assertCanManageRoster(user);
|
|
||||||
} else if (!isAdmin(user)) {
|
|
||||||
throw ApiError.forbidden('该学生尚未分班,仅管理员可以重置密码');
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: studentId },
|
|
||||||
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultStudentPassword) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -81,23 +81,47 @@ export async function resetTeacherPassword(
|
|||||||
/**
|
/**
|
||||||
* 删除教师账号。
|
* 删除教师账号。
|
||||||
*
|
*
|
||||||
* 安全护栏:教师是数据的创建者,`Class.teacherId` / `Course.teacherId` / 作业通知的 createdBy
|
* 安全护栏:教师是**班级共享内容**的创建者,而下面这些外键都是 `onDelete: Cascade`,
|
||||||
* 都是级联删除 —— 直接删班主任会连带删掉整个班级的数据。因此只有"没有任何班级职责、
|
* 删除账号会连带删掉整份内容(不是"作者名变空",是内容真的没了):
|
||||||
* 也不持有课程"的账号才允许删除(实践中就是刚录入错的那条),否则返回 409 让管理员先转移。
|
*
|
||||||
|
* | 关系 | 后果 |
|
||||||
|
* | ---- | ---- |
|
||||||
|
* | `Class.teacherId` | 整个班级(含课表/作业/通知/成绩)被删 |
|
||||||
|
* | `Course.teacherId` | 该教师名下课程被删 |
|
||||||
|
* | `ClassTeacher.teacherId` | 仅协作关系(无害,但一并列出以免漏项) |
|
||||||
|
* | `Homework.createdBy` | **该教师发布过的全部作业被删** |
|
||||||
|
* | `Notification.createdBy` | **该教师发布过的全部通知被删**(含"叫人"消息) |
|
||||||
|
*
|
||||||
|
* 前三条历史上被检查过,后两条(Homework / Notification)曾漏检 —— 于是"班主任转给别人 →
|
||||||
|
* 不在任何协作班级 → 名下无课程"的账号会被放行,删除时静默清掉他在各班发过的作业与通知。
|
||||||
|
* **改 schema 时请回到本函数核对一遍**:任何新增的"指向 User 且 Cascade"的关系都要加到这里
|
||||||
|
* (`HomeworkStatus` / `NotificationRead` / `Grade` / `Enrollment` 的 `userId` 属于个人数据,
|
||||||
|
* 随账号删除是正确行为,不算漏项)。
|
||||||
|
*
|
||||||
|
* 因此只有"没有留下任何班级共享内容"的账号才允许删除(实践中就是刚录入错的那条),
|
||||||
|
* 否则返回 409 让管理员先转移/清理。
|
||||||
*/
|
*/
|
||||||
export async function deleteTeacher(userId: string): Promise<void> {
|
export async function deleteTeacher(userId: string): Promise<void> {
|
||||||
const teacher = await findTeacherOrThrow(userId);
|
const teacher = await findTeacherOrThrow(userId);
|
||||||
|
|
||||||
const [ownedClasses, assignedClasses, courses] = await Promise.all([
|
const [ownedClasses, assignedClasses, courses, homeworks, notifications] = await Promise.all([
|
||||||
prisma.class.count({ where: { teacherId: teacher.id } }),
|
prisma.class.count({ where: { teacherId: teacher.id } }),
|
||||||
prisma.classTeacher.count({ where: { teacherId: teacher.id } }),
|
prisma.classTeacher.count({ where: { teacherId: teacher.id } }),
|
||||||
prisma.course.count({ where: { teacherId: teacher.id } }),
|
prisma.course.count({ where: { teacherId: teacher.id } }),
|
||||||
|
prisma.homework.count({ where: { createdBy: teacher.id } }),
|
||||||
|
prisma.notification.count({ where: { createdBy: teacher.id } }),
|
||||||
]);
|
]);
|
||||||
if (ownedClasses > 0) {
|
if (ownedClasses > 0) {
|
||||||
throw ApiError.conflict(
|
throw ApiError.conflict(
|
||||||
`「${teacher.name}」还是 ${ownedClasses} 个班级的班主任,请先在班级管理里更换班主任再删除`,
|
`「${teacher.name}」还是 ${ownedClasses} 个班级的班主任,请先在班级管理里更换班主任再删除`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (homeworks > 0 || notifications > 0) {
|
||||||
|
throw ApiError.conflict(
|
||||||
|
`「${teacher.name}」发布过 ${homeworks} 条作业 / ${notifications} 条通知,` +
|
||||||
|
'删除账号会连带删除这些内容,请先删除内容或改为保留账号',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (assignedClasses > 0 || courses > 0) {
|
if (assignedClasses > 0 || courses > 0) {
|
||||||
throw ApiError.conflict(
|
throw ApiError.conflict(
|
||||||
`「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`,
|
`「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { createPrismaClient, type DatabaseProvider } from '../lib/db.js';
|
||||||
|
import { assertValidSnapshot, restoreSnapshot, type DatabaseSnapshot } from '../lib/snapshot.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快照导入工具(供跨库迁移的**子进程**调用,也可手工使用)。
|
||||||
|
*
|
||||||
|
* 为什么是子进程:切换数据库时 `prisma generate` 会重新生成 Prisma Client(SQL 方言变了),
|
||||||
|
* 而正在运行的服务进程里缓存的是**旧方言**的客户端模块 —— 只有新进程才能加载重新生成的客户端,
|
||||||
|
* 把快照写进新库。用法:
|
||||||
|
*
|
||||||
|
* node dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json
|
||||||
|
*
|
||||||
|
* stdout 最后一行输出 JSON:{ ok, counts },父进程据此回填任务进度;失败时 exit 1 并输出原因。
|
||||||
|
* 注意 --file 是**未压缩**的 JSON(父进程负责解压到临时文件)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ToolArgs {
|
||||||
|
provider: DatabaseProvider;
|
||||||
|
url: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: readonly string[]): ToolArgs {
|
||||||
|
const read = (name: string): string => {
|
||||||
|
const index = argv.indexOf(`--${name}`);
|
||||||
|
const value = index >= 0 ? argv[index + 1] : undefined;
|
||||||
|
if (!value) throw new Error(`缺少参数 --${name}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const provider = read('provider');
|
||||||
|
if (provider !== 'sqlite' && provider !== 'mysql') {
|
||||||
|
throw new Error(`不支持的 provider:${provider}(只支持 sqlite / mysql)`);
|
||||||
|
}
|
||||||
|
return { provider, url: read('url'), file: read('file') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const raw = fs.readFileSync(args.file, 'utf8');
|
||||||
|
const snapshot = JSON.parse(raw) as DatabaseSnapshot;
|
||||||
|
assertValidSnapshot(snapshot);
|
||||||
|
|
||||||
|
const client = await createPrismaClient(args.provider, args.url);
|
||||||
|
try {
|
||||||
|
const { counts } = await restoreSnapshot(client, args.provider, snapshot);
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, counts })}\n`);
|
||||||
|
} finally {
|
||||||
|
await client.$disconnect().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/generated/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/shared",
|
"name": "@classhelper/shared",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
|
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const API_PATHS = {
|
|||||||
imports: '/imports',
|
imports: '/imports',
|
||||||
/** ClassIsland 联动:设备接入、状态上报、通知下发 */
|
/** ClassIsland 联动:设备接入、状态上报、通知下发 */
|
||||||
integrations: '/integrations',
|
integrations: '/integrations',
|
||||||
|
/** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */
|
||||||
|
database: '/database',
|
||||||
dashboard: '/dashboard/summary',
|
dashboard: '/dashboard/summary',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -55,6 +57,19 @@ export const PRIORITY_TAG_TYPES: Record<NotificationPriority, 'info' | 'primary'
|
|||||||
URGENT: 'danger',
|
URGENT: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优先级权重(越大越重要)。
|
||||||
|
*
|
||||||
|
* 灵动岛有多条通知时按它排序("默认按重要程度排列"),插件合并同 id 消息时也用它取更高优先级 ——
|
||||||
|
* 两边必须同一份口径,否则会出现"列表里的顺序和合并后的优先级不一致"。
|
||||||
|
*/
|
||||||
|
export const PRIORITY_RANK: Record<NotificationPriority, number> = {
|
||||||
|
LOW: 0,
|
||||||
|
NORMAL: 1,
|
||||||
|
HIGH: 2,
|
||||||
|
URGENT: 3,
|
||||||
|
};
|
||||||
|
|
||||||
/** 星期:1=周一 ... 7=周日(与数据库 dayOfWeek 一致) */
|
/** 星期:1=周一 ... 7=周日(与数据库 dayOfWeek 一致) */
|
||||||
export const WEEKDAY_LABELS: Record<number, string> = {
|
export const WEEKDAY_LABELS: Record<number, string> = {
|
||||||
1: '周一',
|
1: '周一',
|
||||||
@@ -213,6 +228,114 @@ export const ISLAND_TYPE_SCALE = {
|
|||||||
/** 胶囊副标题 */
|
/** 胶囊副标题 */
|
||||||
pillSub: 0.74,
|
pillSub: 0.74,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展开态"多条通知列表"的排版度量(单位:**基础字号的倍数**,即与 `--island-font` 同源)。
|
||||||
|
*
|
||||||
|
* 为什么这份常量必须放在 shared:列表比普通展开卡高得多,**窗口包围盒要跟着长高**,
|
||||||
|
* 否则卡片底部(那排按钮)会被窗口裁掉 —— 而窗口尺寸只有主进程能改、行高只有渲染进程知道。
|
||||||
|
* 两边用同一份度量各算一次"该显示几行 / 卡片多高",就不会出现"渲染进程以为放得下、窗口却不够高"的漂移。
|
||||||
|
*
|
||||||
|
* 与 `IslandApp.vue` 里 `.expanded-layer`、`.list-row`、`.list-hint`、`.foot` 的 CSS 逐项对应,
|
||||||
|
* **改 CSS 必须同步改这里**。
|
||||||
|
*/
|
||||||
|
export const ISLAND_LIST_METRICS = {
|
||||||
|
/** 卡片上下内边距 */
|
||||||
|
paddingY: 1.4,
|
||||||
|
/** 卡片内各块(标题行 / 批次标题 / 列表 / 按钮行)之间的间距 */
|
||||||
|
gap: 0.7,
|
||||||
|
/** 顶部标题行(图标 + 徽标 + 时间 + 收起按钮) */
|
||||||
|
headerHeight: 1.7,
|
||||||
|
/** 批次标题("共 N 条待处理通知",单行) */
|
||||||
|
titleHeight: 1.4,
|
||||||
|
/** 单条通知行高 */
|
||||||
|
rowHeight: 2.7,
|
||||||
|
/** 行间距(含提示行上方的间距) */
|
||||||
|
rowGap: 0.4,
|
||||||
|
/** "展开更多 / 更多请前往应用内操作"那一行(含上方间距) */
|
||||||
|
hintHeight: 2.5,
|
||||||
|
/** 底部按钮行(含上方间距、分隔线与内边距) */
|
||||||
|
footerHeight: 3.45,
|
||||||
|
/** 默认只显示前几条 —— 用户要求「只显示前三个,下面显示展开更多」 */
|
||||||
|
defaultVisibleRows: 3,
|
||||||
|
/** 兜底上限:再多也交给"前往应用内操作",避免窗口长到离谱 */
|
||||||
|
maxRows: 20,
|
||||||
|
/** 高度余量:浮点排版误差不至于让最后一行贴着裁切边 */
|
||||||
|
safetyPad: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 列表布局结算结果(主进程据此算窗口包围盒,渲染进程据此渲染) */
|
||||||
|
export interface IslandListLayout {
|
||||||
|
/** 实际要展示的通知行数 */
|
||||||
|
rows: number;
|
||||||
|
/** 底部提示行的形态:more=可"展开更多";app=放不下,请去应用内看;null=没有更多了 */
|
||||||
|
hint: 'more' | 'app' | null;
|
||||||
|
/** 卡片高度(CSS px) */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表卡片里"除通知行之外"的固定高度(CSS px):内边距 + 标题行 + 批次标题 + 底部按钮行 + 可选提示行 */
|
||||||
|
function islandListChromeHeight(fontSize: number, withHint: boolean): number {
|
||||||
|
const m = ISLAND_LIST_METRICS;
|
||||||
|
const base = (m.paddingY * 2 + m.headerHeight + m.titleHeight + m.footerHeight + m.gap * 3) * fontSize;
|
||||||
|
return base + (withHint ? m.hintHeight * fontSize : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** n 行通知(含行间距)占用的高度(CSS px) */
|
||||||
|
function islandListRowsHeight(fontSize: number, rows: number): number {
|
||||||
|
const m = ISLAND_LIST_METRICS;
|
||||||
|
if (rows <= 0) return 0;
|
||||||
|
return (rows * m.rowHeight + (rows - 1) * m.rowGap) * fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在给定高度里最多放得下几行通知(至少 1 行,至多 maxRows) */
|
||||||
|
function islandListFittingRows(fontSize: number, maxHeight: number, withHint: boolean): number {
|
||||||
|
const m = ISLAND_LIST_METRICS;
|
||||||
|
const chrome = islandListChromeHeight(fontSize, withHint) + m.safetyPad;
|
||||||
|
const unit = (m.rowHeight + m.rowGap) * fontSize;
|
||||||
|
const rowUnit = m.rowHeight * fontSize;
|
||||||
|
const room = maxHeight - chrome;
|
||||||
|
if (room < rowUnit) return 1;
|
||||||
|
return Math.max(1, Math.min(m.maxRows, Math.floor((room + m.rowGap * fontSize) / unit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结算"多条通知"的展开卡布局(**主进程与渲染进程共用**):
|
||||||
|
* - 默认只显示前 `defaultVisibleRows` 条,多余的放进"展开更多";
|
||||||
|
* - 放不下的部分(快到屏幕下沿的任务栏了)不再撑高卡片,改为提示"更多请前往应用内操作";
|
||||||
|
* - 返回的 `height` 就是卡片(以及窗口包围盒)应有的高度。
|
||||||
|
*/
|
||||||
|
export function islandListLayout(input: {
|
||||||
|
/** 待处理通知总数 */
|
||||||
|
count: number;
|
||||||
|
/** 基础字号(外观设置) */
|
||||||
|
fontSize: number;
|
||||||
|
/** 当前屏幕/停靠位置下卡片可用的最大高度(CSS px) */
|
||||||
|
maxHeight: number;
|
||||||
|
/** 是否已点过"展开更多" */
|
||||||
|
expanded: boolean;
|
||||||
|
}): IslandListLayout {
|
||||||
|
const m = ISLAND_LIST_METRICS;
|
||||||
|
const total = Math.max(0, Math.floor(input.count));
|
||||||
|
// 先按"用户意图"决定想显示几行;再受屏幕高度限制
|
||||||
|
const desired = input.expanded ? Math.min(total, m.maxRows) : Math.min(total, m.defaultVisibleRows);
|
||||||
|
const wantsHint = desired < total;
|
||||||
|
const fitting = islandListFittingRows(input.fontSize, input.maxHeight, wantsHint);
|
||||||
|
const rows = Math.max(1, Math.min(desired, fitting));
|
||||||
|
const hint: IslandListLayout['hint'] =
|
||||||
|
rows >= total ? null : input.expanded || rows >= fitting ? 'app' : 'more';
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
hint,
|
||||||
|
height: Math.min(
|
||||||
|
input.maxHeight,
|
||||||
|
islandListChromeHeight(input.fontSize, hint !== null) +
|
||||||
|
islandListRowsHeight(input.fontSize, rows) +
|
||||||
|
m.safetyPad,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 默认学期周次上限 */
|
/** 默认学期周次上限 */
|
||||||
export const DEFAULT_WEEK_COUNT = 20;
|
export const DEFAULT_WEEK_COUNT = 20;
|
||||||
|
|
||||||
@@ -355,6 +478,12 @@ export const STORAGE_KEYS = {
|
|||||||
serverUrl: 'classhelper.serverUrl',
|
serverUrl: 'classhelper.serverUrl',
|
||||||
cachePrefix: 'classhelper.cache',
|
cachePrefix: 'classhelper.cache',
|
||||||
lastSyncAt: 'classhelper.lastSyncAt',
|
lastSyncAt: 'classhelper.lastSyncAt',
|
||||||
|
/**
|
||||||
|
* 新手引导「已看过」标记(Web 管理端 localStorage)。
|
||||||
|
* 值为引导版本号:改版引导内容后版本 +1,看过旧版的老用户会再收到一次新版引导。
|
||||||
|
* EXE 客户端不用这个键 —— 它的引导状态在主进程配置(`onboardingDone`)里。
|
||||||
|
*/
|
||||||
|
onboarding: 'classhelper.onboarding',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 桌面客户端默认后端地址 */
|
/** 桌面客户端默认后端地址 */
|
||||||
|
|||||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user