Compare commits
13
Commits
v0.1.0-fix
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e52cfad4d | ||
|
|
79d73c5889 | ||
|
|
48e0c89394 | ||
|
|
5d2fa2d6b4 | ||
|
|
2be8d30376 | ||
|
|
f6edfa5ea1 | ||
|
|
985e602012 | ||
|
|
d820926491 | ||
|
|
f1edb42665 | ||
|
|
67e3961e55 | ||
|
|
9a38f86cd3 | ||
|
|
aad22df4fd | ||
|
|
572295c297 |
No files matched your search
@@ -0,0 +1,131 @@
|
||||
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.0);
|
||||
# 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。
|
||||
#
|
||||
# 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: '要构建并挂载的 Release tag(例如 v1.1.0)'
|
||||
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 release-server/
|
||||
|
||||
- name: 上传构建产物(Artifacts)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: classhelper-server-linux-x64
|
||||
path: |
|
||||
release-server/classhelper-server-linux-x64-*.tar.gz
|
||||
release-server/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}"
|
||||
TARBALL="release-server/classhelper-server-linux-x64-${VERSION}.tar.gz"
|
||||
SHAFILE="${TARBALL}.sha256"
|
||||
SUMS="release-server/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#release-server/##" > "$SHAFILE"
|
||||
fi
|
||||
|
||||
# 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。
|
||||
# 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。
|
||||
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir release-server --clobber || true
|
||||
DOWNLOADED="$(ls release-server/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
|
||||
@@ -55,11 +55,18 @@ scripts/
|
||||
dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-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}/
|
||||
packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts
|
||||
packages/server/
|
||||
@@ -69,27 +76,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.0.0
|
||||
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
|
||||
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
|
||||
src/Models/PluginSettings.cs
|
||||
@@ -174,6 +181,7 @@ pnpm build: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,13 +189,14 @@ 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 dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
|
||||
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
|
||||
|
||||
### 端口
|
||||
@@ -204,6 +213,7 @@ pnpm build:classisland-plugin
|
||||
|
||||
```
|
||||
release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序)
|
||||
pnpm dist:server:linux 的输出(classhelper-server-linux-x64-<版本>.tar.gz + .sha256)
|
||||
packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe)
|
||||
releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/)
|
||||
```
|
||||
@@ -214,6 +224,7 @@ node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对
|
||||
|
||||
# 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/
|
||||
pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过
|
||||
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
|
||||
|
||||
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
|
||||
pnpm dist:classisland-plugin
|
||||
@@ -224,8 +235,9 @@ pnpm dist:classisland-plugin
|
||||
## 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 +372,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 +410,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 +442,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 +462,7 @@ homeworks / notifications / calls / imports / integrations / grades / students /
|
||||
左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。
|
||||
回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。
|
||||
|
||||
41. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
|
||||
38. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
|
||||
`verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端
|
||||
默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为
|
||||
**"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。
|
||||
@@ -457,35 +473,299 @@ 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` 也按主题设置
|
||||
(深色下若仍用浅色底会"先白后黑"闪一下)。
|
||||
|
||||
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。
|
||||
|
||||
⑤ **密码永远走 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 行执行失败"。
|
||||
|
||||
---
|
||||
|
||||
## 6. 代码风格
|
||||
@@ -501,6 +781,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`(它兼作产品说明书与验收对照表)。
|
||||
|
||||
---
|
||||
@@ -653,6 +941,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` 的整套回归要重做。属于独立立项,不是顺手优化。
|
||||
|
||||
### 已知脆弱用例(不是环境问题,别去"修环境")
|
||||
|
||||
| 用例 | 触发条件 |
|
||||
@@ -671,7 +977,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 +987,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 release-server/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 +1025,28 @@ Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
|
||||
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
||||
开发产物通过不等于用户机器上的副本通过。
|
||||
|
||||
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
|
||||
从 `release-server/classhelper-server/` 起,用**自带的 node.exe**:
|
||||
|
||||
```bash
|
||||
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
|
||||
cd release-server/classhelper-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 条)。
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
SERVER_PORT=4000
|
||||
|
||||
# 应用版本(镜像标签)
|
||||
APP_VERSION=0.1.0
|
||||
APP_VERSION=1.1.1
|
||||
|
||||
# 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.1
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
+2
-2
@@ -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"]
|
||||
+1618
File diff suppressed because it is too large.
Load diff
+41
-15
@@ -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.1}
|
||||
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.1}
|
||||
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:
|
||||
|
||||
+9
-274
@@ -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" "$@"
|
||||
+1293
File diff suppressed because it is too large.
Load diff
@@ -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.1",
|
||||
"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,302 @@
|
||||
#!/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)"
|
||||
|
||||
check "弱密码被拒绝(长度/种数不足)" \
|
||||
"$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 | 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)"
|
||||
check "password --list 能列出账号" \
|
||||
"$("$CLASSHELPER" password --list 2>&1 | 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
|
||||
check "backup list 能列出备份" "$("$CLASSHELPER" backup list 2>&1 | 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 时从 stdin 读,因此用管道喂 yes
|
||||
printf 'yes\n' | CLASSHELPER_NO_TTY=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,536 @@
|
||||
# Linux 部署与运维指南
|
||||
|
||||
本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。
|
||||
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。
|
||||
|
||||
不想看长文的话,最短路径是这两条命令:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.0.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.0`);
|
||||
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
|
||||
# → release-server/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.0.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.0.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.0.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` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
|
||||
| 改了配置没生效 | 是否重启过(`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 release-server/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.0.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`)
|
||||
+53
-9
@@ -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。
|
||||
+90
-114
@@ -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.1-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.1.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.1.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 教学周的周一,用于"当前周次"与课表默认视图 |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "class-helper",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.1",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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.1.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: 班级小助手
|
||||
@@ -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.1.0";
|
||||
|
||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||
private static string SafeAppVersion()
|
||||
|
||||
@@ -2,6 +2,18 @@ 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: release
|
||||
buildResources: build
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@classhelper/desktop-client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.1",
|
||||
"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",
|
||||
|
||||
@@ -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(
|
||||
@@ -2087,7 +2097,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];
|
||||
@@ -3712,7 +3723,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 +3767,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 +3849,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 +3891,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 +3997,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 +4162,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 +4341,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -69,6 +140,8 @@ onUnmounted(() => {
|
||||
<p class="boot-text">{{ bootText }}</p>
|
||||
</div>
|
||||
<router-view v-else />
|
||||
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
|
||||
<OnboardingWelcome v-if="!booting" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+332
-12
@@ -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,224 @@ 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: '汉堡按钮点击失败' };
|
||||
const collapsed = await waitUntil(() => {
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 80;
|
||||
}, 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 +1162,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,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,14 @@
|
||||
<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 { 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 +16,77 @@ 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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 未读红点挂在「通知」项的图标右上角 */
|
||||
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 +104,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 +168,44 @@ 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">
|
||||
<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 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
|
||||
<span class="brand-logo">
|
||||
<el-icon :size="16"><School /></el-icon>
|
||||
</span>
|
||||
<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">
|
||||
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
||||
<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 +213,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 +248,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="ui.toggleTheme()"
|
||||
>
|
||||
<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 +269,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>
|
||||
@@ -191,6 +310,11 @@ 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);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -198,10 +322,43 @@ 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);
|
||||
}
|
||||
|
||||
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
|
||||
.nav-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
border-radius: var(--ch-radius-control);
|
||||
background: transparent;
|
||||
color: var(--ch-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-toggle:hover {
|
||||
background: var(--ch-hover-soft);
|
||||
}
|
||||
|
||||
/* 折叠态:侧栏只剩 64px,汉堡按钮水平居中。
|
||||
用**位移 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;
|
||||
}
|
||||
|
||||
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
|
||||
@@ -211,21 +368,68 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
|
||||
overflow: hidden;
|
||||
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 +473,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 +507,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 +529,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 +552,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,34 @@ 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 './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 ?? '',
|
||||
|
||||
@@ -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,29 @@
|
||||
--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 …)共用这一份参数,
|
||||
* 改时长/缓动只改这里,两边不会漂。
|
||||
*/
|
||||
--nav-dur: 260ms;
|
||||
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* 图标与文字的间距(折叠时收到 0) */
|
||||
--nav-gap: 8px;
|
||||
|
||||
/* 兼容旧变量名 */
|
||||
--ch-sidebar: #ffffff;
|
||||
|
||||
@@ -62,6 +86,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 +252,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 +268,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 +297,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 +350,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,8 +366,19 @@ 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 经典样式) */
|
||||
@@ -288,6 +399,93 @@ textarea {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 课表时间轴 */
|
||||
|
||||
.timetable-cell {
|
||||
@@ -320,14 +518,14 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
@@ -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);
|
||||
@@ -198,7 +208,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 +220,12 @@ async function enterOffline(): Promise<void> {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-guide {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.offline-button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -218,6 +234,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>
|
||||
+42
@@ -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.1",
|
||||
"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.1.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.1",
|
||||
"private": true,
|
||||
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
|
||||
"type": "module",
|
||||
|
||||
@@ -25,6 +25,10 @@ export const API_PATHS = {
|
||||
imports: '/imports',
|
||||
/** ClassIsland 联动:设备接入、状态上报、通知下发 */
|
||||
integrations: '/integrations',
|
||||
/** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */
|
||||
database: '/database',
|
||||
/** 更新检查:Web 端经服务端转发查 GitHub 上的最新 Release(浏览器受 CSP 限制不能直连) */
|
||||
update: '/update',
|
||||
dashboard: '/dashboard/summary',
|
||||
} as const;
|
||||
|
||||
@@ -476,6 +480,12 @@ export const STORAGE_KEYS = {
|
||||
serverUrl: 'classhelper.serverUrl',
|
||||
cachePrefix: 'classhelper.cache',
|
||||
lastSyncAt: 'classhelper.lastSyncAt',
|
||||
/**
|
||||
* 新手引导「已看过」标记(Web 管理端 localStorage)。
|
||||
* 值为引导版本号:改版引导内容后版本 +1,看过旧版的老用户会再收到一次新版引导。
|
||||
* EXE 客户端不用这个键 —— 它的引导状态在主进程配置(`onboardingDone`)里。
|
||||
*/
|
||||
onboarding: 'classhelper.onboarding',
|
||||
} as const;
|
||||
|
||||
/** 桌面客户端默认后端地址 */
|
||||
@@ -488,3 +498,29 @@ export const MAX_PAGE_SIZE = 200;
|
||||
|
||||
/** 教师与管理员角色集合,便于 RBAC 判断 */
|
||||
export const STAFF_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER'];
|
||||
|
||||
/* ------------------------------------------------------------------ 更新检查 */
|
||||
|
||||
/**
|
||||
* 更新来源:本项目用 **GitHub Releases** 分发交付物(服务端 / 客户端安装包、便携版、插件 .cipx),
|
||||
* 因此"有没有新版本"就是问 GitHub 上最新的那个 Release。
|
||||
*
|
||||
* 仓库是**公开**的,匿名调用即可(不需要 token);但匿名限流是 60 次/小时/IP,
|
||||
* 所以服务端与客户端都把结果缓存 `UPDATE_CHECK_CACHE_TTL_MS`,不要每次开页面都打一次。
|
||||
*/
|
||||
export const GITHUB_REPO = { owner: 'LoveChengke', repo: 'ClassHelper' } as const;
|
||||
|
||||
/** 最新 Release 的 API 地址(GitHub 保证它不含草稿与预发布版) */
|
||||
export const GITHUB_RELEASES_LATEST_API = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`;
|
||||
|
||||
/** 给人看的 Release 列表页(「前往下载」按钮打开它) */
|
||||
export const GITHUB_RELEASES_PAGE = `https://github.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases`;
|
||||
|
||||
/** GitHub API 强制要求 User-Agent,不带会直接 403 */
|
||||
export const UPDATE_CHECK_USER_AGENT = 'ClassHelper-UpdateCheck';
|
||||
|
||||
/** 检查结果缓存时长(30 分钟):既避开匿名限流,也不至于让用户等太久才知道有新版本 */
|
||||
export const UPDATE_CHECK_CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
/** 单次检查的超时:教室机 / 机房的网络常常很慢,但也不能把界面卡住 */
|
||||
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
|
||||
@@ -41,7 +41,7 @@ export function canAssignTeachers(role: UserRole): boolean {
|
||||
return role === 'ADMIN';
|
||||
}
|
||||
|
||||
/** 学生名单(增删学生、重置密码、导入名单):仅管理员 */
|
||||
/** 学生名单(增删学生、导入名单):仅管理员(学生没有账号,因此没有"重置密码"这一步) */
|
||||
export function canManageRoster(role: UserRole): boolean {
|
||||
return role === 'ADMIN';
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export const PERMISSION_MATRIX: readonly {
|
||||
}[] = [
|
||||
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
|
||||
{ action: '分配班主任与科任老师', admin: true, head: false, subject: false },
|
||||
{ action: '学生名单管理(增删 / 重置密码 / 导入)', admin: true, head: false, subject: false },
|
||||
{ action: '学生名单管理(增删 / 导入)', admin: true, head: false, subject: false },
|
||||
{ action: '教师录入(新建 / 导入名单 / 重置密码)', admin: true, head: false, subject: false },
|
||||
{ action: '课表管理(增删改 / 时间配置导入)', admin: true, head: true, subject: false },
|
||||
{ action: '布置作业', admin: true, head: true, subject: true },
|
||||
|
||||
@@ -1017,3 +1017,122 @@ export interface SocketAuthPayload {
|
||||
token: string;
|
||||
clientType?: 'admin' | 'desktop';
|
||||
}
|
||||
|
||||
/* ================================================================ 数据库管理(仅管理员) */
|
||||
|
||||
/** 数据库管理支持的提供方(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
|
||||
export type DatabaseProvider = 'sqlite' | 'mysql';
|
||||
|
||||
/** GET /api/database/status 的返回 */
|
||||
export interface DatabaseStatusDto {
|
||||
provider: DatabaseProvider;
|
||||
connected: boolean;
|
||||
/** ping 延迟(毫秒);未连接时为 null */
|
||||
latencyMs: number | null;
|
||||
/** 数据库引擎版本(sqlite_version() / VERSION()) */
|
||||
version: string | null;
|
||||
/** 数据体积(字节);MySQL 取 information_schema 汇总 */
|
||||
sizeBytes: number | null;
|
||||
/** 当前连接串的脱敏展示(MySQL 隐藏密码) */
|
||||
databaseUrlMasked: string;
|
||||
/** SQLite 数据文件绝对路径(MySQL 为空串) */
|
||||
sqliteFilePath: string;
|
||||
/** 数据目录(备份与定时配置所在处) */
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
tables: DatabaseTableCountDto[];
|
||||
backups: DatabaseBackupMetaDto[];
|
||||
schedule: DatabaseBackupScheduleDto;
|
||||
/** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */
|
||||
switchRunning: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseTableCountDto {
|
||||
name: string;
|
||||
/** -1 表示统计失败(表结构与代码不一致时出现) */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DatabaseBackupMetaDto {
|
||||
name: string;
|
||||
kind: 'manual' | 'auto';
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DatabaseBackupScheduleDto {
|
||||
enabled: boolean;
|
||||
intervalHours: number;
|
||||
keepCount: number;
|
||||
lastAutoBackupAt: string | null;
|
||||
}
|
||||
|
||||
/** POST /api/database/test-connection 的返回 */
|
||||
export interface DatabaseConnectionTestDto {
|
||||
ok: boolean;
|
||||
provider: DatabaseProvider;
|
||||
latencyMs: number | null;
|
||||
version: string | null;
|
||||
/** 新建 SQLite 库时目标文件还不存在,属正常情况(切换时会自动建库) */
|
||||
note?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 数据库切换任务(POST /switch 后用 jobId 轮询) */
|
||||
export interface DatabaseSwitchJobDto {
|
||||
id: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
target: { provider: DatabaseProvider; url: string };
|
||||
steps: DatabaseSwitchStepDto[];
|
||||
error?: string;
|
||||
/** 完成后为 true:需要重启服务端才能让新数据库生效 */
|
||||
restartRequired: boolean;
|
||||
counts?: Record<string, number>;
|
||||
backupName?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseSwitchStepDto {
|
||||
name: string;
|
||||
status: 'running' | 'done' | 'error';
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 更新检查 */
|
||||
|
||||
/** Release 里的一个附件(安装包 / 便携版 / 插件包) */
|
||||
export interface UpdateAssetDto {
|
||||
name: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新检查结果(服务端 `GET /api/update/check` 与桌面客户端 IPC 共用同一结构)。
|
||||
*
|
||||
* 两点约定:
|
||||
* 1. **`ok:false` 是正常结果,不是异常** —— 服务器没有外网、GitHub 限流、请求超时都会走到这里,
|
||||
* 调用方按"暂时查不到"呈现即可(与 ClassIsland 推送失败只记日志是同一种处理思路)。
|
||||
* 2. **`currentVersion` 由调用方各自提供**:服务端比的是服务端版本、客户端比的是客户端版本。
|
||||
* 两边安装版本可能不同(例如教室机没跟着升级),所以不能由一端替另一端判断。
|
||||
*/
|
||||
export interface UpdateInfo {
|
||||
ok: boolean;
|
||||
/** 失败原因(给人看的一句话);ok=true 时为 null */
|
||||
error: string | null;
|
||||
/** 本机版本 */
|
||||
currentVersion: string;
|
||||
/** GitHub 上最新 Release 的版本号;ok=false 时为 null */
|
||||
latestVersion: string | null;
|
||||
hasUpdate: boolean;
|
||||
/** 给人看的 Release 页面地址(「前往下载」用) */
|
||||
releaseUrl: string;
|
||||
/** Release 标题 */
|
||||
releaseName: string | null;
|
||||
publishedAt: string | null;
|
||||
/** Release 说明(Markdown 原文,由界面自行截断展示) */
|
||||
notes: string | null;
|
||||
assets: UpdateAssetDto[];
|
||||
checkedAt: string;
|
||||
}
|
||||
@@ -28,9 +28,7 @@ export function isDayKey(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
return (
|
||||
date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
|
||||
);
|
||||
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
|
||||
}
|
||||
|
||||
/** 在 YYYY-MM-DD 上加减天数(用于日期选择器的区间与"昨天/明天") */
|
||||
@@ -357,3 +355,65 @@ export function minutesUntil(time: string, now: Date = new Date()): number {
|
||||
if (target < 0) return 0;
|
||||
return target - (now.getHours() * 60 + now.getMinutes());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 版本比较(更新检查) */
|
||||
|
||||
/**
|
||||
* 版本号归一化:去掉首尾空白与前导的 `v` / `V`。
|
||||
*
|
||||
* 之所以需要:GitHub 的 tag 是 `v1.0.0`,而 package.json / `app.getVersion()` 给的是 `1.0.0`,
|
||||
* 两边不归一化就没法比。三端共用这一个实现,避免"客户端说有新版、服务端说没有"。
|
||||
*/
|
||||
export function normalizeVersion(value: string | null | undefined): string {
|
||||
return (value ?? '').trim().replace(/^[vV]/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个版本号:`a` 比 `b` 新返回正数,旧返回负数,相同返回 0。
|
||||
*
|
||||
* 规则:
|
||||
* 1. 按 `.` 分段比数字(每段只取开头的连续数字,因此 `1.0.0-fix` 的第三段仍是 `0`);
|
||||
* 2. 数字全部相同、只有后缀不同时,**带后缀的一方视为更新**。
|
||||
*
|
||||
* 第 2 条是为 `v0.1.0-fix` 这种"同一个版本号的修复发布"定的:按标准 semver,
|
||||
* `0.1.0-fix` 是 `0.1.0` 的**预发布**(应当更旧),但本项目是拿它当"修完再补发一版"用的,
|
||||
* 照 semver 判会让那条修复版永远提示不出来。另外 `/releases/latest` 本身会跳过预发布,
|
||||
* 所以不用担心把 `-beta` 当成正式版。
|
||||
*/
|
||||
export function compareVersions(a: string | null | undefined, b: string | null | undefined): number {
|
||||
const left = normalizeVersion(a);
|
||||
const right = normalizeVersion(b);
|
||||
if (left === right) return 0;
|
||||
|
||||
const parse = (value: string): { numbers: number[]; suffix: string } => {
|
||||
const [core = '', ...rest] = value.split('-');
|
||||
const numbers = core.split('.').map((part) => {
|
||||
const matched = /^\d+/.exec(part.trim());
|
||||
return matched ? Number(matched[0]) : 0;
|
||||
});
|
||||
return { numbers, suffix: rest.join('-') };
|
||||
};
|
||||
|
||||
const l = parse(left);
|
||||
const r = parse(right);
|
||||
const length = Math.max(l.numbers.length, r.numbers.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const diff = (l.numbers[index] ?? 0) - (r.numbers[index] ?? 0);
|
||||
if (diff !== 0) return diff > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
if (l.suffix === r.suffix) return 0;
|
||||
// 数字相同时:裸版本更旧(1.0.0 < 1.0.0-fix),两个都有后缀才按字典序
|
||||
if (!l.suffix) return -1;
|
||||
if (!r.suffix) return 1;
|
||||
return l.suffix.localeCompare(r.suffix);
|
||||
}
|
||||
|
||||
/** `latest` 是否比 `current` 新(更新检查的判定入口;latest 为空一律视为"没有新版") */
|
||||
export function isNewerVersion(
|
||||
current: string | null | undefined,
|
||||
latest: string | null | undefined,
|
||||
): boolean {
|
||||
if (!normalizeVersion(latest)) return false;
|
||||
return compareVersions(latest, current) > 0;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@classhelper/web-admin",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
|
||||
"build": "node ../../scripts/check-icons.mjs && vue-tsc --noEmit -p tsconfig.json && vite build",
|
||||
"build:only": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
||||
@@ -15,7 +15,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",
|
||||
|
||||
@@ -62,4 +62,9 @@ export const api = {
|
||||
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }),
|
||||
put: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PUT', url, data }),
|
||||
delete: <T>(url: string): Promise<T> => request<T>({ method: 'DELETE', url }),
|
||||
/**
|
||||
* 下载二进制(返回 Blob,调用方负责触发浏览器保存)。
|
||||
* 请求拦截器照常带 token;响应是 Blob 时拦截器原样放行(不含 success 字段)。
|
||||
*/
|
||||
download: (url: string): Promise<Blob> => request<Blob>({ method: 'GET', url, responseType: 'blob' }),
|
||||
};
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
type CreateNotificationRequest,
|
||||
type CreateScheduleRequest,
|
||||
type DashboardSummary,
|
||||
type DatabaseBackupMetaDto,
|
||||
type DatabaseBackupScheduleDto,
|
||||
type DatabaseConnectionTestDto,
|
||||
type DatabaseStatusDto,
|
||||
type DatabaseSwitchJobDto,
|
||||
type GradeDto,
|
||||
type GradeStats,
|
||||
type HomeworkDaysDto,
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
type TimeLayoutParsePreview,
|
||||
type UpdateClassAccountRequest,
|
||||
type UpdateClassRequest,
|
||||
type UpdateInfo,
|
||||
type UpdateIntegrationDeviceRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -121,12 +127,8 @@ export const homeworkApi = {
|
||||
date?: string;
|
||||
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
|
||||
/** 哪些天有作业(日期选择器高亮) */
|
||||
days: (params: {
|
||||
classId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
days?: number;
|
||||
}): Promise<HomeworkDaysDto> => api.get(`${API_PATHS.homeworks}/days`, params),
|
||||
days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise<HomeworkDaysDto> =>
|
||||
api.get(`${API_PATHS.homeworks}/days`, params),
|
||||
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
||||
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
|
||||
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
|
||||
@@ -328,3 +330,53 @@ export const integrationApi = {
|
||||
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
|
||||
api.post(`${API_PATHS.integrations}/classisland/notify`, payload),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 数据库管理(仅管理员) */
|
||||
|
||||
/**
|
||||
* 数据库管理:状态 / 连接测试 / 备份 / 导入导出 / 定时备份 / 一键切换。
|
||||
*
|
||||
* 「快照下载」与「数据库文件下载」是二进制流,走 http.ts 的 download()(返回 Blob),
|
||||
* 由页面负责触发浏览器保存;导入把快照 JSON 转 base64 放进请求体(受 12MB 请求体限制)。
|
||||
*/
|
||||
export const databaseApi = {
|
||||
status: (): Promise<DatabaseStatusDto> => api.get(`${API_PATHS.database}/status`),
|
||||
testConnection: (payload: {
|
||||
provider: 'sqlite' | 'mysql';
|
||||
url: string;
|
||||
}): Promise<DatabaseConnectionTestDto> => api.post(`${API_PATHS.database}/test-connection`, payload),
|
||||
listBackups: (): Promise<DatabaseBackupMetaDto[]> => api.get(`${API_PATHS.database}/backups`),
|
||||
createBackup: (): Promise<DatabaseBackupMetaDto> => api.post(`${API_PATHS.database}/backups`, {}),
|
||||
restoreBackup: (name: string): Promise<{ counts: Record<string, number> }> =>
|
||||
api.post(`${API_PATHS.database}/backups/${encodeURIComponent(name)}/restore`, {}),
|
||||
deleteBackup: (name: string): Promise<{ name: string }> =>
|
||||
api.delete(`${API_PATHS.database}/backups/${encodeURIComponent(name)}`),
|
||||
importSnapshot: (base64: string): Promise<{ counts: Record<string, number> }> =>
|
||||
api.post(`${API_PATHS.database}/import`, { data: base64 }),
|
||||
/** 下载当前库的 JSON 快照(Blob,页面触发浏览器保存) */
|
||||
downloadSnapshot: (): Promise<Blob> => api.download(`${API_PATHS.database}/export`),
|
||||
/** 下载数据库文件(仅 SQLite 模式;MySQL 无单文件概念) */
|
||||
downloadSqliteFile: (): Promise<Blob> => api.download(`${API_PATHS.database}/sqlite-file`),
|
||||
getSchedule: (): Promise<DatabaseBackupScheduleDto> => api.get(`${API_PATHS.database}/backup-schedule`),
|
||||
saveSchedule: (payload: DatabaseBackupScheduleDto): Promise<DatabaseBackupScheduleDto> =>
|
||||
api.put(`${API_PATHS.database}/backup-schedule`, payload),
|
||||
startSwitch: (payload: { provider: 'sqlite' | 'mysql'; url: string }): Promise<{ jobId: string }> =>
|
||||
api.post(`${API_PATHS.database}/switch`, payload),
|
||||
getSwitchJob: (jobId: string): Promise<DatabaseSwitchJobDto | null> =>
|
||||
api.get(`${API_PATHS.database}/switch/jobs/${encodeURIComponent(jobId)}`),
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新检查:问服务端 GitHub 上有没有比本机新的版本。
|
||||
*
|
||||
* **Web 端不直连 GitHub** —— 服务端下发的 CSP `connect-src` 只放行 `'self'` 与 WebSocket
|
||||
* (见 `middleware/security.ts`),浏览器直连 `api.github.com` 会被拦掉;
|
||||
* 走服务端转发还顺带让所有管理员共享同一份缓存(匿名限流 60 次/小时/IP)。
|
||||
*
|
||||
* `ok:false` 是正常结果(机房没外网 / GitHub 限流),不是异常。
|
||||
*/
|
||||
export const updateApi = {
|
||||
/** @param force 绕过服务端缓存;仅管理员生效 */
|
||||
check: (force = false): Promise<UpdateInfo> =>
|
||||
api.get(`${API_PATHS.update}/check${force ? '?force=1' : ''}`),
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* 轻量柱状图(纯 HTML/CSS,不依赖任何图表库)。
|
||||
*
|
||||
* 为什么不用 echarts:成绩页只需要「分组柱 + 数值标签 + 坐标轴」这点东西,
|
||||
* 而 `import * as echarts from 'echarts'` 会给构建产物加 1.1MB。
|
||||
*
|
||||
* 实现要点(决定了这里为什么不需要 resize 监听):
|
||||
* - 柱高用**百分比**:`.bar-track` 是行方向 flex 里 `flex:1` 的子项,高度确定,
|
||||
* 柱子的 `height: X%` 直接按它解析;
|
||||
* - 刻度与网格线用**零高度 flex 行 + `justify-content: space-between`**:
|
||||
* 零高度元素的中心正好落在 i/(N-1) 处,与柱状区上下边界精确对齐;
|
||||
* - 纵轴刻度列与绘图区共用同一套上下留白(数值行 18px / 标签行 28px),因此永远对齐。
|
||||
*
|
||||
* 与桌面客户端那份(`packages/desktop-client/src/renderer/components/ScoreBarChart.vue`)
|
||||
* 是刻意分开的两份:两端的设计令牌体系不同(`--ch-*` 浅/深色 vs `--el-*`),
|
||||
* 合用一个组件反而要塞一堆条件样式。
|
||||
*/
|
||||
interface BarItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 每根柱:分类名 + 数值 */
|
||||
items: BarItem[];
|
||||
/** 柱色 */
|
||||
color?: string;
|
||||
/** 纵轴上限;不传时按数据最大值向上取整到「好看」的刻度 */
|
||||
max?: number;
|
||||
/** 数值单位(显示在刻度与柱顶数值后面) */
|
||||
unit?: string;
|
||||
/** 图表高度(px) */
|
||||
height?: number;
|
||||
}>(),
|
||||
{ color: '#409eff', max: undefined, unit: '', height: 220 },
|
||||
);
|
||||
|
||||
/** 把数值向上取整到 1/2/5 × 10^n,让刻度落在整数上 */
|
||||
function niceCeil(value: number): number {
|
||||
if (value <= 0) return 1;
|
||||
const base = 10 ** Math.floor(Math.log10(value));
|
||||
const normalized = value / base;
|
||||
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return step * base;
|
||||
}
|
||||
|
||||
const axisMax = computed(() => {
|
||||
if (props.max !== undefined) return props.max;
|
||||
const dataMax = props.items.reduce((max, item) => Math.max(max, item.value), 0);
|
||||
return niceCeil(dataMax);
|
||||
});
|
||||
|
||||
/** 纵轴刻度(自上而下):100% / 75% / 50% / 25% / 0%,去重后最多 5 条 */
|
||||
const ticks = computed(() => {
|
||||
const values = [1, 0.75, 0.5, 0.25, 0].map((ratio) => Math.round(axisMax.value * ratio));
|
||||
return [...new Set(values)];
|
||||
});
|
||||
|
||||
function barHeight(value: number): string {
|
||||
const ratio = axisMax.value > 0 ? value / axisMax.value : 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: 40px 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(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.axis-y {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 18px 0 28px;
|
||||
}
|
||||
|
||||
.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(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.plot {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gridlines {
|
||||
position: absolute;
|
||||
inset: 18px 0 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gridline {
|
||||
height: 0;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
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(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 46%;
|
||||
max-width: 46px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
flex: none;
|
||||
height: 28px;
|
||||
padding-top: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* 轻量折线图(内联 SVG + HTML 数据点,不依赖任何图表库)。
|
||||
*
|
||||
* 为什么不用 echarts:成绩页只需要一条「各科平均得分率」折线,
|
||||
* 而 `import * as echarts from 'echarts'` 会给构建产物加 1.1MB。
|
||||
*
|
||||
* 实现要点:
|
||||
* - 线画在 `viewBox="0 0 100 100"` + `preserveAspectRatio="none"` 的 SVG 里,
|
||||
* 于是 viewBox 坐标 (x, y) 精确对应绘图区的 (x%, y%);`vector-effect="non-scaling-stroke"`
|
||||
* 保证非等比拉伸时线宽仍是 2px;
|
||||
* - 数据点用**绝对定位的 HTML 圆点**(而不是 SVG `<circle>`),这样非等比拉伸下仍是正圆;
|
||||
* 点位与线的坐标共用同一套百分比,天然对齐;
|
||||
* - 横轴分类点落在**每个分组的中心**(`(i + 0.5) / n`),与 echarts 的 category 轴一致;
|
||||
* - 全程零 resize 监听。
|
||||
*/
|
||||
interface LineItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: LineItem[];
|
||||
color?: string;
|
||||
/** 纵轴上限;不传时按数据最大值向上取整 */
|
||||
max?: number;
|
||||
unit?: string;
|
||||
height?: number;
|
||||
}>(),
|
||||
{ color: '#67c23a', max: undefined, unit: '%', height: 220 },
|
||||
);
|
||||
|
||||
function niceCeil(value: number): number {
|
||||
if (value <= 0) return 1;
|
||||
const base = 10 ** Math.floor(Math.log10(value));
|
||||
const normalized = value / base;
|
||||
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return step * base;
|
||||
}
|
||||
|
||||
const axisMax = computed(() => {
|
||||
if (props.max !== undefined) return props.max;
|
||||
const dataMax = props.items.reduce((max, item) => Math.max(max, item.value), 0);
|
||||
return niceCeil(dataMax);
|
||||
});
|
||||
|
||||
const ticks = computed(() => {
|
||||
const values = [1, 0.75, 0.5, 0.25, 0].map((ratio) => Math.round(axisMax.value * ratio));
|
||||
return [...new Set(values)];
|
||||
});
|
||||
|
||||
/** 数据点:x / y 都是绘图区的百分比坐标(0~100) */
|
||||
const points = computed(() => {
|
||||
const count = props.items.length;
|
||||
const max = axisMax.value;
|
||||
return props.items.map((item, index) => {
|
||||
const ratio = max > 0 ? item.value / max : 0;
|
||||
return {
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
x: ((index + 0.5) / count) * 100,
|
||||
y: (1 - Math.min(1, Math.max(0, ratio))) * 100,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Catmull-Rom 转三次贝塞尔,复刻 echarts `smooth: true` 的观感。
|
||||
* tension 0.2 与 echarts 的默认平滑强度接近。
|
||||
*/
|
||||
const linePath = computed(() => {
|
||||
const list = points.value;
|
||||
if (list.length < 2) return '';
|
||||
const tension = 0.2;
|
||||
const round = (value: number) => Number(value.toFixed(2));
|
||||
|
||||
let path = `M ${round(list[0]!.x)} ${round(list[0]!.y)}`;
|
||||
for (let index = 0; index < list.length - 1; index += 1) {
|
||||
const previous = list[index - 1] ?? list[index]!;
|
||||
const current = list[index]!;
|
||||
const next = list[index + 1]!;
|
||||
const afterNext = list[index + 2] ?? next;
|
||||
|
||||
const control1x = current.x + (next.x - previous.x) * tension;
|
||||
const control1y = current.y + (next.y - previous.y) * tension;
|
||||
const control2x = next.x - (afterNext.x - current.x) * tension;
|
||||
const control2y = next.y - (afterNext.y - current.y) * tension;
|
||||
|
||||
path += ` C ${round(control1x)} ${round(control1y)}, ${round(control2x)} ${round(control2y)}, ${round(
|
||||
next.x,
|
||||
)} ${round(next.y)}`;
|
||||
}
|
||||
return path;
|
||||
});
|
||||
|
||||
/** 折线下方的面积:从终点垂到基线,再沿基线回到起点 */
|
||||
const areaPath = computed(() => {
|
||||
const list = points.value;
|
||||
if (list.length < 2) return '';
|
||||
const first = list[0]!;
|
||||
const last = list[list.length - 1]!;
|
||||
return `${linePath.value} L ${Number(last.x.toFixed(2))} 100 L ${Number(first.x.toFixed(2))} 100 Z`;
|
||||
});
|
||||
|
||||
function formatValue(value: number): string {
|
||||
return `${Number.isInteger(value) ? value : value.toFixed(1)}${props.unit}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="line-chart" :style="{ height: `${height}px` }">
|
||||
<div v-if="items.length === 0" class="line-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>
|
||||
|
||||
<svg class="lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path v-if="areaPath" :d="areaPath" :fill="color" fill-opacity="0.12" />
|
||||
<path
|
||||
v-if="linePath"
|
||||
:d="linePath"
|
||||
fill="none"
|
||||
:stroke="color"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="points">
|
||||
<div v-for="point in points" :key="point.label" class="point-col">
|
||||
<span
|
||||
class="point"
|
||||
:style="{ top: `${point.y}%`, background: color }"
|
||||
:title="`${point.label}:${formatValue(point.value)}`"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="x-labels" aria-hidden="true">
|
||||
<span v-for="point in points" :key="point.label" class="x-label" :title="point.label">
|
||||
{{ point.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-chart {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.line-chart-empty {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
/* 上下留白与绘图区(.gridlines / .lines / .points 的 inset)严格一致 */
|
||||
.axis-y {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 18px 0 28px;
|
||||
}
|
||||
|
||||
.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(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.plot {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gridlines,
|
||||
.lines,
|
||||
.points {
|
||||
position: absolute;
|
||||
inset: 18px 0 28px;
|
||||
}
|
||||
|
||||
.gridlines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gridline {
|
||||
height: 0;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.lines {
|
||||
/* 尺寸完全由上面的 inset 决定(left/right/top/bottom 都给了),不要再写 width/height,
|
||||
否则会和 inset 打架 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.points {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.point-col {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.point {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.x-labels {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.x-label {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-top: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { formatDate, type UpdateInfo } from '@classhelper/shared';
|
||||
import { updateApi } from '@/api';
|
||||
|
||||
/**
|
||||
* 「检查更新」弹窗(Web 管理端顶栏用户菜单)。
|
||||
*
|
||||
* 数据来自**服务端**的 `GET /api/update/check` —— 浏览器受服务端下发的 CSP `connect-src`
|
||||
* 限制,不能直连 `api.github.com`;走服务端转发还顺带让所有管理员共享同一份缓存
|
||||
* (GitHub 匿名限流 60 次/小时/IP)。
|
||||
*
|
||||
* `ok:false` 是**正常结果**(机房没有外网 / GitHub 限流 / 超时),不是错误 ——
|
||||
* 界面按"暂时查不到"呈现,并给出重试入口,而不是弹一句红字报错。
|
||||
*/
|
||||
const props = defineProps<{ modelValue: boolean }>();
|
||||
const emit = defineEmits<{ (event: 'update:modelValue', value: boolean): void }>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const info = ref<UpdateInfo | null>(null);
|
||||
|
||||
/** 发布时间(本地格式),拿不到就不显示 */
|
||||
const publishedText = computed(() => {
|
||||
const at = info.value?.publishedAt;
|
||||
return at ? formatDate(at, true) : '';
|
||||
});
|
||||
|
||||
/**
|
||||
* 用户点菜单里的「检查更新」是一次明确的手动检查,因此**绕过服务端缓存**(force=1,仅管理员生效);
|
||||
* 打开弹窗时才查,而不是进页面就查 —— 没点菜单就不该产生任何外部请求。
|
||||
*/
|
||||
async function check(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
info.value = await updateApi.check(true);
|
||||
} catch {
|
||||
// 请求本身失败(如火并发的接口限流):http.ts 已经弹过提示,这里只保证弹窗里不留空白
|
||||
info.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openRelease(): void {
|
||||
const url = info.value?.releaseUrl;
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (!Number.isFinite(size) || size <= 0) return '';
|
||||
const mb = size / 1024 / 1024;
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(size / 1024)} KB`;
|
||||
}
|
||||
|
||||
// 打开时才查:关闭后清空结果,下次打开重新查一遍(避免显示上一次的陈旧结论)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(opened) => {
|
||||
if (opened) void check();
|
||||
else info.value = null;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="检查更新" width="520px" data-test="update-dialog">
|
||||
<div v-loading="loading" class="update-body">
|
||||
<template v-if="info && info.ok">
|
||||
<el-alert
|
||||
v-if="info.hasUpdate"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
data-test="update-found"
|
||||
:title="`发现新版本 v${info.latestVersion}`"
|
||||
:description="`当前版本 v${info.currentVersion}${publishedText ? ` · 发布于 ${publishedText}` : ''}`"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
data-test="update-latest"
|
||||
title="已是最新版本"
|
||||
:description="`当前版本 v${info.currentVersion}(GitHub 最新为 v${info.latestVersion})`"
|
||||
/>
|
||||
|
||||
<template v-if="info.hasUpdate">
|
||||
<div v-if="info.releaseName" class="update-name">{{ info.releaseName }}</div>
|
||||
|
||||
<div v-if="info.notes" class="update-notes-label">更新说明</div>
|
||||
<pre v-if="info.notes" class="update-notes">{{ info.notes }}</pre>
|
||||
|
||||
<template v-if="info.assets.length > 0">
|
||||
<div class="update-notes-label">可下载文件</div>
|
||||
<ul class="update-assets">
|
||||
<li v-for="asset in info.assets" :key="asset.name">
|
||||
<span class="update-asset-name">{{ asset.name }}</span>
|
||||
<span v-if="formatBytes(asset.size)" class="update-asset-size">
|
||||
{{ formatBytes(asset.size) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 查不到不是错误:机房没有外网、GitHub 限流都会走到这里 -->
|
||||
<el-alert
|
||||
v-else-if="!loading"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
data-test="update-unavailable"
|
||||
title="暂时无法检查更新"
|
||||
:description="info?.error ?? '请稍后重试;若服务器没有外网,可手动打开项目 Release 页面查看'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">关闭</el-button>
|
||||
<el-button :loading="loading" @click="check()">重新检查</el-button>
|
||||
<el-button v-if="info?.ok && info.hasUpdate" type="primary" @click="openRelease">前往下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.update-body {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.update-name {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.update-notes-label {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.update-notes {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: var(--ch-radius-sm);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: inherit;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.update-assets {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.update-asset-size {
|
||||
margin-left: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { App } from 'vue';
|
||||
import {
|
||||
ArrowDown,
|
||||
Bell,
|
||||
Calendar,
|
||||
Coin,
|
||||
Connection,
|
||||
CopyDocument,
|
||||
Document,
|
||||
Guide,
|
||||
Lock,
|
||||
Menu,
|
||||
Notebook,
|
||||
Odometer,
|
||||
Plus,
|
||||
Refresh,
|
||||
School,
|
||||
Search,
|
||||
SwitchButton,
|
||||
Trophy,
|
||||
Upload,
|
||||
User,
|
||||
UserFilled,
|
||||
WarningFilled,
|
||||
} from '@element-plus/icons-vue';
|
||||
|
||||
/**
|
||||
* Element Plus 图标注册表(Web 管理端)。
|
||||
*
|
||||
* **为什么不 `import * as ElementPlusIconsVue` 全量注册**:
|
||||
* 图标库有 293 个组件(实测全量打包 minified 203KB,实际用到的只有 22 个 / 59KB),
|
||||
* 全量注册既把 144KB 死代码打进主 chunk,又要在启动时执行 293 次 `app.component()`。
|
||||
*
|
||||
* **为什么不改成模板里逐个 import**:本项目的图标是按**字符串名**引用的
|
||||
* (菜单/路由配置里的 `icon: 'Odometer'`、按钮上的 `:icon="'Search'"`、
|
||||
* `<component :is="item.icon">`),Element Plus 的 `el-button` / `el-menu-item`
|
||||
* 会用 `<component :is="'Odometer'">` 去解析**全局组件名**,因此图标必须以全局组件存在。
|
||||
*
|
||||
* 新增视图时若用了新图标,`scripts/check-icons.mjs` 会在构建前报错(见 package.json 的 build 脚本),
|
||||
* 不会静默渲染成空白。
|
||||
*/
|
||||
const APP_ICONS = {
|
||||
ArrowDown,
|
||||
Bell,
|
||||
Calendar,
|
||||
Coin,
|
||||
Connection,
|
||||
CopyDocument,
|
||||
Document,
|
||||
Guide,
|
||||
Lock,
|
||||
Menu,
|
||||
Notebook,
|
||||
Odometer,
|
||||
Plus,
|
||||
Refresh,
|
||||
School,
|
||||
Search,
|
||||
SwitchButton,
|
||||
Trophy,
|
||||
Upload,
|
||||
User,
|
||||
UserFilled,
|
||||
WarningFilled,
|
||||
} 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,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { STORAGE_KEYS } from '@classhelper/shared';
|
||||
import { authApi } from '@/api';
|
||||
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
@@ -22,6 +24,7 @@ const { isMobile } = useResponsive();
|
||||
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
|
||||
* - 作业 / 通知:所有教师
|
||||
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
|
||||
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
|
||||
*/
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
|
||||
@@ -33,6 +36,7 @@ const menuItems = [
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/database', title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
|
||||
];
|
||||
|
||||
/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */
|
||||
@@ -104,6 +108,10 @@ async function handleLogout(): Promise<void> {
|
||||
void router.push({ name: 'login' });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 检查更新 */
|
||||
/** 顶栏用户菜单「检查更新」弹窗 */
|
||||
const updateVisible = ref(false);
|
||||
|
||||
/* ------------------------------------------------------------ 修改密码 */
|
||||
const passwordVisible = ref(false);
|
||||
const passwordFormRef = ref<FormInstance>();
|
||||
@@ -141,12 +149,90 @@ async function submitPassword(): Promise<void> {
|
||||
await auth.logout();
|
||||
void router.push({ name: 'login' });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 首次登录引导 */
|
||||
|
||||
/**
|
||||
* 引导版本:内容改版后 +1,看过旧版的老用户会再收到一次新版引导。
|
||||
* 「已看过」存 localStorage(STORAGE_KEYS.onboarding,值为版本号)。
|
||||
*/
|
||||
const ONBOARDING_VERSION = 1;
|
||||
|
||||
const tourOpen = ref(false);
|
||||
|
||||
/** 「看过」落盘:走完、跳过(右上角 × / Esc)都算看过,之后不再自动弹出 */
|
||||
function markOnboardingSeen(): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.onboarding, String(ONBOARDING_VERSION));
|
||||
} catch {
|
||||
// localStorage 不可用(隐私模式等)时仅本次会话内生效
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEYS.onboarding) === String(ONBOARDING_VERSION)) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// 等布局与首屏数据渲染稳定后再弹,避免和首屏加载抢注意力
|
||||
window.setTimeout(() => {
|
||||
tourOpen.value = true;
|
||||
}, 600);
|
||||
});
|
||||
|
||||
/**
|
||||
* 引导锚点:按 data-tour 属性惰性查询 DOM(弹出某一步时才找元素)。
|
||||
* 找不到(例如手机小屏没有侧边栏)就返回 null,el-tour 会把卡片放到屏幕居中,不会报错。
|
||||
*/
|
||||
function tourTarget(name: string): () => HTMLElement | null {
|
||||
return () => document.querySelector<HTMLElement>(`[data-tour="${name}"]`);
|
||||
}
|
||||
|
||||
const tourSteps = computed(() => [
|
||||
{
|
||||
target: null,
|
||||
title: '欢迎使用班级小助手',
|
||||
description:
|
||||
'教师与管理员在这里发布课表、作业、通知与成绩,学生通过桌面客户端实时接收。花一分钟认识一下界面。',
|
||||
},
|
||||
isMobile.value
|
||||
? {
|
||||
target: tourTarget('nav-toggle'),
|
||||
title: '打开功能菜单',
|
||||
description: '小屏幕下侧边栏收进抽屉,点左上角按钮即可唤出全部功能页面。',
|
||||
placement: 'bottom-start',
|
||||
}
|
||||
: {
|
||||
target: tourTarget('side-nav'),
|
||||
title: '左侧功能菜单',
|
||||
description:
|
||||
'菜单按角色显示:管理员可管理班级、学生与教师;班主任与科任老师可维护课表、发布作业与通知、录入成绩。',
|
||||
// 侧栏是全高元素,默认 bottom 会把卡片推到视口外,必须放右侧
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: tourTarget('conn-tag'),
|
||||
title: '实时通道',
|
||||
description: '绿色表示与后端保持实时连接,发布的内容会立刻推送到对应班级的学生端与灵动岛。',
|
||||
},
|
||||
{
|
||||
target: tourTarget('user-chip'),
|
||||
title: '账号菜单',
|
||||
description: '在这里修改密码、退出登录,也可以随时重看本引导。',
|
||||
},
|
||||
{
|
||||
target: null,
|
||||
title: '开始使用',
|
||||
description: '发布第一条内容试试吧。以后随时可以点右上角头像 →「使用引导」重看。',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<!-- 桌面/平板:常驻深色侧边栏 -->
|
||||
<el-aside v-if="!isMobile" width="210px" class="layout-aside ch-sidebar">
|
||||
<el-aside v-if="!isMobile" width="210px" class="layout-aside ch-sidebar" data-tour="side-nav">
|
||||
<div class="brand">
|
||||
<el-icon :size="22"><School /></el-icon>
|
||||
<span class="brand-text">班级小助手</span>
|
||||
@@ -169,12 +255,13 @@ async function submitPassword(): Promise<void> {
|
||||
class="nav-toggle"
|
||||
aria-label="打开菜单"
|
||||
:aria-expanded="drawerVisible ? 'true' : 'false'"
|
||||
data-tour="nav-toggle"
|
||||
@click="drawerVisible = true"
|
||||
>
|
||||
<el-icon :size="20"><Menu /></el-icon>
|
||||
</button>
|
||||
<span class="header-title">{{ isMobile ? currentTitle : APP_TITLE }}</span>
|
||||
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light">
|
||||
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light" data-tour="conn-tag">
|
||||
<span class="header-conn">{{ connectionText }}</span>
|
||||
</el-tag>
|
||||
<!-- 小屏:连接正常时不显示任何圆点/标签,只在异常态显示带文字的紧凑标签 -->
|
||||
@@ -194,7 +281,7 @@ async function submitPassword(): Promise<void> {
|
||||
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click">
|
||||
<span class="user-chip">
|
||||
<span class="user-chip" data-tour="user-chip">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<template v-if="!isMobile">
|
||||
{{ auth.displayName }}
|
||||
@@ -209,6 +296,14 @@ async function submitPassword(): Promise<void> {
|
||||
<el-icon><Lock /></el-icon>
|
||||
修改密码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="tourOpen = true">
|
||||
<el-icon><Guide /></el-icon>
|
||||
使用引导
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided data-test="update-check-entry" @click="updateVisible = true">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
检查更新
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
@@ -265,6 +360,24 @@ async function submitPassword(): Promise<void> {
|
||||
<el-button type="primary" @click="submitPassword">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 检查更新:问服务端最新 Release(浏览器受 CSP 限制不能直连 GitHub),见组件内说明 -->
|
||||
<UpdateCheckDialog v-model="updateVisible" />
|
||||
|
||||
<!--
|
||||
新手引导:首次登录自动弹出(见 script 的 onMounted),头像菜单「使用引导」可重看。
|
||||
target 为 null 的步骤(首尾两步)由 el-tour 放到屏幕居中;其余步骤锚定真实 UI。
|
||||
-->
|
||||
<el-tour v-model="tourOpen" @close="markOnboardingSeen" @finish="markOnboardingSeen">
|
||||
<el-tour-step
|
||||
v-for="(step, index) in tourSteps"
|
||||
:key="index"
|
||||
:target="step.target"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
:placement="step.placement"
|
||||
/>
|
||||
</el-tour>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,19 +2,17 @@ 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';
|
||||
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { registerAppIcons } from './icons';
|
||||
import '@/styles/index.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// 全量注册 Element Plus 图标,模板里可直接 <el-icon><Bell /></el-icon>
|
||||
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(name, component);
|
||||
}
|
||||
// 按需注册图标(见 icons.ts:只注册用到的那些,而不是全量 293 个)
|
||||
registerAppIcons(app);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
@@ -68,6 +68,13 @@ const routes: RouteRecordRaw[] = [
|
||||
// 与后端 requireRole('ADMIN','TEACHER') 对齐:学生角色不该看到这一页
|
||||
meta: { title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
},
|
||||
{
|
||||
path: 'database',
|
||||
name: 'database',
|
||||
component: () => import('@/views/DatabaseView.vue'),
|
||||
// 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员
|
||||
meta: { title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type UploadFile, type UploadInstance } from 'element-plus';
|
||||
import type { DatabaseBackupScheduleDto, DatabaseStatusDto, DatabaseSwitchJobDto } from '@classhelper/shared';
|
||||
import { databaseApi } from '@/api';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
|
||||
/**
|
||||
* 数据库管理(仅管理员):
|
||||
* - 状态卡:当前连接 / 版本 / 体积 / 各表行数
|
||||
* - 连接测试卡:任意目标库连通性(切换前的前置检查)
|
||||
* - 一键切换卡:SQLite ⇄ MySQL 迁移(异步任务轮询;完成后需重启服务端生效)
|
||||
* - 备份卡:手动/定时备份、恢复、删除、快照导出导入、数据库文件下载
|
||||
*
|
||||
* 恢复 / 导入 / 切换都是**整库覆盖**级危险操作,全部走 ElMessageBox 二次确认,
|
||||
* 切换还要求用户手输「确认迁移」防止误触。
|
||||
*/
|
||||
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
const loading = ref(false);
|
||||
const status = ref<DatabaseStatusDto | null>(null);
|
||||
|
||||
const isSqlite = computed(() => status.value?.provider === 'sqlite');
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
status.value = await databaseApi.status();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 连接测试 */
|
||||
|
||||
const testForm = reactive({ provider: 'mysql' as 'sqlite' | 'mysql', url: '' });
|
||||
const testing = ref(false);
|
||||
const testResult = ref<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
async function runTest(): Promise<void> {
|
||||
if (!testForm.url.trim()) {
|
||||
ElMessage.warning('请先填写目标连接串');
|
||||
return;
|
||||
}
|
||||
testing.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const result = await databaseApi.testConnection({ provider: testForm.provider, url: testForm.url.trim() });
|
||||
testResult.value = {
|
||||
ok: result.ok,
|
||||
text: result.ok
|
||||
? `连接成功(${result.latencyMs ?? '-'}ms,版本 ${result.version ?? '-'})${result.note ?? ''}`
|
||||
: `连接失败:${result.error ?? '未知原因'}`,
|
||||
};
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 一键切换 */
|
||||
|
||||
const switchForm = reactive({ provider: 'mysql' as 'sqlite' | 'mysql', url: '', confirmText: '' });
|
||||
const switchJob = ref<DatabaseSwitchJobDto | null>(null);
|
||||
const switchPolling = ref(false);
|
||||
let switchTimer: number | null = null;
|
||||
|
||||
/** 切换目标默认按「与当前相反」的 provider 预填 */
|
||||
watch(
|
||||
() => status.value?.provider,
|
||||
(provider) => {
|
||||
if (provider === 'sqlite') switchForm.provider = 'mysql';
|
||||
if (provider === 'mysql') switchForm.provider = 'sqlite';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function startSwitch(): Promise<void> {
|
||||
if (!status.value) return;
|
||||
if (switchForm.confirmText !== '确认迁移') {
|
||||
ElMessage.warning('请先在输入框中输入「确认迁移」');
|
||||
return;
|
||||
}
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`将把当前${status.value.provider === 'sqlite' ? 'SQLite' : 'MySQL'}数据库的全部数据迁移到目标库,` +
|
||||
'目标库必须为空;完成后需要重启服务端才能生效。确定继续?',
|
||||
'一键切换数据库',
|
||||
{ type: 'warning', confirmButtonText: '开始迁移', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!confirmed) return;
|
||||
|
||||
const created = await databaseApi.startSwitch({
|
||||
provider: switchForm.provider,
|
||||
url: switchForm.url.trim(),
|
||||
});
|
||||
switchForm.confirmText = '';
|
||||
switchJob.value = null;
|
||||
switchPolling.value = true;
|
||||
const poll = async (): Promise<void> => {
|
||||
const job = await databaseApi.getSwitchJob(created.jobId);
|
||||
switchJob.value = job;
|
||||
if (job && job.status !== 'running') {
|
||||
switchPolling.value = false;
|
||||
if (switchTimer) {
|
||||
window.clearTimeout(switchTimer);
|
||||
switchTimer = null;
|
||||
}
|
||||
await reload();
|
||||
if (job.status === 'done') ElMessage.success('迁移完成,请重启服务端使新数据库生效');
|
||||
else ElMessage.error(job.error ?? '迁移失败(已回滚,当前数据库未受影响)');
|
||||
return;
|
||||
}
|
||||
switchTimer = window.setTimeout(() => void poll(), 2000);
|
||||
};
|
||||
await poll();
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (switchTimer) window.clearTimeout(switchTimer);
|
||||
});
|
||||
|
||||
/** el-step 的 status 取值:wait/process/finish/error/success */
|
||||
function switchStepStatus(stepStatus: 'running' | 'done' | 'error'): 'process' | 'success' | 'error' {
|
||||
return stepStatus === 'done' ? 'success' : stepStatus === 'error' ? 'error' : 'process';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 备份 / 恢复 */
|
||||
|
||||
const backups = computed(() => status.value?.backups ?? []);
|
||||
|
||||
async function createBackup(): Promise<void> {
|
||||
await databaseApi.createBackup();
|
||||
ElMessage.success('备份已创建');
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function restoreBackup(name: string): Promise<void> {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
'恢复会用备份内容覆盖当前数据库的全部数据(恢复点之后新增的数据将丢失)。确定继续?',
|
||||
'从备份恢复',
|
||||
{ type: 'warning', confirmButtonText: '恢复', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!confirmed) return;
|
||||
await databaseApi.restoreBackup(name);
|
||||
ElMessage.success('已从备份恢复');
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function deleteBackup(name: string): Promise<void> {
|
||||
const confirmed = await ElMessageBox.confirm(`确定删除备份 ${name}?`, '删除备份', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
}).catch(() => false);
|
||||
if (!confirmed) return;
|
||||
await databaseApi.deleteBackup(name);
|
||||
ElMessage.success('备份已删除');
|
||||
await reload();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 导出 / 导入 */
|
||||
|
||||
function saveBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function exportSnapshot(): Promise<void> {
|
||||
const blob = await databaseApi.downloadSnapshot();
|
||||
saveBlob(blob, `classhelper-snapshot-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
}
|
||||
|
||||
async function exportSqliteFile(): Promise<void> {
|
||||
const blob = await databaseApi.downloadSqliteFile();
|
||||
saveBlob(blob, 'classhelper.db');
|
||||
}
|
||||
|
||||
const importUploadRef = ref<UploadInstance>();
|
||||
const importing = ref(false);
|
||||
|
||||
/** 二进制 → base64(分块转换,避免大文件把 String.fromCharCode 的调用栈撑爆) */
|
||||
function bufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunks: string[] = [];
|
||||
const step = 0x8000;
|
||||
for (let index = 0; index < bytes.length; index += step) {
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(index, index + step)));
|
||||
}
|
||||
return btoa(chunks.join(''));
|
||||
}
|
||||
|
||||
/** 读文件为 base64 后整库导入(覆盖现有数据) */
|
||||
async function handleImportFile(file: File): Promise<void> {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
'导入会用快照内容覆盖当前数据库的全部数据。确定继续?',
|
||||
'导入快照',
|
||||
{ type: 'warning', confirmButtonText: '导入', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!confirmed) return;
|
||||
importing.value = true;
|
||||
try {
|
||||
await databaseApi.importSnapshot(bufferToBase64(await file.arrayBuffer()));
|
||||
ElMessage.success('快照已导入');
|
||||
await reload();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '导入失败');
|
||||
} finally {
|
||||
importing.value = false;
|
||||
importUploadRef.value?.clearFiles();
|
||||
}
|
||||
}
|
||||
|
||||
function onImportChange(uploadFile: UploadFile): void {
|
||||
if (uploadFile.raw) void handleImportFile(uploadFile.raw);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 定时备份 */
|
||||
|
||||
const scheduleForm = reactive<DatabaseBackupScheduleDto>({
|
||||
enabled: false,
|
||||
intervalHours: 24,
|
||||
keepCount: 7,
|
||||
lastAutoBackupAt: null,
|
||||
});
|
||||
const scheduleSaving = ref(false);
|
||||
|
||||
async function saveSchedule(): Promise<void> {
|
||||
scheduleSaving.value = true;
|
||||
try {
|
||||
const saved = await databaseApi.saveSchedule({
|
||||
enabled: scheduleForm.enabled,
|
||||
intervalHours: Number(scheduleForm.intervalHours),
|
||||
keepCount: Number(scheduleForm.keepCount),
|
||||
lastAutoBackupAt: null,
|
||||
});
|
||||
Object.assign(scheduleForm, saved);
|
||||
ElMessage.success('定时备份配置已保存');
|
||||
await reload();
|
||||
} finally {
|
||||
scheduleSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await reload();
|
||||
const schedule = status.value?.schedule;
|
||||
if (schedule) {
|
||||
Object.assign(scheduleForm, {
|
||||
enabled: schedule.enabled,
|
||||
intervalHours: schedule.intervalHours,
|
||||
keepCount: schedule.keepCount,
|
||||
lastAutoBackupAt: schedule.lastAutoBackupAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">数据库管理</h2>
|
||||
<p class="page-subtitle">连接状态、备份恢复、导入导出与一键切换(仅管理员可见)</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前状态 -->
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header><span>当前数据库</span></template>
|
||||
<el-descriptions :column="isMobile ? 1 : 2" size="small" border>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag size="small" :type="isSqlite ? 'success' : 'primary'">
|
||||
{{ isSqlite ? 'SQLite' : 'MySQL' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="连接状态">
|
||||
<el-tag size="small" :type="status?.connected ? 'success' : 'danger'">
|
||||
{{ status?.connected ? `已连接 · ${status.latencyMs ?? '-'}ms` : '未连接' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库版本">{{ status?.version ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据体积">{{ formatBytes(status?.sizeBytes) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="连接串">{{ status?.databaseUrlMasked ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备份目录">{{ status?.backupDir ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="isSqlite" label="数据文件" :span="isMobile ? 1 : 2">
|
||||
{{ status?.sqliteFilePath ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 各表行数 + 备份列表 -->
|
||||
<el-row :gutter="12" class="mt-12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header><span>各表行数</span></template>
|
||||
<el-table :data="status?.tables ?? []" size="small" max-height="360">
|
||||
<el-table-column prop="name" label="表" min-width="140" />
|
||||
<el-table-column label="行数" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.count >= 0" size="small" type="info" effect="plain">{{ row.count }}</el-tag>
|
||||
<el-tag v-else size="small" type="danger">异常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span>备份</span>
|
||||
<el-button size="small" type="primary" plain @click="createBackup">立即备份</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="backups" size="small" max-height="300" empty-text="暂无备份">
|
||||
<el-table-column label="文件" min-width="200">
|
||||
<template #default="{ row }">{{ row.name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.kind === 'manual' ? 'primary' : 'info'">
|
||||
{{ row.kind === 'manual' ? '手动' : '定时' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="90">
|
||||
<template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="restoreBackup(row.name)">恢复</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteBackup(row.name)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider />
|
||||
<el-form label-width="96px" size="small" class="schedule-form">
|
||||
<el-form-item label="定时备份">
|
||||
<el-switch v-model="scheduleForm.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="间隔(小时)">
|
||||
<el-input-number v-model="scheduleForm.intervalHours" :min="1" :max="720" :step="1" />
|
||||
<span class="form-hint">到点自动备份(服务端运行期间生效)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="保留份数">
|
||||
<el-input-number v-model="scheduleForm.keepCount" :min="1" :max="100" :step="1" />
|
||||
<span class="form-hint">超出后自动删最旧的定时备份</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="上次备份">
|
||||
<span class="form-hint">{{ formatTime(scheduleForm.lastAutoBackupAt) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="small" :loading="scheduleSaving" @click="saveSchedule">
|
||||
保存定时配置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 连接测试 / 导入导出 -->
|
||||
<el-row :gutter="12" class="mt-12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>连接测试(任意目标库)</span></template>
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="目标类型">
|
||||
<el-radio-group v-model="testForm.provider">
|
||||
<el-radio-button value="sqlite">SQLite</el-radio-button>
|
||||
<el-radio-button value="mysql">MySQL</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="连接串">
|
||||
<el-input
|
||||
v-model="testForm.url"
|
||||
:placeholder="
|
||||
testForm.provider === 'sqlite'
|
||||
? 'file:./data/target.db'
|
||||
: 'mysql://user:password@host:3306/classhelper'
|
||||
"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="testing" @click="runTest">测试连接</el-button>
|
||||
<el-alert
|
||||
v-if="testResult"
|
||||
class="mt-12"
|
||||
:type="testResult.ok ? 'success' : 'error'"
|
||||
:closable="false"
|
||||
:title="testResult.text"
|
||||
/>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>导入 / 导出</span></template>
|
||||
<p class="card-hint">
|
||||
快照为 JSON 格式(含全部 14 张表数据),可在 SQLite 与 MySQL 之间互相恢复;
|
||||
「数据库文件」仅 SQLite 提供整文件下载。
|
||||
</p>
|
||||
<div class="toolbar-row">
|
||||
<el-button @click="exportSnapshot">下载快照 JSON</el-button>
|
||||
<el-button v-if="isSqlite" @click="exportSqliteFile">下载数据库文件</el-button>
|
||||
<el-upload
|
||||
ref="importUploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="onImportChange"
|
||||
accept=".json,application/json"
|
||||
>
|
||||
<el-button type="warning" plain :loading="importing">导入快照(覆盖现有数据)</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<el-alert
|
||||
class="mt-12"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="导入会整库覆盖当前数据"
|
||||
description="建议先「立即备份」再导入;.json.gz 压缩备份请先解压为 .json 再导入。"
|
||||
/>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 一键切换 -->
|
||||
<el-card shadow="never" class="mt-12 section-card">
|
||||
<template #header><span>一键切换数据库(SQLite ⇄ MySQL)</span></template>
|
||||
<el-alert
|
||||
class="mb-12"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="切换会自动:备份当前库 → 建目标库表结构 → 迁移全部数据 → 改写 .env"
|
||||
description="目标库必须为空;完成后需重启服务端(安装版运行 restart.cmd,开发模式重跑 pnpm dev:server)才会连到新库。中途失败会自动回滚,当前数据库不受影响。"
|
||||
/>
|
||||
<el-form label-position="top" size="small" class="switch-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="6">
|
||||
<el-form-item label="目标类型">
|
||||
<el-radio-group v-model="switchForm.provider">
|
||||
<el-radio-button value="sqlite">SQLite</el-radio-button>
|
||||
<el-radio-button value="mysql">MySQL</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-form-item label="目标连接串(库必须为空)">
|
||||
<el-input
|
||||
v-model="switchForm.url"
|
||||
:placeholder="
|
||||
switchForm.provider === 'sqlite'
|
||||
? 'file:./data/classhelper-target.db'
|
||||
: 'mysql://user:password@host:3306/classhelper'
|
||||
"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="6">
|
||||
<el-form-item label="输入「确认迁移」启用按钮">
|
||||
<el-input v-model="switchForm.confirmText" placeholder="确认迁移" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="switchForm.confirmText !== '确认迁移' || switchPolling"
|
||||
@click="startSwitch"
|
||||
>
|
||||
开始迁移
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- 任务进度 -->
|
||||
<template v-if="switchJob">
|
||||
<el-descriptions class="mt-12" :column="isMobile ? 1 : 2" size="small" border>
|
||||
<el-descriptions-item label="任务状态">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="
|
||||
switchJob.status === 'done' ? 'success' : switchJob.status === 'error' ? 'danger' : 'warning'
|
||||
"
|
||||
>
|
||||
{{ switchJob.status === 'done' ? '已完成' : switchJob.status === 'error' ? '失败' : '进行中' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标">
|
||||
{{ switchJob.target.provider }} · {{ switchJob.target.url }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-steps class="mt-12" direction="vertical" :space="44">
|
||||
<el-step
|
||||
v-for="step in switchJob.steps"
|
||||
:key="step.name"
|
||||
:title="step.name"
|
||||
:status="switchStepStatus(step.status)"
|
||||
:description="step.detail"
|
||||
/>
|
||||
</el-steps>
|
||||
<el-alert
|
||||
v-if="switchJob.status === 'done'"
|
||||
class="mt-12"
|
||||
type="success"
|
||||
:closable="false"
|
||||
title="迁移完成:请立即重启服务端(restart.cmd 或重跑 pnpm dev:server),重启后刷新本页将显示新数据库"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="switchJob.status === 'error'"
|
||||
class="mt-12"
|
||||
type="error"
|
||||
:closable="false"
|
||||
:title="`迁移失败:${switchJob.error ?? '未知原因'}`"
|
||||
description="已自动回滚,当前数据库与 .env 未被修改。"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.schedule-form {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch-form {
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import * as echarts from 'echarts';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
SUBJECT_CATALOG,
|
||||
@@ -15,6 +14,8 @@ import {
|
||||
type StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, gradeApi } from '@/api';
|
||||
import ScoreBarChart from '@/components/ScoreBarChart.vue';
|
||||
import ScoreLineChart from '@/components/ScoreLineChart.vue';
|
||||
import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
@@ -41,10 +42,15 @@ const students = ref<StudentDto[]>([]);
|
||||
const stats = ref<GradeStats | null>(null);
|
||||
const filter = reactive({ classId: '', courseId: '', examName: '' });
|
||||
|
||||
const levelChartRef = ref<HTMLDivElement>();
|
||||
const courseChartRef = ref<HTMLDivElement>();
|
||||
const levelChart = ref<ReturnType<typeof echarts.init> | null>(null);
|
||||
const courseChart = ref<ReturnType<typeof echarts.init> | null>(null);
|
||||
/** 等级分布柱状图的数据(count 是整数,纵轴上限交给组件按数据自动取整) */
|
||||
const levelItems = computed(() =>
|
||||
(stats.value?.distribution ?? []).map((item) => ({ label: `${item.level} 等`, value: item.count })),
|
||||
);
|
||||
|
||||
/** 各课程平均得分率折线图的数据 */
|
||||
const courseItems = computed(() =>
|
||||
(stats.value?.byCourse ?? []).map((item) => ({ label: item.courseName, value: item.averagePercent })),
|
||||
);
|
||||
|
||||
const averagePercent = computed(() => {
|
||||
if (grades.value.length === 0) return 0;
|
||||
@@ -79,7 +85,6 @@ async function loadGrades(): Promise<void> {
|
||||
if (requestId !== gradesRequestId) return;
|
||||
grades.value = list;
|
||||
stats.value = statistic;
|
||||
renderCharts();
|
||||
} finally {
|
||||
if (requestId === gradesRequestId) loading.value = false;
|
||||
}
|
||||
@@ -94,55 +99,15 @@ async function onClassChange(): Promise<void> {
|
||||
await loadTeacherSuggestions();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 图表 */
|
||||
|
||||
function renderCharts(): void {
|
||||
const statistic = stats.value;
|
||||
if (!statistic) return;
|
||||
|
||||
if (levelChartRef.value) {
|
||||
levelChart.value ??= echarts.init(levelChartRef.value);
|
||||
levelChart.value.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 16, top: 30, bottom: 28 },
|
||||
xAxis: { type: 'category', data: statistic.distribution.map((item) => `${item.level} 等`) },
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barWidth: '46%',
|
||||
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
|
||||
data: statistic.distribution.map((item) => item.count),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (courseChartRef.value) {
|
||||
courseChart.value ??= echarts.init(courseChartRef.value);
|
||||
courseChart.value.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 16, top: 30, bottom: 28 },
|
||||
xAxis: { type: 'category', data: statistic.byCourse.map((item) => item.courseName) },
|
||||
yAxis: { type: 'value', max: 100, name: '得分率%' },
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbolSize: 8,
|
||||
areaStyle: { opacity: 0.12 },
|
||||
itemStyle: { color: '#67c23a' },
|
||||
data: statistic.byCourse.map((item) => item.averagePercent),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize(): void {
|
||||
levelChart.value?.resize();
|
||||
courseChart.value?.resize();
|
||||
}
|
||||
/* ------------------------------------------------------------ 图表
|
||||
*
|
||||
* 等级分布与各课程得分率原先用 echarts 渲染(`import * as echarts from 'echarts'`
|
||||
* 会给构建产物加 1.1MB)。现在换成两个自包含的轻量组件:
|
||||
* - ScoreBarChart:纯 HTML/CSS 柱状图
|
||||
* - ScoreLineChart:内联 SVG 折线 + HTML 数据点
|
||||
* 两者都是响应式的(百分比布局),因此不再需要 resize 监听,
|
||||
* 数据也直接从上面两个 computed 流过去,不需要手写 setOption。
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------ 统一科目
|
||||
*
|
||||
@@ -299,14 +264,10 @@ watch(
|
||||
onMounted(async () => {
|
||||
await loadClasses();
|
||||
await loadGrades();
|
||||
window.addEventListener('resize', handleResize);
|
||||
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
levelChart.value?.dispose();
|
||||
courseChart.value?.dispose();
|
||||
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||
});
|
||||
</script>
|
||||
@@ -361,13 +322,13 @@ onUnmounted(() => {
|
||||
<el-col :xs="24" :md="9">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>等级分布</span></template>
|
||||
<div ref="levelChartRef" class="chart"></div>
|
||||
<ScoreBarChart :items="levelItems" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="9">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>各课程平均得分率</span></template>
|
||||
<div ref="courseChartRef" class="chart"></div>
|
||||
<ScoreLineChart :items="courseItems" :max="100" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -503,10 +464,3 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Generated
+71
-45
@@ -41,9 +41,6 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.20.0
|
||||
version: 1.20.0
|
||||
echarts:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
element-plus:
|
||||
specifier: ^2.14.5
|
||||
version: 2.14.5(vue@3.5.42(typescript@5.9.3))
|
||||
@@ -96,6 +93,9 @@ importers:
|
||||
'@prisma/adapter-libsql':
|
||||
specifier: 7.10.0
|
||||
version: 7.10.0
|
||||
'@prisma/adapter-mariadb':
|
||||
specifier: 7.10.0
|
||||
version: 7.10.0
|
||||
'@prisma/client':
|
||||
specifier: 7.10.0
|
||||
version: 7.10.0(prisma@7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3)
|
||||
@@ -123,9 +123,15 @@ importers:
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
prisma:
|
||||
specifier: 7.10.0
|
||||
version: 7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)
|
||||
socket.io:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
typescript:
|
||||
specifier: ~5.9.3
|
||||
version: 5.9.3
|
||||
xlsx:
|
||||
specifier: 0.18.5
|
||||
version: 0.18.5
|
||||
@@ -148,18 +154,12 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.20.2
|
||||
version: 22.20.2
|
||||
prisma:
|
||||
specifier: 7.10.0
|
||||
version: 7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)
|
||||
socket.io-client:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
tsx:
|
||||
specifier: ^4.23.13
|
||||
version: 4.23.13
|
||||
typescript:
|
||||
specifier: ~5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/shared:
|
||||
devDependencies:
|
||||
@@ -178,9 +178,6 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.20.0
|
||||
version: 1.20.0
|
||||
echarts:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
element-plus:
|
||||
specifier: ^2.14.5
|
||||
version: 2.14.5(vue@3.5.42(typescript@5.9.3))
|
||||
@@ -646,6 +643,9 @@ packages:
|
||||
'@prisma/adapter-libsql@7.10.0':
|
||||
resolution: {integrity: sha512-ANjUjJ/TrDQhR8jFU8HvYWRdwP17Lox1WL81zZvHcPVeFehZfNomAsPMNY24EBTLLZp0Xw4Na5Zf1S+EuKMKxw==}
|
||||
|
||||
'@prisma/adapter-mariadb@7.10.0':
|
||||
resolution: {integrity: sha512-n/mNi6/l2V79pNwuvzRlZO+X8dr38eqjx1norvXs3t+bTJkSotxwV40HW0SCSl2vBp/YMfX2w6ZHKePgurf1gw==}
|
||||
|
||||
'@prisma/client-runtime-utils@7.10.0':
|
||||
resolution: {integrity: sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==}
|
||||
|
||||
@@ -1194,6 +1194,10 @@ packages:
|
||||
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
'@xmldom/xmldom@0.9.12':
|
||||
resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
abbrev@4.0.0:
|
||||
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
@@ -1701,9 +1705,6 @@ packages:
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@@ -2053,8 +2054,8 @@ packages:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
glob@7.2.0:
|
||||
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
@@ -2145,6 +2146,10 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2413,6 +2418,9 @@ packages:
|
||||
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
lru-cache@6.0.0:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2428,6 +2436,10 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mariadb@3.4.5:
|
||||
resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
matcher@3.0.0:
|
||||
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2684,6 +2696,10 @@ packages:
|
||||
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
||||
engines: {node: '>=10.4.0'}
|
||||
|
||||
plist@3.1.1:
|
||||
resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
|
||||
engines: {node: '>=10.4.0'}
|
||||
|
||||
postcss-selector-parser@7.1.6:
|
||||
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2987,8 +3003,8 @@ packages:
|
||||
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
||||
engines: {node: '>=10.2.0'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@@ -3093,9 +3109,6 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -3415,9 +3428,6 @@ packages:
|
||||
zod@4.6.2:
|
||||
resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==}
|
||||
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
@@ -3462,7 +3472,7 @@ snapshots:
|
||||
'@electron/asar@3.4.1':
|
||||
dependencies:
|
||||
commander: 5.1.0
|
||||
glob: 7.2.3
|
||||
glob: 7.2.0
|
||||
minimatch: 3.1.5
|
||||
|
||||
'@electron/fuses@1.8.0':
|
||||
@@ -3513,7 +3523,7 @@ snapshots:
|
||||
fs-extra: 10.1.0
|
||||
isbinaryfile: 4.0.10
|
||||
minimist: 1.2.8
|
||||
plist: 3.1.0
|
||||
plist: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3536,7 +3546,7 @@ snapshots:
|
||||
dir-compare: 4.2.0
|
||||
fs-extra: 11.4.0
|
||||
minimatch: 9.0.9
|
||||
plist: 3.1.0
|
||||
plist: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3831,6 +3841,11 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@prisma/adapter-mariadb@7.10.0':
|
||||
dependencies:
|
||||
'@prisma/driver-adapter-utils': 7.10.0
|
||||
mariadb: 3.4.5
|
||||
|
||||
'@prisma/client-runtime-utils@7.10.0': {}
|
||||
|
||||
'@prisma/client@7.10.0(prisma@7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3))(typescript@5.9.3)':
|
||||
@@ -4383,7 +4398,7 @@ snapshots:
|
||||
'@vue/shared': 3.5.42
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@vue/compiler-dom@3.5.42':
|
||||
dependencies:
|
||||
@@ -4400,7 +4415,7 @@ snapshots:
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.28
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
'@vue/compiler-ssr@3.5.42':
|
||||
dependencies:
|
||||
@@ -4469,6 +4484,8 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.15': {}
|
||||
|
||||
'@xmldom/xmldom@0.9.12': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
@@ -5012,11 +5029,6 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.1.0
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
effect@3.20.0:
|
||||
@@ -5502,7 +5514,7 @@ snapshots:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob@7.2.3:
|
||||
glob@7.2.0:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
@@ -5618,6 +5630,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -5843,6 +5859,8 @@ snapshots:
|
||||
|
||||
lowercase-keys@2.0.0: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@6.0.0:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
@@ -5857,6 +5875,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
mariadb@3.4.5:
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
'@types/node': 24.13.4
|
||||
denque: 2.1.0
|
||||
iconv-lite: 0.6.3
|
||||
lru-cache: 10.4.3
|
||||
|
||||
matcher@3.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 4.0.0
|
||||
@@ -6089,6 +6115,12 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
xmlbuilder: 15.1.1
|
||||
|
||||
plist@3.1.1:
|
||||
dependencies:
|
||||
'@xmldom/xmldom': 0.9.12
|
||||
base64-js: 1.5.1
|
||||
xmlbuilder: 15.1.1
|
||||
|
||||
postcss-selector-parser@7.1.6:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
@@ -6098,7 +6130,7 @@ snapshots:
|
||||
dependencies:
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
postgres@3.4.7: {}
|
||||
|
||||
@@ -6243,7 +6275,7 @@ snapshots:
|
||||
|
||||
rimraf@2.6.3:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
glob: 7.2.0
|
||||
|
||||
roarr@2.15.4:
|
||||
dependencies:
|
||||
@@ -6442,7 +6474,7 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
dependencies:
|
||||
@@ -6547,8 +6579,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.23.13:
|
||||
@@ -6813,7 +6843,3 @@ snapshots:
|
||||
graphmatch: 1.1.1
|
||||
|
||||
zod@4.6.2: {}
|
||||
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user