feat: 生产化改造(服务端安装程序/PWA/Docker+HTTPS)并修复侧边栏点击无反应、SPA 静态托管 dotfiles 两个缺陷
- 修复 web-admin 与 desktop-client 侧边栏点击无反应:el-menu 的 index 是路径,不能当路由名使用 - 修复 Web 产物目录含点号目录(如 .cache)时 res.sendFile 因 dotfiles:ignore 返回 500 - 后端生产模式:静态托管 Web 端 + SPA 回退、helmet、限流(通用/登录)、trust proxy、 /healthz 与 /readyz、请求耗时日志、生产环境变量强校验、首启动自动迁移与创建管理员、PID 文件 - Web 管理端 PWA:manifest + service worker + 图标,可在浏览器安装为应用 - 新增服务端 + Web 管理端 Windows 安装程序(内置 Node 运行时、启停与服务脚本、开机自启、卸载保留数据) - 新增 Docker/Compose/Nginx 部署样例与 docs/production.md - 新增 UI 回归测试:Web 端 Electron 驱动真实点击 7 项、客户端冒烟 11 项(含侧边栏点击与离线回退)
No files matched your search
@@ -0,0 +1,17 @@
|
|||||||
|
# Docker 构建上下文排除项(镜像只需要源码与配置)
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/release
|
||||||
|
**/release-server
|
||||||
|
.cache
|
||||||
|
.pnpm-store
|
||||||
|
.probe
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
*.log
|
||||||
|
packages/server/prisma/*.db
|
||||||
|
packages/server/prisma/*.db-journal
|
||||||
|
packages/server/src/generated
|
||||||
|
packages/desktop-client/build
|
||||||
|
packages/web-admin/public/icon-512.png
|
||||||
|
deploy/.env
|
||||||
@@ -6,6 +6,8 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
dist-electron/
|
dist-electron/
|
||||||
release/
|
release/
|
||||||
|
# 服务端免安装目录与安装程序(体积极大,由 pnpm dist:server 生成)
|
||||||
|
release-server/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# generated code
|
# generated code
|
||||||
|
|||||||
@@ -15,14 +15,15 @@
|
|||||||
|
|
||||||
## 当前交付范围
|
## 当前交付范围
|
||||||
|
|
||||||
| 阶段 | 内容 | 状态 |
|
| 阶段 | 内容 | 状态 |
|
||||||
| ---- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
| ---- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 9/9 通过) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 11/11) |
|
||||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(electron-builder 打包 Windows EXE) |
|
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||||
|
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||||
|
|
||||||
> **与原始提示词的两处偏差(已与你确认)**
|
> **与原始提示词的两处偏差(已与你确认)**
|
||||||
>
|
>
|
||||||
@@ -30,18 +31,40 @@
|
|||||||
> (Socket.IO Client、Pinia、IndexedDB 离线缓存、electron-builder)都基于 Electron,
|
> (Socket.IO Client、Pinia、IndexedDB 离线缓存、electron-builder)都基于 Electron,
|
||||||
> 且本机没有 .NET SDK。
|
> 且本机没有 .NET SDK。
|
||||||
> 2. 数据库 **先用 SQLite(libSQL 内嵌)跑通 MVP,并预留 MySQL 切换**:本机没有可用 MySQL
|
> 2. 数据库 **先用 SQLite(libSQL 内嵌)跑通 MVP,并预留 MySQL 切换**:本机没有可用 MySQL
|
||||||
> 实例;`docs/mysql.md` 给出云数据库切换的完整步骤。
|
> 实例;`docs/mysql.md` 给出云数据库切换的完整步骤,`deploy/` 提供 MySQL 版 Compose。
|
||||||
|
|
||||||
|
## 三套交付产物
|
||||||
|
|
||||||
|
| 产物 | 文件 | 用途 |
|
||||||
|
| ------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| **服务端 + Web 管理端安装程序** | `release-server/班级小助手服务端-0.1.0-x64-setup.exe`(34 MB) | 装到教师电脑/校服务器即完整系统,**内置 Node 运行时**,双击安装、开机自启、自动建库建号 |
|
||||||
|
| **学生客户端安装程序** | `packages/desktop-client/release/班级小助手-0.1.0-x64-setup.exe`(107 MB) | 学生机安装(NSIS 安装包) |
|
||||||
|
| **学生客户端单文件版** | `packages/desktop-client/release/班级小助手-0.1.0-x64-portable.exe`(107 MB) | 免安装直接运行(U 盘分发) |
|
||||||
|
| Web 管理端(PWA) | 由服务端在 `/` 直接托管 | 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」 |
|
||||||
|
|
||||||
|
生产部署(Windows 安装包 / Docker + MySQL / 手动部署)请看
|
||||||
|
**[docs/production.md](docs/production.md)**。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
class-helper/
|
class-helper/
|
||||||
├── package.json # 根脚本(dev / build / db:* / verify:* / dist:*)
|
├── 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 基础配置
|
├── tsconfig.base.json # 共享 TS 基础配置
|
||||||
├── eslint.config.mjs # ESLint 扁平配置(TS + Vue)
|
├── eslint.config.mjs # ESLint 扁平配置(TS + Vue)
|
||||||
├── scripts/use-database.mjs # SQLite ⇄ MySQL provider 切换助手
|
├── .dockerignore # 容器构建上下文排除项
|
||||||
├── docs/mysql.md # MySQL 切换与生产部署指南
|
├── build/icon.{ico,png} # 应用图标(由 pnpm icons 生成)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手
|
||||||
|
│ ├── generate-icons.mjs # 用 Electron 渲染 SVG 生成 PNG/ICO 图标
|
||||||
|
│ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序)
|
||||||
|
│ ├── nsis/server-installer.nsi# 安装程序脚本模板
|
||||||
|
│ └── ui-smoke/ # Web 管理端 UI 真实点击回归测试(Electron 驱动)
|
||||||
|
├── deploy/ # 生产部署:Dockerfile / docker-compose.yml / nginx.conf
|
||||||
|
├── docs/
|
||||||
|
│ ├── production.md # 生产部署指南(三种形态 + 运维 + 安全清单)
|
||||||
|
│ └── mysql.md # MySQL 切换指南
|
||||||
└── packages/
|
└── packages/
|
||||||
├── shared/ # 三端共享:类型契约、常量、工具函数
|
├── shared/ # 三端共享:类型契约、常量、工具函数
|
||||||
│ └── src/{types,constants,utils,index}.ts
|
│ └── src/{types,constants,utils,index}.ts
|
||||||
@@ -52,23 +75,25 @@ class-helper/
|
|||||||
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
|
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
|
||||||
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(54 项)
|
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(54 项)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── app.ts # Express 装配(服务首页 + 健康检查 + 模块挂载)
|
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
|
||||||
│ ├── index.ts # 启动入口(HTTP + Socket.IO + 优雅退出)
|
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
|
||||||
│ ├── config/env.ts # 环境变量校验(zod)
|
│ ├── config/env.ts # 环境变量校验(zod)+ 生产配置自检
|
||||||
│ ├── lib/ # db / http / jwt / password / access(RBAC) / mappers
|
│ ├── lib/ # db / db-bootstrap / web-static / http / jwt / access(RBAC) / mappers
|
||||||
│ ├── middleware/ # auth / error / validate
|
│ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志)
|
||||||
│ ├── realtime/ # socket.ts + bus.ts(事件总线)
|
│ ├── realtime/ # socket.ts + bus.ts(事件总线)
|
||||||
│ └── modules/ # 功能模块 + registry.ts
|
│ └── modules/ # 功能模块 + registry.ts
|
||||||
├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus)
|
├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus + PWA)
|
||||||
|
│ ├── public/ # manifest / sw.js / 图标(由 pnpm icons 生成)
|
||||||
│ └── src/{api,stores,router,layouts,views,styles}
|
│ └── src/{api,stores,router,layouts,views,styles}
|
||||||
└── desktop-client/ # EXE 客户端(Electron + Vue 3)
|
└── desktop-client/ # EXE 客户端(Electron + Vue 3)
|
||||||
├── electron-builder.yml # 打包配置(nsis 安装包 + portable 单文件)
|
├── electron-builder.yml # 打包配置(nsis 安装包 + portable 单文件)
|
||||||
|
├── build/icon.ico # 应用图标(electron-builder 自动使用)
|
||||||
├── vite.config.mts # 渲染进程构建(base: './',hash 路由)
|
├── vite.config.mts # 渲染进程构建(base: './',hash 路由)
|
||||||
├── scripts/{build-main,dev,smoke}.mjs
|
├── scripts/{build-main,dev,smoke,dist-win}.mjs
|
||||||
└── src/
|
└── src/
|
||||||
├── main/ # 主进程:窗口/单实例/配置持久化(IPC)/冒烟验证
|
├── main/ # 主进程:窗口/单实例/配置持久化(IPC)/冒烟验证
|
||||||
├── preload/ # contextBridge 安全桥(不暴露 ipcRenderer 本体)
|
├── preload/ # contextBridge 安全桥(不暴露 ipcRenderer 本体)
|
||||||
├── types/desktop.d.ts # 主进程 <-> 渲染进程契约
|
├── types/desktop.d.ts # 主进程 <-> 渲染进程契约
|
||||||
└── renderer/ # 渲染进程:api / stores / cache / router / layouts / views
|
└── renderer/ # 渲染进程:api / stores / cache / router / layouts / views
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -179,9 +204,14 @@ pnpm verify:desktop # Electron 客户端(含联网集成):9 项
|
|||||||
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||||
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
|
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
|
||||||
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||||
| `pnpm verify:desktop` | Electron 冒烟验证(需先 `pnpm build:desktop`;后端在线则含联网集成) |
|
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
|
||||||
| `pnpm dist:dir` | 打包免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
|
||||||
| `pnpm dist:win` | 打包 nsis 安装包 + portable 单文件 EXE |
|
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
||||||
|
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
|
||||||
|
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
||||||
|
| `pnpm verify:e2e` | 后端端到端验收(54 项,需服务端已启动) |
|
||||||
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,7 项) |
|
||||||
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(11 项,含侧边栏点击与离线回退) |
|
||||||
| `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 格式化 / 检查 |
|
||||||
@@ -191,7 +221,6 @@ pnpm verify:desktop # Electron 客户端(含联网集成):9 项
|
|||||||
| `pnpm db:reset` | 重置数据库并重新执行 seed |
|
| `pnpm db:reset` | 重置数据库并重新执行 seed |
|
||||||
| `pnpm db:studio` | 打开 Prisma Studio |
|
| `pnpm db:studio` | 打开 Prisma Studio |
|
||||||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
|
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
|
||||||
| `pnpm verify:e2e` | 端到端验收(需后端已启动) |
|
|
||||||
|
|
||||||
## EXE 客户端(学生端)
|
## EXE 客户端(学生端)
|
||||||
|
|
||||||
@@ -406,14 +435,23 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
|
|
||||||
打包产物验证(对最终 EXE 实测,非仅开发产物):
|
打包产物验证(对最终 EXE 实测,非仅开发产物):
|
||||||
|
|
||||||
| 产物 | 大小 | 冒烟结果 |
|
| 产物 | 大小 | 冒烟结果 |
|
||||||
| ---------------------------------------- | ------- | -------------------------------------------------------------- |
|
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------ |
|
||||||
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 9/9,`packaged: true`,退出码 0 |
|
| 服务端安装程序(内置 Node + Web 端) | 33.9MB | ✅ 静默安装 10s → 自动建库建号 → 服务 34s 就绪 → **对安装实例跑 54/54 e2e + 7/7 UI** |
|
||||||
| `release/…-x64-portable.exe`(单文件) | 106.7MB | ✅ 9/9,含联网集成(登录王小明 / Socket.IO 已连接 / 四类数据) |
|
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 11/11,`packaged: true`,退出码 0 |
|
||||||
| `release/…-x64-setup.exe`(NSIS 安装包) | 106.9MB | ✅ 构建成功(安装后运行同一份 `win-unpacked` 内容) |
|
| `release/…-x64-portable.exe`(单文件) | 106.9MB | ✅ 9/9(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) |
|
||||||
|
| `release/…-x64-setup.exe`(客户端) | 107.2MB | ✅ 构建成功,已嵌入自定义图标 |
|
||||||
|
|
||||||
当前实测:`pnpm verify:e2e` → **54/54**;`pnpm verify:desktop` → **9/9**;
|
当前实测:
|
||||||
`pnpm typecheck`、`pnpm lint`、`pnpm build`、`pnpm dist:win` 全部通过。
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
| ----------------------------------------------------------- | ----------------------------------- |
|
||||||
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **54/54** ✅ |
|
||||||
|
| `pnpm verify:web`(Web 管理端真实点击 + PWA) | **7/7** ✅ |
|
||||||
|
| `pnpm verify:desktop`(客户端冒烟 + 侧边栏点击 + 离线回退) | **11/11** ✅ |
|
||||||
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|
||||||
## 常见问题(本机环境已知坑)
|
## 常见问题(本机环境已知坑)
|
||||||
|
|
||||||
@@ -426,55 +464,66 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
`node packages/web-admin/node_modules/vite/bin/vite.js build`。
|
`node packages/web-admin/node_modules/vite/bin/vite.js build`。
|
||||||
在普通终端(非 Electron 宿主)中 `pnpm <script>` 一切正常。
|
在普通终端(非 Electron 宿主)中 `pnpm <script>` 一切正常。
|
||||||
|
|
||||||
2. **原生模块会按 Electron ABI 构建**
|
2. **不要用 PowerShell 管道调用 `start.cmd`**
|
||||||
|
现象:`& "$install\start.cmd" | Select-Object -First 20` 会一直不返回。
|
||||||
|
原因:脚本内启动的 node 进程在该上下文里继承了管道句柄。
|
||||||
|
规避:双击运行,或用 `Start-Process cmd.exe "/c start.cmd"`(脚本自身 4 秒内退出)。
|
||||||
|
|
||||||
|
3. **原生模块会按 Electron ABI 构建**
|
||||||
同一原因会让 `node-gyp` / `prebuild-install` 以 `runtime=electron` 为目标,
|
同一原因会让 `node-gyp` / `prebuild-install` 以 `runtime=electron` 为目标,
|
||||||
装出的 `.node` 在 Node 进程里无法加载。本项目因此改用 **libSQL 适配器**
|
装出的 `.node` 在 Node 进程里无法加载。本项目因此改用 **libSQL 适配器**
|
||||||
(`@libsql/win32-x64-msvc` 为 npm 预编译包,无需本地编译),彻底规避该问题。
|
(`@libsql/win32-x64-msvc` 为 npm 预编译包,无需本地编译),彻底规避该问题。
|
||||||
|
|
||||||
3. **本机 TLS 中间人证书导致依赖二进制下载失败**
|
4. **本机 TLS 中间人证书导致依赖二进制下载失败**
|
||||||
现象:`prebuild-install warn install unable to verify the first certificate`。
|
现象:`prebuild-install warn install unable to verify the first certificate`。
|
||||||
规避:安装时设置 `NODE_OPTIONS=--use-system-ca`(Node ≥ 22.15 支持),
|
规避:安装时设置 `NODE_OPTIONS=--use-system-ca`(Node ≥ 22.15 支持),
|
||||||
并确保 `npm_config_cache` 指向可写目录(如仓库内 `.cache/npm-cache`)。
|
并确保 `npm_config_cache` 指向可写目录(如仓库内 `.cache/npm-cache`)。
|
||||||
|
|
||||||
4. **受限沙箱下 Vite 构建报 `spawn EPERM`**
|
5. **受限沙箱下 Vite 构建报 `spawn EPERM`**
|
||||||
Vite 在 Windows 上通过 `execFile` 解析真实路径,需要放开子进程管道限制后再构建。
|
Vite 在 Windows 上通过 `execFile` 解析真实路径,需要放开子进程管道限制后再构建。
|
||||||
|
|
||||||
5. **Prisma 7 与旧版本差异**
|
6. **Prisma 7 与旧版本差异**
|
||||||
`prisma` 的 npm `latest` 标签当前指向 `8.0.0-rc`,本项目**显式锁定 7.10.0**;
|
`prisma` 的 npm `latest` 标签当前指向 `8.0.0-rc`,本项目**显式锁定 7.10.0**;
|
||||||
生成器为 `prisma-client`(输出到 `src/generated/prisma`,已 gitignore),
|
生成器为 `prisma-client`(输出到 `src/generated/prisma`,已 gitignore),
|
||||||
连接串在 `prisma.config.ts` 中配置,且必须通过 driver adapter 接入数据库。
|
连接串在 `prisma.config.ts` 中配置,且必须通过 driver adapter 接入数据库。
|
||||||
|
|
||||||
6. **Electron 二进制下载慢或失败**
|
7. **Electron 二进制下载慢或失败**
|
||||||
`electron` 的 postinstall 会从 GitHub 拉取约 151MB 发行包,本机实测 10 分钟仍未完成。
|
`electron` 的 postinstall 会从 GitHub 拉取约 151MB 发行包,本机实测 10 分钟仍未完成。
|
||||||
规避:安装前设置镜像与缓存目录(本项目已把 electron 缓存指向 `.cache/electron`):
|
规避:安装前设置镜像与缓存目录(本项目已把 electron 缓存指向 `.cache/electron`):
|
||||||
`ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/`。
|
`ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/`。
|
||||||
打包阶段 `scripts/dist-win.mjs` 已内置该镜像,并优先复用本地已解压的 Electron。
|
打包阶段 `scripts/dist-win.mjs` 已内置该镜像,并优先复用本地已解压的 Electron。
|
||||||
|
|
||||||
7. **electron-builder 的构建工具(NSIS / winCodeSign)下载慢**
|
8. **electron-builder 的构建工具(NSIS / winCodeSign)下载慢**
|
||||||
通过 `ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/`
|
通过 `ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/`
|
||||||
解决(已内置到 `dist-win.mjs`),实测 nsis-3.0.4.1 与 nsis-resources 正常下载。
|
解决(已内置到 `dist-win.mjs`),实测 nsis-3.0.4.1 与 nsis-resources 正常下载。
|
||||||
|
`scripts/dist-server.mjs` 会直接复用 electron-builder 缓存里的 `makensis`。
|
||||||
|
|
||||||
8. **打包未提供图标**
|
9. **应用图标**
|
||||||
未放置 `packages/desktop-client/build/icon.ico` 时使用 Electron 默认图标。
|
`pnpm icons` 会用 Electron 渲染 SVG 生成 `packages/desktop-client/build/icon.ico`(多尺寸)、
|
||||||
放入 256×256 及以上尺寸的 `.ico`(或 256×256 PNG)即可自动使用。
|
PWA 所需的 PNG 与 `favicon.svg`。electron-builder 与 NSIS 安装程序都会自动使用该图标。
|
||||||
|
|
||||||
## 交付清单(阶段 4-6 新增)
|
10. **服务端安装包内的 `.cmd` 脚本为什么是英文提示?**
|
||||||
|
cmd.exe 按 OEM 代码页(GBK)解析脚本文件,UTF-8 中文会产生乱码甚至语法错误,
|
||||||
|
因此脚本提示统一用 ASCII,中文说明放在安装向导与 `README.txt` 中。
|
||||||
|
|
||||||
| 路径 | 说明 |
|
## 交付清单(阶段 7 · 生产化)
|
||||||
| --------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
||||||
| `packages/desktop-client/src/main/*` | Electron 主进程:窗口、单实例、配置持久化、IPC、冒烟验证 |
|
| 路径 | 说明 |
|
||||||
| `packages/desktop-client/src/preload/index.ts` | contextBridge 安全桥 |
|
| --------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
| `packages/desktop-client/src/renderer/api/*` | Axios 封装(动态服务器地址、JWT、离线不弹错) |
|
| `scripts/dist-server.mjs` | 服务端 + Web 端打包(内置 Node、依赖、迁移、启停脚本、安装程序) |
|
||||||
| `packages/desktop-client/src/renderer/cache/{db,index}.ts` | IndexedDB 缓存层 + `fetchWithCache` 离线回退 |
|
| `scripts/nsis/server-installer.nsi` | NSIS 安装程序模板(安装/快捷方式/开机自启/卸载保留数据) |
|
||||||
| `packages/desktop-client/src/renderer/stores/*` | Pinia:auth / app(可达性与同步)/ realtime / notifications |
|
| `scripts/generate-icons.mjs` + `scripts/icons/` | 图标生成(Electron 渲染 SVG → PNG/ICO) |
|
||||||
| `packages/desktop-client/src/renderer/views/*` | 登录 / 课表 / 作业 / 通知 / 成绩 / 设置 |
|
| `scripts/ui-smoke/` | Web 管理端 UI 真实点击回归测试 |
|
||||||
| `packages/desktop-client/scripts/{build-main,dev,smoke,dist-win}.mjs` | 构建、开发、冒烟、打包脚本 |
|
| `packages/server/src/middleware/security.ts` | helmet + 限流(通用/登录)+ 请求耗时日志 |
|
||||||
| `packages/desktop-client/electron-builder.yml` | 打包配置(nsis + portable) |
|
| `packages/server/src/lib/web-static.ts` | Web 管理端静态托管 + SPA 回退 + 缓存策略 |
|
||||||
|
| `packages/server/src/lib/db-bootstrap.ts` | 首启动自动迁移 + 自动创建管理员 |
|
||||||
|
| `packages/web-admin/public/{manifest,sw.js}` | PWA:可安装为应用 + 离线外壳 |
|
||||||
|
| `deploy/{Dockerfile,docker-compose.yml,nginx.conf}` | 云部署与 HTTPS 反代样例 |
|
||||||
|
| `docs/production.md` | 生产部署指南(三种形态 + 运维 + 安全清单 + 故障排查) |
|
||||||
|
|
||||||
## 后续可选增强
|
## 后续可选增强
|
||||||
|
|
||||||
1. 自定义应用图标与安装向导文案(`build/icon.ico`)。
|
1. 代码签名证书(消除 SmartScreen 提示)。
|
||||||
2. 代码签名证书(消除 SmartScreen 提示)。
|
2. 客户端"提交类操作离线队列"(当前离线为只读,联网后自动同步读取的数据)。
|
||||||
3. 客户端"提交类操作离线队列"(当前离线为只读,联网后自动同步读取的数据)。
|
3. Socket.IO 多实例广播(引入 `@socket.io/redis-adapter`)与 CI 流水线。
|
||||||
4. 云数据库部署清单(Docker Compose + `prisma migrate deploy`)与 CI 流水线。
|
4. 课表按当前时间高亮"正在上的课"与上课提醒(可参考 ClassIsland 的课程提醒设计)。
|
||||||
5. 课表按当前时间高亮"正在上的课"与上课提醒(可参考 ClassIsland 的课程提醒设计)。
|
|
||||||
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 班级小助手 · Docker Compose 环境变量
|
||||||
|
# 复制为 deploy/.env 后填写:cp deploy/.env.example deploy/.env
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 服务端对外端口(宿主机)
|
||||||
|
SERVER_PORT=4000
|
||||||
|
|
||||||
|
# 应用版本(镜像标签)
|
||||||
|
APP_VERSION=0.1.0
|
||||||
|
|
||||||
|
# MySQL 配置
|
||||||
|
MYSQL_ROOT_PASSWORD=请改成强密码_root
|
||||||
|
MYSQL_DATABASE=classhelper
|
||||||
|
MYSQL_USER=classhelper
|
||||||
|
MYSQL_PASSWORD=请改成强密码_app
|
||||||
|
|
||||||
|
# JWT 签名密钥(必填,必须 ≥32 位;生成:
|
||||||
|
# docker run --rm node:22-slim node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
|
# 或本地: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
|
||||||
|
|
||||||
|
# 第 1 教学周的周一(用于"当前周次")
|
||||||
|
TERM_START_DATE=2026-08-31
|
||||||
|
|
||||||
|
# 日志级别:debug | info | warn | error
|
||||||
|
LOG_LEVEL=info
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 班级小助手服务端 · 生产镜像
|
||||||
|
#
|
||||||
|
# 两阶段构建:
|
||||||
|
# build —— 安装依赖并编译 shared / server / web-admin
|
||||||
|
# runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小
|
||||||
|
#
|
||||||
|
# 构建(在仓库根目录执行):
|
||||||
|
# docker build -f deploy/Dockerfile -t classhelper-server:0.1.0 .
|
||||||
|
#
|
||||||
|
# 运行(SQLite + 数据卷):
|
||||||
|
# docker run -d --name classhelper -p 4000:4000 \
|
||||||
|
# -e JWT_SECRET=<48字节随机串> \
|
||||||
|
# -v classhelper-data:/app/data \
|
||||||
|
# classhelper-server:0.1.0
|
||||||
|
#
|
||||||
|
# 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 构建阶段
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 启用 pnpm(用 corepack 保证版本与仓库一致)
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.8.0 --activate
|
||||||
|
|
||||||
|
# 先只拷贝清单以提高缓存命中率
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
COPY packages/server/package.json packages/server/
|
||||||
|
COPY packages/web-admin/package.json packages/web-admin/
|
||||||
|
COPY packages/desktop-client/package.json packages/desktop-client/
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# 再拷贝源码并构建(只构建服务端与 Web 管理端,桌面客户端不参与)
|
||||||
|
COPY packages ./packages
|
||||||
|
COPY scripts ./scripts
|
||||||
|
RUN pnpm build:shared \
|
||||||
|
&& pnpm --filter @classhelper/server build \
|
||||||
|
&& pnpm --filter @classhelper/web-admin build
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 运行时阶段
|
||||||
|
FROM node:22-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
PORT=4000 \
|
||||||
|
LOG_LEVEL=info \
|
||||||
|
TRUST_PROXY=1 \
|
||||||
|
RATE_LIMIT_ENABLED=true \
|
||||||
|
STARTUP_DB_CHECK=true
|
||||||
|
|
||||||
|
# 生产依赖(清单由 scripts/dist-server.mjs 依据 packages/server/package.json 生成)
|
||||||
|
COPY deploy/package.runtime.json ./package.json
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
# 后端产物 + 迁移文件 + Web 管理端 + 工作区共享包
|
||||||
|
COPY --from=build /app/packages/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/packages/server/prisma/migrations ./server/prisma/migrations
|
||||||
|
COPY --from=build /app/packages/web-admin/dist ./web
|
||||||
|
COPY --from=build /app/packages/shared/dist ./node_modules/@classhelper/shared/dist
|
||||||
|
COPY packages/shared/package.json ./node_modules/@classhelper/shared/package.json
|
||||||
|
|
||||||
|
# 数据目录(SQLite 文件 / PID 文件),建议挂载数据卷
|
||||||
|
RUN mkdir -p /app/data /app/logs
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
# 容器健康检查:就绪探针(数据库不可用会返回 503)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:4000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 班级小助手 · 生产部署(MySQL + 服务端 + 可选 HTTPS 反代)
|
||||||
|
#
|
||||||
|
# 使用:
|
||||||
|
# 1) 复制环境变量模板并填写密钥:cp deploy/.env.example deploy/.env
|
||||||
|
# 2) 构建并启动:docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
|
||||||
|
# 3) 访问:http://<服务器IP>:4000/
|
||||||
|
#
|
||||||
|
# 说明:数据库迁移由一次性的 migrate 服务执行(prisma migrate deploy),
|
||||||
|
# 服务端容器因此无需内置 Prisma CLI。
|
||||||
|
|
||||||
|
name: classhelper
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ---------------------------------------------------------------- 数据库
|
||||||
|
db:
|
||||||
|
image: mysql:8.4
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
- --default-time-zone=+08:00
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请在 deploy/.env 中设置}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-classhelper}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-classhelper}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?请在 deploy/.env 中设置}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'mysqladmin', 'ping', '-h', '127.0.0.1', '-p${MYSQL_ROOT_PASSWORD}']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 40s
|
||||||
|
networks: [classhelper]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 数据库迁移(一次性)
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
target: build
|
||||||
|
restart: 'no'
|
||||||
|
working_dir: /app/packages/server
|
||||||
|
environment:
|
||||||
|
DATABASE_PROVIDER: mysql
|
||||||
|
DATABASE_URL: mysql://${MYSQL_USER:-classhelper}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-classhelper}
|
||||||
|
# MySQL 需要 mariadb 驱动适配器(切换数据库时按 docs/mysql.md 安装)
|
||||||
|
NODE_ENV: production
|
||||||
|
command: >
|
||||||
|
sh -c "pnpm --filter @classhelper/server add @prisma/adapter-mariadb &&
|
||||||
|
npx prisma migrate deploy"
|
||||||
|
depends_on:
|
||||||
|
db: { condition: service_healthy }
|
||||||
|
networks: [classhelper]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 服务端 + Web 管理端
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
target: runtime
|
||||||
|
image: classhelper-server:${APP_VERSION:-0.1.0}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 4000
|
||||||
|
DATABASE_PROVIDER: mysql
|
||||||
|
DATABASE_URL: mysql://${MYSQL_USER:-classhelper}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-classhelper}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?请在 deploy/.env 中设置(48 字节随机串)}
|
||||||
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||||
|
TRUST_PROXY: '1'
|
||||||
|
RATE_LIMIT_ENABLED: 'true'
|
||||||
|
STARTUP_DB_CHECK: '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}
|
||||||
|
ports:
|
||||||
|
- '${SERVER_PORT:-4000}:4000'
|
||||||
|
depends_on:
|
||||||
|
db: { condition: service_healthy }
|
||||||
|
migrate: { condition: service_completed_successfully }
|
||||||
|
networks: [classhelper]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
classhelper:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 班级小助手 · Nginx 反向代理样例(HTTPS + WebSocket)
|
||||||
|
#
|
||||||
|
# 作用:把 https://class.example.com 反向代理到本机 4000 端口的服务端,
|
||||||
|
# 同时处理 WebSocket 升级、真实 IP 透传、静态资源缓存与安全响应头。
|
||||||
|
#
|
||||||
|
# 使用:
|
||||||
|
# 1) 把本文件放到 /etc/nginx/conf.d/classhelper.conf(或 sites-available 并做软链)
|
||||||
|
# 2) 修改 server_name 与证书路径
|
||||||
|
# 3) nginx -t && systemctl reload nginx
|
||||||
|
# 4) 服务端 .env 中设置 TRUST_PROXY=1(否则限流按代理 IP 统计)
|
||||||
|
# 以及 CORS_ORIGIN=https://class.example.com
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 上游:本机运行的服务端(Windows 安装包 / Docker / 手动启动均适用)
|
||||||
|
upstream classhelper_backend {
|
||||||
|
server 127.0.0.1:4000;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP -> HTTPS 跳转
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name class.example.com;
|
||||||
|
|
||||||
|
# Let's Encrypt 校验目录(如使用 certbot --webroot)
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name class.example.com;
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 证书
|
||||||
|
ssl_certificate /etc/nginx/certs/class.example.com.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/class.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 基础
|
||||||
|
client_max_body_size 8m;
|
||||||
|
charset utf-8;
|
||||||
|
access_log /var/log/nginx/classhelper.access.log;
|
||||||
|
error_log /var/log/nginx/classhelper.error.log warn;
|
||||||
|
|
||||||
|
# 安全响应头(应用层已用 helmet 设置,这里作为兜底)
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header Referrer-Policy no-referrer always;
|
||||||
|
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# gzip(静态资源与接口响应)
|
||||||
|
gzip on;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript application/manifest+json image/svg+xml;
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- WebSocket(Socket.IO)
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://classhelper_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 静态资源(带哈希,长缓存)
|
||||||
|
location /assets/ {
|
||||||
|
proxy_pass http://classhelper_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 接口与页面
|
||||||
|
location / {
|
||||||
|
proxy_pass http://classhelper_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "classhelper-server-runtime",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "班级小助手服务端运行时(安装包 / 容器内置)",
|
||||||
|
"dependencies": {
|
||||||
|
"@libsql/client": "^0.17.0",
|
||||||
|
"@prisma/adapter-libsql": "7.10.0",
|
||||||
|
"@prisma/client": "7.10.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"compression": "^1.8.2",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.7.0",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"zod": "^4.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# 生产部署指南
|
||||||
|
|
||||||
|
本文档覆盖三种上线形态,按适用场景选择:
|
||||||
|
|
||||||
|
| 形态 | 适用场景 | 产物 | 本机验证情况 |
|
||||||
|
| --------------------- | -------------------------------------------- | ------------------------------------------------------ | -------------------------------------- |
|
||||||
|
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
||||||
|
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
|
||||||
|
| **C. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(54 项端到端) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、Windows 安装包(形态 A)
|
||||||
|
|
||||||
|
### 1.1 生成安装包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dist:server # 首次会执行一次 npm install(约 2 分钟)
|
||||||
|
pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳过 npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
产物:
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
| ----------------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
||||||
|
| `release-server/班级小助手服务端-0.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) |
|
||||||
|
|
||||||
|
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
||||||
|
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
||||||
|
|
||||||
|
### 1.2 安装
|
||||||
|
|
||||||
|
双击安装程序即可,**无需管理员权限**(当前用户级安装):
|
||||||
|
|
||||||
|
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
|
||||||
|
- 静默安装:`"班级小助手服务端-0.1.0-x64-setup.exe" /S`
|
||||||
|
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
||||||
|
- 桌面:`班级小助手服务端.lnk`(打开管理端)
|
||||||
|
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
|
||||||
|
- 卸载信息注册到「应用和功能」
|
||||||
|
|
||||||
|
安装完成后服务会自动启动并完成首次初始化:
|
||||||
|
|
||||||
|
1. 数据库为空 → 自动执行随包迁移(`AUTO_MIGRATE=true`)建表
|
||||||
|
2. 自动创建管理员账号(默认 `admin / admin123`,日志中会提示尽快修改)
|
||||||
|
3. 打开 http://127.0.0.1:4000/ 即为 Web 管理端
|
||||||
|
|
||||||
|
### 1.3 目录与配置
|
||||||
|
|
||||||
|
```
|
||||||
|
%LOCALAPPDATA%\Programs\ClassHelperServer\
|
||||||
|
├── node.exe 内置 Node 运行时
|
||||||
|
├── server\dist\ 后端程序(入口 index.js)
|
||||||
|
├── server\prisma\ 迁移 SQL(首启动自动执行)
|
||||||
|
├── web\ Web 管理端(由后端托管)
|
||||||
|
├── data\ classhelper.db + server.pid(备份只需复制此目录)
|
||||||
|
├── logs\server.log UTF-8 日志(含请求耗时)
|
||||||
|
├── .env 全部配置
|
||||||
|
├── start.cmd / stop.cmd / restart.cmd / status.cmd
|
||||||
|
└── install-service.cmd / uninstall-service.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
start.cmd :: 启动并等待就绪(已在运行则直接返回)
|
||||||
|
stop.cmd :: 按 PID 精确停止
|
||||||
|
restart.cmd :: 重启
|
||||||
|
status.cmd :: 查看状态与 HTTP 探针结果
|
||||||
|
```
|
||||||
|
|
||||||
|
以管理员身份运行 `install-service.cmd` 可注册为**开机启动的计划任务**(等价于系统服务,
|
||||||
|
比注册表 Run 更稳,适合无人值守的机房主机):
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
install-service.cmd :: schtasks /Create /SC ONSTART /RL HIGHEST
|
||||||
|
uninstall-service.cmd :: 删除计划任务
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 升级
|
||||||
|
|
||||||
|
1. `stop.cmd` 停止服务
|
||||||
|
2. 备份 `data` 目录
|
||||||
|
3. 运行新版本安装程序(**覆盖安装会保留原有 `.env`,JWT 密钥与端口设置不变,用户无需重新登录**)
|
||||||
|
4. `start.cmd` 启动(数据库结构如变更,会在首启动时自动补迁移)
|
||||||
|
|
||||||
|
### 1.5 卸载
|
||||||
|
|
||||||
|
开始菜单 → 卸载(或 `uninstall.exe`,静默:`uninstall.exe /S`)。
|
||||||
|
|
||||||
|
卸载会停止服务、删除快捷方式与注册表项、删除程序文件,并把 `data` 目录重命名为
|
||||||
|
**`data.backup` 保留**,避免误删数据库;确认不需要后再手动删除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、Docker + MySQL(形态 B,云部署推荐)
|
||||||
|
|
||||||
|
> 本机没有 Docker,因此这套文件未在本机实测;结构经过与已验证的产物对照(依赖清单、
|
||||||
|
> 目录布局、迁移方式与服务端完全一致)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy/.env.example deploy/.env
|
||||||
|
# 生成 JWT 密钥并填入 deploy/.env
|
||||||
|
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
|
|
||||||
|
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
组成:
|
||||||
|
|
||||||
|
| 服务 | 作用 |
|
||||||
|
| --------- | -------------------------------------------------------------------- |
|
||||||
|
| `db` | MySQL 8.4(utf8mb4、数据卷 `db-data`) |
|
||||||
|
| `migrate` | 一次性任务:安装 mariadb 适配器并执行 `prisma migrate deploy` |
|
||||||
|
| `server` | 后端 + Web 管理端(多阶段构建,仅含生产依赖),暴露 `${SERVER_PORT}` |
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- 服务端容器 `NODE_ENV=production` + `STARTUP_DB_CHECK=true`:**数据库不可用会拒绝启动**
|
||||||
|
(避免带病对外服务);健康检查打 `/readyz`。
|
||||||
|
- 数据卷:MySQL 用 `db-data`;若坚持用 SQLite,把 `/app/data` 挂成卷即可(见 Dockerfile 注释)。
|
||||||
|
- 首次启动会按 `INITIAL_ADMIN_USERNAME/PASSWORD` 创建管理员(请在部署后立即改密码)。
|
||||||
|
- 反向代理上启用 HTTPS:见 `deploy/nginx.conf`,并在服务端环境变量中设置
|
||||||
|
`TRUST_PROXY=1`、`CORS_ORIGIN=https://你的域名`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、手动部署(形态 C)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build:shared
|
||||||
|
pnpm --filter @classhelper/server build
|
||||||
|
pnpm --filter @classhelper/web-admin build
|
||||||
|
pnpm dist:server -- --reuse-deps # 得到 release-server/classhelper-server 免安装目录
|
||||||
|
```
|
||||||
|
|
||||||
|
把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
start.cmd :: Windows
|
||||||
|
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node)
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库初始化二选一:
|
||||||
|
|
||||||
|
- 让服务自愈:`.env` 中 `AUTO_MIGRATE=true`(SQLite 首次启动自动建表 + 建管理员)
|
||||||
|
- 显式迁移:`pnpm db:migrate` / `npx prisma migrate deploy`(MySQL 必须走这条)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、环境变量清单
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
| --------------------------------------------------- | ---------------------- | --------------------------------------------------------- |
|
||||||
|
| `NODE_ENV` | `development` | `production` 会启用生产校验、HSTS、更严的限流 |
|
||||||
|
| `HOST` / `PORT` | `0.0.0.0` / `4000` | 监听地址与端口 |
|
||||||
|
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
|
||||||
|
| `DATABASE_URL` | `file:./prisma/dev.db` | SQLite 相对路径以 `server` 根为基准;MySQL 用连接串 |
|
||||||
|
| `AUTO_MIGRATE` | `false` | SQLite 空库时自动执行随包迁移并创建管理员 |
|
||||||
|
| `JWT_SECRET` | — | **必填**,≥32 位;生产环境仍为示例值会拒绝启动 |
|
||||||
|
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
|
||||||
|
| `CORS_ORIGIN` | `*` | 允许来源,多个用逗号分隔;生产建议写具体域名 |
|
||||||
|
| `TRUST_PROXY` | `false` | 反向代理层数(Nginx 填 `1`),影响限流与日志中的真实 IP |
|
||||||
|
| `RATE_LIMIT_ENABLED` | `true` | 通用限流(生产 600 次/5 分钟)+ 登录限流(20 次/10 分钟) |
|
||||||
|
| `STARTUP_DB_CHECK` | `true` | 启动校验数据库;生产不可用时直接退出 |
|
||||||
|
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
|
||||||
|
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
|
||||||
|
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
|
||||||
|
| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增学生的默认密码 |
|
||||||
|
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
|
||||||
|
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、运维
|
||||||
|
|
||||||
|
### 5.1 探针与监控
|
||||||
|
|
||||||
|
| 端点 | 用途 |
|
||||||
|
| ------------- | ---------------------------------------------------- |
|
||||||
|
| `/healthz` | 存活探针:进程活着即 200(容器 liveness / 守护进程) |
|
||||||
|
| `/readyz` | 就绪探针:数据库可连才 200,否则 503(负载均衡摘流) |
|
||||||
|
| `/api/health` | 详情:环境、数据库、已挂载模块、运行时长 |
|
||||||
|
|
||||||
|
### 5.2 日志
|
||||||
|
|
||||||
|
- 输出到 stdout/stderr,安装包形态由启停脚本重定向到 `logs\server.log`(UTF-8)。
|
||||||
|
- 访问日志含方法、路径、状态码与耗时;5xx 记 error、4xx 记 warn。
|
||||||
|
- 容器形态建议交给 Docker 日志驱动或采集到 ELK/Loki。
|
||||||
|
|
||||||
|
### 5.3 备份与恢复
|
||||||
|
|
||||||
|
| 形态 | 备份 | 恢复 |
|
||||||
|
| ------ | ------------------------------------------------------ | ------------------------ |
|
||||||
|
| SQLite | 停止服务后复制 `data/classhelper.db` | 替换该文件后启动 |
|
||||||
|
| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` |
|
||||||
|
|
||||||
|
建议:每日自动备份 + 保留 7 份;变更 `JWT_SECRET` 会导致所有用户重新登录,请勿在无必要时更换。
|
||||||
|
|
||||||
|
### 5.4 安全清单(上线前逐项确认)
|
||||||
|
|
||||||
|
- [ ] 修改 `admin` 与教师账号的默认密码
|
||||||
|
- [ ] `JWT_SECRET` 使用随机值(≥48 字节),且妥善保管
|
||||||
|
- [ ] 公网访问必须经 HTTPS 反向代理(`deploy/nginx.conf` 可直接用)
|
||||||
|
- [ ] `CORS_ORIGIN` 收敛到具体域名,不要留 `*`
|
||||||
|
- [ ] `TRUST_PROXY=1` 并确认限流按真实 IP 生效(`/api` 响应头有 `RateLimit`)
|
||||||
|
- [ ] 数据库账号使用最小权限,仅授权业务库
|
||||||
|
- [ ] 定期备份并验证可恢复
|
||||||
|
- [ ] 关注 `/readyz` 与 `logs/server.log` 的 5xx
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、故障排查
|
||||||
|
|
||||||
|
| 现象 | 排查方向 |
|
||||||
|
| ------------------ | ----------------------------------------------------------------------------- |
|
||||||
|
| 浏览器打不开管理端 | `status.cmd`;`logs\server.log` 是否有启动报错;端口是否被占用 |
|
||||||
|
| 启动即退出(生产) | 数据库不可用(`DATABASE_URL`/MySQL 未就绪)或 `JWT_SECRET` 未按生产要求配置 |
|
||||||
|
| 页面能开但接口 401 | 令牌过期或更换过 `JWT_SECRET`,重新登录 |
|
||||||
|
| 限流误伤(自己人) | 反向代理未设 `TRUST_PROXY=1`,导致所有请求都算作代理 IP |
|
||||||
|
| 实时推送不生效 | 反向代理未放行 `/socket.io/` 的 WebSocket 升级(见 nginx 样例) |
|
||||||
|
| 安装后服务没起来 | 查看 `logs\server.error.log`;`start.cmd` 会输出就绪等待结果 |
|
||||||
|
| 数据库文件损坏 | 从备份恢复 `data/classhelper.db`;SQLite 场景请确保同一时间只有一个服务实例 |
|
||||||
|
| 升级后需要重新登录 | 说明 `.env` 被覆盖(JWT 密钥变化);覆盖安装应保留 `.env`,请检查安装目录权限 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、性能与容量参考
|
||||||
|
|
||||||
|
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 54 项接口与实时推送全部达标。
|
||||||
|
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
|
||||||
|
当前为单实例广播;如需多实例请参考 `packages/server/src/realtime/socket.ts` 引入 `@socket.io/redis-adapter`)。
|
||||||
|
- 静态资源已设置 immutable 长缓存 + gzip;Web 管理端为 SPA,首屏包体约 900 KB(gzip 约 290 KB),
|
||||||
|
成绩页含 ECharts 独立分包,按需加载。
|
||||||
@@ -25,6 +25,8 @@ export default tseslint.config(
|
|||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
'**/dist-electron/**',
|
'**/dist-electron/**',
|
||||||
'**/release/**',
|
'**/release/**',
|
||||||
|
'**/release-server/**',
|
||||||
|
'**/build/**',
|
||||||
'**/coverage/**',
|
'**/coverage/**',
|
||||||
'**/src/generated/**',
|
'**/src/generated/**',
|
||||||
'**/.cache/**',
|
'**/.cache/**',
|
||||||
@@ -35,6 +37,19 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
...pluginVue.configs['flat/recommended'],
|
...pluginVue.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
// Electron 主进程脚本(图标渲染、UI 冒烟)必须是 CommonJS,允许 require
|
||||||
|
files: ['**/*.cjs'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
globals: sharedGlobals,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
|
'@typescript-eslint/no-var-requires': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.vue'],
|
files: ['**/*.vue'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
@@ -29,8 +29,12 @@
|
|||||||
"db:switch:sqlite": "node scripts/use-database.mjs sqlite",
|
"db:switch:sqlite": "node scripts/use-database.mjs sqlite",
|
||||||
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
|
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
|
||||||
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
|
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
|
||||||
|
"verify:web": "node scripts/ui-smoke/run.mjs",
|
||||||
|
"icons": "node scripts/generate-icons.mjs",
|
||||||
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
|
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
|
||||||
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir"
|
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
|
||||||
|
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
|
||||||
|
"dist:all": "pnpm dist:server && pnpm dist:win"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 264 KiB |
@@ -23,10 +23,31 @@ if (missing.length > 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[smoke] 启动 Electron 冒烟验证...\n');
|
console.log('[smoke] 启动 Electron 冒烟验证...\n');
|
||||||
|
|
||||||
|
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
|
||||||
|
let online = process.env.ELECTRON_SMOKE_ONLINE;
|
||||||
|
if (online === undefined) {
|
||||||
|
online = '0';
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 2500);
|
||||||
|
const response = await fetch('http://127.0.0.1:4000/api/health', { signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
online = response.ok ? '1' : '0';
|
||||||
|
} catch {
|
||||||
|
online = '0';
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
online === '1'
|
||||||
|
? '[smoke] 检测到后端在线,将执行联网集成与侧边栏点击测试'
|
||||||
|
: '[smoke] 后端未在线,仅执行本地检查(离线缓存/界面渲染);如需联网测试请先启动 pnpm dev:server',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const child = spawn(electronPath, ['.'], {
|
const child = spawn(electronPath, ['.'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, ELECTRON_SMOKE_TEST: '1' },
|
env: { ...process.env, ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online },
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
appChildren: number;
|
appChildren: number;
|
||||||
hasLoginPage: boolean;
|
hasLoginPage: boolean;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
|
hasLayout: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
for (let attempt = 0; attempt < 56; attempt += 1) {
|
|
||||||
dom = await win.webContents.executeJavaScript(
|
const snapshot = async (): Promise<typeof dom> =>
|
||||||
|
win.webContents.executeJavaScript(
|
||||||
`(() => {
|
`(() => {
|
||||||
const app = document.querySelector('#app');
|
const app = document.querySelector('#app');
|
||||||
return {
|
return {
|
||||||
@@ -42,15 +44,37 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
appChildren: app ? app.children.length : 0,
|
appChildren: app ? app.children.length : 0,
|
||||||
hasLoginPage: Boolean(document.querySelector('.login-page')),
|
hasLoginPage: Boolean(document.querySelector('.login-page')),
|
||||||
hasInput: Boolean(document.querySelector('input')),
|
hasInput: Boolean(document.querySelector('input')),
|
||||||
|
hasLayout: document.querySelectorAll('.el-menu-item').length > 0,
|
||||||
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 80),
|
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 80),
|
||||||
};
|
};
|
||||||
})()`,
|
})()`,
|
||||||
);
|
);
|
||||||
// 等到登录页真正渲染出来(而不是停在启动过渡页)
|
|
||||||
if (dom?.hasLoginPage && dom?.hasInput) break;
|
for (let attempt = 0; attempt < 56; attempt += 1) {
|
||||||
|
dom = await snapshot();
|
||||||
|
// 等到登录页或主布局真正渲染出来(而不是停在启动过渡页)
|
||||||
|
if ((dom?.hasLoginPage && dom?.hasInput) || dom?.hasLayout) break;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上次运行可能残留登录态(客户端会直接进主界面)——先退出登录,再校验登录页
|
||||||
|
if (dom?.hasLayout && !dom?.hasLoginPage) {
|
||||||
|
console.log('[SMOKE] 检测到已保存的登录态,先退出登录再校验登录页');
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`(async () => {
|
||||||
|
if (!window.__classhelperSmoke__) return false;
|
||||||
|
await window.__classhelperSmoke__.sessionCleanup();
|
||||||
|
location.hash = '#/login';
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||||
|
dom = await snapshot();
|
||||||
|
if (dom?.hasLoginPage && dom?.hasInput) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
|
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
|
||||||
record(
|
record(
|
||||||
'窗口标题正确',
|
'窗口标题正确',
|
||||||
@@ -120,10 +144,27 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
})()`,
|
})()`,
|
||||||
);
|
);
|
||||||
record(
|
record(
|
||||||
'联网集成(登录 + 四类数据 + Socket.IO + 缓存写入)',
|
'联网集成(登录 + 布局挂载 + 四类数据 + Socket.IO + 缓存写入)',
|
||||||
Boolean(online?.ok),
|
Boolean(online?.ok),
|
||||||
String(online?.detail ?? ''),
|
String(online?.detail ?? ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
|
||||||
|
const navigation = await win.webContents.executeJavaScript(
|
||||||
|
`(async () => {
|
||||||
|
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||||
|
return await window.__classhelperSmoke__.layoutNavigationSelfTest();
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
|
||||||
|
|
||||||
|
const cleanup = await win.webContents.executeJavaScript(
|
||||||
|
`(async () => {
|
||||||
|
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||||
|
return await window.__classhelperSmoke__.sessionCleanup();
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
const failed = results.filter((item) => !item.ok);
|
const failed = results.filter((item) => !item.ok);
|
||||||
|
|||||||
@@ -58,8 +58,73 @@ export async function offlineScenario(): Promise<SmokeCheckResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 轮询等待条件成立(用于验证点击后界面确实发生跳转) */
|
||||||
|
async function waitUntil(predicate: () => boolean, timeoutMs = 3000, intervalMs = 50): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (predicate()) return true;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
return predicate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配) */
|
||||||
|
const EXPECTED_MENU: Array<{ label: string; path: string }> = [
|
||||||
|
{ label: '课表', path: '/schedule' },
|
||||||
|
{ label: '作业', path: '/homeworks' },
|
||||||
|
{ label: '通知', path: '/notifications' },
|
||||||
|
{ label: '成绩', path: '/grades' },
|
||||||
|
{ label: '设置', path: '/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 冒烟自检 3(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
* 冒烟自检 3:侧边栏点击导航。
|
||||||
|
* 直接对真实 DOM 触发 click,验证每次点击后路由与视图都发生切换 ——
|
||||||
|
* 这是"点击左侧边栏没反应"这类问题的回归测试。
|
||||||
|
*/
|
||||||
|
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||||
|
const { router } = await import('../router/index.js');
|
||||||
|
|
||||||
|
const findMenuItem = (label: string): HTMLElement | undefined =>
|
||||||
|
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
||||||
|
(element.textContent ?? '').trim().startsWith(label),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!findMenuItem('课表')) {
|
||||||
|
return { ok: false, detail: '侧边栏未渲染(当前不在主布局或未登录)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited: string[] = [];
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
for (const { label, path } of EXPECTED_MENU) {
|
||||||
|
const element = findMenuItem(label);
|
||||||
|
if (!element) {
|
||||||
|
failures.push(`${label}(菜单项缺失)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.click();
|
||||||
|
const navigated = await waitUntil(() => router.currentRoute.value.path === path);
|
||||||
|
const actualPath = router.currentRoute.value.path;
|
||||||
|
|
||||||
|
// 视图内容也要跟着变(标题取自各页 .page-title)
|
||||||
|
const rendered = await waitUntil(() => Boolean(document.querySelector('.page-title')));
|
||||||
|
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
||||||
|
|
||||||
|
visited.push(`${label}→${actualPath}${title ? `(${title})` : ''}`);
|
||||||
|
if (!navigated) failures.push(`${label}(期望 ${path} 实际 ${actualPath})`);
|
||||||
|
else if (!rendered) failures.push(`${label}(视图未渲染)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: failures.length === 0,
|
||||||
|
detail: `visited=[${visited.join(' ')}]${failures.length > 0 ? ` failures=${failures.join(';')}` : ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
||||||
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
|
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
|
||||||
*/
|
*/
|
||||||
export async function onlineScenario(): Promise<SmokeCheckResult> {
|
export async function onlineScenario(): Promise<SmokeCheckResult> {
|
||||||
@@ -82,6 +147,12 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
|||||||
await auth.login(serverUrl, 'student01', 'student123');
|
await auth.login(serverUrl, 'student01', 'student123');
|
||||||
detail.push(`login=${auth.user?.name ?? '-'}`);
|
detail.push(`login=${auth.user?.name ?? '-'}`);
|
||||||
|
|
||||||
|
// 进入主布局(后续的侧边栏点击测试依赖布局已挂载)
|
||||||
|
const { router } = await import('../router/index.js');
|
||||||
|
await router.push('/schedule');
|
||||||
|
const layoutMounted = await waitUntil(() => Boolean(document.querySelector('.el-menu-item')), 5000);
|
||||||
|
detail.push(`layout=${layoutMounted}`);
|
||||||
|
|
||||||
await appStore.init(serverUrl);
|
await appStore.init(serverUrl);
|
||||||
detail.push(`reachable=${appStore.serverReachable}`);
|
detail.push(`reachable=${appStore.serverReachable}`);
|
||||||
|
|
||||||
@@ -131,14 +202,12 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
|||||||
homeworks.length > 0 &&
|
homeworks.length > 0 &&
|
||||||
notifications.length > 0 &&
|
notifications.length > 0 &&
|
||||||
grades.length > 0 &&
|
grades.length > 0 &&
|
||||||
realtime.connected;
|
realtime.connected &&
|
||||||
|
layoutMounted;
|
||||||
|
|
||||||
realtime.disconnect();
|
// 会话保留给后续的侧边栏点击测试,由 sessionCleanup() 统一清理
|
||||||
await auth.logout(); // 清理登录态,保证下次冒烟从登录页开始
|
|
||||||
return { ok, detail: detail.join(' ') };
|
return { ok, detail: detail.join(' ') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
realtime.disconnect();
|
|
||||||
await auth.logout().catch(() => undefined);
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
detail: `${detail.join(' ')} error=${error instanceof Error ? error.message : String(error)}`,
|
detail: `${detail.join(' ')} error=${error instanceof Error ? error.message : String(error)}`,
|
||||||
@@ -146,7 +215,28 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
|
||||||
|
export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||||
|
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
||||||
|
import('../stores/auth.js'),
|
||||||
|
import('../stores/realtime.js'),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
useRealtimeStore().disconnect();
|
||||||
|
await useAuthStore().logout();
|
||||||
|
return { ok: true, detail: '已断开实时通道并清理登录态' };
|
||||||
|
} 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__ = { cacheSelfTest, offlineScenario, onlineScenario };
|
window.__classhelperSmoke__ = {
|
||||||
|
cacheSelfTest,
|
||||||
|
offlineScenario,
|
||||||
|
layoutNavigationSelfTest,
|
||||||
|
onlineScenario,
|
||||||
|
sessionCleanup,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -16,14 +16,39 @@ const realtime = useRealtimeStore();
|
|||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: 'schedule', title: '课表', icon: 'Calendar' },
|
{ path: '/schedule', title: '课表', icon: 'Calendar' },
|
||||||
{ name: 'homeworks', title: '作业', icon: 'Notebook' },
|
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
|
||||||
{ name: 'notifications', title: '通知', icon: 'Bell' },
|
{ path: '/notifications', title: '通知', icon: 'Bell' },
|
||||||
{ name: 'grades', title: '成绩', icon: 'Trophy' },
|
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
||||||
{ name: 'settings', title: '设置', icon: 'Setting' },
|
{ path: '/settings', title: '设置', icon: 'Setting' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeMenu = computed(() => `/${String(route.name ?? 'schedule')}`);
|
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||||
|
const activeMenu = computed(() => route.path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单点击回调。
|
||||||
|
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
|
||||||
|
* 不能当作路由名传给 router.push({ name }),否则会静默失败(点击无反应)。
|
||||||
|
*/
|
||||||
|
function handleMenuSelect(index: string): void {
|
||||||
|
void router.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按路由名跳转(下拉菜单、按钮等程序化调用) */
|
||||||
|
function go(name: string): void {
|
||||||
|
void router.push({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开发期自检:菜单路径必须存在于路由表中 */
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
||||||
|
for (const item of menuItems) {
|
||||||
|
if (!knownPaths.has(item.path)) {
|
||||||
|
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const connectionType = computed(() => {
|
const connectionType = computed(() => {
|
||||||
if (realtime.connected) return 'success';
|
if (realtime.connected) return 'success';
|
||||||
@@ -37,10 +62,6 @@ const connectionText = computed(() => {
|
|||||||
return '离线模式 · 显示缓存数据';
|
return '离线模式 · 显示缓存数据';
|
||||||
});
|
});
|
||||||
|
|
||||||
function go(name: string): void {
|
|
||||||
void router.push({ name });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' });
|
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' });
|
||||||
realtime.disconnect();
|
realtime.disconnect();
|
||||||
@@ -81,12 +102,12 @@ onUnmounted(() => {
|
|||||||
<el-icon :size="20"><School /></el-icon>
|
<el-icon :size="20"><School /></el-icon>
|
||||||
<span>班级小助手</span>
|
<span>班级小助手</span>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="activeMenu" class="menu" @select="go">
|
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.name" :index="`/${item.name}`">
|
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
<el-badge
|
<el-badge
|
||||||
v-if="item.name === 'notifications' && notifications.unreadCount > 0"
|
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
||||||
:value="notifications.unreadCount"
|
:value="notifications.unreadCount"
|
||||||
class="menu-badge"
|
class="menu-badge"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ declare global {
|
|||||||
__classhelperSmoke__?: {
|
__classhelperSmoke__?: {
|
||||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,3 +37,25 @@ TERM_START_DATE=2026-02-23
|
|||||||
|
|
||||||
# 日志级别:debug | info | warn | error
|
# 日志级别:debug | info | warn | error
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 生产部署相关(本地开发保持默认即可)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 监听地址:本地开发用 127.0.0.1;服务器/容器用 0.0.0.0
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# 反向代理层数:使用 Nginx / 云负载均衡时填 1,用于正确识别客户端 IP(限流依赖)
|
||||||
|
TRUST_PROXY=false
|
||||||
|
|
||||||
|
# Web 管理端构建产物目录;留空则自动探测 packages/web-admin/dist 或 ./web
|
||||||
|
WEB_DIST_DIR=
|
||||||
|
|
||||||
|
# 接口限流开关(压测时可临时置 false)
|
||||||
|
RATE_LIMIT_ENABLED=true
|
||||||
|
|
||||||
|
# 启动时校验数据库连通性(生产建议 true:连不上直接启动失败,避免带病对外服务)
|
||||||
|
STARTUP_DB_CHECK=true
|
||||||
|
|
||||||
|
# 数据库为空时自动执行迁移(SQLite 场景;安装程序首次启动用)
|
||||||
|
AUTO_MIGRATE=false
|
||||||
@@ -26,14 +26,18 @@
|
|||||||
"@prisma/adapter-libsql": "7.10.0",
|
"@prisma/adapter-libsql": "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",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.7.0",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"zod": "^4.6.2"
|
"zod": "^4.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/compression": "^1.8.1",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|||||||
@@ -1,36 +1,93 @@
|
|||||||
import express, { type Express, type Request, type Response } from 'express';
|
import express, { type Express, type Request, type Response } from 'express';
|
||||||
|
import compression from 'compression';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { API_PREFIX } from '@classhelper/shared';
|
import { API_PREFIX } from '@classhelper/shared';
|
||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { pingDatabase } from './lib/db.js';
|
import { pingDatabase } from './lib/db.js';
|
||||||
import { sendOk } from './lib/http.js';
|
import { sendOk } from './lib/http.js';
|
||||||
import { logger } from './lib/logger.js';
|
import { logger } from './lib/logger.js';
|
||||||
|
import { mountWebAdmin, resolveWebDistDir } from './lib/web-static.js';
|
||||||
import { errorHandler, notFoundHandler } from './middleware/error.js';
|
import { errorHandler, notFoundHandler } from './middleware/error.js';
|
||||||
|
import { apiRateLimiter, loginRateLimiter, requestLogger, securityHeaders } from './middleware/security.js';
|
||||||
import { apiModules } from './modules/registry.js';
|
import { apiModules } from './modules/registry.js';
|
||||||
|
|
||||||
|
/** 存活探针:进程活着即 200(供容器 livenessProbe / 守护进程使用) */
|
||||||
|
function livenessHandler(_req: Request, res: Response): void {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: { status: 'alive', uptimeSeconds: Math.round(process.uptime()) },
|
||||||
|
message: 'ok',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 就绪探针:数据库可连才 200,否则 503(供负载均衡摘流 / 安装程序自检) */
|
||||||
|
async function readinessHandler(_req: Request, res: Response): Promise<void> {
|
||||||
|
const connected = await pingDatabase();
|
||||||
|
if (!connected) {
|
||||||
|
res
|
||||||
|
.status(503)
|
||||||
|
.json({ success: false, data: { database: false }, message: '数据库不可用', code: 'NOT_READY' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: { database: true }, message: 'ready' });
|
||||||
|
}
|
||||||
|
|
||||||
async function healthHandler(_req: Request, res: Response): Promise<void> {
|
async function healthHandler(_req: Request, res: Response): Promise<void> {
|
||||||
sendOk(
|
sendOk(
|
||||||
res,
|
res,
|
||||||
{
|
{
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
env: env.nodeEnv,
|
||||||
uptimeSeconds: Math.round(process.uptime()),
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
database: {
|
database: {
|
||||||
provider: env.databaseProvider,
|
provider: env.databaseProvider,
|
||||||
connected: await pingDatabase(),
|
connected: await pingDatabase(),
|
||||||
},
|
},
|
||||||
modules: apiModules.map((module) => module.name),
|
modules: apiModules.map((module) => module.name),
|
||||||
|
webAdmin: env.webDistDir ? 'configured' : 'auto',
|
||||||
},
|
},
|
||||||
'服务运行正常',
|
'服务运行正常',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根路径落地页。
|
* 根路径。
|
||||||
* API 全部挂在 /api 下,根路径本身不是接口;这里给出一个可读的服务首页,
|
* - 已托管 Web 管理端且客户端接受 HTML:交给静态资源与 SPA 回退处理(即打开管理端首页)
|
||||||
* 避免浏览器直接访问 4000 端口时看到 404 JSON 而误以为服务没起来。
|
* - 否则返回服务信息页(浏览器)或 JSON(API 客户端)
|
||||||
*/
|
*/
|
||||||
function renderLandingPage(): string {
|
function createRootHandler(webDistDir: string | null) {
|
||||||
|
return (req: Request, res: Response, next: express.NextFunction): void => {
|
||||||
|
if (webDistDir && req.accepts(['html', 'json']) === 'html') {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.accepts(['html', 'json']) === 'json') {
|
||||||
|
sendOk(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
service: 'class-helper-server',
|
||||||
|
version: '0.1.0',
|
||||||
|
environment: env.nodeEnv,
|
||||||
|
apiPrefix: API_PREFIX,
|
||||||
|
socket: '/socket.io',
|
||||||
|
webAdmin: webDistDir ? '/' : 'http://127.0.0.1:5173',
|
||||||
|
health: `${API_PREFIX}/health`,
|
||||||
|
liveness: '/healthz',
|
||||||
|
readiness: '/readyz',
|
||||||
|
modules: apiModules.map((module) => `${API_PREFIX}${module.basePath}`),
|
||||||
|
},
|
||||||
|
'服务运行正常,API 前缀为 /api',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.type('html').send(renderLandingPage(Boolean(webDistDir)));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLandingPage(webAdminServed: boolean): string {
|
||||||
const moduleRows = apiModules
|
const moduleRows = apiModules
|
||||||
.map((module) => `<li><code>${API_PREFIX}${module.basePath}</code> <span>${module.name}</span></li>`)
|
.map((module) => `<li><code>${API_PREFIX}${module.basePath}</code> <span>${module.name}</span></li>`)
|
||||||
.join('');
|
.join('');
|
||||||
@@ -42,9 +99,9 @@ function renderLandingPage(): string {
|
|||||||
<title>班级小助手 · 后端服务</title>
|
<title>班级小助手 · 后端服务</title>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; }
|
body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; }
|
||||||
.card { max-width: 720px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
|
.card { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
|
||||||
h1 { margin: 0 0 6px; font-size: 20px; }
|
h1 { margin: 0 0 6px; font-size: 20px; }
|
||||||
p.sub { margin: 0 0 20px; color: #909399; font-size: 13px; }
|
p.sub { margin: 0 0 8px; color: #909399; font-size: 13px; }
|
||||||
.ok { display: inline-block; padding: 2px 10px; border-radius: 999px; background: #f0f9eb; color: #67c23a; font-size: 12px; }
|
.ok { display: inline-block; padding: 2px 10px; border-radius: 999px; background: #f0f9eb; color: #67c23a; font-size: 12px; }
|
||||||
ul { columns: 2; padding-left: 18px; margin: 12px 0 0; }
|
ul { columns: 2; padding-left: 18px; margin: 12px 0 0; }
|
||||||
li { margin: 6px 0; font-size: 13px; }
|
li { margin: 6px 0; font-size: 13px; }
|
||||||
@@ -52,6 +109,7 @@ function renderLandingPage(): string {
|
|||||||
code { background: #f5f7fa; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
|
code { background: #f5f7fa; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
|
||||||
a { color: #409eff; text-decoration: none; }
|
a { color: #409eff; text-decoration: none; }
|
||||||
.hint { margin-top: 22px; padding-top: 16px; border-top: 1px dashed #e4e7ed; font-size: 13px; color: #606266; line-height: 1.9; }
|
.hint { margin-top: 22px; padding-top: 16px; border-top: 1px dashed #e4e7ed; font-size: 13px; color: #606266; line-height: 1.9; }
|
||||||
|
.btn { display: inline-block; margin-top: 4px; padding: 8px 16px; background: #409eff; color: #fff !important; border-radius: 6px; font-size: 13px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -59,63 +117,63 @@ function renderLandingPage(): string {
|
|||||||
<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">版本 0.1.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> ·
|
||||||
管理端开发地址:<a href="http://127.0.0.1:5173">http://127.0.0.1:5173</a>
|
存活 /healthz · 就绪 /readyz
|
||||||
</p>
|
</p>
|
||||||
|
${webAdminServed ? '<a class="btn" href="/">打开 Web 管理端</a>' : '<p class="sub">Web 管理端未随本服务托管,请单独启动(pnpm dev:web → http://127.0.0.1:5173)。</p>'}
|
||||||
<strong>已挂载模块(${apiModules.length} 个)</strong>
|
<strong>已挂载模块(${apiModules.length} 个)</strong>
|
||||||
<ul>${moduleRows}</ul>
|
<ul>${moduleRows}</ul>
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
提示:本服务只提供 REST API 与 WebSocket,没有网页界面。<br />
|
WebSocket 端点:<code>ws://${env.host === '0.0.0.0' ? '本机地址' : env.host}:${env.port}/socket.io</code><br />
|
||||||
网页界面请访问 Web 管理端 <code>http://127.0.0.1:5173</code>(pnpm dev:web),
|
本服务只提供 REST API 与 WebSocket;学生端请使用「班级小助手」桌面客户端。
|
||||||
WebSocket 端点为 <code>ws://127.0.0.1:${env.port}/socket.io</code>。
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组装 Express 应用:中间件 -> 健康检查 -> 模块路由 -> 404 -> 统一错误处理 */
|
/** 组装 Express 应用:安全中间件 -> 健康检查 -> 模块路由 -> Web 静态托管 -> 404 -> 统一错误处理 */
|
||||||
export function createApp(): Express {
|
export function createApp(): Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
|
||||||
|
// 反向代理场景下正确识别客户端 IP(限流、日志依赖)
|
||||||
|
app.set('trust proxy', env.trustProxy);
|
||||||
|
|
||||||
|
const webDistDir = resolveWebDistDir();
|
||||||
|
|
||||||
|
app.use(securityHeaders());
|
||||||
|
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.json({ limit: '2mb' }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(requestLogger());
|
||||||
|
|
||||||
app.use((req, _res, next) => {
|
// 探针(不参与限流)
|
||||||
logger.debug(`${req.method} ${req.originalUrl}`);
|
app.get('/healthz', livenessHandler);
|
||||||
next();
|
app.get('/readyz', (req, res) => {
|
||||||
|
void readinessHandler(req, res);
|
||||||
|
});
|
||||||
|
app.get(`${API_PREFIX}/health`, (req, res) => {
|
||||||
|
void healthHandler(req, res);
|
||||||
|
});
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
void healthHandler(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 服务首页(浏览器可读);如果客户端显式要 JSON,则返回结构化信息
|
// 根路径信息页(Web 管理端托管时让位给前端首页)
|
||||||
app.get('/', (req, res) => {
|
app.get('/', createRootHandler(webDistDir));
|
||||||
if (req.accepts(['html', 'json']) === 'json') {
|
|
||||||
sendOk(
|
|
||||||
res,
|
|
||||||
{
|
|
||||||
service: 'class-helper-server',
|
|
||||||
version: '0.1.0',
|
|
||||||
apiPrefix: API_PREFIX,
|
|
||||||
webAdmin: 'http://127.0.0.1:5173',
|
|
||||||
socket: `/socket.io`,
|
|
||||||
modules: apiModules.map((module) => `${API_PREFIX}${module.basePath}`),
|
|
||||||
},
|
|
||||||
'服务运行正常,API 前缀为 /api',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.type('html').send(renderLandingPage());
|
|
||||||
});
|
|
||||||
|
|
||||||
// 浏览器会自动请求 favicon,直接返回 204,避免污染日志
|
// 浏览器会自动请求 favicon;有前端产物时交给静态托管处理
|
||||||
app.get('/favicon.ico', (_req, res) => {
|
if (!webDistDir) {
|
||||||
res.status(204).end();
|
app.get('/favicon.ico', (_req, res) => {
|
||||||
});
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 健康检查(同时暴露已挂载模块列表,便于确认模块化装配结果)
|
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
|
||||||
app.get(`${API_PREFIX}/health`, healthHandler);
|
app.use(API_PREFIX, apiRateLimiter());
|
||||||
app.get('/health', healthHandler);
|
app.use(`${API_PREFIX}/auth/login`, loginRateLimiter());
|
||||||
|
|
||||||
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
|
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
|
||||||
for (const module of apiModules) {
|
for (const module of apiModules) {
|
||||||
@@ -124,6 +182,9 @@ export function createApp(): Express {
|
|||||||
logger.info(`已挂载模块 ${module.name.padEnd(14)} -> ${mountPath}`);
|
logger.info(`已挂载模块 ${module.name.padEnd(14)} -> ${mountPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Web 管理端静态资源 + SPA 回退(必须放在 API 之后)
|
||||||
|
mountWebAdmin(app);
|
||||||
|
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
*/
|
*/
|
||||||
export const serverRoot = path.resolve(currentDir, '..', '..');
|
export const serverRoot = path.resolve(currentDir, '..', '..');
|
||||||
|
|
||||||
// 显式按包根目录加载 .env,避免受启动时 cwd 影响
|
// 显式按包根目录加载 .env,避免受启动时 cwd 影响。
|
||||||
|
// 打包安装后配置文件放在安装根目录(server 的上一级),因此按顺序尝试两处;
|
||||||
|
// dotenv 默认不覆盖已存在的变量,所以先匹配到的文件优先生效。
|
||||||
loadEnv({ path: path.join(serverRoot, '.env') });
|
loadEnv({ path: path.join(serverRoot, '.env') });
|
||||||
|
loadEnv({ path: path.join(serverRoot, '..', '.env') });
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
|
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
|
||||||
|
/** 监听地址:生产环境建议 0.0.0.0(容器/内网访问),本地开发可用 127.0.0.1 */
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
DATABASE_PROVIDER: z.enum(['sqlite', 'mysql']).default('sqlite'),
|
DATABASE_PROVIDER: z.enum(['sqlite', 'mysql']).default('sqlite'),
|
||||||
DATABASE_URL: z.string().min(1).default('file:./prisma/dev.db'),
|
DATABASE_URL: z.string().min(1).default('file:./prisma/dev.db'),
|
||||||
JWT_SECRET: z.string().min(16),
|
JWT_SECRET: z.string().min(16),
|
||||||
@@ -25,6 +30,30 @@ const envSchema = z.object({
|
|||||||
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
||||||
TERM_START_DATE: z.string().default(''),
|
TERM_START_DATE: z.string().default(''),
|
||||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
|
||||||
|
TRUST_PROXY: z.string().default('false'),
|
||||||
|
/** Web 管理端构建产物目录;留空则自动探测 ../web-admin/dist 或 ./web */
|
||||||
|
WEB_DIST_DIR: z.string().default(''),
|
||||||
|
/** 是否启用接口限流(压测时可置 false) */
|
||||||
|
RATE_LIMIT_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.default('true')
|
||||||
|
.transform((value) => value !== 'false' && value !== '0'),
|
||||||
|
/** 启动时是否校验数据库连通性 */
|
||||||
|
STARTUP_DB_CHECK: z
|
||||||
|
.string()
|
||||||
|
.default('true')
|
||||||
|
.transform((value) => value !== 'false' && value !== '0'),
|
||||||
|
/** 自动初始化:数据库为空时是否自动执行迁移(安装程序首启动用) */
|
||||||
|
AUTO_MIGRATE: z
|
||||||
|
.string()
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true' || value === '1'),
|
||||||
|
/** 进程 PID 文件(相对路径以 server 根目录为基准),供安装包的启停脚本使用 */
|
||||||
|
PID_FILE: z.string().default(''),
|
||||||
|
/** 首次初始化(空库)时创建的管理员账号 */
|
||||||
|
INITIAL_ADMIN_USERNAME: z.string().min(3).default('admin'),
|
||||||
|
INITIAL_ADMIN_PASSWORD: z.string().min(6).default('admin123'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = envSchema.safeParse(process.env);
|
const parsed = envSchema.safeParse(process.env);
|
||||||
@@ -46,16 +75,57 @@ function resolveSqliteUrl(url: string): string {
|
|||||||
return `file:${absolute.replace(/\\/g, '/')}`;
|
return `file:${absolute.replace(/\\/g, '/')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从 file: URL 中取出真实的文件系统路径(供启动前建目录等使用) */
|
||||||
|
function sqliteFilePathOf(url: string): string {
|
||||||
|
const withoutPrefix = url.startsWith('file:') ? url.slice('file:'.length) : url;
|
||||||
|
return withoutPrefix.replace(/\\/g, path.sep).replace(/\//g, path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
const isSqlite = raw.DATABASE_PROVIDER === 'sqlite';
|
const isSqlite = raw.DATABASE_PROVIDER === 'sqlite';
|
||||||
|
const isProduction = raw.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
const resolvedDatabaseUrl = isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL;
|
||||||
|
/** SQLite 数据文件的绝对路径(MySQL 时为空字符串),供启动前创建目录使用 */
|
||||||
|
const sqliteFilePath = isSqlite ? sqliteFilePathOf(resolvedDatabaseUrl) : '';
|
||||||
|
|
||||||
|
/** 生产环境配置自检:不安全的默认值直接拒绝启动,避免把开发配置带上生产 */
|
||||||
|
const INSECURE_SECRET_MARKERS = ['change-me', 'changeme', 'secret', 'classhelper-dev'];
|
||||||
|
if (isProduction) {
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (raw.JWT_SECRET.length < 32) problems.push('JWT_SECRET 长度必须 ≥ 32 位');
|
||||||
|
if (INSECURE_SECRET_MARKERS.some((marker) => raw.JWT_SECRET.toLowerCase().includes(marker))) {
|
||||||
|
problems.push('JWT_SECRET 仍是示例/开发用值,请重新生成');
|
||||||
|
}
|
||||||
|
if (isSqlite && raw.DATABASE_URL.includes('dev.db')) {
|
||||||
|
problems.push('生产环境仍在使用 dev.db,请指定正式的数据文件或改用 MySQL');
|
||||||
|
}
|
||||||
|
if (problems.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`生产环境(NODE_ENV=production)配置检查未通过:\n${problems.map((item) => ` - ${item}`).join('\n')}\n` +
|
||||||
|
'请修改 packages/server/.env 后重试。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** trust proxy 支持 false / 数字跳数 / loopback 等字符串形式 */
|
||||||
|
function resolveTrustProxy(value: string): boolean | number | string {
|
||||||
|
if (value === 'false' || value === '') return false;
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (/^\d+$/.test(value)) return Number(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
nodeEnv: raw.NODE_ENV,
|
nodeEnv: raw.NODE_ENV,
|
||||||
isProduction: raw.NODE_ENV === 'production',
|
isProduction,
|
||||||
isDevelopment: raw.NODE_ENV === 'development',
|
isDevelopment: raw.NODE_ENV === 'development',
|
||||||
port: raw.PORT,
|
port: raw.PORT,
|
||||||
|
host: raw.HOST,
|
||||||
databaseProvider: raw.DATABASE_PROVIDER,
|
databaseProvider: raw.DATABASE_PROVIDER,
|
||||||
/** 已规范化为绝对路径(SQLite)/ 原始连接串(MySQL) */
|
/** 已规范化为绝对路径(SQLite)/ 原始连接串(MySQL) */
|
||||||
databaseUrl: isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL,
|
databaseUrl: resolvedDatabaseUrl,
|
||||||
|
/** SQLite 数据文件绝对路径(MySQL 时为空) */
|
||||||
|
sqliteFilePath,
|
||||||
rawDatabaseUrl: raw.DATABASE_URL,
|
rawDatabaseUrl: raw.DATABASE_URL,
|
||||||
jwtSecret: raw.JWT_SECRET,
|
jwtSecret: raw.JWT_SECRET,
|
||||||
jwtExpiresIn: raw.JWT_EXPIRES_IN,
|
jwtExpiresIn: raw.JWT_EXPIRES_IN,
|
||||||
@@ -71,6 +141,15 @@ export const env = {
|
|||||||
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
||||||
termStartDate: raw.TERM_START_DATE,
|
termStartDate: raw.TERM_START_DATE,
|
||||||
logLevel: raw.LOG_LEVEL,
|
logLevel: raw.LOG_LEVEL,
|
||||||
|
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
|
||||||
|
webDistDir: raw.WEB_DIST_DIR,
|
||||||
|
rateLimitEnabled: raw.RATE_LIMIT_ENABLED,
|
||||||
|
startupDbCheck: raw.STARTUP_DB_CHECK,
|
||||||
|
autoMigrate: raw.AUTO_MIGRATE,
|
||||||
|
/** PID 文件的绝对路径(未配置时为空字符串) */
|
||||||
|
pidFile: raw.PID_FILE ? path.resolve(serverRoot, raw.PID_FILE) : '',
|
||||||
|
initialAdminUsername: raw.INITIAL_ADMIN_USERNAME,
|
||||||
|
initialAdminPassword: raw.INITIAL_ADMIN_PASSWORD,
|
||||||
serverRoot,
|
serverRoot,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,90 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { API_PREFIX } from '@classhelper/shared';
|
import { API_PREFIX } from '@classhelper/shared';
|
||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { disconnectPrisma, pingDatabase } from './lib/db.js';
|
import { disconnectPrisma, pingDatabase } from './lib/db.js';
|
||||||
|
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 { initRealtime } from './realtime/socket.js';
|
import { initRealtime } from './realtime/socket.js';
|
||||||
|
|
||||||
|
/** 数据库自检与可选的首次初始化 */
|
||||||
|
async function prepareDatabase(): Promise<void> {
|
||||||
|
// SQLite:确保数据文件所在目录存在,否则 libsql 会以 SQLITE_CANTOPEN(14) 失败
|
||||||
|
if (env.sqliteFilePath) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(env.sqliteFilePath), { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`创建数据目录失败:${path.dirname(env.sqliteFilePath)}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.autoMigrate) {
|
||||||
|
try {
|
||||||
|
const result = await ensureSchema();
|
||||||
|
logger.info(`数据库初始化:${result.detail}`);
|
||||||
|
const admin = await ensureInitialAdmin();
|
||||||
|
if (admin.created) logger.info('可使用该账号登录 Web 管理端,并立即修改密码');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('数据库初始化失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.startupDbCheck) return;
|
||||||
|
|
||||||
|
if (await pingDatabase()) {
|
||||||
|
logger.info('数据库连接正常');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
'数据库连接失败:请检查 DATABASE_URL,并确认已执行迁移(pnpm db:migrate / prisma migrate deploy)',
|
||||||
|
);
|
||||||
|
if (env.isProduction) {
|
||||||
|
logger.error('生产环境启动中止:数据库不可用时不应对外提供服务');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prepareDatabase();
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
const io = initRealtime(httpServer);
|
const io = initRealtime(httpServer);
|
||||||
|
|
||||||
if (!(await pingDatabase())) {
|
/** 写入 PID 文件(安装包的 start.cmd / stop.cmd 依赖它精确控制进程) */
|
||||||
logger.warn('数据库连接失败:请先执行 pnpm db:migrate 与 pnpm db:seed');
|
function writePidFile(): void {
|
||||||
|
if (!env.pidFile) return;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(env.pidFile), { recursive: true });
|
||||||
|
fs.writeFileSync(env.pidFile, String(process.pid), 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('写入 PID 文件失败', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
httpServer.listen(env.port, () => {
|
function removePidFile(): void {
|
||||||
logger.info(`班级小助手后端已启动:http://127.0.0.1:${env.port}${API_PREFIX}`);
|
if (!env.pidFile) return;
|
||||||
logger.info(`健康检查:http://127.0.0.1:${env.port}${API_PREFIX}/health`);
|
try {
|
||||||
logger.info(`WebSocket:ws://127.0.0.1:${env.port}/socket.io`);
|
if (fs.existsSync(env.pidFile)) fs.unlinkSync(env.pidFile);
|
||||||
logger.info(`运行环境:${env.nodeEnv},数据库:${env.databaseProvider}`);
|
} catch {
|
||||||
|
// 忽略清理失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer.listen(env.port, env.host, () => {
|
||||||
|
writePidFile();
|
||||||
|
const displayHost = env.host === '0.0.0.0' ? '127.0.0.1' : env.host;
|
||||||
|
const webDir = resolveWebDistDir();
|
||||||
|
logger.info(`班级小助手后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
|
||||||
|
logger.info(`运行环境:${env.nodeEnv} · 监听:${env.host}:${env.port} · 数据库:${env.databaseProvider}`);
|
||||||
|
if (webDir) {
|
||||||
|
logger.info(`Web 管理端:http://${displayHost}:${env.port}/(静态目录 ${webDir})`);
|
||||||
|
}
|
||||||
|
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
|
||||||
|
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
|
||||||
});
|
});
|
||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
@@ -28,9 +94,18 @@ async function shutdown(signal: string): Promise<void> {
|
|||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
logger.info(`收到 ${signal},正在优雅关闭...`);
|
logger.info(`收到 ${signal},正在优雅关闭...`);
|
||||||
|
|
||||||
|
// 停止接收新连接,并给在途请求 5 秒收尾时间
|
||||||
|
const forceExit = setTimeout(() => {
|
||||||
|
logger.warn('关闭超时,强制退出');
|
||||||
|
process.exit(1);
|
||||||
|
}, 5000);
|
||||||
|
forceExit.unref();
|
||||||
|
|
||||||
await new Promise<void>((resolve) => io.close(() => resolve()));
|
await new Promise<void>((resolve) => io.close(() => resolve()));
|
||||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||||
await disconnectPrisma();
|
await disconnectPrisma();
|
||||||
|
removePidFile();
|
||||||
|
clearTimeout(forceExit);
|
||||||
logger.info('已关闭所有连接');
|
logger.info('已关闭所有连接');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -44,3 +119,7 @@ process.on('SIGTERM', () => {
|
|||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
logger.error('未处理的 Promise 拒绝', reason);
|
logger.error('未处理的 Promise 拒绝', reason);
|
||||||
});
|
});
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
logger.error('未捕获异常,进程即将退出', error);
|
||||||
|
void shutdown('uncaughtException');
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createClient } from '@libsql/client';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { prisma } from './db.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { hashPassword } from './password.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次启动时的数据库初始化(SQLite/libSQL)。
|
||||||
|
*
|
||||||
|
* 场景:安装程序首次运行、Docker 挂载了空数据卷、用户手动删除了数据库文件。
|
||||||
|
* 做法:若目标库中还没有业务表,就把随包发布的 Prisma 迁移 SQL 依次执行一遍。
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 只处理 SQLite(本项目打包安装的默认形态);
|
||||||
|
* MySQL 请在部署流程中执行 `prisma migrate deploy`(Docker 镜像已内置该步骤)。
|
||||||
|
* - 迁移文件按目录名(时间戳)升序执行,与 Prisma 的行为一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 迁移文件所在目录(打包后随 server 一起发布) */
|
||||||
|
function migrationsRoot(): string {
|
||||||
|
const candidates = [
|
||||||
|
env.isProduction ? path.resolve(env.serverRoot, 'prisma', 'migrations') : '',
|
||||||
|
path.resolve(env.serverRoot, 'prisma', 'migrations'),
|
||||||
|
path.resolve(env.serverRoot, '..', 'prisma', 'migrations'),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return path.resolve(env.serverRoot, 'prisma', 'migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收集迁移 SQL 文件(按目录名升序) */
|
||||||
|
export function listMigrationFiles(): string[] {
|
||||||
|
const root = migrationsRoot();
|
||||||
|
if (!fs.existsSync(root)) return [];
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort()
|
||||||
|
.map((name) => path.join(root, name, 'migration.sql'))
|
||||||
|
.filter((file) => fs.existsSync(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务表是否已存在(用 User 表作为探针) */
|
||||||
|
async function schemaExists(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.$queryRaw<Array<{ name: string }>>`
|
||||||
|
SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'User'
|
||||||
|
`;
|
||||||
|
return rows.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapResult {
|
||||||
|
applied: number;
|
||||||
|
skipped: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化管理员账号。
|
||||||
|
*
|
||||||
|
* 全新安装的数据库是空的,没有任何账号可登录,因此首次启动(AUTO_MIGRATE=true)
|
||||||
|
* 会创建一个管理员账号;如果使用的是默认密码,会在日志中给出醒目提示。
|
||||||
|
*/
|
||||||
|
export async function ensureInitialAdmin(): Promise<{ created: boolean; username: string }> {
|
||||||
|
const existing = await prisma.user.count({ where: { role: 'ADMIN' } });
|
||||||
|
if (existing > 0) return { created: false, username: env.initialAdminUsername };
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: env.initialAdminUsername,
|
||||||
|
name: '系统管理员',
|
||||||
|
role: 'ADMIN',
|
||||||
|
passwordHash: await hashPassword(env.initialAdminPassword),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (env.initialAdminPassword === 'admin123') {
|
||||||
|
logger.warn(
|
||||||
|
`已创建初始管理员 ${env.initialAdminUsername} / ${env.initialAdminPassword}(默认密码),请登录后立即在「修改密码」中更换`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(`已创建初始管理员账号:${env.initialAdminUsername}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created: true, username: env.initialAdminUsername };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保数据库结构存在。
|
||||||
|
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。
|
||||||
|
*/
|
||||||
|
export async function ensureSchema(): Promise<BootstrapResult> {
|
||||||
|
if (env.databaseProvider !== 'sqlite') {
|
||||||
|
return {
|
||||||
|
applied: 0,
|
||||||
|
skipped: true,
|
||||||
|
detail: '非 SQLite 数据库:请在部署流程中执行 prisma migrate deploy',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await schemaExists()) {
|
||||||
|
return { applied: 0, skipped: true, detail: '数据库结构已存在,跳过初始化' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = listMigrationFiles();
|
||||||
|
if (files.length === 0) {
|
||||||
|
return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient({ url: env.databaseUrl });
|
||||||
|
let applied = 0;
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const sql = fs.readFileSync(file, 'utf8');
|
||||||
|
await client.executeMultiple(sql);
|
||||||
|
applied += 1;
|
||||||
|
logger.info(`已应用迁移:${path.basename(path.dirname(file))}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` };
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import express, { type Express, type RequestHandler } from 'express';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web 管理端静态托管(生产模式)。
|
||||||
|
*
|
||||||
|
* 部署形态:后端同时充当 Web 管理端的静态服务器 —— 单端口即可访问
|
||||||
|
* `http://<host>:4000/` 打开管理端,`/api` 与 `/socket.io` 走同一端口,
|
||||||
|
* 因此不再需要额外的 Nginx/Vite 进程(需要 HTTPS/域名时在前面加反向代理)。
|
||||||
|
*
|
||||||
|
* 目录探测顺序:
|
||||||
|
* 1) 环境变量 WEB_DIST_DIR
|
||||||
|
* 2) Monorepo 开发布局:<serverRoot>/../web-admin/dist
|
||||||
|
* 3) 安装包布局:<serverRoot>/web
|
||||||
|
*/
|
||||||
|
export function resolveWebDistDir(): string | null {
|
||||||
|
const candidates = [
|
||||||
|
env.webDistDir,
|
||||||
|
path.resolve(env.serverRoot, '..', 'web-admin', 'dist'),
|
||||||
|
path.resolve(env.serverRoot, 'web'),
|
||||||
|
path.resolve(env.serverRoot, '..', 'web'),
|
||||||
|
].filter((item): item is string => Boolean(item));
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(path.join(candidate, 'index.html'))) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挂载静态资源与 SPA 回退。
|
||||||
|
* 仅在探测到构建产物时生效;否则保持纯 API 服务(开发模式下 Web 端由 Vite 提供)。
|
||||||
|
*/
|
||||||
|
export function mountWebAdmin(app: Express): string | null {
|
||||||
|
const distDir = resolveWebDistDir();
|
||||||
|
if (!distDir) {
|
||||||
|
logger.info('未检测到 Web 管理端构建产物,跳过静态托管(仅提供 API 与 WebSocket)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带内容哈希的构建产物:长缓存
|
||||||
|
// dotfiles: 'allow' —— 安装/部署目录路径里可能含点号目录(如 .cache、/opt/.apps),
|
||||||
|
// 默认的 'ignore' 会让 express.static / res.sendFile 直接 404(进而 500)
|
||||||
|
app.use(
|
||||||
|
'/assets',
|
||||||
|
express.static(path.join(distDir, 'assets'), {
|
||||||
|
immutable: true,
|
||||||
|
maxAge: '1y',
|
||||||
|
fallthrough: true,
|
||||||
|
index: false,
|
||||||
|
dotfiles: 'allow',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 其余静态文件(favicon、manifest、service worker 等):短缓存
|
||||||
|
app.use(
|
||||||
|
express.static(distDir, {
|
||||||
|
maxAge: '1h',
|
||||||
|
etag: true,
|
||||||
|
index: false,
|
||||||
|
dotfiles: 'allow',
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
// Service Worker 与 index.html 不能被长缓存,否则更新不生效
|
||||||
|
if (filePath.endsWith('sw.js') || filePath.endsWith('index.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexHtml = path.join(distDir, 'index.html');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA 回退:非 /api、非 /socket.io、且接受 HTML 的 GET 请求统一返回 index.html,
|
||||||
|
* 保证前端路由(/grades、/classes/xxx 等)刷新后仍可用。
|
||||||
|
*/
|
||||||
|
app.get(/^\/(?!api\/|socket\.io\/|healthz$|readyz$).*/, (req, res, next) => {
|
||||||
|
if (!req.accepts('html')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.sendFile(indexHtml, { dotfiles: 'allow' }, (error) => {
|
||||||
|
if (!error) return;
|
||||||
|
logger.error(`返回 Web 管理端首页失败:${indexHtml}`, error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
message: 'Web 管理端资源不可用,请检查构建产物是否完整',
|
||||||
|
code: 'WEB_DIST_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Web 管理端已挂载:${distDir}`);
|
||||||
|
return distDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 静态资源请求判断(用于日志降噪等场景) */
|
||||||
|
export const staticAssetMatcher: RequestHandler = (req, _res, next) => {
|
||||||
|
req.headers['x-classhelper-static'] = req.path.startsWith('/assets/') ? '1' : '0';
|
||||||
|
next();
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { RequestHandler } from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { rateLimit } from 'express-rate-limit';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { logger } from '../lib/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全响应头(helmet)。
|
||||||
|
*
|
||||||
|
* CSP 说明:
|
||||||
|
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
|
||||||
|
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
|
||||||
|
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
|
||||||
|
*/
|
||||||
|
export function securityHeaders(): RequestHandler {
|
||||||
|
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
|
||||||
|
if (Array.isArray(env.corsOrigins)) {
|
||||||
|
for (const origin of env.corsOrigins) connectSrc.add(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
useDefaults: true,
|
||||||
|
directives: {
|
||||||
|
'default-src': ["'self'"],
|
||||||
|
'script-src': ["'self'"],
|
||||||
|
'style-src': ["'self'", "'unsafe-inline'"],
|
||||||
|
'img-src': ["'self'", 'data:', 'blob:'],
|
||||||
|
'font-src': ["'self'", 'data:'],
|
||||||
|
'connect-src': [...connectSrc],
|
||||||
|
'object-src': ["'none'"],
|
||||||
|
'base-uri': ["'self'"],
|
||||||
|
'frame-ancestors': ["'none'"],
|
||||||
|
'form-action': ["'self'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
crossOriginOpenerPolicy: false,
|
||||||
|
// 允许桌面客户端等跨源场景读取静态资源与接口响应
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
referrerPolicy: { policy: 'no-referrer' },
|
||||||
|
hsts: env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用接口限流:防止单 IP 高频刷接口 */
|
||||||
|
export function apiRateLimiter(): RequestHandler {
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 5 * 60 * 1000,
|
||||||
|
limit: env.isProduction ? 600 : 3000,
|
||||||
|
standardHeaders: 'draft-7',
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: () => !env.rateLimitEnabled,
|
||||||
|
handler: (req, res) => {
|
||||||
|
logger.warn(`触发接口限流:${req.ip ?? '-'} ${req.method} ${req.originalUrl}`);
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
message: '请求过于频繁,请稍后再试',
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return limiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录接口专用限流:防暴力破解 */
|
||||||
|
export function loginRateLimiter(): RequestHandler {
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: 10 * 60 * 1000,
|
||||||
|
limit: env.isProduction ? 20 : 200,
|
||||||
|
standardHeaders: 'draft-7',
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
skip: () => !env.rateLimitEnabled,
|
||||||
|
handler: (req, res) => {
|
||||||
|
logger.warn(`登录尝试过于频繁:${req.ip ?? '-'}`);
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
message: '登录尝试过于频繁,请 10 分钟后再试',
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求耗时日志(生产 info,开发 debug) */
|
||||||
|
export function requestLogger(): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const startedAt = process.hrtime.bigint();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
||||||
|
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs.toFixed(1)}ms`;
|
||||||
|
if (res.statusCode >= 500) logger.error(message);
|
||||||
|
else if (res.statusCode >= 400) logger.warn(message);
|
||||||
|
else if (env.isProduction) logger.info(message);
|
||||||
|
else logger.debug(message);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,16 @@
|
|||||||
<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" />
|
||||||
|
<meta name="theme-color" content="#409eff" />
|
||||||
|
<meta name="description" content="班级小助手管理端:班级、课表、作业、通知与成绩管理" />
|
||||||
|
<!-- PWA:支持在 Chrome/Edge 中「安装为应用」 -->
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" href="/favicon.png" sizes="64x64" type="image/png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="班级小助手" />
|
||||||
<title>班级小助手 · 管理端</title>
|
<title>班级小助手 · 管理端</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#4facfe"/>
|
||||||
|
<stop offset="55%" stop-color="#409eff"/>
|
||||||
|
<stop offset="100%" stop-color="#1f6feb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="64" height="64" rx="14" ry="14" fill="url(#g)"/>
|
||||||
|
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="central"
|
||||||
|
font-family="'Microsoft YaHei','PingFang SC',sans-serif" font-weight="700"
|
||||||
|
font-size="33" fill="#ffffff">班</text>
|
||||||
|
<rect x="16" y="47" width="32" height="3"
|
||||||
|
rx="1" fill="#ffffff" opacity="0.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 702 B |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "班级小助手 · 管理端",
|
||||||
|
"short_name": "班级小助手",
|
||||||
|
"description": "班级信息管理系统管理端:班级、课表、作业、通知与成绩管理",
|
||||||
|
"lang": "zh-CN",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#f5f7fa",
|
||||||
|
"theme_color": "#409eff",
|
||||||
|
"categories": ["education", "productivity"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "通知发布",
|
||||||
|
"url": "/notifications",
|
||||||
|
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "成绩录入",
|
||||||
|
"url": "/grades",
|
||||||
|
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Service Worker:让 Web 管理端可以被浏览器「安装为应用」,并在断网时打开应用外壳。
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* - 导航请求(打开页面/刷新/前端路由):network-first,失败时回退缓存的 index.html
|
||||||
|
* - 构建产物 /assets/*(带内容哈希):cache-first(文件名变化即自动失效)
|
||||||
|
* - /api 与 /socket.io:完全透传,绝不缓存(避免脏数据与鉴权问题)
|
||||||
|
*/
|
||||||
|
const CACHE_NAME = 'classhelper-admin-v1';
|
||||||
|
const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png'];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.addAll(APP_SHELL))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
.catch(() => undefined),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
// 只接管同源请求
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
// 接口与实时通道透传
|
||||||
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/socket.io/')) return;
|
||||||
|
|
||||||
|
// 页面导航:网络优先,离线时用缓存外壳
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', clone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match('/index.html').then((cached) => cached ?? Response.error())),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态资源:缓存优先 + 后台更新
|
||||||
|
if (url.pathname.startsWith('/assets/') || /\.(png|svg|ico|webmanifest|woff2?)$/.test(url.pathname)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
const network = fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => cached ?? Response.error());
|
||||||
|
return cached ?? network;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
|
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
|
||||||
|
|
||||||
|
/** 页面是否由 http(s) 提供(生产环境由后端托管,浏览器里恒为 true) */
|
||||||
|
const servedOverHttp = typeof window !== 'undefined' && /^https?:$/.test(window.location.protocol);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运行时配置。
|
* 运行时配置。
|
||||||
* 默认直连后端(开发/生产都可用);如需走 Vite 代理,把 .env 里的
|
*
|
||||||
* VITE_API_BASE_URL 设为 /api 即可(vite.config.ts 已配置代理)。
|
* 默认使用**同源相对路径**:
|
||||||
|
* - 生产:后端同时托管 Web 管理端,`/api` 与 `/socket.io` 同源,无需任何配置;
|
||||||
|
* - 开发:Vite 已把 `/api` 与 `/socket.io` 代理到后端(见 vite.config.ts),同样可用;
|
||||||
|
* - 需要指向其它后端(例如静态托管在 CDN)时,构建期设置 VITE_API_BASE_URL / VITE_SOCKET_URL。
|
||||||
*/
|
*/
|
||||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? `${DEFAULT_SERVER_URL}/api`;
|
export const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_BASE_URL ?? (servedOverHttp ? '/api' : `${DEFAULT_SERVER_URL}/api`);
|
||||||
|
|
||||||
export const SOCKET_URL = import.meta.env.VITE_SOCKET_URL ?? DEFAULT_SERVER_URL;
|
export const SOCKET_URL =
|
||||||
|
import.meta.env.VITE_SOCKET_URL ?? (servedOverHttp ? window.location.origin : DEFAULT_SERVER_URL);
|
||||||
|
|
||||||
export const APP_TITLE = '班级小助手 · 管理端';
|
export const APP_TITLE = '班级小助手 · 管理端';
|
||||||
@@ -13,16 +13,36 @@ const auth = useAuthStore();
|
|||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: 'dashboard', title: '仪表盘', icon: 'Odometer' },
|
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
|
||||||
{ name: 'classes', title: '班级管理', icon: 'School' },
|
{ path: '/classes', title: '班级管理', icon: 'School' },
|
||||||
{ name: 'students', title: '学生管理', icon: 'User' },
|
{ path: '/students', title: '学生管理', icon: 'User' },
|
||||||
{ name: 'schedules', title: '课表管理', icon: 'Calendar' },
|
{ path: '/schedules', title: '课表管理', icon: 'Calendar' },
|
||||||
{ name: 'homeworks', title: '作业发布', icon: 'Notebook' },
|
{ path: '/homeworks', title: '作业发布', icon: 'Notebook' },
|
||||||
{ name: 'notifications', title: '通知发布', icon: 'Bell' },
|
{ path: '/notifications', title: '通知发布', icon: 'Bell' },
|
||||||
{ name: 'grades', title: '成绩录入', icon: 'Trophy' },
|
{ path: '/grades', title: '成绩录入', icon: 'Trophy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeMenu = computed(() => `/${String(route.name ?? 'dashboard')}`);
|
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||||
|
const activeMenu = computed(() => route.path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单点击回调。
|
||||||
|
* 注意:el-menu 的 @select 抛出的是 el-menu-item 的 index(这里即路由路径),
|
||||||
|
* 不能当作路由名使用(router.push({ name }) 会因找不到同名路由而静默失败)。
|
||||||
|
*/
|
||||||
|
function handleMenuSelect(index: string): void {
|
||||||
|
void router.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开发期自检:菜单路径必须存在于路由表中,防止再次出现"点了没反应" */
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
||||||
|
for (const item of menuItems) {
|
||||||
|
if (!knownPaths.has(item.path)) {
|
||||||
|
console.error(`[AdminLayout] 菜单项未注册对应路由:${item.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const connectionText = computed(() => {
|
const connectionText = computed(() => {
|
||||||
if (realtime.connected) return '实时通道已连接';
|
if (realtime.connected) return '实时通道已连接';
|
||||||
@@ -34,10 +54,6 @@ const connectionType = computed(() =>
|
|||||||
realtime.connected ? 'success' : realtime.connecting ? 'warning' : 'danger',
|
realtime.connected ? 'success' : realtime.connecting ? 'warning' : 'danger',
|
||||||
);
|
);
|
||||||
|
|
||||||
function go(name: string): void {
|
|
||||||
void router.push({ name });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
@@ -91,8 +107,8 @@ async function submitPassword(): Promise<void> {
|
|||||||
<el-icon :size="22"><School /></el-icon>
|
<el-icon :size="22"><School /></el-icon>
|
||||||
<span class="brand-text">班级小助手</span>
|
<span class="brand-text">班级小助手</span>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="activeMenu" class="layout-menu" @select="go">
|
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.name" :index="`/${item.name}`">
|
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|||||||
@@ -21,3 +21,16 @@ app.use(router);
|
|||||||
app.use(ElementPlus, { locale: zhCn });
|
app.use(ElementPlus, { locale: zhCn });
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 Service Worker(仅生产构建):
|
||||||
|
* - 浏览器可「安装为应用」(PWA),断网时仍能打开应用外壳
|
||||||
|
* - 不缓存 /api 与 /socket.io,保证数据实时性
|
||||||
|
*/
|
||||||
|
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
void navigator.serviceWorker.register('/sw.js').catch((error) => {
|
||||||
|
console.warn('[pwa] Service Worker 注册失败:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -102,6 +102,9 @@ importers:
|
|||||||
bcryptjs:
|
bcryptjs:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
|
compression:
|
||||||
|
specifier: ^1.8.2
|
||||||
|
version: 1.8.2
|
||||||
cors:
|
cors:
|
||||||
specifier: ^2.8.6
|
specifier: ^2.8.6
|
||||||
version: 2.8.6
|
version: 2.8.6
|
||||||
@@ -111,6 +114,12 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
express-rate-limit:
|
||||||
|
specifier: ^8.7.0
|
||||||
|
version: 8.7.0(express@5.2.1)
|
||||||
|
helmet:
|
||||||
|
specifier: ^8.3.0
|
||||||
|
version: 8.3.0
|
||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
@@ -121,6 +130,9 @@ importers:
|
|||||||
specifier: ^4.6.2
|
specifier: ^4.6.2
|
||||||
version: 4.6.2
|
version: 4.6.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/compression':
|
||||||
|
specifier: ^1.8.1
|
||||||
|
version: 1.8.1
|
||||||
'@types/cors':
|
'@types/cors':
|
||||||
specifier: ^2.8.19
|
specifier: ^2.8.19
|
||||||
version: 2.8.19
|
version: 2.8.19
|
||||||
@@ -887,6 +899,9 @@ packages:
|
|||||||
'@types/cacheable-request@6.0.3':
|
'@types/cacheable-request@6.0.3':
|
||||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||||
|
|
||||||
|
'@types/compression@1.8.1':
|
||||||
|
resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==}
|
||||||
|
|
||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
@@ -1444,6 +1459,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==}
|
resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
compressible@2.0.18:
|
||||||
|
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
compression@1.8.2:
|
||||||
|
resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
@@ -1554,6 +1577,14 @@ packages:
|
|||||||
dayjs@1.11.23:
|
dayjs@1.11.23:
|
||||||
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
|
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
|
||||||
|
|
||||||
|
debug@2.6.9:
|
||||||
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1607,6 +1638,10 @@ packages:
|
|||||||
destr@2.0.5:
|
destr@2.0.5:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
|
destroy@1.2.0:
|
||||||
|
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||||
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
|
|
||||||
detect-libc@2.0.2:
|
detect-libc@2.0.2:
|
||||||
resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
|
resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1832,6 +1867,12 @@ packages:
|
|||||||
exponential-backoff@3.1.3:
|
exponential-backoff@3.1.3:
|
||||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||||
|
|
||||||
|
express-rate-limit@8.7.0:
|
||||||
|
resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
peerDependencies:
|
||||||
|
express: '>= 4.11'
|
||||||
|
|
||||||
express@5.2.1:
|
express@5.2.1:
|
||||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@@ -2040,6 +2081,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
helmet@8.3.0:
|
||||||
|
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
hookable@5.5.3:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
@@ -2103,6 +2148,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
ip-address@10.7.0:
|
||||||
|
resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -2436,6 +2485,9 @@ packages:
|
|||||||
mlly@1.8.2:
|
mlly@1.8.2:
|
||||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||||
|
|
||||||
|
ms@2.0.0:
|
||||||
|
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -2462,6 +2514,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
negotiator@0.6.4:
|
||||||
|
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
negotiator@1.1.0:
|
negotiator@1.1.0:
|
||||||
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
|
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2518,6 +2574,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
on-headers@1.1.0:
|
||||||
|
resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
@@ -3952,6 +4012,11 @@ snapshots:
|
|||||||
'@types/node': 22.20.2
|
'@types/node': 22.20.2
|
||||||
'@types/responselike': 1.0.3
|
'@types/responselike': 1.0.3
|
||||||
|
|
||||||
|
'@types/compression@1.8.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.6
|
||||||
|
'@types/node': 22.20.2
|
||||||
|
|
||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.20.2
|
'@types/node': 22.20.2
|
||||||
@@ -4691,6 +4756,23 @@ snapshots:
|
|||||||
|
|
||||||
compare-version@0.1.2: {}
|
compare-version@0.1.2: {}
|
||||||
|
|
||||||
|
compressible@2.0.18:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.54.0
|
||||||
|
|
||||||
|
compression@1.8.2:
|
||||||
|
dependencies:
|
||||||
|
bytes: 3.1.2
|
||||||
|
compressible: 2.0.18
|
||||||
|
debug: 2.6.9
|
||||||
|
destroy: 1.2.0
|
||||||
|
negotiator: 0.6.4
|
||||||
|
on-headers: 1.1.0
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
vary: 1.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
concurrently@10.0.5:
|
concurrently@10.0.5:
|
||||||
@@ -4786,6 +4868,10 @@ snapshots:
|
|||||||
|
|
||||||
dayjs@1.11.23: {}
|
dayjs@1.11.23: {}
|
||||||
|
|
||||||
|
debug@2.6.9:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.0.0
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -4828,6 +4914,8 @@ snapshots:
|
|||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
|
|
||||||
|
destroy@1.2.0: {}
|
||||||
|
|
||||||
detect-libc@2.0.2: {}
|
detect-libc@2.0.2: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
@@ -5156,6 +5244,14 @@ snapshots:
|
|||||||
|
|
||||||
exponential-backoff@3.1.3: {}
|
exponential-backoff@3.1.3: {}
|
||||||
|
|
||||||
|
express-rate-limit@8.7.0(express@5.2.1):
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
express: 5.2.1
|
||||||
|
ip-address: 10.7.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
express@5.2.1:
|
express@5.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
accepts: 2.0.0
|
accepts: 2.0.0
|
||||||
@@ -5420,6 +5516,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
helmet@8.3.0: {}
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
hookified@1.15.1: {}
|
hookified@1.15.1: {}
|
||||||
@@ -5485,6 +5583,8 @@ snapshots:
|
|||||||
|
|
||||||
internmap@2.0.3: {}
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
|
ip-address@10.7.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-extglob@2.1.1: {}
|
is-extglob@2.1.1: {}
|
||||||
@@ -5769,6 +5869,8 @@ snapshots:
|
|||||||
pkg-types: 1.3.1
|
pkg-types: 1.3.1
|
||||||
ufo: 1.6.4
|
ufo: 1.6.4
|
||||||
|
|
||||||
|
ms@2.0.0: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
@@ -5795,6 +5897,8 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@0.6.3: {}
|
negotiator@0.6.3: {}
|
||||||
|
|
||||||
|
negotiator@0.6.4: {}
|
||||||
|
|
||||||
negotiator@1.1.0:
|
negotiator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
content-type: 2.1.0
|
content-type: 2.1.0
|
||||||
@@ -5849,6 +5953,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
|
|
||||||
|
on-headers@1.1.0: {}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
/**
|
||||||
|
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
|
||||||
|
*
|
||||||
|
* 产出:
|
||||||
|
* release-server/classhelper-server/ 可直接运行的免安装目录
|
||||||
|
* release-server/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
|
||||||
|
*
|
||||||
|
* 打包策略(面向"双击即可用、目标机无需安装 Node"):
|
||||||
|
* 1. 内置 Node 运行时(复制本机 node.exe)—— 目标机无需安装 Node.js
|
||||||
|
* 2. 依赖用 npm 安装为真实目录(非 pnpm 软链),保证可整体拷贝到别的机器
|
||||||
|
* 3. 后端使用 tsc 构建产物(ESM),Web 管理端使用 Vite 构建产物
|
||||||
|
* 4. 首次启动自动执行迁移(AUTO_MIGRATE=true)+ 自动生成 JWT 密钥 + PID 文件
|
||||||
|
* 5. 用 electron-builder 自带的 NSIS(makensis)生成安装程序
|
||||||
|
*/
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
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 serverDir = path.join(root, 'packages', 'server');
|
||||||
|
const webDir = path.join(root, 'packages', 'web-admin');
|
||||||
|
const outRoot = path.join(root, 'release-server');
|
||||||
|
const staging = path.join(outRoot, 'classhelper-server');
|
||||||
|
|
||||||
|
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||||||
|
const PRODUCT = '班级小助手服务端';
|
||||||
|
const PORT = '4000';
|
||||||
|
|
||||||
|
const log = (message) => console.log(`[dist:server] ${message}`);
|
||||||
|
|
||||||
|
const run = (command, args, options = {}) => {
|
||||||
|
// Windows 下 .cmd 必须通过 cmd.exe 启动(Node 20+ 不再允许直接 spawn .cmd)
|
||||||
|
const needsShell = process.platform === 'win32' && command.toLowerCase().endsWith('.cmd');
|
||||||
|
const result = spawnSync(command, args, { stdio: 'inherit', shell: needsShell, ...options });
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(`命令启动失败:${command} ${args.join(' ')} -> ${result.error.message}`);
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`命令失败(${result.status}):${command} ${args.join(' ')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 1. 前置构建 */
|
||||||
|
|
||||||
|
function ensureBuilds() {
|
||||||
|
const serverEntry = path.join(serverDir, 'dist', 'index.js');
|
||||||
|
const webEntry = path.join(webDir, 'dist', 'index.html');
|
||||||
|
const missing = [];
|
||||||
|
if (!fs.existsSync(serverEntry))
|
||||||
|
missing.push('packages/server/dist(请先 pnpm --filter @classhelper/server build)');
|
||||||
|
if (!fs.existsSync(webEntry))
|
||||||
|
missing.push('packages/web-admin/dist(请先 pnpm --filter @classhelper/web-admin build)');
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`缺少构建产物:\n - ${missing.join('\n - ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 2. 依赖安装 */
|
||||||
|
|
||||||
|
function serverProdDependencies() {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(serverDir, 'package.json'), 'utf8'));
|
||||||
|
const deps = { ...pkg.dependencies };
|
||||||
|
// 工作区内部包用拷贝的方式提供,不走 npm
|
||||||
|
delete deps['@classhelper/shared'];
|
||||||
|
return deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 运行时依赖清单(同时供安装包与 Docker 运行时镜像使用,单一事实来源) */
|
||||||
|
function writeRuntimeManifest() {
|
||||||
|
const manifest = {
|
||||||
|
name: 'classhelper-server-runtime',
|
||||||
|
version: VERSION,
|
||||||
|
private: true,
|
||||||
|
type: 'module',
|
||||||
|
description: '班级小助手服务端运行时(安装包 / 容器内置)',
|
||||||
|
dependencies: serverProdDependencies(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
const deployDir = path.join(root, 'deploy');
|
||||||
|
fs.mkdirSync(deployDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(deployDir, 'package.runtime.json'),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDependencies() {
|
||||||
|
writeRuntimeManifest();
|
||||||
|
|
||||||
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
|
log('安装生产依赖(npm install --omit=dev,真实目录,便于整体拷贝)...');
|
||||||
|
run(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {
|
||||||
|
cwd: staging,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
npm_config_cache: path.join(root, '.cache', 'npm-cache'),
|
||||||
|
NODE_OPTIONS: process.env.NODE_OPTIONS ?? '--use-system-ca',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 3. 组装运行时 */
|
||||||
|
|
||||||
|
function copyRecursive(from, to) {
|
||||||
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||||
|
fs.cpSync(from, to, { recursive: true, dereference: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuntime() {
|
||||||
|
// 后端代码:保留 dist 层级,保证代码里 serverRoot 的计算(../..)与开发环境一致
|
||||||
|
copyRecursive(path.join(serverDir, 'dist'), path.join(staging, 'server', 'dist'));
|
||||||
|
// 迁移文件(首次启动 AUTO_MIGRATE 使用)
|
||||||
|
copyRecursive(
|
||||||
|
path.join(serverDir, 'prisma', 'migrations'),
|
||||||
|
path.join(staging, 'server', 'prisma', 'migrations'),
|
||||||
|
);
|
||||||
|
// 工作区共享包(tsc 产物)
|
||||||
|
const sharedTarget = path.join(staging, 'node_modules', '@classhelper', 'shared');
|
||||||
|
fs.mkdirSync(sharedTarget, { recursive: true });
|
||||||
|
copyRecursive(path.join(root, 'packages', 'shared', 'dist'), path.join(sharedTarget, 'dist'));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(sharedTarget, 'package.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: '@classhelper/shared',
|
||||||
|
version: VERSION,
|
||||||
|
private: true,
|
||||||
|
type: 'module',
|
||||||
|
main: './dist/index.js',
|
||||||
|
types: './dist/index.d.ts',
|
||||||
|
exports: { '.': { types: './dist/index.d.ts', default: './dist/index.js' } },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Web 管理端
|
||||||
|
copyRecursive(path.join(webDir, 'dist'), path.join(staging, 'web'));
|
||||||
|
|
||||||
|
// Node 运行时
|
||||||
|
const nodeExe = process.execPath;
|
||||||
|
copyRecursive(nodeExe, path.join(staging, 'node.exe'));
|
||||||
|
log(`内置 Node 运行时:${nodeExe}`);
|
||||||
|
|
||||||
|
// 数据目录:强制清空,避免把打包机上测试用的数据库带进安装包
|
||||||
|
fs.rmSync(path.join(staging, 'data'), { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(path.join(staging, 'data'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(staging, 'logs'), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 4. 配置与脚本 */
|
||||||
|
|
||||||
|
function writeEnvironmentFile() {
|
||||||
|
const jwtSecret = crypto.randomBytes(48).toString('hex');
|
||||||
|
const content = `# 班级小助手服务端(安装程序生成,请妥善保管)
|
||||||
|
# 修改后需重启服务生效(开始菜单 → 班级小助手服务端 → 重启服务)
|
||||||
|
|
||||||
|
NODE_ENV=production
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=${PORT}
|
||||||
|
|
||||||
|
# 数据库:SQLite 文件位于安装目录的 data 子目录
|
||||||
|
DATABASE_PROVIDER=sqlite
|
||||||
|
DATABASE_URL="file:../data/classhelper.db"
|
||||||
|
# 首次启动时自动建表(数据库为空时执行随包的迁移 SQL)
|
||||||
|
AUTO_MIGRATE=true
|
||||||
|
|
||||||
|
# 登录令牌签名密钥(首次安装随机生成,请勿泄露/更换,更换后所有用户需重新登录)
|
||||||
|
JWT_SECRET=${jwtSecret}
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# 允许跨域来源:* 表示全部允许;若通过域名访问建议改成具体来源,例如 https://class.example.com
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# 反代场景请设为 1,保证限流按真实客户端 IP 统计
|
||||||
|
TRUST_PROXY=0
|
||||||
|
# 安全与运维
|
||||||
|
RATE_LIMIT_ENABLED=true
|
||||||
|
STARTUP_DB_CHECK=true
|
||||||
|
PID_FILE=../data/server.pid
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# 可选项
|
||||||
|
BCRYPT_ROUNDS=10
|
||||||
|
DEFAULT_STUDENT_PASSWORD=123456
|
||||||
|
TERM_START_DATE=
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(staging, '.env'), content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRuntimeScripts() {
|
||||||
|
// 注意:.cmd 内容保持 ASCII,避免 cmd.exe 按 OEM 代码页(GBK)解析 UTF-8 中文出错;
|
||||||
|
// 中文说明放在 README.txt 与安装向导里。
|
||||||
|
// 启动采用 PowerShell Start-Process 分离进程 + 输出重定向到日志文件,
|
||||||
|
// 避免 `start` 在管道/非交互环境下让子进程继承句柄而卡住调用方。
|
||||||
|
const startCmd = `@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
set "PID="
|
||||||
|
if exist "data\\server.pid" set /p PID=<"data\\server.pid"
|
||||||
|
|
||||||
|
if defined PID (
|
||||||
|
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
echo [ClassHelper] Server is already running ^(PID %PID%^).
|
||||||
|
echo [ClassHelper] Admin UI: http://127.0.0.1:${PORT}/
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "logs" mkdir "logs"
|
||||||
|
echo [ClassHelper] Starting server...
|
||||||
|
|
||||||
|
powershell -NoLogo -NoProfile -NonInteractive -Command ^
|
||||||
|
"Start-Process -FilePath '%~dp0node.exe' -ArgumentList '%~dp0server\\dist\\index.js' -WorkingDirectory '%~dp0' -WindowStyle Hidden -RedirectStandardOutput '%~dp0logs\\server.log' -RedirectStandardError '%~dp0logs\\server.error.log'"
|
||||||
|
|
||||||
|
echo [ClassHelper] Waiting for service to become ready...
|
||||||
|
for /l %%i in (1,1,30) do (
|
||||||
|
powershell -NoLogo -NoProfile -NonInteractive -Command "try { $null = Invoke-WebRequest -Uri 'http://127.0.0.1:${PORT}/healthz' -UseBasicParsing -TimeoutSec 2; exit 0 } catch { exit 1 }" >nul 2>&1
|
||||||
|
if not errorlevel 1 (
|
||||||
|
echo [ClassHelper] Ready: http://127.0.0.1:${PORT}/
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
ping -n 2 127.0.0.1 >nul
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [ClassHelper] Start timed out. Check logs\\server.error.log for details.
|
||||||
|
exit /b 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const stopCmd = `@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
if not exist "data\\server.pid" (
|
||||||
|
echo [ClassHelper] No PID file found - server is not running.
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
set /p PID=<"data\\server.pid"
|
||||||
|
echo [ClassHelper] Stopping server ^(PID %PID%^)...
|
||||||
|
powershell -NoLogo -NoProfile -NonInteractive -Command "try { Stop-Process -Id %PID% -Force -ErrorAction Stop; exit 0 } catch { exit 1 }" >nul 2>&1
|
||||||
|
if errorlevel 1 taskkill /PID %PID% /T /F >nul 2>&1
|
||||||
|
del /q "data\\server.pid" >nul 2>&1
|
||||||
|
echo [ClassHelper] Stopped.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const restartCmd = `@echo off
|
||||||
|
call "%~dp0stop.cmd"
|
||||||
|
ping -n 2 127.0.0.1 >nul
|
||||||
|
call "%~dp0start.cmd"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const statusCmd = `@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
if exist "data\\server.pid" (
|
||||||
|
set /p PID=<"data\\server.pid"
|
||||||
|
powershell -NoLogo -NoProfile -NonInteractive -Command "if (Get-Process -Id !PID! -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" >nul 2>&1
|
||||||
|
if not errorlevel 1 (
|
||||||
|
echo [ClassHelper] Running ^(PID !PID!^)
|
||||||
|
powershell -NoLogo -NoProfile -NonInteractive -Command "try { $r = Invoke-WebRequest -Uri 'http://127.0.0.1:${PORT}/api/health' -UseBasicParsing -TimeoutSec 3; Write-Host ' HTTP:' $r.StatusCode } catch { Write-Host ' HTTP: unreachable' }"
|
||||||
|
echo [ClassHelper] Admin UI: http://127.0.0.1:${PORT}/
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
echo [ClassHelper] Not running.
|
||||||
|
exit /b 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceCmd = `@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
net session >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ClassHelper] Administrator privileges are required. Right-click and run as administrator.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [ClassHelper] Registering scheduled task (auto start at boot)...
|
||||||
|
schtasks /Create /TN "ClassHelperServer" /TR "\\"%~dp0node.exe\\" \\"%~dp0server\\dist\\index.js\\"" /SC ONSTART /RL HIGHEST /F
|
||||||
|
schtasks /Run /TN "ClassHelperServer"
|
||||||
|
echo [ClassHelper] Done.
|
||||||
|
echo start : schtasks /Run /TN ClassHelperServer
|
||||||
|
echo stop : schtasks /End /TN ClassHelperServer
|
||||||
|
echo remove: schtasks /Delete /TN ClassHelperServer /F
|
||||||
|
pause
|
||||||
|
`;
|
||||||
|
|
||||||
|
const unserviceCmd = `@echo off
|
||||||
|
setlocal
|
||||||
|
net session >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ClassHelper] Administrator privileges are required.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
schtasks /End /TN "ClassHelperServer" >nul 2>&1
|
||||||
|
schtasks /Delete /TN "ClassHelperServer" /F
|
||||||
|
echo [ClassHelper] Scheduled task removed.
|
||||||
|
pause
|
||||||
|
`;
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
'start.cmd': startCmd,
|
||||||
|
'stop.cmd': stopCmd,
|
||||||
|
'restart.cmd': restartCmd,
|
||||||
|
'status.cmd': statusCmd,
|
||||||
|
'install-service.cmd': serviceCmd,
|
||||||
|
'uninstall-service.cmd': unserviceCmd,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
fs.writeFileSync(path.join(staging, name), content.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeReadme() {
|
||||||
|
const content = `${PRODUCT} v${VERSION}
|
||||||
|
========================================
|
||||||
|
|
||||||
|
一、启动与访问
|
||||||
|
1. 双击「start.cmd」(或开始菜单 → 班级小助手服务端 → 启动服务)
|
||||||
|
2. 浏览器打开 http://127.0.0.1:${PORT}/ 即可进入 Web 管理端
|
||||||
|
3. 安装程序已把本服务加入当前用户的开机自启项,重启电脑后无需手动启动
|
||||||
|
|
||||||
|
二、默认账号(首次安装后请立即修改密码)
|
||||||
|
管理员 admin / admin123
|
||||||
|
教师 teacher1 / teacher123、teacher2 / teacher123
|
||||||
|
学生 student01 ~ student15 / student123(桌面客户端使用)
|
||||||
|
|
||||||
|
三、目录说明
|
||||||
|
server\\ 后端程序(server\\dist\\index.js 为入口)
|
||||||
|
web\\ Web 管理端(由后端直接托管)
|
||||||
|
data\\ 数据库文件与 PID 文件(备份时复制此目录即可)
|
||||||
|
logs\\ 预留日志目录
|
||||||
|
.env 全部配置项(端口、数据库、密钥、跨域等)
|
||||||
|
node.exe 内置 Node 运行时(无需在系统中安装 Node.js)
|
||||||
|
|
||||||
|
四、常用操作
|
||||||
|
start.cmd 启动服务并等待就绪
|
||||||
|
stop.cmd 停止服务
|
||||||
|
restart.cmd 重启服务
|
||||||
|
status.cmd 查看运行状态
|
||||||
|
install-service.cmd 注册为开机启动的计划任务(需管理员身份运行)
|
||||||
|
uninstall-service.cmd 移除计划任务(需管理员身份运行)
|
||||||
|
|
||||||
|
五、升级
|
||||||
|
1. 先执行 stop.cmd 停止服务
|
||||||
|
2. 备份 data 目录
|
||||||
|
3. 运行新版本安装程序覆盖安装(.env 会被保留,不会重置密钥与数据库)
|
||||||
|
4. 执行 start.cmd
|
||||||
|
|
||||||
|
六、安全建议
|
||||||
|
- 修改 admin / teacher 的默认密码
|
||||||
|
- 如需通过公网访问,请在前面加 Nginx/Caddy 反向代理并启用 HTTPS(见 docs/production.md)
|
||||||
|
- 定期备份 data 目录;更换 JWT_SECRET 会导致所有用户需要重新登录
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(staging, 'README.txt'), content.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||||||
|
|
||||||
|
// 许可证/说明文件(安装向导的许可页读取它)
|
||||||
|
const license = `${PRODUCT} v${VERSION} 使用许可与说明
|
||||||
|
|
||||||
|
1. 用途
|
||||||
|
本程序用于班级信息管理(班级、课表、作业、通知、成绩),由后端服务与 Web 管理端组成。
|
||||||
|
|
||||||
|
2. 运行方式
|
||||||
|
安装后将在本机启动一个 HTTP 服务(默认端口 ${PORT}),并把 Web 管理端一并托管。
|
||||||
|
该服务默认监听 0.0.0.0,同一局域网内的设备可通过本机 IP 访问;
|
||||||
|
若需公网访问,请务必在反向代理上启用 HTTPS 并修改默认密码。
|
||||||
|
|
||||||
|
3. 数据与备份
|
||||||
|
全部数据保存在安装目录的 data 目录(SQLite 文件)。定期复制该目录即为完整备份。
|
||||||
|
|
||||||
|
4. 卸载
|
||||||
|
卸载程序会停止服务并删除程序文件,但会把 data 目录重命名为 data.backup 保留,
|
||||||
|
如需彻底删除请手动删除该目录。
|
||||||
|
|
||||||
|
5. 免责
|
||||||
|
本程序按"现状"提供,请在使用前自行评估并做好数据备份。
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(staging, 'LICENSE.txt'), license.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 5. NSIS 安装程序 */
|
||||||
|
|
||||||
|
function findMakensis() {
|
||||||
|
const cacheRoot = path.join(root, '.cache', 'electron-builder');
|
||||||
|
const candidates = [];
|
||||||
|
const walk = (dir, depth = 0) => {
|
||||||
|
if (depth > 3 || !fs.existsSync(dir)) return;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(full, depth + 1);
|
||||||
|
else if (entry.name.toLowerCase() === 'makensis.exe') candidates.push(full);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(cacheRoot);
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstaller() {
|
||||||
|
const makensis = findMakensis();
|
||||||
|
if (!makensis) {
|
||||||
|
log('未找到 makensis(NSIS),跳过安装程序生成。');
|
||||||
|
log('可执行一次 pnpm dist:win 让 electron-builder 下载 NSIS,或手动安装 NSIS 后重试。');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outFile = path.join(outRoot, `${PRODUCT}-${VERSION}-x64-setup.exe`);
|
||||||
|
const templatePath = path.join(root, 'scripts', 'nsis', 'server-installer.nsi');
|
||||||
|
const script = fs
|
||||||
|
.readFileSync(templatePath, 'utf8')
|
||||||
|
.replaceAll('@PRODUCT@', PRODUCT)
|
||||||
|
.replaceAll('@VERSION@', VERSION)
|
||||||
|
.replaceAll('@STAGING@', staging)
|
||||||
|
.replaceAll('@OUTFILE@', outFile)
|
||||||
|
.replaceAll('@ICON@', path.join(root, 'build', 'icon.ico'))
|
||||||
|
.replaceAll('@PORT@', PORT);
|
||||||
|
|
||||||
|
const generatedScript = path.join(outRoot, 'server-installer.generated.nsi');
|
||||||
|
// NSIS 通过 BOM 识别 UTF-8,脚本含中文必须写入 BOM,否则报 "Bad text encoding"
|
||||||
|
fs.writeFileSync(generatedScript, `\uFEFF${script}`, 'utf8');
|
||||||
|
|
||||||
|
// 防御:若上一次的产物仍被占用(例如同时还开着安装程序),先尝试删除并给出明确提示
|
||||||
|
if (fs.existsSync(outFile)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(outFile, { force: true });
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`无法覆盖已有安装程序(可能仍在运行/被杀毒软件占用):${outFile}\n请关闭该安装程序后重试。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`生成安装程序:${path.relative(root, outFile)}`);
|
||||||
|
run(makensis, ['/V2', generatedScript], { cwd: outRoot });
|
||||||
|
return outFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 主流程 */
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
log(`版本 ${VERSION}`);
|
||||||
|
ensureBuilds();
|
||||||
|
|
||||||
|
// 迭代打包时可加 --reuse-deps 复用已安装的 node_modules,跳过 2 分钟的 npm install
|
||||||
|
const reuseDeps = process.argv.includes('--reuse-deps');
|
||||||
|
const hasDeps = fs.existsSync(path.join(staging, 'node_modules'));
|
||||||
|
|
||||||
|
if (!(reuseDeps && hasDeps)) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
log('复用已安装的 node_modules(--reuse-deps)');
|
||||||
|
// 只保留依赖安装结果,其余目录重新生成,避免残留上一次打包的文件
|
||||||
|
for (const dir of ['server', 'web', 'logs']) {
|
||||||
|
fs.rmSync(path.join(staging, dir), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.mkdirSync(staging, { recursive: true });
|
||||||
|
|
||||||
|
log('组装运行时...');
|
||||||
|
writeRuntimeManifest();
|
||||||
|
if (reuseDeps && hasDeps) {
|
||||||
|
log('跳过依赖安装');
|
||||||
|
} else {
|
||||||
|
installDependencies();
|
||||||
|
}
|
||||||
|
buildRuntime();
|
||||||
|
writeEnvironmentFile();
|
||||||
|
writeRuntimeScripts();
|
||||||
|
writeReadme();
|
||||||
|
|
||||||
|
const installer = buildInstaller();
|
||||||
|
|
||||||
|
const sizeMb = (target) => {
|
||||||
|
let total = 0;
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(full);
|
||||||
|
else total += fs.statSync(full).size;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(target);
|
||||||
|
return Math.round(total / 1024 / 1024);
|
||||||
|
};
|
||||||
|
|
||||||
|
log(`免安装目录:${path.relative(root, staging)}(${sizeMb(staging)} MB)`);
|
||||||
|
if (installer)
|
||||||
|
log(
|
||||||
|
`安装程序:${path.relative(root, installer)}(${Math.round(fs.statSync(installer).size / 1024 / 1024)} MB)`,
|
||||||
|
);
|
||||||
|
log('完成。');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 图标生成启动器:pnpm icons
|
||||||
|
* 复用 desktop-client 的 Electron 运行时渲染 SVG 并导出 PNG/ICO。
|
||||||
|
*/
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
||||||
|
|
||||||
|
if (!fs.existsSync(electronDir)) {
|
||||||
|
console.error('[icons] 未找到 Electron,请先执行 pnpm install');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathFile = path.join(electronDir, 'path.txt');
|
||||||
|
const executable = fs.existsSync(pathFile)
|
||||||
|
? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim())
|
||||||
|
: path.join(electronDir, 'dist', 'electron.exe');
|
||||||
|
|
||||||
|
const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs')], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => process.exit(code ?? 1));
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* 用 Electron 渲染 SVG 并导出应用图标(PNG + ICO)。
|
||||||
|
*
|
||||||
|
* 为什么用 Electron:可以复用系统字体渲染中文标识,且无需任何图形库/ImageMagick。
|
||||||
|
*
|
||||||
|
* 产物:
|
||||||
|
* packages/web-admin/public/icon-{192,512}.png PWA 图标
|
||||||
|
* packages/web-admin/public/apple-touch-icon.png iOS 图标
|
||||||
|
* packages/web-admin/public/favicon.svg 浏览器标签页图标
|
||||||
|
* packages/desktop-client/build/icon.png 512×512(electron-builder 用)
|
||||||
|
* packages/desktop-client/build/icon.ico 多尺寸 ICO(安装包/EXE 图标)
|
||||||
|
* build/icon.ico 服务端安装程序图标
|
||||||
|
*/
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
const SIZES = [16, 24, 32, 48, 64, 128, 180, 192, 256, 512];
|
||||||
|
|
||||||
|
/** 品牌图形:蓝色渐变圆角方块 + 白色"班"字 + 书本底纹 */
|
||||||
|
function logoSvg(size) {
|
||||||
|
const radius = Math.round(size * 0.22);
|
||||||
|
const fontSize = Math.round(size * 0.52);
|
||||||
|
const bookY = Math.round(size * 0.74);
|
||||||
|
const bookWidth = Math.round(size * 0.5);
|
||||||
|
const bookX = Math.round((size - bookWidth) / 2);
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#4facfe"/>
|
||||||
|
<stop offset="55%" stop-color="#409eff"/>
|
||||||
|
<stop offset="100%" stop-color="#1f6feb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="url(#g)"/>
|
||||||
|
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="central"
|
||||||
|
font-family="'Microsoft YaHei','PingFang SC',sans-serif" font-weight="700"
|
||||||
|
font-size="${fontSize}" fill="#ffffff">班</text>
|
||||||
|
<rect x="${bookX}" y="${bookY}" width="${bookWidth}" height="${Math.max(2, Math.round(size * 0.045))}"
|
||||||
|
rx="${Math.round(size * 0.02)}" fill="#ffffff" opacity="0.9"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlFor(size) {
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8" /><style>
|
||||||
|
html,body{margin:0;padding:0;background:transparent;width:${size}px;height:${size}px;overflow:hidden}
|
||||||
|
svg{display:block}
|
||||||
|
</style></head><body>${logoSvg(size)}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把若干 PNG 打包成 ICO 容器(ICO 允许直接内嵌 PNG 数据) */
|
||||||
|
function buildIco(entries) {
|
||||||
|
const count = entries.length;
|
||||||
|
const header = Buffer.alloc(6);
|
||||||
|
header.writeUInt16LE(0, 0); // reserved
|
||||||
|
header.writeUInt16LE(1, 2); // type: icon
|
||||||
|
header.writeUInt16LE(count, 4);
|
||||||
|
|
||||||
|
const directory = Buffer.alloc(16 * count);
|
||||||
|
let offset = 6 + 16 * count;
|
||||||
|
const payloads = [];
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const base = index * 16;
|
||||||
|
directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, base + 0); // width
|
||||||
|
directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, base + 1); // height
|
||||||
|
directory.writeUInt8(0, base + 2); // palette
|
||||||
|
directory.writeUInt8(0, base + 3); // reserved
|
||||||
|
directory.writeUInt16LE(1, base + 4); // color planes
|
||||||
|
directory.writeUInt16LE(32, base + 6); // bits per pixel
|
||||||
|
directory.writeUInt32LE(entry.data.length, base + 8);
|
||||||
|
directory.writeUInt32LE(offset, base + 12);
|
||||||
|
offset += entry.data.length;
|
||||||
|
payloads.push(entry.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.concat([header, directory, ...payloads]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSizes() {
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
show: false,
|
||||||
|
transparent: true,
|
||||||
|
frame: false,
|
||||||
|
webPreferences: { offscreen: false, backgroundThrottling: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempDir = path.join(ROOT, '.cache', 'icons');
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const pngBySize = new Map();
|
||||||
|
|
||||||
|
for (const size of SIZES) {
|
||||||
|
const file = path.join(tempDir, `logo-${size}.html`);
|
||||||
|
fs.writeFileSync(file, htmlFor(size), 'utf8');
|
||||||
|
window.setContentSize(size, size);
|
||||||
|
await window.loadFile(file);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
let buffer = (await window.webContents.capturePage({ x: 0, y: 0, width: size, height: size })).toPNG();
|
||||||
|
|
||||||
|
// 隐藏窗口在部分环境下会截到空图,短暂显示后重试
|
||||||
|
if (!buffer || buffer.length === 0) {
|
||||||
|
window.showInactive();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
buffer = (await window.webContents.capturePage({ x: 0, y: 0, width: size, height: size })).toPNG();
|
||||||
|
window.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!buffer || buffer.length === 0) {
|
||||||
|
throw new Error(`渲染 ${size}×${size} 图标失败:截图为空`);
|
||||||
|
}
|
||||||
|
console.log(`[icons] ${size}×${size} -> ${buffer.length} 字节`);
|
||||||
|
pngBySize.set(size, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.destroy();
|
||||||
|
return pngBySize;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIfChanged(file, data) {
|
||||||
|
if (!data || (Buffer.isBuffer(data) && data.length === 0)) {
|
||||||
|
throw new Error(`拒绝写入空内容:${path.relative(ROOT, file)}`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, data);
|
||||||
|
console.log(`[icons] 写入 ${path.relative(ROOT, file)} (${data.length} 字节)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
try {
|
||||||
|
const pngBySize = await renderSizes();
|
||||||
|
|
||||||
|
// Web 管理端(PWA)
|
||||||
|
const webPublic = path.join(ROOT, 'packages', 'web-admin', 'public');
|
||||||
|
writeIfChanged(path.join(webPublic, 'icon-192.png'), pngBySize.get(192));
|
||||||
|
writeIfChanged(path.join(webPublic, 'icon-512.png'), pngBySize.get(512));
|
||||||
|
writeIfChanged(path.join(webPublic, 'apple-touch-icon.png'), pngBySize.get(180));
|
||||||
|
writeIfChanged(path.join(webPublic, 'favicon.svg'), Buffer.from(logoSvg(64), 'utf8'));
|
||||||
|
writeIfChanged(path.join(webPublic, 'favicon.png'), pngBySize.get(64));
|
||||||
|
|
||||||
|
// 桌面客户端(electron-builder)
|
||||||
|
const desktopBuild = path.join(ROOT, 'packages', 'desktop-client', 'build');
|
||||||
|
writeIfChanged(path.join(desktopBuild, 'icon.png'), pngBySize.get(512));
|
||||||
|
|
||||||
|
// ICO(安装包 / EXE 图标):包含常用尺寸
|
||||||
|
const icoSizes = [16, 24, 32, 48, 64, 128, 256];
|
||||||
|
const ico = buildIco(icoSizes.map((size) => ({ size, data: pngBySize.get(size) })));
|
||||||
|
writeIfChanged(path.join(desktopBuild, 'icon.ico'), ico);
|
||||||
|
writeIfChanged(path.join(ROOT, 'build', 'icon.ico'), ico);
|
||||||
|
writeIfChanged(path.join(ROOT, 'build', 'icon.png'), pngBySize.get(512));
|
||||||
|
|
||||||
|
console.log('[icons] 全部图标生成完成');
|
||||||
|
app.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[icons] 生成失败', error);
|
||||||
|
app.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
; 班级小助手服务端 + Web 管理端 Windows 安装程序
|
||||||
|
; 由 scripts/dist-server.mjs 生成(占位符在打包时替换)
|
||||||
|
;
|
||||||
|
; 设计要点:
|
||||||
|
; - 当前用户级安装($LOCALAPPDATA\Programs),无需管理员权限即可完成安装
|
||||||
|
; - 内置 Node 运行时,目标机无需安装 Node.js
|
||||||
|
; - 快捷方式:启动/停止/重启服务、打开管理端、卸载
|
||||||
|
; - 默认加入当前用户开机自启(注册表 HKCU\...\Run),随时可用
|
||||||
|
; - 卸载时保留 data 目录(数据库)以避免误删数据
|
||||||
|
|
||||||
|
Unicode true
|
||||||
|
SetCompressor /SOLID lzma
|
||||||
|
|
||||||
|
!include "MUI2.nsh"
|
||||||
|
!include "FileFunc.nsh"
|
||||||
|
|
||||||
|
!define PRODUCT "@PRODUCT@"
|
||||||
|
!define VERSION "@VERSION@"
|
||||||
|
!define STAGING "@STAGING@"
|
||||||
|
!define OUT_FILE "@OUTFILE@"
|
||||||
|
!define ICON_FILE "@ICON@"
|
||||||
|
!define APP_PORT "@PORT@"
|
||||||
|
|
||||||
|
Name "${PRODUCT} ${VERSION}"
|
||||||
|
OutFile "${OUT_FILE}"
|
||||||
|
InstallDir "$LOCALAPPDATA\Programs\ClassHelperServer"
|
||||||
|
InstallDirRegKey HKCU "Software\ClassHelperServer" "InstallDir"
|
||||||
|
RequestExecutionLevel user
|
||||||
|
ShowInstDetails show
|
||||||
|
ShowUninstDetails show
|
||||||
|
|
||||||
|
!define MUI_ICON "${ICON_FILE}"
|
||||||
|
!define MUI_UNICON "${ICON_FILE}"
|
||||||
|
!define MUI_ABORTWARNING
|
||||||
|
!define MUI_FINISHPAGE_TITLE "安装完成"
|
||||||
|
!define MUI_FINISHPAGE_TEXT "班级小助手服务端已安装完成。$\r$\n$\r$\n安装程序已将服务加入当前用户的开机自启项,浏览器打开 http://127.0.0.1:${APP_PORT}/ 即可使用管理端。$\r$\n$\r$\n默认账号:admin / admin123(首次登录后请立即修改密码)"
|
||||||
|
!define MUI_FINISHPAGE_RUN
|
||||||
|
!define MUI_FINISHPAGE_RUN_TEXT "立即启动服务并打开管理端"
|
||||||
|
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchService
|
||||||
|
!define MUI_LICENSEPAGE_TEXT_TOP "请阅读以下说明后继续安装。"
|
||||||
|
|
||||||
|
!insertmacro MUI_PAGE_WELCOME
|
||||||
|
!insertmacro MUI_PAGE_LICENSE "${STAGING}\LICENSE.txt"
|
||||||
|
!insertmacro MUI_PAGE_DIRECTORY
|
||||||
|
!insertmacro MUI_PAGE_INSTFILES
|
||||||
|
!insertmacro MUI_PAGE_FINISH
|
||||||
|
|
||||||
|
!insertmacro MUI_UNPAGE_CONFIRM
|
||||||
|
!insertmacro MUI_UNPAGE_INSTFILES
|
||||||
|
|
||||||
|
!insertmacro MUI_LANGUAGE "SimpChinese"
|
||||||
|
|
||||||
|
Function LaunchService
|
||||||
|
ExecShell "open" "$INSTDIR\start.cmd" "" SW_SHOWMINIMIZED
|
||||||
|
Sleep 3000
|
||||||
|
ExecShell "open" "http://127.0.0.1:${APP_PORT}/"
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
; ------------------------------------------------------------------ 安装
|
||||||
|
|
||||||
|
Section "主程序" SecMain
|
||||||
|
SectionIn RO
|
||||||
|
SetOutPath "$INSTDIR"
|
||||||
|
|
||||||
|
; 若目标目录已有旧版本,先停止正在运行的服务,避免文件占用
|
||||||
|
IfFileExists "$INSTDIR\data\server.pid" 0 +3
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\stop.cmd"'
|
||||||
|
Sleep 1500
|
||||||
|
|
||||||
|
; 升级安装时保留原有配置(含 JWT 密钥与端口设置),避免用户被强制重新登录
|
||||||
|
IfFileExists "$INSTDIR\.env" 0 +2
|
||||||
|
Rename "$INSTDIR\.env" "$INSTDIR\.env.previous"
|
||||||
|
|
||||||
|
File /r "${STAGING}\*.*"
|
||||||
|
|
||||||
|
IfFileExists "$INSTDIR\.env.previous" 0 +3
|
||||||
|
Delete "$INSTDIR\.env"
|
||||||
|
Rename "$INSTDIR\.env.previous" "$INSTDIR\.env"
|
||||||
|
|
||||||
|
WriteRegStr HKCU "Software\ClassHelperServer" "InstallDir" "$INSTDIR"
|
||||||
|
WriteRegStr HKCU "Software\ClassHelperServer" "Version" "${VERSION}"
|
||||||
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
; 开始菜单
|
||||||
|
CreateDirectory "$SMPROGRAMS\${PRODUCT}"
|
||||||
|
CreateShortCut "$SMPROGRAMS\${PRODUCT}\启动服务.lnk" "$INSTDIR\start.cmd" "" "$INSTDIR\node.exe" 0 SW_SHOWMINIMIZED
|
||||||
|
CreateShortCut "$SMPROGRAMS\${PRODUCT}\停止服务.lnk" "$INSTDIR\stop.cmd" "" "$INSTDIR\node.exe" 0 SW_SHOWMINIMIZED
|
||||||
|
CreateShortCut "$SMPROGRAMS\${PRODUCT}\重启服务.lnk" "$INSTDIR\restart.cmd" "" "$INSTDIR\node.exe" 0 SW_SHOWMINIMIZED
|
||||||
|
CreateShortCut "$SMPROGRAMS\${PRODUCT}\打开管理端.lnk" "http://127.0.0.1:${APP_PORT}/"
|
||||||
|
CreateShortCut "$SMPROGRAMS\${PRODUCT}\使用说明.lnk" "$INSTDIR\README.txt"
|
||||||
|
CreateShortCut "$SMPROGRAMS\${PRODUCT}\卸载.lnk" "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
; 桌面快捷方式(管理端进浏览器)
|
||||||
|
CreateShortCut "$DESKTOP\${PRODUCT}.lnk" "http://127.0.0.1:${APP_PORT}/"
|
||||||
|
|
||||||
|
; 开机自启(当前用户)
|
||||||
|
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "ClassHelperServer" '"$INSTDIR\start.cmd"'
|
||||||
|
|
||||||
|
; 注册到"应用和功能"列表,便于通过系统设置卸载
|
||||||
|
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||||
|
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "DisplayName" "${PRODUCT} ${VERSION}"
|
||||||
|
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "DisplayIcon" "$INSTDIR\uninstall.exe"
|
||||||
|
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "DisplayVersion" "${VERSION}"
|
||||||
|
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "Publisher" "Class Helper"
|
||||||
|
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "InstallLocation" "$INSTDIR"
|
||||||
|
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||||
|
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "EstimatedSize" "$0"
|
||||||
|
|
||||||
|
; 首次启动即初始化数据库并开始提供服务
|
||||||
|
ExecShell "open" "$INSTDIR\start.cmd" "" SW_SHOWMINIMIZED
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
; ------------------------------------------------------------------ 卸载
|
||||||
|
|
||||||
|
Section "Uninstall"
|
||||||
|
; 停止服务
|
||||||
|
IfFileExists "$INSTDIR\data\server.pid" 0 +3
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\stop.cmd"'
|
||||||
|
Sleep 1200
|
||||||
|
|
||||||
|
; 兜底:按命令行特征结束残留进程
|
||||||
|
nsExec::ExecToLog 'taskkill /F /FI "WINDOWTITLE eq ClassHelperServer*"'
|
||||||
|
|
||||||
|
DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "ClassHelperServer"
|
||||||
|
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer"
|
||||||
|
DeleteRegKey HKCU "Software\ClassHelperServer"
|
||||||
|
|
||||||
|
Delete "$DESKTOP\${PRODUCT}.lnk"
|
||||||
|
RMDir /r "$SMPROGRAMS\${PRODUCT}"
|
||||||
|
|
||||||
|
; 保留数据目录,避免误删数据库:重命名为 data.backup
|
||||||
|
IfFileExists "$INSTDIR\data\classhelper.db" 0 +3
|
||||||
|
RMDir /r "$INSTDIR\data.backup"
|
||||||
|
Rename "$INSTDIR\data" "$INSTDIR\data.backup"
|
||||||
|
|
||||||
|
RMDir /r "$INSTDIR\server"
|
||||||
|
RMDir /r "$INSTDIR\web"
|
||||||
|
RMDir /r "$INSTDIR\node_modules"
|
||||||
|
RMDir /r "$INSTDIR\logs"
|
||||||
|
Delete "$INSTDIR\node.exe"
|
||||||
|
Delete "$INSTDIR\package.json"
|
||||||
|
Delete "$INSTDIR\package-lock.json"
|
||||||
|
Delete "$INSTDIR\LICENSE.txt"
|
||||||
|
Delete "$INSTDIR\start.cmd"
|
||||||
|
Delete "$INSTDIR\stop.cmd"
|
||||||
|
Delete "$INSTDIR\restart.cmd"
|
||||||
|
Delete "$INSTDIR\status.cmd"
|
||||||
|
Delete "$INSTDIR\install-service.cmd"
|
||||||
|
Delete "$INSTDIR\uninstall-service.cmd"
|
||||||
|
Delete "$INSTDIR\README.txt"
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
; 若用户未保留 .env,则一并清理目录
|
||||||
|
Delete "$INSTDIR\.env"
|
||||||
|
RMDir "$INSTDIR"
|
||||||
|
SectionEnd
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Web 管理端 UI 冒烟测试(Electron 驱动真实浏览器内核)。
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1. 首页加载(生产构建产物)
|
||||||
|
* 2. 登录页渲染与真实表单登录
|
||||||
|
* 3. 进入主布局
|
||||||
|
* 4. **侧边栏 7 个菜单逐一点击**:断言路由路径变化 + 页面标题渲染
|
||||||
|
* —— 这是"点击左侧边栏没反应"的回归测试
|
||||||
|
*
|
||||||
|
* 用法(需要后端已启动,且 Web 管理端由其托管):
|
||||||
|
* UI_SMOKE_URL=http://127.0.0.1:4000/ node scripts/ui-smoke/run.mjs
|
||||||
|
*/
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
|
||||||
|
// 使用独立(每次清空)的 userData,避免 Service Worker 缓存影响验证结果
|
||||||
|
if (process.env.UI_SMOKE_PROFILE) {
|
||||||
|
app.setPath('userData', process.env.UI_SMOKE_PROFILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARGET_URL = process.env.UI_SMOKE_URL ?? 'http://127.0.0.1:4000/';
|
||||||
|
const USERNAME = process.env.UI_SMOKE_USER ?? 'teacher1';
|
||||||
|
const PASSWORD = process.env.UI_SMOKE_PASS ?? 'teacher123';
|
||||||
|
const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
|
||||||
|
|
||||||
|
/** 侧边栏菜单:显示文案 -> 期望路由 */
|
||||||
|
const MENU_ITEMS = [
|
||||||
|
{ label: '仪表盘', path: '/dashboard' },
|
||||||
|
{ label: '班级管理', path: '/classes' },
|
||||||
|
{ label: '学生管理', path: '/students' },
|
||||||
|
{ label: '课表管理', path: '/schedules' },
|
||||||
|
{ label: '作业发布', path: '/homeworks' },
|
||||||
|
{ label: '通知发布', path: '/notifications' },
|
||||||
|
{ label: '成绩录入', path: '/grades' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function record(name, ok, detail = '') {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ` - ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在渲染进程里轮询等待条件成立 */
|
||||||
|
async function waitFor(win, expression, timeoutMs = 15000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const ok = await win.webContents.executeJavaScript(
|
||||||
|
`(() => { try { return Boolean(${expression}); } catch { return false; } })()`,
|
||||||
|
);
|
||||||
|
if (ok) return true;
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
show: false,
|
||||||
|
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 把渲染进程的报错转发到控制台(兼容 Electron 新旧事件签名)
|
||||||
|
win.webContents.on('console-message', (...args) => {
|
||||||
|
const first = args[0];
|
||||||
|
const isEventObject = first && typeof first === 'object' && 'message' in first;
|
||||||
|
const level = isEventObject ? first.level : args[1];
|
||||||
|
const message = isEventObject ? first.message : args[2];
|
||||||
|
// level: 旧版为 0..3 数字,新版为 'info' | 'warning' | 'error' | 'debug'
|
||||||
|
const isProblem = level === 'error' || level === 'warning' || level === 2 || level === 3;
|
||||||
|
if (isProblem) console.log(`[renderer] ${String(message)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[ui-smoke] 打开 ${TARGET_URL}`);
|
||||||
|
await win.loadURL(TARGET_URL);
|
||||||
|
|
||||||
|
// 1. 首页
|
||||||
|
const title = await win.webContents.executeJavaScript('document.title');
|
||||||
|
record('Web 管理端首页加载', typeof title === 'string' && title.includes('班级小助手'), `title=${title}`);
|
||||||
|
|
||||||
|
// 2. 登录页
|
||||||
|
const loginReady = await waitFor(win, `document.querySelectorAll('input').length >= 2`, 15000);
|
||||||
|
record('登录页渲染(用户名/密码输入框)', loginReady);
|
||||||
|
|
||||||
|
// 3. 填写表单并点击登录(用原生 setter + input 事件,保证 Vue 双向绑定生效)
|
||||||
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const inputs = Array.from(document.querySelectorAll('input'));
|
||||||
|
const setValue = (element, value) => {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
||||||
|
setter.call(element, value);
|
||||||
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
};
|
||||||
|
setValue(inputs[0], ${JSON.stringify(USERNAME)});
|
||||||
|
const passwordInput = inputs.find((item) => item.type === 'password') ?? inputs[1];
|
||||||
|
setValue(passwordInput, ${JSON.stringify(PASSWORD)});
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const button = Array.from(document.querySelectorAll('button')).find((item) => (item.textContent ?? '').includes('登录'));
|
||||||
|
if (!button) return false;
|
||||||
|
button.click();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
const layoutReady = await waitFor(
|
||||||
|
win,
|
||||||
|
`document.querySelectorAll('.el-menu-item').length >= ${MENU_ITEMS.length}`,
|
||||||
|
20000,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'真实表单登录并进入主布局',
|
||||||
|
layoutReady,
|
||||||
|
`菜单项=${await win.webContents.executeJavaScript(`document.querySelectorAll('.el-menu-item').length`)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!layoutReady) {
|
||||||
|
await finish(win);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 逐一点击侧边栏菜单
|
||||||
|
const visited = [];
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
for (const item of MENU_ITEMS) {
|
||||||
|
const outcome = await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
const label = ${JSON.stringify(item.label)};
|
||||||
|
const expected = ${JSON.stringify(item.path)};
|
||||||
|
const items = Array.from(document.querySelectorAll('.el-menu-item'));
|
||||||
|
const element = items.find((node) => (node.textContent ?? '').trim().startsWith(label));
|
||||||
|
if (!element) return { ok: false, path: location.pathname, title: '', reason: '菜单项未找到' };
|
||||||
|
|
||||||
|
element.click();
|
||||||
|
|
||||||
|
const deadline = Date.now() + 4000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (location.pathname === expected) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||||
|
}
|
||||||
|
// 等视图渲染
|
||||||
|
const viewDeadline = Date.now() + 3000;
|
||||||
|
while (Date.now() < viewDeadline) {
|
||||||
|
if (document.querySelector('.page-title')) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||||
|
}
|
||||||
|
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
||||||
|
return { ok: location.pathname === expected && Boolean(title), path: location.pathname, title, expected };
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
visited.push(`${item.label}→${outcome.path}${outcome.title ? `(${outcome.title})` : ''}`);
|
||||||
|
if (!outcome.ok)
|
||||||
|
failures.push(
|
||||||
|
`${item.label}(期望 ${item.path} 实际 ${outcome.path}${outcome.reason ? ` ${outcome.reason}` : ''})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
record(
|
||||||
|
`侧边栏点击导航(逐一点击 ${MENU_ITEMS.length} 个菜单)`,
|
||||||
|
failures.length === 0,
|
||||||
|
`visited=[${visited.join(' ')}]${failures.length > 0 ? ` failures=${failures.join(';')}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. SPA 深链刷新(生产环境静态托管必须支持)
|
||||||
|
await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/grades`);
|
||||||
|
const deepLinkOk = await waitFor(
|
||||||
|
win,
|
||||||
|
`document.querySelectorAll('.el-menu-item').length >= ${MENU_ITEMS.length}`,
|
||||||
|
15000,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'前端路由深链直接访问(/grades 刷新可用)',
|
||||||
|
deepLinkOk,
|
||||||
|
`path=${await win.webContents.executeJavaScript('location.pathname')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. PWA 可安装性:manifest 可获取 + Service Worker 已注册
|
||||||
|
// 注意:注入代码里不要使用 ${} 插值,避免与宿主文件的模板字符串冲突
|
||||||
|
const manifestInfo = await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
const link = document.querySelector('link[rel="manifest"]');
|
||||||
|
if (!link) return { ok: false, detail: '缺少 manifest 链接' };
|
||||||
|
const response = await fetch(link.getAttribute('href'));
|
||||||
|
const json = await response.json().catch(() => null);
|
||||||
|
const ok = response.ok && Boolean(json && json.name);
|
||||||
|
return { ok, detail: String(response.status) + ' name=' + (json && json.name ? json.name : '-') + ' icons=' + (json && json.icons ? json.icons.length : 0) };
|
||||||
|
})()`);
|
||||||
|
record('PWA manifest 可获取', Boolean(manifestInfo?.ok), String(manifestInfo?.detail ?? ''));
|
||||||
|
|
||||||
|
const swInfo = await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
if (!('serviceWorker' in navigator)) return { ok: false, detail: '不支持 Service Worker' };
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
return { ok: registrations.length > 0, detail: 'registrations=' + registrations.length };
|
||||||
|
})()`);
|
||||||
|
record('Service Worker 已注册(浏览器可安装为应用)', Boolean(swInfo?.ok), String(swInfo?.detail ?? ''));
|
||||||
|
|
||||||
|
await finish(win);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish(win) {
|
||||||
|
const failed = results.filter((item) => !item.ok);
|
||||||
|
const passed = results.length - failed.length;
|
||||||
|
console.log(`\n[ui-smoke] ${passed}/${results.length} 项通过`);
|
||||||
|
|
||||||
|
if (RESULT_FILE) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
RESULT_FILE,
|
||||||
|
JSON.stringify(
|
||||||
|
{ passed, total: results.length, ok: failed.length === 0, results, target: TARGET_URL },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
console.log(`[ui-smoke] 结果已写入 ${RESULT_FILE}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ui-smoke] 写入结果失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
win.destroy();
|
||||||
|
app.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() =>
|
||||||
|
main().catch(async (error) => {
|
||||||
|
record('UI 冒烟执行异常', false, String(error));
|
||||||
|
console.error(error);
|
||||||
|
app.exit(1);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => app.quit());
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Web 管理端 UI 冒烟测试启动器。
|
||||||
|
*
|
||||||
|
* 复用 desktop-client 里已安装的 Electron 运行时(无需额外依赖),
|
||||||
|
* 以「真实浏览器内核 + 真实点击」的方式验证 Web 管理端。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* 1) 启动后端(同时托管 Web 管理端):pnpm dev:server 或生产模式 node dist/index.js
|
||||||
|
* 2) pnpm verify:web
|
||||||
|
*
|
||||||
|
* 环境变量:
|
||||||
|
* UI_SMOKE_URL 默认 http://127.0.0.1:4000/
|
||||||
|
* UI_SMOKE_USER 默认 teacher1
|
||||||
|
* UI_SMOKE_PASS 默认 teacher123
|
||||||
|
* UI_SMOKE_RESULT 结果 JSON 输出路径(可选)
|
||||||
|
*/
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
|
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
||||||
|
|
||||||
|
if (!fs.existsSync(electronDir)) {
|
||||||
|
console.error('[ui-smoke] 未找到 Electron(packages/desktop-client/node_modules/electron)');
|
||||||
|
console.error(' 请先执行:pnpm install');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathFile = path.join(electronDir, 'path.txt');
|
||||||
|
const executable = fs.existsSync(pathFile)
|
||||||
|
? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim())
|
||||||
|
: path.join(electronDir, 'dist', 'electron.exe');
|
||||||
|
|
||||||
|
if (!fs.existsSync(executable)) {
|
||||||
|
console.error(`[ui-smoke] Electron 可执行文件不存在:${executable}`);
|
||||||
|
console.error(' 请执行:node packages/desktop-client/node_modules/electron/install.js');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = path.join(root, 'scripts', 'ui-smoke', 'main.cjs');
|
||||||
|
const target = process.env.UI_SMOKE_URL ?? 'http://127.0.0.1:4000/';
|
||||||
|
const resultFile = process.env.UI_SMOKE_RESULT ?? path.join(root, '.cache', 'ui-smoke-result.json');
|
||||||
|
const profileDir = path.join(root, '.cache', 'ui-smoke-profile');
|
||||||
|
|
||||||
|
// 每次运行前清空浏览器配置目录,避免 Service Worker / 缓存影响结果
|
||||||
|
fs.rmSync(profileDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
console.log(`[ui-smoke] 目标:${target}`);
|
||||||
|
console.log(`[ui-smoke] Electron:${executable}`);
|
||||||
|
|
||||||
|
const child = spawn(executable, [entry], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
UI_SMOKE_URL: target,
|
||||||
|
UI_SMOKE_RESULT: resultFile,
|
||||||
|
UI_SMOKE_PROFILE: profileDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
console.log(`[ui-smoke] 全部通过,结果文件:${resultFile}`);
|
||||||
|
}
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||