Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
580f5bed16 | ||
|
|
ce9bed73f4 | ||
|
|
82c85b59c1 | ||
|
|
7b1ae29774 | ||
|
|
9e52cfad4d | ||
|
|
79d73c5889 | ||
|
|
48e0c89394 | ||
|
|
5d2fa2d6b4 | ||
|
|
2be8d30376 | ||
|
|
f6edfa5ea1 | ||
|
|
985e602012 | ||
|
|
d820926491 | ||
|
|
f1edb42665 | ||
|
|
67e3961e55 | ||
|
|
9a38f86cd3 | ||
|
|
aad22df4fd | ||
|
|
572295c297 |
No files matched your search
@@ -0,0 +1,132 @@
|
||||
name: 发布 Linux 服务端安装包
|
||||
|
||||
# 为什么单独有这个 workflow:
|
||||
# Linux 包的依赖(@libsql/linux-x64-gnu、@prisma/adapter-libsql 等)是平台相关的,
|
||||
# 在 Windows 开发机上 npm install 出来的 node_modules 装到 Linux 跑不起来,
|
||||
# 所以 Linux 包必须在 Linux 上构建。这个 workflow 用 ubuntu runner 产出
|
||||
# classhelper-server-linux-x64-<版本>.tar.gz 并挂到 GitHub Release 上,
|
||||
# 服务器上的 `classhelper upgrade` 就是从这里下载 + 校验 sha256 的。
|
||||
#
|
||||
# 用法:
|
||||
# 1. 先在本地按惯例打出 Windows 包并发 Release(vX.Y.Z);
|
||||
# 2. 到 Actions 页面手动运行本 workflow,填 tag(例如 v1.1.2);
|
||||
# 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。
|
||||
#
|
||||
# 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: '要构建并挂载的 Release tag(例如 v1.1.2)'
|
||||
required: true
|
||||
type: string
|
||||
attach:
|
||||
description: '构建后上传到该 Release'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-linux-package:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
|
||||
steps:
|
||||
- name: 确定 tag
|
||||
id: ref
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
echo "attach=${{ inputs.attach }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "attach=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.tag }}
|
||||
|
||||
- name: 安装 Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.8.0
|
||||
run_install: false
|
||||
|
||||
- name: 安装依赖
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 生成 Prisma Client
|
||||
# 必须显式生成:packages/server/src/generated/prisma 是 gitignored 的,CI 上全新检出没有它。
|
||||
# 少了这一步,`tsc` 会因为 Prisma 类型解析不到而把回调参数全推成 any,报出一大片
|
||||
# "TS7006 Parameter 'item' implicitly has an 'any' type" / "TS2339 Property 'id' does not exist
|
||||
# on type '{}'" —— 看着像代码写错了,实际是缺生成物(本机因为早就生成过,永远踩不到)。
|
||||
# deploy/Dockerfile 的构建阶段有同样的步骤与注释。
|
||||
run: pnpm --filter @classhelper/server db:generate
|
||||
|
||||
- name: 构建 Linux 安装包
|
||||
run: pnpm dist:server:linux
|
||||
|
||||
- name: 展示产物
|
||||
run: ls -la releases/server/*/linux-x64/
|
||||
|
||||
- name: 上传构建产物(Artifacts)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: classhelper-server-linux-x64
|
||||
path: |
|
||||
releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz
|
||||
releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: 挂到 Release 并更新 SHA256SUMS
|
||||
if: steps.ref.outputs.attach == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.ref.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${TAG#v}"
|
||||
ARTIFACT_DIR="releases/server/${VERSION}/linux-x64"
|
||||
TARBALL="${ARTIFACT_DIR}/classhelper-server-linux-x64-${VERSION}.tar.gz"
|
||||
SHAFILE="${TARBALL}.sha256"
|
||||
SUMS="releases/SHA256SUMS-${VERSION}.txt"
|
||||
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)"
|
||||
exit 1
|
||||
fi
|
||||
# .sha256 必须跟着一起传:它是独立发布的资产,只更新 tar.gz 会让这个文件指向旧哈希,
|
||||
# 拿它校验的人会得到 mismatch(classhelper upgrade 用的是 --sha256/这个文件,不是 SHA256SUMS)。
|
||||
if [ ! -f "$SHAFILE" ]; then
|
||||
sha256sum "$TARBALL" | sed "s#${ARTIFACT_DIR}/##" > "$SHAFILE"
|
||||
fi
|
||||
|
||||
# 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。
|
||||
# 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。
|
||||
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir releases --clobber || true
|
||||
DOWNLOADED="$(ls releases/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi
|
||||
touch "$SUMS"
|
||||
grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true
|
||||
printf '%s %s\n' "$(sha256sum "$TARBALL" | awk '{print $1}')" "$(basename "$TARBALL")" >> "${SUMS}.new"
|
||||
mv "${SUMS}.new" "$SUMS"
|
||||
|
||||
gh release upload "$TAG" "$TARBALL" "$SHAFILE" "$SUMS" --clobber
|
||||
echo "已上传:"
|
||||
cat "$SHAFILE"
|
||||
cat "$SUMS"
|
||||
@@ -46,3 +46,5 @@ releases/
|
||||
# AI 浼氳瘽浜х墿锛圸Code 璁″垝/閰嶇疆锛屼笉灞炰簬浜у搧浠g爜锛�
|
||||
.zcode/
|
||||
.zcodeignore
|
||||
packages/server/prisma/backups/
|
||||
packages/server/prisma/database-settings.json
|
||||
@@ -47,20 +47,36 @@ pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白
|
||||
tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
|
||||
eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/
|
||||
.prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格
|
||||
build/icon.{ico,png} 应用图标(由 pnpm icons 生成)
|
||||
build/classhelper.{png,ico} 图标源(**设计导出、手工放入**;`pnpm icons` 从它派生其余全部图标,见 §5 第 62 条)
|
||||
scripts/
|
||||
use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema)
|
||||
generate-icons.mjs Electron 渲染 SVG → PNG/ICO
|
||||
icons/render.cjs 图标渲染(CommonJS)
|
||||
dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-server/)
|
||||
generate-icons.mjs 启动 Electron 跑 icons/render.cjs(= `pnpm icons`)
|
||||
icons/render.cjs 把 classhelper.png 重采样成各端图标(CommonJS,见 §5 第 62 条)
|
||||
dist-server.mjs 服务端 + Web 管理端打包(产物落在 releases/server/<版本>/)
|
||||
nsis/server-installer.nsi 服务端 NSIS 安装脚本
|
||||
verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟
|
||||
check-icons.mjs 图标注册表静态门禁(漏注册会静默渲染空白,见 §5 第 53 条)
|
||||
ui-smoke/{run,main.cjs,live-probe.mjs} Web 管理端真实点击回归(Electron 驱动)
|
||||
lib/{electron-env,node-runtime}.mjs 启动 Electron / 定位真实 node.exe
|
||||
deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / install-linux.sh
|
||||
classhelper.service / package.runtime.json / .env{,.sqlite}.example
|
||||
docs/ production.md(生产部署)/ mysql.md(MySQL 切换)/ winisland-design-tokens.md
|
||||
deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / package.runtime.json
|
||||
install.sh(Linux 一键安装器,TUI)/ install-linux.sh(转发壳)
|
||||
classhelper(运维命令本体)/ verify-linux.sh(Linux 验收,自统计 N/N)
|
||||
tools/admin-cli.mjs(离线改密 / SQLite 一致性快照)
|
||||
profile.d/ logrotate.d/ systemd/(装到 /etc 的模板)
|
||||
Dockerfile.linux-package(安装包内那份,--mode docker 用)
|
||||
classhelper.service(systemd 模板,5 个占位符)/ .env{,.sqlite}.example
|
||||
docs/ production.md(生产部署)/ linux-deploy.md(Linux 部署与运维)
|
||||
mysql.md(MySQL 切换)/ winisland-design-tokens.md
|
||||
screenshots/{island,client,web-mobile,classisland}/
|
||||
website/ 产品官网(**纯静态、无构建步骤**,不进 pnpm workspace)
|
||||
index.html / assets/{styles.css,motion.js,main.js,icon.png,shots/}
|
||||
serve.mjs(本地预览)/ tools/capture-shots.mjs(实机图采集)
|
||||
⚠ assets/motion.js 是动效层:曲线与弹簧 token 逐条取自 beUI
|
||||
(beui.dev/components/motion,见 README §7「官网」)。
|
||||
改官网动效请改 token 表,不要在组件里另写 duration / cubic-bezier。
|
||||
⚠ assets/shots/ 里全是**跑起来截的**实机图,重采集步骤见 README
|
||||
§7「采集实机图」;客户端那两张要配合冒烟的
|
||||
ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。
|
||||
packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts
|
||||
packages/server/
|
||||
prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
|
||||
@@ -69,27 +85,27 @@ packages/server/
|
||||
homework_assign_date / push_kind / schedule_source)
|
||||
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
|
||||
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
|
||||
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,实测 173 项)
|
||||
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-05 实测 190 项)
|
||||
scripts/verify-classisland.mjs 联动链路验收(实测 42 项)
|
||||
scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用)
|
||||
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
|
||||
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
|
||||
src/config/env.ts zod 环境变量校验 + 生产自检
|
||||
src/lib/ access(RBAC) class-account db db-bootstrap http jwt logger mappers
|
||||
password schemas session term web-static
|
||||
password schemas session term version(读 package.json) web-static
|
||||
src/middleware/ auth validate error security
|
||||
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
|
||||
src/modules/ 13 个功能模块 + registry.ts + module.types.ts
|
||||
packages/web-admin/src/ api stores router layouts views(11) components(4) composables styles config.ts
|
||||
src/modules/ 15 个功能模块 + registry.ts + module.types.ts
|
||||
packages/web-admin/src/ api stores router layouts views(12) components(5) composables styles config.ts
|
||||
packages/desktop-client/
|
||||
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / logger / smoke
|
||||
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
|
||||
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
|
||||
src/island/ 灵动岛渲染进程:IslandApp.vue / spring.ts / squircle.ts / main.ts
|
||||
src/renderer/ 学生端渲染进程:api / cache(IndexedDB) / stores / views / island / router
|
||||
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
|
||||
scripts/ build-main / dev / smoke / dist-win
|
||||
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
|
||||
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=0.1.0.0
|
||||
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.1.2.0
|
||||
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
|
||||
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
|
||||
src/Models/PluginSettings.cs
|
||||
@@ -171,9 +187,10 @@ pnpm build:classisland-plugin
|
||||
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
|
||||
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) |
|
||||
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` |
|
||||
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` |
|
||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||
| `pnpm lint` / `pnpm lint:fix` | ESLint |
|
||||
| `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) |
|
||||
| `pnpm format` / `pnpm format:check` | Prettier(`format:check` 在 master 基线上本就失败,见 §7 第 21 条) |
|
||||
| `pnpm db:generate` | 生成 Prisma Client |
|
||||
| `pnpm db:migrate` | `prisma migrate dev`(本机不可靠,别用;见 §7) |
|
||||
@@ -181,14 +198,15 @@ pnpm build:classisland-plugin
|
||||
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
|
||||
| `pnpm db:studio` | Prisma Studio |
|
||||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/mysql.md`) |
|
||||
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-09-30 实测 173 项全过) |
|
||||
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-05 实测 190 项全过) |
|
||||
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
|
||||
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
|
||||
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
|
||||
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 42 项) |
|
||||
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) |
|
||||
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
|
||||
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
|
||||
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
|
||||
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 界面内品牌标) |
|
||||
|
||||
### 端口
|
||||
|
||||
@@ -200,32 +218,52 @@ pnpm build:classisland-plugin
|
||||
|
||||
### 交付产物归集
|
||||
|
||||
打包脚本各自输出到固定目录,**归集到仓库根 `releases/` 是本仓库的人工约定**(该目录 gitignore + eslint ignore):
|
||||
**三个打包脚本都直接输出到仓库根 `releases/<组件>/<版本>/`,没有中间输出目录**
|
||||
(该目录 gitignore + eslint ignore),人工归集这一步已经取消:
|
||||
|
||||
```
|
||||
release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序)
|
||||
packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe)
|
||||
releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/)
|
||||
releases/
|
||||
client/<版本>/
|
||||
安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||
免安装/ electron-builder 的 win-unpacked 内容
|
||||
构建中间/ builder-*.yml、*.nsis.7z
|
||||
server/<版本>/
|
||||
安装包/ 班级小助手服务端-<版本>-x64-setup.exe
|
||||
免安装/ classhelper-server 免安装目录(含 .env、node.exe、data/、logs/)
|
||||
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256 + 同内容解包目录
|
||||
构建中间/ server-installer.generated.nsi
|
||||
classisland-plugin/<版本>/
|
||||
安装包/ ClassHelper.ClassIslandPlugin.cipx
|
||||
免安装/ ClassHelper.ClassIslandPlugin/(解压即用,丢进 Plugins 目录即可加载)
|
||||
checksums.md
|
||||
SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护)
|
||||
```
|
||||
|
||||
版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐);
|
||||
重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的
|
||||
不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。
|
||||
|
||||
```bash
|
||||
# 客户端:electron-builder 支持指定输出目录(dist-win.mjs 会把额外参数透传)
|
||||
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
|
||||
|
||||
# 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/
|
||||
pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过
|
||||
|
||||
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
|
||||
pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
|
||||
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
|
||||
pnpm dist:win # 客户端:安装包 + 免安装目录;dist:dir 只出免安装目录
|
||||
pnpm dist:classisland-plugin
|
||||
```
|
||||
|
||||
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
|
||||
再把原始布局整理成上面的分层(`win-unpacked/` → `免安装/`、`*.exe` → `安装包/`、`*.yml` → `构建中间/`);
|
||||
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
|
||||
**交付物的完整性标签修正(§7 第 7 条)也挂在这条收尾链路上,别把 `tidyArtifacts` 之后的
|
||||
`normalizeIntegrityLabel(destDir)` 拆掉。**
|
||||
|
||||
---
|
||||
|
||||
## 5. 架构硬约定(改代码前必须知道)
|
||||
|
||||
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
|
||||
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 13 个:`auth / classes / courses / schedules /
|
||||
homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard`。
|
||||
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 15 个:`auth / classes / courses / schedules /
|
||||
homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard /
|
||||
database / update`。
|
||||
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
|
||||
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
|
||||
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
|
||||
@@ -360,7 +398,10 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
主进程把窗口**贴合岛体**(`windowBox` = 当前形态 + 2×阴影留白)并**始终接收输入**
|
||||
(`setInteractive` 在触摸模式下把入参强制为 true)。三条护栏别动:
|
||||
|
||||
- **变小要等岛真的收小**(`cardFitsFormSize` + 渲染进程上报的岛体矩形)—— 立刻缩小会把形变中的卡片裁掉;
|
||||
- **窗口可见期间只增不减**:`syncTouchLayout()` 里 `if (shrinking && this.win.isVisible()) return;`
|
||||
—— 需要变小一律等窗口隐藏之后再做(`syncWindow()` 的"不可见时对齐包围盒"那条)。立刻缩小既会裁掉
|
||||
形变中的卡片,又会因为 DWM 还没重画桌面而留下上一帧(用户拍照反馈过"收回一瞬间闪一下");
|
||||
(注:`cardFitsFormSize()` 这个"按岛体矩形判断能不能缩"的旧判据**已无调用**,别再照它理解现状。)
|
||||
- `expectedHitRect` 必须按**窗口实际尺寸**算偏移(触摸模式下窗口不再是固定包围盒);
|
||||
- 失焦宽限期用 `BLUR_GRACE_TOUCH_MS`(1200ms):触摸没有光标,`isCursorOnIsland()` 恒为 false,
|
||||
而实测假失焦出现在 70~520ms(跨过 500ms 边界 ⇒ 会把刚展开的岛缩回胶囊)。
|
||||
@@ -395,9 +436,9 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
长高发生在卡片形变**之前**(`setState` 里先 `relayout()` 再 `emit()`),用户看不到这一步;
|
||||
- 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量**一一对应**,
|
||||
改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。
|
||||
回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。
|
||||
写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**,
|
||||
否则量到的是列表高度。
|
||||
回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。
|
||||
写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**,
|
||||
否则量到的是列表高度。
|
||||
|
||||
34. **"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。**
|
||||
收起一律是**主进程单向**的:`handleAction()` 收到 `dismiss` / `dismiss-all` / `mark-read` /
|
||||
@@ -427,18 +468,19 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
回执拿到 id 后再补记一次,投递前用 id / 指纹双重判定。改作业录入入口时别忘了在请求前调
|
||||
`markHomeworkCreatedLocally()`。回归用例:「本机录入的作业不再上灵动岛」。
|
||||
|
||||
36. **触摸模式下收回动画期间,窗口必须一动不动**(`syncTouchLayout()` 开头 `if (this.closing) return;`)。
|
||||
36. **触摸模式下收回动画期间,窗口必须一动不动**(窗口可见期间一律不缩窗口,见 `syncTouchLayout()`)。
|
||||
触摸模式窗口是"贴合岛体"的,而收回时卡片正在缩小:若跟着缩窗口,**中心停靠下窗口 x 还要右移**,
|
||||
"让出去"的那块区域会留着上一帧(DWM 还没重画桌面)——同一帧里能看到"旧展开卡的左半截 + 新胶囊"
|
||||
两份画面(用户拍照反馈的"收回一瞬间闪一下")。窗口尺寸一律等**隐藏之后**再对齐
|
||||
(`syncWindow` 里"不可见时对齐包围盒"那条,不可见时改尺寸不会有残影)。
|
||||
回归用例:「触摸模式收回:动画期间窗口不动」逐帧采样窗口矩形(只在"窗口可见 / 正在收回"时追究,
|
||||
收完隐藏后对齐尺寸是正常的);「收回动画期间卡片不被窗口裁切」。
|
||||
另:`cardFitsFormSize()` 的判据里**不能**再塞 `closing` —— 那条早退已经把收回整个排除了,
|
||||
早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断");
|
||||
而"隐藏态改边距不生效"那条修法仍要保留:`this.win.isVisible() && (mode !== 'hidden' || idleSliver)`。
|
||||
另:**别再去代码里找 `closing` 或 `cardFitsFormSize()`** —— 自研的那层"收回快照"(§5 第 34 条已说明
|
||||
整体删除)与"按岛体矩形判断能否缩小"这个旧判据都已不在调用链上,最终判据只有上面那条
|
||||
`shrinking && isVisible` 早退。"隐藏态改边距不生效"则是由 `setAppearance()` 无条件 `relayout()`
|
||||
加 `applyWindowLayout()` 的"矩形没变就不调 setBounds"守卫保证的,同样不需要额外的可见性判断。
|
||||
|
||||
40. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动;
|
||||
37. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动;
|
||||
胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
|
||||
(用户反馈的"新消息那行字往右跳一下再缩回")。所以 `IslandApp.vue` 的胶囊层里套了一层
|
||||
`.pill-inner`,**固定成胶囊自己的几何**(`width: var(--island-w)`),并按停靠方式对齐
|
||||
@@ -446,7 +488,7 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。
|
||||
回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。
|
||||
|
||||
41. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
|
||||
38. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
|
||||
`verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端
|
||||
默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为
|
||||
**"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。
|
||||
@@ -457,35 +499,427 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
**只改颜色、不要改停靠位置**:命中/几何断言都是按默认位置写的,把岛挪到角落会把
|
||||
「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。
|
||||
另外:冒烟会**真发通知**给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/
|
||||
岛上会短暂出现几条「张老师 · *自检*」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`),
|
||||
岛上会短暂出现几条「张老师 · _自检_」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`),
|
||||
若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。
|
||||
|
||||
42. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟,
|
||||
39. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟,
|
||||
而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着,
|
||||
他点到的是**冒烟这个窗口**:实测冒烟收到了人发出的 `collapse` / `mark-read`,把「点胶囊展开」
|
||||
「点空白处收起」等用例整片弄红。开关只忽略**真实**输入:`cursorOverride`(`setHitTestCursor`)
|
||||
一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」
|
||||
这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。
|
||||
|
||||
37. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读
|
||||
40. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读
|
||||
一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件
|
||||
无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watch `route.query.date`
|
||||
(`HomeworkView.vue`,与 `selectDate` 的 `router.replace` 形成环时靠"值相同就跳过"防死循环)。
|
||||
同理,冒烟里"切到有作业的日期"的判据是**看板里没有 `.board-card`**,不是"没有 `.board-host`"
|
||||
(空看板照样渲染容器,拿容器当判据兜底永远不会触发)。
|
||||
|
||||
38. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true`
|
||||
41. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true`
|
||||
(上课时段普通通知会被**正确地**暂存到下课、紧急通知不带确认会被 409 `URGENT_DURING_CLASS` 拒掉 ——
|
||||
两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律**轮询**等待,
|
||||
不要写死 sleep:`dismiss` 现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空
|
||||
(「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。
|
||||
|
||||
39. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境):
|
||||
42. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境):
|
||||
① **后端起没起** —— 客户端配置指向 `127.0.0.1:4000`,后端不在就进离线模式、数据全空,
|
||||
观感就是"软件坏了";② **托盘单实例锁** —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被
|
||||
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
|
||||
完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。
|
||||
|
||||
43. **Web 端新手引导(`el-tour`)在自动化里的判据有三条坑**(2026-10-02 实测,`verify:web`
|
||||
「首次登录展示新手引导且可跳过」踩过):
|
||||
① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立,
|
||||
急着找 `.el-tour__closebtn` 会拿到 null;出现判据要等 **× 按钮渲染出来**(等 10s 不为过)。
|
||||
② **关闭后内容元素残留** —— 和 el-dialog 一样(本表第 22 条),点 × 生效后 `.el-tour` 仍在 DOM
|
||||
(父级 `.el-popper` 被隐藏,computed display 仍是 block);**关闭判据是 `.el-tour__mask` 卸载**,
|
||||
不是 `.el-tour` 消失。跳过 / 走完都会写 `classhelper.onboarding`(版本号),可顺带断言。
|
||||
③ **全高元素(侧边栏)作锚点必须显式 `placement: 'right'`** —— 默认 bottom 会把卡片推到
|
||||
视口外(表现为"遮罩挖了洞、卡片不见了")。
|
||||
另外 `ui-smoke` 管理员用例里的 `localStorage.clear()` 会把「已看过」标记一并清掉,管理员登录后
|
||||
引导会再次弹出 —— 该用例里必须先跳过它再继续,别只处理首次登录那一次。
|
||||
|
||||
44. **数据库管理模块(服务端)** 见 §5 第 36 条;做"切库/备份/迁移"类改动前先读那一条,
|
||||
那里写清了快照格式、外键陷阱、切换为什么必须走子进程、以及打包为何要带上 prisma CLI + tsc。
|
||||
|
||||
45. **客户端设置页已拆成子路由**(2026-10-02):`/settings/{general,appearance,island,reminder,account,about}`,
|
||||
`/settings` 重定向到 `/settings/general`;旧的单一 `views/SettingsView.vue` **已删除**。
|
||||
改设置相关冒烟用例时必须改到对应子路由(`smoke.ts` 里 `location.hash = '#/settings'` 已全部改掉),
|
||||
否则会停在通用页、找不到灵动岛滑块或「ClassIsland 联动」卡片而误报失败。
|
||||
|
||||
46. **深色主题的实现边界**:颜色一律走 `--ch-*` / `--el-*` 变量(`styles/index.css` 的 `:root`
|
||||
是浅色值、`html.dark` 是深色值),**组件里不要写死颜色**。已知的固定深色区域(灵动岛、
|
||||
登录页、App 启动过渡页、全屏作业看板)刻意不随主题 —— 改它们会被当成回归。
|
||||
主题状态在 `stores/ui.ts`(持久化到主进程配置 `theme`),主窗口 `backgroundColor` 也按主题设置
|
||||
(深色下若仍用浅色底会"先白后黑"闪一下)。
|
||||
|
||||
**两端都有深色主题,机制一致**(Web 管理端 2026-10-06 补齐):`html.dark` + element-plus 的
|
||||
`dark/css-vars.css` + 两端各自的 `stores/ui.ts`。语义色令牌是
|
||||
`--ch-surface` / `--ch-surface-alt` / `--ch-surface-sunken` / `--ch-surface-hover` 与
|
||||
`--ch-text` / `-secondary` / `-muted` / `-placeholder`;**`html.dark` 只覆盖颜色,
|
||||
绝不覆盖圆角与动效令牌** —— 两套主题的几何必须完全一致,否则切换时会看到布局跳一下。
|
||||
Web 端还要一并同步 `<meta name="theme-color">` 与 `color-scheme`(后者管原生控件:
|
||||
滚动条、日期选择器弹层 —— 只改 CSS 变量的话它们仍是浅色)。
|
||||
默认**跟随系统**(`prefers-color-scheme`,系统改了就跟着变);用户手动点过之后以用户为准。
|
||||
切换入口两端都在顶栏,都走 `useThemeReveal()`(见第 61 条)。
|
||||
改令牌时只改值 —— **不要对样式表跑批量 sed**,理由见第 61 条末尾。
|
||||
|
||||
47. **数据库管理模块(`/api/database/*`,仅 ADMIN)——改它之前必须知道的四件事**:
|
||||
① **一切都建立在 `lib/snapshot.ts` 的 JSON 快照上**(备份 / 导入导出 / 跨库迁移共用一种格式,
|
||||
14 张表按拓扑序导出、恢复时临时关外键检查、分批 `createMany`)。恢复时**必须**先关外键检查:
|
||||
schema 里 `User.classId → Class` 与 `Class.teacherId → User` **互相引用**,任何排序都会被一侧卡死。
|
||||
② **一键切换必须走子进程**:运行中的服务进程缓存的是旧方言的 Prisma Client,改完 provider 后
|
||||
只有**新进程**才能加载重新生成的客户端 —— 因此 `generate` / `db push` / 写数据分别用
|
||||
`spawn(process.execPath, ...)` 拉起(数据写入走 `dist/tools/apply-snapshot.js`)。
|
||||
**顺序不能换**:备份 → 导出 → 测试连接 → 校验目标库为空 → 改 schema → generate → 编译 → db push → 写数据 → 改 .env;
|
||||
`.env` 只在**全部成功后**才改写,中途失败回滚 `schema.prisma`(现有库不受影响)。
|
||||
③ **`prisma generate` 的产物是 `.ts`**(`prisma-client` 生成器),所以:dev 由 tsx 直接跑 src;
|
||||
**打包形态必须再跑一次 `tsc -p tsconfig.generate.json`** 把新客户端编进 `dist/generated`,
|
||||
生产包因此要随包带上 `prisma` CLI、`typescript` 与 `schema.prisma`/`prisma.config.ts`
|
||||
(`scripts/dist-server.mjs` 的 `buildRuntime` 已拷;安装包 ~34MB → ~71MB)。
|
||||
④ **主库只支持 SQLite / MySQL**:Redis 等键值库 Prisma 不支持(无法建表/迁移),
|
||||
schema 层直接 422 拒绝;这是产品口径,别"顺手"加上。
|
||||
e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空),**不真跑切换**(会改写 .env);
|
||||
真实切换用子进程链路单独实测(快照 728 行逐表一致)。切换后**必须重启服务端**才生效。
|
||||
|
||||
48. **客户端主窗口初始尺寸与 ClassIsland 对齐(1242×582)**。这个数不是拍的:用
|
||||
`GetWindowRect` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` 量运行中的
|
||||
`ClassIsland.Desktop` 主窗口(实测可视区 1242×582,屏幕工作区 1600×852、DPI 100%)。
|
||||
`main/index.ts` 的 `createWindow` 按它设 width/height + `center: true`,
|
||||
**`minHeight` 必须 ≤ 582**(否则窗口达不到目标高度,用户会看到"设置不了那个大小")。
|
||||
冒烟断言:「初始窗口尺寸与 ClassIsland 对齐(1242x582)」(`win.getSize()`),改尺寸即红。
|
||||
|
||||
49. **侧栏是"一级项 + 一个设置分组",折叠后是图标导轨 —— 五条硬要求**,都是用户报过的问题固化的:
|
||||
① **菜单结构**:课表 / 作业 / 通知 / 成绩是**一级 `el-menu-item`**(2026-10-05 按用户要求把原来的
|
||||
「学习」分组拆掉:分组标题白占一行、还得多点一次展开,折叠成导轨时尤其明显);只有「设置」
|
||||
保留 `el-sub-menu`(6 个子项平铺太长)。`layoutNavigationSelfTest` 里有断言盯着
|
||||
"一级项不能还挂在任何 `.el-sub-menu` 里"。
|
||||
② **折叠是 CSS 收拢动画,不用 `el-menu` 的 `:collapse`** —— 那个 prop 会把每个分组的子项
|
||||
卸载进浮层(一次结构切换)并让整条菜单重排一遍:既做不出连续形变,也是"点汉堡卡一下"的渲染侧来源。
|
||||
现在折叠只做三件事:栏宽(`.aside` 的 `width` 过渡)、文字(`min-width:0` 让 flex 压得动 +
|
||||
`overflow:hidden` 裁切 + `opacity` 淡出)、图标位置(`padding-left` 过渡)。**全程没有结构变化、
|
||||
没有 `v-if` 增删**,导轨里分组标题与子项都以图标列出(**不再有悬停浮层**)。
|
||||
时长/缓动/图标-文字间距统一在 `styles/index.css` 的 `--nav-dur` / `--nav-ease` / `--nav-gap`。
|
||||
③ **折叠态图标必须与顶部汉堡按钮同一竖列**(冒烟断言中心差 ≤ 2px,实测 11 个图标全 0.0px)。
|
||||
几何:64px 栏、菜单项左右各 8px margin、折叠后 padding 左右各 12px ⇒ 图标中心 = 8+12+12 = 32px
|
||||
= 64/2;汉堡靠 `padding-left: 18px` 位移到位(**不能用 `justify-content: center`**,它不可过渡)。
|
||||
④ **导轨必须"一眼看全",不许出现滚动**(用户要求"不要用滚轮、删掉滚动条"):
|
||||
- 折叠态页脚要**把高度也收掉**(`.is-collapsed .aside-footer` 的 `max-height: 0` +
|
||||
**`min-height: 0`** —— flex 子项默认 `min-height: auto`,而 CSS 里 min-height 优先于 max-height,
|
||||
不显式归零就压不下去)。留着那 ~70px 的话,11 行图标(462px)在 540px 的最小窗口里放不下。
|
||||
- 导轨里用 `.is-collapsed .menu { scrollbar-width: none }` 隐藏滚动条;展开态**不隐藏** ——
|
||||
那一栏确实会超(设置组展开约 606px > 582px),滚动条是"下面还有内容"的唯一提示。
|
||||
- 冒烟断言「导轨无溢出」(先展开设置组造出 11 行的最坏情况,再断言 `scrollHeight ≤ clientHeight`)。
|
||||
⑤ **折叠态文字不能"看得见"** —— 判据已从"没被布局"(老的 `display:none`)改成"看得见"
|
||||
(有宽度且未透明):现在靠 `overflow:hidden` 裁切,文字仍参与布局。冒烟断言「文字残影=无」。
|
||||
(另:菜单区仍要 `min-height: 0` + `overflow-y: auto`,footer 是 aside 的兄弟 flex 子项,
|
||||
菜单区滚动后它恒定可见 —— 冒烟断言「侧栏底部可见」。)
|
||||
|
||||
改折叠相关样式时的两个坑:
|
||||
- **压 Element Plus 的层级缩进必须照抄它的选择器形状**:EP 用**三个不同选择器**分别算
|
||||
「一级项 20px / 分组标题 20px / 分组内子项 40px」(特异度 0-4-0 / 0-4-0 / 0-5-0):
|
||||
`.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
|
||||
(把 `…__title`、以及多加一层 `.el-sub-menu` 的变体都算上)。写成
|
||||
`.ch-nav.is-collapsed .el-sub-menu__title` 这种 0-3-0 的一条都压不住 —— **踩过两次**:
|
||||
第一版漏了分组标题(偏 8.0px),把「学习」拆成一级项后又漏了一级项(同样偏 8.0px)。
|
||||
现在 `index.css` 里那两条(0-5-0 / 0-6-0)把三种形状全覆盖。
|
||||
- 图标与文字的间距放在**文字的 `margin-left`** 上,别用 EP 给图标挂的 `margin-right: 5px`
|
||||
(那会让图标在 24px 的槽里偏左 2.5px)。
|
||||
|
||||
另:分组**默认只展开当前路由所在分组**(落在四个一级项上时一个都不展开);
|
||||
`el-sub-menu__title` 的点击是 **toggle**,自动化里要先判"子项是否可见"再决定点不点标题
|
||||
(盲点会把已展开的组收起来,后续点不到子项)。
|
||||
|
||||
50. **深色主题:渲染进程里除固定深色区域外,颜色一律走 `--ch-*` 变量。**
|
||||
§5 第 46 条讲了机制,这里补具体踩坑:课表页时间轴卡片曾写死 `rgba(255, 255, 255, 0.6)`,
|
||||
深色主题下变成"浅灰卡 + 近白字"几乎不可读(用户截图)。自查命令:
|
||||
`grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer`,
|
||||
除**明确固定深色**的区域(全屏作业看板、登录页渐变、App 启动过渡页、灵动岛自身、
|
||||
以及 accent 渐变底上的白字)外,都应该是变量。
|
||||
|
||||
51. **跑 `verify:desktop` / `verify:packaged` 若大面积报"教师登录失败",先核对本机 teacher1 的密码**:
|
||||
本机实测是 **`123456`**,不是种子默认的 `teacher123` —— 因为「修改密码」弹窗留空会落到
|
||||
`DEFAULT_TEACHER_PASSWORD`(=123456),某次人工/脚本操作就会把演示账号改成它。
|
||||
处理方式是用环境变量传实际凭据(脚本原生支持,**不要去改数据**):
|
||||
`set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop`。
|
||||
|
||||
**注意两个演示账号的口令不一定一起漂**(2026-10-04 实测):本机 `teacher1 = 123456`、
|
||||
`teacher2` **仍是 `teacher123`**。因此 `verify:e2e`(口令硬编码、不支持环境变量)不能简单地
|
||||
全局替换 `teacher123` —— 那样 teacher2 会 401,直接挂掉 9 条科任老师用例。
|
||||
正确做法是**只替换 teacher1 那一行**的临时副本,跑完即删:
|
||||
|
||||
```bash
|
||||
sed "/username: 'teacher1'/s/'teacher123'/'123456'/" packages/server/scripts/verify-e2e.mjs \
|
||||
> packages/server/scripts/.verify-e2e-local.tmp.mjs
|
||||
node packages/server/scripts/.verify-e2e-local.tmp.mjs # 副本必须放在 scripts/ 内
|
||||
rm packages/server/scripts/.verify-e2e-local.tmp.mjs
|
||||
```
|
||||
|
||||
副本**必须放在 `packages/server/scripts/` 里**(不能放 `.cache/`):脚本要 `import 'socket.io-client'`,
|
||||
换个目录就 `ERR_MODULE_NOT_FOUND`。
|
||||
|
||||
52. **更新检查:三端共用一套版本口径,但检查路径与"失败即正常"是硬约束。**
|
||||
① **版本号单一来源**:服务端版本由 `lib/version.ts` 读 package.json 得到
|
||||
(打包后读的是运行时清单,即 `dist-server.mjs` 用根 package.json 写的那个),
|
||||
`app.ts` 里**不要再写字面量版本号**(原先有三处 `'1.0.0'`,而仓库里有 5 份 package.json,必漂)。
|
||||
比较逻辑放 `@classhelper/shared` 的 `compareVersions` / `isNewerVersion`,三端共用。
|
||||
② **检查路径不对称是 CSP 逼的,不是随手选的**:Web 端**不能**直连 GitHub
|
||||
(`securityHeaders()` 的 `connect-src` 只有 `'self'` / ws / CORS_ORIGIN),必须走
|
||||
`GET /api/update/check`;客户端在主进程直连 GitHub(渲染进程的 CSP 与导航防线不要为这个开洞)。
|
||||
③ **`ok:false` 是设计内的结果**:机房与教室机器常常只有内网,离线/超时/限流一律收敛成
|
||||
`ok:false` + 一句人话,**失败结果同样进缓存**(否则断网点一次按钮就白等一个超时)。
|
||||
④ **自动化里绝不查外网**:冒烟跳过启动自动检查,`verify:*` 的断言只验"结构完整 + 能降级",
|
||||
断言"一定有新版本"会把用例绑死在外网可达上。`?force=1` 绕过缓存**只对管理员生效**,
|
||||
否则任意登录用户都能刷爆 GitHub 匿名限流(60 次/小时/IP)。
|
||||
|
||||
53. **Element Plus 图标按需注册,漏注册有构建门禁。** 两端都不再 `import * as ElementPlusIconsVue`
|
||||
全量注册 293 个图标(实测全量打包 minified 203KB,实际只用 22 / 33 个),而是各有一份
|
||||
`packages/web-admin/src/icons.ts` 与 `packages/desktop-client/src/renderer/icons.ts` 显式列名单。
|
||||
**不能改成"模板里逐个 import"**:本项目的图标是按**字符串名**引用的(菜单/路由配置里的
|
||||
`icon: 'Odometer'`、`:icon="'Search'"`、`<component :is="item.icon">`),Element Plus 会用
|
||||
`<component :is="'Odometer'">` 去解析**全局组件名**,图标必须以全局组件存在。
|
||||
漏注册的后果是**界面静默渲染成空白**(只在控制台一条 warning),所以在构建前加了静态门禁
|
||||
`scripts/check-icons.mjs`(已挂进两端 `build` 脚本,根上也可 `pnpm check:icons`):
|
||||
它对比"源码里用到的图标 ∪"与"注册表里的名单",有遗漏即 exit 1 并指出出现位置。
|
||||
新增视图时直接用图标名即可,忘了加进注册表会在构建阶段被拦下。
|
||||
|
||||
54. **服务端 dist 是运行产物,不带声明与 sourcemap。** `packages/server/tsconfig.build.json` 与
|
||||
`tsconfig.generate.json` 都显式关掉 `declaration / declarationMap / sourceMap` 并开 `removeComments`
|
||||
—— 根 `tsconfig.base.json` 默认是给"库"用的(三个都开),照搬到服务端会让 dist 从约 0.7MB
|
||||
膨胀到 3.8MB(其中 `.d.ts` 1.5MB + `.d.ts.map` 1.1MB + `.js.map` 0.5MB,全是运行时用不到的)。
|
||||
需要 sourcemap 调试时用 `tsconfig.json`(`noEmit`)即可。
|
||||
**`tsconfig.generate.json` 还必须是自包含的(不 extends 任何配置)**:它随包发布到安装目录的
|
||||
`server/` 一层,而 `tsconfig.json` / `tsconfig.base.json` 两级基配置都不在包里 —— 一旦 extends
|
||||
就会在打包形态下报 `TS5083 Cannot read file .../server/tsconfig.json`,数据库「一键切换」
|
||||
走到"编译新客户端"这一步直接失败(2026-10-05 实测踩到并修掉)。
|
||||
|
||||
55. **服务端安装包的体积靠"运行时裁剪"压,规则写在 `scripts/dist-server.mjs` 的 `pruneRuntime()`。**
|
||||
打包顺序是 `installDependencies() → buildRuntime() → pruneRuntime() → buildInstaller()`,
|
||||
裁剪在最后一份文件就位、打包之前做。当前四条规则与收益(实测删 2329 项 / 省 101MB):
|
||||
|
||||
| 规则 | 省 | 依据(改之前先看这里) |
|
||||
| ---------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@prisma/client/runtime` 白名单,只留 sqlite/mysql 的 ESM 方言包 | 61.4MB | 生成的客户端按 **provider 写死**动态 import(`internal/class.ts`),换库重新 generate 只会指向对应方言;`_small_bg` 与 CJS 版永不加载 |
|
||||
| `node_modules/**/*.map` | 19.0MB | Node 没开 `--enable-source-maps` |
|
||||
| `prisma/build/` 只留 sqlite/mysql 查询编译器 | 15.0MB | 部署形态里 CLI 只被用于 `generate` 与 `db push` |
|
||||
| 文档(保留 LICENSE/NOTICE)与测试/示例目录 | 5.2MB | 不会被 require |
|
||||
|
||||
**两条硬护栏(都踩过)**:① 整棵 `@types/**` 不参与裁剪 —— 类型包里的 `test/`、`docs/` 是真实声明
|
||||
文件(`@types/node/test/reporters.d.ts` 被 `test.d.ts` 引用),按"测试目录"删掉会让数据库切换的
|
||||
`tsc` 报 TS6053;② 名字命中 `DROP_DIRS` 的目录,只要里面含 `*.d.ts` 就跳过。
|
||||
改完必须按下面的清单复验(尤其数据库管理模块,它是唯一会调 Prisma CLI 的地方)。
|
||||
|
||||
56. **`verify:classisland` 开头会先"抽干"本班的历史积压提醒。** `/integrations/classisland/pending`
|
||||
是「本班全部未确认提醒,按时间升序取**前 20 条**」,而 `verify:desktop` / `verify:web` 等冒烟会
|
||||
**真的往演示班发通知**(见 §5 第 38 条)——那些条没被确认就会一直堆着,攒够 20 条之后
|
||||
本脚本刚发的提醒会被挤出窗口,表现为「待提醒里找不到刚发的那条」,跟代码对不对毫无关系
|
||||
(2026-10-05 实测:库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。
|
||||
现在脚本用 `drainPending()` 按**真实插件重连后的行为**(补弹并逐条确认)先清空,再开始断言,
|
||||
可见范围与插件一致(只看 `deviceId` 为空的本班广播 + 本设备自己的推送)。
|
||||
**别把这个 drain 删掉**,否则这个门禁会重新变成"跑几次就红"的随机失败。
|
||||
|
||||
57. **桌面端配置(`config.json`)是"常驻内存 + 异步合并落盘",别再当成同步写。**
|
||||
`main/config.ts` 里配置**只读一次盘**(懒初始化),`getConfig()` / `saveConfig()` 之后一律走内存缓存
|
||||
(连已解密的 token 一起缓存,不再每次过一遍 DPAPI);落盘改成"记下待写 → 120ms 合并窗口 →
|
||||
`fs.promises.writeFile`",同一拍内的多次改动只写最后一次,多次写入之间串行、不会互相覆盖;
|
||||
退出前由 `flushConfigSync()`(挂在 `main/index.ts` 的 `shutdownResources()` 里)同步兜底写一次。
|
||||
|
||||
**为什么必须这样**:侧栏折叠、主题切换、通知渠道、作业看板偏好、登录/登出**全都走 `saveConfig`**,
|
||||
而它原先一次调用要在**主进程主线程**上做「`readFileSync` + `JSON.parse` + 8 个 normalize」**两遍**
|
||||
(开头读一遍、结尾 `getConfig()` 又读一遍)+ `writeFileSync` + DPAPI 解密一遍 —— 主进程是 Electron 的
|
||||
消息泵进程,被它摁住的那一拍渲染进程也跟着掉帧。用户反馈的「点侧栏汉堡展开/收回时卡一下」
|
||||
就是这一拍(2026-10-05 定位并修掉;当时先误查到灵动岛,那块与它无关)。
|
||||
|
||||
三条硬约束:
|
||||
① **缓存必须懒初始化**:`main/index.ts` 会先 `app.setPath('userData', smokeProfile)` 再读配置,
|
||||
在模块顶层立即读盘会读错文件;
|
||||
② **`saveConfig()` 返回时数据可能还没落盘**(最多晚 120ms)。要"读回刚存的值"一律用 `getConfig()`
|
||||
(读的就是缓存里的最新值),别去读文件;正常退出(托盘 → 退出)会 flush,只有进程被强杀才会丢
|
||||
最后 120ms 内的改动;
|
||||
③ 别把落盘写成"每次保存挂一个 promise 链"—— 拖滑块/连点会排队几十次写。`clearConfig()`(退出登录)
|
||||
走 `immediate` 分支,不参与合并窗口。
|
||||
|
||||
58. **顶栏右侧(主题切换按钮 + 用户名下拉)必须是 flex 容器。** Element Plus 给 `.el-button` 的是
|
||||
`vertical-align: middle`、给 `.el-dropdown` 的是 `vertical-align: top` —— 两个 inline-level
|
||||
子元素放在普通 block 容器里会各按各的垂直对齐规则落位,错开几像素(用户反馈的"**深色模式没对齐**",
|
||||
其实两种主题下都偏,只是深色下更显眼)。`.header-right` 必须写
|
||||
`display:flex; align-items:center; gap`,与 Web 管理端 `AdminLayout` 的同名容器同一套写法
|
||||
(那边一开始就是对的,桌面端当初漏了这条规则)。回归断言在 `layoutChromeSelfTest` 里:
|
||||
量主题按钮与 `.user-chip` 的**中线差**,要求 ≤ 1.5px(实测 0.0px)。
|
||||
|
||||
59. **Linux 交付链有六条硬约定**(`deploy/install.sh`、`deploy/classhelper`、`scripts/dist-server.mjs`
|
||||
的 `--platform linux`、`deploy/verify-linux.sh`)。改这条链路前先读这一节,每条都对应一个踩过的坑:
|
||||
|
||||
① **配置真身在 `/etc/classhelper/config.env`,安装目录里的 `.env` 必须是指向它的软链。**
|
||||
Web 端「数据库管理 → 一键切换」在运行中改写的是 **`<安装目录>/.env`**(`database.service.ts`),
|
||||
而 systemd 是通过 `EnvironmentFile=` 注入同一份配置的;dotenv 不覆盖已存在的环境变量,
|
||||
所以两处一旦不是同一个文件,切换就会**看起来成功、重启后又连回旧库**(极难排查)。
|
||||
权限同时满足"配置文件 600 / 配置目录 700"与服务进程可读写(属主给 `classhelper`)。
|
||||
`classhelper doctor` 里有专门一条断言盯着这条软链。
|
||||
|
||||
② **systemd 单元的 `ReadWritePaths` 必须同时包含安装目录与配置目录。**
|
||||
服务端不是只写 `data/`:一键切库会改写 `server/prisma/schema.prisma`、把新客户端编译进
|
||||
`server/dist/generated`,并改写 `config.env`。`ProtectSystem=full` 下不显式放行就会失败。
|
||||
占位符有 5 个:`__NODE__ __DIR__ __USER__ __CONFIG__ __CONFIGDIR__`(少替换一个,
|
||||
systemd 会拿字面量当路径)。
|
||||
|
||||
③ **升级/回滚替换程序文件必须用"改名 + 就位",不能 `rm -rf` 再 `cp`。**
|
||||
升级时正在运行的就是 `bin/classhelper` 自己:先 `mv bin bin.old-…` 再放新目录,
|
||||
运行中的进程继续读旧 inode;直接删掉同名文件会让 bash 读到半个脚本(报莫名其妙的语法错误)。
|
||||
|
||||
④ **Linux 包只能在 Linux 上构建,产物名固定。** `@libsql/linux-x64-gnu`、`@prisma/adapter-libsql`
|
||||
是平台相关依赖,Windows 上装的 `node_modules` 拷过去跑不起来 ⇒ 由
|
||||
`.github/workflows/release-linux-server.yml`(ubuntu runner)出包。命名约定
|
||||
`classhelper-server-linux-<arch>-<版本>.tar.gz` + 同名 `.sha256`(旁边那份、以及 Release 里的
|
||||
`SHA256SUMS-<版本>.txt`),`classhelper upgrade` 按这个名字拼下载地址,**改名即断升级链路**。
|
||||
非 Linux 上 `--platform linux` 也能跑(会加 npm 的 `--os=linux --cpu=x64` 按目标平台解析依赖),
|
||||
但那是**应急**,已在 2026-10-05 实测核对过包内容:`@libsql/linux-x64-gnu`/`-musl` 就位、无 win32 残留、
|
||||
服务端与全部运维命令可用;**只有 Web 端「一键切换数据库」存疑** —— 那条链路要 prisma CLI 的原生
|
||||
schema engine,CLI 运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径,
|
||||
而交叉包里只有 `schema-engine-windows.exe`。交叉构建会写入 `.cross-built` 标记,
|
||||
`classhelper doctor` 据此提示(别改成静默),`upgrade` 替换程序文件时会按新包有无该标记来增删。
|
||||
正式产物仍走 CI。**交叉构建时 `tar` 还有两个 Windows 专有坑**(`packLinuxTarball()`):
|
||||
`-f F:\...tar.gz` 里的**盘符冒号**会被 GNU tar 当成 `主机:路径` 的远程归档写法,报
|
||||
`tar (child): Cannot connect to F: resolve failed`、退出码 2 —— 现象很误导:包已经整套铺好、
|
||||
就差最后打 tar 那一步失败。反斜杠还会被当成转义符。所以那一步把路径统一转成**正斜杠**
|
||||
并加 `--force-local`。该选项在 Linux 上无害(本地路径本来就该走本地分支),因此无条件加上、
|
||||
不做平台分支 —— **Linux 上路径没有盘符,这个坑在 CI 与真机上永远复现不了**,只在交叉构建时现形。
|
||||
|
||||
⑤ **密码永远走 stdin,命令行一律不接受密码;调用服务端模块的工具必须压掉它的 stdout 噪声。**
|
||||
`classhelper password` 用 `read -s` 优先读 `/dev/tty`、无 tty 回退 stdin(这样既能交互,
|
||||
也能被验收脚本用管道喂 —— 都不进 history/ps)。输入循环**必须有界**:stdin 结束时 `read`
|
||||
返回非 0,不看这个标记就会在 EOF 上死循环(`verify-linux.sh` 里有一条"不合规时不卡死"的回归)。
|
||||
写配置(`config set`)同理:值经临时文件传给 `awk`,不进 argv;敏感键强制交互输入,
|
||||
`key rotate` 自己生成的密钥走内部写函数。另外 `tools/admin-cli.mjs` 复用了服务端的
|
||||
`config/env.ts` 与 `lib/logger.ts`,**这两个模块会往 stdout 打日志**(dotenv 的
|
||||
`◇ injected env …` 提示、Prisma 初始化一行)—— 调它时必须带上
|
||||
`LOG_LEVEL=error DOTENV_CONFIG_QUIET=true`,否则 `dump-hash` 写出的"原哈希备份"里会多一行日志,
|
||||
变成非法 JSON,**改密失败自动回滚整条链路静默失效**(dotenv 不覆盖已有环境变量,所以这两个
|
||||
变量能生效,已实测)。
|
||||
|
||||
⑥ **内置 Node 要按 glibc 选构建。** Node 官方 linux-x64 二进制要求 glibc ≥ 2.28,
|
||||
CentOS 7 是 2.17 —— 装上去的表现为"服务起来就退出、日志什么都没有"。安装器检测 glibc,
|
||||
低于 2.28 时自动改用 unofficial-builds 的 `-glibc-217` 变体。CentOS 7 仍属尽力而为。
|
||||
|
||||
两条写这类脚本时的通用坑(本仓库踩过):**`set -e` 下 `[ cond ] && 可能失败的命令` 会
|
||||
在条件为假时……其实不会退出**(bash 对 `&&` 列表里非最后一条失败是豁免的),但
|
||||
`A && B` 里 B 失败、以及**用 `$(函数)` 捕获输出时把日志一起捕获**(所以安装器用全局
|
||||
`STAGE_DIR` 而不是 `echo` 返回路径)这两类是实打实的 bug;`die` 要先 `trap - ERR` 再退出,
|
||||
免得 ERR 钩子再补一句误导的"第 N 行执行失败"。
|
||||
|
||||
60. **安全响应头里的 `upgrade-insecure-requests` 与 HSTS 只能在 HTTPS 请求下下发**(`middleware/security.ts`
|
||||
的 `buildHelmet(httpsRequest)`,按 `req.secure` 二选一;TRUST_PROXY 开启时 `req.secure` 已含
|
||||
`x-forwarded-proto` 判断)。helmet 的默认 CSP **自带 `upgrade-insecure-requests`**,而安装器的
|
||||
默认形态是 `http://IP:端口` 直连(无 TLS):这条指令会把页面**子资源**请求全部改写成 https 发出去,
|
||||
服务器没有 TLS 监听,全部连接失败 —— 表现为「管理端一片空白、标签页标题正常」
|
||||
(2026-10-05 用户在 98.142.241.144 实测踩到;`/healthz`、登录 API 都正常,纯浏览器侧的资产全灭)。
|
||||
HSTS 在明文响应里按规范会被浏览器忽略,但 CSP 指令没有这层豁免,所以两者都要跟着 https 走。
|
||||
开发时一直用 localhost(浏览器视为安全上下文、不做改写),所以这个坑在本地永远不复现,
|
||||
只有真机 http 直连部署才炸 —— 别因为"本地好好的"就把条件判断摘掉。
|
||||
|
||||
61. **动效层:令牌在 `packages/shared/src/motion.ts`,两端各一份组合式函数。**
|
||||
移植自 [beUI](https://beui.dev) 的 `lib/ease.ts`(它的组件是 React,用不了;动效语言与框架无关)。
|
||||
三组缓动 + 六组具名弹簧(`SPRING_PRESS/SWAP/PANEL/LAYOUT/MOUSE/GLIDE`)。
|
||||
|
||||
- **令牌必须是纯常量**:`packages/shared` 被后端一起消费,它的 tsconfig 是 `lib: ["ES2023"]`
|
||||
(不含 DOM),所以 `motion.ts` 里不碰 `document` / `window` / vue。DOM 注入与 Vue 组合式函数
|
||||
在两端各自的 `composables/motion.ts`(两端各一份,与 `ScoreBarChart.vue` 同一做法)。
|
||||
- **CSS 变量在 `app.mount()` 之前由 JS 注入 `:root`**(两端 `main.ts` 调 `applyMotionTokens()`)。
|
||||
CSS 里只引用 `var(--ch-spring-*)`,**不要抄数值** —— 抄了必漂。
|
||||
- **弹簧靠数值积分生成 CSS `linear()`**,于是弹簧可以纯 CSS 驱动,不需要每组件一个 rAF 循环。
|
||||
算落定时间**不能用包络公式 `6/(ζ·ω0)`**:它只对欠阻尼成立,过阻尼要用较慢的实极点
|
||||
`ω0(ζ−√(ζ²−1))`,否则曲线会被截断在 0.87/0.95 上、元素永远差一截没到位(实测踩过)。
|
||||
- **`--ch-spring-X` 与 `--ch-spring-X-dur` 必须成对用**:`linear()` 是把 [0,1] 进度重映射,
|
||||
时长给短了等于把整条曲线等比压缩 —— 形状还在,但已经不是那组参数的手感了。
|
||||
- `linear()` 的进度值可以 >1(过冲):只能用在不越界无害的属性上。**别用在 `opacity`**。
|
||||
|
||||
**四条硬约束(都对应实测过的坑)**:
|
||||
|
||||
- **路由层不要包 `<transition>`,尤其不要 `mode="out-in"`。** 它把"旧组件卸载、新组件挂载"推迟到
|
||||
退场动画结束(90ms 起),后果有两个:① 点菜单后有一小段时间页面上还是上一页,`ui-smoke` 的多条
|
||||
用例("pathname 一变就找新页面的元素")会整片判红 —— 实测一次掉 9 项;② 退场判定依赖
|
||||
`animationend`,**窗口被遮挡时 Chromium 冻结 CSS 动画**(实测 `document.timeline` 1.2 秒推进 0ms),
|
||||
事件永不触发 ⇒ 新页面永远不挂载,整个应用卡死在第一页。页面切换的动效改用 `.page > *` 错峰入场,
|
||||
那是随组件挂载**同步**触发的。
|
||||
- **Vue 的 `<transition>` 一律显式给 `:duration`。** 同上,靠 `animationend` 判断的写法在窗口不可见时
|
||||
会永远停住(`UrgentClassWarning` 那块铺满全屏的遮罩尤其致命:不卸载就再也点不动)。
|
||||
- **`position: fixed` 的整屏层、以及"自己量几何再据此设尺寸"的元素,不要叠位移类动画。**
|
||||
`.page > *` 错峰入场排除了 `.el-overlay`(Element Plus 的 `el-dialog` 默认 `append-to-body=false`,
|
||||
声明在视图里的弹窗其遮罩就是 `.page` 的直接子元素)、`.el-loading-mask`、`.board-host`
|
||||
(客户端的作业看板按"刚好铺满"算 `transform: scale()`,下移 6px 就溢出屏幕)。
|
||||
判定方法是 `getBoundingClientRect()` **包含** transform,而 `clientHeight` / `offsetHeight` 不含。
|
||||
- **弹窗面板的动画要挂在 `.el-dialog` 上,不要挂在 `.el-overlay-dialog`(居中容器)上。**
|
||||
Element Plus 的 `@opened` 按**根元素**(`.el-overlay`,只有 200ms 淡入)的动画结束触发,
|
||||
而外层容器上的弹簧要跑 463ms —— 任何在 `@opened` 里量容器尺寸做自适应的弹窗都会量到
|
||||
"还带着 `scale(0.97)` 的缩小版",被 `minScale` 兜住后反而超出屏幕(作业看板实测 +7px)。
|
||||
挂 `.el-dialog` 还有个好处:全屏弹窗能用 `is-fullscreen` 精确豁免(全屏表面本来也不该缩放入场)。
|
||||
|
||||
**两条容易踩的实现细节**:
|
||||
- 客户端的 `--nav-ease` **刻意保留** Material 的 `cubic-bezier(.4, 0, .2, 1)`,没有统一成 EASE_OUT。
|
||||
EASE_OUT 起步极快,宽度掉到 80px 以下时动画才走了约 35%,而 `layoutChromeSelfTest` 一到读宽度
|
||||
判据就往下走、立刻量图标中心,此刻内边距还在动 ⇒ 6 个分组子项偏 2.3px 判红。想换曲线得连那条断言
|
||||
一起重新商量。(该断言的宽度判据已从 `≤80` 收紧到 `≤65`,即"真正收拢到稳态"再量 —— 折叠目标宽度是 64px。)
|
||||
- **Vue 的 scoped `<style>` 可以引用全局 `@keyframes`**(只有同一个 scoped 块里**定义**的才会被改名)。
|
||||
实测:`.login-card` 的 `animation: ch-panel-in …` 原样输出且生效。但 `mode="out-in"` 之类的
|
||||
transition 类名仍在 scoped 块里写、元素也带 `data-v`,能正常命中。
|
||||
- `el-aside` / `el-menu` 这些是**组件**不是原生标签:模板 ref 拿到的是组件实例,
|
||||
`.querySelector` 不是函数(会被 `app.config.errorHandler` 吞成一行中文异常,界面看着毫无异样)。
|
||||
用 `$el`,或直接 `document.querySelector`(客户端的主侧栏只有一个,后者更直白)。
|
||||
- 降低动态效果:两端 `styles/index.css` 各有一个 `@media (prefers-reduced-motion: reduce)` 全局块,
|
||||
把动画压到 1ms(不是删掉,`transition: none` 会让依赖 transitionend 的逻辑失灵)。
|
||||
**加载指示旋要豁免**(改为放慢而非停)—— 停掉会让界面看起来像卡死。
|
||||
|
||||
**灵动岛:只移植曲线,不移植弹簧。**
|
||||
岛的形变由主进程缓动窗口尺寸(`src/main/island.ts`),而冒烟断言「展开过程不震动」
|
||||
「收回过程不抖动」**逐帧采样岛体几何**并要求单调 —— 任何带过冲的弹簧都会直接判红。
|
||||
所以岛用 `--ch-ease-out` 这类单调曲线,壳的物理仍归它自己的 `island/spring.ts`(WinIsland 移植),
|
||||
不要拿 beUI 的 `SPRING_*` 去换。
|
||||
`IslandApp.vue` 是**另一个渲染进程**,不共享主窗口的 `:root`:令牌要在
|
||||
`src/island/main.ts` 里**再注入一次**(`applyMotionTokens()`)。漏了的话 `var(--ch-ease-out)`
|
||||
解析为空、**整条 `transition` 简写作废**,反而比改造前更差(完全没有过渡)。
|
||||
另外岛的 `:hover` 规则必须关在 `@media (hover: hover) and (pointer: fine)` 里 ——
|
||||
教室触摸屏的"幽灵 hover"会让点过「知道了」的按钮永远高亮。
|
||||
|
||||
**深色主题的语义色令牌:只改值,不要对样式表跑批量 sed。**
|
||||
`--ch-text: #303133` 被"把 #303133 换成 var(--ch-text)"的批量替换一过,就变成
|
||||
`--ch-text: var(--ch-text)` —— **自引用**会让整条自定义属性失效,`getPropertyValue` 返回空串,
|
||||
全站文字颜色静默回落成继承色(浅色下看着"还行",深色下就露馅)。实测踩过。
|
||||
|
||||
62. **图标:全项目只有一个图形源 `build/classhelper.{png,ico}`,其余一律由 `pnpm icons` 派生。**
|
||||
这两个文件是**设计导出、手工放入**的,`pnpm icons` 只读不写 —— 要换图标就换这两个文件,
|
||||
别去改脚本。派生清单写在 `scripts/icons/render.cjs` 顶部的注释里,覆盖:Web 端 PWA 三件套
|
||||
(`icon-192` / `icon-512` / `apple-touch-icon`)、`favicon`(png + svg 各一份)、
|
||||
ClassIsland 插件 `icon.png`、客户端 `build/{classhelper,icon}.{png,ico}`、
|
||||
托盘 `public/tray.png`、官网 `assets/icon.png`,以及**两端界面内的品牌标** `public/logo.png`。
|
||||
|
||||
**四条硬约束(都对应实测踩过的坑)**:
|
||||
|
||||
- **ICO 原样透传,不要重新生成。** `classhelper.ico` 里 9 个尺寸
|
||||
(16/24/32/48/64/72/96/128/256)是设计工具**逐尺寸栅格化**的,比把那张 345×339 的位图
|
||||
缩下去干净得多;脚本只解析它的目录项做日志与校验,然后把整份复制给客户端与 electron-builder。
|
||||
- **`packages/desktop-client/build/icon.{png,ico}` 别删。** 它们是与 `classhelper.*` 同步的副本,
|
||||
而名字是 **electron-builder 的兜底约定**:`convertIcon()` 的候选表是
|
||||
`['<win.icon>', 'icon.ico', 'icons', 'icon', 'icon.png', 'icon.svg', 'icon.icns']`,
|
||||
依次在 `[buildResources, projectDir]` 两个根下 stat,**第一个命中的就赢**。
|
||||
`win.icon` 现在显式写着 `build/classhelper.ico`(实测 `isFallback = false`,
|
||||
解析到 `packages/desktop-client/build/classhelper.ico`),但那条配置一旦被拿掉,
|
||||
就会**静默**回落到 `icon.ico` —— 副本同步着,回落也还是新图标。
|
||||
- **界面内的品牌标用 `<img>`,且两端 URL 的写法必须不同。** Web 端写根绝对路径 `/logo.png`
|
||||
(永远从源站根提供服务;前端是 history 路由,相对路径在 `/classes/1` 这类嵌套路由下会解析错)。
|
||||
客户端**必须经 `BRAND_LOGO_URL`(`src/renderer/config.ts`)绑定给 `:src`** ——
|
||||
字面量的相对 `src` 会被 Vite 当成模块导入去解析
|
||||
(`Rolldown failed to resolve import "logo.png" from "LoginView.vue"`,构建直接失败),
|
||||
而根绝对路径在 `file://` 加载的打包形态下会解析到**盘符根目录**。
|
||||
- **`School` 字形在两端 `icons.ts` 里的注册不要删**:它仍是「班级管理」菜单项与
|
||||
「班级码」输入框前缀的字段图标,只是不再是品牌标。品牌标换成真图标后,
|
||||
`.brand-logo` 的 `background` / `color` / `border-radius` / `overflow` 都要撤掉 ——
|
||||
圆角与透明边角本来就在图里,再叠一层会出现双重圆角。
|
||||
|
||||
跑完 `pnpm icons` 记得把**生成出来的那些图**一起提交(脚本会比对内容,没变动的不会重写)。
|
||||
源图 345×339 不是正方形,脚本按等比缩放居中补透明(补边约 1.7%,肉眼不可见);
|
||||
512 那两档是**放大**,会略软 —— 那是源图分辨率的上限,要更清晰只能换更大的设计导出。
|
||||
|
||||
---
|
||||
|
||||
## 6. 代码风格
|
||||
@@ -501,6 +935,14 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
- **提交信息**:Conventional Commits + 模块 scope,描述用中文,可多 scope,例如
|
||||
`fix(island): 胶囊"点不动"的根因——窗口必须始终可激活`、
|
||||
`feat(teachers,classes,schedules,web): 教师录入(仅管理员)`。
|
||||
- **图表不引第三方库。** 全项目只有三张图(桌面端成绩页 1 张柱状图;Web 端成绩页柱状 + 折线各 1 张),
|
||||
历史上用 echarts 全量引入,占了桌面端渲染产物 1.12MB(35%)与 Web 端 1.13MB。现在换成
|
||||
自包含的轻量组件:`packages/desktop-client/src/renderer/components/ScoreBarChart.vue`、
|
||||
`packages/web-admin/src/components/{ScoreBarChart,ScoreLineChart}.vue` ——
|
||||
柱状图是纯 HTML/CSS(柱高走百分比、刻度与网格线用**零高度 flex 行 + space-between** 对齐),
|
||||
折线图是 `viewBox="0 0 100 100" + preserveAspectRatio="none"` 的 SVG(数据点用 HTML 绝对定位,
|
||||
免得非等比拉伸把圆点压成椭圆)。两者都是响应式的,**不要给它们加重算尺寸的 resize 监听**。
|
||||
新增图表请沿用这个做法,不要重新引入图表库。
|
||||
- **改动交付形态 / 新增功能后**,同步更新 `README.md`(它兼作产品说明书与验收对照表)。
|
||||
|
||||
---
|
||||
@@ -597,10 +1039,12 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore。)
|
||||
11. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
|
||||
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
|
||||
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉;`release-server/` 同理)。
|
||||
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉;旧的 `release-server/` 条目留作兜底)。
|
||||
**它一度只在 ESLint 里被忽略、`.gitignore` 里漏了**(后者只有单数的 `release/` 和 `release-server/`):
|
||||
归集一次客户端产物就有 225MB 的 EXE 变成未跟踪文件,`git add -A` 会直接把安装包提交入库。
|
||||
2026-09-30 已在 `.gitignore` 末尾补上 `releases/`。
|
||||
(2026-10-05 起 `release-server/`、`packages/desktop-client/release/` 这两个中间输出目录已取消,
|
||||
三个打包脚本都直接输出到 `releases/`,见 §4;那两条 ignore 保留作兜底。)
|
||||
12. **ClassIsland 的数据根是 `D:\Classisland\data`**(**不是**旧文档写的 `F:\data`,F 盘已不存在):
|
||||
- 插件 DLL 覆盖到 `D:\Classisland\data\Plugins\classhelper.classisland.bridge\`(**ClassIsland 运行时会锁住
|
||||
DLL,必须先退出它再覆盖**:`Get-Process ClassIsland.Desktop | Stop-Process -Force`);
|
||||
@@ -625,7 +1069,8 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
- `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**,
|
||||
而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成
|
||||
`D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。
|
||||
`win-unpacked` 与 `release/*.nsis.7z` 此时都已生成,**只需重跑 electron-builder,不必重建前端**:
|
||||
`win-unpacked` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、`免安装/` 的那一步在打包
|
||||
成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**:
|
||||
|
||||
```bash
|
||||
mkdir -p .cache/tmp
|
||||
@@ -653,6 +1098,24 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
同理,断言岛体尺寸别写死"胶囊尺寸 + 留白":那一刻是胶囊还是卡片取决于前面用例留下的状态,
|
||||
应改成**与形态无关的不变量**(如"窗口 = 卡片实测尺寸 + 2×阴影留白")。
|
||||
|
||||
23. **客户端安装包有硬底:`班级小助手.exe` 单独压缩后就有 85~90MB,"压到 60MB"做不到 —— 别再为这个反复折腾。**
|
||||
实测(2026-10-05,Electron 44.3.0):`win-unpacked` 372MB,其中 `班级小助手.exe`(就是 electron.exe,
|
||||
rcedit 只加了 74KB)**234.7MB**;brotli-9 抽样压缩后估算它单独就有 85~90MB。也就是说把渲染产物
|
||||
从 3.2MB 压到 2.1MB、把图标从 293 个减到 42 个,对安装包只影响约 1MB。
|
||||
剩下能动的只有这些(合计约 12MB,风险不成比例,**当前一律不做**):
|
||||
`dxcompiler.dll + dxil.dll` 26MB(D3D12 着色器编译)、`vk_swiftshader + vulkan-1` 6.2MB、
|
||||
`d3dcompiler_47.dll` 4.5MB(D3D11 回退)、`chrome_200_percent.pak` 1.2MB(高 DPI)、`ffmpeg.dll` 3MB。
|
||||
教室机器多是老显卡 / 希沃一体机 / 虚拟机,删掉这些就是拿渲染可靠性换体积。
|
||||
**唯一做的一条**是语言包:`electron-builder.yml` 的 `electronLanguages: [zh-CN]`
|
||||
(官方选项,`app-builder-lib` 的 `removeUnusedLanguagesIfNeeded()` 会精确删掉其余 54 个
|
||||
`locales/*.pak`)。实测 `win-unpacked` 372MB → **323MB**、`setup.exe`/`portable.exe`
|
||||
107.2MB → **98.7MB / 98.4MB**(表中口径均为 MiB,与本机 `ls -la` / `du` 一致)。
|
||||
|
||||
要真正做到 60MB 只能换渲染运行时(WebView2 / Tauri,安装包可到 5~15MB),代价是把
|
||||
`src/main/island.ts`(1840 行)里全部原生窗口行为重写一遍 —— 逐像素透明、鼠标穿透、
|
||||
窗口贴合岛体、触摸模式、多屏/DPI,全是 §5 第 10/16/21/22/30/32/36/37 条踩过坑的地方,
|
||||
且 `verify:desktop` / `verify:packaged` 的整套回归要重做。属于独立立项,不是顺手优化。
|
||||
|
||||
### 已知脆弱用例(不是环境问题,别去"修环境")
|
||||
|
||||
| 用例 | 触发条件 |
|
||||
@@ -660,6 +1123,7 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) |
|
||||
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
|
||||
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
|
||||
| `verify:desktop`「作业看板全屏自适应」 | 演示数据里某天有 6 条长作业、而冒烟窗口只有 ~543px 高时,`computeScale` 算出的缩放低于下限 0.45 被 `Math.max(minScale, …)` **钳住**,于是必然溢出(实测 565 > 543)。判据用的是 `host.clientHeight` / `inner.offsetHeight`,**这两个是布局属性、transform 不影响**,所以与动效层无关;数据在冒烟里建了又删,每次跑出来的高度还会差几十像素 |
|
||||
| 岛交互 / 岛动画 / 岛截图留档这几个用例 | **AI 会话里会间歇性失败**:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 |
|
||||
|
||||
---
|
||||
@@ -671,7 +1135,7 @@ pnpm lint # ESLint(0 error 为底线)
|
||||
pnpm typecheck # 全仓库类型检查
|
||||
|
||||
pnpm dev:server # 另开终端
|
||||
pnpm verify:e2e # 后端端到端(2026-09-30 实测 173 项全过)
|
||||
pnpm verify:e2e # 后端端到端(2026-10-05 实测 190 项全过)
|
||||
pnpm verify:classisland # ClassIsland 联动链路(实测 42 项)
|
||||
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 68 项)
|
||||
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
|
||||
@@ -681,6 +1145,25 @@ pnpm build && pnpm verify:web # Web 端真实点击回归(同上
|
||||
条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`、`ClassIsland 联动插件静态校验:N/N 项通过`),
|
||||
文档不要写死容易过期的总数。
|
||||
|
||||
改动到 **Linux 交付链**(`deploy/install.sh`、`deploy/classhelper`、`deploy/tools/`、
|
||||
`scripts/dist-server.mjs` 的 linux 分支、systemd/profile.d/logrotate 模板)时:
|
||||
|
||||
```bash
|
||||
bash -n deploy/install.sh deploy/classhelper deploy/verify-linux.sh deploy/install-linux.sh # 语法(Windows 上就能跑)
|
||||
pnpm dist:server:linux # 只能在 Linux 上跑;开发机是 Windows 时到服务器/CI 上验证
|
||||
|
||||
# 真机(任意一台能 SSH 的 Linux,容器也行但 systemd 用例会跳过)
|
||||
scp releases/server/<版本>/linux-x64/classhelper-server-linux-x64-*.tar.gz deploy/{install.sh,verify-linux.sh} root@<主机>:/tmp/
|
||||
ssh root@<主机> 'bash /tmp/install.sh --check'
|
||||
ssh root@<主机> "printf '%s\n' '<密码>' | bash /tmp/install.sh --yes --admin-password-stdin --package /tmp/classhelper-server-linux-x64-*.tar.gz"
|
||||
ssh root@<主机> "bash /tmp/verify-linux.sh --admin-password-stdin" < <(printf '%s\n' '<密码>')
|
||||
```
|
||||
|
||||
`verify-linux.sh` 会**真的**改一次管理员密码再回滚、真的发一条备份,跑完机器仍是可用状态;
|
||||
升级/回滚链路要加 `--full --package <新版本包>`(会真的替换一次程序文件)。
|
||||
**改过这条链路就要在真机上跑一遍**——脚本写在 Windows 上跑不出问题,`set -e` 的坑、
|
||||
systemd 的 `EnvironmentFile` 解析、glibc 差异都只有在 Linux 上才暴露。
|
||||
|
||||
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
|
||||
本机已装 ClassIsland 2.1.0.1(`D:\Classisland`)与 .NET 8 SDK(8.0.425),因此**真机验证是可行的**
|
||||
(直接 `pnpm build:classisland-plugin` 编译,再按 §7 第 12 条部署):
|
||||
@@ -700,4 +1183,28 @@ Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
|
||||
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
||||
开发产物通过不等于用户机器上的副本通过。
|
||||
|
||||
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
|
||||
从 `releases/server/<版本>/免安装/` 起,用**自带的 node.exe**:
|
||||
|
||||
```bash
|
||||
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
|
||||
cd releases/server/<版本>/免安装 && PORT=4100 ./node.exe server/dist/index.js
|
||||
curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz
|
||||
curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \
|
||||
-H "content-type: application/json" -d '{"username":"admin","password":"admin123"}'
|
||||
|
||||
# ② 数据库「一键切换」的三步子进程链路(唯一会调用 Prisma CLI 的地方)
|
||||
cd server
|
||||
DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
|
||||
../node.exe ../node_modules/prisma/build/index.js generate
|
||||
../node.exe ../node_modules/typescript/lib/tsc.js -p tsconfig.generate.json
|
||||
DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
|
||||
../node.exe ../node_modules/prisma/build/index.js db push --accept-data-loss
|
||||
```
|
||||
|
||||
跑完把这几个测试残留删掉,别带进交付目录:`server/src`、`server/prune-test.db`,
|
||||
并把 `data/`、`logs/` 清空。
|
||||
(`prisma db push` 是破坏性命令,Prisma 7.10 的 AI 代理护栏会要求显式同意;
|
||||
这里的目标是脚本刚建出来的空临时库,不是真实数据。)
|
||||
|
||||
自测本地接口记得绕过代理(见 §7 第 1 条)。
|
||||
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 264 KiB |
@@ -7,7 +7,7 @@
|
||||
SERVER_PORT=4000
|
||||
|
||||
# 应用版本(镜像标签)
|
||||
APP_VERSION=0.1.0
|
||||
APP_VERSION=1.1.2
|
||||
|
||||
# MySQL 配置
|
||||
MYSQL_ROOT_PASSWORD=请改成强密码_root
|
||||
@@ -31,7 +31,7 @@ INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=admin123
|
||||
|
||||
# 各类账号的默认初始密码(新增/重置时使用)
|
||||
DEFAULT_STUDENT_PASSWORD=123456
|
||||
|
||||
DEFAULT_CLASS_PASSWORD=123456
|
||||
DEFAULT_TEACHER_PASSWORD=123456
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
SERVER_PORT=4000
|
||||
|
||||
# 应用版本(镜像标签)
|
||||
APP_VERSION=0.1.0
|
||||
APP_VERSION=1.1.2
|
||||
|
||||
# JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词)
|
||||
# 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
@@ -25,7 +25,7 @@ INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=admin123
|
||||
|
||||
# 各类账号的默认初始密码(新增/重置时使用)
|
||||
DEFAULT_STUDENT_PASSWORD=123456
|
||||
|
||||
DEFAULT_CLASS_PASSWORD=123456
|
||||
DEFAULT_TEACHER_PASSWORD=123456
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
# runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小
|
||||
#
|
||||
# 构建(在仓库根目录执行):
|
||||
# docker build -f deploy/Dockerfile -t classhelper-server:0.1.0 .
|
||||
# docker build -f deploy/Dockerfile -t classhelper-server:1.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
|
||||
# classhelper-server:1.1.0
|
||||
#
|
||||
# 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手服务端 · Docker 形态镜像(由 install.sh --mode docker 使用)
|
||||
#
|
||||
# 与仓库根 deploy/Dockerfile 的区别:
|
||||
# - 那份是"从源码构建"(两阶段、需要 pnpm 与全量源码),适合开发与自己打包镜像;
|
||||
# - 这份是"从安装包构建":安装包里的 server/dist、web、node_modules 已经是**linux-x64 的真实目录**,
|
||||
# 所以这里只做 COPY,不需要联网装依赖、不需要构建工具,几十秒就能出镜像。
|
||||
#
|
||||
# 构建(install.sh 自动执行,也可手工):
|
||||
# docker build -t classhelper-server:1.1.0 -f Dockerfile .
|
||||
# 运行:
|
||||
# docker run -d --name classhelper-server -p 4000:4000 \
|
||||
# --env-file /etc/classhelper/config.env -v classhelper-data:/app/data \
|
||||
# classhelper-server:1.1.0
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-slim
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=4000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 运行时会用到的全部内容(安装包里就是这套布局)
|
||||
COPY package.json ./
|
||||
COPY node_modules ./node_modules
|
||||
COPY server ./server
|
||||
COPY web ./web
|
||||
COPY tools ./tools
|
||||
|
||||
# 数据目录(compose 里挂成卷)
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
|
||||
# 说明:不用 `USER node`
|
||||
# - 镜像只在本机跑,容器本身就是隔离边界;
|
||||
# - 数据卷里的文件需要与宿主机上的 classhelper backup / restore 互通,
|
||||
# 用 root 可以避免 uid 不一致导致的权限问题。
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
# 健康检查用 Node 自己发请求:slim 镜像里没有 curl/wget
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
@@ -1,24 +1,33 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 服务端 systemd 单元(Linux)
|
||||
#
|
||||
# 这是**模板**:deploy/install-linux.sh 会把 __NODE__ / __DIR__ / __USER__ 替换成真实值后
|
||||
# 安装到 /etc/systemd/system/classhelper.service。手动部署时直接改这三处即可:
|
||||
# 这是**模板**:`deploy/install.sh` 会把占位符替换成真实值后安装到
|
||||
# /etc/systemd/system/classhelper.service。手动部署时照着替换这四处即可:
|
||||
#
|
||||
# sudo sed -e 's#__NODE__#/usr/bin/node#' \
|
||||
# __NODE__ Node 可执行文件的绝对路径(内置运行时:<安装目录>/runtime/node/bin/node)
|
||||
# __DIR__ 安装目录(默认 /opt/classhelper)
|
||||
# __USER__ 运行服务的系统用户(默认 classhelper)
|
||||
# __CONFIG__ 环境变量文件(默认 /etc/classhelper/config.env)
|
||||
# __CONFIGDIR__ __CONFIG__ 所在目录(需要写权限,见下面的 ReadWritePaths)
|
||||
#
|
||||
# sudo sed -e 's#__NODE__#/opt/classhelper/runtime/node/bin/node#' \
|
||||
# -e 's#__DIR__#/opt/classhelper#' \
|
||||
# -e 's#__USER__#classhelper#' \
|
||||
# deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service
|
||||
# -e 's#__CONFIGDIR__#/etc/classhelper#' \
|
||||
# -e 's#__CONFIG__#/etc/classhelper/config.env#' \
|
||||
# deploy/classhelper.service > /tmp/classhelper.service
|
||||
# sudo install -m 644 /tmp/classhelper.service /etc/systemd/system/classhelper.service
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
|
||||
#
|
||||
# 常用命令:
|
||||
# 常用命令(也有对应的 classhelper 封装):
|
||||
# classhelper status / start / stop / restart / logs -f
|
||||
# systemctl status classhelper
|
||||
# journalctl -u classhelper -f # 实时日志
|
||||
# systemctl restart classhelper # 重启(改完 .env 需要)
|
||||
# journalctl -u classhelper -f
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[Unit]
|
||||
Description=ClassHelper Server (班级小助手服务端)
|
||||
Documentation=file://__DIR__/README.txt
|
||||
Documentation=https://github.com/LoveChengke/ClassHelper
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
@@ -27,15 +36,18 @@ Type=simple
|
||||
User=__USER__
|
||||
Group=__USER__
|
||||
WorkingDirectory=__DIR__
|
||||
# .env 由服务端自己从 WorkingDirectory 加载(工作目录即安装根目录)
|
||||
|
||||
# 配置从这里注入(唯一的配置真身;安装目录里的 .env 是指向它的软链,见 docs/linux-deploy.md)。
|
||||
# systemd 会剥掉值两侧的引号,所以同一份文件给 dotenv / shell source / systemd 三种读法都能用。
|
||||
# 前面不加 "-":文件被删掉时直接启动失败,比拿着一份假配置跑起来更容易发现。
|
||||
EnvironmentFile=__CONFIG__
|
||||
ExecStart=__NODE__ server/dist/index.js
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
# Socket.IO 长连接较多:提高文件描述符上限
|
||||
LimitNOFILE=65535
|
||||
# 优雅退出:给在途请求与 websocket 收尾时间
|
||||
# 优雅退出:给在途请求与 websocket 收尾时间(index.ts 处理 SIGTERM/SIGINT)
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=20
|
||||
|
||||
@@ -44,12 +56,26 @@ StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=classhelper
|
||||
|
||||
# 基础加固(服务只需要读程序、写 data/logs)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
# ---------------------------------------------------------------- 加固
|
||||
# 为什么 ReadWritePaths 要给整个 __DIR__ 与配置目录,而不是只给 data/logs:
|
||||
# 1) Web 端「数据库管理 → 一键切换」会在运行中改写 server/prisma/schema.prisma,
|
||||
# 并把重新生成的 Prisma Client 编译进 server/dist/generated —— 那两处都在 __DIR__ 里;
|
||||
# 2) 同一功能切换成功后要改写 __CONFIG__(DATABASE_PROVIDER / DATABASE_URL 两行)。
|
||||
# ProtectSystem=full 默认把 /etc 与 /usr 设成只读,ReadWritePaths 是官方的"打洞"方式。
|
||||
# 服务本来就以 __USER__ 身份运行,这里给的写权限没有超出它的既有边界。
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=__DIR__/data __DIR__/logs
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
ReadWritePaths=__DIR__ __CONFIGDIR__
|
||||
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
# 需要 AF_NETLINK:glibc 解析主机名(MySQL 主机 / GitHub API)时会用到
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
target: runtime
|
||||
image: classhelper-server:${APP_VERSION:-0.1.0}
|
||||
image: classhelper-server:${APP_VERSION:-1.1.2}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
TERM_START_DATE: ${TERM_START_DATE:-}
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
|
||||
DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456}
|
||||
|
||||
DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456}
|
||||
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
|
||||
ports:
|
||||
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
target: runtime
|
||||
image: classhelper-server:${APP_VERSION:-0.1.0}
|
||||
image: classhelper-server:${APP_VERSION:-1.1.2}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
@@ -78,7 +78,7 @@ services:
|
||||
TERM_START_DATE: ${TERM_START_DATE:-}
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
|
||||
DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456}
|
||||
|
||||
DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456}
|
||||
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
|
||||
ports:
|
||||
|
||||
@@ -1,279 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 服务端 Linux 一键部署(systemd + SQLite,单机单实例)
|
||||
# 兼容壳:早期文档、脚本与 README 里写的是 deploy/install-linux.sh,
|
||||
# 现在安装器统一在 deploy/install.sh(避免两份实现漂移)。
|
||||
#
|
||||
# 干什么:
|
||||
# 在 Linux 服务器上把服务端装成 systemd 服务,布局与已验证的 Windows 免安装目录完全一致:
|
||||
# /opt/classhelper/
|
||||
# ├── .env 全部配置(首次安装随机生成 JWT_SECRET)
|
||||
# ├── server/dist 后端程序(入口 index.js)
|
||||
# ├── server/prisma 迁移 SQL(AUTO_MIGRATE=true 时首启动自动执行)
|
||||
# ├── web/ Web 管理端(由后端托管,单端口 4000)
|
||||
# ├── node_modules/ 仅生产依赖(npm install --omit=dev)
|
||||
# ├── data/ classhelper.db + server.pid
|
||||
# └── logs/ 预留(日志默认进 journald)
|
||||
#
|
||||
# 前置条件:
|
||||
# - 在**仓库根目录**已执行过构建:pnpm build:shared && pnpm --filter @classhelper/server build \
|
||||
# && pnpm --filter @classhelper/web-admin build
|
||||
# - 目标机装好 Node.js ≥ 20.19(推荐 22 LTS);本脚本不代装 Node
|
||||
#
|
||||
# 用法(在仓库根目录执行,需要 root):
|
||||
# sudo bash deploy/install-linux.sh # 安装/升级,默认端口 4000、目录 /opt/classhelper
|
||||
# sudo bash deploy/install-linux.sh --port 8080 # 换端口
|
||||
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
|
||||
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,不装 systemd(自己接管进程)
|
||||
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node 版本/端口探针
|
||||
# sudo bash deploy/install-linux.sh --uninstall # 停止并禁用服务(保留 data 与 .env)
|
||||
#
|
||||
# 升级:把新代码构建好后**再跑一次同样的命令**即可。脚本不会覆盖已有 .env(JWT 密钥与端口保持
|
||||
# 不变,用户不用重新登录),只会补齐缺失项并覆盖程序文件。
|
||||
#
|
||||
# 注意:本脚本未在 Linux 实机验证过(开发机是 Windows,无 Docker/WSL 发行版)。
|
||||
# 它复用的是**已验证**的两套布局:Windows 免安装目录(install/ 结构)与 deploy/Dockerfile
|
||||
# 的 runtime 阶段(package.runtime.json + prisma/migrations + web/)。
|
||||
# 两者参数完全一致:
|
||||
# sudo bash deploy/install-linux.sh --port 8080
|
||||
# 等价于
|
||||
# sudo bash deploy/install.sh --port 8080
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_DIR="/opt/classhelper"
|
||||
SERVICE_USER="classhelper"
|
||||
PORT="4000"
|
||||
WITH_SERVICE=1
|
||||
UNINSTALL=0
|
||||
CHECK_ONLY=0
|
||||
SERVICE_NAME="classhelper"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) TARGET_DIR="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||
--no-service) WITH_SERVICE=0; shift ;;
|
||||
--check) CHECK_ONLY=1; shift ;;
|
||||
--uninstall) UNINSTALL=1; shift ;;
|
||||
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数:$1(用 --help 看用法)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { printf '\033[32m[classhelper]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33m[classhelper]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31m[classhelper]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# 就绪探针:优先 curl(几乎每台 Linux 都有),否则退回 node。
|
||||
# 注意不要用 `node -e "...process.exit()"` —— 在部分 Node 版本上于 promise 回调里硬退会触发
|
||||
# libuv 断言崩溃(本机 Windows 实测退出码 -1073740791),改成设置 process.exitCode 让事件循环自然结束。
|
||||
probe_ready() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
"$NODE_BIN" -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>{process.exitCode=r.ok?0:1}).catch(()=>{process.exitCode=1})" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
[ "$(id -u)" = "0" ] || { [ "$CHECK_ONLY" = "1" ] || die "请用 root 执行:sudo bash deploy/install-linux.sh"; }
|
||||
|
||||
# ---------------------------------------------------------------- 卸载分支
|
||||
if [ "$UNINSTALL" = "1" ]; then
|
||||
if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then
|
||||
systemctl disable --now "$SERVICE_NAME" || true
|
||||
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
systemctl daemon-reload
|
||||
log "已停止并移除 systemd 服务 ${SERVICE_NAME}"
|
||||
else
|
||||
warn "没有找到 ${SERVICE_NAME}.service(可能没装成服务)"
|
||||
fi
|
||||
log "程序文件与数据保留在 ${TARGET_DIR};确认不需要后再手动删除"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 定位仓库与产物
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SERVER_DIST="packages/server/dist/index.js"
|
||||
SERVER_PRISMA="packages/server/prisma"
|
||||
WEB_DIST="packages/web-admin/dist/index.html"
|
||||
SHARED_DIST="packages/shared/dist/index.js"
|
||||
RUNTIME_PKG="deploy/package.runtime.json"
|
||||
|
||||
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG"; do
|
||||
[ -e "$f" ] || die "缺少构建产物:$f
|
||||
请先在仓库根目录执行:
|
||||
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build"
|
||||
done
|
||||
[ -d "$SERVER_PRISMA/migrations" ] || die "缺少迁移文件目录:$SERVER_PRISMA/migrations"
|
||||
|
||||
# ---------------------------------------------------------------- Node 检查
|
||||
NODE_BIN="$(command -v node || true)"
|
||||
[ -n "$NODE_BIN" ] || die "未找到 node,请先安装 Node.js ≥ 20.19(推荐 22 LTS)"
|
||||
NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]')"
|
||||
NODE_MINOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[1]')"
|
||||
if [ "$NODE_MAJOR" -lt 20 ] || { [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -lt 19 ]; }; then
|
||||
die "Node 版本过低:$("$NODE_BIN" -v)(要求 ≥ 20.19)"
|
||||
fi
|
||||
NPM_BIN="$(command -v npm || true)"
|
||||
[ -n "$NPM_BIN" ] || die "未找到 npm(随 Node 一起安装)"
|
||||
log "Node $("$NODE_BIN" -v) → $NODE_BIN"
|
||||
|
||||
# ---------------------------------------------------------------- 只体检
|
||||
if [ "$CHECK_ONLY" = "1" ]; then
|
||||
echo
|
||||
echo "构建产物:"
|
||||
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG" "$SERVER_PRISMA/migrations"; do
|
||||
printf ' %-42s %s\n' "$f" "OK"
|
||||
done
|
||||
printf ' %-42s %s\n' "迁移文件数量" "$(find "$SERVER_PRISMA/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||
echo
|
||||
echo "运行环境:"
|
||||
printf ' %-42s %s\n' "node" "$("$NODE_BIN" -v)(要求 ≥ 20.19)"
|
||||
printf ' %-42s %s\n' "npm" "$("$NPM_BIN" -v)"
|
||||
printf ' %-42s %s\n' "systemd" "$(command -v systemctl >/dev/null 2>&1 && echo 可用 || echo '不可用(用 --no-service)')"
|
||||
printf ' %-42s %s\n' "目标目录 ${TARGET_DIR}" "$([ -e "$TARGET_DIR" ] && echo '已存在(会就地升级,保留 .env)' || echo 尚未创建)"
|
||||
printf ' %-42s %s\n' "端口 ${PORT} 上的既有服务" "$(probe_ready && echo '健康(就绪)' || echo '无响应')"
|
||||
echo
|
||||
log "--check 通过:构建产物与运行环境都满足,可以执行安装(sudo bash deploy/install-linux.sh)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 用户与目录
|
||||
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null \
|
||||
|| useradd --system --shell /sbin/nologin "$SERVICE_USER"
|
||||
log "已创建系统用户 ${SERVICE_USER}"
|
||||
fi
|
||||
|
||||
mkdir -p "${TARGET_DIR}/server" "${TARGET_DIR}/web" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
|
||||
log "安装目录:${TARGET_DIR}"
|
||||
|
||||
# ---------------------------------------------------------------- 拷贝产物
|
||||
rm -rf "${TARGET_DIR}/server/dist"
|
||||
cp -R "packages/server/dist" "${TARGET_DIR}/server/dist"
|
||||
rm -rf "${TARGET_DIR}/server/prisma"
|
||||
cp -R "$SERVER_PRISMA" "${TARGET_DIR}/server/prisma"
|
||||
# 运行时不读 schema,但保留一份便于排查
|
||||
[ -f packages/server/prisma/schema.prisma ] && cp packages/server/prisma/schema.prisma "${TARGET_DIR}/server/prisma/schema.prisma"
|
||||
|
||||
rm -rf "${TARGET_DIR}/web"
|
||||
cp -R "packages/web-admin/dist" "${TARGET_DIR}/web"
|
||||
|
||||
cp "$RUNTIME_PKG" "${TARGET_DIR}/package.json"
|
||||
cp -R deploy/classhelper.service "${TARGET_DIR}/classhelper.service.template"
|
||||
|
||||
# 生产依赖(与 Dockerfile 的 runtime 阶段同一份清单)
|
||||
if [ ! -d "${TARGET_DIR}/node_modules" ] || [ "${FORCE_NPM_INSTALL:-0}" = "1" ]; then
|
||||
log "安装生产依赖(npm install --omit=dev,约 1~3 分钟)..."
|
||||
( cd "$TARGET_DIR" && "$NPM_BIN" install --omit=dev --no-audit --no-fund )
|
||||
else
|
||||
log "复用已有 node_modules(如需强制重装:FORCE_NPM_INSTALL=1 再跑一次)"
|
||||
fi
|
||||
|
||||
# workspace 共享包(后端 import '@classhelper/shared'):拷进 node_modules 里
|
||||
mkdir -p "${TARGET_DIR}/node_modules/@classhelper/shared"
|
||||
rm -rf "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
|
||||
cp -R "packages/shared/dist" "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
|
||||
cp packages/shared/package.json "${TARGET_DIR}/node_modules/@classhelper/shared/package.json"
|
||||
|
||||
# ---------------------------------------------------------------- .env(已存在则不覆盖)
|
||||
ENV_FILE="${TARGET_DIR}/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
log "检测到已有 .env:保留现有配置(含 JWT_SECRET),只补齐缺失项"
|
||||
else
|
||||
JWT_SECRET="$("$NODE_BIN" -e 'console.log(require("crypto").randomBytes(48).toString("hex"))')"
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# 班级小助手服务端(install-linux.sh 生成,请妥善保管)
|
||||
# 修改后重启服务生效:systemctl restart ${SERVICE_NAME}
|
||||
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=${PORT}
|
||||
|
||||
# 数据库:SQLite 文件放在安装根目录的 data 下(备份只需复制该目录)
|
||||
DATABASE_PROVIDER=sqlite
|
||||
DATABASE_URL="file:../data/classhelper.db"
|
||||
# 空库首启动自动执行随包迁移并创建管理员
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
# 令牌签名密钥(随机生成;更换后所有用户需重新登录)
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 允许的跨域来源:走域名/反代时建议改成具体来源,例如 https://class.example.com
|
||||
CORS_ORIGIN=*
|
||||
# 前面有 Nginx 等反向代理时设为 1,限流才会按真实客户端 IP 统计
|
||||
TRUST_PROXY=1
|
||||
|
||||
RATE_LIMIT_ENABLED=true
|
||||
STARTUP_DB_CHECK=true
|
||||
PID_FILE=../data/server.pid
|
||||
LOG_LEVEL=info
|
||||
|
||||
BCRYPT_ROUNDS=10
|
||||
DEFAULT_STUDENT_PASSWORD=123456
|
||||
DEFAULT_CLASS_PASSWORD=123456
|
||||
DEFAULT_TEACHER_PASSWORD=123456
|
||||
TERM_START_DATE=
|
||||
EOF
|
||||
log "已生成 ${ENV_FILE}(含随机 JWT_SECRET)"
|
||||
fi
|
||||
|
||||
# data/logs 必须可写;程序文件只读即可
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/node_modules"
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "$ENV_FILE"
|
||||
chown -R root:root "${TARGET_DIR}/server" "${TARGET_DIR}/web"
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
# ---------------------------------------------------------------- systemd
|
||||
if [ "$WITH_SERVICE" = "1" ]; then
|
||||
command -v systemctl >/dev/null 2>&1 || die "本机没有 systemd;请加 --no-service 自行托管进程"
|
||||
|
||||
sed -e "s#__NODE__#${NODE_BIN}#g" \
|
||||
-e "s#__DIR__#${TARGET_DIR}#g" \
|
||||
-e "s#__USER__#${SERVICE_USER}#g" \
|
||||
deploy/classhelper.service > "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME" >/dev/null
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
log "等待服务就绪..."
|
||||
ready=0
|
||||
for _ in $(seq 1 60); do
|
||||
if probe_ready; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ready" != "1" ]; then
|
||||
warn "60 秒内没通过 /healthz,最近日志:"
|
||||
journalctl -u "$SERVICE_NAME" -n 50 --no-pager || true
|
||||
die "启动失败,请按上面的日志排查(常见:端口被占用、数据目录权限、JWT_SECRET 未生成)"
|
||||
fi
|
||||
|
||||
log "服务已就绪 ✅"
|
||||
else
|
||||
log "已跳过 systemd(--no-service)。手动启动:"
|
||||
echo " sudo -u ${SERVICE_USER} sh -c 'cd ${TARGET_DIR} && ${NODE_BIN} server/dist/index.js'"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 结果
|
||||
cat <<EOF
|
||||
|
||||
------------------------------------------------------------------
|
||||
安装完成
|
||||
访问管理端:http://<服务器IP>:${PORT}/
|
||||
健康探针: http://<服务器IP>:${PORT}/healthz · /readyz
|
||||
配置文件: ${ENV_FILE}
|
||||
数据目录: ${TARGET_DIR}/data(备份只需复制这里)
|
||||
查看日志: journalctl -u ${SERVICE_NAME} -f
|
||||
重启服务: systemctl restart ${SERVICE_NAME}
|
||||
|
||||
首次启动会自动建表并创建管理员:admin / admin123(登录后请立即在右上角「修改密码」改掉)。
|
||||
公网部署请在前面加 Nginx + HTTPS(deploy/nginx.conf 可直接用),并把 .env 里的
|
||||
TRUST_PROXY 设为 1、CORS_ORIGIN 收敛到你的域名。
|
||||
学生端客户端登录时填的「服务器地址」就是 https://你的域名(或 http://IP:${PORT})。
|
||||
------------------------------------------------------------------
|
||||
EOF
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
printf '\033[33m[classhelper]\033[0m install-linux.sh 已更名为 install.sh,本次自动转发(后续请直接用 install.sh)\n' >&2
|
||||
exec bash "${DIR}/install.sh" "$@"
|
||||
@@ -0,0 +1,19 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 日志轮转
|
||||
# 由 deploy/install.sh 安装到 /etc/logrotate.d/classhelper
|
||||
#
|
||||
# copytruncate 而不是 create+信号:安装器与 classhelper 都是"追加写完就关"
|
||||
# (`>>` 打开、写完立即关闭),没有常驻 fd 可通知,copytruncate 是这里唯一正确的做法。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
/var/log/classhelper/*.log {
|
||||
weekly
|
||||
rotate 8
|
||||
missingok
|
||||
notifempty
|
||||
compress
|
||||
delaycompress
|
||||
copytruncate
|
||||
create 0600 root root
|
||||
su root root
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "classhelper-server-runtime",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "班级小助手服务端运行时(安装包 / 容器内置)",
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@prisma/adapter-libsql": "7.10.0",
|
||||
"@prisma/adapter-mariadb": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"compression": "^1.8.2",
|
||||
@@ -16,7 +17,9 @@
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"helmet": "^8.3.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"prisma": "7.10.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"typescript": "~5.9.3",
|
||||
"xlsx": "0.18.5",
|
||||
"zod": "^4.6.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 登录 shell 环境变量
|
||||
#
|
||||
# 由 deploy/install.sh 安装到 /etc/profile.d/classhelper.sh(占位符替换成真实路径)。
|
||||
# 生效方式:重新登录,或 `source /etc/profile.d/classhelper.sh`。
|
||||
#
|
||||
# 说明:`classhelper` 命令本身装在 /usr/local/bin(软链到 $CLASSHELPER_HOME/bin/classhelper),
|
||||
# 不依赖这个文件也能用;这里提供的是路径类变量,方便写脚本时引用。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 只在目录真实存在时导出:卸载后残留的 profile.d 不应该污染别人的环境
|
||||
if [ -d '__DIR__' ]; then
|
||||
export CLASSHELPER_HOME='__DIR__'
|
||||
export CLASSHELPER_CONFIG='__CONFIG__'
|
||||
export CLASSHELPER_LOG_DIR='__LOGDIR__'
|
||||
export CLASSHELPER_BACKUP_DIR='__BACKUPDIR__'
|
||||
export CLASSHELPER_SERVICE='classhelper'
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":${CLASSHELPER_HOME}/bin:"*) ;;
|
||||
*) export PATH="${PATH}:${CLASSHELPER_HOME}/bin" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# 让 `classhelper` 在非 root 登录时也能补全(bash 补全由 `classhelper completion bash` 输出)
|
||||
if [ -n "${BASH_VERSION:-}" ] && [ -r /usr/share/bash-completion/completions/classhelper ]; then
|
||||
. /usr/share/bash-completion/completions/classhelper
|
||||
fi
|
||||
@@ -0,0 +1,22 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 定时备份(systemd timer)
|
||||
# 由 `classhelper backup schedule daily|weekly` 安装(占位符 __BIN__ / __KEEP__ 由命令替换)
|
||||
#
|
||||
# 手工安装:
|
||||
# sudo sed -e 's#__BIN__#/usr/local/bin/classhelper#' -e 's#__KEEP__#7#' \
|
||||
# deploy/systemd/classhelper-backup.service > /tmp/x.service ...
|
||||
# 然后 systemctl daemon-reload && systemctl enable --now classhelper-backup.timer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[Unit]
|
||||
Description=ClassHelper 定时备份
|
||||
Documentation=https://github.com/LoveChengke/ClassHelper
|
||||
After=classhelper.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=__BIN__ backup --quiet --keep __KEEP__
|
||||
# 备份失败时不要静默:journalctl -u classhelper-backup 里能看到退出码与原因
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=classhelper-backup
|
||||
@@ -0,0 +1,19 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · 定时备份(触发器)
|
||||
# 由 `classhelper backup schedule daily|weekly` 安装;OnCalendar 行由命令按选择替换
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[Unit]
|
||||
Description=ClassHelper 定时备份触发器
|
||||
Documentation=https://github.com/LoveChengke/ClassHelper
|
||||
|
||||
[Timer]
|
||||
OnCalendar=__ONCALENDAR__
|
||||
# 错开整点:避免所有装了本服务的机器在同一秒打盘
|
||||
RandomizedDelaySec=30m
|
||||
# 机器关机期间错过的备份在开机后补一次
|
||||
Persistent=true
|
||||
Unit=classhelper-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 班级小助手 · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs)
|
||||
*
|
||||
* 为什么不用 HTTP API 改密码:`classhelper password` 要能在**服务停机**时救急,
|
||||
* 而调 API 必须先有一个能登录的账号 —— 管理员忘了密码时就是死锁。
|
||||
* 因此这里直连数据库,复用服务端自己的 Prisma Client 与 bcrypt 参数(`server/dist/lib/*`),
|
||||
* 保证写入的哈希与登录校验用的是同一套实现。
|
||||
*
|
||||
* 安全约定(务必保持):
|
||||
* - **密码只从 stdin 读**,绝不接受 argv / 环境变量形式的密码(否则会进 shell history 与 ps 输出);
|
||||
* - 本工具自己**不打印任何密码**,也不写任何日志;
|
||||
* - `dump-hash` 会输出 bcrypt 哈希 —— 仅由 root 调用的 `classhelper password` 用于改前备份,
|
||||
* 调用方负责把结果落到 600 权限的文件里。
|
||||
*
|
||||
* 用法(全部由 classhelper 封装,一般不需要手工调):
|
||||
* node tools/admin-cli.mjs info
|
||||
* node tools/admin-cli.mjs list [--json]
|
||||
* node tools/admin-cli.mjs dump-hash <用户名|class:班级码>
|
||||
* node tools/admin-cli.mjs set-password <用户名> # 新密码从 stdin 读
|
||||
* node tools/admin-cli.mjs set-class-password <班级码> # 新密码从 stdin 读
|
||||
* node tools/admin-cli.mjs verify <用户名> # 待校验密码从 stdin 读
|
||||
* node tools/admin-cli.mjs restore-hash <备份json路径>
|
||||
* node tools/admin-cli.mjs snapshot <目标db文件> # SQLite 一致性快照(VACUUM INTO)
|
||||
*
|
||||
* 退出码:0 成功 / 1 通用失败 / 2 用法错误 / 3 目标不存在 / 4 密码不合规 / 5 数据库不可用
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
/** 安装根目录(tools/ 的上一级) */
|
||||
const installDir = path.resolve(scriptDir, '..');
|
||||
const serverDist = path.join(installDir, 'server', 'dist');
|
||||
|
||||
const EXIT = { OK: 0, FAIL: 1, USAGE: 2, NOT_FOUND: 3, WEAK: 4, NO_DB: 5 };
|
||||
|
||||
function fail(message, code = EXIT.FAIL) {
|
||||
process.stderr.write(`admin-cli: ${message}\n`);
|
||||
process.exitCode = code;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 一次性读完 stdin(去掉行尾换行;保留内部空白,密码里可以有空格) */
|
||||
async function readSecretFromStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
|
||||
}
|
||||
|
||||
/** 与服务端一致的密码强度规则(classhelper 里还有一份交互式版本,两边保持同样口径) */
|
||||
function checkStrength(password, username) {
|
||||
const problems = [];
|
||||
if (password.length < 8) problems.push('长度至少 8 位');
|
||||
if (password.length > 128) problems.push('长度不要超过 128 位');
|
||||
const kinds = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
|
||||
if (kinds < 3) problems.push('需包含小写字母 / 大写字母 / 数字 / 符号 中至少 3 类');
|
||||
if (username && password.toLowerCase() === username.toLowerCase()) problems.push('不能与用户名相同');
|
||||
if (/^(123456|password|admin|classhelper|qwerty|111111|abc123)/i.test(password) && password.length < 12) {
|
||||
problems.push('过于常见,请换一个');
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** 懒加载服务端模块:先做"库在不在"的检查,避免 libSQL 在连接时凭空建出一个空库 */
|
||||
async function loadServerModules() {
|
||||
const envFile = path.join(serverDist, 'config', 'env.js');
|
||||
if (!fs.existsSync(envFile)) {
|
||||
fail(`找不到服务端产物:${envFile}`, EXIT.NO_DB);
|
||||
return null;
|
||||
}
|
||||
const env = await import(pathToFileURL(envFile).href);
|
||||
if (env.env.databaseProvider === 'sqlite') {
|
||||
// classhelper 做备份时会用 CLASSHELPER_SNAPSHOT_DB 显式指定数据文件(跳过硬编码路径假设)
|
||||
const dbFile = process.env.CLASSHELPER_SNAPSHOT_DB || env.env.sqliteFilePath;
|
||||
if (!dbFile || !fs.existsSync(dbFile)) {
|
||||
fail(
|
||||
`数据库文件不存在:${dbFile || '(未配置)'}\n` +
|
||||
' 提示:全新安装请先 systemctl start classhelper 完成建表(AUTO_MIGRATE);',
|
||||
EXIT.NO_DB,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const db = await import(pathToFileURL(path.join(serverDist, 'lib', 'db.js')).href);
|
||||
const password = await import(pathToFileURL(path.join(serverDist, 'lib', 'password.js')).href);
|
||||
return { env: env.env, prisma: db.prisma, disconnectPrisma: db.disconnectPrisma, ...password };
|
||||
}
|
||||
|
||||
/** 目标账号:普通用户按 username,班级账号按 Class.code(学生端登录用的是班级码) */
|
||||
async function resolveTarget(prisma, key) {
|
||||
if (key.startsWith('class:')) {
|
||||
const code = key.slice('class:'.length).toUpperCase();
|
||||
const klass = await prisma.class.findUnique({ where: { code } });
|
||||
if (!klass) return { error: `班级码不存在:${code}`, code: EXIT.NOT_FOUND };
|
||||
return { kind: 'class', id: klass.id, label: `班级 ${klass.name}(码 ${klass.code})`, hash: klass.passwordHash };
|
||||
}
|
||||
const user = await prisma.user.findUnique({ where: { username: key } });
|
||||
if (!user) return { error: `用户名不存在:${key}`, code: EXIT.NOT_FOUND };
|
||||
if (user.role === 'STUDENT') {
|
||||
return {
|
||||
error: `学生没有个人账号(${key} 只是名单记录),学生端用「班级码 + 班级密码」登录,请用 --class 改班级密码`,
|
||||
code: EXIT.NOT_FOUND,
|
||||
};
|
||||
}
|
||||
return { kind: 'user', id: user.id, label: `${user.name}(${user.username} / ${user.role})`, hash: user.passwordHash };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 子命令 */
|
||||
|
||||
/**
|
||||
* SQLite 一致性快照:`VACUUM INTO` 在服务运行中也能拿到自洽的副本
|
||||
* (直接 cp 数据文件可能抓到写入中的中间状态)。
|
||||
* 目标文件必须不存在 —— 这是 SQLite 的硬要求,先删掉。
|
||||
*/
|
||||
async function cmdSnapshot(modules, dest) {
|
||||
const { env, prisma } = modules;
|
||||
if (env.databaseProvider !== 'sqlite') {
|
||||
return fail('snapshot 只支持 SQLite;MySQL 请用 mysqldump', EXIT.FAIL);
|
||||
}
|
||||
const target = path.resolve(dest);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.rmSync(target, { force: true });
|
||||
// VACUUM INTO 不接受参数占位符,只能拼字面量 —— 单引号按 SQL 规则翻倍转义
|
||||
await prisma.$executeRawUnsafe(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
||||
if (!fs.existsSync(target)) return fail('快照未生成(VACUUM INTO 未报错但没有产出文件)', EXIT.FAIL);
|
||||
process.stdout.write(`${target}\n`);
|
||||
}
|
||||
|
||||
async function cmdInfo(modules) {
|
||||
const { env, prisma } = modules;
|
||||
const counts = {};
|
||||
for (const model of ['user', 'class', 'course', 'schedule', 'homework', 'notification', 'grade']) {
|
||||
counts[model] = await prisma[model].count();
|
||||
}
|
||||
const payload = {
|
||||
provider: env.databaseProvider,
|
||||
sqliteFile: env.sqliteFilePath || null,
|
||||
serverRoot: env.serverRoot,
|
||||
termStartDate: env.termStartDate || null,
|
||||
counts,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function cmdList(modules, asJson) {
|
||||
const { prisma } = modules;
|
||||
const users = await prisma.user.findMany({
|
||||
where: { role: { in: ['ADMIN', 'TEACHER'] } },
|
||||
select: { username: true, name: true, role: true, classId: true, updatedAt: true },
|
||||
orderBy: [{ role: 'asc' }, { username: 'asc' }],
|
||||
});
|
||||
const classes = await prisma.class.findMany({
|
||||
select: { code: true, name: true, passwordHash: true },
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
if (asJson) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
users,
|
||||
classes: classes.map((item) => ({ code: item.code, name: item.name, hasPassword: !!item.passwordHash })),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write('可登录账号(教师 / 管理员):\n');
|
||||
for (const user of users) {
|
||||
process.stdout.write(` ${user.role.padEnd(8)} ${user.username.padEnd(16)} ${user.name}\n`);
|
||||
}
|
||||
process.stdout.write('\n班级账号(学生端用「班级码 + 班级密码」登录):\n');
|
||||
for (const item of classes) {
|
||||
const state = item.passwordHash ? '已设置' : '⚠ 未设置密码(该班当前无法登录)';
|
||||
process.stdout.write(` ${item.code.padEnd(8)} ${item.name.padEnd(16)} ${state}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdDumpHash(modules, key) {
|
||||
const { prisma } = modules;
|
||||
const target = await resolveTarget(prisma, key);
|
||||
if (target.error) return fail(target.error, target.code);
|
||||
if (!target.hash) return fail(`${target.label} 当前没有密码(passwordHash 为空)`, EXIT.NOT_FOUND);
|
||||
// 只给调用方(root)看的原始数据,不落到本工具的日志里
|
||||
process.stdout.write(`${JSON.stringify({ target: key, kind: target.kind, id: target.id, hash: target.hash })}\n`);
|
||||
}
|
||||
|
||||
async function cmdSetPassword(modules, key, readSecret) {
|
||||
const { prisma, hashPassword } = modules;
|
||||
const target = await resolveTarget(prisma, key);
|
||||
if (target.error) return fail(target.error, target.code);
|
||||
|
||||
const secret = await readSecret();
|
||||
const problems = checkStrength(secret, key);
|
||||
if (problems.length > 0) return fail(`密码不合规:${problems.join(';')}`, EXIT.WEAK);
|
||||
|
||||
const hash = await hashPassword(secret);
|
||||
if (target.kind === 'class') {
|
||||
await prisma.class.update({ where: { id: target.id }, data: { passwordHash: hash } });
|
||||
} else {
|
||||
await prisma.user.update({ where: { id: target.id }, data: { passwordHash: hash } });
|
||||
}
|
||||
|
||||
// 写完立刻复核一次:曾经出现过"bcrypt 参数不一致导致写进去的哈希验证不过"的排查成本
|
||||
const ok = await modules.verifyPassword(secret, hash);
|
||||
if (!ok) return fail('写入后复核失败:哈希无法通过校验,请用 restore-hash 回滚', EXIT.FAIL);
|
||||
|
||||
process.stdout.write(`已更新密码:${target.label}\n`);
|
||||
}
|
||||
|
||||
async function cmdVerify(modules, key, readSecret) {
|
||||
const { prisma, verifyPassword } = modules;
|
||||
const target = await resolveTarget(prisma, key);
|
||||
if (target.error) return fail(target.error, target.code);
|
||||
const secret = await readSecret();
|
||||
const ok = !!target.hash && (await verifyPassword(secret, target.hash));
|
||||
process.stdout.write(`${ok ? 'match' : 'mismatch'}\n`);
|
||||
if (!ok) process.exitCode = EXIT.FAIL;
|
||||
}
|
||||
|
||||
async function cmdRestoreHash(modules, backupFile) {
|
||||
const { prisma } = modules;
|
||||
if (!fs.existsSync(backupFile)) return fail(`备份文件不存在:${backupFile}`, EXIT.NOT_FOUND);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(fs.readFileSync(backupFile, 'utf8'));
|
||||
} catch (error) {
|
||||
return fail(`备份文件不是合法 JSON:${error.message}`);
|
||||
}
|
||||
const { target, hash } = payload;
|
||||
if (typeof target !== 'string' || typeof hash !== 'string') {
|
||||
return fail('备份文件缺少 target / hash 字段,拒绝回滚');
|
||||
}
|
||||
if (target.startsWith('class:')) {
|
||||
await prisma.class.update({ where: { code: target.slice('class:'.length).toUpperCase() }, data: { passwordHash: hash } });
|
||||
} else {
|
||||
await prisma.user.update({ where: { username: target }, data: { passwordHash: hash } });
|
||||
}
|
||||
process.stdout.write(`已回滚密码哈希:${target}\n`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 入口 */
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const command = argv[0] ?? 'help';
|
||||
const args = argv.slice(1);
|
||||
const wantsJson = args.includes('--json');
|
||||
const positional = args.filter((item) => !item.startsWith('--'));
|
||||
|
||||
function usage() {
|
||||
process.stdout.write(
|
||||
[
|
||||
'classhelper 离线账号工具(一般由 classhelper 命令调用)',
|
||||
'',
|
||||
' info 输出数据库与表行数(JSON)',
|
||||
' list [--json] 列出可登录账号与班级账号',
|
||||
' dump-hash <用户名|class:班级码> 输出当前密码哈希(供改密前备份)',
|
||||
' set-password <用户名> 从 stdin 读新密码并写入',
|
||||
' set-class-password <班级码> 从 stdin 读新密码并写入班级账号',
|
||||
' verify <用户名> 从 stdin 读密码,输出 match / mismatch',
|
||||
' restore-hash <备份json路径> 用备份文件里的哈希回滚',
|
||||
' snapshot <目标db文件> 把 SQLite 库一致性快照到指定路径(VACUUM INTO)',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (command === 'help' || command === '--help' || command === '-h') {
|
||||
usage();
|
||||
} else if (
|
||||
![
|
||||
'info',
|
||||
'list',
|
||||
'dump-hash',
|
||||
'set-password',
|
||||
'set-class-password',
|
||||
'verify',
|
||||
'restore-hash',
|
||||
'snapshot',
|
||||
].includes(command)
|
||||
) {
|
||||
fail(`未知子命令:${command}\n`, EXIT.USAGE);
|
||||
usage();
|
||||
} else {
|
||||
const modules = await loadServerModules();
|
||||
if (!modules) {
|
||||
// 已经在 loadServerModules 里设了 exitCode
|
||||
} else {
|
||||
try {
|
||||
switch (command) {
|
||||
case 'info':
|
||||
await cmdInfo(modules);
|
||||
break;
|
||||
case 'list':
|
||||
await cmdList(modules, wantsJson);
|
||||
break;
|
||||
case 'dump-hash':
|
||||
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
|
||||
else await cmdDumpHash(modules, positional[0]);
|
||||
break;
|
||||
case 'set-password':
|
||||
case 'set-class-password': {
|
||||
const key = command === 'set-class-password' ? `class:${positional[0] ?? ''}` : (positional[0] ?? '');
|
||||
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
|
||||
else await cmdSetPassword(modules, key, readSecretFromStdin);
|
||||
break;
|
||||
}
|
||||
case 'verify':
|
||||
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
|
||||
else await cmdVerify(modules, positional[0], readSecretFromStdin);
|
||||
break;
|
||||
case 'restore-hash':
|
||||
if (!positional[0]) fail('缺少备份文件路径', EXIT.USAGE);
|
||||
else await cmdRestoreHash(modules, positional[0]);
|
||||
break;
|
||||
case 'snapshot':
|
||||
if (!positional[0]) fail('缺少目标文件路径', EXIT.USAGE);
|
||||
else await cmdSnapshot(modules, positional[0]);
|
||||
break;
|
||||
default:
|
||||
usage();
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
// 不用 process.exit():在顶层 await 里硬退会踩 libuv 断言(见 AGENTS.md §7)
|
||||
await modules.disconnectPrisma();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# 班级小助手 · Linux 安装与运维链路验收(在**已安装的服务器上**运行)
|
||||
#
|
||||
# 覆盖:安装结果 → 服务可用性 → API 登录 → 改密(含回滚与审计)→ 备份 →
|
||||
# 配置读写 → key rotate →(可选)升级 / 回滚 / 卸载
|
||||
#
|
||||
# 用法(在服务器上,需要 root):
|
||||
# sudo bash verify-linux.sh # 基础链路
|
||||
# sudo bash verify-linux.sh --full --package /tmp/xxx.tar.gz # 追加升级/回滚
|
||||
# sudo bash verify-linux.sh --admin-password-stdin # 密码从 stdin 读一行(不进 argv/history)
|
||||
#
|
||||
# 说明:
|
||||
# - 本脚本会**真的**改一次管理员密码(换成随机强密码,再改回去),并真的发一条备份;
|
||||
# - 除了 --purge 的卸载测试,不会删除任何数据;
|
||||
# - 全程不需要外网(升级测试用 --package 指向本地包)。
|
||||
# ---------------------------------------------------------------------------
|
||||
set -uo pipefail
|
||||
|
||||
INSTALL_DIR="/opt/classhelper"
|
||||
CONFIG_FILE="/etc/classhelper/config.env"
|
||||
LOG_DIR="/var/log/classhelper"
|
||||
BACKUP_DIR="/var/backups/classhelper"
|
||||
PORT=""
|
||||
ADMIN_USER="admin"
|
||||
ADMIN_PASSWORD=""
|
||||
ADMIN_PASSWORD_FROM_STDIN=0
|
||||
DO_FULL=0
|
||||
PACKAGE=""
|
||||
|
||||
TOTAL=0; PASS=0; FAIL=0
|
||||
FAILED=()
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
--config) CONFIG_FILE="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--user) ADMIN_USER="$2"; shift 2 ;;
|
||||
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
|
||||
--admin-password-stdin) ADMIN_PASSWORD_FROM_STDIN=1; shift ;;
|
||||
--package) PACKAGE="$2"; shift 2 ;;
|
||||
--full) DO_FULL=1; shift ;;
|
||||
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数:$1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -t 1 ]; then G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; D=$'\033[2m'; B=$'\033[1m'; N=$'\033[0m'; else G=; R=; Y=; D=; B=; N=; fi
|
||||
|
||||
section() { printf '\n%s▶ %s%s\n' "$B" "$1" "$N"; }
|
||||
check() { # $1=描述 $2=0 通过
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if [ "$2" = "0" ]; then
|
||||
PASS=$((PASS + 1)); printf ' %s✔%s %s\n' "$G" "$N" "$1"
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAILED+=("$1"); printf ' %s✘%s %s\n' "$R" "$N" "$1"
|
||||
fi
|
||||
}
|
||||
note() { printf ' %s· %s%s\n' "$D" "$1" "$N"; }
|
||||
|
||||
[ "$(id -u)" = "0" ] || { echo "请用 root 运行:sudo bash verify-linux.sh" >&2; exit 1; }
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "找不到安装:${CONFIG_FILE} 不存在(用 --config 指定,或先跑 install.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
read_cfg() { sed -n "s/^[[:space:]]*$1=//p" "$CONFIG_FILE" | tail -n 1 | tr -d '"'; }
|
||||
[ -n "$PORT" ] || PORT="$(read_cfg PORT)"
|
||||
[ -n "$PORT" ] || PORT=4000
|
||||
CLASSHELPER="${INSTALL_DIR}/bin/classhelper"
|
||||
API="http://127.0.0.1:${PORT}"
|
||||
CURL=(curl -fsS --noproxy '*')
|
||||
|
||||
if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then IFS= read -r ADMIN_PASSWORD || true; fi
|
||||
|
||||
printf '%s班级小助手 · Linux 安装验收%s\n' "$B" "$N"
|
||||
note "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_FILE}|端口 ${PORT}"
|
||||
|
||||
# ---------------------------------------------------------------- 1. 安装结果
|
||||
|
||||
section "安装结果"
|
||||
check "安装目录存在且含 server/dist/index.js" "$([ -f "${INSTALL_DIR}/server/dist/index.js" ] && echo 0 || echo 1)"
|
||||
check "版本文件 VERSION 可读" "$([ -s "${INSTALL_DIR}/VERSION" ] && echo 0 || echo 1)"
|
||||
check "安装元数据 .installed.json 存在" "$([ -f "${INSTALL_DIR}/.installed.json" ] && echo 0 || echo 1)"
|
||||
|
||||
cfg_mode="$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || echo '?')"
|
||||
check "配置文件权限 600(实际 ${cfg_mode})" "$([ "$cfg_mode" = "600" ] && echo 0 || echo 1)"
|
||||
dir_mode="$(stat -c '%a' "$(dirname "$CONFIG_FILE")" 2>/dev/null || echo '?')"
|
||||
check "配置目录权限 700(实际 ${dir_mode})" "$([ "$dir_mode" = "700" ] && echo 0 || echo 1)"
|
||||
|
||||
env_link="${INSTALL_DIR}/.env"
|
||||
check "安装目录 .env 是指向配置真身的软链(Web 端切库依赖它)" \
|
||||
"$([ -L "$env_link" ] && [ "$(readlink "$env_link")" = "$CONFIG_FILE" ] && echo 0 || echo 1)"
|
||||
|
||||
node_bin="${INSTALL_DIR}/runtime/node/bin/node"
|
||||
node_ver="$([ -x "$node_bin" ] && "$node_bin" -v 2>/dev/null || echo '')"
|
||||
check "内置 Node 可用(${node_ver:-未找到})" "$([ -n "$node_ver" ] && echo 0 || echo 1)"
|
||||
|
||||
check "classhelper 命令已装到 /usr/local/bin" "$([ -x /usr/local/bin/classhelper ] && echo 0 || echo 1)"
|
||||
check "安装日志 ${LOG_DIR}/install.log 存在" "$([ -s "${LOG_DIR}/install.log" ] && echo 0 || echo 1)"
|
||||
check "审计日志 ${LOG_DIR}/audit.log 存在" "$([ -f "${LOG_DIR}/audit.log" ] && echo 0 || echo 1)"
|
||||
check "审计/安装日志权限不放行他人(≤640)" \
|
||||
"$([ "$(stat -c '%a' "${LOG_DIR}/audit.log" 2>/dev/null || echo 644)" -le 640 ] && echo 0 || echo 1)"
|
||||
check "/etc/profile.d/classhelper.sh 已写入" "$([ -f /etc/profile.d/classhelper.sh ] && echo 0 || echo 1)"
|
||||
profile_home="$(bash -lc 'echo "${CLASSHELPER_HOME:-}"' 2>/dev/null)"
|
||||
check "新登录 shell 里 CLASSHELPER_HOME 生效(${profile_home:-空})" "$([ -n "$profile_home" ] && echo 0 || echo 1)"
|
||||
|
||||
# ---------------------------------------------------------------- 2. 服务
|
||||
|
||||
section "服务与探针"
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -f /etc/systemd/system/classhelper.service ]; then
|
||||
check "systemd 单元已安装" 0
|
||||
check "服务处于 active" "$(systemctl is-active --quiet classhelper && echo 0 || echo 1)"
|
||||
check "服务开机自启(enabled)" "$(systemctl is-enabled --quiet classhelper 2>/dev/null && echo 0 || echo 1)"
|
||||
else
|
||||
check "systemd 单元已安装" 1
|
||||
fi
|
||||
check "/healthz 返回 200" "$("${CURL[@]}" -o /dev/null "${API}/healthz" 2>/dev/null && echo 0 || echo 1)"
|
||||
|
||||
status_json="$("$CLASSHELPER" status --json 2>/dev/null || true)"
|
||||
check "classhelper status --json 是合法 JSON" "$(printf '%s' "$status_json" | grep -q '"healthy"' && echo 0 || echo 1)"
|
||||
check "status 报告 healthy=yes" "$(printf '%s' "$status_json" | grep -q '"healthy": "yes"' && echo 0 || echo 1)"
|
||||
check "status 报告的端口与实际一致" "$(printf '%s' "$status_json" | grep -q "\"port\": ${PORT}" && echo 0 || echo 1)"
|
||||
check "classhelper help 可执行(非 root 也能看)" "$("$CLASSHELPER" help >/dev/null 2>&1 && echo 0 || echo 1)"
|
||||
check "classhelper completion bash 有输出" "$("$CLASSHELPER" completion bash 2>/dev/null | grep -q 'classhelper' && echo 0 || echo 1)"
|
||||
|
||||
doctor_out="$("$CLASSHELPER" doctor 2>&1 || true)"
|
||||
check "classhelper doctor 无待处理项" "$(printf '%s' "$doctor_out" | grep -q '体检通过' && echo 0 || echo 1)"
|
||||
if ! printf '%s' "$doctor_out" | grep -q '体检通过'; then
|
||||
printf '%s' "$doctor_out" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 3. API
|
||||
|
||||
section "API 与登录"
|
||||
TOKEN=""
|
||||
login() { # $1=密码 → 输出 token(失败则空)
|
||||
"${CURL[@]}" -X POST "${API}/api/auth/login" -H 'content-type: application/json' \
|
||||
-d "{\"username\":\"${ADMIN_USER}\",\"password\":\"$1\"}" 2>/dev/null \
|
||||
| sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
|
||||
}
|
||||
|
||||
if [ -z "$ADMIN_PASSWORD" ]; then
|
||||
note "未提供 --admin-password(-stdin),跳过登录与改密用例"
|
||||
else
|
||||
TOKEN="$(login "$ADMIN_PASSWORD")"
|
||||
check "管理员用提供的密码能登录并拿到 token" "$([ -n "$TOKEN" ] && echo 0 || echo 1)"
|
||||
if [ -n "$TOKEN" ]; then
|
||||
classes="$("${CURL[@]}" -H "Authorization: Bearer ${TOKEN}" "${API}/api/classes" 2>/dev/null || true)"
|
||||
check "带 token 能读业务接口 /api/classes" "$(printf '%s' "$classes" | grep -q '"data"' && echo 0 || echo 1)"
|
||||
me="$("${CURL[@]}" -H "Authorization: Bearer ${TOKEN}" "${API}/api/auth/me" 2>/dev/null || true)"
|
||||
check "/api/auth/me 返回的是 ADMIN" "$(printf '%s' "$me" | grep -q '"ADMIN"' && echo 0 || echo 1)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 4. 改密
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
section "password 子命令(改密 → 回滚 → 审计)"
|
||||
# 生成一个一定满足强度要求的密码(大小写+数字+符号,且不落在常见口令里)
|
||||
GENERATED="Ch$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')!a9Z"
|
||||
before_count="$(grep -c 'action=password' "${LOG_DIR}/audit.log" 2>/dev/null || echo 0)"
|
||||
|
||||
# 密码经管道进 stdin,不出现在 argv / 环境变量 / shell history
|
||||
out="$(printf '%s\n%s\n' "$GENERATED" "$GENERATED" | "$CLASSHELPER" password "$ADMIN_USER" 2>&1)"
|
||||
check "classhelper password 执行成功" "$(printf '%s' "$out" | grep -q '密码已更新' && echo 0 || echo 1)"
|
||||
|
||||
new_token="$(login "$GENERATED")"
|
||||
check "新密码能登录" "$([ -n "$new_token" ] && echo 0 || echo 1)"
|
||||
old_token="$(login "$ADMIN_PASSWORD")"
|
||||
check "旧密码已失效(登录被拒)" "$([ -z "$old_token" ] && echo 0 || echo 1)"
|
||||
|
||||
hash_backup="$(ls -1t "${BACKUP_DIR}"/password-*.json 2>/dev/null | head -n 1 || true)"
|
||||
check "改密前备份了原哈希(${hash_backup:-无})" "$([ -n "$hash_backup" ] && echo 0 || echo 1)"
|
||||
check "哈希备份权限 600" \
|
||||
"$([ -n "$hash_backup" ] && [ "$(stat -c '%a' "$hash_backup")" = "600" ] && echo 0 || echo 1)"
|
||||
|
||||
after_count="$(grep -c 'action=password' "${LOG_DIR}/audit.log" 2>/dev/null || echo 0)"
|
||||
check "审计日志新增了 password 记录" "$([ "${after_count:-0}" -gt "${before_count:-0}" ] && echo 0 || echo 1)"
|
||||
check "审计日志里没有新密码明文" "$(grep -q -- "$GENERATED" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
|
||||
check "审计日志里没有原密码明文" "$(grep -q -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
|
||||
|
||||
# 先把输出收进变量再 grep:`外部命令 | grep -q` 在 `set -o pipefail` 下,grep 命中即退出会让
|
||||
# 写端收到 SIGPIPE(退出码 141),于是"匹配到了"被算成失败
|
||||
weak_out="$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 || true)"
|
||||
check "弱密码被拒绝(长度/种数不足)" "$(printf '%s' "$weak_out" | grep -q '不合规' && echo 0 || echo 1)"
|
||||
check "密码不合规时不会卡死(stdin 结束即退出)" \
|
||||
"$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)"
|
||||
list_out="$("$CLASSHELPER" password --list 2>&1 || true)"
|
||||
check "password --list 能列出账号" "$(printf '%s' "$list_out" | grep -q "$ADMIN_USER" && echo 0 || echo 1)"
|
||||
|
||||
# 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠
|
||||
# (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为)
|
||||
if [ -n "$hash_backup" ]; then
|
||||
restore_hash_out="$(LOG_LEVEL=error DOTENV_CONFIG_QUIET=true "$node_bin" "${INSTALL_DIR}/tools/admin-cli.mjs" restore-hash "$hash_backup" 2>&1 || true)"
|
||||
check "restore-hash 能把原哈希回滚回来" "$(printf '%s' "$restore_hash_out" | grep -q '已回滚密码哈希' && echo 0 || echo 1)"
|
||||
check "原密码重新可用" "$([ -n "$(login "$ADMIN_PASSWORD")" ] && echo 0 || echo 1)"
|
||||
fi
|
||||
else
|
||||
note "跳过改密用例(没有可用的登录会话)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 5. 备份
|
||||
|
||||
section "backup 子命令"
|
||||
backup_out="$("$CLASSHELPER" backup --keep 5 2>&1)"
|
||||
check "classhelper backup 执行成功" "$(printf '%s' "$backup_out" | grep -q '备份完成' && echo 0 || echo 1)"
|
||||
|
||||
latest="$(ls -1dt "${BACKUP_DIR}"/*/ 2>/dev/null | head -n 1)"
|
||||
check "生成了新的备份目录" "$([ -n "$latest" ] && echo 0 || echo 1)"
|
||||
if [ -n "$latest" ]; then
|
||||
check "备份含 config.env" "$([ -f "${latest}config.env" ] && echo 0 || echo 1)"
|
||||
check "备份 meta.json 带版本号" "$(grep -q '"version"' "${latest}meta.json" 2>/dev/null && echo 0 || echo 1)"
|
||||
db_kind="$(read_cfg DATABASE_PROVIDER)"
|
||||
if [ "$db_kind" = "sqlite" ]; then
|
||||
snap="${latest}classhelper.db"
|
||||
check "备份含 SQLite 快照" "$([ -s "$snap" ] && echo 0 || echo 1)"
|
||||
# 一致性快照必须是完整的 SQLite 文件(VACUUM INTO 产物)
|
||||
check "快照是合法 SQLite 文件(文件头正确)" \
|
||||
"$([ -n "$snap" ] && [ "$(head -c 15 "$snap" 2>/dev/null)" = "SQLite format 3" ] && echo 0 || echo 1)"
|
||||
check "快照权限 600" "$([ -n "$snap" ] && [ "$(stat -c '%a' "$snap")" = "600" ] && echo 0 || echo 1)"
|
||||
else
|
||||
note "数据库是 MySQL,跳过 SQLite 快照用例"
|
||||
fi
|
||||
check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)"
|
||||
fi
|
||||
backup_list_out="$("$CLASSHELPER" backup list 2>&1 || true)"
|
||||
check "backup list 能列出备份" "$(printf '%s' "$backup_list_out" | grep -qE '^ [0-9]{8}-' && echo 0 || echo 1)"
|
||||
|
||||
# ---------------------------------------------------------------- 6. 配置
|
||||
|
||||
section "config 子命令"
|
||||
check "config get PORT 返回当前端口" "$([ "$("$CLASSHELPER" config get PORT)" = "$PORT" ] && echo 0 || echo 1)"
|
||||
masked="$("$CLASSHELPER" config show 2>/dev/null | grep '^JWT_SECRET=')"
|
||||
check "config show 对 JWT_SECRET 打码" "$(printf '%s' "$masked" | grep -q '\*\*\*\*' && echo 0 || echo 1)"
|
||||
check "config show 没打印出真实密钥" \
|
||||
"$(secret="$(read_cfg JWT_SECRET)"; [ -n "$secret" ] && printf '%s' "$masked" | grep -q -- "$secret" && echo 1 || echo 0)"
|
||||
|
||||
# 用一个无害的键做读写往返(不重启服务)
|
||||
printf 'CH_VERIFY_PROBE=1\n' >>"$CONFIG_FILE"
|
||||
probe="$("$CLASSHELPER" config get CH_VERIFY_PROBE 2>/dev/null)"
|
||||
check "config get 能读到新增键" "$([ "$probe" = "1" ] && echo 0 || echo 1)"
|
||||
sed -i '/^CH_VERIFY_PROBE=/d' "$CONFIG_FILE"
|
||||
|
||||
# ---------------------------------------------------------------- 7. key rotate
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
section "key rotate"
|
||||
if [ "${CLASSHELPER_VERIFY_SKIP_KEY_ROTATE:-0}" = "1" ]; then
|
||||
note "按环境变量跳过"
|
||||
else
|
||||
old_secret="$(read_cfg JWT_SECRET)"
|
||||
# key rotate 无 tty 时会问两次(确认轮换、是否现在重启);只喂一个 yes 的话第二次读到 EOF,
|
||||
# 重启被跳过 —— 新密钥不生效,下面「旧 token 已失效」必然失败。
|
||||
# 用 CLASSHELPER_ASSUME_YES=1 跳过问答并强制重启,语义才是"轮换后旧会话立即失效"。
|
||||
CLASSHELPER_NO_TTY=1 CLASSHELPER_ASSUME_YES=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true
|
||||
new_secret="$(read_cfg JWT_SECRET)"
|
||||
check "key rotate 改变了 JWT_SECRET" "$([ -n "$new_secret" ] && [ "$new_secret" != "$old_secret" ] && echo 0 || echo 1)"
|
||||
check "新密钥长度 ≥32" "$([ "${#new_secret}" -ge 32 ] && echo 0 || echo 1)"
|
||||
check "轮换后服务仍可用" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
|
||||
check "轮换前的 token 已失效(/api/auth/me 返回 401)" \
|
||||
"$(code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${TOKEN}" "${API}/api/auth/me")"; [ "$code" = "401" ] && echo 0 || echo 1)"
|
||||
# 轮换后要重新登录,否则后面的用例会带着失效 token 跑
|
||||
TOKEN="$(login "$ADMIN_PASSWORD")"
|
||||
check "轮换后重新登录成功" "$([ -n "$TOKEN" ] && echo 0 || echo 1)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 8. 升级 / 回滚
|
||||
|
||||
if [ "$DO_FULL" = "1" ]; then
|
||||
section "upgrade / rollback(--full)"
|
||||
if [ -z "$PACKAGE" ]; then
|
||||
note "未提供 --package,跳过升级用例"
|
||||
else
|
||||
before_version="$("$CLASSHELPER" version 2>/dev/null | awk '{print $2}')"
|
||||
sha="$(sha256sum "$PACKAGE" | awk '{print $1}')"
|
||||
up_out="$("$CLASSHELPER" upgrade --url "$PACKAGE" --sha256 "$sha" --force 2>&1)"
|
||||
check "upgrade 执行成功" "$(printf '%s' "$up_out" | grep -q '升级完成' && echo 0 || echo 1)"
|
||||
check "升级后服务仍就绪" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
|
||||
check "升级后配置未被改动(JWT_SECRET 保持)" \
|
||||
"$([ "$(read_cfg JWT_SECRET)" = "${new_secret:-$(read_cfg JWT_SECRET)}" ] && echo 0 || echo 1)"
|
||||
check "产生了程序快照(可回滚)" "$(ls -1 "${BACKUP_DIR}/rollback" 2>/dev/null | grep -q . && echo 0 || echo 1)"
|
||||
check "坏包被拒绝(sha256 不匹配时不改动任何文件)" \
|
||||
"$("$CLASSHELPER" upgrade --url "$PACKAGE" --sha256 0000000000000000000000000000000000000000000000000000000000000000 2>&1 | grep -q '校验失败' && echo 0 || echo 1)"
|
||||
check "校验失败后服务仍在运行" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
|
||||
|
||||
note "(回滚需要交互确认,这里用 yes 管道)"
|
||||
rb_out="$(printf 'yes\n' | "$CLASSHELPER" rollback 2>&1)"
|
||||
check "rollback 执行成功" "$(printf '%s' "$rb_out" | grep -qE '已回滚到|回滚' && echo 0 || echo 1)"
|
||||
check "回滚后服务仍就绪" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
|
||||
note "升级前版本 ${before_version}|升级后 $("$CLASSHELPER" version 2>/dev/null | awk '{print $2}')"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 结果
|
||||
|
||||
printf '\n'
|
||||
if [ "$FAIL" = "0" ]; then
|
||||
printf '%s=== 结果:%s/%s 项通过 ===%s\n' "$G" "$PASS" "$TOTAL" "$N"
|
||||
else
|
||||
printf '%s=== 结果:%s/%s 项通过,%s 项失败 ===%s\n' "$R" "$PASS" "$TOTAL" "$FAIL" "$N"
|
||||
printf '%s失败项:%s\n' "$Y" "$N"
|
||||
for item in "${FAILED[@]}"; do printf ' · %s\n' "$item"; done
|
||||
fi
|
||||
[ "$FAIL" = "0" ] || exit 1
|
||||
@@ -0,0 +1,560 @@
|
||||
# Linux 部署与运维指南
|
||||
|
||||
本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。
|
||||
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。
|
||||
|
||||
不想看长文的话,最短路径就是这**一行**(在服务器上执行):
|
||||
|
||||
```bash
|
||||
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && sudo bash /tmp/classhelper-install.sh
|
||||
```
|
||||
|
||||
它自己会把整套流程走完:识别发行版 → 补依赖 → 建系统用户 → **从 GitHub Release 取最新版服务端包
|
||||
并校验发布页上的 `.sha256`** → 下载内置 Node 运行时 → 建库建号 → 装成 systemd 服务。装完直接:
|
||||
|
||||
```bash
|
||||
sudo classhelper status
|
||||
```
|
||||
|
||||
参数接在最后即可,例如先只体检(不需要 root、不改动系统):
|
||||
|
||||
```bash
|
||||
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && bash /tmp/classhelper-install.sh --check
|
||||
```
|
||||
|
||||
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
|
||||
> 不是 `$1` —— 脚本收不到它(实测踩到:本意是体检,结果走进了交互式安装)。
|
||||
> 先落盘再执行没这个坑,也不会因为下载中断而执行到半个脚本。
|
||||
|
||||
以下是"已有安装包 / 内网离线"的用法:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.2.tar.gz
|
||||
sudo classhelper status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 三种形态怎么选
|
||||
|
||||
| 形态 | 适合 | 命令 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| **直接安装(native)** | 有一台 Linux 服务器/Mini 主机,单机跑 | `install.sh` 选 1 | systemd 管理,自带 Node 运行时,SQLite 或 MySQL |
|
||||
| **直接安装 + MySQL** | 多终端共享数据、已有 MySQL | `install.sh` 选 2 | 连接串写在配置里;库要先建好 |
|
||||
| **Docker** | 机器上已有 Docker、想与其他服务隔离 | `install.sh` 选 3 | 用安装包里随带的 Dockerfile 构建镜像 + compose 起容器 |
|
||||
|
||||
> 只想先看看这台机器能不能装:`bash install.sh --check`(**不需要 root**,不改动任何东西)。
|
||||
> Windows 机器用服务端安装包(`pnpm dist:server` 的产物),不需要本文。
|
||||
|
||||
---
|
||||
|
||||
## 2. 安装包从哪来
|
||||
|
||||
Linux 安装包命名固定为:
|
||||
|
||||
```
|
||||
classhelper-server-linux-x64-<版本>.tar.gz ← install.sh / classhelper upgrade 都认这个名字
|
||||
classhelper-server-linux-x64-<版本>.tar.gz.sha256 ← 旁边那份校验值
|
||||
```
|
||||
|
||||
它发布在 [GitHub Releases](https://github.com/LoveChengke/ClassHelper/releases) 上。
|
||||
|
||||
**为什么 Linux 包要单独出一个**:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 这些依赖是平台相关的,
|
||||
Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不起来,因此 Linux 包必须在 Linux 上构建。
|
||||
仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml)
|
||||
就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上):
|
||||
|
||||
1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.2`);
|
||||
2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行;
|
||||
3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。
|
||||
|
||||
内网离线分发也可以:在能上网的机器上 `pnpm dist:server:linux`,把 tar.gz 拷进去,
|
||||
用 `install.sh --package /path/to/xxx.tar.gz --sha256 <哈希>` 安装。
|
||||
|
||||
### 没有 Linux 机器时(交叉构建)
|
||||
|
||||
`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64`
|
||||
按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。
|
||||
适合本地快速打一个包去验证安装与运维流程。已核对过的结论:
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| SQLite 驱动 | ✅ 包内是 `@libsql/linux-x64-gnu` + `linux-x64-musl`,**没有任何 win32 残留** |
|
||||
| 服务端本体 | ✅ Node + libsql + express + socket.io 都是纯 JS 或已就位的 Linux 原生包 |
|
||||
| 运维命令 | ✅ `status` / `doctor` / `password` / `backup` / `upgrade` / `rollback` 都不依赖平台差异 |
|
||||
| Web 端「一键切换数据库」 | ⚠ **可能失败**:这条链路要调 prisma CLI 的 schema engine,CLI 在运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径,而交叉包里只有宿主平台的 `schema-engine-windows.exe` |
|
||||
|
||||
所以交叉包**能装、能跑、能验收**,只是那一个 Web 端功能存疑 —— 它会在包内写入 `.cross-built` 标记,
|
||||
`classhelper doctor` 会据此提示你(不会静默)。**正式发布一律用 GitHub Actions 那条路。**
|
||||
|
||||
```bash
|
||||
# 开发机(Windows 也可以)
|
||||
pnpm dist:server:linux
|
||||
# → releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz(+ .sha256)
|
||||
# 重跑很快:node scripts/dist-server.mjs --platform linux --reuse-deps(复用已装的 node_modules)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 一键安装
|
||||
|
||||
### 3.1 交互式(推荐第一次用)
|
||||
|
||||
```bash
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
TUI 会依次问:形态 → 安装目录 → 端口 → 数据库 → 初始管理员密码 → 是否要 Nginx 配置 / 放行防火墙。
|
||||
每一问都有默认值,直接回车即可。
|
||||
|
||||
### 3.2 非交互(自动化 / 批量部署)
|
||||
|
||||
```bash
|
||||
# 全部默认:/opt/classhelper、4000 端口、SQLite、内置 Node
|
||||
sudo bash install.sh --yes --package ./classhelper-server-linux-x64-1.1.2.tar.gz
|
||||
|
||||
# 指定目录与端口,从 stdin 传初始管理员密码(不进 argv / shell history)
|
||||
printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \
|
||||
--dir /srv/classhelper --port 8080 --admin-password-stdin \
|
||||
--package ./classhelper-server-linux-x64-1.1.2.tar.gz
|
||||
|
||||
# MySQL 形态
|
||||
sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \
|
||||
--mysql-user classhelper --mysql-password-stdin \
|
||||
--package ./classhelper-server-linux-x64-1.1.2.tar.gz <<< 'MySQL密码'
|
||||
```
|
||||
|
||||
完整参数见 `bash install.sh --help`。要点:
|
||||
|
||||
| 参数 | 作用 |
|
||||
| --- | --- |
|
||||
| `--package <url|file://路径>` | 安装包地址(默认从 GitHub Release 取最新版) |
|
||||
| `--sha256 <哈希>` | 强制校验安装包哈希 |
|
||||
| `--admin-password-stdin` | 从 stdin 读一行作为初始管理员密码 |
|
||||
| `--node-source bundled|system|tarball` | 内置 Node / 系统 Node / 指定压缩包 |
|
||||
| `--node-mirror <url>` | Node 下载镜像(默认 `https://npmmirror.com/mirrors/node`) |
|
||||
| `--node-tarball <url|路径>` | 内网离线时直接用本地 Node 包 |
|
||||
| `--with-nginx` / `--open-firewall` | 顺带生成反代配置 / 放行端口 |
|
||||
| `--yes` | 全默认、不再交互 |
|
||||
| `--check` | 只体检(无需 root) |
|
||||
| `--force` | 覆盖已有配置;在无 systemd 的机器上只铺文件不装服务 |
|
||||
|
||||
**幂等性**:重复执行就是覆盖安装。已有的 `config.env`(含 `JWT_SECRET`)与 `data/` 一律保留,
|
||||
用户不会被踢下线;端口与数据库形态也沿用旧配置,不会被改回默认值。
|
||||
|
||||
### 3.3 安装器做了什么
|
||||
|
||||
1. 识别发行版与包管理器,补齐 `curl / tar / gzip / ca-certificates`(只装缺的);
|
||||
2. 创建系统用户与组 `classhelper`(`--system`,nologin);
|
||||
3. 建目录、收权限(见下一节);
|
||||
4. 下载安装包 → 校验 sha256 → 解压铺文件;
|
||||
5. 下载 Node(默认走 npmmirror 镜像,从 `SHASUMS256.txt` 同时取版本号与哈希);
|
||||
6. 生成 `/etc/classhelper/config.env`(随机 `JWT_SECRET`,权限 600);
|
||||
7. 装 systemd 单元、`/usr/local/bin/classhelper`、`/etc/profile.d/classhelper.sh`、logrotate;
|
||||
8. 启动服务并轮询 `/healthz`,通过后才算安装成功;
|
||||
9. 全过程写 `/var/log/classhelper/install.log`。
|
||||
|
||||
### 3.4 Docker 形态
|
||||
|
||||
`install.sh` 选 3(或 `--mode docker`):用安装包里随带的 `Dockerfile` 直接构建镜像
|
||||
(包的 `node_modules` 已经是 linux 的真实目录,**构建时不需要联网装依赖**,几十秒出镜像),
|
||||
然后写一份 `/etc/classhelper/compose.yml` 拉起容器。
|
||||
|
||||
```bash
|
||||
classhelper status # 走 docker compose ps / logs
|
||||
classhelper restart # 重建容器
|
||||
classhelper password admin # 通过 docker exec 在容器里执行同一份 admin-cli
|
||||
classhelper backup # 容器内做一致性快照再取出来
|
||||
```
|
||||
|
||||
Docker 形态与直接安装的差异:
|
||||
|
||||
| | 直接安装 | Docker |
|
||||
| --- | --- | --- |
|
||||
| 数据位置 | `<安装目录>/data` | Docker 卷 `classhelper-data` |
|
||||
| 配置 | `/etc/classhelper/config.env`(systemd 注入) | 同一份文件(compose `env_file`) |
|
||||
| 升级 / 回滚 | 替换程序文件后重启服务 | 同一套流程:替换构建上下文 → 重建镜像(`classhelper-server:local`)→ `compose up -d --force-recreate` |
|
||||
| 恢复数据 | `classhelper backup restore <目录>` | 需手工 `docker cp` 回容器(命令里有提示) |
|
||||
| `backup schedule` | 支持(systemd timer) | 用宿主机 cron 调 `classhelper backup --quiet` |
|
||||
|
||||
> Docker 形态的镜像用**稳定标签** `classhelper-server:local`(另打一个 `classhelper-server:<版本>` 便于追溯)。
|
||||
> 版本号写在 compose 里的话,升级替换完程序文件后那个标签就过期了,`compose up` 会一直沿用旧镜像 ——
|
||||
> 表现为"升级成功但行为没变"。`classhelper` 自己按 `VERSION` 决定要不要重建镜像。
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录、权限与配置
|
||||
|
||||
```
|
||||
/opt/classhelper/ 安装目录(--dir 可改)
|
||||
├── server/ 后端程序(入口 server/dist/index.js)
|
||||
│ ├── dist/ prisma/ 产物 + 迁移 SQL(首启动 AUTO_MIGRATE 自动执行)
|
||||
│ ├── prisma.config.ts tsconfig.generate.json 数据库「一键切换」用
|
||||
├── web/ Web 管理端(由后端托管)
|
||||
├── node_modules/ 生产依赖(真实目录,非软链)
|
||||
├── tools/admin-cli.mjs 离线改密 / 数据库快照工具
|
||||
├── bin/classhelper 运维命令本体(/usr/local/bin/classhelper 软链到它)
|
||||
├── runtime/node/ 内置 Node 运行时
|
||||
├── systemd/ logrotate.d/ profile.d/ 模板(安装时拷到 /etc 下)
|
||||
├── Dockerfile Docker 形态构建用
|
||||
├── data/ 数据库 + Web 端自建快照(备份就是拷这里)
|
||||
├── logs/ 预留
|
||||
├── VERSION 当前版本
|
||||
├── .installed.json 安装元数据(形态/版本/端口,classhelper 靠它决策)
|
||||
└── .env → /etc/classhelper/config.env (软链,见下)
|
||||
|
||||
/etc/classhelper/ 目录 700,属主 classhelper
|
||||
└── config.env 配置文件,**权限 600**,唯一真身
|
||||
/var/log/classhelper/ 安装日志 install.log + 审计日志 audit.log
|
||||
/var/backups/classhelper/ 备份 + 程序快照(回滚用)
|
||||
```
|
||||
|
||||
### 4.1 为什么安装目录里的 `.env` 是软链
|
||||
|
||||
Web 端「数据库管理 → 一键切换数据库」在运行中改写的是 **`<安装目录>/.env`**
|
||||
(`packages/server/src/modules/database/database.service.ts`),而 systemd 是通过
|
||||
`EnvironmentFile=/etc/classhelper/config.env` 注入环境变量的。dotenv 不覆盖已存在的环境变量 →
|
||||
如果这两处不是同一份文件,**切换看起来成功、重启后又连回旧库**。
|
||||
|
||||
所以安装器把 `<安装目录>/.env` 做成指向 `config.env` 的软链:两种读法落到同一个真身。
|
||||
`classhelper doctor` 会专门检查这条软链,别手工删掉。
|
||||
|
||||
### 4.2 配置项
|
||||
|
||||
配置就是一份 `KEY=VALUE` 的 env 文件,systemd / dotenv / `source` 三种读法都兼容
|
||||
(含空白或 `#` 的值请加双引号)。最常见的几项:
|
||||
|
||||
| KEY | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `PORT` / `HOST` | `4000` / `0.0.0.0` | 监听端口与地址 |
|
||||
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
|
||||
| `DATABASE_URL` | `file:../data/classhelper.db` | SQLite 相对 `<安装目录>/server` 解析;MySQL 是连接串 |
|
||||
| `JWT_SECRET` | 随机 | 登录令牌签名密钥。**换掉=所有用户重新登录**(`classhelper key rotate`) |
|
||||
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
|
||||
| `CORS_ORIGIN` | `*` | 走域名/反代时建议收敛成 `https://你的域名` |
|
||||
| `TRUST_PROXY` | `1` | 前面有 Nginx 时保持 1,限流才按真实 IP 统计 |
|
||||
| `AUTO_MIGRATE` | `true` | 空库首启动自动建表 + 建管理员 |
|
||||
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | **只在空库首次初始化时生效**,之后改密码用 `classhelper password` |
|
||||
| `DEFAULT_CLASS_PASSWORD` / `DEFAULT_TEACHER_PASSWORD` | `123456` | 新建班级 / 重置教师时的初始密码 |
|
||||
| `TERM_START_DATE` | 空 | 第 1 教学周的周一(`YYYY-MM-DD`),影响"当前周次" |
|
||||
|
||||
改配置的三种方式(都会先自动备份、改完问你是否重启):
|
||||
|
||||
```bash
|
||||
sudo classhelper config show # 敏感值打码查看
|
||||
sudo classhelper config get PORT
|
||||
sudo classhelper config set CORS_ORIGIN https://class.example.com # 敏感项会改成交互输入
|
||||
sudo classhelper config edit # 用 $EDITOR 直接编辑
|
||||
sudo classhelper port 8080 # 改端口的快捷方式
|
||||
```
|
||||
|
||||
> 手工改也可以(`vim /etc/classhelper/config.env`),但记得改完 `classhelper restart`,
|
||||
> 并确认权限仍是 600。
|
||||
|
||||
---
|
||||
|
||||
## 5. `classhelper` 运维命令
|
||||
|
||||
安装后可直接使用(`/usr/local/bin/classhelper`)。除 `help` / `version` / `completion` 外都需要 root。
|
||||
|
||||
### 5.1 查看与诊断
|
||||
|
||||
```bash
|
||||
classhelper status # 服务状态、版本、端口、数据库、各类路径
|
||||
classhelper status --json # 同上,机器可读(接监控用)
|
||||
classhelper doctor # 完整体检:权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份
|
||||
classhelper logs -f # 实时日志(= journalctl -u classhelper -f)
|
||||
classhelper logs -n 200 --since "1 hour ago"
|
||||
classhelper logs --unit classhelper-backup # 看定时备份的日志
|
||||
```
|
||||
|
||||
`doctor` 会把每一项标成 `✔` / `✘`,失败项后面直接跟修复建议,例如:
|
||||
|
||||
```
|
||||
✘ 安装目录 .env 是指向配置真身的软链
|
||||
✔ Node 运行时可用且 ≥20.19(v22.20.0)
|
||||
✘ 最近 7 天内有备份(无)
|
||||
建议依次处理:
|
||||
· ln -sfn /etc/classhelper/config.env /opt/classhelper/.env(Web 端「一键切换数据库」依赖它)
|
||||
· classhelper backup;长期可用 classhelper backup schedule daily
|
||||
```
|
||||
|
||||
### 5.2 改密码
|
||||
|
||||
```bash
|
||||
sudo classhelper password # 改 admin
|
||||
sudo classhelper password teacher1 # 改指定教师
|
||||
sudo classhelper password --list # 列出可登录账号与班级账号(含是否已设密码)
|
||||
sudo classhelper password --class G101 # 改某个班的班级密码(学生端登录用)
|
||||
```
|
||||
|
||||
安全约定(照要求逐条落实):
|
||||
|
||||
- 密码**交互输入、不回显**;不接受命令行传参,因此不会进 shell history、也不出现在 `ps` 里;
|
||||
- 强度校验:≥8 位,小写/大写/数字/符号至少 3 类,不得与用户名相同,常见弱口令直接拒绝;
|
||||
需二次确认,连续 3 次不合规即退出(密码不变);
|
||||
- 改前把**原密码哈希**备份到 `/var/backups/classhelper/password-<时间戳>.json`(权限 600),
|
||||
写入失败或写入后复核不通过会**自动回滚**原哈希;
|
||||
- 全过程写审计日志,**审计里只有"谁对谁做了什么、结果如何",没有密码**;
|
||||
- 自动化可以用管道传入(仍不进 history):
|
||||
|
||||
```bash
|
||||
printf '%s\n%s\n' "$NEW_PW" "$NEW_PW" | sudo classhelper password admin
|
||||
```
|
||||
|
||||
> 顺带说明:改密码不需要重启服务(登录时读库校验);已登录的会话仍有效,
|
||||
> 要让所有会话立刻失效用 `classhelper key rotate`。
|
||||
|
||||
### 5.3 备份与恢复
|
||||
|
||||
```bash
|
||||
sudo classhelper backup # 备份配置 + 数据库
|
||||
sudo classhelper backup --note "升级前" --keep 7
|
||||
sudo classhelper backup list
|
||||
sudo classhelper backup restore 20261005-033000 # 用某个备份覆盖当前配置与数据(会先停服务)
|
||||
sudo classhelper backup schedule daily # 每天 03:30 自动备份(systemd timer,保留 7 份)
|
||||
sudo classhelper backup schedule off
|
||||
```
|
||||
|
||||
- 备份落在 `/var/backups/classhelper/<时间戳>[-说明]/`:`config.env` + `classhelper.db` + `meta.json`,
|
||||
目录 700、文件 600;
|
||||
- SQLite 用 `VACUUM INTO` 做**一致性快照**(服务在跑也安全,比直接 `cp` 可靠);
|
||||
- MySQL 用 `mysqldump --single-transaction`(密码经 `MYSQL_PWD` 传给子进程,不进 argv);
|
||||
- `restore` 前会先自动备份现状,恢复对象是配置与数据(不是程序);
|
||||
- 程序文件快照(回滚用)在 `/var/backups/classhelper/rollback/`,是另一类东西,`backup list` 会分开列。
|
||||
|
||||
### 5.4 升级与回滚
|
||||
|
||||
```bash
|
||||
sudo classhelper upgrade --check # 只检查有没有新版本(查 GitHub Release)
|
||||
sudo classhelper upgrade # 升到最新版
|
||||
sudo classhelper upgrade --version 1.2.0 # 升到指定版本
|
||||
sudo classhelper upgrade --url ./classhelper-server-linux-x64-1.0.1.tar.gz --sha256 <哈希> # 内网离线
|
||||
sudo classhelper upgrade --url <包> --force # 强制重装同一版本
|
||||
|
||||
sudo classhelper rollback --list # 看有哪些程序快照
|
||||
sudo classhelper rollback # 回滚到最近一份(会问一次确认)
|
||||
```
|
||||
|
||||
`upgrade` 的完整链路(任一步失败都不改动程序文件):
|
||||
|
||||
```
|
||||
解析目标版本 → 下载 → 校验 sha256 → 备份数据与配置 → 快照当前程序文件 →
|
||||
停服 → 替换程序文件(mv 原子替换,data/ 配置/ 运行时原地不动)→ 起服 → 轮询 /healthz
|
||||
↘ 失败:自动用刚才的快照回滚并重新起服,再告诉你失败原因
|
||||
```
|
||||
|
||||
两点必须知道:
|
||||
|
||||
1. **升级不会回滚数据库结构**。如果新版执行过迁移,回滚程序前要确认旧版还能读新库,
|
||||
必要时用 `classhelper backup restore` 连数据一起回退;
|
||||
2. 替换程序文件用的是"改名 + 就位"而不是删除重写 —— 升级时正在运行的就是 `bin/classhelper` 自己,
|
||||
必须先改名再放新文件,运行中的进程继续读旧 inode,否则会读到半个脚本。
|
||||
|
||||
### 5.5 服务与配置
|
||||
|
||||
```bash
|
||||
sudo classhelper start | stop | restart | reload # reload 与 restart 同义(服务端不支持热重载)
|
||||
sudo classhelper port # 看当前端口
|
||||
sudo classhelper port 8080 # 改端口(顺带提示放行防火墙)
|
||||
sudo classhelper config show [--reveal]
|
||||
sudo classhelper config set KEY VALUE
|
||||
sudo classhelper key rotate # 轮换 JWT_SECRET(所有用户需重新登录)
|
||||
```
|
||||
|
||||
### 5.6 其它
|
||||
|
||||
```bash
|
||||
classhelper version
|
||||
classhelper help
|
||||
source <(classhelper completion bash) # 补全(安装时已装到 /usr/share/bash-completion)
|
||||
```
|
||||
|
||||
### 5.7 卸载
|
||||
|
||||
```bash
|
||||
sudo classhelper uninstall
|
||||
```
|
||||
|
||||
会依次问:保留配置?保留数据?保留备份?默认全部保留 —— 数据与配置会被**搬到**
|
||||
`/var/backups/classhelper/uninstall-<时间戳>/`,然后才删除程序目录,不会悄悄丢数据。
|
||||
|
||||
```bash
|
||||
sudo classhelper uninstall --yes # 不问,用默认(保留配置/数据/备份)
|
||||
sudo classhelper uninstall --purge # 全删(含备份与数据),不可恢复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 环境变量文件
|
||||
|
||||
安装器会写 `/etc/profile.d/classhelper.sh`(内容见
|
||||
[`deploy/profile.d/classhelper.sh`](../deploy/profile.d/classhelper.sh)):
|
||||
|
||||
```sh
|
||||
export CLASSHELPER_HOME=/opt/classhelper
|
||||
export CLASSHELPER_CONFIG=/etc/classhelper/config.env
|
||||
export CLASSHELPER_LOG_DIR=/var/log/classhelper
|
||||
export CLASSHELPER_BACKUP_DIR=/var/backups/classhelper
|
||||
export PATH=$PATH:$CLASSHELPER_HOME/bin
|
||||
```
|
||||
|
||||
新登录的 shell 自动生效;当前会话要手工 `source /etc/profile.d/classhelper.sh`。
|
||||
`classhelper` 命令本身装在 `/usr/local/bin`(软链),所以**不登录也照样能用**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 反向代理与 HTTPS(公网部署必做)
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --with-nginx # 或者安装后手工:
|
||||
sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `client_max_body_size 12m`(表格导入最大 8MB,base64 后约 11MB);
|
||||
- Socket.IO 需要 `Upgrade` / `Connection` 头与较长的 `proxy_read_timeout`(模板里已配);
|
||||
- 配置里保持 `TRUST_PROXY=1`,并把 `CORS_ORIGIN` 收敛成你的域名;
|
||||
- 证书用 certbot 或你自己的证书,装好后 `nginx -t && nginx -s reload`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 故障排查
|
||||
|
||||
| 现象 | 先查什么 |
|
||||
| --- | --- |
|
||||
| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 |
|
||||
| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
|
||||
| 页面能打开但是**空白** | 1.1.0 的服务端在 HTTP 直连访问时会下发 CSP `upgrade-insecure-requests`,浏览器把所有子资源改写成 https 加载而服务器没有 TLS,于是整页空白(HTML/接口都正常)。已修复的版本按请求协议条件下发;若暂不能升级,唯一正解是上 HTTPS 反代 |
|
||||
| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 |
|
||||
| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) |
|
||||
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
|
||||
| `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` |
|
||||
| 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) |
|
||||
| 学生端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 |
|
||||
| CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" |
|
||||
|
||||
---
|
||||
|
||||
## 9. 已知限制
|
||||
|
||||
### CentOS 7 / glibc < 2.28
|
||||
|
||||
Node 官方的 linux-x64 二进制要求 **glibc ≥ 2.28**,而 CentOS 7 是 2.17 —— 直接装官方包会表现成
|
||||
"装完服务立刻退出、日志里什么也没有"。安装器会自动检测 glibc,低于 2.28 时改用
|
||||
[unofficial-builds](https://unofficial-builds.nodejs.org/) 的 **glibc-217** 构建(功能一致,只是编译基线更低)。
|
||||
|
||||
但即便这样,个别原生依赖仍可能不兼容,CentOS 7 支持属于**尽力而为**:
|
||||
|
||||
- CentOS 7 已于 2024-06 EOL,**新部署请用 Rocky Linux 9 / AlmaLinux 9 / Ubuntu 22.04+**;
|
||||
- 若坚持用 CentOS 7,建议装完立刻跑一遍 `classhelper doctor` 与本文第 10 节的验收清单。
|
||||
|
||||
### 其它
|
||||
|
||||
- 直接安装形态要求 **systemd**(容器里没有 `/run/systemd/system` 时会明确拒绝;
|
||||
容器场景请用 Docker 形态)。
|
||||
- Docker 形态的数据恢复需要手工 `docker cp`(数据在容器卷里,宿主机上看不到那个文件);
|
||||
`classhelper backup restore` 会给出具体命令。
|
||||
- `classhelper reload` 等价于 restart(服务端没有 SIGHUP 处理)。
|
||||
- 升级包只发 x64;arm64 需要自行构建(`node scripts/dist-server.mjs --platform linux` 跑在 arm64 机器上)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收清单
|
||||
|
||||
下面这套在干净的 **Ubuntu 22.04** 与 **Rocky Linux 9** 上各跑一遍。
|
||||
`deploy/verify-linux.sh` 会把第 3~8 步自动跑完并打印 `=== 结果:N/N 项通过 ===`。
|
||||
|
||||
### 10.0 准备
|
||||
|
||||
```bash
|
||||
# 在开发机:做出 Linux 包(或在 Actions 里下载)
|
||||
pnpm dist:server:linux
|
||||
scp releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz root@<服务器>:/tmp/
|
||||
scp deploy/install.sh deploy/verify-linux.sh root@<服务器>:/tmp/
|
||||
```
|
||||
|
||||
### 10.1 体检(不需要 root)
|
||||
|
||||
```bash
|
||||
bash /tmp/install.sh --check
|
||||
```
|
||||
|
||||
期望:系统/包管理器/glibc/docker/端口/磁盘逐项列出,末尾「体检通过」。
|
||||
端口被占用、缺 curl、没有 systemd 都会在这里被点出来。
|
||||
|
||||
### 10.2 安装
|
||||
|
||||
```bash
|
||||
sudo bash /tmp/install.sh --yes \
|
||||
--package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz \
|
||||
--admin-password-stdin <<< 'Verify-Pass-2026!'
|
||||
```
|
||||
|
||||
期望:末尾出现「安装完成」与「服务已就绪 ✅」,并打印管理端地址。
|
||||
|
||||
### 10.3 自动验收
|
||||
|
||||
```bash
|
||||
sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'Verify-Pass-2026!'
|
||||
```
|
||||
|
||||
覆盖项:
|
||||
|
||||
| 分组 | 检查内容 |
|
||||
| --- | --- |
|
||||
| 安装结果 | 程序文件、`VERSION`、`.installed.json`、`config.env` 600、配置目录 700、`.env` 软链、内置 Node、`/usr/local/bin/classhelper`、安装/审计日志、profile.d 生效 |
|
||||
| 服务 | systemd 单元、active、enabled、`/healthz` 200、`status --json` 合法且 healthy |
|
||||
| 体检 | `classhelper doctor` 无待处理项、`help` / `completion` 可用 |
|
||||
| API | 用初始密码登录拿 token、带 token 读 `/api/classes` 与 `/api/auth/me` |
|
||||
| 改密 | 改密成功 → 新密码可登录 → **旧密码被拒** → 原哈希已备份且 600 → 审计有记录且**不含密码明文** → 弱密码被拒 → stdin 结束不卡死 → `restore-hash` 把原密码回滚回来 |
|
||||
| 备份 | 备份目录/文件/权限、SQLite 快照是合法库文件、`backup list` |
|
||||
| 配置 | `config get/show`、敏感值打码、读写往返 |
|
||||
| key rotate | 密钥确实变了且 ≥32 位、服务仍可用、**旧 token 变 401**、重新登录成功 |
|
||||
|
||||
追加升级/回滚链路(会真的替换一次程序文件):
|
||||
|
||||
```bash
|
||||
sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x64-1.0.1.tar.gz \
|
||||
--admin-password-stdin <<< 'Verify-Pass-2026!'
|
||||
```
|
||||
|
||||
覆盖:升级成功 → 服务恢复 → 配置未被改动 → 产生程序快照 → **坏哈希被拒绝且服务不受影响** →
|
||||
`rollback` 回到旧版且服务可用。
|
||||
|
||||
### 10.4 手工补验(自动化覆盖不到的)
|
||||
|
||||
```bash
|
||||
# ① 浏览器打开 http://<IP>:4000/ ,用 admin 登录,改一次密码再登录
|
||||
# ② 学生端客户端填 http://<IP>:4000 用班级码 G101/123456 登录,收到一条老师发的通知
|
||||
# ③ 重跑安装器(幂等性):sudo bash /tmp/install.sh --yes --package <同一个包>
|
||||
# 期望:配置与数据保留、JWT_SECRET 不变(已登录用户不被踢下线)
|
||||
# ④ 卸载:sudo classhelper uninstall --yes
|
||||
# 期望:服务停止、单元删除、/usr/local/bin/classhelper 消失、
|
||||
# 数据与配置被搬到 /var/backups/classhelper/uninstall-*/
|
||||
# ⑤ 重启机器后服务自动起来(systemctl is-enabled classhelper)
|
||||
```
|
||||
|
||||
### 10.5 Rocky 9 的差异点
|
||||
|
||||
- 包管理器走 `dnf`,依赖包名与 apt 不同(`install.sh` 已按 `PKG` 分支处理);
|
||||
- 默认没有 `curl` 之外的工具时会被补装,注意首次安装需要能访问 dnf 源;
|
||||
- 防火墙默认是 `firewalld`:用 `--open-firewall` 或手工
|
||||
`firewall-cmd --add-port=4000/tcp --permanent && firewall-cmd --reload`;
|
||||
- SELinux(Enforcing):服务读写 `/opt/classhelper` 与 `/etc/classhelper` 一般不受影响,
|
||||
若日志里出现 `Permission denied` 且路径正常,先 `ausearch -m avc -ts recent` 看是不是 SELinux,
|
||||
可以用 `semanage fcontext` 加规则,或临时 `setenforce 0` 验证判断。
|
||||
|
||||
---
|
||||
|
||||
## 11. 安全清单
|
||||
|
||||
- [ ] 改掉默认管理员密码:`classhelper password admin`(**公网部署第一件事**)
|
||||
- [ ] 公网服务前面加 Nginx + HTTPS,`CORS_ORIGIN` 收敛到具体域名
|
||||
- [ ] 定期备份:`classhelper backup schedule daily`,并确认备份能恢复(`backup restore` 演练一次)
|
||||
- [ ] 配置与备份权限:`config.env` 600、`/etc/classhelper` 700、备份目录 700
|
||||
- [ ] `classhelper doctor` 无待处理项;关注"最近 7 天内有备份"
|
||||
- [ ] 审计日志(`/var/log/classhelper/audit.log`)随日志轮转保留,必要时接集中日志
|
||||
- [ ] 只在需要时使用 `--purge`;卸载默认保留数据与备份
|
||||
- [ ] 升级包一律校验 sha256(`upgrade` 内网分发时也要带 `--sha256`)
|
||||
@@ -19,28 +19,62 @@ MySQL 兼容方式设计,可平滑切换到云端 MySQL / MariaDB 统一存储
|
||||
| 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 |
|
||||
| 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 |
|
||||
|
||||
## 切换步骤
|
||||
## 切换到 MySQL
|
||||
|
||||
### 1. 修改 provider
|
||||
### 方式一:Web 管理端「数据库管理 → 一键切换」(推荐,v1.0.0 新增)
|
||||
|
||||
管理员在 Web 管理端打开 **数据库管理** 页(`/database`,仅 ADMIN 可见),在「一键切换数据库」卡片里:
|
||||
|
||||
1. 选目标类型(SQLite ⇄ MySQL)并填**空库**连接串(有表的库会被拒绝,防误覆盖);
|
||||
2. 输入「确认迁移」→ 点「开始迁移」。
|
||||
|
||||
界面会自动依次执行(带步骤进度条):
|
||||
|
||||
1. **备份当前数据库**(gzip JSON 快照存 `<数据目录>/backups/`,失败可回滚);
|
||||
2. 导出当前数据为快照;
|
||||
3. 测试目标库连接;
|
||||
4. 检查目标库为空;
|
||||
5. 改写 `schema.prisma` 的 provider;
|
||||
6. 子进程 `prisma generate` 生成新方言的 Prisma 客户端;
|
||||
7. (打包形态)把生成的 `.ts` 客户端编译进 `dist/generated`;
|
||||
8. 子进程 `prisma db push` 在目标库建表;
|
||||
9. 子进程 `dist/tools/apply-snapshot.js` 把快照写入目标库;
|
||||
10. 全部成功后改写 `.env` 的 `DATABASE_PROVIDER` / `DATABASE_URL`。
|
||||
|
||||
**完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库。
|
||||
中途任一步失败会**回滚 `schema.prisma`**,`.env` 不被改写 —— 现有数据库与配置保持可用。
|
||||
|
||||
> 同一页还提供:连接状态检测(版本 / 体积 / 各表行数)、连接测试、手动与**定时备份**、
|
||||
> 备份恢复、快照导出导入、SQLite 数据库文件下载。备份与导入导出共用同一种 JSON 快照格式,
|
||||
> 因此**任意备份都能恢复回任意一种受支持的数据库**。
|
||||
>
|
||||
> 只支持 SQLite 与 MySQL 两种主库:Redis 等键值库不是 Prisma 支持的主数据库(无法建表/迁移),
|
||||
> 接口层直接拒绝。
|
||||
|
||||
### 方式二:命令行手工切换
|
||||
|
||||
#### 1. 修改 provider
|
||||
|
||||
```bash
|
||||
# 自动把 prisma/schema.prisma 的 provider 改成 mysql
|
||||
node scripts/use-database.mjs mysql
|
||||
```
|
||||
|
||||
### 2. 安装 MySQL 驱动适配器
|
||||
#### 2. MySQL 驱动适配器(自 v1.0.0 起已随包内置)
|
||||
|
||||
Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb`
|
||||
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。
|
||||
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。**它已在 `packages/server/package.json` 的
|
||||
依赖里**(数据库管理模块的连接测试/迁移需要),因此无需再手动安装:
|
||||
|
||||
```bash
|
||||
# 仅当从旧版本升级、依赖里还没有时才需要
|
||||
pnpm --filter @classhelper/server add @prisma/adapter-mariadb
|
||||
```
|
||||
|
||||
`packages/server/src/lib/db.ts` 已经内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
|
||||
因此只需要安装依赖 + 配置环境变量,服务端代码无需改动。
|
||||
`packages/server/src/lib/db.ts` 内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
|
||||
服务端代码无需改动。
|
||||
|
||||
### 3. 配置环境变量
|
||||
#### 3. 配置环境变量
|
||||
|
||||
编辑 `packages/server/.env`:
|
||||
|
||||
@@ -52,7 +86,7 @@ DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper"
|
||||
> 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址;
|
||||
> 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。
|
||||
|
||||
### 4. 重建迁移并生成客户端
|
||||
#### 4. 重建迁移并生成客户端
|
||||
|
||||
SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**:
|
||||
|
||||
@@ -71,7 +105,11 @@ pnpm db:seed
|
||||
pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql
|
||||
```
|
||||
|
||||
### 5. 回到 SQLite
|
||||
> 只搬数据、不要演示数据时,用界面的「下载快照 / 导入快照」更省事:
|
||||
> 旧库导出 JSON → 切库建表 → 新库导入,一份快照也可用 CLI 迁移:
|
||||
> `node server/dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json`
|
||||
|
||||
#### 5. 回到 SQLite
|
||||
|
||||
```bash
|
||||
node scripts/use-database.mjs sqlite
|
||||
@@ -80,6 +118,8 @@ rm -rf packages/server/prisma/migrations # 若之前已切换成 MySQL 迁移
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
> 用界面「一键切换」回 SQLite 时,目标库同样必须是空文件:删除旧 `.db` 或指定一个新路径。
|
||||
|
||||
## 生产部署注意事项
|
||||
|
||||
1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置,
|
||||
@@ -88,3 +128,7 @@ pnpm db:migrate
|
||||
三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。
|
||||
3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。
|
||||
4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。
|
||||
界面「定时备份」把快照写进 `<数据目录>/backups/`(MySQL 模式下即安装根 `data/backups/`),
|
||||
**服务端进程不运行时不会补跑**;关键环境建议同时用数据库自带的备份策略。
|
||||
5. **安装包体积**:切换链路需要随包内置 `prisma` CLI 与 `typescript` 编译器(Prisma 7 生成器产出
|
||||
`.ts`,切换后需在目标机编译进 `dist/generated`),服务端安装包因此约 34MB → 71MB。
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
| 形态 | 适用场景 | 产物 | 本机验证情况 |
|
||||
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
|
||||
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
||||
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
||||
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
|
||||
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd + SQLite) | `deploy/install-linux.sh` + `deploy/classhelper.service` | ⚠️ 脚本已提供,本机无 Linux 未实测 |
|
||||
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](../deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
|
||||
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
|
||||
|
||||
---
|
||||
@@ -22,10 +22,10 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
||||
|
||||
产物:
|
||||
|
||||
| 路径 | 说明 |
|
||||
| ----------------------------------------------------- | ----------------------------------------------- |
|
||||
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
||||
| `release-server/班级小助手服务端-0.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) |
|
||||
| 路径 | 说明 |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
||||
| `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||
|
||||
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
||||
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
||||
@@ -35,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
||||
双击安装程序即可,**无需管理员权限**(当前用户级安装):
|
||||
|
||||
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
|
||||
- 静默安装:`"班级小助手服务端-0.1.0-x64-setup.exe" /S`
|
||||
- 静默安装:`"班级小助手服务端-1.1.2-x64-setup.exe" /S`
|
||||
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
||||
- 桌面:`班级小助手服务端.lnk`(打开管理端)
|
||||
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
|
||||
@@ -149,108 +149,97 @@ docker compose -f deploy/docker-compose.sqlite.yml --env-file deploy/.env.sqlite
|
||||
|
||||
---
|
||||
|
||||
## 三、Linux 原生部署(形态 C,systemd + SQLite)
|
||||
## 三、Linux 部署(形态 C,推荐一键安装器)
|
||||
|
||||
适用:Ubuntu / Debian / CentOS / Rocky 等有 systemd 的服务器,单机跑一套(SQLite),
|
||||
用 Nginx 挂 HTTPS 给校内网/公网访问。
|
||||
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky / AlmaLinux,任何有 systemd 的服务器
|
||||
(容器环境用下面的 Docker 形态)。**完整指南见 [linux-deploy.md](linux-deploy.md)**,这里是速查。
|
||||
|
||||
### 3.1 一键脚本(推荐)
|
||||
### 3.1 一键安装(推荐)
|
||||
|
||||
在**仓库根目录**先构建,然后执行脚本(需要 root):
|
||||
Linux 包由 GitHub Actions 在 ubuntu runner 上构建(平台相关的依赖只能在 Linux 上装),
|
||||
产物挂在 Release 上;也可以本地 `pnpm dist:server:linux` 后把 tar.gz 拷到服务器。
|
||||
安装器脚本两个文件都在仓库的 `deploy/` 下:
|
||||
|
||||
```bash
|
||||
# 1) 构建(Node ≥ 20.19,推荐 22 LTS;目标机没有 pnpm 时可在别的机器上构建后拷仓库过来)
|
||||
corepack enable && corepack prepare pnpm@11.8.0 --activate
|
||||
pnpm install
|
||||
pnpm build:shared
|
||||
pnpm --filter @classhelper/server build
|
||||
pnpm --filter @classhelper/web-admin build
|
||||
# 开发机:做出 Linux 包并连同脚本一起拷到服务器
|
||||
pnpm dist:server:linux
|
||||
scp releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz deploy/install.sh deploy/verify-linux.sh \
|
||||
root@<服务器>:/tmp/
|
||||
|
||||
# 2) 安装成 systemd 服务
|
||||
sudo bash deploy/install-linux.sh # 默认端口 4000、目录 /opt/classhelper
|
||||
# 常用变体:
|
||||
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node/端口探针
|
||||
# sudo bash deploy/install-linux.sh --port 8080
|
||||
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
|
||||
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,自己托管进程
|
||||
# sudo bash deploy/install-linux.sh --uninstall # 停服务并移除 unit(保留 data 与 .env)
|
||||
# 服务器上
|
||||
bash /tmp/install.sh --check # 只体检(不需要 root,不改动系统)
|
||||
sudo bash /tmp/install.sh # 交互式:选形态 → 选目录 → 选库 → 设初始管理员密码
|
||||
```
|
||||
|
||||
> 建议先跑一次 `--check`:它只读地校验"构建产物齐不齐、Node 版本够不够、目标端口上有没有旧实例",
|
||||
> 不需要 root,也不会改任何东西。输出示例见 `deploy/install-linux.sh` 头部注释。
|
||||
|
||||
脚本做的事:建系统用户 `classhelper` → 把后端产物、`prisma/migrations`、Web 管理端拷到
|
||||
`/opt/classhelper` → `npm install --omit=dev`(清单就是 `deploy/package.runtime.json`,与
|
||||
容器运行时同一份)→ 生成 `.env`(随机 `JWT_SECRET`、`AUTO_MIGRATE=true`)→ 装 systemd unit →
|
||||
`enable --now` → 轮询 `/healthz` 就绪后打印地址与初始管理员。
|
||||
|
||||
安装后的目录(与已验证的 Windows 免安装目录同构,因此 `.env` 里的相对路径语义一致):
|
||||
|
||||
```
|
||||
/opt/classhelper/
|
||||
├── .env 全部配置(权限 600)
|
||||
├── server/dist 后端程序(入口 index.js)
|
||||
├── server/prisma 迁移 SQL(首启动自动执行)
|
||||
├── web/ Web 管理端(后端托管,单端口)
|
||||
├── node_modules/ 仅生产依赖
|
||||
├── data/ classhelper.db + server.pid(备份只复制这里)
|
||||
└── logs/
|
||||
```
|
||||
|
||||
首次启动会自动建表并创建管理员 `admin / admin123`(**登录后立刻改密码**)。
|
||||
|
||||
### 3.2 手动部署(不想用脚本 / 没有 systemd)
|
||||
|
||||
等价的手工步骤,便于排查:
|
||||
非交互(批量部署 / 验收用):
|
||||
|
||||
```bash
|
||||
sudo useradd --system --shell /usr/sbin/nologin classhelper
|
||||
sudo mkdir -p /opt/classhelper/{server,web,data,logs}
|
||||
printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
|
||||
--dir /srv/classhelper --port 8080 --admin-password-stdin \
|
||||
--package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz
|
||||
```
|
||||
|
||||
# 程序产物
|
||||
sudo cp -R packages/server/dist /opt/classhelper/server/dist
|
||||
sudo cp -R packages/server/prisma /opt/classhelper/server/prisma
|
||||
sudo cp -R packages/web-admin/dist /opt/classhelper/web
|
||||
sudo cp deploy/package.runtime.json /opt/classhelper/package.json
|
||||
安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 →
|
||||
铺文件(`server/` `web/` `node_modules/` `tools/` `bin/`)→ 下载内置 Node 运行时 →
|
||||
写 `/etc/classhelper/config.env`(随机 `JWT_SECRET`,权限 600)→ 装 systemd 单元与
|
||||
`/usr/local/bin/classhelper` → 启动并轮询 `/healthz` 通过为止。全过程写
|
||||
`/var/log/classhelper/install.log`。
|
||||
|
||||
# 生产依赖 + 工作区共享包
|
||||
cd /opt/classhelper && sudo npm install --omit=dev --no-audit --no-fund
|
||||
sudo mkdir -p /opt/classhelper/node_modules/@classhelper/shared
|
||||
sudo cp -R packages/shared/dist /opt/classhelper/node_modules/@classhelper/shared/dist
|
||||
sudo cp packages/shared/package.json /opt/classhelper/node_modules/@classhelper/shared/package.json
|
||||
重跑就是**覆盖安装**:`config.env`(含密钥)与 `data/` 保留,已登录用户不会被踢下线。
|
||||
|
||||
# 配置(参考脚本生成的 .env,务必自己生成 JWT_SECRET)
|
||||
sudo tee /opt/classhelper/.env >/dev/null <<'EOF'
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=4000
|
||||
DATABASE_PROVIDER=sqlite
|
||||
DATABASE_URL="file:../data/classhelper.db"
|
||||
AUTO_MIGRATE=true
|
||||
JWT_SECRET=换成 openssl rand -hex 48 的输出
|
||||
JWT_EXPIRES_IN=7d
|
||||
CORS_ORIGIN=https://class.example.com
|
||||
TRUST_PROXY=1
|
||||
RATE_LIMIT_ENABLED=true
|
||||
STARTUP_DB_CHECK=true
|
||||
PID_FILE=../data/server.pid
|
||||
LOG_LEVEL=info
|
||||
EOF
|
||||
### 3.2 安装后的布局与运维
|
||||
|
||||
sudo chown -R classhelper:classhelper /opt/classhelper/{data,logs,node_modules} /opt/classhelper/.env
|
||||
sudo chmod 600 /opt/classhelper/.env
|
||||
```
|
||||
/opt/classhelper/ 程序、node_modules、内置 Node、data/、tools/、bin/
|
||||
/etc/classhelper/config.env 唯一配置真身(600);安装目录里的 .env 是指向它的软链
|
||||
/var/log/classhelper/ install.log + audit.log
|
||||
/var/backups/classhelper/ 备份与程序快照(回滚用)
|
||||
```
|
||||
|
||||
# systemd
|
||||
sudo sed -e 's#__NODE__#/usr/bin/node#' -e 's#__DIR__#/opt/classhelper#' -e 's#__USER__#classhelper#' \
|
||||
deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service
|
||||
> 为什么安装目录的 `.env` 是软链:Web 端「数据库管理 → 一键切换」改写的是 `<安装目录>/.env`,
|
||||
> 而 systemd 用 `EnvironmentFile=` 注入同一份配置;两处不是同一个文件时,切换会"看起来成功、
|
||||
> 重启后又连回旧库"。软链把两者收成一份真身(`classhelper doctor` 会检查它)。
|
||||
|
||||
日常运维全部走 `classhelper`(`classhelper help` 看全部):
|
||||
|
||||
| 事项 | 命令 |
|
||||
| ------ | --------------------------------------------------------------------------------------- |
|
||||
| 状态 | `classhelper status [--json]` |
|
||||
| 体检 | `classhelper doctor`(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) |
|
||||
| 改密码 | `classhelper password [用户名]`(交互输入、不回显、不进 shell history) |
|
||||
| 备份 | `classhelper backup`;每日自动:`classhelper backup schedule daily` |
|
||||
| 升级 | `classhelper upgrade [--check]`(下载 + 校验 sha256 + 备份 + 替换 + 探针,失败自动回滚) |
|
||||
| 回滚 | `classhelper rollback [--list]` |
|
||||
| 日志 | `classhelper logs -f` |
|
||||
| 改配置 | `classhelper config set KEY VALUE` / `config show` / `port 8080` |
|
||||
| 换密钥 | `classhelper key rotate`(所有用户需重新登录) |
|
||||
| 卸载 | `classhelper uninstall`(默认保留配置、数据与备份) |
|
||||
| 服务 | `classhelper start|stop|restart|reload` |
|
||||
|
||||
### 3.3 手动部署(不想用安装器 / 没有 systemd)
|
||||
|
||||
没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程:
|
||||
|
||||
```bash
|
||||
sudo bash /tmp/install.sh --yes --force --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz
|
||||
sudo -u classhelper /opt/classhelper/runtime/node/bin/node /opt/classhelper/server/dist/index.js
|
||||
```
|
||||
|
||||
完全手工也可以(等价步骤,便于排查):解包后把 `server/` `web/` `node_modules/` `tools/` 铺到安装目录,
|
||||
配置写到 `/etc/classhelper/config.env`(`JWT_SECRET` 用 `openssl rand -hex 48`),再用
|
||||
`deploy/classhelper.service` 模板生成单元 —— 注意模板有 5 个占位符
|
||||
(`__NODE__` `__DIR__` `__USER__` `__CONFIG__` `__CONFIGDIR__`):
|
||||
|
||||
```bash
|
||||
sudo sed -e "s#__NODE__#/opt/classhelper/runtime/node/bin/node#" \
|
||||
-e "s#__DIR__#/opt/classhelper#" -e "s#__USER__#classhelper#" \
|
||||
-e "s#__CONFIG__#/etc/classhelper/config.env#" -e "s#__CONFIGDIR__#/etc/classhelper#" \
|
||||
deploy/classhelper.service > /tmp/classhelper.service
|
||||
sudo install -m 644 /tmp/classhelper.service /etc/systemd/system/classhelper.service
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
|
||||
systemctl status classhelper --no-pager
|
||||
```
|
||||
|
||||
也可以用 PM2 之类托管(`pm2 start /opt/classhelper/server/dist/index.js --name classhelper`),
|
||||
但 systemd 更省心:`Restart=always`、日志进 journald、开机自启一次配好。
|
||||
|
||||
### 3.3 Nginx + HTTPS
|
||||
### 3.4 Nginx + HTTPS
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
|
||||
@@ -259,7 +248,7 @@ sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
`deploy/nginx.conf` 已经处理了最容易踩的两点:`/socket.io/` 的 WebSocket 升级头,
|
||||
以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合 `.env` 里 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。
|
||||
以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合配置里的 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。
|
||||
用 certbot 签证书:
|
||||
|
||||
```bash
|
||||
@@ -267,31 +256,19 @@ sudo apt install -y certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d class.example.com
|
||||
```
|
||||
|
||||
### 3.4 升级 / 备份 / 排障
|
||||
|
||||
升级(不会动 `.env`,JWT 密钥不变,学生端不用重新登录):
|
||||
### 3.5 验收
|
||||
|
||||
```bash
|
||||
git pull && pnpm install
|
||||
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build
|
||||
sudo bash deploy/install-linux.sh # 覆盖程序文件 + 自动补迁移(AUTO_MIGRATE)
|
||||
journalctl -u classhelper -n 30 --no-pager
|
||||
sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'StrongPass!2026'
|
||||
# 追加升级 / 回滚链路(会真的替换一次程序文件):
|
||||
sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x64-1.0.1.tar.gz \
|
||||
--admin-password-stdin <<< 'StrongPass!2026'
|
||||
```
|
||||
|
||||
| 事项 | 命令 |
|
||||
| -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| 备份 | `systemctl stop classhelper && cp -a /opt/classhelper/data /root/classhelper-data-$(date +%F)`(然后启动) |
|
||||
| 恢复 | 停服务 → 用备份覆盖 `data/` → 启动 |
|
||||
| 看日志 | `journalctl -u classhelper -f` |
|
||||
| 重启 | `systemctl restart classhelper`(改 `.env` 后必须重启) |
|
||||
| 探针 | `curl -s localhost:4000/healthz` / `/readyz`(就绪 200,数据库不可用 503) |
|
||||
| 换端口 | 改 `.env` 的 `PORT` → 重启;同时改 Nginx 的 `upstream` |
|
||||
| 换数据库 | 见 `docs/mysql.md`:切 provider + 装 `@prisma/adapter-mariadb` + `npx prisma migrate deploy` |
|
||||
覆盖安装结果、服务探针、API 登录、改密(含审计与回滚)、备份、配置读写、key rotate、
|
||||
升级与回滚整条链路,末尾打印 `=== 结果:N/N 项通过 ===`。逐条清单见
|
||||
[linux-deploy.md](linux-deploy.md) 第 10 节。
|
||||
|
||||
> 注意:本机(开发环境)是 Windows 且无 Docker/WSL 发行版,因此 **3.1 的脚本没有在 Linux 实机跑过**。
|
||||
> 它复用的两套东西都已验证:Windows 免安装目录(同样的目录结构、`.env` 相对路径与
|
||||
> `AUTO_MIGRATE` 首启动行为)和 `deploy/Dockerfile` 的 runtime 阶段(同样的依赖清单与产物布局)。
|
||||
> 首次在服务器上执行后请按 3.4 的探针 + Web 登录 + `pnpm verify:e2e`(把 `VERIFY_BASE_URL` 指向服务器)确认。
|
||||
|
||||
---
|
||||
|
||||
@@ -302,7 +279,7 @@ 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 免安装目录
|
||||
pnpm dist:server -- --reuse-deps # 得到 releases/server/<版本>/免安装/ 免安装目录
|
||||
```
|
||||
|
||||
把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后:
|
||||
@@ -337,7 +314,6 @@ start.cmd :: Windows
|
||||
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
|
||||
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
|
||||
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
|
||||
| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增/重置学生账号的默认密码 |
|
||||
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 |
|
||||
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
|
||||
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "class-helper",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
@@ -16,6 +16,7 @@
|
||||
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build",
|
||||
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
|
||||
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
|
||||
"check:icons": "node scripts/check-icons.mjs",
|
||||
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
@@ -38,6 +39,7 @@
|
||||
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
|
||||
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
|
||||
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
|
||||
"dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
|
||||
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
|
||||
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 83 KiB |
@@ -14,6 +14,6 @@ description: |-
|
||||
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||
url: https://github.com/LoveChengke/ClassHelper
|
||||
version: 0.1.0.0
|
||||
version: 1.1.2.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: 班级小助手
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
|
||||
* node scripts/build.mjs --cipx # 追加打包 .cipx(ClassIsland 插件包)并归集到 releases/classisland-plugin/
|
||||
* node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
|
||||
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
|
||||
*
|
||||
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
|
||||
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'node:url';
|
||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = path.resolve(pluginDir, '..', '..');
|
||||
const cacheDir = path.join(repoRoot, '.cache');
|
||||
const releasesDir = path.join(repoRoot, 'releases', 'classisland-plugin');
|
||||
const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const withCipx = argv.includes('--cipx');
|
||||
@@ -146,14 +146,19 @@ if (!fs.existsSync(cipxFile)) {
|
||||
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
|
||||
}
|
||||
|
||||
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库)
|
||||
fs.rmSync(releasesDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(releasesDir, { recursive: true });
|
||||
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile)));
|
||||
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
|
||||
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
|
||||
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
|
||||
const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
|
||||
const versionDir = path.join(releasesRoot, pluginVersion);
|
||||
const installerDir = path.join(versionDir, '安装包');
|
||||
const portableDir = path.join(versionDir, '免安装', 'ClassHelper.ClassIslandPlugin');
|
||||
fs.rmSync(installerDir, { recursive: true, force: true });
|
||||
fs.rmSync(path.dirname(portableDir), { recursive: true, force: true });
|
||||
fs.mkdirSync(installerDir, { recursive: true });
|
||||
fs.mkdirSync(portableDir, { recursive: true });
|
||||
|
||||
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
|
||||
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
|
||||
fs.mkdirSync(portableDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(outputDir)) {
|
||||
const from = path.join(outputDir, name);
|
||||
if (!fs.statSync(from).isFile()) continue;
|
||||
@@ -165,10 +170,11 @@ for (const name of fs.readdirSync(outputDir)) {
|
||||
}
|
||||
|
||||
for (const file of fs.readdirSync(cipxDir)) {
|
||||
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file));
|
||||
// .cipx 是插件包本体,校验清单跟着版本目录走
|
||||
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
|
||||
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
|
||||
}
|
||||
|
||||
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`);
|
||||
for (const file of fs.readdirSync(releasesDir)) {
|
||||
log(` ${file}`);
|
||||
}
|
||||
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
|
||||
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
|
||||
for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);
|
||||
@@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/* ---------------------------------------------------------------- 内部实现 */
|
||||
|
||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||
private const string PluginVersion = "0.1.0.0";
|
||||
private const string PluginVersion = "1.1.2.0";
|
||||
|
||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||
private static string SafeAppVersion()
|
||||
|
||||
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 250 KiB |
@@ -2,7 +2,21 @@ appId: com.classhelper.desktop
|
||||
productName: 班级小助手
|
||||
copyright: Copyright © 2026 Class Helper
|
||||
|
||||
# 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后),
|
||||
# 而本应用界面文案只有中文,其余 54 个语言包纯属白占体积(`win-unpacked` 372MB → 约 324MB)。
|
||||
# 这是 electron-builder 的官方选项:`app-builder-lib` 的 removeUnusedLanguagesIfNeeded()
|
||||
# 会对 `locales/*.pak` 做精确匹配删除,只留 `zh-CN.pak`(0.6MB)。
|
||||
#
|
||||
# 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件,
|
||||
# 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退,
|
||||
# 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。
|
||||
# 另外安装包有硬底:`班级小助手.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
|
||||
electronLanguages:
|
||||
- zh-CN
|
||||
|
||||
directories:
|
||||
# 这里的 output 只是 electron-builder 的兜底值:scripts/dist-win.mjs 会把它覆盖成
|
||||
# releases/client/<版本>/(与服务端、插件同一约定)。直接手敲 electron-builder 才会落到这里。
|
||||
output: release
|
||||
buildResources: build
|
||||
|
||||
@@ -31,7 +45,10 @@ win:
|
||||
arch: [x64]
|
||||
- target: portable
|
||||
arch: [x64]
|
||||
# 未提供 build/icon.ico 时使用 Electron 默认图标
|
||||
# 应用/安装包图标。**显式指定**而不是靠 electron-builder 的默认约定(默认找 build/icon.ico):
|
||||
# 现在生效的是仓库根 build/classhelper.{ico,png}(设计导出、手工放入),
|
||||
# 而 `pnpm icons` 那条 SVG 流水线只会重写 icon.*,两者不打架。
|
||||
icon: build/classhelper.ico
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
|
||||
nsis:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@classhelper/desktop-client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||
"author": "Class Helper",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "npm run build:main && npm run build:renderer && npm run check:secrets",
|
||||
"build": "node ../../scripts/check-icons.mjs && npm run build:main && npm run build:renderer && npm run check:secrets",
|
||||
"build:main": "node scripts/build-main.mjs",
|
||||
"build:renderer": "vite build",
|
||||
"check:secrets": "node scripts/check-bundle-secrets.mjs",
|
||||
@@ -21,7 +21,6 @@
|
||||
"@classhelper/shared": "workspace:*",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.20.0",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"pinia": "^4.0.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -10,7 +10,14 @@
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
|
||||
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked
|
||||
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
|
||||
*
|
||||
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
|
||||
* 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||
* 免安装/ electron-builder 的 win-unpacked 内容
|
||||
* 构建中间/ builder-*.yml、*.nsis.7z
|
||||
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
|
||||
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
|
||||
*/
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
@@ -59,6 +66,14 @@ if (!fs.existsSync(cli)) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// 交付产物落到 releases/client/<版本>/(与服务端、插件同一约定;releases/ 不入库)
|
||||
const clientVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||||
const explicitOutput = passthrough.find((item) => item.includes('directories.output'));
|
||||
const destDir = explicitOutput
|
||||
? path.resolve(root, explicitOutput.slice(explicitOutput.indexOf('=') + 1))
|
||||
: path.join(repoRoot, 'releases', 'client', clientVersion);
|
||||
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
|
||||
|
||||
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
|
||||
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
|
||||
|
||||
@@ -72,18 +87,18 @@ console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
|
||||
// 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)),
|
||||
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
|
||||
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
|
||||
const outputDir = path.join(root, 'release');
|
||||
if (fs.existsSync(outputDir)) {
|
||||
for (const name of fs.readdirSync(outputDir)) {
|
||||
if (!/\.(exe|7z|blockmap|yml)$/i.test(name)) continue;
|
||||
try {
|
||||
fs.rmSync(path.join(outputDir, name), { force: true });
|
||||
} catch (error) {
|
||||
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
|
||||
}
|
||||
let cleaned = false;
|
||||
for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
|
||||
const target = path.join(destDir, name);
|
||||
if (!fs.existsSync(target)) continue;
|
||||
try {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
cleaned = true;
|
||||
} catch (error) {
|
||||
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
|
||||
}
|
||||
console.log('[dist] 已清理上一轮的安装包 / 归档');
|
||||
}
|
||||
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间');
|
||||
|
||||
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
|
||||
|
||||
@@ -105,21 +120,58 @@ const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env })
|
||||
function normalizeIntegrityLabel(dir) {
|
||||
if (process.platform !== 'win32') return;
|
||||
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
|
||||
// 这一行才是"交付物能不能用"的关键,失败必须喊出来
|
||||
try {
|
||||
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
|
||||
// 目录自身恢复为"继承父目录的 Low",保证下次构建时 Low 的 7za 仍能写入归档
|
||||
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
|
||||
console.log('[dist] 已修正交付物完整性标签为 Medium,目录恢复继承:', dir);
|
||||
console.log('[dist] 已把交付物完整性标签修正为 Medium:', dir);
|
||||
} catch (error) {
|
||||
console.warn('[dist] 完整性标签修正跳过:', error.message);
|
||||
console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md §7 第 7 条):', error.message);
|
||||
}
|
||||
// 目录自身恢复为"继承父目录的 Low",保证下次构建时带 Low 标签的 7za 仍能写进这个目录。
|
||||
// 宿主目录树本来就没有 Low 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
|
||||
try {
|
||||
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
|
||||
* win-unpacked/ → 免安装/
|
||||
* *.exe / *.blockmap → 安装包/
|
||||
* *.yml / *.nsis.7z → 构建中间/
|
||||
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
|
||||
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
|
||||
*/
|
||||
function tidyArtifacts(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const moveInto = (from, sub) => {
|
||||
const to = path.join(dir, sub);
|
||||
fs.mkdirSync(to, { recursive: true });
|
||||
fs.renameSync(from, path.join(to, path.basename(from)));
|
||||
};
|
||||
|
||||
const unpacked = path.join(dir, 'win-unpacked');
|
||||
const portableDir = path.join(dir, '免安装');
|
||||
if (fs.existsSync(unpacked)) {
|
||||
fs.rmSync(portableDir, { recursive: true, force: true });
|
||||
fs.renameSync(unpacked, portableDir);
|
||||
}
|
||||
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const from = path.join(dir, name);
|
||||
if (!fs.statSync(from).isFile()) continue;
|
||||
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
|
||||
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
|
||||
}
|
||||
}
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
const output = path.join(root, 'release');
|
||||
normalizeIntegrityLabel(output);
|
||||
console.log('\n[dist] 打包完成,产物目录:', output);
|
||||
tidyArtifacts(destDir);
|
||||
normalizeIntegrityLabel(destDir);
|
||||
console.log('\n[dist] 打包完成,产物目录:', destDir);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -31,7 +31,9 @@ import { squirclePath } from './squircle.js';
|
||||
* - 阴影:只在展开态,`0 2px 3px rgba(0,0,0,.11)`(WinIsland `draw_expanded_shadow`);
|
||||
* - 排版:白字 + alpha 分级(.92/.72/.58),**所有文本 = 基础字号 × 排版系数**,
|
||||
* 因此「字号」设置一改,岛内文字整体等比缩放(WinIsland `font_size` 模型);
|
||||
* - 动效:单调缓动(无过冲、无回弹),只有淡入淡出与窗口形变,符合"开合不震动"。
|
||||
* - 动效:**单调缓动(无过冲、无回弹)**,只有淡入淡出与窗口形变,符合"开合不震动"。
|
||||
* 曲线取自共享令牌(`--ch-ease-out`,见 packages/shared/src/motion.ts);
|
||||
* 刻意**不用** beUI 那几组带过冲的弹簧 —— 理由写在文件末尾"悬停微交互"那段。
|
||||
*
|
||||
* 窗口尺寸仍由主进程按状态缓动(见 src/main/island.ts),渲染进程负责形状、排版与交互回传。
|
||||
*/
|
||||
@@ -1201,11 +1203,7 @@ body {
|
||||
color: var(--wn-text-1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.list-row:hover {
|
||||
background: var(--wn-surface);
|
||||
transition: background var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
@@ -1292,11 +1290,7 @@ body {
|
||||
font-size: calc(var(--island-font, 13px) * 0.76);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.more-btn:hover {
|
||||
background: var(--wn-surface-hover);
|
||||
transition: background var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.more-app {
|
||||
@@ -1430,13 +1424,8 @@ body {
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--wn-surface-hover);
|
||||
color: #fff;
|
||||
background var(--ch-dur-fast) var(--ch-ease-out),
|
||||
color var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.icon-btn svg {
|
||||
@@ -1521,8 +1510,8 @@ body {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
transform 0.12s ease;
|
||||
background var(--ch-dur-fast) var(--ch-ease-out),
|
||||
transform var(--ch-dur-instant) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
@@ -1530,10 +1519,6 @@ body {
|
||||
color: var(--wn-text-1);
|
||||
}
|
||||
|
||||
.ghost-btn:hover {
|
||||
background: var(--wn-surface-hover);
|
||||
}
|
||||
|
||||
.solid-btn {
|
||||
background: #ffffff;
|
||||
color: #0b0b0f;
|
||||
@@ -1554,35 +1539,40 @@ body {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 过渡(无缩放、无回弹) */
|
||||
/* --------------------------------------------------------------- 悬停微交互
|
||||
*
|
||||
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
|
||||
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
|
||||
* 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。
|
||||
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
|
||||
*/
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.list-row:hover {
|
||||
background: var(--wn-surface);
|
||||
}
|
||||
|
||||
.island-instant-enter-active {
|
||||
transition: none;
|
||||
.more-btn:hover,
|
||||
.ghost-btn:hover {
|
||||
background: var(--wn-surface-hover);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--wn-surface-hover);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.island-instant-enter-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.island-instant-leave-active {
|
||||
transition: opacity calc(var(--island-dur, 320ms) * 0.28) ease-out;
|
||||
}
|
||||
|
||||
.island-instant-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 展开 / 收回:只做淡入淡出,形变交给窗口尺寸(避免任何缩放回弹"震动") */
|
||||
.island-fade-enter-active {
|
||||
transition: opacity calc(var(--island-dur, 320ms) * 0.45) ease-out;
|
||||
}
|
||||
|
||||
.island-fade-leave-active {
|
||||
transition: opacity calc(var(--island-dur, 320ms) * 0.3) ease-out;
|
||||
}
|
||||
|
||||
.island-fade-enter-from,
|
||||
.island-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
/*
|
||||
* 关于本岛的动效:**只有单调缓动(无过冲、无回弹)**,曲线取自共享令牌
|
||||
* (`--ch-ease-out`,见 packages/shared/src/motion.ts)。
|
||||
*
|
||||
* 这里刻意**不用** beUI 那几组弹簧:弹簧带过冲,而本岛的形变由主进程按状态缓动窗口尺寸
|
||||
* (`src/main/island.ts`),任何回弹都会让"开合"看起来在震动 ——
|
||||
* 冒烟断言「展开过程不震动」「收回过程不抖动」逐帧采样岛体几何,正是为这条立的规矩。
|
||||
* 岛的物理是自己的一套(`./spring.ts`,从 WinIsland 逐行移植),不该被换掉。
|
||||
*
|
||||
* (这里原本还有 `.island-instant-*` / `.island-fade-*` 两组过渡类 —— 它们**是死规则**:
|
||||
* 全包只有 `<style>` 里出现,没有任何地方挂类名,是 §5 第 34 条删掉"收回快照"动画后的残留。
|
||||
* 已删除,免得后人以为岛上存在一层 Vue `<Transition>`。)
|
||||
*/
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
import IslandApp from './IslandApp.vue';
|
||||
import { applyMotionTokens } from '../renderer/composables/motion.js';
|
||||
|
||||
/**
|
||||
* 灵动岛渲染进程入口。
|
||||
@@ -8,6 +9,19 @@ import IslandApp from './IslandApp.vue';
|
||||
*/
|
||||
const app = createApp(IslandApp);
|
||||
|
||||
/**
|
||||
* 动效令牌(beUI 的缓动曲线,见 packages/shared/src/motion.ts)写进 `:root`。
|
||||
*
|
||||
* **必须在这里也注入一次**:灵动岛是**另一个渲染进程**,不共享主窗口的 `:root`。
|
||||
* 而 `IslandApp.vue` 的悬停微交互引用了 `var(--ch-dur-fast)` / `var(--ch-ease-out)`——
|
||||
* 变量缺席会让整条 `transition` 简写失效(自定义属性解析不出来 → 声明作废),
|
||||
* 结果是**完全没有过渡**,比改造前还差。
|
||||
*
|
||||
* 成本可控:多出来的是共享模块里的令牌常量与弹簧积分(约 2KB),
|
||||
* 相比岛内已经打包的 Vue 可以忽略;换来的是"两端一岛同一套曲线、不各抄一份"。
|
||||
*/
|
||||
applyMotionTokens();
|
||||
|
||||
app.config.errorHandler = (error) => {
|
||||
console.error('[island] 渲染异常', error);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,14 @@ interface PersistedConfig {
|
||||
notificationChannel?: ClassIslandNotificationChannel;
|
||||
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
|
||||
homeworkPhrases?: string[];
|
||||
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
|
||||
onboardingDone?: boolean;
|
||||
/** 界面主题(向后兼容:旧配置没有该字段用浅色) */
|
||||
theme?: 'light' | 'dark';
|
||||
/** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */
|
||||
sidebarCollapsed?: boolean;
|
||||
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
|
||||
ignoredUpdateVersion?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PersistedConfig = {
|
||||
@@ -47,8 +55,17 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
||||
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
||||
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
|
||||
onboardingDone: false,
|
||||
theme: 'light',
|
||||
sidebarCollapsed: false,
|
||||
ignoredUpdateVersion: '',
|
||||
};
|
||||
|
||||
/** 主题白名单:非法值一律回落浅色 */
|
||||
function normalizeTheme(input?: 'light' | 'dark'): 'light' | 'dark' {
|
||||
return input === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业快捷短语的合法化:去空白、去重、限长限量。
|
||||
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
|
||||
@@ -165,7 +182,7 @@ function decryptToken(raw: PersistedConfig): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted(): PersistedConfig {
|
||||
function readPersistedFromDisk(): PersistedConfig {
|
||||
try {
|
||||
const content = fs.readFileSync(configFilePath(), 'utf8');
|
||||
const parsed = JSON.parse(content) as Partial<PersistedConfig>;
|
||||
@@ -181,16 +198,128 @@ function readPersisted(): PersistedConfig {
|
||||
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
||||
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
||||
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
|
||||
onboardingDone: parsed.onboardingDone === true,
|
||||
theme: normalizeTheme(parsed.theme),
|
||||
sidebarCollapsed: parsed.sidebarCollapsed === true,
|
||||
ignoredUpdateVersion:
|
||||
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function writePersisted(config: PersistedConfig): void {
|
||||
const target = configFilePath();
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
|
||||
/* ------------------------------------------------------------ 内存缓存 + 异步合并写
|
||||
*
|
||||
* 为什么不能每次 saveConfig 都同步读写盘(原来的写法):
|
||||
* 侧栏折叠(`stores/ui.ts` 的 toggleSidebar)、主题切换、通知渠道、作业看板偏好、登录/登出
|
||||
* 全都走 `saveConfig`,而它一次调用要做「readFileSync + JSON.parse + 8 个 normalize」**两遍**
|
||||
* (开头读一遍、结尾 `getConfig()` 又读一遍)+ `writeFileSync` 一遍 + DPAPI 解密一遍,
|
||||
* **全部同步跑在主进程主线程上**。主进程是 Electron 的消息泵进程,被它摁住的那一拍
|
||||
* 渲染进程也跟着掉帧 —— 用户反馈的"点汉堡展开/收回侧栏时卡一下"就是这一拍。
|
||||
*
|
||||
* 现在:配置常驻内存(连已解密的 token 一起缓存),写盘改成**异步 + 合并** ——
|
||||
* 同一拍内的多次改动只落最后一次,写入串行不会互相覆盖;退出前由 `flushConfigSync()` 兜底。
|
||||
*
|
||||
* **语义变化(有意为之)**:`saveConfig()` 返回时数据可能还没落盘(延迟 CONFIG_WRITE_DEBOUNCE_MS)。
|
||||
* 正常退出(托盘 → 退出)会走 `flushConfigSync()`,不会丢最后一次改动;只有进程被强杀
|
||||
* 才可能丢这最后 120ms 内的改动。
|
||||
*/
|
||||
|
||||
/** 写入合并窗口:拖动同类控件(滑块/连续点击)时的连续改动只落最后一次 */
|
||||
const CONFIG_WRITE_DEBOUNCE_MS = 120;
|
||||
|
||||
interface ConfigCache {
|
||||
config: PersistedConfig;
|
||||
/** 已解密的登录令牌(null = 未登录 / 解密失败)。缓存它是为了不再每次都走一遍 DPAPI */
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存配置。**必须懒初始化**:`main/index.ts` 会先 `app.setPath('userData', smokeProfile)`
|
||||
* 把配置目录指到冒烟专用 profile,之后才第一次读配置;在模块顶层立即读盘会读错文件。
|
||||
*/
|
||||
let cache: ConfigCache | null = null;
|
||||
|
||||
function loadCache(): ConfigCache {
|
||||
if (cache) return cache;
|
||||
const config = readPersistedFromDisk();
|
||||
cache = { config, token: decryptToken(config) };
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** 待写入的配置快照(合并窗口内只保留最后一次) */
|
||||
let pendingWrite: PersistedConfig | null = null;
|
||||
let writeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 是否有一次异步写入正在进行(用它把写入串行化,避免两次写互相覆盖) */
|
||||
let writing = false;
|
||||
|
||||
/** 排一次落盘。合并窗口内的多次调用只会写最后一次;`immediate` 用于不该被延迟的场景 */
|
||||
function scheduleWrite(config: PersistedConfig, immediate = false): void {
|
||||
pendingWrite = config;
|
||||
if (immediate) {
|
||||
if (writeTimer !== null) {
|
||||
clearTimeout(writeTimer);
|
||||
writeTimer = null;
|
||||
}
|
||||
void flushConfigAsync();
|
||||
return;
|
||||
}
|
||||
// 已有待写任务(定时器在跑或正在写):内容已经记在 pendingWrite 里,等它自己收尾即可。
|
||||
// 正在写的那次结束后会再检查一次 pendingWrite(见 flushConfigAsync 的 finally)。
|
||||
if (writeTimer !== null || writing) return;
|
||||
writeTimer = setTimeout(() => {
|
||||
writeTimer = null;
|
||||
void flushConfigAsync();
|
||||
}, CONFIG_WRITE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** 异步落盘(不阻塞主进程消息泵)。失败只记日志 —— 与 readPersistedFromDisk 的容错口径一致 */
|
||||
async function flushConfigAsync(): Promise<void> {
|
||||
if (writing) return;
|
||||
const target = pendingWrite;
|
||||
if (!target) return;
|
||||
pendingWrite = null;
|
||||
writing = true;
|
||||
// 先把内容序列化下来:写入期间的后续改动由 pendingWrite 负责补一次,
|
||||
// 不会出现"这一次写的是半新半旧的对象"
|
||||
const payload = JSON.stringify(target, null, 2);
|
||||
try {
|
||||
const file = configFilePath();
|
||||
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.promises.writeFile(file, payload, 'utf8');
|
||||
} catch (error) {
|
||||
logger.warn(`写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`);
|
||||
} finally {
|
||||
writing = false;
|
||||
// 写入期间又有改动:再写一次(内容已被 pendingWrite 合并成最后一次)
|
||||
if (pendingWrite) void flushConfigAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出前同步落地一次(`main/index.ts` 的 shutdownResources 调用)。
|
||||
*
|
||||
* 即使没有待写内容也把内存里的配置重写一遍:那一时刻可能正有一次异步写在进行中,
|
||||
* 它未必来得及完成;退出时多写一次的代价可以忽略。
|
||||
*/
|
||||
export function flushConfigSync(): void {
|
||||
if (writeTimer !== null) {
|
||||
clearTimeout(writeTimer);
|
||||
writeTimer = null;
|
||||
}
|
||||
const target = pendingWrite ?? cache?.config ?? null;
|
||||
if (!target) return;
|
||||
pendingWrite = null;
|
||||
try {
|
||||
const file = configFilePath();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(target, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`退出前写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,23 +341,33 @@ function normalizeServerUrl(input: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取配置(token 已解密),供渲染进程使用 */
|
||||
export function getConfig(): DesktopStoredConfig {
|
||||
const persisted = readPersisted();
|
||||
/** 由内存缓存组装对渲染进程暴露的配置(不再读盘、不再解密) */
|
||||
function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
|
||||
const persisted = state.config;
|
||||
return {
|
||||
serverUrl: persisted.serverUrl,
|
||||
username: persisted.username,
|
||||
token: decryptToken(persisted),
|
||||
token: state.token,
|
||||
island: normalizeIslandAppearance(persisted.island),
|
||||
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
||||
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
||||
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
|
||||
onboardingDone: persisted.onboardingDone === true,
|
||||
theme: normalizeTheme(persisted.theme),
|
||||
sidebarCollapsed: persisted.sidebarCollapsed === true,
|
||||
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取配置(token 已解密),供渲染进程使用 */
|
||||
export function getConfig(): DesktopStoredConfig {
|
||||
return toDesktopConfig(loadCache());
|
||||
}
|
||||
|
||||
/** 局部更新配置:只覆盖传入的字段 */
|
||||
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
|
||||
const persisted = readPersisted();
|
||||
const state = loadCache();
|
||||
const persisted = state.config;
|
||||
|
||||
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
|
||||
const normalized = normalizeServerUrl(patch.serverUrl);
|
||||
@@ -253,20 +392,39 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
||||
if (patch.notificationChannel !== undefined) {
|
||||
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
||||
}
|
||||
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
|
||||
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
|
||||
if (patch.onboardingDone === true) {
|
||||
persisted.onboardingDone = true;
|
||||
}
|
||||
if (patch.theme !== undefined) {
|
||||
persisted.theme = normalizeTheme(patch.theme);
|
||||
}
|
||||
if (patch.sidebarCollapsed !== undefined) {
|
||||
persisted.sidebarCollapsed = patch.sidebarCollapsed === true;
|
||||
}
|
||||
// 空串是合法值(= 没有忽略任何版本),因此这里只判类型不判空
|
||||
if (typeof patch.ignoredUpdateVersion === 'string') {
|
||||
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
|
||||
}
|
||||
if (patch.token !== undefined) {
|
||||
const encrypted = encryptToken(patch.token);
|
||||
persisted.token = encrypted.token;
|
||||
persisted.tokenEncrypted = encrypted.tokenEncrypted;
|
||||
// 明文一并进缓存:既省掉一次 DPAPI 解密,也保证紧接着的 getConfig() 读到的就是刚存的令牌
|
||||
state.token = patch.token;
|
||||
}
|
||||
|
||||
writePersisted(persisted);
|
||||
return getConfig();
|
||||
scheduleWrite(persisted);
|
||||
return toDesktopConfig(state);
|
||||
}
|
||||
|
||||
/** 清空为默认配置(退出登录 / 重置) */
|
||||
/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */
|
||||
export function clearConfig(): DesktopStoredConfig {
|
||||
writePersisted({ ...DEFAULT_CONFIG });
|
||||
return getConfig();
|
||||
const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null };
|
||||
cache = state;
|
||||
scheduleWrite(state.config, true);
|
||||
return toDesktopConfig(state);
|
||||
}
|
||||
|
||||
export function getConfigPath(): string {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { BrowserWindow, app, shell } from 'electron';
|
||||
import { flushConfigSync, getConfig } from './config.js';
|
||||
import { registerIpcHandlers } from './ipc.js';
|
||||
import { island, registerIslandIpc } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
import { runSmokeTest } from './smoke.js';
|
||||
import { createTray, destroyTray, isTrayReady } from './tray.js';
|
||||
import { scheduleStartupUpdateCheck } from './update.js';
|
||||
|
||||
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
|
||||
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
|
||||
@@ -49,16 +51,20 @@ function isAllowedNavigation(url: string): boolean {
|
||||
}
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
|
||||
const theme = getConfig().theme;
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 980,
|
||||
minHeight: 640,
|
||||
// 初始尺寸与 ClassIsland 主窗口对齐(实测其可视区 1242x582),保持同一观感
|
||||
width: 1242,
|
||||
height: 582,
|
||||
minWidth: 1000,
|
||||
minHeight: 540,
|
||||
center: true,
|
||||
title: '班级小助手',
|
||||
// 冒烟验证时不弹窗,避免打扰使用者
|
||||
show: !isSmokeTest,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#f5f7fa',
|
||||
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
|
||||
webPreferences: {
|
||||
preload: path.join(currentDir, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
@@ -158,6 +164,9 @@ if (!gotLock) {
|
||||
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
|
||||
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
|
||||
scheduleStartupUpdateCheck(() => mainWindow);
|
||||
|
||||
if (isSmokeTest) {
|
||||
try {
|
||||
await runSmokeTest(mainWindow);
|
||||
@@ -185,6 +194,11 @@ if (!gotLock) {
|
||||
*/
|
||||
function shutdownResources(): string {
|
||||
const done: string[] = [];
|
||||
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
|
||||
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
|
||||
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
|
||||
flushConfigSync();
|
||||
done.push('配置已落盘');
|
||||
if (isTrayReady()) {
|
||||
destroyTray();
|
||||
done.push('托盘已销毁');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { app, ipcMain, shell } from 'electron';
|
||||
import type { UpdateInfo } from '@classhelper/shared';
|
||||
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
|
||||
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
|
||||
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
|
||||
|
||||
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
|
||||
@@ -9,6 +11,8 @@ export const IPC_CHANNELS = {
|
||||
clearConfig: 'classhelper:config:clear',
|
||||
appInfo: 'classhelper:app:info',
|
||||
openExternal: 'classhelper:shell:open-external',
|
||||
updateCheck: 'classhelper:update:check',
|
||||
updateIgnore: 'classhelper:update:ignore',
|
||||
} as const;
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
@@ -30,6 +34,7 @@ export function registerIpcHandlers(): void {
|
||||
node: process.versions.node ?? '',
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
userDataPath: app.getPath('userData'),
|
||||
configPath: getConfigPath(),
|
||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||
}));
|
||||
|
||||
@@ -38,6 +43,21 @@ export function registerIpcHandlers(): void {
|
||||
await shell.openExternal(url);
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* 检查更新(走主进程直连 GitHub,见 main/update.ts 的说明)。
|
||||
*
|
||||
* `force` 为 true 时绕过主进程缓存 —— 对应「关于」页里用户主动点的那次检查;
|
||||
* 启动自动检查一律用缓存(避免同一分钟内重复请求打满匿名限流)。
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.updateCheck, (_event, force?: boolean): Promise<UpdateInfo> => {
|
||||
return checkForUpdates(force === true);
|
||||
});
|
||||
|
||||
/** 忽略某个版本的更新提示(记入 config.json,启动自动检查不再提示它) */
|
||||
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
|
||||
if (typeof version === 'string') ignoreUpdateVersion(version);
|
||||
});
|
||||
}
|
||||
|
||||
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
|
||||
|
||||
@@ -958,9 +958,9 @@ class IslandController {
|
||||
}
|
||||
|
||||
/** 当前应该把岛放在哪块屏上:跟随鼠标时用光标所在屏,否则用主屏 */
|
||||
private targetDisplay(): Electron.Display {
|
||||
private targetDisplay(cursor?: { x: number; y: number } | null): Electron.Display {
|
||||
return this.appearance.followCursorDisplay
|
||||
? screen.getDisplayNearestPoint(this.cursorOverride ?? screen.getCursorScreenPoint())
|
||||
? screen.getDisplayNearestPoint(cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint())
|
||||
: screen.getPrimaryDisplay();
|
||||
}
|
||||
|
||||
@@ -970,10 +970,13 @@ class IslandController {
|
||||
* 原先锚点只在 `setAppearance()` 里算一次,于是"跟随鼠标屏幕"实际上**只在改设置的那一瞬间**
|
||||
* 生效:讲台从投影切回来之后,岛还留在原来那块屏上,直到用户再动一次外观设置。
|
||||
* 这里挂在已有的 60ms 命中轮询上(不新增定时器),并且只在目标屏幕真的变了、且窗口可见时才动窗口。
|
||||
*
|
||||
* 不需要额外监听 `screen` 的 display-* 事件:目标屏是由**当前光标位置**或主屏推出来的,
|
||||
* 插拔/切主屏之后下一轮 tick 自然会算出不同的 id,一样能触发重排。
|
||||
*/
|
||||
private syncAnchorDisplay(): void {
|
||||
private syncAnchorDisplay(cursor?: { x: number; y: number } | null): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const displayId = this.targetDisplay().id;
|
||||
const displayId = this.targetDisplay(cursor).id;
|
||||
if (this.anchorDisplayId === displayId) return;
|
||||
this.anchorDisplayId = displayId;
|
||||
// 换屏了:可用高度(工作区)也跟着换,列表"能放几行"要重算
|
||||
@@ -1409,11 +1412,14 @@ class IslandController {
|
||||
/**
|
||||
* 光标相对窗口左上角的本地坐标(CSS px / DIP,与渲染进程上报的岛体矩形同一坐标系)。
|
||||
* 冒烟里用 `setHitTestCursor` 注入,真实场景读系统光标。
|
||||
*
|
||||
* `cursor`:调用方在本轮 tick 里已经读过的光标位置(见 `startHitPoll`)。
|
||||
* 传进来就复用它,避免同一个 tick 里重复调 `screen.getCursorScreenPoint()`。
|
||||
*/
|
||||
private cursorLocal(): { x: number; y: number } | null {
|
||||
private cursorLocal(cursor?: { x: number; y: number } | null): { x: number; y: number } | null {
|
||||
if (!this.win || this.win.isDestroyed()) return null;
|
||||
const bounds = this.win.getBounds();
|
||||
const point = this.cursorOverride ?? screen.getCursorScreenPoint();
|
||||
const point = cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint();
|
||||
return { x: point.x - bounds.x, y: point.y - bounds.y };
|
||||
}
|
||||
|
||||
@@ -1494,7 +1500,7 @@ class IslandController {
|
||||
}
|
||||
|
||||
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
|
||||
private syncHitFromCursor(): void {
|
||||
private syncHitFromCursor(cursor?: { x: number; y: number } | null): void {
|
||||
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
|
||||
this.setInteractive(false, '窗口不可见');
|
||||
return;
|
||||
@@ -1505,18 +1511,34 @@ class IslandController {
|
||||
this.setInteractive(false, '隐藏态');
|
||||
return;
|
||||
}
|
||||
this.setInteractive(this.isInsideHitRect(this.cursorLocal()), '光标轮询');
|
||||
this.setInteractive(this.isInsideHitRect(this.cursorLocal(cursor)), '光标轮询');
|
||||
}
|
||||
|
||||
private startHitPoll(): void {
|
||||
if (this.hitTimer) return;
|
||||
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
|
||||
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
|
||||
//
|
||||
// 每一跳的成本要压到最低(这个定时器从岛初始化一直跑到进程退出):
|
||||
// 1) 光标位置**每跳只读一次**,命中判定与"跟随鼠标屏幕"共用 —— 原先 cursorLocal() 与
|
||||
// targetDisplay() 各读一次,等于每跳两次 `screen.getCursorScreenPoint()`;
|
||||
// 2) 窗口不可见时直接返回:此时命中判定与心跳检查本来就各自早退(窗口不可见 ⇒ 无命中可言),
|
||||
// 留着那两次系统调用纯属白跑。
|
||||
this.hitTimer = setInterval(() => {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const visible = this.win.isVisible();
|
||||
// 不可见、且不跟随鼠标屏幕时,连读光标都可省掉(targetDisplay 会走主屏分支)
|
||||
const cursor =
|
||||
visible || this.appearance.followCursorDisplay
|
||||
? (this.cursorOverride ?? screen.getCursorScreenPoint())
|
||||
: null;
|
||||
|
||||
// 先按目标屏幕校正窗口位置(换屏/插拔显示器),再算命中与心跳:
|
||||
// 顺序不能反,否则命中判定会用上一块屏的窗口坐标
|
||||
this.syncAnchorDisplay();
|
||||
this.syncHitFromCursor();
|
||||
this.syncAnchorDisplay(cursor);
|
||||
if (!visible) return;
|
||||
|
||||
this.syncHitFromCursor(cursor);
|
||||
this.checkRendererAlive();
|
||||
}, 60);
|
||||
// 定时器不应拖住进程退出
|
||||
@@ -1748,12 +1770,12 @@ const MIN_CARD_HEIGHT = 120;
|
||||
|
||||
|
||||
/**
|
||||
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
|
||||
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
|
||||
* 超过这个时间没上报就认为它已经卡死/不响应。
|
||||
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
|
||||
* 形成"幽灵胶囊":窗口还在、看着正常,但怎么点都没反应(实测复现:崩掉渲染进程后
|
||||
* 点击完全不产生任何动作)。所以必须有心跳 + 重建来兜底。
|
||||
*/
|
||||
const RENDERER_ALIVE_INTERVAL_MS = 5_000;
|
||||
const RENDERER_ALIVE_TIMEOUT_MS = 20_000;
|
||||
/** 窗口重建限流:一段时间内最多重建几次,避免持续崩坏时无限重建 */
|
||||
const RECOVER_MAX_TIMES = 3;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ISLAND_URGENT_SIZE,
|
||||
} from './island.js';
|
||||
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
|
||||
import { checkForUpdates, wasStartupCheckSkipped } from './update.js';
|
||||
|
||||
interface SmokeResult {
|
||||
name: string;
|
||||
@@ -555,11 +556,26 @@ async function diagnoseClosingFlash(label: string, withTouch: boolean): Promise<
|
||||
);
|
||||
if (buttonPoint) {
|
||||
const bounds = win.getBounds();
|
||||
island.setHitTestCursor({ x: Math.round(bounds.x + buttonPoint.x), y: Math.round(bounds.y + buttonPoint.y) });
|
||||
island.setHitTestCursor({
|
||||
x: Math.round(bounds.x + buttonPoint.x),
|
||||
y: Math.round(bounds.y + buttonPoint.y),
|
||||
});
|
||||
await sleep(160);
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x: buttonPoint.x, y: buttonPoint.y, button: 'left', clickCount: 1 });
|
||||
win.webContents.sendInputEvent({
|
||||
type: 'mouseDown',
|
||||
x: buttonPoint.x,
|
||||
y: buttonPoint.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
});
|
||||
await sleep(40);
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x: buttonPoint.x, y: buttonPoint.y, button: 'left', clickCount: 1 });
|
||||
win.webContents.sendInputEvent({
|
||||
type: 'mouseUp',
|
||||
x: buttonPoint.x,
|
||||
y: buttonPoint.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
});
|
||||
}
|
||||
const deadline = Date.now() + 1600;
|
||||
let index = 0;
|
||||
@@ -581,14 +597,8 @@ async function diagnoseClosingFlash(label: string, withTouch: boolean): Promise<
|
||||
const crop = image.crop({
|
||||
x: Math.max(0, Math.round((geometry.window.x - pad) * shotScale)),
|
||||
y: Math.max(0, Math.round((geometry.window.y - pad) * shotScale)),
|
||||
width: Math.min(
|
||||
image.getSize().width,
|
||||
Math.round((geometry.window.width + pad * 2) * shotScale),
|
||||
),
|
||||
height: Math.min(
|
||||
image.getSize().height,
|
||||
Math.round((geometry.window.height + pad * 2) * shotScale),
|
||||
),
|
||||
width: Math.min(image.getSize().width, Math.round((geometry.window.width + pad * 2) * shotScale)),
|
||||
height: Math.min(image.getSize().height, Math.round((geometry.window.height + pad * 2) * shotScale)),
|
||||
});
|
||||
fs.writeFileSync(path.join(dir, `${label}-${String(index).padStart(2, '0')}.png`), crop.toPNG());
|
||||
lines.push(
|
||||
@@ -1132,8 +1142,20 @@ async function runIslandChecks(
|
||||
* (他常是 top-center + 较大 marginY,验证实例是 top-center + marginY=8)—— 两个岛一高一低叠在一起,
|
||||
* 用户会以为"屏幕上出现了第二个灵动岛",点它又"反应不对"(它归验证脚本控制,会自己展开/收起)。
|
||||
* 只改**颜色**(不动停靠位置):命中/几何断言全部按默认位置写过,挪位置会把它们弄红(实测踩过)。
|
||||
*
|
||||
* 例外:采集产品留档图时这两个值可以临时换成产品默认外观 ——
|
||||
* `ELECTRON_SMOKE_ISLAND_ACCENT` / `ELECTRON_SMOKE_ISLAND_STYLE`(见 website/tools/capture-shots.mjs)。
|
||||
* 留档图要的是"产品长什么样",不是"验证实例长什么样"。**日常跑验证不要设它们。**
|
||||
*/
|
||||
island.setAppearance({ accent: '#e91e8c', style: 'tinted' });
|
||||
const shotAppearance: Parameters<typeof island.setAppearance>[0] = {
|
||||
accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c',
|
||||
};
|
||||
if (process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' || process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass') {
|
||||
shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE;
|
||||
} else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) {
|
||||
shotAppearance.style = 'tinted';
|
||||
}
|
||||
island.setAppearance(shotAppearance);
|
||||
/**
|
||||
* 强制关掉触摸模式:本节的命中/穿透/窗口几何断言全部是按"鼠标模式"写的(用
|
||||
* `setHitTestCursor` 注入光标位置代替挪动真实鼠标)。若跑在带触摸屏的机器上,
|
||||
@@ -2087,7 +2109,8 @@ async function runIslandChecks(
|
||||
let count = 0;
|
||||
for (let y = Math.max(0, fromY); y < Math.min(collapseShotSize.height, toY); y += 2) {
|
||||
for (let x = Math.max(0, fromX); x < Math.min(collapseShotSize.width, toX); x += 2) {
|
||||
if (x >= cardBox.x && x <= cardBox.x + cardBox.w && y >= cardBox.y && y <= cardBox.y + cardBox.h) continue;
|
||||
if (x >= cardBox.x && x <= cardBox.x + cardBox.w && y >= cardBox.y && y <= cardBox.y + cardBox.h)
|
||||
continue;
|
||||
const index = (y * collapseShotSize.width + x) * 4;
|
||||
if (index + 3 >= bitmap.length) continue;
|
||||
const b = bitmap[index];
|
||||
@@ -3515,6 +3538,94 @@ async function runIslandChecks(
|
||||
* "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路,
|
||||
* 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。
|
||||
*/
|
||||
/**
|
||||
* 客户端逐页留档(官网素材用)。只有设了 `ELECTRON_CLIENT_SHOTS_DIR` 才会调用。
|
||||
*
|
||||
* 页面顺序就是侧边栏的顺序,方便照着做图注;走的是 hash 路由,与真实点击菜单等价。
|
||||
* 作业页带 `?date=` 深链 —— 当天通常没有作业,空看板当素材没意义(日期取自
|
||||
* `GET /homeworks/days`,种子数据里只有 2026-09-27 有作业)。
|
||||
*/
|
||||
const CLIENT_PAGE_SHOTS: Array<{ name: string; hash: string }> = [
|
||||
{ name: 'client-schedule', hash: '#/schedule' },
|
||||
{ name: 'client-homeworks', hash: '#/homeworks?date=2026-09-27' },
|
||||
{ name: 'client-notifications', hash: '#/notifications' },
|
||||
{ name: 'client-grades', hash: '#/grades' },
|
||||
{ name: 'client-settings-island', hash: '#/settings/island' },
|
||||
{ name: 'client-settings-appearance', hash: '#/settings/appearance' },
|
||||
];
|
||||
|
||||
/** 页面里飘着的 ElMessage 提示会挡内容,截之前先收掉 */
|
||||
const STRIP_TOASTS = `document.querySelectorAll('.el-message, .el-notification').forEach((node) => node.remove()); true;`;
|
||||
|
||||
/** 主内容区的纯文本长度(侧栏不算):用来判断页面到底渲染出来没有 */
|
||||
const MAIN_TEXT_LENGTH = `((document.querySelector('.el-main.main') ?? document.body).innerText ?? '').trim().length`;
|
||||
|
||||
/**
|
||||
* 截图前把 CSS 动画与过渡停掉。
|
||||
*
|
||||
* 为什么必须:客户端每个页面有「错峰入场」动画(styles/index.css 的 `.page > *`),
|
||||
* 起始帧是 `opacity: 0`。窗口被别的窗口盖住时 Chromium 会冻结 CSS 动画,
|
||||
* 于是 DOM 里文本齐全(实测 3000 多字)、截出来却是**整片空白** —— 排查了半天。
|
||||
* `animation: none` 之后元素回落到自己的基础样式(可见),截图也顺带变成确定性的一帧。
|
||||
*/
|
||||
const FREEZE_ANIMATIONS = `(() => {
|
||||
const id = 'smoke-shot-freeze';
|
||||
if (!document.getElementById(id)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = id;
|
||||
style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
|
||||
document.head.append(style);
|
||||
}
|
||||
return true;
|
||||
})()`;
|
||||
|
||||
const UNFREEZE_ANIMATIONS = `(() => {
|
||||
document.getElementById('smoke-shot-freeze')?.remove();
|
||||
return true;
|
||||
})()`;
|
||||
|
||||
async function captureClientPages(win: BrowserWindow, dir: string): Promise<void> {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
await win.webContents.executeJavaScript(FREEZE_ANIMATIONS);
|
||||
for (const page of CLIENT_PAGE_SHOTS) {
|
||||
try {
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
// 连点两次同样的 hash 不会触发路由,先回课表再过去,保证每次都真的重新渲染
|
||||
await win.webContents.executeJavaScript(`location.hash = '#/schedule'; true;`);
|
||||
await sleep(350);
|
||||
await win.webContents.executeJavaScript(`location.hash = ${JSON.stringify(page.hash)}; true;`);
|
||||
await sleep(1200);
|
||||
await win.webContents.executeJavaScript(STRIP_TOASTS);
|
||||
await sleep(200);
|
||||
|
||||
// 懒加载的视图偶尔会来不及挂上,量到空内容就重来一次
|
||||
const length = await win.webContents.executeJavaScript(MAIN_TEXT_LENGTH);
|
||||
if (typeof length === 'number' && length >= 60) break;
|
||||
if (attempt === 3) {
|
||||
console.warn(`[SHOTS] ${page.name} 内容区一直是空的(最后一次 ${length} 字),跳过`);
|
||||
break;
|
||||
}
|
||||
await sleep(900);
|
||||
}
|
||||
|
||||
const length = await win.webContents.executeJavaScript(MAIN_TEXT_LENGTH);
|
||||
if (typeof length !== 'number' || length < 60) continue;
|
||||
|
||||
const image = await win.webContents.capturePage();
|
||||
if (image.isEmpty()) {
|
||||
console.warn(`[SHOTS] ${page.name} 截到空图,跳过`);
|
||||
continue;
|
||||
}
|
||||
const file = path.join(dir, `${page.name}.png`);
|
||||
fs.writeFileSync(file, image.toPNG());
|
||||
console.log(`[SHOTS] ${page.name} → ${file}(内容 ${length} 字)`);
|
||||
} catch (error) {
|
||||
console.warn(`[SHOTS] ${page.name} 截图失败`, error);
|
||||
}
|
||||
}
|
||||
await win.webContents.executeJavaScript(UNFREEZE_ANIMATIONS);
|
||||
}
|
||||
|
||||
async function runIslandRealtimeCheck(
|
||||
win: BrowserWindow,
|
||||
): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> {
|
||||
@@ -3712,7 +3823,7 @@ async function runSettingsPagePasswordEntryCheck(
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
location.hash = '#/schedule';
|
||||
await wait(250);
|
||||
location.hash = '#/settings';
|
||||
location.hash = '#/settings/account';
|
||||
let card = null;
|
||||
for (let i = 0; i < 40 && !card; i += 1) {
|
||||
await wait(100);
|
||||
@@ -3756,7 +3867,7 @@ async function runSettingsPageAppearanceCheck(win: BrowserWindow): Promise<{ ok:
|
||||
window.desktop.islandSetAppearance({ ...before, height: 44 });
|
||||
await wait(300);
|
||||
|
||||
location.hash = '#/settings';
|
||||
location.hash = '#/settings/island';
|
||||
let runway = null;
|
||||
for (let i = 0; i < 40 && !runway; i += 1) {
|
||||
await wait(100);
|
||||
@@ -3838,7 +3949,7 @@ async function runSettingsPageMarginCheck(
|
||||
// 页面里会留着过期的位置(这正是"改了设置没反应"的一个真实来源)
|
||||
location.hash = '#/schedule';
|
||||
await wait(250);
|
||||
location.hash = '#/settings';
|
||||
location.hash = '#/settings/island';
|
||||
let item = null;
|
||||
for (let i = 0; i < 40 && !item; i += 1) {
|
||||
await wait(100);
|
||||
@@ -3880,15 +3991,15 @@ async function runSettingsPageMarginCheck(
|
||||
.executeJavaScript(
|
||||
`(async () => {
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
location.hash = '#/schedule';
|
||||
await wait(200);
|
||||
location.hash = '#/settings';
|
||||
let runway = null;
|
||||
for (let i = 0; i < 40 && !runway; i += 1) {
|
||||
await wait(100);
|
||||
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
|
||||
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
|
||||
);
|
||||
location.hash = '#/schedule';
|
||||
await wait(200);
|
||||
location.hash = '#/settings/island';
|
||||
let runway = null;
|
||||
for (let i = 0; i < 40 && !runway; i += 1) {
|
||||
await wait(100);
|
||||
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
|
||||
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
|
||||
);
|
||||
runway = item?.querySelector('.el-slider__runway') ?? null;
|
||||
}
|
||||
if (!runway) return { ok: false, detail: '设置页里找不到「左右边距」滑块' };
|
||||
@@ -3986,7 +4097,7 @@ async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: bo
|
||||
`(async () => {
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const before = (await window.desktop.getConfig()).notificationChannel;
|
||||
location.hash = '#/settings';
|
||||
location.hash = '#/settings/reminder';
|
||||
|
||||
// 找到「通知显示位置」那张卡片里的单选组
|
||||
let group = null;
|
||||
@@ -4151,6 +4262,28 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
`text=${dom?.text ?? ''}`,
|
||||
);
|
||||
|
||||
// 初始窗口尺寸:与 ClassIsland 主窗口对齐(实测其可视区 1242x582,见 main/index.ts)
|
||||
const [initialWidth, initialHeight] = win.getSize();
|
||||
record(
|
||||
'初始窗口尺寸与 ClassIsland 对齐(1242x582)',
|
||||
initialWidth === 1242 && initialHeight === 582,
|
||||
`实际 ${initialWidth}x${initialHeight}`,
|
||||
);
|
||||
|
||||
// 初次启动引导(新装客户端首启自动弹出):真实点击走完一遍,断言完成后写入配置。
|
||||
// 必须放在"登录页不含示例内容"的全文扫描**之前**——那时引导还开着,先把它的文案从屏幕上撤走。
|
||||
const onboarding = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__?.onboardingSelfTest) return { ok: false, detail: '缺少引导自检钩子' };
|
||||
return await window.__classhelperSmoke__.onboardingSelfTest();
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
'初次启动引导(首启自动弹出、可走完、状态写入配置)',
|
||||
Boolean(onboarding?.ok),
|
||||
String(onboarding?.detail ?? ''),
|
||||
);
|
||||
|
||||
// 需求:"删掉登录页所有示例内容" —— 占位符/提示里不允许出现示例班级码或示例账号。
|
||||
// 判据不写死具体示例码:用"演示/示例"字样 + 旧版占位符的**形状**(例如 G101)+ 环境变量传入的凭据,
|
||||
// 这样断言更严(任何"例如 XXX123"都会被抓到),安装包里也不留示例账号字面量。
|
||||
@@ -4308,6 +4441,65 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
);
|
||||
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
|
||||
|
||||
// 主界面外观件(新功能回归):真实点击主题日月按钮与侧边栏汉堡按钮,
|
||||
// 断言 html.dark 翻转、collapse 态生效、两者都写入主进程配置;结束时还原主题
|
||||
const chrome = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__?.layoutChromeSelfTest) return { ok: false, detail: '缺少外观件自检钩子' };
|
||||
return await window.__classhelperSmoke__.layoutChromeSelfTest();
|
||||
})()`,
|
||||
);
|
||||
record('主题切换与侧边栏折叠(真实点击、写回配置)', Boolean(chrome?.ok), String(chrome?.detail ?? ''));
|
||||
|
||||
// 关于页(新功能回归):/settings/about 渲染 + 诊断信息(getAppInfo 含 configPath)
|
||||
const aboutPage = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__?.aboutPageSelfTest) return { ok: false, detail: '缺少关于页自检钩子' };
|
||||
return await window.__classhelperSmoke__.aboutPageSelfTest();
|
||||
})()`,
|
||||
);
|
||||
record('关于页渲染(应用信息/诊断信息/鸣谢)', Boolean(aboutPage?.ok), String(aboutPage?.detail ?? ''));
|
||||
|
||||
// 更新检查(新功能回归):主进程直连 GitHub 取最新 Release。
|
||||
//
|
||||
// **刻意不断言"一定有新版本"**:那会把冒烟绑死在外网可达上,而教室机器常常没有外网
|
||||
// (`ok=false` 是设计内的降级结果,不是失败)。因此两种结果都算通过,验的是:
|
||||
// 结构完整、失败时有可读原因、以及缓存真的生效。
|
||||
const updateFirst = await checkForUpdates(true);
|
||||
const isVersionLike = (value: unknown): boolean => typeof value === 'string' && /^\d/.test(value);
|
||||
const updateShapeOk =
|
||||
isVersionLike(updateFirst.currentVersion) &&
|
||||
typeof updateFirst.ok === 'boolean' &&
|
||||
typeof updateFirst.hasUpdate === 'boolean' &&
|
||||
(updateFirst.ok
|
||||
? isVersionLike(updateFirst.latestVersion) &&
|
||||
Array.isArray(updateFirst.assets) &&
|
||||
/^https:\/\//.test(updateFirst.releaseUrl) &&
|
||||
updateFirst.error === null
|
||||
: typeof updateFirst.error === 'string' && updateFirst.error.length > 0);
|
||||
record(
|
||||
'更新检查返回结构正确(无外网时降级为 ok=false 且带原因)',
|
||||
updateShapeOk,
|
||||
`ok=${updateFirst.ok} 本机=${updateFirst.currentVersion} 最新=${updateFirst.latestVersion ?? '-'} ` +
|
||||
`有新版本=${updateFirst.hasUpdate} 附件=${updateFirst.assets.length}` +
|
||||
`${updateFirst.error ? ` 原因=${updateFirst.error}` : ''}`,
|
||||
);
|
||||
|
||||
// 缓存:紧接着再查一次(不传 force)应命中缓存 —— checkedAt 不变即说明没有二次请求
|
||||
const updateSecond = await checkForUpdates();
|
||||
record(
|
||||
'更新检查结果带缓存(不重复请求 GitHub)',
|
||||
updateSecond.checkedAt === updateFirst.checkedAt,
|
||||
`两次 checkedAt 相同=${updateSecond.checkedAt === updateFirst.checkedAt}`,
|
||||
);
|
||||
|
||||
// 启动自动检查在冒烟模式下必须被跳过,否则每次验证都会产生一次真实网络请求与提示条
|
||||
record(
|
||||
'冒烟模式下跳过启动自动检查(验证不依赖外网)',
|
||||
wasStartupCheckSkipped(),
|
||||
`skipped=${wasStartupCheckSkipped()}`,
|
||||
);
|
||||
|
||||
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
|
||||
const readBadgeGeometry = async (): Promise<{
|
||||
ok: boolean;
|
||||
@@ -4362,6 +4554,17 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
);
|
||||
if (injectedId) await deleteSmokeNotification(injectedId);
|
||||
|
||||
/**
|
||||
* 官网素材:逐页留档。只有设了 ELECTRON_CLIENT_SHOTS_DIR 才跑 —— 日常验证不受影响。
|
||||
*
|
||||
* 位置两头的约束把这里夹死了:
|
||||
* - 必须在**收尾清理登录态之前**,否则截出来的是登录页 + 缓存数据;
|
||||
* - 必须在**所有 UI 断言之后**:翻页会改变侧栏分组展开态,紧接着测「折叠后图标与汉堡同列」
|
||||
* 就会量到过渡中间值(实测:宽度 200→77、中心差 3.2px,把那条用例弄红了)。
|
||||
*/
|
||||
const clientShotsDir = process.env.ELECTRON_CLIENT_SHOTS_DIR ?? '';
|
||||
if (clientShotsDir) await captureClientPages(win, clientShotsDir);
|
||||
|
||||
const cleanup = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { app, type BrowserWindow } from 'electron';
|
||||
import {
|
||||
GITHUB_RELEASES_LATEST_API,
|
||||
GITHUB_RELEASES_PAGE,
|
||||
UPDATE_CHECK_CACHE_TTL_MS,
|
||||
UPDATE_CHECK_TIMEOUT_MS,
|
||||
UPDATE_CHECK_USER_AGENT,
|
||||
isNewerVersion,
|
||||
normalizeVersion,
|
||||
type UpdateAssetDto,
|
||||
type UpdateInfo,
|
||||
} from '@classhelper/shared';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 更新检查(桌面客户端)。
|
||||
*
|
||||
* **为什么在主进程做**:渲染进程通过 preload 能拿到 `getConfig()` 的明文 token(代表全班身份),
|
||||
* 它的 CSP 与导航防线是整个安全模型里最要紧的一层;让它额外去连一个外部域名、
|
||||
* 解析外部 JSON,等于把"外部数据"请进最有价值的那一侧。主进程本来就在做网络与文件,
|
||||
* 放这里也顺带复用 Node 的 fetch(无需额外依赖)。
|
||||
*
|
||||
* 与 Web 管理端的差别:Web 端受服务端 CSP 的 `connect-src` 限制,只能走 `/api/update/check` 转发;
|
||||
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
|
||||
*/
|
||||
|
||||
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */
|
||||
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
|
||||
|
||||
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
|
||||
const STARTUP_DELAY_MS = 5_000;
|
||||
|
||||
interface GithubReleasePayload {
|
||||
tag_name?: unknown;
|
||||
name?: unknown;
|
||||
published_at?: unknown;
|
||||
body?: unknown;
|
||||
html_url?: unknown;
|
||||
assets?: unknown;
|
||||
}
|
||||
|
||||
let cache: { at: number; info: UpdateInfo } | null = null;
|
||||
let startupTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下必须是 true) */
|
||||
let startupCheckSkipped = false;
|
||||
|
||||
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
|
||||
function failure(error: string): UpdateInfo {
|
||||
return {
|
||||
ok: false,
|
||||
error,
|
||||
currentVersion: app.getVersion(),
|
||||
latestVersion: null,
|
||||
hasUpdate: false,
|
||||
releaseUrl: GITHUB_RELEASES_PAGE,
|
||||
releaseName: null,
|
||||
publishedAt: null,
|
||||
notes: null,
|
||||
assets: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toAssets(raw: unknown): UpdateAssetDto[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
|
||||
.filter(
|
||||
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
|
||||
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
downloadUrl: item.browser_download_url,
|
||||
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function toInfo(payload: GithubReleasePayload, currentVersion: string): UpdateInfo {
|
||||
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
|
||||
if (!latestVersion) {
|
||||
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
|
||||
return failure('GitHub 返回的版本号无法识别');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
hasUpdate: isNewerVersion(currentVersion, latestVersion),
|
||||
releaseUrl:
|
||||
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
|
||||
releaseName: typeof payload.name === 'string' ? payload.name : null,
|
||||
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
|
||||
notes: typeof payload.body === 'string' ? payload.body : null,
|
||||
assets: toAssets(payload.assets),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 把各种失败翻译成一句用户能看懂的话(教室里最常见的就是"这台机器没有外网") */
|
||||
function describeFailure(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
|
||||
}
|
||||
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate|net::/i.test(message)) {
|
||||
return '无法连接 GitHub,本机可能没有外网';
|
||||
}
|
||||
return `检查更新失败:${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 GitHub 上有没有比本机新的版本(结果带缓存)。
|
||||
*
|
||||
* 缓存的意义:匿名调用 GitHub API 的限流是 60 次/小时/IP,而「关于」页的按钮可以随便点。
|
||||
* 失败结果同样进缓存 —— 否则离线时每点一次都要白等一个超时。
|
||||
*/
|
||||
export async function checkForUpdates(force = false): Promise<UpdateInfo> {
|
||||
const currentVersion = app.getVersion();
|
||||
const now = Date.now();
|
||||
if (!force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
|
||||
return cache.info;
|
||||
}
|
||||
|
||||
let info: UpdateInfo;
|
||||
try {
|
||||
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
|
||||
headers: {
|
||||
// GitHub API 强制要求 User-Agent,不带会直接 403
|
||||
'User-Agent': UPDATE_CHECK_USER_AGENT,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
info =
|
||||
response.status === 403 || response.status === 429
|
||||
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
|
||||
: failure(`GitHub 返回 HTTP ${response.status}`);
|
||||
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${currentVersion})`);
|
||||
} else {
|
||||
info = toInfo((await response.json()) as GithubReleasePayload, currentVersion);
|
||||
logger.info(
|
||||
`更新检查:本机 ${currentVersion} / 最新 ${info.latestVersion ?? '-'}` +
|
||||
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
info = failure(describeFailure(error));
|
||||
logger.warn(`更新检查失败:${info.error}(本机版本 ${currentVersion})`);
|
||||
}
|
||||
|
||||
cache = { at: now, info };
|
||||
return info;
|
||||
}
|
||||
|
||||
/** 「知道了 / 忽略此版本」:记到配置里,同一个版本不再重复弹提示 */
|
||||
export function ignoreUpdateVersion(version: string): void {
|
||||
const normalized = normalizeVersion(version);
|
||||
if (!normalized) return;
|
||||
saveConfig({ ignoredUpdateVersion: normalized });
|
||||
logger.info(`已忽略版本 ${normalized} 的更新提示`);
|
||||
}
|
||||
|
||||
/** 该版本是否已被用户忽略 */
|
||||
export function isUpdateIgnored(version: string): boolean {
|
||||
return normalizeVersion(getConfig().ignoredUpdateVersion) === normalizeVersion(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动后的自动检查:延迟 `STARTUP_DELAY_MS` 查一次,有新版本(且没被忽略)就通知渲染进程弹提示。
|
||||
*
|
||||
* **冒烟模式直接跳过**:自动化验证不该每次都发真实网络请求(既抖动又依赖外网),
|
||||
* 而且凭空多出来的提示条会撞坏既有 UI 断言。冒烟用例改为直接调 IPC 验证返回结构。
|
||||
*/
|
||||
export function scheduleStartupUpdateCheck(getWindow: () => BrowserWindow | null): void {
|
||||
if (process.env.ELECTRON_SMOKE_TEST === '1') {
|
||||
startupCheckSkipped = true;
|
||||
logger.info('更新检查:冒烟模式下跳过启动自动检查');
|
||||
return;
|
||||
}
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
|
||||
startupTimer = setTimeout(() => {
|
||||
startupTimer = null;
|
||||
void (async () => {
|
||||
try {
|
||||
const info = await checkForUpdates();
|
||||
if (!info.ok || !info.hasUpdate || !info.latestVersion) return;
|
||||
if (isUpdateIgnored(info.latestVersion)) {
|
||||
logger.info(`更新检查:${info.latestVersion} 已被用户忽略,本次不提示`);
|
||||
return;
|
||||
}
|
||||
const win = getWindow();
|
||||
if (!win || win.isDestroyed()) return;
|
||||
win.webContents.send(UPDATE_AVAILABLE_CHANNEL, info);
|
||||
logger.info(`更新检查:已提示新版本 ${info.latestVersion}`);
|
||||
} catch (error) {
|
||||
// 自动检查失败不该打扰用户(手动检查才需要看到原因)
|
||||
logger.warn(`启动自动检查更新失败:${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
})();
|
||||
}, STARTUP_DELAY_MS);
|
||||
// 不阻止进程退出:这个定时器只是"顺便查一下"
|
||||
startupTimer.unref?.();
|
||||
}
|
||||
|
||||
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下应为 true —— 否则每次验证都会发真实网络请求) */
|
||||
export function wasStartupCheckSkipped(): boolean {
|
||||
return startupCheckSkipped;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../main/ipc.js';
|
||||
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js';
|
||||
import type {
|
||||
DesktopAppInfo,
|
||||
DesktopBridge,
|
||||
@@ -7,7 +8,7 @@ import type {
|
||||
IslandClassStatePayload,
|
||||
IslandPushContext,
|
||||
} from '../types/desktop.js';
|
||||
import type { IslandNotification } from '@classhelper/shared';
|
||||
import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
|
||||
|
||||
/**
|
||||
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
|
||||
@@ -37,6 +38,15 @@ const bridge: DesktopBridge = {
|
||||
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
|
||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url),
|
||||
|
||||
/* 更新检查(主进程直连 GitHub,见 main/update.ts) */
|
||||
checkForUpdates: (force = false): Promise<UpdateInfo> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.updateCheck, force),
|
||||
ignoreUpdateVersion: (version: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.updateIgnore, version),
|
||||
onUpdateAvailable: (handler: (info: UpdateInfo) => void): void => {
|
||||
ipcRenderer.on(UPDATE_AVAILABLE_CHANNEL, (_event, info: UpdateInfo) => handler(info));
|
||||
},
|
||||
|
||||
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
|
||||
ipcRenderer.send('island:push', payload);
|
||||
},
|
||||
|
||||
@@ -1,24 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { h, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElButton, ElNotification } from 'element-plus';
|
||||
import type { UpdateInfo } from '@classhelper/shared';
|
||||
import {
|
||||
startIslandBridge,
|
||||
stopIslandBridge,
|
||||
subscribeIslandMarkAllRead,
|
||||
subscribeIslandMarkRead,
|
||||
} from './island/bridge.js';
|
||||
import OnboardingWelcome from './components/OnboardingWelcome.vue';
|
||||
import { useAppStore } from './stores/app.js';
|
||||
import { useAuthStore } from './stores/auth.js';
|
||||
import { useOnboardingStore } from './stores/onboarding.js';
|
||||
import { useRealtimeStore } from './stores/realtime.js';
|
||||
import { useUiStore } from './stores/ui.js';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const ui = useUiStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const booting = ref(true);
|
||||
const bootText = ref('正在读取本地配置...');
|
||||
|
||||
/**
|
||||
* 启动自动检查发现新版本时的提示(主进程查出后推过来,见 main/update.ts)。
|
||||
*
|
||||
* **为什么订阅放在 App.vue**:`onUpdateAvailable` 底层是 `ipcRenderer.on`,
|
||||
* 桥接层没有对应的 off —— 挂在会随"登出 → 登录"反复挂载的 ClientLayout 里,
|
||||
* 处理器会越堆越多(与灵动岛订阅同一个坑)。App.vue 整个应用生命周期只挂载一次。
|
||||
*
|
||||
* 提示是**一次性**且可永久消音:点「忽略此版本」写进 config.json,同一版本不再提示;
|
||||
* 「关于」页里仍可随时手动检查。
|
||||
*/
|
||||
function showUpdateAvailable(info: UpdateInfo): void {
|
||||
if (!info.latestVersion) return;
|
||||
ElNotification({
|
||||
title: `发现新版本 v${info.latestVersion}`,
|
||||
type: 'info',
|
||||
// 不自动消失:这是需要用户决定的事(去下载 / 忽略),滑过去就只能靠「关于」页再想起来
|
||||
duration: 0,
|
||||
message: h('div', { style: 'line-height: 1.7' }, [
|
||||
h('p', { style: 'margin: 0 0 10px' }, `当前版本 ${info.currentVersion},可前往下载更新。`),
|
||||
h('div', { style: 'display: flex; gap: 8px' }, [
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
onClick: () => {
|
||||
void window.desktop?.openExternal(info.releaseUrl);
|
||||
},
|
||||
},
|
||||
() => '前往下载',
|
||||
),
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
size: 'small',
|
||||
onClick: () => {
|
||||
void window.desktop?.ignoreUpdateVersion(info.latestVersion ?? '');
|
||||
},
|
||||
},
|
||||
() => '忽略此版本',
|
||||
),
|
||||
]),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动流程:
|
||||
* 1) 从主进程配置恢复服务器地址与登录令牌
|
||||
@@ -27,6 +80,10 @@ const bootText = ref('正在读取本地配置...');
|
||||
*/
|
||||
onMounted(async () => {
|
||||
try {
|
||||
bootText.value = '正在读取本地配置...';
|
||||
// 主题与侧栏折叠最先恢复:在首屏渲染前挂好 html.dark,避免"先白后黑"闪一下
|
||||
await ui.init();
|
||||
|
||||
bootText.value = '正在恢复登录状态...';
|
||||
const session = await auth.restore();
|
||||
|
||||
@@ -50,8 +107,22 @@ onMounted(async () => {
|
||||
subscribeIslandMarkRead();
|
||||
subscribeIslandMarkAllRead();
|
||||
|
||||
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
|
||||
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
|
||||
|
||||
if (auth.isAuthenticated) await router.replace('/schedule');
|
||||
else await router.replace('/login');
|
||||
|
||||
// 初次启动引导:配置里没记「已看过」就自动弹一次(完成/跳过后由引导组件写入配置)。
|
||||
// 稍等一拍再弹:让登录页/主界面先渲染完,避免和启动过渡的收尾抢帧。
|
||||
window.setTimeout(() => {
|
||||
void window.desktop
|
||||
?.getConfig()
|
||||
.then((config) => {
|
||||
if (config.onboardingDone !== true) onboarding.open();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 400);
|
||||
} finally {
|
||||
booting.value = false;
|
||||
}
|
||||
@@ -66,9 +137,21 @@ onUnmounted(() => {
|
||||
<div v-if="booting" class="boot-screen">
|
||||
<el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon>
|
||||
<p class="boot-title">班级小助手</p>
|
||||
<p class="boot-text">{{ bootText }}</p>
|
||||
<!--
|
||||
启动文案会依次经过「读取本地配置 → 恢复登录状态」,直接换字是一下"啪"的跳变。
|
||||
交叉淡入(旧的上移淡出、新的从下方淡入)把两次状态读成**同一行字在更新**,
|
||||
而不是"屏幕闪了一下"。
|
||||
|
||||
`:duration` 是必需的(理由同 ClientLayout 的页面过渡):靠 `animationend` 判断的话,
|
||||
窗口不可见时动画被冻结、事件不触发,文案会一直停在第一句。
|
||||
-->
|
||||
<transition name="boot-text" mode="out-in" :duration="{ enter: 200, leave: 90 }">
|
||||
<p class="boot-text" :key="bootText">{{ bootText }}</p>
|
||||
</transition>
|
||||
</div>
|
||||
<router-view v-else />
|
||||
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
|
||||
<OnboardingWelcome v-if="!booting" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -87,6 +170,19 @@ onUnmounted(() => {
|
||||
margin: 6px 0 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
/* 启动页是"整个应用的第一帧",给标题一点入场分量是值得的 */
|
||||
animation: boot-title-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
|
||||
}
|
||||
|
||||
@keyframes boot-title-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.boot-text {
|
||||
@@ -94,4 +190,34 @@ onUnmounted(() => {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
@keyframes boot-text-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes boot-text-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.boot-text-enter-active {
|
||||
animation: boot-text-in var(--ch-dur-base) var(--ch-ease-out) both;
|
||||
}
|
||||
|
||||
.boot-text-leave-active {
|
||||
animation: boot-text-out var(--ch-dur-instant) var(--ch-ease-out) both;
|
||||
}
|
||||
</style>
|
||||
@@ -93,19 +93,31 @@ async function waitUntil(predicate: () => boolean, timeoutMs = 3000, 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' },
|
||||
/** 设置组的代表子项:设置入口改成分组后,用「通用」验证设置子页导航可用 */
|
||||
{ label: '通用', path: '/settings/general' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 冒烟自检 3:侧边栏点击导航。
|
||||
* 直接对真实 DOM 触发 click,验证每次点击后路由与视图都发生切换 ——
|
||||
* 这是"点击左侧边栏没反应"这类问题的回归测试。
|
||||
* 一级菜单项:2026-10-05 起「学习」分组已拆掉,课表/作业/通知/成绩**直接铺在侧栏一级**。
|
||||
* 下面的结构自检会确认它们真的不在任何 `el-sub-menu` 里。
|
||||
*/
|
||||
const PRIMARY_MENU_ITEMS = ['课表', '作业', '通知', '成绩'];
|
||||
|
||||
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项。设置组自成一类,保留分组 */
|
||||
const MENU_GROUPS: Array<{ title: string; labels: string[] }> = [
|
||||
{ title: '设置', labels: ['通用', '外观', '灵动岛', '提醒', '账号', '关于'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* 冒烟自检 3:侧边栏点击导航(分组结构版)。
|
||||
* 先点分组标题展开,再逐个点击子项,验证每次点击后路由与视图都发生切换 ——
|
||||
* 这是"点击左侧边栏没反应"这类问题的回归测试(侧边栏改为「学习/设置」分组后重写)。
|
||||
*/
|
||||
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
const { router } = await import('../router/index.js');
|
||||
@@ -113,20 +125,51 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
|
||||
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
|
||||
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
|
||||
const findMenuItem = (label: string): HTMLElement | undefined =>
|
||||
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
||||
Array.from(element.querySelectorAll('span')).some(
|
||||
(span) => (span.textContent ?? '').trim() === label,
|
||||
),
|
||||
const findByLabel = (selector: string, label: string): HTMLElement | undefined =>
|
||||
(Array.from(document.querySelectorAll(selector)) as HTMLElement[]).find((element) =>
|
||||
Array.from(element.querySelectorAll('span')).some((span) => (span.textContent ?? '').trim() === label),
|
||||
);
|
||||
const findMenuItem = (label: string): HTMLElement | undefined => findByLabel('.el-menu-item', label);
|
||||
const findGroupTitle = (title: string): HTMLElement | undefined =>
|
||||
findByLabel('.el-sub-menu__title', title);
|
||||
|
||||
if (!findMenuItem('课表')) {
|
||||
return { ok: false, detail: '侧边栏未渲染(当前不在主布局或未登录)' };
|
||||
if (!findGroupTitle('设置')) {
|
||||
return { ok: false, detail: '侧边栏「设置」分组未渲染(当前不在主布局或未登录)' };
|
||||
}
|
||||
const missingPrimary = PRIMARY_MENU_ITEMS.filter((label) => !findMenuItem(label));
|
||||
if (missingPrimary.length > 0) {
|
||||
return { ok: false, detail: `侧边栏缺少一级项:${missingPrimary.join('、')}` };
|
||||
}
|
||||
|
||||
const visited: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
// 展开分组:默认只展开"当前路由所在分组",其它组需要先点开。
|
||||
// 判据用"子项是否可见"而不是"盲点一次标题" —— 标题点击是 toggle,
|
||||
// 对已经展开的组再点一下会把它**收起**,后续点不到子项。
|
||||
const isVisible = (element: HTMLElement | undefined): boolean =>
|
||||
Boolean(element && element.offsetParent !== null);
|
||||
for (const group of MENU_GROUPS) {
|
||||
const firstItem = findMenuItem(group.labels[0] ?? '');
|
||||
if (!isVisible(firstItem)) {
|
||||
findGroupTitle(group.title)?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
// 结构自检:
|
||||
// ① 一级项必须**真的在一级** —— 不能还挂在某个 el-sub-menu 里(用户要求把"学习"拆掉);
|
||||
// ② 分组各自应包含全部子项
|
||||
for (const label of PRIMARY_MENU_ITEMS) {
|
||||
const element = findMenuItem(label);
|
||||
if (element?.closest('.el-sub-menu')) failures.push(`${label}(仍挂在分组里,应在一级)`);
|
||||
}
|
||||
for (const group of MENU_GROUPS) {
|
||||
for (const label of group.labels) {
|
||||
if (!findMenuItem(label)) failures.push(`${group.title}组缺少子项 ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { label, path } of EXPECTED_MENU) {
|
||||
const element = findMenuItem(label);
|
||||
if (!element) {
|
||||
@@ -818,6 +861,62 @@ export async function islandHomeworkSuppressionCheck(): Promise<SmokeCheckResult
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟自检(不需要后端):**初次启动引导**。
|
||||
*
|
||||
* 冒烟每次都用全新配置目录(等于"首次启动"),因此引导必须自动弹出;
|
||||
* 这里真实点击把它一步步走完,断言:遮罩关闭 + `onboardingDone` 已写入配置。
|
||||
* 关闭判据用「元素从 DOM 消失」——引导是 v-if 自绘遮罩(特意不用 el-dialog,
|
||||
* 避免"关闭后 DOM 残留在文档里"的误报,见 AGENTS §7 第 22 条)。
|
||||
*/
|
||||
export async function onboardingSelfTest(): Promise<SmokeCheckResult> {
|
||||
try {
|
||||
// 引导在启动后延迟 ~400ms 弹出,这里轮询等它出现
|
||||
const appearDeadline = Date.now() + 5000;
|
||||
while (Date.now() < appearDeadline && !document.querySelector('.onboarding-welcome')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!document.querySelector('.onboarding-welcome')) {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
return {
|
||||
ok: false,
|
||||
detail: `初次启动引导未自动出现(onboardingDone=${config?.onboardingDone ?? '-'})`,
|
||||
};
|
||||
}
|
||||
|
||||
// 逐页「下一步」到最后一页再点「开始使用」(按钮由 data-action 标识,不依赖文案)
|
||||
const clicks: string[] = [];
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const action = document.querySelector('.onboarding-welcome [data-action="finish"]') ? 'finish' : 'next';
|
||||
const button = document.querySelector<HTMLElement>(`.onboarding-welcome [data-action="${action}"]`);
|
||||
if (!button) break;
|
||||
clicks.push(action);
|
||||
button.click();
|
||||
if (action === 'finish') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
let closed = !document.querySelector('.onboarding-welcome');
|
||||
const closeDeadline = Date.now() + 2000;
|
||||
while (Date.now() < closeDeadline && !closed) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
closed = !document.querySelector('.onboarding-welcome');
|
||||
}
|
||||
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
const done = config?.onboardingDone === true;
|
||||
return {
|
||||
ok: closed && done,
|
||||
detail: `步骤点击=[${clicks.join(',')}] 已关闭=${closed} onboardingDone=${config?.onboardingDone ?? '-'}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `引导自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
|
||||
export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
||||
@@ -834,6 +933,232 @@ export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟自检(不需要后端):主界面外观件 —— 主题切换与侧边栏折叠。
|
||||
* 走**真实 DOM 点击**(顶栏日月按钮 / 汉堡按钮),验证:
|
||||
* 1) html.dark 类与 store 同步翻转,且写入主进程配置;
|
||||
* 2) 侧边栏折叠后 el-menu 进入 collapse 态、宽度收窄,配置同步;
|
||||
* 结束时把主题还原为初始值(后续截图/几何用例依赖固定主题)。
|
||||
*/
|
||||
export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
|
||||
try {
|
||||
const { router } = await import('../router/index.js');
|
||||
await router.push('/schedule');
|
||||
if (!(await waitUntil(() => Boolean(document.querySelector('[data-test="theme-toggle"]')), 6000))) {
|
||||
return { ok: false, detail: '主布局未渲染(找不到主题切换按钮)' };
|
||||
}
|
||||
|
||||
const clickToggle = (selector: string): boolean => {
|
||||
const node = document.querySelector<HTMLElement>(selector);
|
||||
if (!node) return false;
|
||||
node.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 1) 主题:真实点击日月按钮 → html.dark 翻转 → 配置落盘
|
||||
const initialDark = document.documentElement.classList.contains('dark');
|
||||
if (!clickToggle('[data-test="theme-toggle"]')) return { ok: false, detail: '主题按钮点击失败' };
|
||||
const flipped = await waitUntil(
|
||||
() => document.documentElement.classList.contains('dark') !== initialDark,
|
||||
2500,
|
||||
);
|
||||
const flippedDark = document.documentElement.classList.contains('dark');
|
||||
const configAfterFlip = await window.desktop?.getConfig?.();
|
||||
const themePersisted = configAfterFlip?.theme === (flippedDark ? 'dark' : 'light');
|
||||
|
||||
// 1.5) 顶栏右侧必须水平对齐(用户反馈"深色模式没对齐",两种主题其实都偏)。
|
||||
// Element Plus 给按钮的是 `vertical-align:middle`、给下拉容器的是 `vertical-align:top`,
|
||||
// 容器不是 flex 的话两个 inline-level 子元素会各按各的规则落位,错开几像素。
|
||||
const centerYOf = (element: Element | null | undefined): number | null => {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.height > 0 ? rect.top + rect.height / 2 : null;
|
||||
};
|
||||
const themeToggleY = centerYOf(document.querySelector('[data-test="theme-toggle"]'));
|
||||
const userChipY = centerYOf(document.querySelector('.user-chip'));
|
||||
const headerCenterDelta =
|
||||
themeToggleY !== null && userChipY !== null ? Math.abs(themeToggleY - userChipY) : null;
|
||||
const headerAligned = headerCenterDelta !== null && headerCenterDelta <= 1.5;
|
||||
|
||||
// 2) 侧边栏:点汉堡折叠 → 图标导轨态 + 宽度收窄(是动画,要等它走完)→ 配置落盘 → 再展开还原
|
||||
const asideBefore = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
const widthBefore = asideBefore?.offsetWidth ?? 0;
|
||||
// 2.0) 折叠必须是**动画**而不是瞬切(用户要求"重写菜单的动画")。
|
||||
// 不去抓中间帧(时序敏感),直接断言侧栏上挂着 width 的过渡 —— 确定性的。
|
||||
const asideComputed = asideBefore ? getComputedStyle(asideBefore) : null;
|
||||
const asideTransitionProperty = asideComputed?.transitionProperty ?? '';
|
||||
const asideTransitionSeconds = asideComputed ? Number.parseFloat(asideComputed.transitionDuration) : 0;
|
||||
const widthAnimated =
|
||||
asideTransitionProperty.includes('width') &&
|
||||
Number.isFinite(asideTransitionSeconds) &&
|
||||
asideTransitionSeconds > 0.05;
|
||||
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
|
||||
/*
|
||||
* 判据是「宽度收拢到**稳态**」而不是「宽度小于 80」。
|
||||
*
|
||||
* 折叠的目标宽度是 64px(ClientLayout 的 `:width="ui.sidebarCollapsed ? '64px' : '200px'"`),
|
||||
* 而宽度与菜单项内边距是同一条过渡在驱动 —— 只等「≤80」会在动画还剩几十毫秒时就往下走,
|
||||
* 下面量图标中心时内边距仍在变化,量到的是中间帧而非稳态(实测出现过 0.5px 与 2.3px 两个值,
|
||||
* 全看当时机器有多忙)。等它真正到位之后再量,这条断言才是它想表达的"折叠后同列"。
|
||||
*/
|
||||
const collapsed = await waitUntil(() => {
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 65;
|
||||
}, 4000);
|
||||
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
const widthAfter = asideAfter?.offsetWidth ?? 0;
|
||||
const configAfterCollapse = await window.desktop?.getConfig?.();
|
||||
const collapsePersisted = configAfterCollapse?.sidebarCollapsed === true;
|
||||
|
||||
// 2.1) 折叠态的两条观感硬要求(用户反馈回归):
|
||||
// a) 菜单里不允许残留任何**可见**的文字。折叠实现是"文字宽度被 flex 压到 0 + overflow:hidden
|
||||
// 裁掉 + opacity 归零"(`display:none` 无法过渡,用不了),所以判据是"看得见"
|
||||
// —— 有宽度且未透明 —— 而不是"根本没参与布局";
|
||||
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px)。
|
||||
// 分组标题里还有个绝对定位的折叠箭头,它同样带 el-icon 类但已淡出,量它没有意义,排除。
|
||||
const centerXOf = (element: Element | null | undefined): number | null => {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 ? rect.left + rect.width / 2 : null;
|
||||
};
|
||||
const leftoverText: string[] = [];
|
||||
for (const node of Array.from(
|
||||
document.querySelectorAll('.aside .el-menu-item > span, .aside .el-sub-menu__title > span'),
|
||||
)) {
|
||||
const element = node as HTMLElement;
|
||||
if (element.classList.contains('menu-icon-slot')) continue;
|
||||
const text = (element.textContent ?? '').trim();
|
||||
if (!text || element.getClientRects().length === 0) continue;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const opacity = Number(getComputedStyle(element).opacity || '1');
|
||||
if (rect.width >= 1 && opacity > 0.05) leftoverText.push(`可见:${text.slice(0, 6)}`);
|
||||
}
|
||||
const toggleCenter = centerXOf(document.querySelector('[data-test="sidebar-toggle"]'));
|
||||
const iconCenters = Array.from(
|
||||
document.querySelectorAll(
|
||||
'.aside.is-collapsed .menu-icon-slot .el-icon, ' +
|
||||
'.aside.is-collapsed .el-sub-menu__title > .el-icon:not([class*="icon-arrow"])',
|
||||
),
|
||||
).map((node) => centerXOf(node));
|
||||
const iconDeltas = iconCenters
|
||||
.filter((value): value is number => value !== null && toggleCenter !== null)
|
||||
.map((value) => Math.abs(value - (toggleCenter as number)));
|
||||
const iconsAligned = iconDeltas.length > 0 && Math.max(...iconDeltas) <= 2;
|
||||
|
||||
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
|
||||
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
|
||||
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
|
||||
const settingsItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
|
||||
).find((element) =>
|
||||
Array.from(element.querySelectorAll('span')).some((span) => (span.textContent ?? '').trim() === '通用'),
|
||||
);
|
||||
const submenuOpen = (): boolean => Boolean(settingsItem && settingsItem.offsetParent !== null);
|
||||
if (!submenuOpen()) {
|
||||
// el-sub-menu__title 的点击是 **toggle**:先判"子项是否可见"再点,
|
||||
// 盲点一次会把已展开的组收起来(AGENTS §5 第 49 条)
|
||||
document.querySelector<HTMLElement>('.aside .el-sub-menu__title')?.click();
|
||||
await waitUntil(() => submenuOpen(), 2000);
|
||||
}
|
||||
const railMenu = document.querySelector<HTMLElement>('.aside .menu');
|
||||
const railOverflowPx = railMenu ? railMenu.scrollHeight - railMenu.clientHeight : Number.NaN;
|
||||
const railFits = Number.isFinite(railOverflowPx) && railOverflowPx <= 1;
|
||||
|
||||
clickToggle('[data-test="sidebar-toggle"]');
|
||||
// 展开同样是动画:等宽度回到 200 再继续,否则下面量页脚时侧栏还停在中间宽度
|
||||
const expandedBack = await waitUntil(() => {
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
return !aside?.classList.contains('is-collapsed') && (aside?.offsetWidth ?? 0) >= 190;
|
||||
}, 4000);
|
||||
|
||||
// 3) 侧栏不溢出:菜单区自己滚动(.menu 有 min-height:0 + overflow-y:auto),
|
||||
// 底部「第 N 周 / 最近同步」始终留在窗口内 —— 窗口默认高 582px,
|
||||
// 在加这条结构性保证之前,两个分组全展开会把 footer 挤出屏幕(用户反馈)。
|
||||
const footer = document.querySelector<HTMLElement>('.aside-footer');
|
||||
const footerRect = footer?.getBoundingClientRect();
|
||||
const footerVisible =
|
||||
Boolean(footer && footer.offsetParent !== null) &&
|
||||
Boolean(footerRect && footerRect.bottom <= window.innerHeight + 1);
|
||||
|
||||
// 3) 还原主题,避免影响后续用例
|
||||
clickToggle('[data-test="theme-toggle"]');
|
||||
await waitUntil(() => document.documentElement.classList.contains('dark') === initialDark, 2500);
|
||||
|
||||
const ok =
|
||||
flipped &&
|
||||
themePersisted &&
|
||||
headerAligned &&
|
||||
widthAnimated &&
|
||||
collapsed &&
|
||||
collapsePersisted &&
|
||||
widthAfter < widthBefore &&
|
||||
widthAfter <= 80 &&
|
||||
expandedBack &&
|
||||
leftoverText.length === 0 &&
|
||||
iconsAligned &&
|
||||
railFits &&
|
||||
footerVisible;
|
||||
return {
|
||||
ok,
|
||||
detail:
|
||||
`主题翻转=${flipped} 落盘=${themePersisted}(${configAfterFlip?.theme ?? '-'}) ` +
|
||||
`顶栏对齐=${headerAligned}(中线差=${headerCenterDelta === null ? '-' : headerCenterDelta.toFixed(1)}px,要求 ≤1.5px) ` +
|
||||
`宽度过渡=${widthAnimated}(${asideTransitionProperty} ${asideTransitionSeconds}s) ` +
|
||||
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 展开还原=${expandedBack} 落盘=${collapsePersisted} ` +
|
||||
`文字残影=${leftoverText.length === 0 ? '无' : leftoverText.join(',')} ` +
|
||||
`图标与汉堡同列=${iconsAligned}(中心差=${iconDeltas.map((d) => d.toFixed(1)).join('/') || '-'},要求 ≤2px) ` +
|
||||
`导轨无溢出=${railFits}(溢出=${Number.isFinite(railOverflowPx) ? `${railOverflowPx}px` : '-'}) ` +
|
||||
`侧栏底部可见=${footerVisible}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `外观件自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟自检(不需要后端):关于页(/settings/about)。
|
||||
* 断言页面渲染出标题与「鸣谢 / 诊断信息」区块,且 getAppInfo 返回了新增的 configPath
|
||||
* (诊断信息依赖它)。
|
||||
*/
|
||||
export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
|
||||
try {
|
||||
const { router } = await import('../router/index.js');
|
||||
await router.push('/settings/about');
|
||||
const rendered = await waitUntil(
|
||||
() => (document.querySelector('.page-title')?.textContent ?? '').includes('关于'),
|
||||
6000,
|
||||
);
|
||||
if (!rendered) return { ok: false, detail: '关于页未挂载' };
|
||||
|
||||
const text = document.body.innerText.replace(/\s+/g, ' ');
|
||||
const hasThanks = text.includes('鸣谢');
|
||||
const hasDiagnostics = text.includes('诊断信息');
|
||||
const info = await window.desktop?.getAppInfo?.();
|
||||
const hasConfigPath = Boolean(info?.configPath);
|
||||
|
||||
// 检查更新入口(新功能回归):关于页要有这一块,且按钮可点。
|
||||
// 这里只断言"入口存在"——是否真有新版本取决于 GitHub,不该绑进冒烟(见 main/update.ts)
|
||||
const updateBlock = document.querySelector('[data-test="update-check"]');
|
||||
const updateButton = document.querySelector('[data-test="update-check-button"]');
|
||||
|
||||
await router.push('/schedule');
|
||||
return {
|
||||
ok: hasThanks && hasDiagnostics && hasConfigPath && Boolean(updateBlock) && Boolean(updateButton),
|
||||
detail:
|
||||
`鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'} ` +
|
||||
`检查更新块=${updateBlock ? '有' : '无'} 按钮=${updateButton ? '有' : '无'}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: `关于页自检异常:${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册到 window,供主进程冒烟脚本调用 */
|
||||
export function registerSmokeHooks(): void {
|
||||
window.__classhelperSmoke__ = {
|
||||
@@ -845,6 +1170,9 @@ export function registerSmokeHooks(): void {
|
||||
islandRealtimeCleanup,
|
||||
islandReadState,
|
||||
islandHomeworkSuppressionCheck,
|
||||
onboardingSelfTest,
|
||||
layoutChromeSelfTest,
|
||||
aboutPageSelfTest,
|
||||
scheduleTimelineSelfTest,
|
||||
homeworkBoardSelfTest,
|
||||
sessionCleanup,
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useOnboardingStore } from '../stores/onboarding.js';
|
||||
|
||||
/**
|
||||
* 初次启动引导(六步向导)。
|
||||
*
|
||||
* - 首次启动自动弹出(App.vue 按 `onboardingDone` 判定);完成 / 跳过(右上角按钮 / Esc)
|
||||
* 都算看过,由这里写入主进程配置,之后不再自动弹出。
|
||||
* - 登录页与设置页的「使用引导」入口可随时重看(重看关闭时再写一次也无副作用)。
|
||||
* - **用 v-if 自绘遮罩而不是 el-dialog**:关闭即从 DOM 卸载。冒烟断言"引导已关闭"时
|
||||
* 不会踩 el-dialog「DOM 残留在文档里」的坑(AGENTS §7 第 22 条)。
|
||||
* - 操作按钮用 `data-action` 标识(prev/next/finish/skip),冒烟点击不依赖按钮文案。
|
||||
*/
|
||||
const onboarding = useOnboardingStore();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: 'School',
|
||||
title: '欢迎使用班级小助手',
|
||||
lines: [
|
||||
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
|
||||
'第一次使用,先花半分钟看看怎么开始。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'Link',
|
||||
title: '第一步 · 连接服务器',
|
||||
lines: [
|
||||
'在登录页填写老师或管理员提供的服务器地址(通常形如 http://192.168.x.x:4000)。',
|
||||
'点「测试连接」确认可达;以后也能在「设置」里随时修改。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'Key',
|
||||
title: '第二步 · 登录班级',
|
||||
lines: [
|
||||
'使用班级码 + 班级密码登录(向老师或管理员索取)。',
|
||||
'登录后这台设备就代表全班:作业完成、通知已读都会按全班记录,不需要每个学生单独登录。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'Calendar',
|
||||
title: '登录之后',
|
||||
lines: [
|
||||
'左侧菜单切换「课表 / 作业 / 通知 / 成绩」四个页面,作业支持看板全屏展示。',
|
||||
'断网时自动显示最近同步的缓存数据,联网后自动更新。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'Bell',
|
||||
title: '灵动岛提醒',
|
||||
lines: [
|
||||
'屏幕上方会有一条「灵动岛」浮窗:新通知、叫人、作业都会在那里弹出。',
|
||||
'上课时段自动隐藏、下课自动弹出;外观与位置可在「设置 → 灵动岛」调整。',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'CircleCheckFilled',
|
||||
title: '准备好了',
|
||||
lines: [
|
||||
'按上面的步骤登录就可以开始使用了。',
|
||||
'侧边栏「设置」分组里有通用、外观(深色模式)、灵动岛等页面;这份引导可以在登录页或「设置 → 通用 → 使用引导」随时重看。',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const index = ref(0);
|
||||
const current = computed(() => steps[index.value] ?? steps[0]);
|
||||
const isFirst = computed(() => index.value === 0);
|
||||
const isLast = computed(() => index.value === steps.length - 1);
|
||||
|
||||
/** 每次打开都从第一步开始(重看入口也走完整流程) */
|
||||
watch(
|
||||
() => onboarding.visible,
|
||||
(visible) => {
|
||||
if (visible) index.value = 0;
|
||||
},
|
||||
);
|
||||
|
||||
function goNext(): void {
|
||||
if (isLast.value) finish();
|
||||
else index.value += 1;
|
||||
}
|
||||
|
||||
function goPrev(): void {
|
||||
if (!isFirst.value) index.value -= 1;
|
||||
}
|
||||
|
||||
/** 完成 / 跳过统一走这里:记「已看过」并关闭(重看时重复写一次无副作用) */
|
||||
function finish(): void {
|
||||
const bridge = window.desktop;
|
||||
// 桥接不可用(浏览器调试)也要能正常关掉引导
|
||||
if (!bridge) {
|
||||
onboarding.close();
|
||||
return;
|
||||
}
|
||||
void bridge
|
||||
.saveConfig({ onboardingDone: true })
|
||||
.then(() => onboarding.close())
|
||||
.catch(() => onboarding.close());
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') finish();
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
}
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="onboarding-fade">
|
||||
<div
|
||||
v-if="onboarding.visible"
|
||||
class="onboarding-mask"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="初次启动引导"
|
||||
>
|
||||
<div class="onboarding-card onboarding-welcome">
|
||||
<div class="onboarding-head">
|
||||
<span class="onboarding-logo">
|
||||
<el-icon :size="18"><School /></el-icon>
|
||||
</span>
|
||||
<div class="onboarding-heading">
|
||||
<h2 class="onboarding-title">班级小助手</h2>
|
||||
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
|
||||
</div>
|
||||
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-body">
|
||||
<span class="onboarding-icon">
|
||||
<el-icon :size="30"><component :is="current.icon" /></el-icon>
|
||||
</span>
|
||||
<h3 class="onboarding-step-title">{{ current.title }}</h3>
|
||||
<p v-for="(line, lineIndex) in current.lines" :key="lineIndex" class="onboarding-line">
|
||||
{{ line }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-foot">
|
||||
<div class="onboarding-dots">
|
||||
<span
|
||||
v-for="(step, dotIndex) in steps"
|
||||
:key="dotIndex"
|
||||
class="dot"
|
||||
:class="{ active: dotIndex === index }"
|
||||
/>
|
||||
</div>
|
||||
<div class="onboarding-actions">
|
||||
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
|
||||
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
|
||||
下一步
|
||||
</el-button>
|
||||
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.onboarding-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 30, 50, 0.55);
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
width: 520px;
|
||||
max-width: 92vw;
|
||||
max-height: 86vh;
|
||||
overflow-y: auto;
|
||||
background: var(--ch-layer-solid);
|
||||
border-radius: 16px;
|
||||
padding: 22px 24px 18px;
|
||||
box-shadow: 0 24px 64px rgba(15, 30, 50, 0.35);
|
||||
}
|
||||
|
||||
.onboarding-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.onboarding-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
|
||||
}
|
||||
|
||||
.onboarding-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.onboarding-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.onboarding-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.onboarding-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 18px 4px 6px;
|
||||
}
|
||||
|
||||
.onboarding-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 14px;
|
||||
color: var(--ch-accent);
|
||||
background: var(--ch-accent-soft);
|
||||
}
|
||||
|
||||
.onboarding-step-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.onboarding-line {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--ch-text-regular, #606266);
|
||||
}
|
||||
|
||||
.onboarding-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--ch-divider);
|
||||
}
|
||||
|
||||
.onboarding-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--ch-border-strong);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
width: 20px;
|
||||
background: var(--ch-accent);
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 进出场:淡入 + 轻微上浮,跟随系统"动画"开关无强依赖 */
|
||||
.onboarding-fade-enter-active,
|
||||
.onboarding-fade-leave-active {
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.onboarding-fade-enter-active .onboarding-card,
|
||||
.onboarding-fade-leave-active .onboarding-card {
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
.onboarding-fade-enter-from,
|
||||
.onboarding-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.onboarding-fade-enter-from .onboarding-card,
|
||||
.onboarding-fade-leave-to .onboarding-card {
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* 轻量柱状图(纯 HTML/CSS,不依赖任何图表库)。
|
||||
*
|
||||
* 为什么不用 echarts:成绩页只需要「分组柱 + 数值标签 + 坐标轴」这点东西,
|
||||
* 而 `import * as echarts from 'echarts'` 会给渲染产物加 1.1MB(客户端里最大的一块),
|
||||
* 按需引入 echarts/core 也仍有约 500KB。
|
||||
*
|
||||
* 实现要点(决定了这里为什么不需要 resize 监听):
|
||||
* - 柱高用**百分比**:`.bar-track` 是行方向 flex 里 `flex:1` 的子项,高度确定,
|
||||
* 柱子的 `height: X%` 直接按它解析;
|
||||
* - 刻度与网格线用**零高度 flex 行 + `justify-content: space-between`**:
|
||||
* 零高度元素的中心正好落在 i/(N-1) 处,与柱状区上下边界精确对齐;
|
||||
* - 纵轴刻度列与绘图区共用同一套上下留白(数值行 18px / 标签行 30px),因此永远对齐。
|
||||
*/
|
||||
interface BarItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 每根柱:分类名 + 数值(0 ~ max) */
|
||||
items: BarItem[];
|
||||
/** 柱色 */
|
||||
color?: string;
|
||||
/** 纵轴上限 */
|
||||
max?: number;
|
||||
/** 数值单位(显示在刻度与柱顶数值后面) */
|
||||
unit?: string;
|
||||
/** 图表高度(px) */
|
||||
height?: number;
|
||||
}>(),
|
||||
{ color: '#409eff', max: 100, unit: '%', height: 300 },
|
||||
);
|
||||
|
||||
/** 纵轴刻度(自上而下):100 / 75 / 50 / 25 / 0(按 max 换算) */
|
||||
const ticks = computed(() => [1, 0.75, 0.5, 0.25, 0].map((ratio) => Math.round(props.max * ratio)));
|
||||
|
||||
function barHeight(value: number): string {
|
||||
const ratio = props.max > 0 ? value / props.max : 0;
|
||||
return `${Math.min(1, Math.max(0, ratio)) * 100}%`;
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
return `${Number.isInteger(value) ? value : value.toFixed(1)}${props.unit}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar-chart" :style="{ height: `${height}px` }">
|
||||
<div v-if="items.length === 0" class="bar-chart-empty">暂无数据</div>
|
||||
<template v-else>
|
||||
<div class="axis-y" aria-hidden="true">
|
||||
<span v-for="tick in ticks" :key="tick" class="tick">{{ tick }}{{ unit }}</span>
|
||||
</div>
|
||||
|
||||
<div class="plot">
|
||||
<div class="gridlines" aria-hidden="true">
|
||||
<span v-for="tick in ticks" :key="tick" class="gridline"></span>
|
||||
</div>
|
||||
|
||||
<div class="bars">
|
||||
<div v-for="item in items" :key="item.label" class="bar-col">
|
||||
<div class="bar-value">{{ formatValue(item.value) }}</div>
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar"
|
||||
:style="{ height: barHeight(item.value), background: color }"
|
||||
:title="`${item.label}:${formatValue(item.value)}`"
|
||||
></div>
|
||||
</div>
|
||||
<div class="bar-label" :title="item.label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bar-chart {
|
||||
display: grid;
|
||||
grid-template-columns: 46px 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bar-chart-empty {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
/* 纵轴刻度:上下留白与 .bars 里的数值行 / 标签行一一对应 */
|
||||
.axis-y {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 18px 0 30px;
|
||||
}
|
||||
|
||||
.tick {
|
||||
height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.plot {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gridlines {
|
||||
position: absolute;
|
||||
inset: 18px 0 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gridline {
|
||||
height: 0;
|
||||
border-top: 1px solid var(--ch-border);
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.bar-col {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
flex: none;
|
||||
height: 18px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
max-width: 46px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
flex: none;
|
||||
height: 30px;
|
||||
padding-top: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 动效组合式函数 —— beUI 那套动效语言在客户端的落地层。
|
||||
*
|
||||
* 令牌的**数值**在 `@classhelper/shared` 的 `motion.ts`(单一来源);
|
||||
* 这里只放需要 DOM / Vue 的部分。内容与 Web 管理端的
|
||||
* `packages/web-admin/src/composables/motion.ts` 一致 —— 与 `ScoreBarChart.vue`
|
||||
* 一样,两端各一份是仓库既有做法。
|
||||
*
|
||||
* 客户端相较 Web 端多一层现实约束:教室机器多为**希沃触摸屏**,
|
||||
* 触摸点按会产生粘住的幽灵 hover,因此 `useHoverCapable()` 在客户端是**必需**的,
|
||||
* 不是锦上添花(见 AGENTS.md §5 第 30 条)。
|
||||
*/
|
||||
import { computed, onUnmounted, ref, watch, type CSSProperties, type Ref } from 'vue';
|
||||
import {
|
||||
EASE_OUT,
|
||||
HOVER_CAPABLE_QUERY,
|
||||
MOTION_DURATION,
|
||||
REDUCED_MOTION_QUERY,
|
||||
cubicBezierCss,
|
||||
motionCssVars,
|
||||
} from '@classhelper/shared';
|
||||
|
||||
/**
|
||||
* 把动效令牌写进 `:root` 的自定义属性。
|
||||
*
|
||||
* **必须在 `app.mount()` 之前调用**:那样 CSS 里可以放心写 `var(--ch-spring-panel)`,
|
||||
* 首帧之前就已就位,既没有 FOUC,也不需要到处写兜底值。
|
||||
*
|
||||
* ⚠️ 只给**主渲染进程**注入。灵动岛是另一个渲染进程(`src/island/main.ts`),
|
||||
* 它有自己那套从 WinIsland 移植的弹簧物理,刻意不参与这里的动效体系。
|
||||
*
|
||||
* 弹簧用 CSS `linear()` 表达;Electron 44(Chromium 13x)原生支持,
|
||||
* 仍保留 `cubic-bezier` 回落分支以防将来降版本。
|
||||
*/
|
||||
export function applyMotionTokens(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const linear =
|
||||
typeof CSS !== 'undefined' &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
CSS.supports('transition-timing-function', 'linear(0, 1)');
|
||||
|
||||
for (const [name, value] of Object.entries(motionCssVars({ linear }))) {
|
||||
document.documentElement.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听 `prefers-reduced-motion`。
|
||||
*
|
||||
* 三元表达式而非直接返回 `matchMedia(...)` 的结果:这条偏好可能在运行中被改动,
|
||||
* 必须跟着变;JS 驱动的动画(数字滚动、位置测量)拿不到 CSS 那层自动降级,得自己看这个值。
|
||||
*/
|
||||
export function useReducedMotion(): Ref<boolean> {
|
||||
const matches = ref(false);
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return matches;
|
||||
|
||||
const query = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||
matches.value = query.matches;
|
||||
|
||||
const update = (): void => {
|
||||
matches.value = query.matches;
|
||||
};
|
||||
// Electron 的 Chromium 支持 addEventListener;保留旧接口兜底
|
||||
if (typeof query.addEventListener === 'function') {
|
||||
query.addEventListener('change', update);
|
||||
onUnmounted(() => query.removeEventListener('change', update));
|
||||
} else if (typeof query.addListener === 'function') {
|
||||
query.addListener(update);
|
||||
onUnmounted(() => query.removeListener(update));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有真正的悬停能力(鼠标 / 触控板)。
|
||||
*
|
||||
* 触摸设备在点按时会触发**幽灵 hover** 并一直粘着,直到点别处才消失 ——
|
||||
* 教室里的希沃触摸屏正属于这种,所有 hover-only 效果(抬升、磁吸、光标跟随)
|
||||
* 都必须先过这一关,否则触摸屏上会出现"点一下,卡片就永远浮着"。
|
||||
*/
|
||||
export function useHoverCapable(): Ref<boolean> {
|
||||
const capable = ref(false);
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return capable;
|
||||
|
||||
const query = window.matchMedia(HOVER_CAPABLE_QUERY);
|
||||
capable.value = query.matches;
|
||||
|
||||
const update = (): void => {
|
||||
capable.value = query.matches;
|
||||
};
|
||||
if (typeof query.addEventListener === 'function') {
|
||||
query.addEventListener('change', update);
|
||||
onUnmounted(() => query.removeEventListener('change', update));
|
||||
} else if (typeof query.addListener === 'function') {
|
||||
query.addListener(update);
|
||||
onUnmounted(() => query.removeListener(update));
|
||||
}
|
||||
return capable;
|
||||
}
|
||||
|
||||
/** 缓动函数:给定进度 t∈[0,1],返回缓动后的进度。即 EASE_OUT 那条贝塞尔的求值。 */
|
||||
function easeOutProgress(t: number): number {
|
||||
const [x1, y1, x2, y2] = EASE_OUT;
|
||||
// 牛顿迭代反解 x(u) = t,再用同一个 u 求 y —— 三次贝塞尔求值的标准做法
|
||||
let u = t;
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const x = 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u ** 2 * x2 + u ** 3;
|
||||
const dx = 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u ** 2 * (1 - x2);
|
||||
if (Math.abs(dx) < 1e-6) break;
|
||||
u = Math.min(1, Math.max(0, u - (x - t) / dx));
|
||||
}
|
||||
return 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u ** 2 * y2 + u ** 3;
|
||||
}
|
||||
|
||||
export interface CountUpOptions {
|
||||
/** 时长(毫秒)。默认 `MOTION_DURATION.slow`。 */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字滚动:目标值变化时从当前显示值滚过去,而不是直接跳。
|
||||
*
|
||||
* `reduced-motion` 下**直接落位**(不启动 rAF)。
|
||||
*
|
||||
* 刻意**不**给"首次变化"开豁免:数据是异步加载的,挂载那一刻目标值还是 0,
|
||||
* 真正想看到滚动的恰恰是"0 → 实际值"这第一跳;而如果数据在挂载时就已就位
|
||||
* (离线缓存命中),`displayed` 初始化即等于目标值、根本不会触发 watch。
|
||||
*/
|
||||
export function useCountUp(source: () => number, options: CountUpOptions = {}): Ref<number> {
|
||||
const reduce = useReducedMotion();
|
||||
const duration = options.duration ?? MOTION_DURATION.slow;
|
||||
const displayed = ref(source());
|
||||
let frame = 0;
|
||||
|
||||
function stop(): void {
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
watch(source, (target) => {
|
||||
stop();
|
||||
if (reduce.value || duration <= 0) {
|
||||
displayed.value = target;
|
||||
return;
|
||||
}
|
||||
const from = displayed.value;
|
||||
const delta = target - from;
|
||||
if (delta === 0) return;
|
||||
|
||||
const start = performance.now();
|
||||
const tick = (now: number): void => {
|
||||
const progress = Math.min(1, (now - start) / duration);
|
||||
displayed.value = from + delta * easeOutProgress(progress);
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
else {
|
||||
displayed.value = target;
|
||||
frame = 0;
|
||||
}
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
onUnmounted(stop);
|
||||
return displayed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表错峰入场:返回一个"给第 index 项算内联样式"的函数,
|
||||
* 配合 CSS 类 `.ch-reveal`(见 `styles/index.css`)使用。
|
||||
*
|
||||
* 延迟**封顶**(默认第 8 项之后不再累加):一屏几十行时,最后一行不该等一秒才出现。
|
||||
*/
|
||||
export function useStaggerIn(step = 35, maxIndex = 8): (index: number) => CSSProperties {
|
||||
const reduce = useReducedMotion();
|
||||
return (index: number): CSSProperties =>
|
||||
reduce.value ? {} : ({ '--ch-reveal-delay': `${Math.min(index, maxIndex) * step}ms` } as CSSProperties);
|
||||
}
|
||||
|
||||
/** 把 reduced-motion 折成一个布尔,供模板里判断"要不要挂动画类"。 */
|
||||
export function useMotionEnabled(): Ref<boolean> {
|
||||
const reduce = useReducedMotion();
|
||||
return computed(() => !reduce.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 整页换肤的揭示动画(beUI 的 `theme-toggle`):新旧两帧各存一张快照,
|
||||
* 让新帧从一个**从点击位置扩散的圆**里长出来 —— 这是"整个界面换了张皮"这件事
|
||||
* 唯一能让人**看见**的表达方式,比整页硬切一下好懂得多。
|
||||
*
|
||||
* 返回的函数直接挂 `@click`:`@click="reveal($event, () => ui.setTheme(next))"`。
|
||||
*
|
||||
* 三条守卫缺一不可:
|
||||
* ① 不认识 View Transition API 的浏览器直接走同步切换;
|
||||
* ② 系统开了「减少动态效果」时不放全屏动画;
|
||||
* ③ **文档不可见时不放**(最小化、被遮挡、无头浏览器)—— 那种情况下浏览器不产生帧,
|
||||
* 而 View Transition 的更新回调要等到下一个渲染时机才执行,**主题会一直不切**。
|
||||
* 教室机器上窗口被别的窗口盖住是常态,这条尤其要守。
|
||||
*/
|
||||
export function useThemeReveal(): (event: MouseEvent, apply: () => void) => void {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (event, apply) => {
|
||||
const doc = document as Document & {
|
||||
startViewTransition?: (callback: () => void) => { ready: Promise<void> };
|
||||
};
|
||||
if (
|
||||
typeof doc.startViewTransition !== 'function' ||
|
||||
reduce.value ||
|
||||
document.visibilityState !== 'visible'
|
||||
) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
const { clientX: x, clientY: y } = event;
|
||||
void doc
|
||||
.startViewTransition(apply)
|
||||
.ready.then(() => {
|
||||
// 半径取到最远的那个角,圆才能把整屏吃完
|
||||
const radius = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
);
|
||||
document.documentElement.animate(
|
||||
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
|
||||
{
|
||||
// 比常规过渡长一点 —— 它是一次**整屏**的变化,太快会读成"闪了一下"而不是"换了一层皮"
|
||||
duration: MOTION_DURATION.slow * 1.5,
|
||||
// 缓动取自共享令牌,别在这里手写曲线字面量(那是漂移的起点)
|
||||
easing: cubicBezierCss(EASE_OUT),
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,19 @@ import { DEFAULT_SERVER_URL } from '@classhelper/shared';
|
||||
/** 默认后端地址(首次启动的预填值) */
|
||||
export const DEFAULT_SERVER = DEFAULT_SERVER_URL;
|
||||
|
||||
/**
|
||||
* 品牌标地址(侧栏与登录页的 `<img>` 用它)。
|
||||
*
|
||||
* 模板里既不能写 `src="logo.png"`、也不能写 `src="/logo.png"`:
|
||||
* ① 字面量的**相对** `src` 会被 Vite 当成模块导入去解析 —— 构建直接失败
|
||||
* (`Rolldown failed to resolve import "logo.png" from "LoginView.vue"`);
|
||||
* ② 根绝对路径在打包形态下是坏的:Electron 用 `file://` 加载
|
||||
* `dist/renderer/index.html`,`/logo.png` 会解析到**盘符根目录**。
|
||||
* 所以只能「绑定属性 + 跟着 vite base 走」:base 为 `'./'`,结果是 `./logo.png`,
|
||||
* 开发态(5174 静态服务)与打包态(`dist/renderer/logo.png`)都指向 public/ 拷过去的那份。
|
||||
*/
|
||||
export const BRAND_LOGO_URL = `${import.meta.env.BASE_URL}logo.png`;
|
||||
|
||||
/** 由服务器地址推导 API 基地址,例如 http://127.0.0.1:4000 -> http://127.0.0.1:4000/api */
|
||||
export function apiBaseOf(serverUrl: string): string {
|
||||
return `${serverUrl.trim().replace(/\/+$/, '')}/api`;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { App } from 'vue';
|
||||
import {
|
||||
AlarmClock,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bell,
|
||||
Brush,
|
||||
Calendar,
|
||||
CircleCheckFilled,
|
||||
Connection,
|
||||
Expand,
|
||||
Fold,
|
||||
FullScreen,
|
||||
Guide,
|
||||
InfoFilled,
|
||||
Key,
|
||||
Link,
|
||||
Loading,
|
||||
Lock,
|
||||
MagicStick,
|
||||
Moon,
|
||||
Notebook,
|
||||
Plus,
|
||||
Pointer,
|
||||
Refresh,
|
||||
School,
|
||||
Setting,
|
||||
Sunny,
|
||||
SwitchButton,
|
||||
Tools,
|
||||
Trophy,
|
||||
UserFilled,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue';
|
||||
|
||||
/**
|
||||
* Element Plus 图标注册表(桌面客户端渲染进程)。
|
||||
*
|
||||
* **为什么不 `import * as ElementPlusIconsVue` 全量注册**:
|
||||
* 图标库有 293 个组件(实测全量打包 minified 203KB,实际用到的只有 33 个),
|
||||
* 全量注册既把死代码打进主 chunk,又要在启动时执行 293 次 `app.component()`。
|
||||
*
|
||||
* **为什么不改成模板里逐个 import**:本项目的图标是按**字符串名**引用的
|
||||
* (`ClientLayout.vue` 的菜单配置 `icon: 'Setting'`、`OnboardingWelcome.vue` 的引导步骤、
|
||||
* 按钮上的 `:icon="'FullScreen'"`、`<component :is="item.icon">`),Element Plus 会用
|
||||
* `<component :is="'Setting'">` 去解析**全局组件名**,因此图标必须以全局组件存在。
|
||||
*
|
||||
* 新增视图时若用了新图标,`scripts/check-icons.mjs` 会在构建前报错,
|
||||
* 不会静默渲染成空白。
|
||||
*/
|
||||
const APP_ICONS = {
|
||||
AlarmClock,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bell,
|
||||
Brush,
|
||||
Calendar,
|
||||
CircleCheckFilled,
|
||||
Connection,
|
||||
Expand,
|
||||
Fold,
|
||||
FullScreen,
|
||||
Guide,
|
||||
InfoFilled,
|
||||
Key,
|
||||
Link,
|
||||
Loading,
|
||||
Lock,
|
||||
MagicStick,
|
||||
Moon,
|
||||
Notebook,
|
||||
Plus,
|
||||
Pointer,
|
||||
Refresh,
|
||||
School,
|
||||
Setting,
|
||||
Sunny,
|
||||
SwitchButton,
|
||||
Tools,
|
||||
Trophy,
|
||||
UserFilled,
|
||||
Warning,
|
||||
} as const;
|
||||
|
||||
/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */
|
||||
export const APP_ICON_NAMES: readonly string[] = Object.keys(APP_ICONS).sort();
|
||||
|
||||
export function registerAppIcons(app: App): void {
|
||||
for (const [name, component] of Object.entries(APP_ICONS)) {
|
||||
app.component(name, component);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
|
||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
|
||||
import { useThemeReveal } from '../composables/motion.js';
|
||||
import { BRAND_LOGO_URL } from '../config.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useNotificationStore } from '../stores/notifications.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
import { useUiStore } from '../stores/ui.js';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -15,18 +18,186 @@ const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const menuItems = [
|
||||
/**
|
||||
* 主侧边栏。
|
||||
*
|
||||
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
|
||||
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
|
||||
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
|
||||
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
|
||||
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
|
||||
*
|
||||
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
|
||||
*/
|
||||
const primaryItems = [
|
||||
{ path: '/schedule', title: '课表', icon: 'Calendar' },
|
||||
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
|
||||
{ path: '/notifications', title: '通知', icon: 'Bell' },
|
||||
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
||||
{ path: '/settings', title: '设置', icon: 'Setting' },
|
||||
];
|
||||
|
||||
const menuGroups = [
|
||||
{
|
||||
title: '设置',
|
||||
icon: 'Setting',
|
||||
items: [
|
||||
{ path: '/settings/general', title: '通用', icon: 'Tools' },
|
||||
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
|
||||
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
|
||||
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
|
||||
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
|
||||
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------- 导航指示器 */
|
||||
|
||||
/**
|
||||
* 侧栏激活项的**滑动指示器**(beUI 的 `shared-layout-bg` / tabs 的 layoutId 手法)。
|
||||
*
|
||||
* 做法是**一个**绝对定位的底片跟着激活项平移,而不是让每个菜单项各自画一块底色 ——
|
||||
* 后者在切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系。
|
||||
*
|
||||
* 位置靠**量 DOM**:菜单项的位置由 Element Plus 的 padding 与行高决定,
|
||||
* 写死会在组件库升级或文案变长时错位;折叠成图标导轨时宽度还会变,量出来才跟得上。
|
||||
*
|
||||
* ⚠️ 这里用 `document.querySelector` 而不是模板 ref:`.aside` 是 `el-aside` **组件**,
|
||||
* 模板 ref 拿到的是组件实例(`.querySelector` 不是函数,实测踩过);
|
||||
* 客户端只有一个主侧栏,直接查文档反而更直白。
|
||||
*/
|
||||
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
|
||||
|
||||
function syncIndicator(): void {
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
|
||||
if (!aside || !active) {
|
||||
indicator.value = { ...indicator.value, visible: false };
|
||||
return;
|
||||
}
|
||||
const asideRect = aside.getBoundingClientRect();
|
||||
const rect = active.getBoundingClientRect();
|
||||
indicator.value = {
|
||||
x: rect.left - asideRect.left,
|
||||
y: rect.top - asideRect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
};
|
||||
}
|
||||
|
||||
const indicatorStyle = computed(() => ({
|
||||
width: `${indicator.value.width}px`,
|
||||
height: `${indicator.value.height}px`,
|
||||
transform: `translate(${indicator.value.x}px, ${indicator.value.y}px)`,
|
||||
opacity: indicator.value.visible ? '1' : '0',
|
||||
}));
|
||||
|
||||
let asideObserver: ResizeObserver | null = null;
|
||||
let indicatorFrame = 0;
|
||||
|
||||
/**
|
||||
* 把测量推迟到下一帧再跑。
|
||||
*
|
||||
* 侧栏折叠时 `.aside` 的宽度有一整段过渡,`ResizeObserver` 会逐帧回调;若在回调里**同步**
|
||||
* 读 `getBoundingClientRect()` 再写样式,就是在每一帧中间强行插入一次同步布局
|
||||
* (Chromium 还会因此报 "ResizeObserver loop completed with undelivered notifications")。
|
||||
* 放进 rAF 就变成"本帧布局完成后统一量一次",代价是跟随慢一帧,肉眼无感。
|
||||
*/
|
||||
function scheduleIndicatorSync(): void {
|
||||
if (indicatorFrame) return;
|
||||
indicatorFrame = requestAnimationFrame(() => {
|
||||
indicatorFrame = 0;
|
||||
syncIndicator();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
|
||||
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
|
||||
*
|
||||
* `ResizeObserver` 不是为了窗口缩放,而是为了**侧栏折叠**:折叠时 `.aside` 的宽度
|
||||
* 有一整段过渡(`--nav-dur`),观察它会逐帧回调,指示器于是跟着栏宽一起收 ——
|
||||
* 否则图标导轨形成后,底片还停在展开态的宽度上。
|
||||
*/
|
||||
onMounted(() => {
|
||||
syncIndicator();
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
if (aside && typeof ResizeObserver !== 'undefined') {
|
||||
asideObserver = new ResizeObserver(scheduleIndicatorSync);
|
||||
asideObserver.observe(aside);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
|
||||
indicatorFrame = 0;
|
||||
asideObserver?.disconnect();
|
||||
asideObserver = null;
|
||||
});
|
||||
|
||||
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
void nextTick(syncIndicator);
|
||||
},
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------- 主题切换动画 */
|
||||
|
||||
/**
|
||||
* 顶栏的日夜快捷切换。
|
||||
*
|
||||
* "从点击位置扩散的圆"那套整页揭示由 `useThemeReveal()` 提供 ——
|
||||
* **与 Web 管理端共用同一份实现**(两端 `composables/motion.ts` 里的同名函数),
|
||||
* 这里只负责把"点击事件"和"切换主题"接起来。那三条守卫(不支持 View Transition /
|
||||
* 减少动态效果 / 文档不可见)都在组合式函数里,别在这里再判一遍。
|
||||
*/
|
||||
const revealThemeChange = useThemeReveal();
|
||||
|
||||
function handleThemeToggle(event: MouseEvent): void {
|
||||
revealThemeChange(event, () => ui.toggleTheme());
|
||||
}
|
||||
|
||||
/** 未读红点挂在「通知」项的图标右上角 */
|
||||
function showBadge(path: string): boolean {
|
||||
return path === '/notifications' && notifications.unreadCount > 0;
|
||||
}
|
||||
|
||||
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||
const activeMenu = computed(() => route.path);
|
||||
|
||||
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
|
||||
const activeGroupTitle = computed(() => {
|
||||
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
|
||||
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
|
||||
return group?.title ?? null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 分组展开控制:默认只展开**当前路由所在的分组**(一级项不属于任何分组,此时一个都不展开)。
|
||||
*
|
||||
* 窗口默认高度与 ClassIsland 对齐(582px),设置组 6 个子项展开后会超出可视高度、
|
||||
* 把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的观感(它也只展开当前组),
|
||||
* 又让小窗口下的侧栏始终完整可见。用户手动展开其它分组不受限制(可多个同时展开)。
|
||||
*/
|
||||
const menuRef = ref<MenuInstance>();
|
||||
const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.value] : []);
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
async (path) => {
|
||||
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
|
||||
if (!group) return;
|
||||
// 展开当前组(不主动关闭用户已展开的其它组)
|
||||
await nextTick();
|
||||
menuRef.value?.open(group.title);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 菜单点击回调。
|
||||
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
|
||||
@@ -44,9 +215,11 @@ function go(name: string): void {
|
||||
/** 开发期自检:菜单路径必须存在于路由表中 */
|
||||
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}`);
|
||||
for (const group of menuGroups) {
|
||||
for (const item of group.items) {
|
||||
if (!knownPaths.has(item.path)) {
|
||||
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,20 +279,45 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside width="200px" class="aside ch-nav">
|
||||
<el-aside
|
||||
:width="ui.sidebarCollapsed ? '64px' : '200px'"
|
||||
class="aside ch-nav"
|
||||
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
|
||||
>
|
||||
<div class="brand">
|
||||
<span class="brand-logo">
|
||||
<el-icon :size="16"><School /></el-icon>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
aria-label="折叠或展开侧边栏"
|
||||
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
|
||||
data-test="sidebar-toggle"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
|
||||
</button>
|
||||
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
|
||||
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
|
||||
<img class="brand-logo" :src="BRAND_LOGO_URL" alt="" width="26" height="26" />
|
||||
<span class="brand-text">班级小助手</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
||||
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
|
||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
||||
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
|
||||
<el-menu
|
||||
ref="menuRef"
|
||||
:default-active="activeMenu"
|
||||
:default-openeds="openedGroups"
|
||||
class="menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<!-- 一级项:直接铺在侧栏上(不再套「学习」分组)。
|
||||
下面 el-sub-menu 里的菜单项与本段是同一套标记(图标槽 + 未读红点 + 标题),
|
||||
改动时两处要一起改。 -->
|
||||
<el-menu-item v-for="item in primaryItems" :key="item.path" :index="item.path">
|
||||
<span class="menu-icon-slot">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<el-badge
|
||||
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
||||
v-if="showBadge(item.path)"
|
||||
:value="notifications.unreadCount"
|
||||
:max="99"
|
||||
class="menu-badge"
|
||||
@@ -127,7 +325,27 @@ onUnmounted(() => {
|
||||
</span>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
|
||||
<template #title>
|
||||
<el-icon><component :is="group.icon" /></el-icon>
|
||||
<span>{{ group.title }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
|
||||
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
||||
<span class="menu-icon-slot">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<el-badge
|
||||
v-if="showBadge(item.path)"
|
||||
:value="notifications.unreadCount"
|
||||
:max="99"
|
||||
class="menu-badge"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
<!-- 同上:折叠时不删节点,只淡出并收掉内边距 -->
|
||||
<div class="aside-footer">
|
||||
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
|
||||
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
|
||||
@@ -142,6 +360,19 @@ onUnmounted(() => {
|
||||
<span class="server-url">{{ appStore.serverUrl }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- 黑夜/白天快捷切换:外观页里也有同样的设置,两处写同一份配置 -->
|
||||
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
|
||||
<el-button
|
||||
class="theme-toggle"
|
||||
text
|
||||
circle
|
||||
data-test="theme-toggle"
|
||||
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
|
||||
@click="handleThemeToggle"
|
||||
>
|
||||
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-dropdown trigger="click">
|
||||
<span class="user-chip">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
@@ -150,7 +381,7 @@ onUnmounted(() => {
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="go('settings')">
|
||||
<el-dropdown-item @click="go('settings-general')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
设置
|
||||
</el-dropdown-item>
|
||||
@@ -174,6 +405,19 @@ onUnmounted(() => {
|
||||
title="当前处于离线状态"
|
||||
:description="`服务器不可达,正在显示本地缓存数据(最近同步:${appStore.lastSyncText})。恢复网络后会自动同步。`"
|
||||
/>
|
||||
<!--
|
||||
这里**刻意不用** `<transition>` 包 router-view。
|
||||
页面切换的动效由 `.page > *` 的错峰入场承担(styles/index.css)——
|
||||
那是随组件挂载同步触发的,不推迟任何东西。
|
||||
|
||||
包一层过渡(尤其 `mode="out-in"`)会把"旧组件卸载、新组件挂载"推迟到退场动画结束
|
||||
(90ms 起)。代价有两个,都不值得:
|
||||
① 点菜单后有一小段时间页面上还是**上一页**,用户和自动化看到的都是旧内容;
|
||||
② 退场判定依赖 `animationend`,窗口被遮挡时 Chromium 冻结 CSS 动画、
|
||||
事件永不触发,新页面就永远不挂载 —— 整个客户端卡死在当前页。
|
||||
教室机器上窗口被别的东西盖住是常态,这条尤其要守。
|
||||
真要加回来,必须先确认这两条都有人兜住。
|
||||
-->
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</router-view>
|
||||
@@ -191,6 +435,54 @@ onUnmounted(() => {
|
||||
.aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 展开/收回动画:宽度来自 el-aside 的内联 --el-aside-width,这里只负责过渡。
|
||||
overflow:hidden 是必需的 —— 收窄过程中文字要被栏边裁掉,不能溢出到主区域;
|
||||
菜单自己的滚动由内部的 .menu(flex:1 + min-height:0 + overflow-y:auto)负责。 */
|
||||
overflow: hidden;
|
||||
transition: width var(--nav-dur) var(--nav-ease);
|
||||
/* 滑动指示器(.nav-indicator)的定位父级 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/*
|
||||
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
|
||||
*
|
||||
* 位置与尺寸都由 syncIndicator() 量出来后内联下发,这里只负责外观与过渡。
|
||||
* 缓动用 LAYOUT 弹簧(397ms)—— 它是"共享布局滑动"的专用参数:比 SWAP(263ms)更有分量,
|
||||
* 因为这一跳可能跨越好几个菜单项,太快会显得"闪"而不是"滑"。
|
||||
*
|
||||
* 折叠成图标导轨时,栏宽有一整段 260ms 的过渡,ResizeObserver 会逐帧把新宽度送进来,
|
||||
* 底片于是跟着栏宽一起收窄 —— 两个时长不一致是**故意的**:底片比栏慢一点,
|
||||
* 读起来像"被栏宽带着走",同步反而显得僵硬。
|
||||
*/
|
||||
.nav-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
border-radius: var(--ch-radius-control);
|
||||
background: var(--ch-accent-soft);
|
||||
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
transition:
|
||||
transform var(--ch-spring-layout-dur) var(--ch-spring-layout),
|
||||
width var(--ch-spring-layout-dur) var(--ch-spring-layout),
|
||||
height var(--ch-spring-layout-dur) var(--ch-spring-layout),
|
||||
opacity var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
/* 左侧强调条(原有观感,现在跟着底片一起滑) */
|
||||
.nav-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: var(--ch-accent);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -198,34 +490,119 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
padding: 0 10px 0 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--ch-text);
|
||||
transition:
|
||||
padding-left var(--nav-dur) var(--nav-ease),
|
||||
gap var(--nav-dur) var(--nav-ease);
|
||||
}
|
||||
|
||||
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
|
||||
.brand-logo {
|
||||
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
|
||||
.nav-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
border-radius: var(--ch-radius-control);
|
||||
background: transparent;
|
||||
color: var(--ch-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-toggle:hover {
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
/* 折叠态:侧栏只剩 64px,汉堡按钮水平居中。
|
||||
用**位移 padding-left** 而不是 `justify-content: center` —— 后者不可过渡,会在动画中途"跳"一下。
|
||||
汉堡宽 28px,padding-left 18px ⇒ 中心 = 18 + 14 = 32px,正好是 64/2,与折叠后的图标导轨同列
|
||||
(冒烟断言中心差 ≤ 2px)。
|
||||
这里刻意用**自己的类名**(`.is-collapsed`)而不是 `:has(.el-menu--collapse)`:
|
||||
`:has()` 的失效会让 Chromium 标记整棵文档树重新计算样式。 */
|
||||
.aside.ch-nav.is-collapsed .brand {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 品牌标:与应用图标同一份图(`public/logo.png`,由 `pnpm icons` 从 `build/classhelper.png` 生成)。
|
||||
* 上一版这里是"强调色渐变方块 + 白色 School 字形"手搓的占位标,换成真图标之后
|
||||
* `background` / `color` / `border-radius` / `overflow` 全部撤掉 —— 圆角与透明边角本来就在图里,
|
||||
* 再叠一层只会出现双重圆角。
|
||||
*
|
||||
* `src` 走 `BRAND_LOGO_URL`(见 `config.ts`):模板里既不能写字面量的相对 `src`(Vite 会把它
|
||||
* 当模块导入去解析、构建失败),也不能写根绝对路径(Electron 生产环境走 file://,会解析到盘符根目录)。
|
||||
*
|
||||
* 折叠态的 `.is-collapsed .brand-logo { width: 0 }` 照旧生效:img 的 width 一样可过渡,
|
||||
* 而且它本来就带 opacity 淡出,收放观感与改造前完全一致。
|
||||
*/
|
||||
.brand-logo {
|
||||
display: block;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
transition:
|
||||
width var(--nav-dur) var(--nav-ease),
|
||||
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
|
||||
}
|
||||
|
||||
/*
|
||||
* 品牌文字 / 菜单文字在折叠时**不靠 max-width 归零**(那要么写死一个魔法数字,要么
|
||||
* `none → 0` 根本不可过渡),而是:
|
||||
* - `min-width: 0` 让这个 flex 子项能被压缩(默认的 `min-width: auto` 会撑住内容宽度);
|
||||
* - 宽度自然跟着栏宽收缩,多出来的字被 `overflow: hidden` 裁掉;
|
||||
* - 再叠一层 opacity 淡出,避免在中间帧里留下"半截字"。
|
||||
* 于是文字宽度是**跟着侧栏宽度连续变化**的,不需要给它单独设动画终点。
|
||||
*/
|
||||
.brand-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.2px;
|
||||
transition: opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
|
||||
}
|
||||
|
||||
/* 折叠:品牌区收成 0 宽并淡出(不是删节点,理由见模板上的注释) */
|
||||
.aside.ch-nav.is-collapsed .brand {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 图标导轨里**不显示滚动条**:所有图标应当一眼看全(用户要求"不要用滚轮、删掉滚动条")。
|
||||
* 导轨之所以放得下,靠的是页脚在折叠态把高度也收成 0(见 .is-collapsed .aside-footer)——
|
||||
* 不再有那 ~70px 的留白,11 行图标(462px)在 540px 的最小窗口里也够。
|
||||
* 这里只隐藏滚动条本身,`overflow-y: auto` 保留:窗口被拉到极矮时还能滚,不至于把图标直接裁掉。
|
||||
* 展开态**不隐藏** —— 那一栏确实会超(设置组展开时约 606px > 582px),
|
||||
* 滚动条是"下面还有内容"的唯一提示,不能拿掉。
|
||||
*/
|
||||
.aside.ch-nav.is-collapsed .menu {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.aside.ch-nav.is-collapsed .brand-logo {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.aside.ch-nav.is-collapsed .brand-text {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
flex: 1;
|
||||
/* min-height:0 是 flex 子项能滚动的关键:否则内容撑高、底部 footer 被挤出窗口 */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
--el-menu-bg-color: transparent;
|
||||
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
|
||||
--el-menu-hover-bg-color: var(--ch-hover-soft);
|
||||
--el-menu-text-color: var(--ch-text);
|
||||
--el-menu-active-color: var(--ch-text);
|
||||
}
|
||||
@@ -269,6 +646,30 @@ onUnmounted(() => {
|
||||
.aside-footer {
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid var(--ch-divider);
|
||||
overflow: hidden;
|
||||
/* max-height 是给折叠动画用的终点:`* { box-sizing: border-box }`,展开态给一个略高于
|
||||
内容(约 70px)的值,折叠时归零 —— 导轨里**一个像素都不给页脚留**。
|
||||
不这么做的话,文字淡出后仍占着 ~70px,10 个图标在 540px 的最小窗口里就放不下、被迫滚动
|
||||
(用户反馈:"图标无法完全显示、需要上下滑动、滚动条碍眼")。 */
|
||||
max-height: 96px;
|
||||
transition:
|
||||
opacity calc(var(--nav-dur) * 0.7) var(--nav-ease),
|
||||
padding var(--nav-dur) var(--nav-ease),
|
||||
max-height var(--nav-dur) var(--nav-ease),
|
||||
border-top-color var(--nav-dur) var(--nav-ease);
|
||||
}
|
||||
|
||||
/* 折叠:页脚淡出、内边距与高度一起收到 0,分隔线也透明掉。
|
||||
`min-height: 0` 是必需的:flex 子项默认 `min-height: auto`(= 内容高度),
|
||||
而 CSS 里 min-height 优先于 max-height —— 不显式归零的话 max-height:0 根本压不下去。 */
|
||||
.aside.ch-nav.is-collapsed .aside-footer {
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
max-height: 0;
|
||||
min-height: 0;
|
||||
border-top-color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aside-meta {
|
||||
@@ -279,7 +680,7 @@ onUnmounted(() => {
|
||||
|
||||
.header {
|
||||
height: 52px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
background: var(--ch-header-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--ch-border);
|
||||
display: flex;
|
||||
@@ -301,6 +702,17 @@ onUnmounted(() => {
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
/* 必须是 flex:Element Plus 的按钮是 `vertical-align: middle`、下拉容器是 `vertical-align: top`,
|
||||
放在普通 block 容器里两个 inline-level 子元素会按各自的垂直对齐规则落位,主题切换按钮与
|
||||
用户名牌就会错开几像素(用户反馈的"深色模式没对齐",其实两种主题下都偏,只是深色下更显眼)。
|
||||
与 Web 管理端 AdminLayout 的同名容器保持同一套写法。 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -313,7 +725,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.user-chip:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
/* 与用户名牌的间距由 .header-right 的 gap 负责,这里不再自带 margin */
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.main {
|
||||
|
||||
@@ -2,27 +2,35 @@ import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import ElementPlus from 'element-plus';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
||||
import 'element-plus/dist/index.css';
|
||||
// 深色主题:html.dark 下的 --el-* 变量(品牌覆盖在 styles/index.css 的 html.dark 块)
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||
|
||||
import App from './App.vue';
|
||||
import router from './router/index.js';
|
||||
import { registerSmokeHooks, setSmokeTeacherCredentials } from './cache/self-test.js';
|
||||
import { registerAppIcons } from './icons.js';
|
||||
import { applyMotionTokens } from './composables/motion.js';
|
||||
import './styles/index.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(name, component);
|
||||
}
|
||||
// 按需注册图标(见 icons.ts:只注册用到的那些,而不是全量 293 个)
|
||||
registerAppIcons(app);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
|
||||
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)。
|
||||
//
|
||||
// **动态 import 是刻意的**:`cache/self-test.ts` 有 1000 多行自检代码,
|
||||
// 静态 import 会把它打进渲染进程入口 chunk,每次启动都要解析一遍。改成动态 import 后
|
||||
// Vite/Rollup 会把它切成独立 chunk,只在冒烟模式下才加载(`verify:desktop` /
|
||||
// `verify:packaged` 的那条路径)。
|
||||
//
|
||||
// 教师凭据由 preload 从环境变量透传(**不写在代码里**:渲染产物会进 app.asar 发给学生机)。
|
||||
if (window.desktop?.smokeTest) {
|
||||
const { registerSmokeHooks, setSmokeTeacherCredentials } = await import('./cache/self-test.js');
|
||||
setSmokeTeacherCredentials({
|
||||
username: window.desktop.smokeCredentials?.username ?? '',
|
||||
password: window.desktop.smokeCredentials?.password ?? '',
|
||||
@@ -30,5 +38,17 @@ if (window.desktop?.smokeTest) {
|
||||
registerSmokeHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 动效令牌(beUI 那套缓动与弹簧,见 packages/shared/src/motion.ts)写进 :root。
|
||||
*
|
||||
* 必须在 mount 之前:CSS 里引用的 `var(--ch-spring-*)` 要在首帧之前就已就位,
|
||||
* 否则第一帧会按"变量不存在"渲染一遍(表现为入场动画闪一下)。
|
||||
* 也刻意排在主题恢复(stores/ui.ts 的 init)之前 —— 令牌与主题互不相干,
|
||||
* 但先落令牌能让首屏的入场动画从第一帧起就是对的节奏。
|
||||
*
|
||||
* ⚠️ 灵动岛是**另一个渲染进程**(src/island/main.ts),不经过这里。
|
||||
*/
|
||||
applyMotionTokens();
|
||||
|
||||
await router.isReady();
|
||||
app.mount('#app');
|
||||
@@ -41,11 +41,46 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/GradeView.vue'),
|
||||
meta: { title: '成绩' },
|
||||
},
|
||||
/* 设置:主侧边栏「设置」分组下的六个子页(ClassIsland 式 主菜单+子菜单) */
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
meta: { title: '设置' },
|
||||
redirect: '/settings/general',
|
||||
},
|
||||
{
|
||||
path: 'settings/general',
|
||||
name: 'settings-general',
|
||||
component: () => import('../views/settings/GeneralSettingsView.vue'),
|
||||
meta: { title: '通用设置' },
|
||||
},
|
||||
{
|
||||
path: 'settings/appearance',
|
||||
name: 'settings-appearance',
|
||||
component: () => import('../views/settings/AppearanceView.vue'),
|
||||
meta: { title: '外观' },
|
||||
},
|
||||
{
|
||||
path: 'settings/island',
|
||||
name: 'settings-island',
|
||||
component: () => import('../views/settings/IslandSettingsView.vue'),
|
||||
meta: { title: '灵动岛' },
|
||||
},
|
||||
{
|
||||
path: 'settings/reminder',
|
||||
name: 'settings-reminder',
|
||||
component: () => import('../views/settings/ReminderSettingsView.vue'),
|
||||
meta: { title: '提醒' },
|
||||
},
|
||||
{
|
||||
path: 'settings/account',
|
||||
name: 'settings-account',
|
||||
component: () => import('../views/settings/AccountSettingsView.vue'),
|
||||
meta: { title: '账号' },
|
||||
},
|
||||
{
|
||||
path: 'settings/about',
|
||||
name: 'settings-about',
|
||||
component: () => import('../views/settings/AboutView.vue'),
|
||||
meta: { title: '关于' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
/**
|
||||
* 初次启动引导的显示状态。
|
||||
*
|
||||
* 首次启动由 App.vue 按「配置里是否记过 onboardingDone」自动弹出;
|
||||
* 登录页与设置页的「使用引导」入口调用 open() 随时重看。
|
||||
* 单独一个 store 而不是组件内部 ref:触发入口(登录页 / 设置页)与
|
||||
* 渲染点(App.vue 顶层)分处两棵互不相干的子树。
|
||||
*/
|
||||
export const useOnboardingStore = defineStore('onboarding', () => {
|
||||
const visible = ref(false);
|
||||
|
||||
function open(): void {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
return { visible, open, close };
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { DesktopStoredConfig } from '../../types/desktop.js';
|
||||
|
||||
/**
|
||||
* 界面偏好(主题 / 侧边栏折叠)。
|
||||
*
|
||||
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
|
||||
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
|
||||
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
|
||||
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
|
||||
*/
|
||||
|
||||
const THEME_KEY = 'classhelper.theme';
|
||||
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
|
||||
|
||||
function applyThemeClass(theme: 'light' | 'dark'): void {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const theme = ref<'light' | 'dark'>('light');
|
||||
const sidebarCollapsed = ref(false);
|
||||
|
||||
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
|
||||
async function init(): Promise<void> {
|
||||
if (window.desktop) {
|
||||
const config = await window.desktop.getConfig().catch(() => null);
|
||||
if (config) {
|
||||
theme.value = config.theme === 'dark' ? 'dark' : 'light';
|
||||
sidebarCollapsed.value = config.sidebarCollapsed === true;
|
||||
}
|
||||
} else {
|
||||
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
|
||||
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
|
||||
}
|
||||
applyThemeClass(theme.value);
|
||||
}
|
||||
|
||||
/** 切换主题(外观页单选 / 顶栏快捷切换共用),写回配置 */
|
||||
async function setTheme(next: 'light' | 'dark'): Promise<void> {
|
||||
theme.value = next;
|
||||
applyThemeClass(next);
|
||||
if (window.desktop) {
|
||||
await window.desktop.saveConfig({ theme: next }).catch(() => undefined);
|
||||
} else {
|
||||
localStorage.setItem(THEME_KEY, next);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme(): void {
|
||||
void setTheme(theme.value === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
/** 主侧边栏折叠/展开(汉堡按钮),写回配置 */
|
||||
async function setSidebarCollapsed(collapsed: boolean): Promise<void> {
|
||||
sidebarCollapsed.value = collapsed;
|
||||
if (window.desktop) {
|
||||
await window.desktop.saveConfig({ sidebarCollapsed: collapsed }).catch(() => undefined);
|
||||
} else {
|
||||
localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar(): void {
|
||||
void setSidebarCollapsed(!sidebarCollapsed.value);
|
||||
}
|
||||
|
||||
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
|
||||
});
|
||||
|
||||
/** 供类型检查使用(仅引用类型) */
|
||||
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
|
||||
@@ -1,12 +1,19 @@
|
||||
/* ============================================================================
|
||||
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
|
||||
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
|
||||
*
|
||||
* 参考 ClassIsland(Windows 课件表应用)的观感:
|
||||
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
||||
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
||||
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
|
||||
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
|
||||
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
|
||||
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
|
||||
*
|
||||
* 主题机制:
|
||||
* - 全部颜色走 --ch-* / --el-* 变量,:root 是浅色值;
|
||||
* - 深色主题 = html 根元素挂 .dark 类(由 stores/ui.ts 按配置切换),
|
||||
* Element Plus 的 dark/css-vars.css(main.ts 引入)提供 --el-* 深色默认,
|
||||
* 下面的 html.dark 块覆盖品牌变量(特异性高于 :root,必定生效);
|
||||
* - 组件里**不要写死颜色**,一律用变量——深色回归靠这条纪律守着。
|
||||
* ========================================================================== */
|
||||
|
||||
:root {
|
||||
@@ -33,12 +40,40 @@
|
||||
--ch-text-secondary: #5c5c5c;
|
||||
--ch-text-tertiary: #8a8a8a;
|
||||
|
||||
/* 交互态(悬停底色 / 侧栏与顶栏的亚克力底 / 滚动条) */
|
||||
--ch-hover-soft: rgba(0, 0, 0, 0.04);
|
||||
--ch-nav-bg: rgba(255, 255, 255, 0.65);
|
||||
--ch-header-bg: rgba(255, 255, 255, 0.6);
|
||||
--ch-scrollbar: rgba(0, 0, 0, 0.18);
|
||||
--ch-scrollbar-hover: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* 圆角与阴影 */
|
||||
--ch-radius-card: 8px;
|
||||
--ch-radius-control: 4px;
|
||||
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
|
||||
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/*
|
||||
* 侧栏收放动画。组件(ClientLayout 的 .aside/.brand/.brand-logo/.aside-footer)
|
||||
* 与全局的菜单导轨规则(下面的 .ch-nav.is-collapsed …)共用这一份参数,
|
||||
* 改时长/缓动只改这里,两边不会漂。
|
||||
*
|
||||
* ⚠️ 缓动**刻意保留 Material 的 cubic-bezier(.4, 0, .2, 1)**,没有换成全站统一的
|
||||
* beUI EASE_OUT。试过的后果:EASE_OUT 起步极快,宽度掉到 80px 以下时动画才走了约 35%,
|
||||
* 而冒烟自检 layoutChromeSelfTest 一旦读到"宽度 ≤ 80"就立刻量图标中心 ——
|
||||
* 此刻内边距还在动,量出来 6 个分组子项偏 2.3px(要求 ≤2px),直接判红。
|
||||
* 原曲线的 0.882 进度点落在 74% 处,剩下的时间足够动画跑完,量到的是稳态。
|
||||
* 换句话说:**这条断言是按"收完之后的几何"写的,而它采样的时机取决于缓动的形状**,
|
||||
* 想换曲线就得连那条断言一起重新商量。
|
||||
*
|
||||
* ⚠️ 时长**不要**改到 0.05s 以下:同一条自检断言"折叠必须真的是动画"
|
||||
* (读 getComputedStyle 的 transitionDuration,要求 > 0.05)。
|
||||
*/
|
||||
--nav-dur: 260ms;
|
||||
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* 图标与文字的间距(折叠时收到 0) */
|
||||
--nav-gap: 8px;
|
||||
|
||||
/* 兼容旧变量名 */
|
||||
--ch-sidebar: #ffffff;
|
||||
|
||||
@@ -62,6 +97,55 @@
|
||||
--el-font-size-base: 13px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 深色主题 */
|
||||
|
||||
html.dark {
|
||||
--ch-accent: #5aabec;
|
||||
--ch-accent-hover: #7cc0f4;
|
||||
--ch-accent-soft: rgba(90, 171, 236, 0.16);
|
||||
--ch-accent-soft-strong: rgba(90, 171, 236, 0.26);
|
||||
|
||||
--ch-bg: #161719;
|
||||
--ch-layer: rgba(32, 33, 36, 0.72);
|
||||
--ch-layer-solid: #1e1f23;
|
||||
--ch-layer-alt: rgba(28, 29, 32, 0.5);
|
||||
--ch-card-subtle: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--ch-border: rgba(255, 255, 255, 0.08);
|
||||
--ch-border-strong: rgba(255, 255, 255, 0.14);
|
||||
--ch-divider: rgba(255, 255, 255, 0.06);
|
||||
|
||||
--ch-text: #e8e8ea;
|
||||
--ch-text-secondary: #b0b1b5;
|
||||
--ch-text-tertiary: #7e7f85;
|
||||
|
||||
--ch-hover-soft: rgba(255, 255, 255, 0.07);
|
||||
--ch-nav-bg: rgba(28, 29, 32, 0.65);
|
||||
--ch-header-bg: rgba(30, 31, 34, 0.6);
|
||||
--ch-scrollbar: rgba(255, 255, 255, 0.22);
|
||||
--ch-scrollbar-hover: rgba(255, 255, 255, 0.36);
|
||||
|
||||
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.45);
|
||||
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
|
||||
--ch-sidebar: #1e1f23;
|
||||
|
||||
/* Element Plus 深色下的品牌色对齐(dark/css-vars.css 的默认蓝偏亮,这里对齐品牌) */
|
||||
--el-color-primary: #4a9eff;
|
||||
--el-color-primary-light-3: #337fd6;
|
||||
--el-color-primary-light-5: #2563ab;
|
||||
--el-color-primary-light-7: #1b4a82;
|
||||
--el-color-primary-light-8: #163c6b;
|
||||
--el-color-primary-light-9: #122f55;
|
||||
--el-color-primary-dark-2: #6cb4ff;
|
||||
--el-text-color-primary: #e8e8ea;
|
||||
--el-text-color-regular: #c9cacd;
|
||||
--el-text-color-secondary: #9a9b9f;
|
||||
--el-border-color: rgba(255, 255, 255, 0.12);
|
||||
--el-border-color-light: rgba(255, 255, 255, 0.08);
|
||||
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -179,12 +263,12 @@ textarea {
|
||||
}
|
||||
|
||||
.el-button--default {
|
||||
background: rgba(255, 255, 255, 0.7) !important;
|
||||
background: var(--ch-layer) !important;
|
||||
border-color: var(--ch-border-strong) !important;
|
||||
}
|
||||
|
||||
.el-button--default:hover {
|
||||
background: rgba(0, 0, 0, 0.04) !important;
|
||||
background: var(--ch-hover-soft) !important;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
@@ -195,7 +279,7 @@ textarea {
|
||||
.el-textarea__inner,
|
||||
.el-select__wrapper {
|
||||
border-radius: var(--ch-radius-control) !important;
|
||||
background: rgba(255, 255, 255, 0.7) !important;
|
||||
background: var(--ch-layer) !important;
|
||||
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
|
||||
}
|
||||
|
||||
@@ -224,12 +308,38 @@ textarea {
|
||||
box-shadow: var(--ch-shadow-flyout) !important;
|
||||
}
|
||||
|
||||
/* 关于页的折叠卡片:与 el-card 同一套观感(分割线代替卡片描边) */
|
||||
.el-collapse {
|
||||
--el-collapse-border-color: var(--ch-divider);
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.el-collapse-item__header {
|
||||
background: var(--ch-layer) !important;
|
||||
border: 1px solid var(--ch-border) !important;
|
||||
border-radius: var(--ch-radius-card) !important;
|
||||
padding: 0 16px;
|
||||
margin-top: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.el-collapse-item__wrap {
|
||||
background: transparent !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.el-collapse-item__content {
|
||||
padding: 12px 6px 4px !important;
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 表格 */
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: var(--ch-divider);
|
||||
--el-table-header-bg-color: transparent;
|
||||
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
|
||||
--el-table-row-hover-bg-color: var(--ch-hover-soft);
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
@@ -251,12 +361,13 @@ textarea {
|
||||
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
|
||||
|
||||
.ch-nav {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
background: var(--ch-nav-bg);
|
||||
backdrop-filter: blur(24px) saturate(1.2);
|
||||
border-right: 1px solid var(--ch-border);
|
||||
}
|
||||
|
||||
.ch-nav .el-menu-item {
|
||||
.ch-nav .el-menu-item,
|
||||
.ch-nav .el-sub-menu__title {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
margin: 2px 8px;
|
||||
@@ -266,26 +377,122 @@ textarea {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ch-nav .el-menu-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
.ch-nav .el-menu-item:hover,
|
||||
.ch-nav .el-sub-menu__title:hover {
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
/* 分组标题:比普通菜单项轻一档(分组是"容器",不是目的地) */
|
||||
.ch-nav .el-sub-menu__title {
|
||||
font-weight: 600;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.ch-nav .el-sub-menu .el-menu {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式)。
|
||||
*
|
||||
* 底色与强调条**都改由滑动指示器承担**(见 ClientLayout.vue 的 `.nav-indicator`)——
|
||||
* 原先每个激活项各画一块,切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系;
|
||||
* 现在由同一个底片滑过去。指示器是 z-index:0 的绝对定位元素,菜单项抬到 1 层,
|
||||
* 文字才不会被它盖住(`.ch-nav .el-menu-item` 上面已有 `position: relative`)。
|
||||
*/
|
||||
.ch-nav .el-menu-item {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
|
||||
.ch-nav .el-menu-item.is-active {
|
||||
background: var(--ch-accent-soft);
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
.ch-nav .el-menu-item.is-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: var(--ch-accent);
|
||||
/* ---------------------------------------------------------------- 折叠态:图标导轨
|
||||
*
|
||||
* 折叠**不再用** el-menu 的 `:collapse`:那会把每个分组的子项卸载进浮层(一次结构切换),
|
||||
* 并让整条菜单重排一遍 —— 既做不出连续动画,也是"点汉堡卡一下"的渲染侧来源。
|
||||
* 现在改成纯粹的 CSS 收拢:栏宽、文字宽度、图标位置全是可过渡属性,全程没有结构变化,
|
||||
* 折叠后就是一条图标导轨(分组标题与子项都以图标列出)。
|
||||
*
|
||||
* 几何(64px 栏、菜单项左右各 8px margin):
|
||||
* 展开 一级项 padding-left 20px、二级项 40px(Element Plus 自带)→ 图标中心 40 / 60px
|
||||
* 折叠 所有项 padding-left / padding-right 都是 12px → 图标中心 8 + 12 + 12 = 32px,
|
||||
* 正好与顶部汉堡按钮的居中位置同列(冒烟断言中心差 ≤ 2px)。
|
||||
* 图标槽(.menu-icon-slot)与分组标题里的裸图标都是 Element Plus 给的 24px 宽。
|
||||
*/
|
||||
|
||||
/* 图标与文字的间距改由文字的 margin-left 负责:Element Plus 给图标挂了 margin-right:5px,
|
||||
那会让图标在 24px 的槽里偏左 2.5px(折叠时肉眼可见地不居中)。 */
|
||||
.ch-nav .el-menu-item [class^='el-icon'],
|
||||
.ch-nav .el-sub-menu__title > [class^='el-icon'] {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ch-nav .el-menu-item,
|
||||
.ch-nav .el-sub-menu__title {
|
||||
transition:
|
||||
padding-left var(--nav-dur) var(--nav-ease),
|
||||
padding-right var(--nav-dur) var(--nav-ease),
|
||||
border-color var(--el-transition-duration),
|
||||
background-color var(--el-transition-duration),
|
||||
color var(--el-transition-duration);
|
||||
}
|
||||
|
||||
/*
|
||||
* 文字:**不给它单独设动画终点**,宽度靠 flex 被压缩 —— `min-width:0` 让这个 flex 子项
|
||||
* 能被压到 0(默认的 `min-width:auto` 会撑住内容宽度),多出来的字由 `overflow:hidden` 裁掉。
|
||||
* 于是文字宽度天然跟着栏宽连续变化,不需要写什么魔法 max-width;再叠一层 opacity 淡出,
|
||||
* 避免中间帧留下"半截字"(`display:none` 没法过渡,用不了)。
|
||||
*/
|
||||
.ch-nav .el-menu-item > span:not(.menu-icon-slot),
|
||||
.ch-nav .el-sub-menu__title > span:not(.menu-icon-slot) {
|
||||
min-width: 0;
|
||||
margin-left: var(--nav-gap);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
margin-left var(--nav-dur) var(--nav-ease),
|
||||
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
|
||||
}
|
||||
|
||||
/* 分组标题右侧的折叠箭头:导轨里没有它的位置 */
|
||||
.ch-nav .el-sub-menu__icon-arrow {
|
||||
transition: opacity calc(var(--nav-dur) * 0.5) var(--nav-ease);
|
||||
}
|
||||
|
||||
.ch-nav.is-collapsed .el-menu-item > span:not(.menu-icon-slot),
|
||||
.ch-nav.is-collapsed .el-sub-menu__title > span:not(.menu-icon-slot) {
|
||||
margin-left: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ch-nav.is-collapsed .el-sub-menu__icon-arrow {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 折叠后的内边距。
|
||||
*
|
||||
* 这三条选择器形状都是**照抄 Element Plus 的**,不是冗余 —— 它按层级用三个不同的选择器算缩进
|
||||
* (一级项 20px、分组标题 20px、分组内子项 40px),特异度分别是 0-4-0 / 0-4-0 / 0-5-0:
|
||||
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
|
||||
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu__title`
|
||||
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu .el-menu-item`
|
||||
* 写成 `.ch-nav.is-collapsed .el-sub-menu__title` 这种(0-3-0)压不住任何一条 —— 实测踩过两次:
|
||||
* 第一版漏了分组标题(偏 8.0px),把「学习」拆成一级项后又漏了一级项(同样偏 8.0px)。
|
||||
* 下面两条(0-5-0 / 0-6-0)把它们全覆盖,且不用 !important。
|
||||
*/
|
||||
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-menu-item,
|
||||
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu__title {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu .el-menu-item {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 课表时间轴 */
|
||||
@@ -320,17 +527,422 @@ textarea {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
background: var(--ch-scrollbar);
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--ch-scrollbar-hover);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 动效层 —— beUI 的动效语言
|
||||
*
|
||||
* 令牌(缓动曲线、六组弹簧、时长)在 `packages/shared/src/motion.ts`,
|
||||
* 由 `main.ts` 在 mount 之前写进 `:root`;这里只负责**用**它们。
|
||||
* 与 Web 管理端(packages/web-admin/src/styles/index.css)是同一套词汇表。
|
||||
*
|
||||
* ⚠️ 灵动岛**不在**这套体系里:它是另一个渲染进程,用自研的 WinIsland 弹簧物理
|
||||
* (src/island/spring.ts),刻意保持独立,不要往那边接这里的变量。
|
||||
*
|
||||
* 分三层,越往下越"重":
|
||||
* ① 变量调音 —— 改写 Element Plus 自己的过渡变量,一次性改掉全站组件库过渡;
|
||||
* ② 弹簧覆盖 —— 弹窗 / 抽屉 / 浮层换成真正的物理曲线;
|
||||
* ③ 自有动效 —— 页面过渡、列表错峰入场、按钮按压、卡片抬升。
|
||||
* ========================================================================== */
|
||||
|
||||
/* ------------------------------------------------- ① 变量调音(覆盖组件库)
|
||||
*
|
||||
* Element Plus 的每个过渡都由这几个变量拼出来(`--el-transition-all` / `-fade` /
|
||||
* `-md-fade`),调这里等于一次性调完全站,比逐个覆盖类名安全。
|
||||
*
|
||||
* Fluent/ClassIsland 观感本来就偏"轻快",原来 300/200ms 的默认值在这套界面里更显拖沓,
|
||||
* 收到 200/140ms;缓动换成 beUI 的 EASE_OUT。
|
||||
*
|
||||
* 只改时长与缓动、不改颜色,所以不需要在 html.dark 里再写一份。
|
||||
* 侧栏的 `--nav-ease` 也在文件开头的 :root 里改成了同一条曲线(与折叠几何放在一起,
|
||||
* 免得"改侧栏动画要翻两个地方")。
|
||||
*/
|
||||
:root {
|
||||
--el-transition-duration: 200ms;
|
||||
--el-transition-duration-fast: 140ms;
|
||||
--el-transition-function-ease-in-out-bezier: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--el-transition-function-fast-bezier: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ ② 弹簧覆盖 */
|
||||
|
||||
/*
|
||||
* 弹窗(el-dialog)。Element Plus 的入场做在 `.el-overlay-dialog`(居中容器)上,
|
||||
* 遮罩淡入在 `.dialog-fade-enter-active` 自己身上,两条都要换 ——
|
||||
* 否则遮罩先到位、弹窗还在动,看起来是"背景已经变暗了但框还没出来"。
|
||||
*
|
||||
* 弹簧 PANEL 总时长 463ms 看着偏长,但它 95% 的行程在 262ms 就走完了,
|
||||
* 剩下的是极慢的收尾 —— 观感上就是"200ms 到位、再轻轻落定一下"。
|
||||
*/
|
||||
@keyframes ch-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ch-panel-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 位移与缩放挂在 `.el-dialog` **自己**身上,不挂在 `.el-overlay-dialog`(居中容器)上。
|
||||
*
|
||||
* 挂外层容器有个隐蔽的坑:Element Plus 的「打开完成」回调(@opened)按**根元素**
|
||||
* (`.el-overlay`,只有 200ms 的淡入)的动画结束触发,而外层容器上的弹簧要跑 463ms ——
|
||||
* 于是任何在 @opened 里量容器尺寸做自适应的弹窗,都会量到"还带着 scale(0.97) 的缩小版"。
|
||||
* **本客户端的作业看板正是这么算 scale 的**:测小了会被最小缩放兜住,反而超出屏幕 7px
|
||||
* (冒烟断言「作业看板全屏自适应(铺满屏幕、内容不溢出)」实测逮到,两次跑出来都是 550 > 543)。
|
||||
* 挂到 `.el-dialog` 上还有个好处:全屏弹窗能靠 `is-fullscreen` 精确豁免(见下)。
|
||||
*/
|
||||
.dialog-fade-enter-active .el-overlay-dialog,
|
||||
.dialog-fade-leave-active .el-overlay-dialog {
|
||||
/* 抵消 Element Plus 自带的那两条(它们同样带 translateY,会与下面这两条叠起来) */
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.dialog-fade-enter-active .el-dialog {
|
||||
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* 退出**刻意比入场快得多**:用户已经决定关掉它了,这时候再让他等一段"有分量"的动画
|
||||
* 只会显得卡。而且冒烟里多处断言"窗口/弹窗已消失",退出越快越稳。
|
||||
*/
|
||||
.dialog-fade-leave-active .el-dialog {
|
||||
animation: ch-panel-out var(--ch-dur-fast) var(--ch-ease-out) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* 全屏弹窗(`is-fullscreen`,就是作业看板)**不做位移缩放**。
|
||||
* 它本来就是铺满整屏的表面,"从略小处落下来"既看不出效果,又会让内部的
|
||||
* fitBoard / fitFullscreen 在 @opened 那一刻量到被 scale 过的容器 —— 测小了会被
|
||||
* 最小缩放兜住,结果反而超出屏幕。只留淡入。
|
||||
*/
|
||||
.dialog-fade-enter-active .el-dialog.is-fullscreen {
|
||||
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
|
||||
}
|
||||
|
||||
.dialog-fade-leave-active .el-dialog.is-fullscreen {
|
||||
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* 确认框(ElMessageBox)。这条沿用了"动画挂在遮罩层上"的做法:
|
||||
* 它内部没有任何按容器尺寸做自适应的内容,不存在上面那个 @opened 测量的坑。
|
||||
*/
|
||||
.el-overlay-message-box {
|
||||
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
|
||||
}
|
||||
|
||||
.dialog-fade-enter-active {
|
||||
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
|
||||
}
|
||||
|
||||
.dialog-fade-leave-active {
|
||||
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
|
||||
}
|
||||
|
||||
@keyframes ch-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ch-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 抽屉。位移由 `.el-drawer` **自身**的 `transition: all` 承载
|
||||
* (`.el-drawer-fade-*` 那组类只管遮罩与 overflow),所以弹簧挂在 `.el-drawer` 上,
|
||||
* 且显式列出属性 —— 不想让背景色的变化也走弹簧曲线。
|
||||
*/
|
||||
.el-drawer {
|
||||
transition:
|
||||
transform var(--ch-spring-panel-dur) var(--ch-spring-panel),
|
||||
opacity var(--ch-dur-base) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
/*
|
||||
* 浮层(下拉菜单 / 选择器 / 气泡)。Element Plus 用 `el-zoom-in-*` 系列,
|
||||
* transform-origin 已按 placement 设好。换成 SWAP 弹簧(263ms,比 PANEL 短):
|
||||
* 这类浮层是高频出现的,每次点下拉都等半秒会烦。
|
||||
*/
|
||||
.el-zoom-in-top-enter-active,
|
||||
.el-zoom-in-top-leave-active,
|
||||
.el-zoom-in-bottom-enter-active,
|
||||
.el-zoom-in-bottom-leave-active,
|
||||
.el-zoom-in-center-enter-active,
|
||||
.el-zoom-in-center-leave-active,
|
||||
.el-zoom-in-left-enter-active,
|
||||
.el-zoom-in-left-leave-active {
|
||||
transition:
|
||||
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
|
||||
opacity var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.el-zoom-in-top-enter-from,
|
||||
.el-zoom-in-bottom-enter-from,
|
||||
.el-zoom-in-center-enter-from,
|
||||
.el-zoom-in-left-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.el-zoom-in-top-leave-active,
|
||||
.el-zoom-in-bottom-leave-active,
|
||||
.el-zoom-in-center-leave-active,
|
||||
.el-zoom-in-left-leave-active {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* 消息条与通知:原生的位移是 0.4s,连弹三条时明显拖沓 */
|
||||
.el-message {
|
||||
transition:
|
||||
opacity var(--ch-dur-fast) var(--ch-ease-out),
|
||||
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
|
||||
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
|
||||
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
|
||||
}
|
||||
|
||||
.el-notification {
|
||||
transition:
|
||||
opacity var(--ch-dur-fast) var(--ch-ease-out),
|
||||
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
|
||||
left var(--ch-spring-swap-dur) var(--ch-spring-swap),
|
||||
right var(--ch-spring-swap-dur) var(--ch-spring-swap),
|
||||
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
|
||||
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ ③ 自有动效 */
|
||||
|
||||
/*
|
||||
* 按钮按压。Fluent 的按钮只有颜色过渡,没有"按下去"的物理反馈。
|
||||
*
|
||||
* 上行/下行用**不同的缓动**,这是这里唯一需要技巧的地方:
|
||||
* - 按下(`:active` 生效时):90ms 直落,要"跟手",慢了就是延迟;
|
||||
* - 抬起(`:active` 撤销后回到基础规则):走 PRESS 弹簧,轻轻弹回原位。
|
||||
* CSS 的规则是"进入某状态用该状态的 transition、离开用目标状态的 transition",
|
||||
* 所以把弹簧写在基础规则、把快落写在 `:active` 里,正好得到想要的一对。
|
||||
*/
|
||||
.el-button {
|
||||
transition:
|
||||
transform var(--ch-spring-press-dur) var(--ch-spring-press),
|
||||
color var(--el-transition-duration-fast) var(--ch-ease-out),
|
||||
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
|
||||
background-color var(--el-transition-duration-fast) var(--ch-ease-out),
|
||||
box-shadow var(--el-transition-duration-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
.el-button:not(.is-disabled):active {
|
||||
transform: scale(var(--ch-press-scale));
|
||||
transition-duration: var(--ch-dur-instant);
|
||||
transition-timing-function: var(--ch-ease-out);
|
||||
}
|
||||
|
||||
/*
|
||||
* 卡片抬升。**必须关在 `@media (hover: hover) and (pointer: fine)` 里**:
|
||||
* 教室机器的希沃触摸屏点一下会产生"幽灵 hover"并一直粘着,
|
||||
* 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md §5 第 30 条)。
|
||||
* 这也正是 `composables/motion.ts` 里 `useHoverCapable()` 的 CSS 版本 ——
|
||||
* 纯 CSS 效果用媒体查询就够了,不必为它挂一个 composable。
|
||||
*/
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.el-card {
|
||||
transition:
|
||||
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
|
||||
box-shadow var(--el-transition-duration) var(--ch-ease-out),
|
||||
transform var(--ch-spring-swap-dur) var(--ch-spring-swap);
|
||||
}
|
||||
|
||||
.el-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格行:hover 底色快速跟上,别用 Element Plus 的 0.3s */
|
||||
.el-table td.el-table__cell {
|
||||
transition: background-color var(--el-transition-duration-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
/*
|
||||
* 列表错峰入场。配合 `composables/motion.ts` 的 `useStaggerIn()` 使用 ——
|
||||
* 那个函数按索引下发 `--ch-reveal-delay`,延迟在 CSS 里生效。
|
||||
* `both` 是必需的:否则带延迟的元素会在延迟期间先按终态渲染一下(闪一帧)。
|
||||
*/
|
||||
@keyframes ch-reveal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ch-reveal {
|
||||
animation: ch-reveal-in var(--ch-dur-slow) var(--ch-ease-out) both;
|
||||
animation-delay: var(--ch-reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
/*
|
||||
* 页面切换**没有**容器级的过渡 —— 这里曾经有一对 `.ch-page-enter-active` /
|
||||
* `-leave-active`(配 `<transition mode="out-in">`),已经去掉,原因写在
|
||||
* ClientLayout.vue 的 router-view 注释里(延期换页 + 窗口被遮挡时卡死)。
|
||||
* 页面切换的动效全部由下面的 `.page > *` 错峰入场承担。
|
||||
*/
|
||||
|
||||
/*
|
||||
* 页面内容的错峰入场。
|
||||
*
|
||||
* 挂在 `.page > *` 上而不是逐个视图去写类名:所有视图(含六个设置子页)的根容器
|
||||
* 都叫 `.page`,一条规则覆盖全部,新增视图自动享有,也不会有人忘记加。
|
||||
*
|
||||
* 延迟按**块**递进(30ms 一档、封顶 180ms):一屏通常是"页头 + 两三块内容",
|
||||
* 这个节奏读起来是依次浮现;给更多块继续累加就变成排队了,所以第 7 块起不再递增。
|
||||
*/
|
||||
@keyframes ch-page-block-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page > * {
|
||||
animation: ch-page-block-in var(--ch-dur-slow) var(--ch-ease-out) both;
|
||||
}
|
||||
|
||||
.page > *:nth-child(2) {
|
||||
animation-delay: 30ms;
|
||||
}
|
||||
|
||||
.page > *:nth-child(3) {
|
||||
animation-delay: 60ms;
|
||||
}
|
||||
|
||||
.page > *:nth-child(4) {
|
||||
animation-delay: 90ms;
|
||||
}
|
||||
|
||||
.page > *:nth-child(5) {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
|
||||
.page > *:nth-child(6) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
.page > *:nth-child(n + 7) {
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
|
||||
/*
|
||||
* 三个例外,都是"动了就会破坏别人的测量":
|
||||
* ① `v-loading` 的遮罩是插在 `.page` 里的直接子元素,让它跟着淡入会闪一下;
|
||||
* ② **弹窗遮罩**(`.el-overlay`)。Element Plus 的 `el-dialog` 默认 `append-to-body=false`,
|
||||
* 所以声明在视图里的弹窗,它的遮罩是 `.page` 的**直接子元素**。而 `.el-overlay` 是
|
||||
* `position: fixed; inset: 0` 的全屏层,整体下移 6px 就会把"刚好铺满"的内容顶出屏幕 ——
|
||||
* 作业看板正是这样被顶出去的(冒烟断言「作业看板全屏自适应」读到可视底 550 > 视口高 543)。
|
||||
* 全屏层本来也不该参与"内容块依次浮现",它一出现就该盖住整屏。
|
||||
* ③ **看板容器**(`.board-host`):它的尺寸是按"刚好铺满屏幕"用 `transform: scale()`
|
||||
* 精确算出来的(见 HomeworkView 的 computeScale),算完就是贴边的,同样经不起位移。
|
||||
* 一句话:**凡是 `position: fixed` 的整屏层、或自己量几何再据此设尺寸的元素,
|
||||
* 都不要叠位移类动画。**
|
||||
*/
|
||||
.page > .el-loading-mask,
|
||||
.page > .el-overlay,
|
||||
.page > .board-host {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- 降低动态效果偏好
|
||||
*
|
||||
* 系统开了「减少动态效果」时,把全站的动画压到 1ms —— 不是删掉,
|
||||
* 而是让它在同一帧内完成(`transition: none` 会让依赖 transitionend 的逻辑失灵)。
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 1ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* 例外:加载指示旋的是"状态"不是"装饰"。把它一起停掉会让界面看起来像卡死了
|
||||
* (用户会去重复点击),所以这里改为**放慢**而不是停。
|
||||
*/
|
||||
.el-icon.is-loading,
|
||||
.el-loading-spinner .circular,
|
||||
.el-loading-spinner svg,
|
||||
.is-loading {
|
||||
animation-duration: 1.6s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- 整页换肤(View Transition)
|
||||
*
|
||||
* `handleThemeToggle`(ClientLayout.vue)用 View Transition API 做"从点击位置扩散的圆"。
|
||||
* 浏览器默认会给新旧两帧做一次交叉淡入 —— 这里把它关掉,改由 JS 用 WAAPI 驱动
|
||||
* `::view-transition-new(root)` 的 clip-path。不关掉的话两套动画会叠在一起,
|
||||
* 观感是"圆在扩散的同时整页又在闪"。
|
||||
*
|
||||
* 层级:老帧在下(z-index 1)、新帧在上(z-index 2),新帧的圆从无到有地盖住老帧。
|
||||
* 不支持这个 API 的浏览器不会渲染这些伪元素,规则自然失效(JS 侧也有守卫)。
|
||||
*/
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
averageOf,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from '@classhelper/shared';
|
||||
import { gradeApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import ScoreBarChart from '../components/ScoreBarChart.vue';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
@@ -24,8 +24,6 @@ const loading = ref(false);
|
||||
const grades = ref<GradeDto[]>([]);
|
||||
const fromCache = ref(false);
|
||||
const updatedAt = ref<number | null>(null);
|
||||
const chartRef = ref<HTMLDivElement>();
|
||||
let chart: ReturnType<typeof echarts.init> | null = null;
|
||||
|
||||
const averagePercent = computed(() =>
|
||||
grades.value.length === 0
|
||||
@@ -53,6 +51,21 @@ const levelCounts = computed(() => {
|
||||
return levels;
|
||||
});
|
||||
|
||||
/**
|
||||
* 按考试聚合平均得分率(最近 8 次考试),交给 ScoreBarChart 渲染。
|
||||
* 这里原先调的是 `echarts.setOption(...)`;改用纯 CSS 柱状图是为了去掉
|
||||
* echarts(渲染产物里最大的一块,1.1MB)。聚合口径与之前完全一致。
|
||||
*/
|
||||
const examAverages = computed(() => {
|
||||
const byExam = new Map<string, number[]>();
|
||||
for (const item of grades.value) {
|
||||
const bucket = byExam.get(item.examName) ?? [];
|
||||
bucket.push(gradePercent(item.score, item.totalScore));
|
||||
byExam.set(item.examName, bucket);
|
||||
}
|
||||
return [...byExam.entries()].slice(-8).map(([label, percents]) => ({ label, value: averageOf(percents) }));
|
||||
});
|
||||
|
||||
async function loadGrades(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -61,50 +74,11 @@ async function loadGrades(): Promise<void> {
|
||||
fromCache.value = result.fromCache;
|
||||
updatedAt.value = result.updatedAt;
|
||||
if (!result.fromCache) appStore.markSynced();
|
||||
renderChart();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(): void {
|
||||
if (!chartRef.value) return;
|
||||
chart ??= echarts.init(chartRef.value);
|
||||
|
||||
// 按考试聚合平均得分率(最近 8 次考试)
|
||||
const byExam = new Map<string, number[]>();
|
||||
for (const item of grades.value) {
|
||||
const bucket = byExam.get(item.examName) ?? [];
|
||||
bucket.push(gradePercent(item.score, item.totalScore));
|
||||
byExam.set(item.examName, bucket);
|
||||
}
|
||||
const exams = [...byExam.entries()].slice(-8);
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 44, right: 18, top: 30, bottom: 34 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: exams.map(([name]) => name),
|
||||
axisLabel: { interval: 0, rotate: exams.length > 4 ? 20 : 0, fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value', max: 100, name: '得分率%' },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barMaxWidth: 46,
|
||||
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
|
||||
label: { show: true, position: 'top', formatter: '{c}%', fontSize: 11 },
|
||||
data: exams.map(([, percents]) => averageOf(percents)),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleResize(): void {
|
||||
chart?.resize();
|
||||
}
|
||||
|
||||
function onGradeEvent(): void {
|
||||
void loadGrades();
|
||||
}
|
||||
@@ -115,15 +89,11 @@ function onRecovered(): void {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadGrades();
|
||||
window.addEventListener('resize', handleResize);
|
||||
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||
appStore.onServerRecovered(onRecovered);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||
appStore.offServerRecovered(onRecovered);
|
||||
});
|
||||
@@ -172,7 +142,7 @@ onUnmounted(() => {
|
||||
<el-col :xs="24" :md="16">
|
||||
<el-card v-loading="loading" shadow="never">
|
||||
<template #header><span>各次考试平均得分率</span></template>
|
||||
<div ref="chartRef" class="chart"></div>
|
||||
<ScoreBarChart :items="examAverages" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -226,7 +196,7 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
background: var(--ch-layer-solid);
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
@@ -234,7 +204,7 @@ onUnmounted(() => {
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@@ -246,7 +216,7 @@ onUnmounted(() => {
|
||||
.stat-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.level-list {
|
||||
@@ -255,9 +225,4 @@ onUnmounted(() => {
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
@@ -916,7 +916,7 @@ onUnmounted(() => {
|
||||
.content-block {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.75;
|
||||
background: #fafafa;
|
||||
background: var(--ch-layer-alt);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
@@ -1148,7 +1148,7 @@ onUnmounted(() => {
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
color: var(--ch-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1176,7 +1176,7 @@ onUnmounted(() => {
|
||||
.submission-list {
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ebeef5;
|
||||
border: 1px solid var(--ch-divider);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useOnboardingStore } from '../stores/onboarding.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||||
import { DEFAULT_SERVER, BRAND_LOGO_URL, normalizeServerUrl } from '../config.js';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
@@ -91,7 +93,7 @@ async function enterOffline(): Promise<void> {
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-head">
|
||||
<el-icon :size="30" color="#409eff"><School /></el-icon>
|
||||
<img class="login-logo" :src="BRAND_LOGO_URL" alt="" width="44" height="44" />
|
||||
<div>
|
||||
<h1 class="login-title">班级小助手</h1>
|
||||
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
|
||||
@@ -134,6 +136,14 @@ async function enterOffline(): Promise<void> {
|
||||
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 重看初次启动引导:首次启动会自动弹出过一次,这里供随时回看 -->
|
||||
<div class="login-guide">
|
||||
<el-button link type="primary" @click="onboarding.open()">
|
||||
<el-icon><Guide /></el-icon>
|
||||
查看使用引导
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="testResult === 'ok'"
|
||||
class="mt-12"
|
||||
@@ -177,7 +187,7 @@ async function enterOffline(): Promise<void> {
|
||||
|
||||
.login-card {
|
||||
width: 420px;
|
||||
background: #fff;
|
||||
background: var(--ch-layer-solid);
|
||||
border-radius: 14px;
|
||||
padding: 28px 26px 20px;
|
||||
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
|
||||
@@ -190,6 +200,16 @@ async function enterOffline(): Promise<void> {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 品牌标:与应用图标同一份图(`public/logo.png`)。
|
||||
* 图自带圆角与透明边角,不要再加 border-radius;`src` 走 `BRAND_LOGO_URL` 的理由同 ClientLayout。
|
||||
* (表单里「班级码」输入框的前缀仍用 School 字形 —— 那是字段语义图标,不是品牌标。)
|
||||
*/
|
||||
.login-logo {
|
||||
flex: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
@@ -198,7 +218,7 @@ async function enterOffline(): Promise<void> {
|
||||
.login-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
@@ -210,6 +230,12 @@ async function enterOffline(): Promise<void> {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-guide {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.offline-button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -218,6 +244,6 @@ async function enterOffline(): Promise<void> {
|
||||
margin: 16px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #c0c4cc;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -165,11 +165,11 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.notice-item:hover {
|
||||
background: #f7f9fc;
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
.notice-item.unread {
|
||||
background: #f0f7ff;
|
||||
background: var(--ch-accent-soft);
|
||||
}
|
||||
|
||||
.notice-item:last-child {
|
||||
@@ -194,7 +194,7 @@ onUnmounted(() => {
|
||||
.notice-content {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
color: var(--ch-text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -220,7 +220,7 @@ onUnmounted(() => {
|
||||
.content-block {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
background: #fafafa;
|
||||
background: var(--ch-layer-alt);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin: 0 0 16px;
|
||||
|
||||
@@ -460,7 +460,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.hero-status.status-idle {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
.status-title {
|
||||
@@ -553,7 +553,7 @@ onUnmounted(() => {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--ch-layer-solid);
|
||||
border: 2px solid var(--ch-border-strong);
|
||||
}
|
||||
|
||||
@@ -574,7 +574,8 @@ onUnmounted(() => {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: var(--ch-radius-card);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
/* 走主题变量:浅色是白玻璃、深色是深玻璃(写死白色会在深色主题下变成"浅灰卡 + 浅字"看不清) */
|
||||
background: var(--ch-layer);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
@@ -595,7 +596,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.timeline-item.is-past .timeline-card-inner {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: var(--ch-layer-alt);
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,708 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
HOMEWORK_PHRASE_MAX_COUNT,
|
||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
ISLAND_POSITION_LABELS,
|
||||
ISLAND_STYLE_LABELS,
|
||||
ISLAND_STYLES,
|
||||
ISLAND_POSITIONS,
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
type IslandAppearance,
|
||||
type IslandPosition,
|
||||
type IslandStyle,
|
||||
} from '@classhelper/shared';
|
||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||
import { classIslandStatusApi } from '../api/index.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { normalizeServerUrl } from '../config.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useNotificationStore } from '../stores/notifications.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
const serverInput = ref(appStore.serverUrl);
|
||||
const testing = ref(false);
|
||||
const saving = ref(false);
|
||||
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||
|
||||
const storeLabels: Record<string, string> = {
|
||||
profile: '登录资料',
|
||||
classes: '班级 / 学期',
|
||||
schedules: '课表',
|
||||
homeworks: '作业',
|
||||
notifications: '通知',
|
||||
grades: '成绩',
|
||||
};
|
||||
|
||||
const cacheRows = computed(() =>
|
||||
appStore.cacheStoreStats.map((item) => ({
|
||||
label: storeLabels[item.store] ?? item.store,
|
||||
store: item.store,
|
||||
count: item.count,
|
||||
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
|
||||
})),
|
||||
);
|
||||
|
||||
async function loadAppInfo(): Promise<void> {
|
||||
if (!window.desktop) return;
|
||||
appInfo.value = await window.desktop.getAppInfo();
|
||||
}
|
||||
|
||||
async function saveServer(): Promise<void> {
|
||||
const normalized = normalizeServerUrl(serverInput.value);
|
||||
if (!normalized) {
|
||||
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await appStore.applyServerUrl(normalized);
|
||||
serverInput.value = normalized;
|
||||
ElMessage.success('服务器地址已保存');
|
||||
await testConnection();
|
||||
if (auth.token) realtime.connect(normalized, auth.token);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = await pingHealth();
|
||||
if (result.ok) ElMessage.success('服务器连接正常');
|
||||
else ElMessage.error('无法连接服务器,请检查地址与网络');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualSync(): Promise<void> {
|
||||
setApiBaseUrl(appStore.serverUrl);
|
||||
await appStore.ping();
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
|
||||
return;
|
||||
}
|
||||
await notifications.load();
|
||||
appStore.markSynced();
|
||||
ElMessage.success('已同步最新数据');
|
||||
}
|
||||
|
||||
async function clearCache(): Promise<void> {
|
||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||
type: 'warning',
|
||||
});
|
||||
await appStore.clearCache();
|
||||
ElMessage.success('离线缓存已清空');
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||
realtime.disconnect();
|
||||
await auth.logout();
|
||||
ElMessage.success('已退出登录');
|
||||
await router.replace('/login');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 通知显示位置 */
|
||||
|
||||
/**
|
||||
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
||||
*
|
||||
* - both:ClassHelper 客户端(弹窗 + 灵动岛)与 ClassIsland 都弹(默认)
|
||||
* - client:只弹 ClassHelper 客户端
|
||||
* - classisland:只弹 ClassIsland(客户端只进通知中心,不弹窗、不上岛)
|
||||
*
|
||||
* 值会落本地配置(本机立刻生效)并写回班级记录(服务端据此决定要不要推 ClassIsland)。
|
||||
*/
|
||||
const channelSaving = ref(false);
|
||||
/**
|
||||
* 本班有没有接入 ClassIsland 设备。
|
||||
*
|
||||
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
|
||||
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||
*/
|
||||
const classIslandConnected = ref(false);
|
||||
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
|
||||
const classIslandStatus = ref<{
|
||||
connected: boolean;
|
||||
deviceName: string | null;
|
||||
deviceCount: number;
|
||||
lastSeenAt: string | null;
|
||||
pluginVersion: string | null;
|
||||
classIslandVersion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const classIslandStatusText = computed(() => {
|
||||
const status = classIslandStatus.value;
|
||||
if (!status || !status.connected) return '未接入';
|
||||
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
|
||||
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
|
||||
});
|
||||
|
||||
const channelOptions = computed(() =>
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
||||
value,
|
||||
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
|
||||
disabled: value === 'classisland' && !classIslandConnected.value,
|
||||
})),
|
||||
);
|
||||
|
||||
async function loadClassChannel(): Promise<void> {
|
||||
const classId = auth.classId;
|
||||
if (!classId) return;
|
||||
await appStore.loadNotificationChannel(classId);
|
||||
}
|
||||
|
||||
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
|
||||
channelSaving.value = true;
|
||||
try {
|
||||
const saved = await appStore.setNotificationChannel(auth.classId, value);
|
||||
if (saved) ElMessage.success('提醒显示位置已更新');
|
||||
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
|
||||
} finally {
|
||||
channelSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 作业录入快捷短语 */
|
||||
|
||||
/**
|
||||
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
|
||||
*
|
||||
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
|
||||
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
|
||||
*/
|
||||
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
|
||||
const phraseInput = ref('');
|
||||
const savingPhrases = ref(false);
|
||||
|
||||
async function loadPhrases(): Promise<void> {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
|
||||
}
|
||||
|
||||
async function savePhrases(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingPhrases.value = true;
|
||||
try {
|
||||
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
|
||||
phrases.value = [...saved.homeworkPhrases];
|
||||
} finally {
|
||||
savingPhrases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addPhrase(): Promise<void> {
|
||||
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
|
||||
if (!value) return;
|
||||
if (phrases.value.includes(value)) {
|
||||
ElMessage.warning('这条短语已经有了');
|
||||
return;
|
||||
}
|
||||
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
|
||||
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
|
||||
return;
|
||||
}
|
||||
phrases.value = [...phrases.value, value];
|
||||
phraseInput.value = '';
|
||||
await savePhrases();
|
||||
ElMessage.success('已添加');
|
||||
}
|
||||
|
||||
async function removePhrase(value: string): Promise<void> {
|
||||
phrases.value = phrases.value.filter((item) => item !== value);
|
||||
await savePhrases();
|
||||
}
|
||||
|
||||
async function resetPhrases(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
|
||||
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
|
||||
await savePhrases();
|
||||
ElMessage.success('已恢复默认');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 灵动岛个性化 */
|
||||
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
|
||||
/**
|
||||
* 左右边距是否有效:只在停靠左 / 右时生效。
|
||||
*
|
||||
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
|
||||
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
|
||||
*/
|
||||
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
|
||||
const savingIsland = ref(false);
|
||||
|
||||
const positionOptions = ISLAND_POSITIONS.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_POSITION_LABELS[value],
|
||||
}));
|
||||
|
||||
const styleOptions = ISLAND_STYLES.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_STYLE_LABELS[value],
|
||||
}));
|
||||
|
||||
/** 读取当前生效的外观(主进程为单一事实来源) */
|
||||
async function loadIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.islandGetAppearance) return;
|
||||
island.value = await window.desktop.islandGetAppearance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
|
||||
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
|
||||
*
|
||||
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
|
||||
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
|
||||
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
|
||||
*/
|
||||
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
|
||||
island.value = { ...island.value, ...patch };
|
||||
window.desktop?.islandSetAppearance({ ...island.value });
|
||||
}
|
||||
|
||||
async function saveIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingIsland.value = true;
|
||||
try {
|
||||
await window.desktop.saveConfig({ island: { ...island.value } });
|
||||
ElMessage.success('个性化设置已保存');
|
||||
} finally {
|
||||
savingIsland.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetIslandAppearance(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
|
||||
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
await saveIslandAppearance();
|
||||
}
|
||||
|
||||
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
|
||||
function testIsland(): void {
|
||||
window.desktop?.islandPush?.({
|
||||
notification: {
|
||||
id: `appearance-test-${Date.now()}`,
|
||||
title: '灵动岛外观预览',
|
||||
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
|
||||
priority: 'NORMAL',
|
||||
createdAt: new Date().toISOString(),
|
||||
courseName: null,
|
||||
teacherName: '本地预览',
|
||||
},
|
||||
context: { inClass: false, currentPeriodEnd: null, preview: true },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAppInfo();
|
||||
await appStore.refreshCacheStats();
|
||||
await loadIslandAppearance();
|
||||
await loadClassChannel();
|
||||
await loadPhrases();
|
||||
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
||||
if (auth.classId) {
|
||||
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
||||
classIslandStatus.value = status;
|
||||
classIslandConnected.value = status?.connected === true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">设置</h2>
|
||||
<p class="page-subtitle">服务器连接、账号信息与离线缓存管理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>服务器连接</span></template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="服务器地址">
|
||||
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
|
||||
<template #prefix>
|
||||
<el-icon><Link /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
|
||||
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
|
||||
<el-button @click="manualSync">立即同步</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions class="mt-16" :column="1" border size="small">
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
|
||||
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||
</el-tag>
|
||||
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
|
||||
{{ realtime.statusText }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header>
|
||||
<span>灵动岛 · 个性化</span>
|
||||
</template>
|
||||
<el-form label-width="96px" label-position="left">
|
||||
<el-form-item :label="`高度 ${island.height}px`">
|
||||
<el-slider
|
||||
:model-value="island.height"
|
||||
:min="RANGES.height.min"
|
||||
:max="RANGES.height.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ height: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`宽度 ${island.width}px`">
|
||||
<el-slider
|
||||
:model-value="island.width"
|
||||
:min="RANGES.width.min"
|
||||
:max="RANGES.width.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ width: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`圆角 ${island.radius}px`">
|
||||
<el-slider
|
||||
:model-value="island.radius"
|
||||
:min="RANGES.radius.min"
|
||||
:max="RANGES.radius.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ radius: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
|
||||
<el-slider
|
||||
:model-value="island.opacity"
|
||||
:min="RANGES.opacity.min"
|
||||
:max="RANGES.opacity.max"
|
||||
:step="0.05"
|
||||
@input="(value: number) => applyIslandAppearance({ opacity: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`字号 ${island.fontSize}px`">
|
||||
<el-slider
|
||||
:model-value="island.fontSize"
|
||||
:min="RANGES.fontSize.min"
|
||||
:max="RANGES.fontSize.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
|
||||
<el-slider
|
||||
:model-value="island.speed"
|
||||
:min="RANGES.speed.min"
|
||||
:max="RANGES.speed.max"
|
||||
:step="0.1"
|
||||
@input="(value: number) => applyIslandAppearance({ speed: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="主题色">
|
||||
<el-color-picker
|
||||
:model-value="island.accent"
|
||||
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
|
||||
/>
|
||||
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="视觉风格">
|
||||
<el-select
|
||||
:model-value="island.style"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in styleOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示位置">
|
||||
<el-select
|
||||
:model-value="island.position"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in positionOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`左右边距 ${island.marginX}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginX"
|
||||
:min="RANGES.marginX.min"
|
||||
:max="RANGES.marginX.max"
|
||||
:step="2"
|
||||
:disabled="!marginXApplies"
|
||||
@input="(value: number) => applyIslandAppearance({ marginX: value })"
|
||||
/>
|
||||
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
|
||||
<span v-else class="text-muted ml-8">
|
||||
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`上下边距 ${island.marginY}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginY"
|
||||
:min="RANGES.marginY.min"
|
||||
:max="RANGES.marginY.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ marginY: value })"
|
||||
/>
|
||||
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟随鼠标屏幕">
|
||||
<el-switch
|
||||
:model-value="island.followCursorDisplay"
|
||||
@change="
|
||||
(value: boolean | string | number) =>
|
||||
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="动画">
|
||||
<el-switch
|
||||
:model-value="island.animations"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="始终置顶">
|
||||
<el-switch
|
||||
:model-value="island.alwaysOnTop"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="空闲细缝">
|
||||
<el-switch
|
||||
:model-value="island.idleSliver"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
|
||||
保存设置
|
||||
</el-button>
|
||||
<el-button @click="testIsland">预览效果</el-button>
|
||||
<el-button @click="resetIslandAppearance">恢复默认</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>ClassIsland 联动</span></template>
|
||||
<el-descriptions :column="1" border size="small" class="mb-12">
|
||||
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="插件 / ClassIsland">
|
||||
{{ classIslandStatus?.pluginVersion ?? '—' }} /
|
||||
{{ classIslandStatus?.classIslandVersion ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-muted">
|
||||
<strong>通知模式</strong>
|
||||
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
|
||||
</p>
|
||||
<el-radio-group
|
||||
:model-value="appStore.notificationChannel"
|
||||
:disabled="channelSaving"
|
||||
class="channel-group"
|
||||
@change="
|
||||
(value: string | number | boolean | undefined) =>
|
||||
applyChannel(value as ClassIslandNotificationChannel)
|
||||
"
|
||||
>
|
||||
<el-radio
|
||||
v-for="item in channelOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="text-muted channel-hint">
|
||||
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
|
||||
<template v-if="!classIslandConnected">
|
||||
<br />
|
||||
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
|
||||
ClassIsland 上弹")。
|
||||
</template>
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>作业录入</span></template>
|
||||
<p class="text-muted">
|
||||
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
|
||||
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
|
||||
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
|
||||
</p>
|
||||
<div class="phrase-row">
|
||||
<el-tag
|
||||
v-for="item in phrases"
|
||||
:key="item"
|
||||
closable
|
||||
type="info"
|
||||
effect="plain"
|
||||
@close="removePhrase(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
|
||||
</div>
|
||||
<div class="toolbar mt-12">
|
||||
<el-input
|
||||
v-model="phraseInput"
|
||||
placeholder="新增短语,如 大本"
|
||||
style="width: 200px"
|
||||
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
|
||||
@keyup.enter="addPhrase"
|
||||
/>
|
||||
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
|
||||
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>账号信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
|
||||
{{ auth.user?.username ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录方式">
|
||||
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-16">
|
||||
<el-button type="danger" plain @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
<div class="text-muted mt-12">
|
||||
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
|
||||
班级密码重新登录。
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>离线缓存(IndexedDB)</span></template>
|
||||
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
|
||||
<el-table-column prop="label" label="数据类型" width="130" />
|
||||
<el-table-column label="条目数" width="90">
|
||||
<template #default="{ row }">{{ row.count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近更新" min-width="150">
|
||||
<template #default="{ row }">{{ row.updatedAt }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="离线可用"
|
||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>客户端信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Electron">{{ appInfo?.electron ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Chromium">{{ appInfo?.chrome ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Node.js">{{ appInfo?.node ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.channel-hint {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.phrase-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { UpdateInfo } from '@classhelper/shared';
|
||||
import type { DesktopAppInfo } from '../../../types/desktop.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
|
||||
/**
|
||||
* 关于(ClassIsland 同款折叠卡片式):
|
||||
* 应用信息(版本/运行时/常用链接/检查更新)→ 诊断信息(排查问题用)→ 鸣谢。
|
||||
* 所有外链走主进程 openExternal(只放行 http/https,见 main/ipc.ts 的白名单)。
|
||||
*/
|
||||
const appStore = useAppStore();
|
||||
|
||||
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||
/** 折叠面板默认展开第一项(应用信息),诊断信息默认收起 */
|
||||
const expandedNames = ref(['app']);
|
||||
|
||||
/** 检查更新:结果由主进程直连 GitHub 得到(见 main/update.ts) */
|
||||
const checking = ref(false);
|
||||
const updateInfo = ref<UpdateInfo | null>(null);
|
||||
|
||||
const PROJECT_URL = 'https://github.com/LoveChengke/ClassHelper';
|
||||
const ISSUES_URL = 'https://github.com/LoveChengke/ClassHelper/issues';
|
||||
|
||||
const collapseItems = [
|
||||
{ name: 'app', title: '应用信息', icon: 'School' },
|
||||
{ name: 'diagnostics', title: '查看诊断信息', icon: 'Warning' },
|
||||
{ name: 'thanks', title: '鸣谢', icon: 'Pointer' },
|
||||
];
|
||||
|
||||
async function openExternal(url: string): Promise<void> {
|
||||
if (window.desktop?.openExternal) {
|
||||
const handled = await window.desktop.openExternal(url);
|
||||
if (handled) return;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查更新。
|
||||
*
|
||||
* @param force true = 用户主动点击(绕过主进程缓存);false = 进页面时带出启动自动检查的结果
|
||||
*/
|
||||
async function checkUpdate(force: boolean): Promise<void> {
|
||||
if (!window.desktop?.checkForUpdates) return;
|
||||
checking.value = true;
|
||||
try {
|
||||
updateInfo.value = await window.desktop.checkForUpdates(force);
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 忽略这个版本:写进配置,启动自动检查不再提示它 */
|
||||
async function ignoreVersion(): Promise<void> {
|
||||
const version = updateInfo.value?.latestVersion;
|
||||
if (!version) return;
|
||||
await window.desktop?.ignoreUpdateVersion(version);
|
||||
ElMessage.success(`已忽略 v${version} 的更新提示`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!window.desktop) return;
|
||||
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
|
||||
// 带出启动自动检查的结果(命中主进程缓存,不会再发一次请求)
|
||||
void checkUpdate(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">关于</h2>
|
||||
<p class="page-subtitle">版本、诊断信息与鸣谢</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="expandedNames" class="about-collapse">
|
||||
<!-- 应用信息 -->
|
||||
<el-collapse-item name="app">
|
||||
<template #title>
|
||||
<span class="about-title">
|
||||
<el-icon><School /></el-icon>
|
||||
{{ collapseItems[0].title }}
|
||||
</span>
|
||||
<span class="about-extra">班级小助手 v{{ appInfo?.appVersion ?? '-' }}</span>
|
||||
</template>
|
||||
<div class="about-section">
|
||||
<p class="about-copy">
|
||||
Copyright © 2025-2026 班级小助手
|
||||
<br />
|
||||
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
|
||||
</p>
|
||||
<div class="about-links">
|
||||
<span class="about-links-label">常用链接</span>
|
||||
<el-button link type="primary" @click="openExternal(PROJECT_URL)">项目主页</el-button>
|
||||
<el-button link type="primary" @click="openExternal(ISSUES_URL)">问题反馈</el-button>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small" class="mt-12">
|
||||
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运行时">
|
||||
Electron {{ appInfo?.electron ?? '-' }} · Chromium {{ appInfo?.chrome ?? '-' }} · Node
|
||||
{{ appInfo?.node ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 检查更新:直连 GitHub 看最新的 Release 是不是比本机新 -->
|
||||
<div class="about-update" data-test="update-check">
|
||||
<div class="about-update-head">
|
||||
<span class="about-update-label">检查更新</span>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="checking"
|
||||
data-test="update-check-button"
|
||||
@click="checkUpdate(true)"
|
||||
>
|
||||
检查更新
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="about-update-body">
|
||||
<template v-if="checking">正在检查…</template>
|
||||
<template v-else-if="!updateInfo">尚未检查</template>
|
||||
<template v-else-if="!updateInfo.ok">
|
||||
<!-- 教室机器没有外网是常态,这里只是"查不到",不是错误 -->
|
||||
<span class="about-update-muted">{{ updateInfo.error ?? '暂时无法检查更新' }}</span>
|
||||
</template>
|
||||
<template v-else-if="updateInfo.hasUpdate">
|
||||
<el-tag size="small" type="success" effect="light">
|
||||
发现新版本 v{{ updateInfo.latestVersion }}
|
||||
</el-tag>
|
||||
<span class="about-update-muted">当前 v{{ updateInfo.currentVersion }}</span>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
data-test="update-download"
|
||||
@click="openExternal(updateInfo.releaseUrl)"
|
||||
>
|
||||
前往下载
|
||||
</el-button>
|
||||
<el-button link size="small" @click="ignoreVersion">忽略此版本</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
|
||||
<span class="about-update-muted">
|
||||
v{{ updateInfo.currentVersion }} · {{ updateInfo.latestVersion }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 诊断信息 -->
|
||||
<el-collapse-item name="diagnostics">
|
||||
<template #title>
|
||||
<span class="about-title">
|
||||
<el-icon><Warning /></el-icon>
|
||||
{{ collapseItems[1].title }}
|
||||
</span>
|
||||
<span class="about-extra">报障时请附带本页信息</span>
|
||||
</template>
|
||||
<div class="about-section">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="服务器地址">{{ appStore.serverUrl }}</el-descriptions-item>
|
||||
<el-descriptions-item label="连接状态">
|
||||
<el-tag size="small" :type="appStore.serverReachable ? 'success' : 'danger'">
|
||||
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="缓存条目">{{ appStore.cacheItemCount }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="配置文件">{{ appInfo?.configPath ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 鸣谢 -->
|
||||
<el-collapse-item name="thanks">
|
||||
<template #title>
|
||||
<span class="about-title">
|
||||
<el-icon><Pointer /></el-icon>
|
||||
{{ collapseItems[2].title }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="about-section">
|
||||
<ul class="about-thanks">
|
||||
<li>
|
||||
<strong>ClassIsland</strong>
|
||||
—— 界面风格、设置页与「今天」时间轴的交互参考
|
||||
</li>
|
||||
<li>
|
||||
<strong>WinIsland</strong>
|
||||
—— 灵动岛的窗口形态与弹簧动画参考
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vue / Element Plus / Prisma / Express / Socket.IO</strong>
|
||||
—— 本项目赖以构建的开源基石
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<p class="about-quote">谨以此应用,献给朝夕相伴的班级与同窗;愿时光流转,情谊常在。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.about-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.about-extra {
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.about-section {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.about-copy {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.8;
|
||||
color: var(--ch-text-secondary);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.about-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.about-links-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--ch-text-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 检查更新:颜色一律走 --ch-* 变量(深色主题下不能写死浅色底/深色字) */
|
||||
.about-update {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: var(--ch-radius-card);
|
||||
background: var(--ch-layer-alt);
|
||||
}
|
||||
|
||||
.about-update-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.about-update-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.about-update-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
.about-update-muted {
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
|
||||
.about-thanks {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
line-height: 2;
|
||||
font-size: 12.5px;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.about-quote {
|
||||
margin: 18px 0 4px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||
|
||||
/**
|
||||
* 账号设置:本机登录的班级账号信息 + 退出登录。
|
||||
* 班级密码由管理员在 Web 管理端维护(本机按用户要求不提供改密入口)。
|
||||
*/
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||
realtime.disconnect();
|
||||
await auth.logout();
|
||||
ElMessage.success('已退出登录');
|
||||
await router.replace('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">账号</h2>
|
||||
<p class="page-subtitle">本机登录的班级账号</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>账号信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
|
||||
{{ auth.user?.username ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录方式">
|
||||
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-16">
|
||||
<el-button type="danger" plain @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
<div class="text-muted mt-12">
|
||||
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
|
||||
班级密码重新登录。
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useUiStore } from '../../stores/ui.js';
|
||||
|
||||
/**
|
||||
* 外观:黑夜 / 白天模式。
|
||||
* 状态与持久化都在 stores/ui.ts(主进程配置 theme 字段),顶栏右上角的
|
||||
* 日月按钮与本页写同一份配置,切换立即生效(html.dark + Element Plus dark css-vars)。
|
||||
*/
|
||||
const ui = useUiStore();
|
||||
|
||||
const options = [
|
||||
{ value: 'light', label: '浅色', desc: '明亮界面,适合白天与投影环境' },
|
||||
{ value: 'dark', label: '深色', desc: '黑夜模式,适合晚自习与暗教室' },
|
||||
] as const;
|
||||
|
||||
async function selectTheme(value: 'light' | 'dark'): Promise<void> {
|
||||
if (value === ui.theme) return;
|
||||
await ui.setTheme(value);
|
||||
ElMessage.success(value === 'dark' ? '已切换到深色模式' : '已切换到浅色模式');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">外观</h2>
|
||||
<p class="page-subtitle">黑夜与白天模式,切换立即生效并自动保存</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>界面主题</span></template>
|
||||
<div class="theme-options">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="theme-option"
|
||||
:class="[`theme-${option.value}`, { active: ui.theme === option.value }]"
|
||||
:data-theme-option="option.value"
|
||||
@click="selectTheme(option.value)"
|
||||
>
|
||||
<span class="theme-preview">
|
||||
<span class="preview-side" />
|
||||
<span class="preview-body">
|
||||
<span class="preview-line" />
|
||||
<span class="preview-line short" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="theme-name">
|
||||
<el-icon><Sunny v-if="option.value === 'light'" /><Moon v-else /></el-icon>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<span class="theme-desc">{{ option.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="顶栏右上角也有主题快捷切换按钮"
|
||||
description="灵动岛始终为深色浮窗,不随主题变化;登录页与全屏作业看板为固定配色,亦不受影响。"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ch-border-strong);
|
||||
border-radius: var(--ch-radius-card);
|
||||
background: var(--ch-layer);
|
||||
cursor: pointer;
|
||||
color: var(--ch-text);
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
border-color: var(--ch-accent);
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
border-color: var(--ch-accent);
|
||||
box-shadow: 0 0 0 1px var(--ch-accent) inset;
|
||||
}
|
||||
|
||||
/* 主题缩略预览:两块小色板直接表现各自配色 */
|
||||
.theme-preview {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ch-border-strong);
|
||||
}
|
||||
|
||||
.theme-light .theme-preview {
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
.theme-dark .theme-preview {
|
||||
background: #161719;
|
||||
}
|
||||
|
||||
.preview-side {
|
||||
width: 28px;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.theme-dark .preview-side {
|
||||
background: rgba(32, 33, 36, 0.9);
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-line {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
width: 80%;
|
||||
background: rgba(15, 108, 189, 0.55);
|
||||
}
|
||||
|
||||
.preview-line.short {
|
||||
width: 50%;
|
||||
background: rgba(128, 128, 128, 0.35);
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ch-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
HOMEWORK_PHRASE_MAX_COUNT,
|
||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||
formatDate,
|
||||
} from '@classhelper/shared';
|
||||
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
|
||||
import { normalizeServerUrl } from '../../config.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
import { useNotificationStore } from '../../stores/notifications.js';
|
||||
import { useOnboardingStore } from '../../stores/onboarding.js';
|
||||
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||
|
||||
/**
|
||||
* 通用设置:服务器连接、离线缓存与作业录入短语。
|
||||
* (外观 / 灵动岛 / 提醒 / 账号 / 关于 各自是独立子页,见侧边栏「设置」分组。)
|
||||
*/
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
const serverInput = ref(appStore.serverUrl);
|
||||
const testing = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const storeLabels: Record<string, string> = {
|
||||
profile: '登录资料',
|
||||
classes: '班级 / 学期',
|
||||
schedules: '课表',
|
||||
homeworks: '作业',
|
||||
notifications: '通知',
|
||||
grades: '成绩',
|
||||
};
|
||||
|
||||
const cacheRows = computed(() =>
|
||||
appStore.cacheStoreStats.map((item) => ({
|
||||
label: storeLabels[item.store] ?? item.store,
|
||||
store: item.store,
|
||||
count: item.count,
|
||||
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
|
||||
})),
|
||||
);
|
||||
|
||||
async function saveServer(): Promise<void> {
|
||||
const normalized = normalizeServerUrl(serverInput.value);
|
||||
if (!normalized) {
|
||||
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await appStore.applyServerUrl(normalized);
|
||||
serverInput.value = normalized;
|
||||
ElMessage.success('服务器地址已保存');
|
||||
await testConnection();
|
||||
if (auth.token) realtime.connect(normalized, auth.token);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = await pingHealth();
|
||||
if (result.ok) ElMessage.success('服务器连接正常');
|
||||
else ElMessage.error('无法连接服务器,请检查地址与网络');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualSync(): Promise<void> {
|
||||
setApiBaseUrl(appStore.serverUrl);
|
||||
await appStore.ping();
|
||||
if (appStore.offline) {
|
||||
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
|
||||
return;
|
||||
}
|
||||
await notifications.load();
|
||||
appStore.markSynced();
|
||||
ElMessage.success('已同步最新数据');
|
||||
}
|
||||
|
||||
async function clearCache(): Promise<void> {
|
||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||
type: 'warning',
|
||||
});
|
||||
await appStore.clearCache();
|
||||
ElMessage.success('离线缓存已清空');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 作业录入快捷短语 */
|
||||
|
||||
/**
|
||||
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
|
||||
*
|
||||
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
|
||||
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
|
||||
*/
|
||||
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
|
||||
const phraseInput = ref('');
|
||||
const savingPhrases = ref(false);
|
||||
|
||||
async function loadPhrases(): Promise<void> {
|
||||
const config = await window.desktop?.getConfig?.();
|
||||
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
|
||||
}
|
||||
|
||||
async function savePhrases(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingPhrases.value = true;
|
||||
try {
|
||||
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
|
||||
phrases.value = [...saved.homeworkPhrases];
|
||||
} finally {
|
||||
savingPhrases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addPhrase(): Promise<void> {
|
||||
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
|
||||
if (!value) return;
|
||||
if (phrases.value.includes(value)) {
|
||||
ElMessage.warning('这条短语已经有了');
|
||||
return;
|
||||
}
|
||||
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
|
||||
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
|
||||
return;
|
||||
}
|
||||
phrases.value = [...phrases.value, value];
|
||||
phraseInput.value = '';
|
||||
await savePhrases();
|
||||
ElMessage.success('已添加');
|
||||
}
|
||||
|
||||
async function removePhrase(value: string): Promise<void> {
|
||||
phrases.value = phrases.value.filter((item) => item !== value);
|
||||
await savePhrases();
|
||||
}
|
||||
|
||||
async function resetPhrases(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
|
||||
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
|
||||
await savePhrases();
|
||||
ElMessage.success('已恢复默认');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.refreshCacheStats();
|
||||
await loadPhrases();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">通用</h2>
|
||||
<p class="page-subtitle">服务器连接、离线缓存与作业录入</p>
|
||||
</div>
|
||||
<!-- 重看初次启动引导(首启时由 App.vue 自动弹出过一次) -->
|
||||
<el-button @click="onboarding.open()">
|
||||
<el-icon><Guide /></el-icon>
|
||||
使用引导
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>服务器连接</span></template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="服务器地址">
|
||||
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
|
||||
<template #prefix>
|
||||
<el-icon><Link /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
|
||||
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
|
||||
<el-button @click="manualSync">立即同步</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions class="mt-16" :column="1" border size="small">
|
||||
<el-descriptions-item label="当前状态">
|
||||
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
|
||||
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
|
||||
</el-tag>
|
||||
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
|
||||
{{ realtime.statusText }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>作业录入</span></template>
|
||||
<p class="text-muted">
|
||||
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
|
||||
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
|
||||
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
|
||||
</p>
|
||||
<div class="phrase-row">
|
||||
<el-tag
|
||||
v-for="item in phrases"
|
||||
:key="item"
|
||||
closable
|
||||
type="info"
|
||||
effect="plain"
|
||||
@close="removePhrase(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
|
||||
</div>
|
||||
<div class="toolbar mt-12">
|
||||
<el-input
|
||||
v-model="phraseInput"
|
||||
placeholder="新增短语,如 大本"
|
||||
style="width: 200px"
|
||||
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
|
||||
@keyup.enter="addPhrase"
|
||||
/>
|
||||
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
|
||||
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>离线缓存(IndexedDB)</span></template>
|
||||
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
|
||||
<el-table-column prop="label" label="数据类型" width="130" />
|
||||
<el-table-column label="条目数" width="90">
|
||||
<template #default="{ row }">{{ row.count }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近更新" min-width="150">
|
||||
<template #default="{ row }">{{ row.updatedAt }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="离线可用"
|
||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||
/>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.phrase-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
ISLAND_POSITION_LABELS,
|
||||
ISLAND_POSITIONS,
|
||||
ISLAND_STYLE_LABELS,
|
||||
ISLAND_STYLES,
|
||||
type IslandAppearance,
|
||||
type IslandPosition,
|
||||
type IslandStyle,
|
||||
} from '@classhelper/shared';
|
||||
|
||||
/**
|
||||
* 灵动岛个性化(原设置页「灵动岛 · 个性化」卡整块迁移)。
|
||||
*
|
||||
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
|
||||
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
|
||||
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
|
||||
*/
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const savingIsland = ref(false);
|
||||
|
||||
/**
|
||||
* 左右边距是否有效:只在停靠左 / 右时生效。
|
||||
*
|
||||
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
|
||||
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
|
||||
*/
|
||||
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
|
||||
|
||||
const positionOptions = ISLAND_POSITIONS.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_POSITION_LABELS[value],
|
||||
}));
|
||||
|
||||
const styleOptions = ISLAND_STYLES.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_STYLE_LABELS[value],
|
||||
}));
|
||||
|
||||
/** 读取当前生效的外观(主进程为单一事实来源) */
|
||||
async function loadIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.islandGetAppearance) return;
|
||||
island.value = await window.desktop.islandGetAppearance();
|
||||
}
|
||||
|
||||
/** 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘 */
|
||||
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
|
||||
island.value = { ...island.value, ...patch };
|
||||
window.desktop?.islandSetAppearance({ ...island.value });
|
||||
}
|
||||
|
||||
async function saveIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
savingIsland.value = true;
|
||||
try {
|
||||
await window.desktop.saveConfig({ island: { ...island.value } });
|
||||
ElMessage.success('个性化设置已保存');
|
||||
} finally {
|
||||
savingIsland.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetIslandAppearance(): Promise<void> {
|
||||
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
|
||||
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
await saveIslandAppearance();
|
||||
}
|
||||
|
||||
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
|
||||
function testIsland(): void {
|
||||
window.desktop?.islandPush?.({
|
||||
notification: {
|
||||
id: `appearance-test-${Date.now()}`,
|
||||
title: '灵动岛外观预览',
|
||||
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
|
||||
priority: 'NORMAL',
|
||||
createdAt: new Date().toISOString(),
|
||||
courseName: null,
|
||||
teacherName: '本地预览',
|
||||
},
|
||||
context: { inClass: false, currentPeriodEnd: null, preview: true },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadIslandAppearance();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">灵动岛</h2>
|
||||
<p class="page-subtitle">屏幕上方的消息浮窗:外观、停靠位置与动画</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>灵动岛 · 个性化</span></template>
|
||||
<el-form label-width="96px" label-position="left">
|
||||
<el-form-item :label="`高度 ${island.height}px`">
|
||||
<el-slider
|
||||
:model-value="island.height"
|
||||
:min="RANGES.height.min"
|
||||
:max="RANGES.height.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ height: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`宽度 ${island.width}px`">
|
||||
<el-slider
|
||||
:model-value="island.width"
|
||||
:min="RANGES.width.min"
|
||||
:max="RANGES.width.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ width: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`圆角 ${island.radius}px`">
|
||||
<el-slider
|
||||
:model-value="island.radius"
|
||||
:min="RANGES.radius.min"
|
||||
:max="RANGES.radius.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ radius: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
|
||||
<el-slider
|
||||
:model-value="island.opacity"
|
||||
:min="RANGES.opacity.min"
|
||||
:max="RANGES.opacity.max"
|
||||
:step="0.05"
|
||||
@input="(value: number) => applyIslandAppearance({ opacity: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`字号 ${island.fontSize}px`">
|
||||
<el-slider
|
||||
:model-value="island.fontSize"
|
||||
:min="RANGES.fontSize.min"
|
||||
:max="RANGES.fontSize.max"
|
||||
:step="1"
|
||||
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
|
||||
<el-slider
|
||||
:model-value="island.speed"
|
||||
:min="RANGES.speed.min"
|
||||
:max="RANGES.speed.max"
|
||||
:step="0.1"
|
||||
@input="(value: number) => applyIslandAppearance({ speed: value })"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="主题色">
|
||||
<el-color-picker
|
||||
:model-value="island.accent"
|
||||
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
|
||||
/>
|
||||
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="视觉风格">
|
||||
<el-select
|
||||
:model-value="island.style"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in styleOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示位置">
|
||||
<el-select
|
||||
:model-value="island.position"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in positionOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`左右边距 ${island.marginX}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginX"
|
||||
:min="RANGES.marginX.min"
|
||||
:max="RANGES.marginX.max"
|
||||
:step="2"
|
||||
:disabled="!marginXApplies"
|
||||
@input="(value: number) => applyIslandAppearance({ marginX: value })"
|
||||
/>
|
||||
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
|
||||
<span v-else class="text-muted ml-8">
|
||||
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`上下边距 ${island.marginY}px`">
|
||||
<el-slider
|
||||
:model-value="island.marginY"
|
||||
:min="RANGES.marginY.min"
|
||||
:max="RANGES.marginY.max"
|
||||
:step="2"
|
||||
@input="(value: number) => applyIslandAppearance({ marginY: value })"
|
||||
/>
|
||||
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟随鼠标屏幕">
|
||||
<el-switch
|
||||
:model-value="island.followCursorDisplay"
|
||||
@change="
|
||||
(value: boolean | string | number) =>
|
||||
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="动画">
|
||||
<el-switch
|
||||
:model-value="island.animations"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="始终置顶">
|
||||
<el-switch
|
||||
:model-value="island.alwaysOnTop"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="空闲细缝">
|
||||
<el-switch
|
||||
:model-value="island.idleSliver"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">保存设置</el-button>
|
||||
<el-button @click="testIsland">预览效果</el-button>
|
||||
<el-button @click="resetIslandAppearance">恢复默认</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
} from '@classhelper/shared';
|
||||
import { classIslandStatusApi } from '../../api/index.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
|
||||
/**
|
||||
* 提醒设置:这台教室机器把提醒弹在哪个端(客户端 / ClassIsland / 两端都弹)。
|
||||
* 值同时落本地配置与班级记录(服务端据此决定是否推送 ClassIsland)。
|
||||
*/
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const channelSaving = ref(false);
|
||||
/**
|
||||
* 本班有没有接入 ClassIsland 设备。
|
||||
*
|
||||
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
|
||||
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||
*/
|
||||
const classIslandConnected = ref(false);
|
||||
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
|
||||
const classIslandStatus = ref<{
|
||||
connected: boolean;
|
||||
deviceName: string | null;
|
||||
deviceCount: number;
|
||||
lastSeenAt: string | null;
|
||||
pluginVersion: string | null;
|
||||
classIslandVersion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const classIslandStatusText = computed(() => {
|
||||
const status = classIslandStatus.value;
|
||||
if (!status || !status.connected) return '未接入';
|
||||
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
|
||||
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
|
||||
});
|
||||
|
||||
const channelOptions = computed(() =>
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
||||
value,
|
||||
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
|
||||
disabled: value === 'classisland' && !classIslandConnected.value,
|
||||
})),
|
||||
);
|
||||
|
||||
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
|
||||
channelSaving.value = true;
|
||||
try {
|
||||
const saved = await appStore.setNotificationChannel(auth.classId, value);
|
||||
if (saved) ElMessage.success('提醒显示位置已更新');
|
||||
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
|
||||
} finally {
|
||||
channelSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.loadNotificationChannel(auth.classId);
|
||||
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
|
||||
if (auth.classId) {
|
||||
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
|
||||
classIslandStatus.value = status;
|
||||
classIslandConnected.value = status?.connected === true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">提醒</h2>
|
||||
<p class="page-subtitle">老师发通知 / 叫人时,提醒弹在哪个端</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header><span>ClassIsland 联动</span></template>
|
||||
<el-descriptions :column="1" border size="small" class="mb-12">
|
||||
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="插件 / ClassIsland">
|
||||
{{ classIslandStatus?.pluginVersion ?? '—' }} /
|
||||
{{ classIslandStatus?.classIslandVersion ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-muted">
|
||||
<strong>通知模式</strong>
|
||||
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
|
||||
</p>
|
||||
<el-radio-group
|
||||
:model-value="appStore.notificationChannel"
|
||||
:disabled="channelSaving"
|
||||
class="channel-group"
|
||||
@change="
|
||||
(value: string | number | boolean | undefined) =>
|
||||
applyChannel(value as ClassIslandNotificationChannel)
|
||||
"
|
||||
>
|
||||
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="text-muted channel-hint">
|
||||
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
|
||||
<template v-if="!classIslandConnected">
|
||||
<br />
|
||||
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
|
||||
ClassIsland 上弹")。
|
||||
</template>
|
||||
</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.channel-hint {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
IslandAppearance,
|
||||
IslandNotification,
|
||||
IslandState,
|
||||
UpdateInfo,
|
||||
} from '@classhelper/shared';
|
||||
|
||||
/** 作业页展示偏好(看板 / 列表,看板外观) */
|
||||
@@ -47,6 +48,24 @@ export interface DesktopStoredConfig {
|
||||
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
|
||||
*/
|
||||
notificationChannel: ClassIslandNotificationChannel;
|
||||
/**
|
||||
* 是否已看过「初次启动引导」。
|
||||
*
|
||||
* 首次启动自动弹出一次(完成或跳过即记为看过并写入配置,之后不再自动弹出);
|
||||
* 登录页与设置页的「使用引导」入口可随时重看(重看不改变该值)。
|
||||
*/
|
||||
onboardingDone: boolean;
|
||||
/** 界面主题:light=浅色(默认)/ dark=深色(黑夜模式) */
|
||||
theme: 'light' | 'dark';
|
||||
/** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */
|
||||
sidebarCollapsed: boolean;
|
||||
/**
|
||||
* 用户点过「忽略此版本」的版本号(空串 = 没忽略过)。
|
||||
*
|
||||
* 启动时的自动检查会跳过这个版本,避免每次开客户端都弹一次同样的提示;
|
||||
* 在「关于」页手动检查时仍会正常显示结果。
|
||||
*/
|
||||
ignoredUpdateVersion: string;
|
||||
}
|
||||
|
||||
export interface DesktopAppInfo {
|
||||
@@ -56,6 +75,8 @@ export interface DesktopAppInfo {
|
||||
node: string;
|
||||
platform: string;
|
||||
userDataPath: string;
|
||||
/** 主进程配置文件(config.json)的绝对路径(关于页「诊断信息」展示) */
|
||||
configPath: string;
|
||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */
|
||||
smokeTest: boolean;
|
||||
}
|
||||
@@ -108,6 +129,21 @@ export interface DesktopBridge {
|
||||
getAppInfo(): Promise<DesktopAppInfo>;
|
||||
openExternal(url: string): Promise<boolean>;
|
||||
|
||||
/* 更新检查 */
|
||||
/**
|
||||
* 检查 GitHub 上有没有新版本(主进程直连,不经后端)。
|
||||
*
|
||||
* @param force true = 绕过主进程缓存(对应「关于」页里用户主动点的那次检查)
|
||||
*
|
||||
* 返回的 `ok:false` 是**正常结果**(教室机器没有外网 / GitHub 限流 / 超时),不是异常,
|
||||
* 界面按"暂时查不到更新"呈现即可。
|
||||
*/
|
||||
checkForUpdates(force?: boolean): Promise<UpdateInfo>;
|
||||
/** 忽略某个版本的更新提示(写入 config.json,启动自动检查不再提示它) */
|
||||
ignoreUpdateVersion(version: string): Promise<void>;
|
||||
/** 订阅"启动自动检查发现新版本"(仅新版本且未被忽略时触发一次) */
|
||||
onUpdateAvailable(handler: (info: UpdateInfo) => void): void;
|
||||
|
||||
/* 灵动岛 */
|
||||
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
|
||||
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
|
||||
@@ -209,6 +245,12 @@ declare global {
|
||||
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */
|
||||
islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 初次启动引导自检(首启自动出现 → 真实点击走完 → 完成状态写入配置) */
|
||||
onboardingSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 主界面外观件自检(真实点击:主题切换 html.dark + 落盘、侧边栏折叠 + 落盘) */
|
||||
layoutChromeSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
/** 关于页自检(/settings/about 渲染 + getAppInfo 含 configPath) */
|
||||
aboutPageSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ CORS_ORIGIN=*
|
||||
# bcrypt 加密强度
|
||||
BCRYPT_ROUNDS=10
|
||||
|
||||
# 教师新增学生账号时未指定密码所使用的初始密码
|
||||
|
||||
# 新建班级 / 新增教师未指定密码时使用的初始密码
|
||||
# (学生没有个人账号,因此没有 DEFAULT_STUDENT_PASSWORD —— 见 AGENTS.md §5 第 31 条)
|
||||
DEFAULT_CLASS_PASSWORD=123456
|
||||
DEFAULT_TEACHER_PASSWORD=123456
|
||||
# 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算
|
||||
TERM_START_DATE=2026-02-23
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/server",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||
"type": "module",
|
||||
@@ -25,6 +25,7 @@
|
||||
"@classhelper/shared": "workspace:*",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@prisma/adapter-libsql": "7.10.0",
|
||||
"@prisma/adapter-mariadb": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"compression": "^1.8.2",
|
||||
@@ -34,7 +35,9 @@
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"helmet": "^8.3.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"prisma": "7.10.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"typescript": "~5.9.3",
|
||||
"xlsx": "0.18.5",
|
||||
"zod": "^4.6.2"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
|
||||
// 字段全部使用 String / Int / Float / Boolean / DateTime 等跨库通用类型,
|
||||
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
|
||||
// MySQL 版本见同目录 schema.mysql.prisma,切换步骤见 docs/mysql.md。
|
||||
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
|
||||
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
|
||||
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、
|
||||
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
|
||||
// 这样 SQLite / MySQL 行为完全一致。
|
||||
|
||||
@@ -105,6 +105,34 @@ async function loginWithCandidates(username, passwords) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反复「拉取待提醒 → 逐条确认」,直到该设备视角下没有积压。
|
||||
*
|
||||
* 为什么验证脚本要做这件事:`GET /integrations/classisland/pending` 是
|
||||
* 「本班全部未确认且未过期的提醒,按时间升序取前 20 条」。而 `verify:desktop` /
|
||||
* `verify:web` 等冒烟会**真的往演示班发通知**(AGENTS.md §5 第 38 条),
|
||||
* 那些条如果没被确认就会一直堆着 —— 攒够 20 条之后,本脚本刚发的提醒会被挤到窗口之外,
|
||||
* 表现为「待提醒里找不到刚发的那条」,与代码对不对完全无关(2026-10-05 实测踩过:
|
||||
* 库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。
|
||||
*
|
||||
* 这里做的事情与真实插件重连后的行为完全一致(补弹积压并逐条确认),
|
||||
* 因此不会掩盖任何产品侧问题。可见范围也一致:只处理 `deviceId` 为空的班级广播
|
||||
* 与本设备自己的推送,别的设备的目标推送取不到、也确认不了。
|
||||
*/
|
||||
async function drainPending(deviceToken) {
|
||||
let total = 0;
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
const pending = await api('/integrations/classisland/pending', { deviceToken });
|
||||
const items = pending.payload?.data?.notifications ?? [];
|
||||
if (items.length === 0) break;
|
||||
for (const item of items) {
|
||||
await api('/integrations/classisland/ack', { method: 'POST', deviceToken, body: { id: item.id } });
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
|
||||
|
||||
@@ -182,7 +210,7 @@ async function main() {
|
||||
|
||||
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
|
||||
const reportBody = {
|
||||
pluginVersion: '0.1.0.0',
|
||||
pluginVersion: '1.1.2.0',
|
||||
classIslandVersion: '2.1.0.0',
|
||||
state: {
|
||||
inClass: true,
|
||||
@@ -237,6 +265,13 @@ async function main() {
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- 4. 下发提醒
|
||||
// 先确认掉本班的历史积压(历次冒烟留下的未确认提醒),否则 /pending 的前 20 条窗口
|
||||
// 会被它们占满,下面「插件拉取到待提醒」等用例会拿不到刚发的这条(理由见 drainPending)
|
||||
const drained = await drainPending(deviceToken);
|
||||
if (drained > 0) {
|
||||
console.log(`(已确认本班 ${drained} 条历史积压提醒,避免挤占待提醒窗口)\n`);
|
||||
}
|
||||
|
||||
const notify = await api('/integrations/classisland/notify', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
|
||||
@@ -67,6 +67,17 @@ async function main() {
|
||||
}
|
||||
console.log(` 已挂载模块:${(health.payload?.data?.modules ?? []).join(', ')}\n`);
|
||||
|
||||
// 版本号不再硬编码(见 lib/version.ts):健康检查必须暴露一个像样的版本,
|
||||
// 更新检查要靠它判断"本机是不是旧版";同时确认 update 模块已进注册表。
|
||||
const healthData = health.payload?.data ?? {};
|
||||
record(
|
||||
'健康检查暴露版本号与模块清单(含 update 模块)',
|
||||
typeof healthData.version === 'string' &&
|
||||
/^\d+\.\d+\.\d+/.test(healthData.version) &&
|
||||
(healthData.modules ?? []).includes('update'),
|
||||
`版本=${healthData.version} 模块数=${(healthData.modules ?? []).length}`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- 1. 登录
|
||||
const teacherLogin = await api('/auth/login', {
|
||||
method: 'POST',
|
||||
@@ -2339,6 +2350,236 @@ async function main() {
|
||||
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
|
||||
);
|
||||
|
||||
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
|
||||
// ---------------------------------------------------------------- 数据库管理(仅管理员)
|
||||
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
|
||||
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
|
||||
const dbStatus = await api('/database/status', { token: adminToken });
|
||||
const dbStatusData = dbStatus.payload?.data ?? {};
|
||||
record(
|
||||
'数据库状态(管理员):已连接且返回 14 张表行数',
|
||||
dbStatus.status === 200 &&
|
||||
dbStatusData.connected === true &&
|
||||
Array.isArray(dbStatusData.tables) &&
|
||||
dbStatusData.tables.length === 14 &&
|
||||
dbStatusData.tables.every((item) => item.count >= 0),
|
||||
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
|
||||
(dbStatusData.tables ?? []).length
|
||||
}`,
|
||||
);
|
||||
|
||||
const dbStatusTeacher = await api('/database/status', { token: teacherToken });
|
||||
const dbBackupTeacher = await api('/database/backups', { method: 'POST', token: teacherToken });
|
||||
const dbScheduleTeacher = await api('/database/backup-schedule', {
|
||||
method: 'PUT',
|
||||
token: teacherToken,
|
||||
body: { enabled: true, intervalHours: 24, keepCount: 7 },
|
||||
});
|
||||
const dbSwitchTeacher = await api('/database/switch', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { provider: 'sqlite', url: 'file:./data/teacher-forbidden.db' },
|
||||
});
|
||||
record(
|
||||
'数据库管理越权(教师全部 403)',
|
||||
dbStatusTeacher.status === 403 &&
|
||||
dbBackupTeacher.status === 403 &&
|
||||
dbScheduleTeacher.status === 403 &&
|
||||
dbSwitchTeacher.status === 403,
|
||||
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
|
||||
);
|
||||
|
||||
// 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支)
|
||||
let testOkResult;
|
||||
let testOkName;
|
||||
if (dbStatusData.provider === 'sqlite') {
|
||||
testOkResult = await api('/database/test-connection', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { provider: 'sqlite', url: 'file:./prisma/dev.db' },
|
||||
});
|
||||
testOkName = '连接测试(当前 SQLite 库可达)';
|
||||
} else {
|
||||
testOkResult = await api('/database/test-connection', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { provider: 'mysql', url: 'mysql://root:wrong-password@127.0.0.1:1/none' },
|
||||
});
|
||||
testOkName = '连接测试(不可达 MySQL 返回 ok=false)';
|
||||
}
|
||||
record(
|
||||
testOkName,
|
||||
testOkResult.status === 200 &&
|
||||
(dbStatusData.provider === 'sqlite'
|
||||
? testOkResult.payload?.data?.ok === true
|
||||
: testOkResult.payload?.data?.ok === false),
|
||||
`ok=${testOkResult.payload?.data?.ok} 版本=${testOkResult.payload?.data?.version} 详情=${testOkResult.payload?.data?.error ?? testOkResult.payload?.data?.note ?? '-'}`,
|
||||
);
|
||||
|
||||
const dbTestRedis = await api('/database/test-connection', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
|
||||
});
|
||||
record(
|
||||
'Redis 作为主库被拒绝(schema 层面 422,产品口径:主库仅 SQLite/MySQL)',
|
||||
dbTestRedis.status === 422,
|
||||
`status=${dbTestRedis.status}`,
|
||||
);
|
||||
|
||||
const badConnection = await api('/database/test-connection', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { provider: 'mysql', url: 'mysql://root:wrong@127.0.0.1:1/none' },
|
||||
});
|
||||
record(
|
||||
'连接测试(不可达 MySQL → ok=false 且不抛 500)',
|
||||
badConnection.status === 200 && badConnection.payload?.data?.ok === false,
|
||||
`ok=${badConnection.payload?.data?.ok} error=${(badConnection.payload?.data?.error ?? '').slice(0, 60)}`,
|
||||
);
|
||||
|
||||
// 切换校验分支:同库拒绝(真实切换不在此跑,避免改写 .env)
|
||||
const switchSame =
|
||||
dbStatusData.provider === 'sqlite'
|
||||
? await api('/database/switch', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked },
|
||||
})
|
||||
: { status: 0, payload: null };
|
||||
const switchInvalid = await api('/database/switch', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
|
||||
});
|
||||
record(
|
||||
'一键切换校验分支(同库 400 / 非法 provider 422,不真跑切换)',
|
||||
(dbStatusData.provider === 'sqlite' ? switchSame.status === 400 : true) && switchInvalid.status === 422,
|
||||
`同库=${switchSame.status}(${dbStatusData.provider === 'sqlite' ? 'sqlite 当前库' : 'mysql 跳过'}) redis=${switchInvalid.status}`,
|
||||
);
|
||||
|
||||
// 备份 → 导出快照 → 造数据 → 快照导入 → 造数据 → 从备份恢复(三个恢复路径全验)
|
||||
const dbBackup = await api('/database/backups', { method: 'POST', token: adminToken });
|
||||
const backupName = dbBackup.payload?.data?.name ?? '';
|
||||
record(
|
||||
'手动备份(201,文件名符合约定格式)',
|
||||
dbBackup.status === 201 && /^backup-manual-\d{8}-\d{6}\.json\.gz$/.test(backupName),
|
||||
`name=${backupName} size=${dbBackup.payload?.data?.sizeBytes ?? '-'}`,
|
||||
);
|
||||
|
||||
const dbExport = await fetch(`${BASE_URL}/api/database/export`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const snapshotJson = dbExport.status === 200 ? await dbExport.text() : '';
|
||||
let snapshotParsed = null;
|
||||
try {
|
||||
snapshotParsed = JSON.parse(snapshotJson);
|
||||
} catch {
|
||||
snapshotParsed = null;
|
||||
}
|
||||
record(
|
||||
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
|
||||
dbExport.status === 200 &&
|
||||
snapshotParsed?.format === 'classhelper-snapshot' &&
|
||||
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
|
||||
(snapshotParsed?.counts?.User ?? 0) > 0,
|
||||
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
|
||||
);
|
||||
|
||||
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
|
||||
const dbRoundtripNotice = await api('/notifications', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { classId, title: '数据库roundtrip自检', content: '导入/恢复后应消失' },
|
||||
});
|
||||
const dbRoundtripNoticeId = dbRoundtripNotice.payload?.data?.id;
|
||||
const dbImport = await api('/database/import', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { data: Buffer.from(snapshotJson, 'utf8').toString('base64') },
|
||||
});
|
||||
const afterImport = await api(`/notifications?classId=${classId}`, { token: adminToken });
|
||||
const roundtripNoticeGone = !(afterImport.payload?.data ?? []).some(
|
||||
(item) => item.id === dbRoundtripNoticeId,
|
||||
);
|
||||
record(
|
||||
'快照导入(整库覆盖:导入后临时通知消失)',
|
||||
dbImport.status === 200 && roundtripNoticeGone,
|
||||
`导入=${dbImport.status} 通知已清除=${roundtripNoticeGone} 行数=${JSON.stringify(dbImport.payload?.data?.counts ?? {}).slice(0, 80)}`,
|
||||
);
|
||||
|
||||
const dbRoundtripNotice2 = await api('/notifications', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { classId, title: '数据库roundtrip自检2', content: '备份恢复后应消失' },
|
||||
});
|
||||
const dbRoundtripNotice2Id = dbRoundtripNotice2.payload?.data?.id;
|
||||
const dbRestore = await api(`/database/backups/${backupName}/restore`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
});
|
||||
const afterRestore = await api(`/notifications?classId=${classId}`, { token: adminToken });
|
||||
const restoreNoticeGone = !(afterRestore.payload?.data ?? []).some(
|
||||
(item) => item.id === dbRoundtripNotice2Id,
|
||||
);
|
||||
record(
|
||||
'从备份恢复(整库覆盖:恢复后临时通知消失)',
|
||||
dbRestore.status === 200 && restoreNoticeGone,
|
||||
`恢复=${dbRestore.status} 通知已清除=${restoreNoticeGone}`,
|
||||
);
|
||||
if (dbRoundtripNoticeId) {
|
||||
await api(`/notifications/${dbRoundtripNoticeId}`, { method: 'DELETE', token: adminToken });
|
||||
}
|
||||
if (dbRoundtripNotice2Id) {
|
||||
await api(`/notifications/${dbRoundtripNotice2Id}`, { method: 'DELETE', token: adminToken });
|
||||
}
|
||||
|
||||
const dbBackupList = await api('/database/backups', { token: adminToken });
|
||||
const dbBackupDelete = await api(`/database/backups/${backupName}`, {
|
||||
method: 'DELETE',
|
||||
token: adminToken,
|
||||
});
|
||||
const dbBackupListAfter = await api('/database/backups', { token: adminToken });
|
||||
record(
|
||||
'备份列表与删除(创建后可见,删除后消失)',
|
||||
dbBackupList.status === 200 &&
|
||||
(dbBackupList.payload?.data ?? []).some((item) => item.name === backupName) &&
|
||||
dbBackupDelete.status === 200 &&
|
||||
!(dbBackupListAfter.payload?.data ?? []).some((item) => item.name === backupName),
|
||||
`删除前=${(dbBackupList.payload?.data ?? []).length} 删除后=${(dbBackupListAfter.payload?.data ?? []).length}`,
|
||||
);
|
||||
|
||||
const dbScheduleInvalid = await api('/database/backup-schedule', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { enabled: true, intervalHours: 0, keepCount: 7 },
|
||||
});
|
||||
const dbScheduleSave = await api('/database/backup-schedule', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { enabled: false, intervalHours: 24, keepCount: 7 },
|
||||
});
|
||||
record(
|
||||
'定时备份配置(非法间隔 422 / 合法保存并回读)',
|
||||
dbScheduleInvalid.status === 422 &&
|
||||
dbScheduleSave.status === 200 &&
|
||||
dbScheduleSave.payload?.data?.enabled === false &&
|
||||
dbScheduleSave.payload?.data?.intervalHours === 24,
|
||||
`非法=${dbScheduleInvalid.status} 保存=${dbScheduleSave.status} enabled=${dbScheduleSave.payload?.data?.enabled}`,
|
||||
);
|
||||
|
||||
if (dbStatusData.provider === 'sqlite') {
|
||||
const sqliteFile = await fetch(`${BASE_URL}/api/database/sqlite-file`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const sqliteBytes = sqliteFile.status === 200 ? (await sqliteFile.arrayBuffer()).byteLength : 0;
|
||||
record(
|
||||
'SQLite 数据文件下载(快捷导出 .db 文件)',
|
||||
sqliteFile.status === 200 && sqliteBytes > 0,
|
||||
`status=${sqliteFile.status} bytes=${sqliteBytes}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
|
||||
const realtimeNoticeId = createdNotification.payload?.data?.id;
|
||||
if (realtimeNoticeId) {
|
||||
@@ -2388,6 +2629,42 @@ async function main() {
|
||||
} 残留班级=${leftoverClasses.map((item) => item.name).join(',') || '无'}`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- 更新检查
|
||||
// 验的是"结构完整 + 权限 + 可降级",**刻意不断言"一定有新版本"** ——
|
||||
// 那会把用例绑死在外网可达上,而学校机房/内网服务器查不到 GitHub 是常态
|
||||
// (`ok:false` 是设计内的结果,不是失败)。真实联网取值在交付前人工验一次。
|
||||
const updateAnon = await api('/update/check');
|
||||
record('更新检查未登录 401', updateAnon.status === 401, `status=${updateAnon.status}`);
|
||||
|
||||
const updateRes = await api('/update/check', { token: adminToken });
|
||||
const updateData = updateRes.payload?.data ?? {};
|
||||
const isVersionLike = (value) => typeof value === 'string' && /^\d/.test(value);
|
||||
record(
|
||||
'更新检查(管理员):结构完整,本机版本取自 package.json',
|
||||
updateRes.status === 200 &&
|
||||
isVersionLike(updateData.currentVersion) &&
|
||||
typeof updateData.ok === 'boolean' &&
|
||||
typeof updateData.hasUpdate === 'boolean' &&
|
||||
(updateData.ok
|
||||
? isVersionLike(updateData.latestVersion) &&
|
||||
Array.isArray(updateData.assets) &&
|
||||
/^https:\/\//.test(updateData.releaseUrl ?? '') &&
|
||||
updateData.error === null
|
||||
: typeof updateData.error === 'string' && updateData.error.length > 0),
|
||||
`ok=${updateData.ok} 本机=${updateData.currentVersion} 最新=${updateData.latestVersion ?? '-'} ` +
|
||||
`有新版本=${updateData.hasUpdate} 附件=${(updateData.assets ?? []).length}` +
|
||||
`${updateData.error ? ` 原因=${updateData.error}` : ''}`,
|
||||
);
|
||||
|
||||
// force 只对管理员生效:教师带 force=1 也必须命中缓存(否则任意登录用户都能刷爆匿名限流)
|
||||
const updateTeacherForced = await api('/update/check?force=1', { token: teacherToken });
|
||||
record(
|
||||
'更新检查:教师可查(公开信息),但 force 仅管理员生效',
|
||||
updateTeacherForced.status === 200 &&
|
||||
updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt,
|
||||
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
|
||||
);
|
||||
|
||||
socket.close();
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
@@ -6,6 +6,7 @@ import { env } from './config/env.js';
|
||||
import { pingDatabase } from './lib/db.js';
|
||||
import { sendOk } from './lib/http.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { APP_VERSION } from './lib/version.js';
|
||||
import { mountWebAdmin, resolveWebDistDir } from './lib/web-static.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error.js';
|
||||
import { apiRateLimiter, loginRateLimiter, requestLogger, securityHeaders } from './middleware/security.js';
|
||||
@@ -37,7 +38,8 @@ async function healthHandler(_req: Request, res: Response): Promise<void> {
|
||||
res,
|
||||
{
|
||||
status: 'ok',
|
||||
version: '0.1.0',
|
||||
// 版本号取自 package.json(见 lib/version.ts):更新检查要拿它跟 GitHub 上的 Release 比
|
||||
version: APP_VERSION,
|
||||
env: env.nodeEnv,
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
database: {
|
||||
@@ -68,7 +70,7 @@ function createRootHandler(webDistDir: string | null) {
|
||||
res,
|
||||
{
|
||||
service: 'class-helper-server',
|
||||
version: '0.1.0',
|
||||
version: APP_VERSION,
|
||||
environment: env.nodeEnv,
|
||||
apiPrefix: API_PREFIX,
|
||||
socket: '/socket.io',
|
||||
@@ -115,7 +117,7 @@ function renderLandingPage(webAdminServed: boolean): string {
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
|
||||
<p class="sub">版本 0.1.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
|
||||
<p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
|
||||
<p class="sub">
|
||||
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
|
||||
存活 /healthz · 就绪 /readyz
|
||||
|
||||
@@ -156,7 +156,6 @@ export const env = {
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
bcryptRounds: raw.BCRYPT_ROUNDS,
|
||||
/** 新增学生时未指定密码时的初始密码 */
|
||||
/** 新建班级时的默认班级密码(班级账号登录) */
|
||||
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
|
||||
/** 新建/重置教师账号时的初始密码 */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { resolveWebDistDir } from './lib/web-static.js';
|
||||
import { initRealtime } from './realtime/socket.js';
|
||||
import { startBackupScheduler } from './modules/database/database.service.js';
|
||||
|
||||
/** 数据库自检与可选的首次初始化 */
|
||||
async function prepareDatabase(): Promise<void> {
|
||||
@@ -85,6 +86,8 @@ httpServer.listen(env.port, env.host, () => {
|
||||
}
|
||||
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
|
||||
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
|
||||
// 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json)
|
||||
startBackupScheduler();
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
@@ -3,27 +3,33 @@ import { PrismaClient } from '../generated/prisma/client.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/** 本项目支持的主数据库类型(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
|
||||
export type DatabaseProvider = 'sqlite' | 'mysql';
|
||||
|
||||
/**
|
||||
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
|
||||
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
|
||||
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md)
|
||||
* - MySQL -> @prisma/adapter-mariadb(依赖里常驻,切换数据库时直接可用)
|
||||
*
|
||||
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
|
||||
* 也不会影响类型检查与 SQLite 模式的运行。
|
||||
*
|
||||
* 该工厂独立于进程级单例:数据库管理模块的「连接测试」与跨库迁移的工具进程
|
||||
* 都需要按任意 provider/url 构建临时客户端,而不是读 env 单例。
|
||||
*/
|
||||
async function createAdapter(): Promise<unknown> {
|
||||
if (env.databaseProvider === 'mysql') {
|
||||
export async function createPrismaClient(
|
||||
provider: DatabaseProvider,
|
||||
url: string,
|
||||
): Promise<PrismaClient> {
|
||||
if (provider === 'mysql') {
|
||||
const adapterModule = '@prisma/adapter-mariadb';
|
||||
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
|
||||
logger.info('已加载 MySQL/MariaDB 驱动适配器');
|
||||
return new mod.PrismaMariaDb(env.databaseUrl);
|
||||
return new PrismaClient({ adapter: new mod.PrismaMariaDb(url) as never });
|
||||
}
|
||||
return new PrismaLibSql({ url: env.databaseUrl });
|
||||
return new PrismaClient({ adapter: new PrismaLibSql({ url }) as never });
|
||||
}
|
||||
|
||||
const adapter = await createAdapter();
|
||||
|
||||
export const prisma = new PrismaClient({ adapter: adapter as never });
|
||||
export const prisma = await createPrismaClient(env.databaseProvider, env.databaseUrl);
|
||||
|
||||
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { PrismaClient } from '../generated/prisma/client.js';
|
||||
import type { DatabaseProvider } from './db.js';
|
||||
|
||||
/**
|
||||
* 全库 JSON 快照:备份 / 恢复 / 导入导出 / 跨库迁移共用这一种格式。
|
||||
*
|
||||
* 为什么不用「复制数据库文件」或 mysqldump:
|
||||
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
|
||||
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
|
||||
*
|
||||
* 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用),
|
||||
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
|
||||
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
|
||||
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
|
||||
*/
|
||||
|
||||
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
|
||||
export const SNAPSHOT_VERSION = 1;
|
||||
|
||||
/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */
|
||||
export const SNAPSHOT_TABLES = [
|
||||
'User',
|
||||
'Class',
|
||||
'Course',
|
||||
'ClassTeacher',
|
||||
'Enrollment',
|
||||
'Schedule',
|
||||
'TimeLayout',
|
||||
'Homework',
|
||||
'HomeworkStatus',
|
||||
'Notification',
|
||||
'NotificationRead',
|
||||
'Grade',
|
||||
'IntegrationDevice',
|
||||
'ClassIslandPush',
|
||||
] as const;
|
||||
|
||||
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
|
||||
|
||||
export interface DatabaseSnapshot {
|
||||
format: string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
source: { provider: string };
|
||||
/** 每张表的行数(导出时统计,恢复时校验总量用) */
|
||||
counts: Record<string, number>;
|
||||
/** 表名 → 行数组(DateTime 已是 ISO 字符串,Prisma 接受 ISO 字符串作为 DateTime 输入) */
|
||||
data: Record<SnapshotTable, Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
/** 模型代理的最小接口(避免 any:Prisma 各模型的 delegate 形状一致) */
|
||||
interface ModelDelegate {
|
||||
findMany(): Promise<Record<string, unknown>[]>;
|
||||
count(): Promise<number>;
|
||||
createMany(args: { data: Record<string, unknown>[] }): Promise<unknown>;
|
||||
deleteMany(): Promise<unknown>;
|
||||
}
|
||||
|
||||
function delegatesOf(prisma: PrismaClient): Record<SnapshotTable, ModelDelegate> {
|
||||
return prisma as unknown as Record<SnapshotTable, ModelDelegate>;
|
||||
}
|
||||
|
||||
/** 单次 createMany 的批量大小(班级规模数据量小,小批次足够且省内存) */
|
||||
const CHUNK_SIZE = 200;
|
||||
|
||||
/** 导出全库快照(行内 DateTime 为 Date 对象,JSON.stringify 时自动转 ISO) */
|
||||
export async function exportSnapshot(
|
||||
prisma: PrismaClient,
|
||||
provider: DatabaseProvider,
|
||||
): Promise<DatabaseSnapshot> {
|
||||
const delegates = delegatesOf(prisma);
|
||||
const data = {} as Record<SnapshotTable, Record<string, unknown>[]>;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = await delegates[table].findMany();
|
||||
data[table] = rows;
|
||||
counts[table] = rows.length;
|
||||
}
|
||||
return {
|
||||
format: SNAPSHOT_FORMAT,
|
||||
version: SNAPSHOT_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
source: { provider },
|
||||
counts,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/** 快照结构校验:给出可读的失败原因("为什么恢复失败"比失败本身重要) */
|
||||
export function assertValidSnapshot(value: unknown): asserts value is DatabaseSnapshot {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('快照内容不是合法的 JSON 对象');
|
||||
}
|
||||
const snapshot = value as Partial<DatabaseSnapshot>;
|
||||
if (snapshot.format !== SNAPSHOT_FORMAT) {
|
||||
throw new Error(`快照格式不正确(期望 ${SNAPSHOT_FORMAT},收到 ${String(snapshot.format ?? '未知')})`);
|
||||
}
|
||||
if (snapshot.version !== SNAPSHOT_VERSION) {
|
||||
throw new Error(`快照版本不兼容(期望 v${SNAPSHOT_VERSION},收到 v${String(snapshot.version)})`);
|
||||
}
|
||||
if (typeof snapshot.data !== 'object' || snapshot.data === null) {
|
||||
throw new Error('快照缺少 data 字段');
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = (snapshot.data as Record<string, unknown>)[table];
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(`快照缺少表 ${table} 的数据`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise<void> {
|
||||
const sql =
|
||||
provider === 'mysql'
|
||||
? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}`
|
||||
: `PRAGMA foreign_keys=${enabled ? 'ON' : 'OFF'}`;
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把快照写入目标库(先清空再写入)。
|
||||
* 用于:备份恢复、快照导入、跨库迁移的子进程。
|
||||
*/
|
||||
export async function restoreSnapshot(
|
||||
prisma: PrismaClient,
|
||||
provider: DatabaseProvider,
|
||||
snapshot: DatabaseSnapshot,
|
||||
options: { clean?: boolean } = {},
|
||||
): Promise<{ counts: Record<string, number> }> {
|
||||
assertValidSnapshot(snapshot);
|
||||
const delegates = delegatesOf(prisma);
|
||||
const clean = options.clean !== false;
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
await setForeignKeys(prisma, provider, false);
|
||||
try {
|
||||
if (clean) {
|
||||
// 反向清空,保证被引用的表最后清(外键关闭后只是防御性的,保持确定性)
|
||||
for (const table of [...SNAPSHOT_TABLES].reverse()) {
|
||||
await delegates[table].deleteMany();
|
||||
}
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = snapshot.data[table];
|
||||
for (let index = 0; index < rows.length; index += CHUNK_SIZE) {
|
||||
await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) });
|
||||
}
|
||||
counts[table] = rows.length;
|
||||
}
|
||||
} finally {
|
||||
await setForeignKeys(prisma, provider, true).catch(() => undefined);
|
||||
}
|
||||
return { counts };
|
||||
}
|
||||
|
||||
/** 汇总快照总行数(列表展示 / 切换任务结果用) */
|
||||
export function snapshotTotalRows(snapshot: DatabaseSnapshot): number {
|
||||
return Object.values(snapshot.counts).reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 服务端版本号。
|
||||
*
|
||||
* 为什么不再硬编码:`app.ts` 里原本写了两个字面量 `'1.0.0'`(健康检查 + 首页),
|
||||
* 而版本号在仓库里已经有 5 份 package.json —— 更新检查要靠它判断"本机是不是旧版",
|
||||
* 再留一份手写副本就一定会漂移。
|
||||
*
|
||||
* 取哪一份(按顺序):
|
||||
* 1. `serverRoot/..` —— 打包形态下是运行时清单(`scripts/dist-server.mjs` 把**根**
|
||||
* package.json 的 version 写进 `staging/package.json`),也就是产物命名用的那个版本;
|
||||
* 2. `serverRoot` —— 开发形态下是 `packages/server/package.json`。
|
||||
*
|
||||
* 与 `config/env.ts` 加载 `.env` 的双候选写法同一思路:不依赖启动时的 cwd。
|
||||
*/
|
||||
function readVersion(): string {
|
||||
const candidates = [
|
||||
path.resolve(env.serverRoot, '..', 'package.json'),
|
||||
path.resolve(env.serverRoot, 'package.json'),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { version?: unknown };
|
||||
if (typeof parsed.version === 'string' && parsed.version.trim()) {
|
||||
return parsed.version.trim();
|
||||
}
|
||||
} catch {
|
||||
// 换下一个候选
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`未能从 package.json 读到版本号(候选:${candidates.join('、')}),暂时按 0.0.0 处理`);
|
||||
return '0.0.0';
|
||||
}
|
||||
|
||||
export const APP_VERSION = readVersion();
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import express, { type Express, type RequestHandler } from 'express';
|
||||
import express, { type Express } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
@@ -100,9 +100,3 @@ export function mountWebAdmin(app: Express): string | null {
|
||||
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();
|
||||
};
|
||||
@@ -11,8 +11,13 @@ import { logger } from '../lib/logger.js';
|
||||
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
|
||||
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
|
||||
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
|
||||
* - helmet 默认 CSP 自带 upgrade-insecure-requests,它会把页面**子资源**请求全部改写成
|
||||
* https —— 而安装器的默认形态是 http://IP:端口 直连(无 TLS),改写后的请求必然
|
||||
* 连接失败,表现为「管理端一片空白、标签页标题正常」(2026-10-05 实测踩到)。
|
||||
* HSTS 在明文响应里按规范本就会被浏览器忽略,但 CSP 指令没有这层豁免,
|
||||
* 所以这两者都只在 HTTPS 请求下下发(见 buildHelmet 的 httpsRequest 参数)。
|
||||
*/
|
||||
export function securityHeaders(): RequestHandler {
|
||||
function buildHelmet(httpsRequest: boolean): RequestHandler {
|
||||
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
|
||||
if (Array.isArray(env.corsOrigins)) {
|
||||
for (const origin of env.corsOrigins) connectSrc.add(origin);
|
||||
@@ -32,6 +37,7 @@ export function securityHeaders(): RequestHandler {
|
||||
'base-uri': ["'self'"],
|
||||
'frame-ancestors': ["'none'"],
|
||||
'form-action': ["'self'"],
|
||||
'upgrade-insecure-requests': httpsRequest ? [] : null,
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
@@ -39,10 +45,18 @@ export function securityHeaders(): RequestHandler {
|
||||
// 允许桌面客户端等跨源场景读取静态资源与接口响应
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
referrerPolicy: { policy: 'no-referrer' },
|
||||
hsts: env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
|
||||
hsts: httpsRequest && env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function securityHeaders(): RequestHandler {
|
||||
const secure = buildHelmet(true);
|
||||
const plain = buildHelmet(false);
|
||||
// req.secure:直连 TLS 时为 true;前面有反代时由 x-forwarded-proto 决定
|
||||
// (app.ts 里 app.set('trust proxy', env.trustProxy) 已生效)
|
||||
return (req, res, next) => (req.secure ? secure : plain)(req, res, next);
|
||||
}
|
||||
|
||||
/** 通用接口限流:防止单 IP 高频刷接口 */
|
||||
export function apiRateLimiter(): RequestHandler {
|
||||
const limiter = rateLimit({
|
||||
@@ -85,11 +99,20 @@ export function loginRateLimiter(): RequestHandler {
|
||||
});
|
||||
}
|
||||
|
||||
/** 请求耗时日志(生产 info,开发 debug) */
|
||||
/**
|
||||
* 请求耗时日志(生产 info,开发 debug)。
|
||||
*
|
||||
* 静态构建产物(`/assets/*`,带内容哈希、长缓存)**成功的请求不打点**:
|
||||
* Web 管理端首屏一次就有十几个资源请求,逐条刷 info 会把真正有用的接口日志淹掉,
|
||||
* 而且每条都要走一次 stdout 写入。失败的静态请求(404 / 500)照常记录 ——
|
||||
* 那种情况恰恰是最需要看见的(产物缺失、路径不对)。
|
||||
*/
|
||||
export function requestLogger(): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const isStaticAsset = req.path.startsWith('/assets/');
|
||||
const startedAt = process.hrtime.bigint();
|
||||
res.on('finish', () => {
|
||||
if (isStaticAsset && res.statusCode < 400) return;
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
backupNameParamSchema,
|
||||
backupScheduleSchema,
|
||||
importSnapshotSchema,
|
||||
jobIdParamSchema,
|
||||
switchDatabaseSchema,
|
||||
testConnectionSchema,
|
||||
type BackupScheduleInput,
|
||||
type ImportSnapshotInput,
|
||||
type SwitchDatabaseInput,
|
||||
type TestConnectionInput,
|
||||
} from './database.schemas.js';
|
||||
import * as databaseService from './database.service.js';
|
||||
|
||||
/**
|
||||
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
|
||||
*
|
||||
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
|
||||
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
|
||||
*
|
||||
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
|
||||
* Redis 是键值库,不是 Prisma 支持的主数据库,因此不在此模块范围内。
|
||||
*/
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate());
|
||||
router.use(requireRole('ADMIN'));
|
||||
|
||||
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
|
||||
router.get('/status', async (_req, res) => {
|
||||
sendOk(res, await databaseService.getDatabaseStatus());
|
||||
});
|
||||
|
||||
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
|
||||
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
|
||||
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req));
|
||||
sendOk(res, result, result.ok ? '连接成功' : '连接失败');
|
||||
});
|
||||
|
||||
/** GET /api/database/backups - 备份列表 */
|
||||
router.get('/backups', (_req, res) => {
|
||||
sendOk(res, databaseService.listBackups());
|
||||
});
|
||||
|
||||
/** POST /api/database/backups - 立即创建一份手动备份 */
|
||||
router.post('/backups', async (_req, res) => {
|
||||
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建');
|
||||
});
|
||||
|
||||
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
|
||||
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
|
||||
const { name } = validatedParams<{ name: string }>(req);
|
||||
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复');
|
||||
});
|
||||
|
||||
/** DELETE /api/database/backups/:name - 删除备份 */
|
||||
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
|
||||
const { name } = validatedParams<{ name: string }>(req);
|
||||
databaseService.deleteBackup(name);
|
||||
sendOk(res, { name }, '备份已删除');
|
||||
});
|
||||
|
||||
/** GET /api/database/export - 下载当前库的 JSON 快照 */
|
||||
router.get('/export', async (_req, res) => {
|
||||
const json = await databaseService.exportSnapshotJson();
|
||||
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.status(200).send(json);
|
||||
});
|
||||
|
||||
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
|
||||
router.get('/sqlite-file', (_req, res) => {
|
||||
const filePath = databaseService.sqliteFilePathForDownload();
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
|
||||
res.status(200).send(fs.readFileSync(filePath));
|
||||
});
|
||||
|
||||
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
|
||||
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
|
||||
const { data } = validatedBody<ImportSnapshotInput>(req);
|
||||
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)');
|
||||
});
|
||||
|
||||
/** GET /api/database/backup-schedule - 定时备份配置 */
|
||||
router.get('/backup-schedule', (_req, res) => {
|
||||
sendOk(res, databaseService.getBackupSchedule());
|
||||
});
|
||||
|
||||
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
|
||||
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
|
||||
sendOk(
|
||||
res,
|
||||
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
|
||||
'定时备份配置已保存',
|
||||
);
|
||||
});
|
||||
|
||||
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
|
||||
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
|
||||
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req));
|
||||
sendCreated(res, { jobId: job.id }, '切换任务已启动');
|
||||
});
|
||||
|
||||
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
|
||||
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const job = databaseService.getSwitchJob(id);
|
||||
sendOk(res, job, job ? 'ok' : '任务不存在');
|
||||
});
|
||||
|
||||
export const databaseModule = defineModule({
|
||||
name: 'database',
|
||||
basePath: '/database',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** 本项目支持的主数据库类型。Redis 等键值库不是 Prisma 支持的主库,不提供切换。 */
|
||||
export const databaseProviderSchema = z.enum(['sqlite', 'mysql']);
|
||||
export type DatabaseProviderInput = z.infer<typeof databaseProviderSchema>;
|
||||
|
||||
/** 连接串与 provider 配套校验:SQLite 必须 file: 前缀,MySQL 必须 mysql:// 前缀 */
|
||||
export const targetConnectionSchema = z
|
||||
.object({
|
||||
provider: databaseProviderSchema,
|
||||
url: z.string().min(1, '请填写连接串'),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const url = value.url.trim();
|
||||
if (value.provider === 'sqlite' && !url.startsWith('file:')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['url'],
|
||||
message: 'SQLite 连接串必须以 file: 开头,例如 file:./data/classhelper.db',
|
||||
});
|
||||
}
|
||||
if (value.provider === 'mysql' && !/^mysqls?:\/\//.test(url)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['url'],
|
||||
message: 'MySQL 连接串必须以 mysql:// 或 mysqls:// 开头',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type TargetConnectionInput = z.infer<typeof targetConnectionSchema>;
|
||||
|
||||
/** POST /database/test-connection:测试任意目标库的连通性 */
|
||||
export const testConnectionSchema = targetConnectionSchema;
|
||||
export type TestConnectionInput = z.infer<typeof testConnectionSchema>;
|
||||
|
||||
/** POST /database/switch:一键切换数据库(含数据迁移) */
|
||||
export const switchDatabaseSchema = targetConnectionSchema;
|
||||
export type SwitchDatabaseInput = z.infer<typeof switchDatabaseSchema>;
|
||||
|
||||
/** POST /database/import:上传快照(base64,随 JSON 请求体,受 12MB 请求体限制) */
|
||||
export const importSnapshotSchema = z.object({
|
||||
data: z.string().min(1, '请提供快照内容(base64)'),
|
||||
});
|
||||
export type ImportSnapshotInput = z.infer<typeof importSnapshotSchema>;
|
||||
|
||||
/** PUT /database/backup-schedule:定时备份配置 */
|
||||
export const backupScheduleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
/** 间隔小时数(1~720,默认 24 = 每天一次) */
|
||||
intervalHours: z.number().int().min(1).max(720),
|
||||
/** 最多保留多少份自动备份(超出后删最旧的;手动备份不受影响) */
|
||||
keepCount: z.number().int().min(1).max(100),
|
||||
});
|
||||
export type BackupScheduleInput = z.infer<typeof backupScheduleSchema>;
|
||||
|
||||
/** 路径参数:备份文件名(必须是本服务生成的固定格式,杜绝路径穿越) */
|
||||
export const backupNameParamSchema = z.object({
|
||||
name: z.string().regex(/^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/, '备份文件名不合法'),
|
||||
});
|
||||
|
||||
/** 路径参数:切换任务 id(本服务生成,字母数字与连字符) */
|
||||
export const jobIdParamSchema = z.object({
|
||||
id: z.string().regex(/^[0-9a-z-]{6,40}$/, '任务 id 不合法'),
|
||||
});
|
||||
@@ -0,0 +1,771 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { env } from '../../config/env.js';
|
||||
import { createPrismaClient, prisma, type DatabaseProvider } from '../../lib/db.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import {
|
||||
assertValidSnapshot,
|
||||
exportSnapshot,
|
||||
restoreSnapshot,
|
||||
SNAPSHOT_TABLES,
|
||||
type DatabaseSnapshot,
|
||||
} from '../../lib/snapshot.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type {
|
||||
BackupScheduleInput,
|
||||
TargetConnectionInput,
|
||||
} from './database.schemas.js';
|
||||
|
||||
/**
|
||||
* 数据库管理服务:状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
|
||||
*
|
||||
* 所有实现都基于 `lib/snapshot.ts` 的 JSON 快照:与 provider 无关,
|
||||
* 因此备份可以恢复回任意一种受支持的数据库(备份即迁移的载体)。
|
||||
*/
|
||||
|
||||
/* ================================================================ 路径 */
|
||||
|
||||
/** 数据目录:SQLite = 数据文件所在目录;MySQL = 安装根的 data 目录(与部署约定一致) */
|
||||
function dataDir(): string {
|
||||
if (env.databaseProvider === 'sqlite' && env.sqliteFilePath) {
|
||||
return path.dirname(env.sqliteFilePath);
|
||||
}
|
||||
return path.join(env.serverRoot, '..', 'data');
|
||||
}
|
||||
|
||||
function backupDir(): string {
|
||||
return path.join(dataDir(), 'backups');
|
||||
}
|
||||
|
||||
function settingsFilePath(): string {
|
||||
return path.join(dataDir(), 'database-settings.json');
|
||||
}
|
||||
|
||||
function ensureBackupDir(): string {
|
||||
const dir = backupDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/* ================================================================ 定时备份配置 */
|
||||
|
||||
interface BackupSchedule {
|
||||
enabled: boolean;
|
||||
intervalHours: number;
|
||||
keepCount: number;
|
||||
lastAutoBackupAt: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_SCHEDULE: BackupSchedule = {
|
||||
enabled: false,
|
||||
intervalHours: 24,
|
||||
keepCount: 7,
|
||||
lastAutoBackupAt: null,
|
||||
};
|
||||
|
||||
function readSchedule(): BackupSchedule {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(settingsFilePath(), 'utf8')) as Partial<BackupSchedule>;
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
intervalHours:
|
||||
typeof raw.intervalHours === 'number' && raw.intervalHours >= 1 ? Math.floor(raw.intervalHours) : DEFAULT_SCHEDULE.intervalHours,
|
||||
keepCount: typeof raw.keepCount === 'number' && raw.keepCount >= 1 ? Math.floor(raw.keepCount) : DEFAULT_SCHEDULE.keepCount,
|
||||
lastAutoBackupAt: typeof raw.lastAutoBackupAt === 'string' ? raw.lastAutoBackupAt : null,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SCHEDULE };
|
||||
}
|
||||
}
|
||||
|
||||
function writeSchedule(schedule: BackupSchedule): void {
|
||||
ensureBackupDir();
|
||||
fs.writeFileSync(settingsFilePath(), `${JSON.stringify(schedule, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
/** 读取定时备份配置(GET /backup-schedule) */
|
||||
export function getBackupSchedule(): BackupSchedule {
|
||||
return readSchedule();
|
||||
}
|
||||
|
||||
/** 更新定时备份配置(PUT /backup-schedule),写盘后立即生效 */
|
||||
export function saveSchedulePatch(patch: BackupScheduleInput): BackupSchedule {
|
||||
const next: BackupSchedule = { ...readSchedule(), ...patch };
|
||||
writeSchedule(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
let schedulerTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* 定时备份调度:每 10 分钟检查一次,到点自动备份并清理旧备份。
|
||||
* 依赖服务端进程常驻(进程不运行不会补跑);lastAutoBackupAt 持久化在配置文件里,
|
||||
* 重启后不会立刻重复备份。
|
||||
*/
|
||||
export function startBackupScheduler(): void {
|
||||
if (schedulerTimer) return;
|
||||
const tick = async (): Promise<void> => {
|
||||
if (isSwitchRunning()) return;
|
||||
const schedule = readSchedule();
|
||||
if (!schedule.enabled) return;
|
||||
const last = schedule.lastAutoBackupAt ? Date.parse(schedule.lastAutoBackupAt) : 0;
|
||||
const dueAt = (Number.isFinite(last) ? last : 0) + schedule.intervalHours * 3_600_000;
|
||||
if (Date.now() < dueAt) return;
|
||||
try {
|
||||
await createBackup('auto');
|
||||
writeSchedule({ ...schedule, lastAutoBackupAt: new Date().toISOString() });
|
||||
logger.info('定时备份完成');
|
||||
} catch (error) {
|
||||
logger.error('定时备份失败', error);
|
||||
}
|
||||
};
|
||||
schedulerTimer = setInterval(() => void tick(), 10 * 60_000);
|
||||
schedulerTimer.unref();
|
||||
logger.info('定时备份调度已启动(每 10 分钟检查一次到点任务)');
|
||||
}
|
||||
|
||||
/* ================================================================ 备份 / 恢复 */
|
||||
|
||||
export interface BackupMeta {
|
||||
name: string;
|
||||
kind: 'manual' | 'auto';
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function isBackupName(name: string): boolean {
|
||||
return /^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/.test(name);
|
||||
}
|
||||
|
||||
function backupPath(name: string): string {
|
||||
if (!isBackupName(name)) {
|
||||
throw ApiError.badRequest('备份文件名不合法');
|
||||
}
|
||||
const target = path.join(backupDir(), name);
|
||||
if (path.dirname(target) !== backupDir()) {
|
||||
throw ApiError.badRequest('备份文件名不合法');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function timestampSlug(): string {
|
||||
const now = new Date();
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
return (
|
||||
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
||||
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建一份备份(gzip 压缩的 JSON 快照)。kind 决定文件名前缀与自动清理策略。 */
|
||||
export async function createBackup(kind: 'manual' | 'auto'): Promise<BackupMeta> {
|
||||
assertNotSwitching();
|
||||
const dir = ensureBackupDir();
|
||||
const snapshot = await exportSnapshot(prisma, env.databaseProvider);
|
||||
const payload = zlib.gzipSync(Buffer.from(JSON.stringify(snapshot), 'utf8'));
|
||||
const name = `backup-${kind}-${timestampSlug()}.json.gz`;
|
||||
fs.writeFileSync(path.join(dir, name), payload);
|
||||
if (kind === 'auto') pruneAutoBackups();
|
||||
const meta: BackupMeta = { name, kind, sizeBytes: payload.length, createdAt: snapshot.createdAt };
|
||||
logger.info(`已创建${kind === 'auto' ? '定时' : '手动'}备份:${name}`);
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** 自动备份按 keepCount 清理最旧的(手动备份不受影响) */
|
||||
function pruneAutoBackups(): void {
|
||||
const schedule = readSchedule();
|
||||
const autos = listBackups()
|
||||
.filter((item) => item.kind === 'auto')
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
for (const stale of autos.slice(0, Math.max(0, autos.length - schedule.keepCount))) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(backupDir(), stale.name));
|
||||
} catch {
|
||||
// 清理失败不影响本次备份
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listBackups(): BackupMeta[] {
|
||||
const dir = backupDir();
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter(isBackupName)
|
||||
.map((name) => {
|
||||
const stat = fs.statSync(path.join(dir, name));
|
||||
return {
|
||||
name,
|
||||
kind: name.includes('-manual-') ? ('manual' as const) : ('auto' as const),
|
||||
sizeBytes: stat.size,
|
||||
createdAt: stat.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
function readBackupSnapshot(name: string): DatabaseSnapshot {
|
||||
const file = backupPath(name);
|
||||
if (!fs.existsSync(file)) {
|
||||
throw ApiError.notFound('备份不存在(可能已被删除)');
|
||||
}
|
||||
const snapshot = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')) as DatabaseSnapshot;
|
||||
assertValidSnapshot(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** 从备份恢复(整库覆盖:先清空现有数据再写入备份内容) */
|
||||
export async function restoreBackup(name: string): Promise<{ counts: Record<string, number> }> {
|
||||
assertNotSwitching();
|
||||
const snapshot = readBackupSnapshot(name);
|
||||
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
|
||||
}
|
||||
|
||||
export function deleteBackup(name: string): void {
|
||||
const file = backupPath(name);
|
||||
if (!fs.existsSync(file)) {
|
||||
throw ApiError.notFound('备份不存在(可能已被删除)');
|
||||
}
|
||||
fs.unlinkSync(file);
|
||||
}
|
||||
|
||||
/* ================================================================ 导入 / 导出 */
|
||||
|
||||
/** 导出当前库的快照 JSON(未压缩,供下载与跨库迁移使用) */
|
||||
export async function exportSnapshotJson(): Promise<string> {
|
||||
assertNotSwitching();
|
||||
return JSON.stringify(await exportSnapshot(prisma, env.databaseProvider));
|
||||
}
|
||||
|
||||
/** 从 base64 快照导入(整库覆盖)。用于"快捷导入"。 */
|
||||
export async function importSnapshotBase64(data: string): Promise<{ counts: Record<string, number> }> {
|
||||
assertNotSwitching();
|
||||
let snapshot: DatabaseSnapshot;
|
||||
try {
|
||||
const json = Buffer.from(data, 'base64').toString('utf8');
|
||||
snapshot = JSON.parse(json) as DatabaseSnapshot;
|
||||
} catch {
|
||||
throw ApiError.badRequest('快照内容不是合法的 base64 JSON');
|
||||
}
|
||||
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
|
||||
}
|
||||
|
||||
/** SQLite 数据文件下载(仅 SQLite 模式提供;MySQL 无"单文件"概念) */
|
||||
export function sqliteFilePathForDownload(): string {
|
||||
if (env.databaseProvider !== 'sqlite' || !env.sqliteFilePath || !fs.existsSync(env.sqliteFilePath)) {
|
||||
throw ApiError.badRequest('当前不是 SQLite 数据库或数据文件不存在');
|
||||
}
|
||||
return env.sqliteFilePath;
|
||||
}
|
||||
|
||||
/* ================================================================ 状态 / 连接测试 */
|
||||
|
||||
export interface TableCount {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
async function databaseVersion(provider: DatabaseProvider, client: typeof prisma): Promise<string> {
|
||||
if (provider === 'mysql') {
|
||||
const rows = (await client.$queryRawUnsafe('SELECT VERSION() AS version')) as { version?: string }[];
|
||||
return rows[0]?.version ?? 'unknown';
|
||||
}
|
||||
const rows = (await client.$queryRawUnsafe('SELECT sqlite_version() AS version')) as {
|
||||
version?: string;
|
||||
}[];
|
||||
return rows[0]?.version ?? 'unknown';
|
||||
}
|
||||
|
||||
async function databaseSizeBytes(provider: DatabaseProvider, client: typeof prisma): Promise<number | null> {
|
||||
if (provider === 'sqlite') {
|
||||
return env.sqliteFilePath && fs.existsSync(env.sqliteFilePath) ? fs.statSync(env.sqliteFilePath).size : 0;
|
||||
}
|
||||
const rows = (await client.$queryRawUnsafe(
|
||||
'SELECT COALESCE(SUM(data_length + index_length), 0) AS size FROM information_schema.tables WHERE table_schema = DATABASE()',
|
||||
)) as { size?: bigint | number }[];
|
||||
const size = rows[0]?.size;
|
||||
return size === undefined ? null : Number(size);
|
||||
}
|
||||
|
||||
export interface DatabaseStatus {
|
||||
provider: DatabaseProvider;
|
||||
connected: boolean;
|
||||
latencyMs: number | null;
|
||||
version: string | null;
|
||||
sizeBytes: number | null;
|
||||
/** 当前连接串的脱敏展示(MySQL 隐藏密码) */
|
||||
databaseUrlMasked: string;
|
||||
/** SQLite 数据文件绝对路径(MySQL 为空) */
|
||||
sqliteFilePath: string;
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
tables: TableCount[];
|
||||
backups: ReturnType<typeof listBackups>;
|
||||
schedule: BackupSchedule;
|
||||
/** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */
|
||||
switchRunning: boolean;
|
||||
}
|
||||
|
||||
/** 连接串脱敏:mysql://user:password@host → mysql://user:***@host */
|
||||
function maskDatabaseUrl(provider: DatabaseProvider, url: string): string {
|
||||
if (provider !== 'mysql') return url;
|
||||
return url.replace(/(\/\/[^:/@]+:)[^@]*(@)/, '$1***$2');
|
||||
}
|
||||
|
||||
export async function getDatabaseStatus(): Promise<DatabaseStatus> {
|
||||
const startedAt = Date.now();
|
||||
let connected = false;
|
||||
let version: string | null = null;
|
||||
try {
|
||||
version = await databaseVersion(env.databaseProvider, prisma);
|
||||
connected = true;
|
||||
} catch {
|
||||
connected = false;
|
||||
}
|
||||
const latencyMs = connected ? Date.now() - startedAt : null;
|
||||
const delegates = prisma as unknown as Record<
|
||||
(typeof SNAPSHOT_TABLES)[number],
|
||||
{ count(): Promise<number> }
|
||||
>;
|
||||
const tables: TableCount[] = connected
|
||||
? await Promise.all(
|
||||
SNAPSHOT_TABLES.map(async (name): Promise<TableCount> => {
|
||||
try {
|
||||
return { name, count: await delegates[name].count() };
|
||||
} catch {
|
||||
return { name, count: -1 };
|
||||
}
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
provider: env.databaseProvider,
|
||||
connected,
|
||||
latencyMs,
|
||||
version,
|
||||
sizeBytes: connected ? await databaseSizeBytes(env.databaseProvider, prisma).catch(() => null) : null,
|
||||
databaseUrlMasked: maskDatabaseUrl(env.databaseProvider, env.databaseUrl),
|
||||
sqliteFilePath: env.sqliteFilePath,
|
||||
dataDir: dataDir(),
|
||||
backupDir: backupDir(),
|
||||
tables,
|
||||
backups: listBackups(),
|
||||
schedule: readSchedule(),
|
||||
switchRunning: isSwitchRunning(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
ok: boolean;
|
||||
provider: DatabaseProvider;
|
||||
latencyMs: number | null;
|
||||
version: string | null;
|
||||
/** 新建 SQLite 库时目标文件还不存在,属于正常情况(切换时会自动建库) */
|
||||
note?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 测试任意目标库的连通性(发起切换前的第一步) */
|
||||
export async function testConnection(target: TargetConnectionInput): Promise<ConnectionTestResult> {
|
||||
const startedAt = Date.now();
|
||||
let note: string | undefined;
|
||||
let url = target.url.trim();
|
||||
if (target.provider === 'sqlite') {
|
||||
// 与 env.resolveSqliteUrl 同口径:相对路径以 serverRoot 为基准解析为绝对路径
|
||||
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
|
||||
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
const parent = path.dirname(absolute);
|
||||
if (!fs.existsSync(parent)) {
|
||||
return { ok: false, provider: target.provider, latencyMs: null, version: null, error: `目录不存在:${parent}` };
|
||||
}
|
||||
note = '目标文件不存在,属新库(切换时会自动建表)';
|
||||
}
|
||||
url = `file:${absolute.replace(/\\/g, '/')}`;
|
||||
}
|
||||
let client: Awaited<ReturnType<typeof createPrismaClient>> | null = null;
|
||||
try {
|
||||
client = await createPrismaClient(target.provider, url);
|
||||
const version = await databaseVersion(target.provider, client);
|
||||
return { ok: true, provider: target.provider, latencyMs: Date.now() - startedAt, version, note };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
provider: target.provider,
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
await client?.$disconnect().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================ 一键切换 */
|
||||
|
||||
export interface SwitchStep {
|
||||
name: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface SwitchJob {
|
||||
id: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
target: { provider: DatabaseProvider; url: string };
|
||||
steps: SwitchStep[];
|
||||
error?: string;
|
||||
/** 完成后必填:需要重启服务端才能让新数据库生效 */
|
||||
restartRequired: boolean;
|
||||
counts?: Record<string, number>;
|
||||
backupName?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
const switchJobs = new Map<string, SwitchJob>();
|
||||
|
||||
function isSwitchRunning(): boolean {
|
||||
for (const job of switchJobs.values()) {
|
||||
if (job.status === 'running') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function assertNotSwitching(): void {
|
||||
if (isSwitchRunning()) {
|
||||
throw ApiError.conflict('正在执行数据库切换,请等待完成后再进行备份/导入/恢复');
|
||||
}
|
||||
}
|
||||
|
||||
/** 防止路径穿越:任务 id 只由本服务生成(时间戳 + 随机数) */
|
||||
function newJobId(): string {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function getSwitchJob(id: string): SwitchJob | null {
|
||||
return switchJobs.get(id) ?? null;
|
||||
}
|
||||
|
||||
function stepOf(job: SwitchJob, name: string): SwitchStep {
|
||||
let step = job.steps.find((item) => item.name === name);
|
||||
if (!step) {
|
||||
step = { name, status: 'running' };
|
||||
job.steps.push(step);
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
function finishStep(job: SwitchJob, name: string, detail?: string): void {
|
||||
const step = stepOf(job, name);
|
||||
step.status = 'done';
|
||||
if (detail) step.detail = detail;
|
||||
}
|
||||
|
||||
function appendDetail(job: SwitchJob, name: string, detail: string): void {
|
||||
const step = stepOf(job, name);
|
||||
step.detail = `${step.detail ? `${step.detail}\n` : ''}${detail}`.slice(-4000);
|
||||
}
|
||||
|
||||
/** 校验目标库必须是空库(防误覆盖):有表即拒绝 */
|
||||
async function assertTargetEmpty(target: TargetConnectionInput): Promise<string> {
|
||||
let url = target.url.trim();
|
||||
let sqliteAbsolute: string | null = null;
|
||||
if (target.provider === 'sqlite') {
|
||||
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
|
||||
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
|
||||
sqliteAbsolute = absolute;
|
||||
url = `file:${absolute.replace(/\\/g, '/')}`;
|
||||
}
|
||||
const client = await createPrismaClient(target.provider, url);
|
||||
try {
|
||||
const rows =
|
||||
target.provider === 'sqlite'
|
||||
? (await client.$queryRawUnsafe(
|
||||
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||
)) as { count?: bigint | number }[]
|
||||
: (await client.$queryRawUnsafe(
|
||||
'SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = DATABASE()',
|
||||
)) as { count?: bigint | number }[];
|
||||
const tableCount = Number(rows[0]?.count ?? 0);
|
||||
if (tableCount > 0) {
|
||||
throw ApiError.badRequest(
|
||||
`目标数据库不是空库(已有 ${tableCount} 张表)。为防误覆盖,只允许迁移到空库;` +
|
||||
'如需重用已有库,请先备份并清空它。',
|
||||
);
|
||||
}
|
||||
return target.provider === 'sqlite' ? `目标文件:${sqliteAbsolute}` : '目标 MySQL 库为空';
|
||||
} finally {
|
||||
await client.$disconnect().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const SCHEMA_FILE = path.join(env.serverRoot, 'prisma', 'schema.prisma');
|
||||
|
||||
function rewriteSchemaProvider(provider: DatabaseProvider): string {
|
||||
const original = fs.readFileSync(SCHEMA_FILE, 'utf8');
|
||||
const updated = original.replace(/provider\s*=\s*"(sqlite|mysql)"/, `provider = "${provider}"`);
|
||||
if (updated === original) {
|
||||
throw new Error('未能改写 schema.prisma 的 provider(未匹配到 provider 声明)');
|
||||
}
|
||||
fs.writeFileSync(SCHEMA_FILE, updated, 'utf8');
|
||||
return original;
|
||||
}
|
||||
|
||||
/** 子进程运行 Prisma CLI(generate / db push)。连接串通过环境变量显式传入,不依赖 .env。 */
|
||||
function runPrismaCli(
|
||||
job: SwitchJob,
|
||||
stepName: string,
|
||||
args: string[],
|
||||
target: TargetConnectionInput,
|
||||
): Promise<void> {
|
||||
const candidates = [
|
||||
path.join(env.serverRoot, 'node_modules', 'prisma', 'build', 'index.js'),
|
||||
path.join(env.serverRoot, '..', 'node_modules', 'prisma', 'build', 'index.js'),
|
||||
];
|
||||
const cli = candidates.find((file) => fs.existsSync(file));
|
||||
if (!cli) {
|
||||
throw new Error('未找到 Prisma CLI(node_modules/prisma)。安装版请确认安装包完整。');
|
||||
}
|
||||
return runNodeTool(job, stepName, [cli, ...args], {
|
||||
...process.env,
|
||||
DATABASE_PROVIDER: target.provider,
|
||||
DATABASE_URL: target.url.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 编译新生成的 Prisma 客户端(仅生产打包形态需要:dist 里跑的是编译产物) */
|
||||
async function compileGeneratedClient(job: SwitchJob): Promise<void> {
|
||||
// 开发模式(tsx 直接跑 src)不需要编译;打包形态(存在 dist/index.js)才需要
|
||||
if (!fs.existsSync(path.join(env.serverRoot, 'dist', 'index.js'))) return;
|
||||
const candidates = [
|
||||
path.join(env.serverRoot, 'node_modules', 'typescript', 'lib', 'tsc.js'),
|
||||
path.join(env.serverRoot, '..', 'node_modules', 'typescript', 'lib', 'tsc.js'),
|
||||
];
|
||||
const tsc = candidates.find((file) => fs.existsSync(file));
|
||||
if (!tsc) {
|
||||
throw new Error('未找到 TypeScript 编译器(node_modules/typescript)。安装包请确认完整。');
|
||||
}
|
||||
await runNodeTool(job, '编译新生成的 Prisma 客户端', [tsc, '-p', 'tsconfig.generate.json'], process.env);
|
||||
}
|
||||
|
||||
/** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */
|
||||
function runApplySnapshotTool(job: SwitchJob, target: TargetConnectionInput, snapshotFile: string): Promise<Record<string, number>> {
|
||||
const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js');
|
||||
const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts');
|
||||
const tsxCli = path.join(env.serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||
let args: string[];
|
||||
if (fs.existsSync(distTool)) {
|
||||
args = [distTool];
|
||||
} else if (fs.existsSync(tsxCli) && fs.existsSync(srcTool)) {
|
||||
args = [tsxCli, srcTool];
|
||||
} else {
|
||||
throw new Error('未找到快照导入工具(dist/tools/apply-snapshot.js),请先构建服务端');
|
||||
}
|
||||
args.push('--provider', target.provider, '--url', target.url.trim(), '--file', snapshotFile);
|
||||
const capture: { text: string } = { text: '' };
|
||||
return runNodeTool(job, '迁移数据到目标库', args, process.env, capture).then(() => {
|
||||
const lines = capture.text.trim().split(/\r?\n/).filter(Boolean);
|
||||
const last = lines[lines.length - 1] ?? '';
|
||||
try {
|
||||
const parsed = JSON.parse(last) as { ok?: boolean; counts?: Record<string, number>; error?: string };
|
||||
if (!parsed.ok) throw new Error(parsed.error ?? '快照导入工具返回失败');
|
||||
return parsed.counts ?? {};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`无法解析快照导入工具的输出:${error instanceof Error ? error.message : String(error)}(输出尾部:${last.slice(0, 400)})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function runNodeTool(
|
||||
job: SwitchJob,
|
||||
stepName: string,
|
||||
args: string[],
|
||||
nodeEnv: NodeJS.ProcessEnv,
|
||||
capture?: { text: string },
|
||||
timeoutMs = 10 * 60_000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: env.serverRoot,
|
||||
env: nodeEnv,
|
||||
windowsHide: true,
|
||||
});
|
||||
let output = '';
|
||||
const collect = (chunk: Buffer | string): void => {
|
||||
output += String(chunk);
|
||||
if (output.length > 20_000) output = output.slice(-20_000);
|
||||
if (capture) capture.text = output;
|
||||
};
|
||||
child.stdout?.on('data', collect);
|
||||
child.stderr?.on('data', collect);
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`${stepName} 超时(${Math.round(timeoutMs / 60_000)} 分钟)`));
|
||||
}, timeoutMs);
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`${stepName} 启动失败:${error.message}`));
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
appendDetail(job, stepName, output.trim().split(/\r?\n/).slice(-6).join('\n'));
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const tail = output.trim().split(/\r?\n/).slice(-10).join('\n');
|
||||
reject(new Error(`${stepName} 失败(exit ${code ?? 'signal'}):${tail || '无输出'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 切换完成后改写 .env(DATABASE_PROVIDER / DATABASE_URL 两行) */
|
||||
function updateEnvFiles(target: TargetConnectionInput): string {
|
||||
const candidates = [path.join(env.serverRoot, '.env'), path.join(env.serverRoot, '..', '.env')];
|
||||
const existing = candidates.filter((file) => fs.existsSync(file));
|
||||
const files = existing.length > 0 ? existing : [candidates[0]];
|
||||
const updated: string[] = [];
|
||||
for (const file of files) {
|
||||
let content = fs.readFileSync(file, 'utf8');
|
||||
const providerLine = `DATABASE_PROVIDER=${target.provider}`;
|
||||
const urlLine = `DATABASE_URL="${target.url.trim()}"`;
|
||||
if (/^DATABASE_PROVIDER=.*$/m.test(content)) {
|
||||
content = content.replace(/^DATABASE_PROVIDER=.*$/m, providerLine);
|
||||
} else {
|
||||
content = `${content.trimEnd()}\n${providerLine}\n`;
|
||||
}
|
||||
if (/^DATABASE_URL=.*$/m.test(content)) {
|
||||
content = content.replace(/^DATABASE_URL=.*$/m, urlLine);
|
||||
} else {
|
||||
content = `${content.trimEnd()}\n${urlLine}\n`;
|
||||
}
|
||||
fs.writeFileSync(file, content, 'utf8');
|
||||
updated.push(file);
|
||||
}
|
||||
return updated.join(' , ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起一键切换(异步任务)。流程与失败语义见模块注释:
|
||||
* 数据写入全部成功后才改写 .env;中途失败回滚 schema.prisma,现有库不受影响。
|
||||
*/
|
||||
export function startSwitch(target: TargetConnectionInput): SwitchJob {
|
||||
if (isSwitchRunning()) {
|
||||
throw ApiError.conflict('已有切换任务正在进行,请等待完成');
|
||||
}
|
||||
if (target.provider === env.databaseProvider && target.url.trim() === env.databaseUrl) {
|
||||
throw ApiError.badRequest('目标数据库与当前使用的数据库相同');
|
||||
}
|
||||
const job: SwitchJob = {
|
||||
id: newJobId(),
|
||||
status: 'running',
|
||||
target: { provider: target.provider, url: target.url.trim() },
|
||||
steps: [],
|
||||
restartRequired: false,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
switchJobs.set(job.id, job);
|
||||
// 不 await:HTTP 立即返回 jobId,前端轮询 GET /switch/jobs/:id
|
||||
void runSwitch(job, target).catch(() => undefined);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise<void> {
|
||||
let schemaOriginal: string | null = null;
|
||||
let snapshotFile: string | null = null;
|
||||
try {
|
||||
// 1. 先备份当前库(切换失败时的兜底)
|
||||
stepOf(job, '备份当前数据库');
|
||||
const backup = await createBackup('manual');
|
||||
job.backupName = backup.name;
|
||||
finishStep(job, '备份当前数据库', `备份文件 ${backup.name}`);
|
||||
|
||||
// 2. 导出快照到临时文件(供子进程写入目标库)
|
||||
stepOf(job, '导出当前数据');
|
||||
fs.mkdirSync(backupDir(), { recursive: true });
|
||||
snapshotFile = path.join(backupDir(), `switch-${job.id}.json`);
|
||||
fs.writeFileSync(snapshotFile, await exportSnapshotJson(), 'utf8');
|
||||
finishStep(job, '导出当前数据');
|
||||
|
||||
// 3. 测试目标连接
|
||||
stepOf(job, '测试目标库连接');
|
||||
const test = await testConnection(target);
|
||||
if (!test.ok) {
|
||||
throw new Error(test.error ?? '目标库连接失败');
|
||||
}
|
||||
finishStep(job, '测试目标库连接', test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`);
|
||||
|
||||
// 4. 目标库必须为空
|
||||
stepOf(job, '检查目标库为空');
|
||||
finishStep(job, '检查目标库为空', await assertTargetEmpty(target));
|
||||
|
||||
// 5. 改写 schema.prisma 的 provider(失败时回滚)
|
||||
stepOf(job, '改写 schema.prisma');
|
||||
schemaOriginal = rewriteSchemaProvider(target.provider);
|
||||
finishStep(job, '改写 schema.prisma', `provider → ${target.provider}`);
|
||||
|
||||
// 6. 生成新方言的 Prisma 客户端
|
||||
stepOf(job, '生成 Prisma 客户端');
|
||||
await runPrismaCli(job, '生成 Prisma 客户端', ['generate'], target);
|
||||
finishStep(job, '生成 Prisma 客户端');
|
||||
|
||||
// 7. 生产形态:把生成出来的 .ts 客户端编译进 dist(dev 由 tsx 直接跑 src,跳过)
|
||||
await compileGeneratedClient(job);
|
||||
|
||||
// 8. 目标库建表(Prisma 7 的 db push 不再自动生成客户端,generate 是独立步骤 6)
|
||||
stepOf(job, '在目标库建表');
|
||||
await runPrismaCli(job, '在目标库建表', ['db', 'push', '--accept-data-loss'], target);
|
||||
finishStep(job, '在目标库建表');
|
||||
|
||||
// 9. 迁移数据(独立子进程加载新方言客户端)
|
||||
stepOf(job, '迁移数据到目标库');
|
||||
const counts = await runApplySnapshotTool(job, target, snapshotFile);
|
||||
job.counts = counts;
|
||||
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
||||
finishStep(job, '迁移数据到目标库', `共写入 ${total} 行`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// 失败发生在哪一步,就把那一步标成 error(UI 上能直接看到卡在哪)
|
||||
const running = [...job.steps].reverse().find((item) => item.status === 'running');
|
||||
if (running) {
|
||||
running.status = 'error';
|
||||
running.detail = message.slice(0, 1000);
|
||||
}
|
||||
job.error = message;
|
||||
job.status = 'error';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
// 回滚 schema(.env 尚未改写,现有库不受影响)
|
||||
if (schemaOriginal !== null) {
|
||||
try {
|
||||
fs.writeFileSync(SCHEMA_FILE, schemaOriginal, 'utf8');
|
||||
} catch (rollbackError) {
|
||||
logger.error('回滚 schema.prisma 失败,请手工核对 provider 设置', rollbackError);
|
||||
}
|
||||
}
|
||||
logger.error(`数据库切换失败(job=${job.id}):${message}`);
|
||||
return;
|
||||
} finally {
|
||||
if (snapshotFile) fs.rmSync(snapshotFile, { force: true });
|
||||
}
|
||||
|
||||
// 10. 全部成功后才切换 .env(失败已在上面的 catch 里返回)
|
||||
try {
|
||||
stepOf(job, '写入 .env 配置');
|
||||
const files = updateEnvFiles(target);
|
||||
finishStep(job, '写入 .env 配置', files);
|
||||
job.status = 'done';
|
||||
job.restartRequired = true;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
logger.info(`数据库切换任务完成(job=${job.id})→ ${target.provider},重启服务端后生效`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
job.error = `数据已迁移到目标库,但写入 .env 失败:${message}(请手工修改 .env 的 DATABASE_PROVIDER / DATABASE_URL 后重启)`;
|
||||
job.status = 'error';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { authModule } from './auth/auth.module.js';
|
||||
import { callsModule } from './calls/calls.module.js';
|
||||
import { classesModule } from './classes/classes.module.js';
|
||||
import { coursesModule } from './courses/courses.module.js';
|
||||
import { databaseModule } from './database/database.module.js';
|
||||
import { dashboardModule } from './dashboard/dashboard.module.js';
|
||||
import { gradesModule } from './grades/grades.module.js';
|
||||
import { homeworksModule } from './homeworks/homeworks.module.js';
|
||||
@@ -11,6 +12,7 @@ import { notificationsModule } from './notifications/notifications.module.js';
|
||||
import { schedulesModule } from './schedules/schedules.module.js';
|
||||
import { studentsModule } from './students/students.module.js';
|
||||
import { teachersModule } from './teachers/teachers.module.js';
|
||||
import { updateModule } from './update/update.module.js';
|
||||
import type { ApiModule } from './module.types.js';
|
||||
|
||||
/**
|
||||
@@ -34,4 +36,6 @@ export const apiModules: ApiModule[] = [
|
||||
studentsModule,
|
||||
teachersModule,
|
||||
dashboardModule,
|
||||
databaseModule,
|
||||
updateModule,
|
||||
].filter((module) => module.enabled !== false);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser } from '../../middleware/auth.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as updateService from './update.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* GET /api/update/check?force=1 - 检查 GitHub 上有没有比本机新的版本
|
||||
*
|
||||
* 为什么 Web 管理端不直连 GitHub:浏览器的请求受服务端下发的 CSP 约束,
|
||||
* `connect-src` 只放行 `'self'` 与 WebSocket(见 `middleware/security.ts`),
|
||||
* 直连 `api.github.com` 会被浏览器拦掉。走服务端转发还顺带让所有管理员共享同一份缓存。
|
||||
*
|
||||
* 桌面客户端不受这条约束(它由主进程直连),但两边拿到的 `UpdateInfo` 结构是一致的。
|
||||
*
|
||||
* 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存,
|
||||
* 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。
|
||||
*/
|
||||
router.get('/check', async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const force = req.query.force === '1' && user.role === 'ADMIN';
|
||||
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
|
||||
});
|
||||
|
||||
export const updateModule = defineModule({
|
||||
name: 'update',
|
||||
basePath: '/update',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
GITHUB_RELEASES_LATEST_API,
|
||||
GITHUB_RELEASES_PAGE,
|
||||
UPDATE_CHECK_CACHE_TTL_MS,
|
||||
UPDATE_CHECK_TIMEOUT_MS,
|
||||
UPDATE_CHECK_USER_AGENT,
|
||||
isNewerVersion,
|
||||
normalizeVersion,
|
||||
type UpdateAssetDto,
|
||||
type UpdateInfo,
|
||||
} from '@classhelper/shared';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { APP_VERSION } from '../../lib/version.js';
|
||||
|
||||
/**
|
||||
* 更新检查:问 GitHub 上最新的 Release 是不是比本机新。
|
||||
*
|
||||
* 三个设计约束:
|
||||
* 1. **失败是常态,不是异常**:学校的服务器常常在只有内网的机房网络里,
|
||||
* 因此离线 / 限流 / 超时一律收敛成 `ok:false` + 一句人话,绝不抛给路由层(否则管理员看到的会是 500)。
|
||||
* 2. **必须带 User-Agent**:GitHub API 对没有 UA 的请求直接 403。
|
||||
* 3. **必须有缓存**:匿名调用限流是 60 次/小时/IP,而 Web 管理端每次打开弹窗都会问一次。
|
||||
*
|
||||
* 这里用 Node 内置的 `fetch`(Node 20+ / Electron 同源),不引入任何 HTTP 依赖 ——
|
||||
* 这也是本服务端**唯一**的出网请求。
|
||||
*/
|
||||
|
||||
/** GitHub Releases API 里我们真正用到的字段(其余忽略,避免把整个响应建模一遍) */
|
||||
interface GithubReleasePayload {
|
||||
tag_name?: unknown;
|
||||
name?: unknown;
|
||||
published_at?: unknown;
|
||||
body?: unknown;
|
||||
html_url?: unknown;
|
||||
assets?: unknown;
|
||||
}
|
||||
|
||||
let cache: { at: number; info: UpdateInfo } | null = null;
|
||||
|
||||
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
|
||||
function failure(error: string): UpdateInfo {
|
||||
return {
|
||||
ok: false,
|
||||
error,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: null,
|
||||
hasUpdate: false,
|
||||
releaseUrl: GITHUB_RELEASES_PAGE,
|
||||
releaseName: null,
|
||||
publishedAt: null,
|
||||
notes: null,
|
||||
assets: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toAssets(raw: unknown): UpdateAssetDto[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
|
||||
.filter(
|
||||
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
|
||||
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
downloadUrl: item.browser_download_url,
|
||||
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function toInfo(payload: GithubReleasePayload): UpdateInfo {
|
||||
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
|
||||
// 拿不到版本号就当"查不到",不要伪造一个 latestVersion 出来
|
||||
if (!latestVersion) {
|
||||
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
|
||||
return failure('GitHub 返回的版本号无法识别');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion,
|
||||
hasUpdate: isNewerVersion(APP_VERSION, latestVersion),
|
||||
// 优先用 Release 自己的 html_url,拿不到才回落到列表页
|
||||
releaseUrl:
|
||||
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
|
||||
releaseName: typeof payload.name === 'string' ? payload.name : null,
|
||||
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
|
||||
notes: typeof payload.body === 'string' ? payload.body : null,
|
||||
assets: toAssets(payload.assets),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 把各种失败翻译成一句管理员能看懂的话 */
|
||||
function describeFailure(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
|
||||
}
|
||||
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate/i.test(message)) {
|
||||
return '无法连接 GitHub,本机可能没有外网';
|
||||
}
|
||||
return `检查更新失败:${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查更新(结果带缓存)。
|
||||
*
|
||||
* @param options.force 绕过缓存(由路由层限定为管理员才能生效)
|
||||
*/
|
||||
export async function checkForUpdates(options: { force?: boolean } = {}): Promise<UpdateInfo> {
|
||||
const now = Date.now();
|
||||
if (!options.force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
|
||||
return cache.info;
|
||||
}
|
||||
|
||||
let info: UpdateInfo;
|
||||
try {
|
||||
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
|
||||
headers: {
|
||||
'User-Agent': UPDATE_CHECK_USER_AGENT,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 403 / 429 基本都是匿名限流(60 次/小时/IP)—— 给出可操作的提示而不是原始状态码
|
||||
info =
|
||||
response.status === 403 || response.status === 429
|
||||
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
|
||||
: failure(`GitHub 返回 HTTP ${response.status}`);
|
||||
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${APP_VERSION})`);
|
||||
} else {
|
||||
info = toInfo((await response.json()) as GithubReleasePayload);
|
||||
logger.info(
|
||||
`更新检查:本机 ${APP_VERSION} / 最新 ${info.latestVersion ?? '-'}` +
|
||||
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
info = failure(describeFailure(error));
|
||||
logger.warn(`更新检查失败:${info.error}(本机版本 ${APP_VERSION})`);
|
||||
}
|
||||
|
||||
// 失败结果**也缓存**:否则机房断网时,管理员每点一次按钮都要白等一个超时。
|
||||
cache = { at: now, info };
|
||||
return info;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from 'node:fs';
|
||||
import process from 'node:process';
|
||||
import { createPrismaClient, type DatabaseProvider } from '../lib/db.js';
|
||||
import { assertValidSnapshot, restoreSnapshot, type DatabaseSnapshot } from '../lib/snapshot.js';
|
||||
|
||||
/**
|
||||
* 快照导入工具(供跨库迁移的**子进程**调用,也可手工使用)。
|
||||
*
|
||||
* 为什么是子进程:切换数据库时 `prisma generate` 会重新生成 Prisma Client(SQL 方言变了),
|
||||
* 而正在运行的服务进程里缓存的是**旧方言**的客户端模块 —— 只有新进程才能加载重新生成的客户端,
|
||||
* 把快照写进新库。用法:
|
||||
*
|
||||
* node dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json
|
||||
*
|
||||
* stdout 最后一行输出 JSON:{ ok, counts },父进程据此回填任务进度;失败时 exit 1 并输出原因。
|
||||
* 注意 --file 是**未压缩**的 JSON(父进程负责解压到临时文件)。
|
||||
*/
|
||||
|
||||
interface ToolArgs {
|
||||
provider: DatabaseProvider;
|
||||
url: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): ToolArgs {
|
||||
const read = (name: string): string => {
|
||||
const index = argv.indexOf(`--${name}`);
|
||||
const value = index >= 0 ? argv[index + 1] : undefined;
|
||||
if (!value) throw new Error(`缺少参数 --${name}`);
|
||||
return value;
|
||||
};
|
||||
const provider = read('provider');
|
||||
if (provider !== 'sqlite' && provider !== 'mysql') {
|
||||
throw new Error(`不支持的 provider:${provider}(只支持 sqlite / mysql)`);
|
||||
}
|
||||
return { provider, url: read('url'), file: read('file') };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const raw = fs.readFileSync(args.file, 'utf8');
|
||||
const snapshot = JSON.parse(raw) as DatabaseSnapshot;
|
||||
assertValidSnapshot(snapshot);
|
||||
|
||||
const client = await createPrismaClient(args.provider, args.url);
|
||||
try {
|
||||
const { counts } = await restoreSnapshot(client, args.provider, snapshot);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, counts })}\n`);
|
||||
} finally {
|
||||
await client.$disconnect().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,7 +3,11 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false
|
||||
"noEmit": false,
|
||||
"removeComments": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"removeComments": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/generated/**/*.ts"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/shared",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
|
||||
"type": "module",
|
||||
|
||||