feat(server,web,desktop,shared,deploy,plugin): 数据库管理模块 + 客户端深色模式/设置拆子路由/首启引导,版本统一 1.0.0

- 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份
  共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换
  (备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写
  .env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript
  (prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大)
- Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写
  localStorage,改版后老用户会再收到一次)
- 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由
  (/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置,
  主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息)
- 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone)
- 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中)
- 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、
  服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名
- 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口
  尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条
  数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导

验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68;
build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例,
客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。

注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
This commit is contained in:
LoveChengke committed 2026-10-02 11:18:47 +08:00
1 parent 391e2c1591
commit 572295c297
66 files changed
+5131 -1027

No files matched your search

+2
View File
@@ -46,3 +46,5 @@ releases/
# AI 浼氳瘽浜х墿锛圸Code 璁″垝/閰嶇疆锛屼笉灞炰簬浜у搧浠g爜锛� # AI 浼氳瘽浜х墿锛圸Code 璁″垝/閰嶇疆锛屼笉灞炰簬浜у搧浠g爜锛�
.zcode/ .zcode/
.zcodeignore .zcodeignore
packages/server/prisma/backups/
packages/server/prisma/database-settings.json
+88 -11
View File
@@ -89,7 +89,7 @@ packages/desktop-client/
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约 src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win scripts/ build-main / dev / smoke / dist-win
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install) 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.0.0.0
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0) ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs src/Models/PluginSettings.cs
@@ -395,9 +395,9 @@ homeworks / notifications / calls / imports / integrations / grades / students /
长高发生在卡片形变**之前**(`setState` 里先 `relayout()` 再 `emit()`),用户看不到这一步; 长高发生在卡片形变**之前**(`setState` 里先 `relayout()` 再 `emit()`),用户看不到这一步;
- 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量**一一对应**, - 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量**一一对应**,
改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。 改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。
回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。 回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。
写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**, 写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**,
否则量到的是列表高度。 否则量到的是列表高度。
34. **"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。** 34. **"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。**
收起一律是**主进程单向**的:`handleAction()` 收到 `dismiss` / `dismiss-all` / `mark-read` / 收起一律是**主进程单向**的:`handleAction()` 收到 `dismiss` / `dismiss-all` / `mark-read` /
@@ -438,7 +438,7 @@ homeworks / notifications / calls / imports / integrations / grades / students /
早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断"); 早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断");
而"隐藏态改边距不生效"那条修法仍要保留:`this.win.isVisible() && (mode !== 'hidden' || idleSliver)`。 而"隐藏态改边距不生效"那条修法仍要保留:`this.win.isVisible() && (mode !== 'hidden' || idleSliver)`。
40. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动; 37. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动;
胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回" 胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
(用户反馈的"新消息那行字往右跳一下再缩回")。所以 `IslandApp.vue` 的胶囊层里套了一层 (用户反馈的"新消息那行字往右跳一下再缩回")。所以 `IslandApp.vue` 的胶囊层里套了一层
`.pill-inner`,**固定成胶囊自己的几何**(`width: var(--island-w)`),并按停靠方式对齐 `.pill-inner`,**固定成胶囊自己的几何**(`width: var(--island-w)`),并按停靠方式对齐
@@ -446,7 +446,7 @@ homeworks / notifications / calls / imports / integrations / grades / students /
左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。 左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。
回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。 回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。
41. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。** 38. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
`verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端 `verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端
默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为 默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为
**"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。 **"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。
@@ -457,35 +457,112 @@ homeworks / notifications / calls / imports / integrations / grades / students /
**只改颜色、不要改停靠位置**:命中/几何断言都是按默认位置写的,把岛挪到角落会把 **只改颜色、不要改停靠位置**:命中/几何断言都是按默认位置写的,把岛挪到角落会把
「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。 「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。
另外:冒烟会**真发通知**给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/ 另外:冒烟会**真发通知**给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/
岛上会短暂出现几条「张老师 · *自检*」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`), 岛上会短暂出现几条「张老师 · _自检_」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`),
若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。 若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。
42. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟, 39. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟,
而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着, 而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着,
他点到的是**冒烟这个窗口**:实测冒烟收到了人发出的 `collapse` / `mark-read`,把「点胶囊展开」 他点到的是**冒烟这个窗口**:实测冒烟收到了人发出的 `collapse` / `mark-read`,把「点胶囊展开」
「点空白处收起」等用例整片弄红。开关只忽略**真实**输入:`cursorOverride`(`setHitTestCursor`) 「点空白处收起」等用例整片弄红。开关只忽略**真实**输入:`cursorOverride`(`setHitTestCursor`)
一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」 一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」
这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。 这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。
37. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读 40. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读
一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件 一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件
无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watch `route.query.date` 无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watch `route.query.date`
(`HomeworkView.vue`,与 `selectDate` 的 `router.replace` 形成环时靠"值相同就跳过"防死循环)。 (`HomeworkView.vue`,与 `selectDate` 的 `router.replace` 形成环时靠"值相同就跳过"防死循环)。
同理,冒烟里"切到有作业的日期"的判据是**看板里没有 `.board-card`**,不是"没有 `.board-host`" 同理,冒烟里"切到有作业的日期"的判据是**看板里没有 `.board-card`**,不是"没有 `.board-host`"
(空看板照样渲染容器,拿容器当判据兜底永远不会触发)。 (空看板照样渲染容器,拿容器当判据兜底永远不会触发)。
38. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true` 41. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true`
(上课时段普通通知会被**正确地**暂存到下课、紧急通知不带确认会被 409 `URGENT_DURING_CLASS` 拒掉 —— (上课时段普通通知会被**正确地**暂存到下课、紧急通知不带确认会被 409 `URGENT_DURING_CLASS` 拒掉 ——
两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律**轮询**等待, 两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律**轮询**等待,
不要写死 sleep:`dismiss` 现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空 不要写死 sleep:`dismiss` 现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空
(「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。 (「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。
39. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境): 42. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境):
① **后端起没起** —— 客户端配置指向 `127.0.0.1:4000`,后端不在就进离线模式、数据全空, ① **后端起没起** —— 客户端配置指向 `127.0.0.1:4000`,后端不在就进离线模式、数据全空,
观感就是"软件坏了";② **托盘单实例锁** —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被 观感就是"软件坏了";② **托盘单实例锁** —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。 单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。 完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。
43. **Web 端新手引导(`el-tour`)在自动化里的判据有三条坑**(2026-10-02 实测,`verify:web`
「首次登录展示新手引导且可跳过」踩过):
① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立,
急着找 `.el-tour__closebtn` 会拿到 null;出现判据要等 **× 按钮渲染出来**(等 10s 不为过)。
② **关闭后内容元素残留** —— 和 el-dialog 一样(本表第 22 条),点 × 生效后 `.el-tour` 仍在 DOM
(父级 `.el-popper` 被隐藏,computed display 仍是 block);**关闭判据是 `.el-tour__mask` 卸载**,
不是 `.el-tour` 消失。跳过 / 走完都会写 `classhelper.onboarding`(版本号),可顺带断言。
③ **全高元素(侧边栏)作锚点必须显式 `placement: 'right'`** —— 默认 bottom 会把卡片推到
视口外(表现为"遮罩挖了洞、卡片不见了")。
另外 `ui-smoke` 管理员用例里的 `localStorage.clear()` 会把「已看过」标记一并清掉,管理员登录后
引导会再次弹出 —— 该用例里必须先跳过它再继续,别只处理首次登录那一次。
44. **数据库管理模块(服务端)** 见 §5 第 36 条;做"切库/备份/迁移"类改动前先读那一条,
那里写清了快照格式、外键陷阱、切换为什么必须走子进程、以及打包为何要带上 prisma CLI + tsc。
45. **客户端设置页已拆成子路由**(2026-10-02):`/settings/{general,appearance,island,reminder,account,about}`,
`/settings` 重定向到 `/settings/general`;旧的单一 `views/SettingsView.vue` **已删除**。
改设置相关冒烟用例时必须改到对应子路由(`smoke.ts` 里 `location.hash = '#/settings'` 已全部改掉),
否则会停在通用页、找不到灵动岛滑块或「ClassIsland 联动」卡片而误报失败。
46. **深色主题的实现边界**:颜色一律走 `--ch-*` / `--el-*` 变量(`styles/index.css` 的 `:root`
是浅色值、`html.dark` 是深色值),**组件里不要写死颜色**。已知的固定深色区域(灵动岛、
登录页、App 启动过渡页、全屏作业看板)刻意不随主题 —— 改它们会被当成回归。
主题状态在 `stores/ui.ts`(持久化到主进程配置 `theme`),主窗口 `backgroundColor` 也按主题设置
(深色下若仍用浅色底会"先白后黑"闪一下)。
47. **数据库管理模块(`/api/database/*`,仅 ADMIN)——改它之前必须知道的四件事**:
① **一切都建立在 `lib/snapshot.ts` 的 JSON 快照上**(备份 / 导入导出 / 跨库迁移共用一种格式,
14 张表按拓扑序导出、恢复时临时关外键检查、分批 `createMany`)。恢复时**必须**先关外键检查:
schema 里 `User.classId → Class` 与 `Class.teacherId → User` **互相引用**,任何排序都会被一侧卡死。
② **一键切换必须走子进程**:运行中的服务进程缓存的是旧方言的 Prisma Client,改完 provider 后
只有**新进程**才能加载重新生成的客户端 —— 因此 `generate` / `db push` / 写数据分别用
`spawn(process.execPath, ...)` 拉起(数据写入走 `dist/tools/apply-snapshot.js`)。
**顺序不能换**:备份 → 导出 → 测试连接 → 校验目标库为空 → 改 schema → generate → 编译 → db push → 写数据 → 改 .env;
`.env` 只在**全部成功后**才改写,中途失败回滚 `schema.prisma`(现有库不受影响)。
③ **`prisma generate` 的产物是 `.ts`**(`prisma-client` 生成器),所以:dev 由 tsx 直接跑 src;
**打包形态必须再跑一次 `tsc -p tsconfig.generate.json`** 把新客户端编进 `dist/generated`,
生产包因此要随包带上 `prisma` CLI、`typescript` 与 `schema.prisma`/`prisma.config.ts`
(`scripts/dist-server.mjs` 的 `buildRuntime` 已拷;安装包 ~34MB → ~71MB)。
④ **主库只支持 SQLite / MySQL**:Redis 等键值库 Prisma 不支持(无法建表/迁移),
schema 层直接 422 拒绝;这是产品口径,别"顺手"加上。
e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空),**不真跑切换**(会改写 .env);
真实切换用子进程链路单独实测(快照 728 行逐表一致)。切换后**必须重启服务端**才生效。
48. **客户端主窗口初始尺寸与 ClassIsland 对齐(1242×582)**。这个数不是拍的:用
`GetWindowRect` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` 量运行中的
`ClassIsland.Desktop` 主窗口(实测可视区 1242×582,屏幕工作区 1600×852、DPI 100%)。
`main/index.ts` 的 `createWindow` 按它设 width/height + `center: true`,
**`minHeight` 必须 ≤ 582**(否则窗口达不到目标高度,用户会看到"设置不了那个大小")。
冒烟断言:「初始窗口尺寸与 ClassIsland 对齐(1242x582)」(`win.getSize()`),改尺寸即红。
49. **侧栏(分组折叠菜单)的三条硬要求**,都是用户报过的问题固化的:
① **折叠态文字必须 `display: none`** —— Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 +
overflow",外层只要有缩进/margin/transition 干扰,文字就会以"0 宽溢出"留在画面上
(用户截图里的半截字)。规则写在 `styles/index.css` 的 `.ch-nav .el-menu--collapse ... > span:not(.menu-icon-slot)`。
② **折叠态图标必须与顶部汉堡按钮同一竖列**(冒烟断言中心差 ≤ 2px)—— 一个居中、一个偏左就会
看起来"没对齐"。汉堡在 `.brand` 里也是居中的,两边都在 64/2 = 32px。
③ **菜单区必须 `min-height: 0` + `overflow-y: auto`** —— 否则菜单项多时会把底部
「第 N 周 / 最近同步」挤出窗口(窗口 582px 高、两组全展开时必然发生)。footer 是 aside 的
兄弟 flex 子项,菜单区滚动后它恒定可见(冒烟断言「侧栏底部可见」)。
另:分组**默认只展开当前路由所在分组**(ClassIsland 同款,也让小窗口侧栏不溢出);
`el-sub-menu__title` 的点击是 **toggle**,自动化里要先判"子项是否可见"再决定点不点标题
(盲点会把已展开的组收起来,后续点不到子项)。
50. **深色主题:渲染进程里除固定深色区域外,颜色一律走 `--ch-*` 变量。**
§5 第 46 条讲了机制,这里补具体踩坑:课表页时间轴卡片曾写死 `rgba(255, 255, 255, 0.6)`,
深色主题下变成"浅灰卡 + 近白字"几乎不可读(用户截图)。自查命令:
`grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer`,
除**明确固定深色**的区域(全屏作业看板、登录页渐变、App 启动过渡页、灵动岛自身、
以及 accent 渐变底上的白字)外,都应该是变量。
51. **跑 `verify:desktop` / `verify:packaged` 若大面积报"教师登录失败",先核对本机 teacher1 的密码**:
本机实测是 **`123456`**,不是种子默认的 `teacher123` —— 因为「修改密码」弹窗留空会落到
`DEFAULT_TEACHER_PASSWORD`(=123456),某次人工/脚本操作就会把演示账号改成它。
处理方式是用环境变量传实际凭据(脚本原生支持,**不要去改数据**):
`set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop`。
--- ---
## 6. 代码风格 ## 6. 代码风格
+249 -151
View File
@@ -113,10 +113,13 @@ class-helper/
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出) │ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
│ ├── config/env.ts # 环境变量校验(zod)+ 生产配置自检 │ ├── config/env.ts # 环境变量校验(zod)+ 生产配置自检
│ ├── lib/ # access(RBAC) / class-account / session / db / db-bootstrap / │ ├── lib/ # access(RBAC) / class-account / session / db / db-bootstrap /
│ │ # web-static / http / jwt / logger / mappers / password / schemas / term │ │ # snapshot(全库 JSON 快照) / web-static / http / jwt / logger /
│ │ # mappers / password / schemas / term
│ ├── tools/apply-snapshot.ts # 跨库迁移的子进程入口(编译到 dist/tools/)
│ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志) │ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志)
│ ├── realtime/ # socket.ts + bus.ts(事件总线) │ ├── realtime/ # socket.ts + bus.ts(事件总线)
│ └── modules/ # 13 个功能模块 + registry.ts(模块注册表) │ └── modules/ # 14 个功能模块 + registry.ts(模块注册表)
│ └── database/ # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员)
├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace) ├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace)
│ ├── src/Plugin.cs # 插件入口(读配置 → 注册提醒提供方/设置页/联动服务) │ ├── src/Plugin.cs # 插件入口(读配置 → 注册提醒提供方/设置页/联动服务)
│ ├── src/Models/PluginSettings.cs │ ├── src/Models/PluginSettings.cs
@@ -251,14 +254,14 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
课表 93(每班 5 天 × 6 节 + 1 条双周对调)/ 作业 15 / 通知 12 / 成绩 90 课表 93(每班 5 天 × 6 节 + 1 条双周对调)/ 作业 15 / 通知 12 / 成绩 90
``` ```
| 角色 | 用户名 | 密码 | 说明 | | 角色 | 用户名 | 密码 | 说明 |
| ------ | ------------- | ------------ | ------------------------------------------- | | ------ | ------------- | ------------ | ------------------------------------------ |
| 管理员 | `admin` | `admin123` | 可访问全部班级 | | 管理员 | `admin` | `admin123` | 可访问全部班级 |
| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 | | 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 |
| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 | | 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 |
| 班级 | 班级码 `G101` | `123456` | 高一(1)班(**学生端唯一登录方式**) | | 班级 | 班级码 `G101` | `123456` | 高一(1)班(**学生端唯一登录方式**) |
| 班级 | 班级码 `G102` | `123456` | 高一(2)班 | | 班级 | 班级码 `G102` | `123456` | 高一(2)班 |
| 班级 | 班级码 `G203` | `123456` | 高二(3)班 | | 班级 | 班级码 `G203` | `123456` | 高二(3)班 |
- 密码由**种子脚本硬编码**(`admin123` / `teacher123` / 班级 `123456`);`.env` 的 - 密码由**种子脚本硬编码**(`admin123` / `teacher123` / 班级 `123456`);`.env` 的
`DEFAULT_CLASS_PASSWORD` 决定**新建**班级时的初始班级密码。 `DEFAULT_CLASS_PASSWORD` 决定**新建**班级时的初始班级密码。
@@ -307,9 +310,18 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
布局沿用 **1Panel 风格**:固定深色侧边栏(`#1f2d3d` + 蓝色圆角选中态)+ 白色顶栏(当前页标题/连接状态/用户菜单)+ 布局沿用 **1Panel 风格**:固定深色侧边栏(`#1f2d3d` + 蓝色圆角选中态)+ 白色顶栏(当前页标题/连接状态/用户菜单)+
浅灰底 + 白色圆角卡片内容区。手机小屏下侧边栏收进抽屉,由顶栏汉堡按钮唤出。 浅灰底 + 白色圆角卡片内容区。手机小屏下侧边栏收进抽屉,由顶栏汉堡按钮唤出。
页面(路由)共 11 个:登录、仪表盘、班级管理、学生管理、教师管理、课表管理、作业发布、通知发布、 页面(路由)共 12 个:登录、仪表盘、班级管理、学生管理、教师管理、课表管理、作业发布、通知发布、
成绩录入、ClassIsland 联动、页面不存在;其中**班级管理 / 学生管理 / 教师管理**为 `meta.roles: ['ADMIN']` 成绩录入、ClassIsland 联动、数据库管理、页面不存在;其中**班级管理 / 学生管理 / 教师管理 / 数据库管理**
(直接输网址会被挡回仪表盘,服务端同样拦),**ClassIsland 联动**为 `['ADMIN','TEACHER']`。 为 `meta.roles: ['ADMIN']`(直接输网址会被挡回仪表盘,服务端同样拦),
**ClassIsland 联动**为 `['ADMIN','TEACHER']`。
### 新手引导(首次登录 + 随时重看)
首次登录进入主布局后会自动弹出**聚焦式引导**(Element Plus `el-tour`):欢迎页 → 侧边菜单(小屏为汉堡按钮,
锚点不存在时卡片自动改为屏幕居中)→ 实时通道状态 → 账号菜单 → 完成。走完或跳过(右上角 × / Esc)即记为
"已看过"(localStorage `classhelper.onboarding`,值为引导版本号,内容改版后 +1 可让老用户再看一次),
之后不再自动弹出;**右上角头像菜单 →「使用引导」可随时重看**。实现集中在 `AdminLayout.vue`(锚点用
`data-tour` 属性惰性查询)。
### 圆角设计(统一设计令牌) ### 圆角设计(统一设计令牌)
@@ -410,18 +422,52 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
渲染进程能通过桥接拿到明文登录令牌,CSP 是唯一能拦住"加载并执行远程脚本"的那一层; 渲染进程能通过桥接拿到明文登录令牌,CSP 是唯一能拦住"加载并执行远程脚本"的那一层;
灵动岛窗口另有独立的导航防线(拒绝一切导航与新开窗口)。 灵动岛窗口另有独立的导航防线(拒绝一切导航与新开窗口)。
**主窗口初始尺寸与 ClassIsland 对齐(1242×582)**:这个数是从运行中的 ClassIsland 主窗口实测来的
(`GetWindowRect` + `DwmWindowAttribute` 读可视区),启动时居中显示;窗口底色按主题设置,避免深色下"先白后黑"。
**安装包里不得出现任何凭据**:`dist/main/index.js` 与 `dist/renderer/assets/*.js` 会被打进 `app.asar`(可直接解包),因此冒烟/自检代码用的账号口令一律由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量(`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `..._CLASS_CODE` / `..._CLASS_PASSWORD`)注入,不写在源码里;`pnpm build:desktop` 结尾会跑 `check-bundle-secrets` 门禁,产物里一旦出现种子口令即构建失败。 **安装包里不得出现任何凭据**:`dist/main/index.js` 与 `dist/renderer/assets/*.js` 会被打进 `app.asar`(可直接解包),因此冒烟/自检代码用的账号口令一律由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量(`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `..._CLASS_CODE` / `..._CLASS_PASSWORD`)注入,不写在源码里;`pnpm build:desktop` 结尾会跑 `check-bundle-secrets` 门禁,产物里一旦出现种子口令即构建失败。
### 功能页面 ### 功能页面
| 页面 | 功能 | | 页面 | 功能 |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 登录 | 服务器地址 + **班级码 + 班级密码**,附带「测试连接」;已登录但服务器不可达时可「离线进入」 | | 登录 | 服务器地址 + **班级码 + 班级密码**,附带「测试连接」;已登录但服务器不可达时可「离线进入」;下方有「查看使用引导」入口 |
| 课表 | 按周展示 7 列课表(周次下拉 + 单双周标签),另有「今天」时间轴视图(大时钟 + 当前状态卡 + 已结束/正在上/下一节) | | 课表 | 按周展示 7 列课表(周次下拉 + 单双周标签),另有「今天」时间轴视图(大时钟 + 当前状态卡 + 已结束/正在上/下一节) |
| 作业 | **看板模式**(按科目卡片、全屏放大、显示时间开关、看板字号)+ 列表模式、详情抽屉、附件链接(只接受 `http(s)://` 或站内相对路径,挡住 `javascript:` 这类可执行链接);教室设备可**录入作业**(快捷短语)与勾选**未交名单** | | 作业 | **看板模式**(按科目卡片、全屏放大、显示时间开关、看板字号)+ 列表模式、详情抽屉、附件链接(只接受 `http(s)://` 或站内相对路径,挡住 `javascript:` 这类可执行链接);教室设备可**录入作业**(快捷短语)与勾选**未交名单** |
| 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 | | 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 |
| 成绩 | 班级账号看到**全班成绩总览**:表格(分数/得分率/等级)+ ECharts 柱状图 + 等级分布 + 最近更新 | | 成绩 | 班级账号看到**全班成绩总览**:表格(分数/得分率/等级)+ ECharts 柱状图 + 等级分布 + 最近更新 |
| 设置 | 服务器地址(保存并测试)、通知显示位置、ClassIsland 联动状态、作业录入短语、灵动岛个性化、**修改密码**、缓存统计与清空、版本信息、退出登录 | | 设置 | 拆成六个子页(见下):通用(服务器/离线缓存/作业录入短语 + 使用引导)、外观(黑夜/白天)、灵动岛、提醒(ClassIsland 联动)、账号、关于 |
### 设置改版:分组侧边栏 + 子页 + 深色模式 + 关于页
**侧边栏(ClassIsland 同款 NavigationView)**:主侧边栏改为「学习」「设置」两个**可展开分组**
(学习 = 课表/作业/通知/成绩;设置 = 通用/外观/灵动岛/提醒/账号/关于,每个子项是独立路由
`/settings/*`);顶部**汉堡按钮**把整栏折叠成 64px 图标栏(折叠状态持久化到主进程配置,
重启仍记忆)。红点徽标仍挂在「通知」项图标右上角。
**设置子页**:原单页设置按卡片拆为 6 个子页——通用(服务器连接 + 离线缓存 + 作业录入短语 +
「使用引导」)、外观、灵动岛、提醒、账号、关于;设置入口重定向到 `/settings/general`。
**黑夜/白天模式**:外观子页选「浅色 / 深色」,顶栏右上角有日月快捷切换按钮;切换立即生效
(`html.dark` + Element Plus dark css-vars + 品牌变量 `html.dark` 覆盖)并写入主进程配置
(`theme` 字段),启动窗口底色也按主题设置(避免"先白后黑"闪一下)。灵动岛、登录页、
全屏作业看板本来就是深色/固定配色,不随主题变化。
**关于页**(ClassIsland 同款折叠卡片):应用信息(版本 / Electron / Chromium / Node / 常用链接)、
查看诊断信息(服务器与连接状态、缓存条目、数据目录、**配置文件路径**)、鸣谢;底部寄语。
诊断信息依赖 `getAppInfo` 新增的 `configPath` 字段。
### 初次启动引导(六步向导,可重看)
客户端**首次启动**(配置里没有 `onboardingDone`)会在登录页上自动弹出六步向导:
欢迎 → 连接服务器 → 登录班级 → 登录之后(主界面与离线缓存)→ 灵动岛提醒 → 完成。
完成或跳过(右上角「跳过引导」/ Esc)都会把 `onboardingDone: true` 写入主进程配置(`config.json`),
之后不再自动弹出;**登录页「查看使用引导」与「设置 → 通用 → 使用引导」可随时重看**。
实现说明:引导是自绘遮罩 + `v-if`(`renderer/components/OnboardingWelcome.vue`),关闭即从 DOM 卸载
(特意不用 el-dialog,避免"关闭后 DOM 残留"的断言陷阱);显隐由 `stores/onboarding.ts` 承载,
让登录页 / 设置页 / App.vue 三处共用一个开关。`verify:desktop` 有一条端到端回归:
「初次启动引导(首启自动弹出、可走完、状态写入配置)」——真实点击把六步走完并断言配置落盘。
**作业看板(学生端)**:默认按科目分卡片(每张卡列该科作业,条目可点开详情), **作业看板(学生端)**:默认按科目分卡片(每张卡列该科作业,条目可点开详情),
工具栏可切「看板 / 列表」、开关「显示时间」、拖「字号」(11–28px 即时生效), 工具栏可切「看板 / 列表」、开关「显示时间」、拖「字号」(11–28px 即时生效),
@@ -492,27 +538,27 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
> >
> 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。 > 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。
| 场景 | 灵动岛行为 | | 场景 | 灵动岛行为 |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` | | 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` |
| 多条未处理消息 | 胶囊标题汇总类型:`新消息:叫人/作业/通知(共 3 条)` + `点击查看`,右侧附类型小标签(单条时显示具体类型名) | | 多条未处理消息 | 胶囊标题汇总类型:`新消息:叫人/作业/通知(共 3 条)` + `点击查看`,右侧附类型小标签(单条时显示具体类型名) |
| 点击胶囊(只有一条) | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` | | 点击胶囊(只有一条) | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` |
| **点击胶囊(多条)** | 展开为**竖排列表**:按**重要程度**排序(紧急 > 重要 > 普通 > 低,同级按时间新的在前),默认**只显示前三条**,下面一行 `展开更多(还有 N 条)`;列表始终只有**一排按钮** | | **点击胶囊(多条)** | 展开为**竖排列表**:按**重要程度**排序(紧急 > 重要 > 普通 > 低,同级按时间新的在前),默认**只显示前三条**,下面一行 `展开更多(还有 N 条)`;列表始终只有**一排按钮** |
| **点"展开更多"** | 把放得下的通知都铺出来;铺到屏幕工作区下沿(任务栏)仍放不下时,不再硬撑,改成 `更多请前往应用内操作`(卡片高度始终 ≤ 可用高度) | | **点"展开更多"** | 把放得下的通知都铺出来;铺到屏幕工作区下沿(任务栏)仍放不下时,不再硬撑,改成 `更多请前往应用内操作`(卡片高度始终 ≤ 可用高度) |
| **多条时的"知道了 / 标为已读"** | 都是**整批**操作:`知道了` 整批关闭(通知中心仍为未读)、`标为已读` 把这一批全部标为已读并关闭;两者都是状态同步清空 + 窗口淡出隐藏 | | **多条时的"知道了 / 标为已读"** | 都是**整批**操作:`知道了` 整批关闭(通知中心仍为未读)、`标为已读` 把这一批全部标为已读并关闭;两者都是状态同步清空 + 窗口淡出隐藏 |
| **上课时段的"普通叫人"** | **不自动展开**(不打断课堂),但保留 `叫人` 胶囊并**允许学生主动点开**;下课自动展开(`isOpenable`:紧急消息与任何叫人都可点开) | | **上课时段的"普通叫人"** | **不自动展开**(不打断课堂),但保留 `叫人` 胶囊并**允许学生主动点开**;下课自动展开(`isOpenable`:紧急消息与任何叫人都可点开) |
| **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失**;彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段 | | **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失**;彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段 |
| **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中 | | **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中 |
| 点击卡片空白处 / 屏幕任意位置 | 回缩为**胶囊**(不直接消失,仍可再次点开);展开时窗口临时可聚焦,点到别处即失焦收起 | | 点击卡片空白处 / 屏幕任意位置 | 回缩为**胶囊**(不直接消失,仍可再次点开);展开时窗口临时可聚焦,点到别处即失焦收起 |
| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(多条时是整批,见上) | | **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(多条时是整批,见上) |
| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见作业要求(**截止时间功能已下线**);**本机刚录入的那条不上岛**(自己通知自己没意义,见下) | | **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见作业要求(**截止时间功能已下线**);**本机刚录入的那条不上岛**(自己通知自己没意义,见下) |
| **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) | | **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) |
| **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) | | **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) |
| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 | | 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 |
| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情 → 收起为胶囊 | | 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情 → 收起为胶囊 |
| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | | 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 |
| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 | | 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 |
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current` | | 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current` |
- 动画:窗口尺寸用逐帧缓动实现"形变",**单调不过冲**——展开 300ms、收回 220ms,统一 `easeOutCubic` - 动画:窗口尺寸用逐帧缓动实现"形变",**单调不过冲**——展开 300ms、收回 220ms,统一 `easeOutCubic`
(早期版本用过冲弹簧缓动,实测窗口会冲到目标之外再回落,观感就是"开合时震一下",现已移除); (早期版本用过冲弹簧缓动,实测窗口会冲到目标之外再回落,观感就是"开合时震一下",现已移除);
@@ -580,7 +626,10 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
### 个性化设置与系统托盘 ### 个性化设置与系统托盘
**设置页分区**:服务器地址 / 通知显示位置 / ClassIsland 联动 / 作业录入短语 / 灵动岛 · 个性化 / 账号信息 / 离线缓存 / 关于。 **设置页结构(2026-10-02 改版)**:侧边栏「设置」分组下六个子页 ——
**通用**(服务器地址 / 离线缓存 / 作业录入短语 / 使用引导)、**外观**(黑夜白天)、
**灵动岛**(下表全部选项)、**提醒**(通知显示位置 / ClassIsland 联动状态)、**账号**(账号信息 / 退出登录)、
**关于**(版本 / 诊断信息 / 鸣谢)。详见上文「设置改版」一节。
> **改密入口(2026-10-01 调整)**:客户端不再提供「修改密码」(曾有过,改的是班级密码;随学生个人 > **改密入口(2026-10-01 调整)**:客户端不再提供「修改密码」(曾有过,改的是班级密码;随学生个人
> 账号清理一并移除,防止教室机器上误改班级密码后其他机器被锁在门外)。班级密码由管理员在 Web 端 > 账号清理一并移除,防止教室机器上误改班级密码后其他机器被锁在门外)。班级密码由管理员在 Web 端
@@ -1010,74 +1059,113 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
- 部署到 `D:\Classisland`(先退出 ClassIsland 再覆盖 DLL,别覆盖 `Settings.json`)的完整步骤见 - 部署到 `D:\Classisland`(先退出 ClassIsland 再覆盖 DLL,别覆盖 `Settings.json`)的完整步骤见
[AGENTS.md](AGENTS.md) §7 第 12 条与 §8。 [AGENTS.md](AGENTS.md) §7 第 12 条与 §8。
## 数据库管理(仅管理员)
Web 管理端新增「数据库管理」页(`/database`,仅 ADMIN 可见,后端同样 `requireRole('ADMIN')`),
把过去要手工完成的数据库运维全部收进界面:
| 能力 | 说明 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、14 张表行数、连接串(MySQL 密码脱敏) |
| 连接测试 | 任意目标库的连通性试连(切换前的第一步) |
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚 |
| 备份 / 恢复 | gzip JSON 快照存 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
| 定时备份 | enabled + 间隔小时数 + 保留份数,存 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(进程不运行不补跑) |
| 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) |
关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,14 张表按拓扑序导出,
恢复时临时关外键检查——`User.classId` 与 `Class.teacherId` 互相引用)。因此任意备份都能恢复回任意一种
受支持的数据库。MySQL 的连接测试/建表/写入依赖随包内置的 `@prisma/adapter-mariadb`(已在依赖里,
不再是"切换时手动安装")。
> 边界与须知:
>
> - **主库只支持 SQLite 与 MySQL**。Redis 是内存键值库,Prisma ORM 不支持它作为主数据库(无法建表/迁移),
> 接口层直接拒绝(e2e 有 422 断言);PostgreSQL 暂未纳入。
> - **切换完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库;
> 切换过程失败会自动回滚 schema.prisma,`.env` 只在数据全部迁移成功后才改写。
> - **目标库必须为空**(有表即拒绝),防止误覆盖。
> - 安装包体积因此增大约 37MB(~34MB → ~71MB):切换需要随包内置 prisma CLI 与 TypeScript 编译器
> (Prisma 7 的生成器产出 .ts 源码,切换后要在目标机编译进 dist)。
> - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);
> e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。
## REST API 一览 ## REST API 一览
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。 统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
所有接口前缀 `/api`,除登录/健康检查外均需 `Authorization: Bearer <token>` 所有接口前缀 `/api`,除登录/健康检查外均需 `Authorization: Bearer <token>`
(ClassIsland 设备接口用 `X-ClassIsland-Token`)。 (ClassIsland 设备接口用 `X-ClassIsland-Token`)。
| 方法 | 路径 | 权限 | 说明 | | 方法 | 路径 | 权限 | 说明 |
| --------------------------- | ----------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------- | | --------------------------- | ------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------- |
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) | | POST | `/auth/login` | 公开 | 登录(教师 / 管理员);`role=STUDENT` 返回 403(学生只有名单,改用班级登录) |
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 | | POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 |
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码 | | PATCH | `/auth/password` | 登录 | 修改自己的密码 |
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | | POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
| GET | `/classes` | 登录 | 班级列表(按权限收敛) | | GET | `/classes` | 登录 | 班级列表(按权限收敛) |
| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | | GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) |
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码 = 班级账号,可指定班主任) | | POST | `/classes` | 管理员 | 创建班级(自动生成班级码 = 班级账号,可指定班主任) |
| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 | | PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 |
| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) | | PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) |
| PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 | | PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 |
| GET | `/classes/:id/classisland-status` | 班级可见 | 本班 ClassIsland 联动状态(教室客户端设置页用) | | GET | `/classes/:id/classisland-status` | 班级可见 | 本班 ClassIsland 联动状态(教室客户端设置页用) |
| GET | `/classes/:id/notification-channel` | 班级可见 | 读取"通知显示到哪个端" | | GET | `/classes/:id/notification-channel` | 班级可见 | 读取"通知显示到哪个端" |
| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置"通知显示到哪个端"(默认由教室机器自己选) | | PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置"通知显示到哪个端"(默认由教室机器自己选) |
| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生(已存在账号直接转入) | | GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生(已存在账号直接转入) |
| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 | | DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 |
| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | ADMIN·TEACHER | 分配 / 取消协作教师 | | POST / DELETE | `/classes/:id/teachers[/:teacherId]` | ADMIN·TEACHER | 分配 / 取消协作教师 |
| GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任/管理员) | 课程管理 | | GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任/管理员) | 课程管理 |
| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) | | GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) |
| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | | GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) |
| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | | GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) |
| POST / PATCH / DELETE | `/schedules[/:id]` | 班主任 / 管理员 | 课表增删改(广播 `schedule:updated`) | | POST / PATCH / DELETE | `/schedules[/:id]` | 班主任 / 管理员 | 课表增删改(广播 `schedule:updated`) |
| GET | `/homeworks?classId=&courseId=&date=&pendingOnly=&keyword=` | 登录 | 作业列表(支持按所属日期;学生带完成状态,教师带完成人数) | | GET | `/homeworks?classId=&courseId=&date=&pendingOnly=&keyword=` | 登录 | 作业列表(支持按所属日期;学生带完成状态,教师带完成人数) |
| GET | `/homeworks/days?classId=&from=&to=` | 登录 | 哪些天有作业(日期高亮) | | GET | `/homeworks/days?classId=&from=&to=` | 登录 | 哪些天有作业(日期高亮) |
| GET | `/homeworks/:id` | 班级可见 | 作业详情 | | GET | `/homeworks/:id` | 班级可见 | 作业详情 |
| POST | `/homeworks` | 教师 / 管理员 / **班级账号** | 发布作业(附 `assignDate`;班级账号用于教室机器录入,归属班主任) | | POST | `/homeworks` | 教师 / 管理员 / **班级账号** | 发布作业(附 `assignDate`;班级账号用于教室机器录入,归属班主任) |
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) | | PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) | | PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) |
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 | | GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | | GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) |
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | | GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` | | POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` |
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) | | POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 | | DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 |
| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) | | POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) |
| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) | | GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) |
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 | | GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 |
| GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | 等级分布 + 各课程平均得分率 | | GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | 等级分布 + 各课程平均得分率 |
| POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | 单条 / 批量录入(广播 `grade:updated`) | | POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | 单条 / 批量录入(广播 `grade:updated`) |
| PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | 修改 / 删除成绩 | | PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | 修改 / 删除成绩 |
| GET | `/students?classId=&keyword=` | **管理员** | 学生名单 | | GET | `/students?classId=&keyword=` | **管理员** | 学生名单 |
| POST / PATCH / DELETE | `/students[/:id]` | **管理员** | 学生名单增删改(无密码概念;重置密码接口已下线 → 404) | | POST / PATCH / DELETE | `/students[/:id]` | **管理员** | 学生名单增删改(无密码概念;重置密码接口已下线 → 404) |
| GET | `/teachers?keyword=` | **管理员** | 教师列表 | | GET | `/teachers?keyword=` | **管理员** | 教师列表 |
| POST / PATCH / DELETE | `/teachers[/:id]` | **管理员** | 新建 / 编辑 / 删除教师账号 | | POST / PATCH / DELETE | `/teachers[/:id]` | **管理员** | 新建 / 编辑 / 删除教师账号 |
| POST | `/teachers/:id/reset-password` | **管理员** | 修改 / 重置教师密码(`newPassword` 可选,留空 = 默认初始密码) | | POST | `/teachers/:id/reset-password` | **管理员** | 修改 / 重置教师密码(`newPassword` 可选,留空 = 默认初始密码) |
| GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | | GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 |
| GET | `/imports/template?kind=&format=` | 管理员 / 班主任 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) | | GET | `/imports/template?kind=&format=` | 管理员 / 班主任 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) |
| POST | `/imports/table/preview` | 管理员 / 班主任 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 | | POST | `/imports/table/preview` | 管理员 / 班主任 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 |
| POST | `/imports/table/commit` | 管理员 / 班主任 | 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单) | | POST | `/imports/table/commit` | 管理员 / 班主任 | 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单) |
| POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) | | POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) |
| GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 | | GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 |
| POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) | | POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) |
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | | GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | | POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | | POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间(`X-ClassIsland-Token`,非 JWT) | | GET | `/database/status` | **管理员** | 数据库状态(连接/版本/体积/14 张表行数/备份列表/定时配置) |
| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) | | POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(切换前置检查;provider 仅 sqlite / mysql) |
| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) | | GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland | | POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
| GET | `/health` | 公开 | 健康检查(含已挂载模块列表);另有 `/healthz` 存活探针与 `/readyz` 就绪探针 | | GET | `/database/export`、`/database/sqlite-file` | **管理员** | 下载 JSON 快照 / 下载数据库文件(后者仅 SQLite) |
| POST | `/database/import` | **管理员** | 导入快照(base64,整库覆盖;可在 SQLite/MySQL 之间互迁) |
| GET / PUT | `/database/backup-schedule` | **管理员** | 定时备份配置(enabled/intervalHours/keepCount,存 data/database-settings.json) |
| POST / GET | `/database/switch`、`/database/switch/jobs/:id` | **管理员** | **一键切换数据库**(异步任务:备份→建表→迁数据→改 .env,需重启生效)/ 轮询进度 |
| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间(`X-ClassIsland-Token`,非 JWT) |
| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) |
| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) |
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland |
| GET | `/health` | 公开 | 健康检查(含已挂载模块列表);另有 `/healthz` 存活探针与 `/readyz` 就绪探针 |
## WebSocket 事件 ## WebSocket 事件
@@ -1161,7 +1249,7 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`: 后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`:
```ts ```ts
// packages/server/src/modules/registry.ts(当前 13 个模块) // packages/server/src/modules/registry.ts(当前 14 个模块)
export const apiModules: ApiModule[] = [ export const apiModules: ApiModule[] = [
authModule, authModule,
classesModule, classesModule,
@@ -1176,6 +1264,7 @@ export const apiModules: ApiModule[] = [
studentsModule, studentsModule,
teachersModule, teachersModule,
dashboardModule, dashboardModule,
databaseModule, // ← 数据库管理(仅管理员)
].filter((module) => module.enabled !== false); ].filter((module) => module.enabled !== false);
``` ```
@@ -1224,59 +1313,68 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
## 验收标准对照 ## 验收标准对照
| 验收项 | 结果 | 证据 | | 验收项 | 结果 | 证据 |
| ------------------------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` 实测 30–50ms 到达(要求 < 5 秒) | | 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` 实测 30–50ms 到达(要求 < 5 秒) |
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` 实时到达,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | | 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` 实时到达,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` 实时到达,`/grades/my` 返回记录;批量录入与统计接口通过 | | 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` 实时到达,`/grades/my` 返回记录;批量录入与统计接口通过 |
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回周视图条目,`week` 过滤 `weekStart ≤ week ≤ weekEnd`,单双周按第 1 周=单周过滤 | | 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回周视图条目,`week` 过滤 `weekStart ≤ week ≤ weekEnd`,单双周按第 1 周=单周过滤 |
| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true`;离线横幅 + 缓存统计页可用 | | 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true`;离线横幅 + 缓存统计页可用 |
| 权限隔离:学生不能访问其他班级数据 | ✅ | e2e 中 10 余项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | | 权限隔离:学生不能访问其他班级数据 | ✅ | e2e 中 10 余项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) |
| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | | 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 |
| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | | 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) |
| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 逐帧采样卡片尺寸恒定(窗口固定包围盒)+ 失焦自动收回 | | 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 逐帧采样卡片尺寸恒定(窗口固定包围盒)+ 失焦自动收回 |
| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数减少 | | 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数减少 |
| **多条通知展开为竖排列表** | ✅ | `verify:desktop`:按重要程度排序、默认 3 行 + `展开更多(还有 N 条)`、卡片高度与 `islandListLayout()` 一致;点"展开更多"铺满;放不下时提示 `更多请前往应用内操作` 且卡片高度 ≤ 可用高度 | | **多条通知展开为竖排列表** | ✅ | `verify:desktop`:按重要程度排序、默认 3 行 + `展开更多(还有 N 条)`、卡片高度与 `islandListLayout()` 一致;点"展开更多"铺满;放不下时提示 `更多请前往应用内操作` 且卡片高度 ≤ 可用高度 |
| **多条通知的整批操作** | ✅ | `verify:desktop`:`知道了` 整批关闭且状态同步清空、窗口随后淡出隐藏;`标为已读` 经真实链路把**两条**通知都标为已读 | | **多条通知的整批操作** | ✅ | `verify:desktop`:`知道了` 整批关闭且状态同步清空、窗口随后淡出隐藏;`标为已读` 经真实链路把**两条**通知都标为已读 |
| **本机录入的作业不再上岛** | ✅ | `verify:desktop`:按 id 与内容指纹双重判定,投递后灵动岛状态不变;别的作业照常上岛 | | **本机录入的作业不再上岛** | ✅ | `verify:desktop`:按 id 与内容指纹双重判定,投递后灵动岛状态不变;别的作业照常上岛 |
| 作业发布也上岛("新作业"胶囊) | ✅ | `kind=homework` + 展开显示作业要求;**截止时间功能已下线**(接口不再返回 `dueAt`,e2e 有断言) | | 作业发布也上岛("新作业"胶囊) | ✅ | `kind=homework` + 展开显示作业要求;**截止时间功能已下线**(接口不再返回 `dueAt`,e2e 有断言) |
| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:收起后胶囊常驻、「收起态点击可再次展开」、「空闲细缝态保持可交互」 | | **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:收起后胶囊常驻、「收起态点击可再次展开」、「空闲细缝态保持可交互」 |
| 叫人(老师点名,分紧急/普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 | | 叫人(老师点名,分紧急/普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 |
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗实测 | | 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗实测 |
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍在、merge 覆盖与保留行为符合预期 | | ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍在、merge 覆盖与保留行为符合预期 |
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象`,新表自动建好 | | 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象`,新表自动建好 |
| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 | | 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 |
| 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入全班、教师端 `readCount` = 班级学生数、`completedCount` 一致、`/grades/my` 返回全班成绩 | | 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入全班、教师端 `readCount` = 班级学生数、`completedCount` 一致、`/grades/my` 返回全班成绩 |
| 客户端只保留班级登录入口 | ✅ | `verify:desktop`:导航「成绩」标题变为「本班成绩」;个人学生登录 403 | | 客户端只保留班级登录入口 | ✅ | `verify:desktop`:导航「成绩」标题变为「本班成绩」;个人学生登录 403 |
| **教师录入(仅管理员)** | ✅ | `verify:e2e`:教师读/建教师 403、管理员建 201 → 编辑 → 重置密码(新密码 200/旧密码 401)→ 删除(有职责 409 / 无职责 200);`verify:web`:管理员真人点「教师管理 → 新建教师 → 保存 → 列表出现 → 删除」 | | **教师录入(仅管理员)** | ✅ | `verify:e2e`:教师读/建教师 403、管理员建 201 → 编辑 → 重置密码(新密码 200/旧密码 401)→ 删除(有职责 409 / 无职责 200);`verify:web`:管理员真人点「教师管理 → 新建教师 → 保存 → 列表出现 → 删除」 |
| **教师名单表格导入(仅管理员)** | ✅ | `verify:e2e`:教师预览 403、管理员预览/提交 200(`inserted=1`)、导入账号可登录;模板列=用户名/姓名/初始密码/角色 | | **教师名单表格导入(仅管理员)** | ✅ | `verify:e2e`:教师预览 403、管理员预览/提交 200(`inserted=1`)、导入账号可登录;模板列=用户名/姓名/初始密码/角色 |
| **教师/学生的「修改密码」(管理员)** | ✅ | 服务端 `POST /teachers\|students/:id/reset-password` 本来就收可选 `newPassword`,但界面原先只发空请求、按钮还叫「重置密码」——等于**只能重置成默认密码,没法改成指定值**(用户指着教师列表问"教师修改密码呢")。现两个页面都改成「修改密码」弹框输入:**填了就设成填的值,留空才是默认初始密码**。实测(真实浏览器点界面):设 `teacher777` → 新密码可登录、旧密码 401;留空 → 落到 `DEFAULT_TEACHER_PASSWORD`(`123456`) | | **教师/学生的「修改密码」(管理员)** | ✅ | 服务端 `POST /teachers\|students/:id/reset-password` 本来就收可选 `newPassword`,但界面原先只发空请求、按钮还叫「重置密码」——等于**只能重置成默认密码,没法改成指定值**(用户指着教师列表问"教师修改密码呢")。现两个页面都改成「修改密码」弹框输入:**填了就设成填的值,留空才是默认初始密码**。实测(真实浏览器点界面):设 `teacher777` → 新密码可登录、旧密码 401;留空 → 落到 `DEFAULT_TEACHER_PASSWORD`(`123456`) |
| **教室机器自助改密(班级账号 / 教师账号)** | ✅ | 客户端「设置 → 账号信息 → 修改密码」;服务端同一个 `PATCH /auth/password` 按会话分流(班级账号 → 班级密码,教师/管理员 → 本人密码)。实测:班级账号改 `123456`→`class999` 后新密码可登录、旧密码 401;`verify:desktop` 断言该入口存在且对话框有 3 个密码框 | | **教室机器自助改密(班级账号 / 教师账号)** | ✅ | 客户端「设置 → 账号信息 → 修改密码」;服务端同一个 `PATCH /auth/password` 按会话分流(班级账号 → 班级密码,教师/管理员 → 本人密码)。实测:班级账号改 `123456`→`class999` 后新密码可登录、旧密码 401;`verify:desktop` 断言该入口存在且对话框有 3 个密码框 |
| **触摸屏触摸灵动岛可展开(希沃白板)** | ⏳ | 触摸模式下窗口贴合岛体 + 始终接收输入(见「灵动岛」一节);`verify:desktop` 断言「触摸模式窗口=岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(希沃白板)触摸需在装有触摸屏的机器上人工复验** —— AI 会话里跑不了 Electron GUI(见 AGENTS.md §7) | | **触摸屏触摸灵动岛可展开(希沃白板)** | ⏳ | 触摸模式下窗口贴合岛体 + 始终接收输入(见「灵动岛」一节);`verify:desktop` 断言「触摸模式窗口=岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(希沃白板)触摸需在装有触摸屏的机器上人工复验** —— AI 会话里跑不了 Electron GUI(见 AGENTS.md §7) |
| **创建班级仅管理员 + 设置/更改班主任** | ✅ | `verify:e2e`:教师建班 403;管理员建班可带 `teacherId`、`PATCH /classes/:id/head-teacher` 落库生效;`verify:web`:教师直接访问 `/teachers` 被挡回仪表盘 | | **创建班级仅管理员 + 设置/更改班主任** | ✅ | `verify:e2e`:教师建班 403;管理员建班可带 `teacherId`、`PATCH /classes/:id/head-teacher` 落库生效;`verify:web`:教师直接访问 `/teachers` 被挡回仪表盘 |
| **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) | | **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) |
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` | | **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;真机实测截图 [03-from-notify-page](docs/screenshots/classisland/03-from-notify-page.png) | | **初次启动引导(首启弹出 + 随时重看)** | ✅ | Web 管理端:首次登录弹出 `el-tour` 聚焦引导,头像菜单「使用引导」重看(`verify:web`:「首次登录展示新手引导且可跳过」);学生端:首启六步向导,登录页 / 设置页可重看(`verify:desktop`:「初次启动引导(首启自动弹出、可走完、状态写入配置)」) |
| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 | | **数据库管理(仅管理员:状态/备份/导入导出/一键切换)** | ✅ | `verify:e2e` 新增 13 项:状态与 14 表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份→快照导入→备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表写入一致;`verify:web`:「数据库管理页(仅管理员)」状态卡与备份表渲染 |
| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 作业录入」管理短语 | | **客户端设置改版(分组侧栏/深色模式/关于页)** | ✅ | `verify:desktop`:「侧边栏点击导航」适配「学习/设置」分组结构;「主题切换与侧边栏折叠(真实点击、写回配置)」——html.dark 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染(应用信息/诊断信息/鸣谢)」含 configPath;四个设置页用例改走子路由后全过(101/101 + 二次启动 88/88) |
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 通知显示位置」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:client 时通知与联动页下发都被跳过) | | **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;真机实测截图 [03-from-notify-page](docs/screenshots/classisland/03-from-notify-page.png) |
| **班级小助手能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增/更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 | | **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 |
| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题/内容/时长/优先级/语音朗读/是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 | | **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 作业录入」管理短语 |
| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe` / `win-unpacked/*.exe`(见下表) | | **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 通知显示位置」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:client 时通知与联动页下发都被跳过) |
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | | **班级小助手能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增/更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 |
| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题/内容/时长/优先级/语音朗读/是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 |
| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe` / `win-unpacked/*.exe`(见下表) |
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
验收脚本实测(**2026-09-30**,本机): 验收脚本实测(**2026-10-02**,本机):
| 验证 | 结果 | | 验证 | 结果 |
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `pnpm verify:e2e`(后端 + REST + Socket.IO + RBAC + 上课时段 + 导入 + 班级账号 + 教师录入) | **173/173** ✅ | | `pnpm verify:e2e`(后端 + REST + Socket.IO + RBAC + 上课时段 + 导入 + 班级账号 + 教师录入 + 数据库管理) | **186/186** ✅(2026-10-02 实测) |
| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅ | | `pnpm verify:desktop`(客户端冒烟,不弹窗) | **101/101** ✅ + 二次启动 **88/88** ✅ |
| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ | | `pnpm verify:web`(Web 管理端真实点击回归) | **30/31** ✅(未过的 1 项是既有脆弱用例,见下) |
| `pnpm verify:packaged`(对打包后的 EXE 跑客户端冒烟) | 首次 **89/91**、二次 **79/79**(未过的 2 项是既有功能用例,见下) | | `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅ |
| `pnpm typecheck` / `pnpm lint` | 全过 ✅(lint 0 error,1 条既有 warning) | | `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ |
| 安装程序完整生命周期(静默安装 → 启动 → 卸载,装到 `%LOCALAPPDATA%\Programs`) | 通过 ✅(2026-09-30 实测:安装 76 个文件,启动出窗口,卸载干净) | | `pnpm dist:server`(服务端安装包 + Web 端) | ✅ 打出 71MB 安装程序(体积增大是因随包内置切换链路所需的依赖) |
| 便捷版(单文件)解包启动 | 通过 ✅(2026-09-30 实测:wrapper + 6 个进程,主窗口正常) | | `pnpm typecheck` / `pnpm lint` | 全过 ✅(lint 0 error,1 条既有 warning) |
| Docker / Nginx 部署样例 | 文件已提供(含 Linux systemd 脚本),本机无 Docker/Linux 未实测 ⚠️ | | 数据库切换子进程链路(快照 → generate → db push → 写入目标库) | ✅ 实测:728 行逐表一致(users=18 / schedules=93 / grades=98 抽查一致) |
| 安装程序完整生命周期(静默安装 → 启动 → 卸载,装到 `%LOCALAPPDATA%\Programs`) | 通过 ✅(2026-09-30 实测:安装 76 个文件,启动出窗口,卸载干净) |
| 便捷版(单文件)解包启动 | 通过 ✅(2026-09-30 实测:wrapper + 6 个进程,主窗口正常) |
| Docker / Nginx 部署样例 | 文件已提供(含 Linux systemd 脚本),本机无 Docker/Linux 未实测 ⚠️ |
> 本轮 `verify:web` 未过的 1 项是**既有脆弱用例**(AGENTS.md 「已知脆弱用例」表已列明):
> 「课表科目为全校统一目录」——种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组。
> 打包版冒烟未过的都是**应用功能用例**(与打包/安装无关)。其中前两项是**测试自身的 bug、已修** > 打包版冒烟未过的都是**应用功能用例**(与打包/安装无关)。其中前两项是**测试自身的 bug、已修**
> (2026-09-30 复跑确认不再失败): > (2026-09-30 复跑确认不再失败):
+1 -1
View File
@@ -7,7 +7,7 @@
SERVER_PORT=4000 SERVER_PORT=4000
# 应用版本(镜像标签) # 应用版本(镜像标签)
APP_VERSION=0.1.0 APP_VERSION=1.0.0
# MySQL 配置 # MySQL 配置
MYSQL_ROOT_PASSWORD=请改成强密码_root MYSQL_ROOT_PASSWORD=请改成强密码_root
+1 -1
View File
@@ -8,7 +8,7 @@
SERVER_PORT=4000 SERVER_PORT=4000
# 应用版本(镜像标签) # 应用版本(镜像标签)
APP_VERSION=0.1.0 APP_VERSION=1.0.0
# JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词) # JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词)
# 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" # 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
+2 -2
View File
@@ -5,13 +5,13 @@
# runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小 # runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小
# #
# 构建(在仓库根目录执行): # 构建(在仓库根目录执行):
# docker build -f deploy/Dockerfile -t classhelper-server:0.1.0 . # docker build -f deploy/Dockerfile -t classhelper-server:1.0.0 .
# #
# 运行(SQLite + 数据卷): # 运行(SQLite + 数据卷):
# docker run -d --name classhelper -p 4000:4000 \ # docker run -d --name classhelper -p 4000:4000 \
# -e JWT_SECRET=<48字节随机串> \ # -e JWT_SECRET=<48字节随机串> \
# -v classhelper-data:/app/data \ # -v classhelper-data:/app/data \
# classhelper-server:0.1.0 # classhelper-server:1.0.0
# #
# 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代) # 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代)
+1 -1
View File
@@ -23,7 +23,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-0.1.0} image: classhelper-server:${APP_VERSION:-1.0.0}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+1 -1
View File
@@ -60,7 +60,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-0.1.0} image: classhelper-server:${APP_VERSION:-1.0.0}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+4 -1
View File
@@ -1,12 +1,13 @@
{ {
"name": "classhelper-server-runtime", "name": "classhelper-server-runtime",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "班级小助手服务端运行时(安装包 / 容器内置)", "description": "班级小助手服务端运行时(安装包 / 容器内置)",
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0", "@prisma/adapter-libsql": "7.10.0",
"@prisma/adapter-mariadb": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"compression": "^1.8.2", "compression": "^1.8.2",
@@ -16,7 +17,9 @@
"express-rate-limit": "^8.7.0", "express-rate-limit": "^8.7.0",
"helmet": "^8.3.0", "helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"prisma": "7.10.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"typescript": "~5.9.3",
"xlsx": "0.18.5", "xlsx": "0.18.5",
"zod": "^4.6.2" "zod": "^4.6.2"
} }
+53 -9
View File
@@ -19,28 +19,62 @@ MySQL 兼容方式设计,可平滑切换到云端 MySQL / MariaDB 统一存储
| 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 | | 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 |
| 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 | | 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 |
## 切换步骤 ## 切换到 MySQL
### 1. 修改 provider ### 方式一:Web 管理端「数据库管理 → 一键切换」(推荐,v1.0.0 新增)
管理员在 Web 管理端打开 **数据库管理** 页(`/database`,仅 ADMIN 可见),在「一键切换数据库」卡片里:
1. 选目标类型(SQLite ⇄ MySQL)并填**空库**连接串(有表的库会被拒绝,防误覆盖);
2. 输入「确认迁移」→ 点「开始迁移」。
界面会自动依次执行(带步骤进度条):
1. **备份当前数据库**(gzip JSON 快照存 `<数据目录>/backups/`,失败可回滚);
2. 导出当前数据为快照;
3. 测试目标库连接;
4. 检查目标库为空;
5. 改写 `schema.prisma` 的 provider;
6. 子进程 `prisma generate` 生成新方言的 Prisma 客户端;
7. (打包形态)把生成的 `.ts` 客户端编译进 `dist/generated`;
8. 子进程 `prisma db push` 在目标库建表;
9. 子进程 `dist/tools/apply-snapshot.js` 把快照写入目标库;
10. 全部成功后改写 `.env` 的 `DATABASE_PROVIDER` / `DATABASE_URL`。
**完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库。
中途任一步失败会**回滚 `schema.prisma`**,`.env` 不被改写 —— 现有数据库与配置保持可用。
> 同一页还提供:连接状态检测(版本 / 体积 / 各表行数)、连接测试、手动与**定时备份**、
> 备份恢复、快照导出导入、SQLite 数据库文件下载。备份与导入导出共用同一种 JSON 快照格式,
> 因此**任意备份都能恢复回任意一种受支持的数据库**。
>
> 只支持 SQLite 与 MySQL 两种主库:Redis 等键值库不是 Prisma 支持的主数据库(无法建表/迁移),
> 接口层直接拒绝。
### 方式二:命令行手工切换
#### 1. 修改 provider
```bash ```bash
# 自动把 prisma/schema.prisma 的 provider 改成 mysql # 自动把 prisma/schema.prisma 的 provider 改成 mysql
node scripts/use-database.mjs mysql node scripts/use-database.mjs mysql
``` ```
### 2. 安装 MySQL 驱动适配器 #### 2. MySQL 驱动适配器(自 v1.0.0 起已随包内置)
Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb` Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb`
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。 (该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。**它已在 `packages/server/package.json` 的
依赖里**(数据库管理模块的连接测试/迁移需要),因此无需再手动安装:
```bash ```bash
# 仅当从旧版本升级、依赖里还没有时才需要
pnpm --filter @classhelper/server add @prisma/adapter-mariadb pnpm --filter @classhelper/server add @prisma/adapter-mariadb
``` ```
`packages/server/src/lib/db.ts` 已经内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器, `packages/server/src/lib/db.ts` 内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
因此只需要安装依赖 + 配置环境变量,服务端代码无需改动。 服务端代码无需改动。
### 3. 配置环境变量 #### 3. 配置环境变量
编辑 `packages/server/.env`: 编辑 `packages/server/.env`:
@@ -52,7 +86,7 @@ DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper"
> 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址; > 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址;
> 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。 > 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。
### 4. 重建迁移并生成客户端 #### 4. 重建迁移并生成客户端
SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**: SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**:
@@ -71,7 +105,11 @@ pnpm db:seed
pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql
``` ```
### 5. 回到 SQLite > 只搬数据、不要演示数据时,用界面的「下载快照 / 导入快照」更省事:
> 旧库导出 JSON → 切库建表 → 新库导入,一份快照也可用 CLI 迁移:
> `node server/dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json`
#### 5. 回到 SQLite
```bash ```bash
node scripts/use-database.mjs sqlite node scripts/use-database.mjs sqlite
@@ -80,6 +118,8 @@ rm -rf packages/server/prisma/migrations # 若之前已切换成 MySQL 迁移
pnpm db:migrate pnpm db:migrate
``` ```
> 用界面「一键切换」回 SQLite 时,目标库同样必须是空文件:删除旧 `.db` 或指定一个新路径。
## 生产部署注意事项 ## 生产部署注意事项
1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置, 1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置,
@@ -88,3 +128,7 @@ pnpm db:migrate
三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。 三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。
3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。 3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。
4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。 4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。
界面「定时备份」把快照写进 `<数据目录>/backups/`(MySQL 模式下即安装根 `data/backups/`),
**服务端进程不运行时不会补跑**;关键环境建议同时用数据库自带的备份策略。
5. **安装包体积**:切换链路需要随包内置 `prisma` CLI 与 `typescript` 编译器(Prisma 7 生成器产出
`.ts`,切换后需在目标机编译进 `dist/generated`),服务端安装包因此约 34MB → 71MB。
+2 -2
View File
@@ -25,7 +25,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
| 路径 | 说明 | | 路径 | 说明 |
| ----------------------------------------------------- | ----------------------------------------------- | | ----------------------------------------------------- | ----------------------------------------------- |
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 | | `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
| `release-server/班级小助手服务端-0.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) | | `release-server/班级小助手服务端-1.0.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) |
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、 打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。 生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
@@ -35,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
双击安装程序即可,**无需管理员权限**(当前用户级安装): 双击安装程序即可,**无需管理员权限**(当前用户级安装):
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer` - 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
- 静默安装:`"班级小助手服务端-0.1.0-x64-setup.exe" /S` - 静默安装:`"班级小助手服务端-1.0.0-x64-setup.exe" /S`
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
- 桌面:`班级小助手服务端.lnk`(打开管理端) - 桌面:`班级小助手服务端.lnk`(打开管理端)
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`) - 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "class-helper", "name": "class-helper",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
+1 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。 4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll' entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper url: https://github.com/LoveChengke/ClassHelper
version: 0.1.0.0 version: 1.0.0.0
apiVersion: 2.0.0.0 apiVersion: 2.0.0.0
author: 班级小助手 author: 班级小助手
@@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */ /* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary> /// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "0.1.0.0"; private const string PluginVersion = "1.0.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary> /// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion() private static string SafeAppVersion()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/desktop-client", "name": "@classhelper/desktop-client",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper", "author": "Class Helper",
@@ -36,6 +36,12 @@ interface PersistedConfig {
notificationChannel?: ClassIslandNotificationChannel; notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */ /** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[]; homeworkPhrases?: string[];
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
onboardingDone?: boolean;
/** 界面主题(向后兼容:旧配置没有该字段用浅色) */
theme?: 'light' | 'dark';
/** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */
sidebarCollapsed?: boolean;
} }
const DEFAULT_CONFIG: PersistedConfig = { const DEFAULT_CONFIG: PersistedConfig = {
@@ -47,8 +53,16 @@ const DEFAULT_CONFIG: PersistedConfig = {
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD }, homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL, notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS], homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
onboardingDone: false,
theme: 'light',
sidebarCollapsed: false,
}; };
/** 主题白名单:非法值一律回落浅色 */
function normalizeTheme(input?: 'light' | 'dark'): 'light' | 'dark' {
return input === 'dark' ? 'dark' : 'light';
}
/** /**
* 作业快捷短语的合法化:去空白、去重、限长限量。 * 作业快捷短语的合法化:去空白、去重、限长限量。
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。 * 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
@@ -181,6 +195,9 @@ function readPersisted(): PersistedConfig {
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard), homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel), notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases), homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
onboardingDone: parsed.onboardingDone === true,
theme: normalizeTheme(parsed.theme),
sidebarCollapsed: parsed.sidebarCollapsed === true,
}; };
} catch { } catch {
return { ...DEFAULT_CONFIG }; return { ...DEFAULT_CONFIG };
@@ -223,6 +240,9 @@ export function getConfig(): DesktopStoredConfig {
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard), homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel), notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases), homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
onboardingDone: persisted.onboardingDone === true,
theme: normalizeTheme(persisted.theme),
sidebarCollapsed: persisted.sidebarCollapsed === true,
}; };
} }
@@ -253,6 +273,17 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.notificationChannel !== undefined) { if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel); persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
} }
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
if (patch.onboardingDone === true) {
persisted.onboardingDone = true;
}
if (patch.theme !== undefined) {
persisted.theme = normalizeTheme(patch.theme);
}
if (patch.sidebarCollapsed !== undefined) {
persisted.sidebarCollapsed = patch.sidebarCollapsed === true;
}
if (patch.token !== undefined) { if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token); const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token; persisted.token = encrypted.token;
+10 -5
View File
@@ -1,6 +1,7 @@
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron'; import { BrowserWindow, app, shell } from 'electron';
import { getConfig } from './config.js';
import { registerIpcHandlers } from './ipc.js'; import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js'; import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -49,16 +50,20 @@ function isAllowedNavigation(url: string): boolean {
} }
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
const theme = getConfig().theme;
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, // 初始尺寸与 ClassIsland 主窗口对齐(实测其可视区 1242x582),保持同一观感
height: 800, width: 1242,
minWidth: 980, height: 582,
minHeight: 640, minWidth: 1000,
minHeight: 540,
center: true,
title: '班级小助手', title: '班级小助手',
// 冒烟验证时不弹窗,避免打扰使用者 // 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest, show: !isSmokeTest,
autoHideMenuBar: true, autoHideMenuBar: true,
backgroundColor: '#f5f7fa', backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: { webPreferences: {
preload: path.join(currentDir, '../preload/index.js'), preload: path.join(currentDir, '../preload/index.js'),
contextIsolation: true, contextIsolation: true,
+1
View File
@@ -30,6 +30,7 @@ export function registerIpcHandlers(): void {
node: process.versions.node ?? '', node: process.versions.node ?? '',
platform: `${process.platform} ${process.arch}`, platform: `${process.platform} ${process.arch}`,
userDataPath: app.getPath('userData'), userDataPath: app.getPath('userData'),
configPath: getConfigPath(),
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1', smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
})); }));
+54 -13
View File
@@ -3712,7 +3712,7 @@ async function runSettingsPagePasswordEntryCheck(
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
location.hash = '#/schedule'; location.hash = '#/schedule';
await wait(250); await wait(250);
location.hash = '#/settings'; location.hash = '#/settings/account';
let card = null; let card = null;
for (let i = 0; i < 40 && !card; i += 1) { for (let i = 0; i < 40 && !card; i += 1) {
await wait(100); await wait(100);
@@ -3756,7 +3756,7 @@ async function runSettingsPageAppearanceCheck(win: BrowserWindow): Promise<{ ok:
window.desktop.islandSetAppearance({ ...before, height: 44 }); window.desktop.islandSetAppearance({ ...before, height: 44 });
await wait(300); await wait(300);
location.hash = '#/settings'; location.hash = '#/settings/island';
let runway = null; let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) { for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100); await wait(100);
@@ -3838,7 +3838,7 @@ async function runSettingsPageMarginCheck(
// 页面里会留着过期的位置(这正是"改了设置没反应"的一个真实来源) // 页面里会留着过期的位置(这正是"改了设置没反应"的一个真实来源)
location.hash = '#/schedule'; location.hash = '#/schedule';
await wait(250); await wait(250);
location.hash = '#/settings'; location.hash = '#/settings/island';
let item = null; let item = null;
for (let i = 0; i < 40 && !item; i += 1) { for (let i = 0; i < 40 && !item; i += 1) {
await wait(100); await wait(100);
@@ -3880,15 +3880,15 @@ async function runSettingsPageMarginCheck(
.executeJavaScript( .executeJavaScript(
`(async () => { `(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
location.hash = '#/schedule'; location.hash = '#/schedule';
await wait(200); await wait(200);
location.hash = '#/settings'; location.hash = '#/settings/island';
let runway = null; let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) { for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100); await wait(100);
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) => const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'), (node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
); );
runway = item?.querySelector('.el-slider__runway') ?? null; runway = item?.querySelector('.el-slider__runway') ?? null;
} }
if (!runway) return { ok: false, detail: '设置页里找不到「左右边距」滑块' }; if (!runway) return { ok: false, detail: '设置页里找不到「左右边距」滑块' };
@@ -3986,7 +3986,7 @@ async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: bo
`(async () => { `(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = (await window.desktop.getConfig()).notificationChannel; const before = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/settings'; location.hash = '#/settings/reminder';
// 找到「通知显示位置」那张卡片里的单选组 // 找到「通知显示位置」那张卡片里的单选组
let group = null; let group = null;
@@ -4151,6 +4151,28 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
`text=${dom?.text ?? ''}`, `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)+ 环境变量传入的凭据, // 判据不写死具体示例码:用"演示/示例"字样 + 旧版占位符的**形状**(例如 G101)+ 环境变量传入的凭据,
// 这样断言更严(任何"例如 XXX123"都会被抓到),安装包里也不留示例账号字面量。 // 这样断言更严(任何"例如 XXX123"都会被抓到),安装包里也不留示例账号字面量。
@@ -4308,6 +4330,25 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
); );
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? '')); 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 ?? ''));
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对) // 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
const readBadgeGeometry = async (): Promise<{ const readBadgeGeometry = async (): Promise<{
ok: boolean; ok: boolean;
@@ -7,13 +7,18 @@ import {
subscribeIslandMarkAllRead, subscribeIslandMarkAllRead,
subscribeIslandMarkRead, subscribeIslandMarkRead,
} from './island/bridge.js'; } from './island/bridge.js';
import OnboardingWelcome from './components/OnboardingWelcome.vue';
import { useAppStore } from './stores/app.js'; import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js'; import { useAuthStore } from './stores/auth.js';
import { useOnboardingStore } from './stores/onboarding.js';
import { useRealtimeStore } from './stores/realtime.js'; import { useRealtimeStore } from './stores/realtime.js';
import { useUiStore } from './stores/ui.js';
const router = useRouter(); const router = useRouter();
const appStore = useAppStore(); const appStore = useAppStore();
const auth = useAuthStore(); const auth = useAuthStore();
const onboarding = useOnboardingStore();
const ui = useUiStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const booting = ref(true); const booting = ref(true);
@@ -27,6 +32,10 @@ const bootText = ref('正在读取本地配置...');
*/ */
onMounted(async () => { onMounted(async () => {
try { try {
bootText.value = '正在读取本地配置...';
// 主题与侧栏折叠最先恢复:在首屏渲染前挂好 html.dark,避免"先白后黑"闪一下
await ui.init();
bootText.value = '正在恢复登录状态...'; bootText.value = '正在恢复登录状态...';
const session = await auth.restore(); const session = await auth.restore();
@@ -52,6 +61,17 @@ onMounted(async () => {
if (auth.isAuthenticated) await router.replace('/schedule'); if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login'); else await router.replace('/login');
// 初次启动引导:配置里没记「已看过」就自动弹一次(完成/跳过后由引导组件写入配置)。
// 稍等一拍再弹:让登录页/主界面先渲染完,避免和启动过渡的收尾抢帧。
window.setTimeout(() => {
void window.desktop
?.getConfig()
.then((config) => {
if (config.onboardingDone !== true) onboarding.open();
})
.catch(() => undefined);
}, 400);
} finally { } finally {
booting.value = false; booting.value = false;
} }
@@ -69,6 +89,8 @@ onUnmounted(() => {
<p class="boot-text">{{ bootText }}</p> <p class="boot-text">{{ bootText }}</p>
</div> </div>
<router-view v-else /> <router-view v-else />
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
<OnboardingWelcome v-if="!booting" />
</template> </template>
<style scoped> <style scoped>
+254 -9
View File
@@ -93,19 +93,26 @@ async function waitUntil(predicate: () => boolean, timeoutMs = 3000, intervalMs
return predicate(); return predicate();
} }
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配) */ /** 侧边栏菜单与期望路由的对应关系(按显示文案匹配;侧边栏是「分组 + 子菜单」结构) */
const EXPECTED_MENU: Array<{ label: string; path: string }> = [ const EXPECTED_MENU: Array<{ label: string; path: string }> = [
{ label: '课表', path: '/schedule' }, { label: '课表', path: '/schedule' },
{ label: '作业', path: '/homeworks' }, { label: '作业', path: '/homeworks' },
{ label: '通知', path: '/notifications' }, { label: '通知', path: '/notifications' },
{ label: '成绩', path: '/grades' }, { label: '成绩', path: '/grades' },
{ label: '设置', path: '/settings' }, /** 设置组的代表子项:设置入口改成分组后,用「通用」验证设置子页导航可用 */
{ label: '通用', path: '/settings/general' },
];
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项 */
const MENU_GROUPS: Array<{ title: string; labels: string[] }> = [
{ title: '学习', labels: ['课表', '作业', '通知', '成绩'] },
{ title: '设置', labels: ['通用', '外观', '灵动岛', '提醒', '账号', '关于'] },
]; ];
/** /**
* 冒烟自检 3:侧边栏点击导航。 * 冒烟自检 3:侧边栏点击导航(分组结构版)。
* 直接对真实 DOM 触发 click,验证每次点击后路由与视图都发生切换 —— * 先点分组标题展开,再逐个点击子项,验证每次点击后路由与视图都发生切换 ——
* 这是"点击左侧边栏没反应"这类问题的回归测试。 * 这是"点击左侧边栏没反应"这类问题的回归测试(侧边栏改为「学习/设置」分组后重写)。
*/ */
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> { export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
const { router } = await import('../router/index.js'); const { router } = await import('../router/index.js');
@@ -113,20 +120,43 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent: // 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知", // "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。 // 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
const findMenuItem = (label: string): HTMLElement | undefined => const findByLabel = (selector: string, label: string): HTMLElement | undefined =>
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) => (Array.from(document.querySelectorAll(selector)) as HTMLElement[]).find((element) =>
Array.from(element.querySelectorAll('span')).some( Array.from(element.querySelectorAll('span')).some(
(span) => (span.textContent ?? '').trim() === label, (span) => (span.textContent ?? '').trim() === label,
), ),
); );
const findMenuItem = (label: string): HTMLElement | undefined => findByLabel('.el-menu-item', label);
const findGroupTitle = (title: string): HTMLElement | undefined =>
findByLabel('.el-sub-menu__title', title);
if (!findMenuItem('课表')) { if (!findGroupTitle('学习') || !findGroupTitle('设置')) {
return { ok: false, detail: '侧边栏未渲染(当前不在主布局或未登录)' }; return { ok: false, detail: '侧边栏分组未渲染(当前不在主布局或未登录)' };
} }
const visited: string[] = []; const visited: string[] = [];
const failures: string[] = []; const failures: string[] = [];
// 展开分组:默认只展开"当前路由所在分组",其它组需要先点开。
// 判据用"子项是否可见"而不是"盲点一次标题" —— 标题点击是 toggle,
// 对已经展开的组再点一下会把它**收起**,后续点不到子项。
const isVisible = (element: HTMLElement | undefined): boolean =>
Boolean(element && element.offsetParent !== null);
for (const group of MENU_GROUPS) {
const firstItem = findMenuItem(group.labels[0] ?? '');
if (!isVisible(firstItem)) {
findGroupTitle(group.title)?.click();
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
// 分组结构自检:两个分组各自应包含全部子项
for (const group of MENU_GROUPS) {
for (const label of group.labels) {
if (!findMenuItem(label)) failures.push(`${group.title}组缺少子项 ${label}`);
}
}
for (const { label, path } of EXPECTED_MENU) { for (const { label, path } of EXPECTED_MENU) {
const element = findMenuItem(label); const element = findMenuItem(label);
if (!element) { if (!element) {
@@ -818,6 +848,66 @@ 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> { export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([ const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -834,6 +924,158 @@ export async function sessionCleanup(): Promise<SmokeCheckResult> {
} }
} }
/**
* 冒烟自检(不需要后端):主界面外观件 —— 主题切换与侧边栏折叠。
* 走**真实 DOM 点击**(顶栏日月按钮 / 汉堡按钮),验证:
* 1) html.dark 类与 store 同步翻转,且写入主进程配置;
* 2) 侧边栏折叠后 el-menu 进入 collapse 态、宽度收窄,配置同步;
* 结束时把主题还原为初始值(后续截图/几何用例依赖固定主题)。
*/
export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
try {
const { router } = await import('../router/index.js');
await router.push('/schedule');
if (!(await waitUntil(() => Boolean(document.querySelector('[data-test="theme-toggle"]')), 6000))) {
return { ok: false, detail: '主布局未渲染(找不到主题切换按钮)' };
}
const clickToggle = (selector: string): boolean => {
const node = document.querySelector<HTMLElement>(selector);
if (!node) return false;
node.click();
return true;
};
// 1) 主题:真实点击日月按钮 → html.dark 翻转 → 配置落盘
const initialDark = document.documentElement.classList.contains('dark');
if (!clickToggle('[data-test="theme-toggle"]')) return { ok: false, detail: '主题按钮点击失败' };
const flipped = await waitUntil(
() => document.documentElement.classList.contains('dark') !== initialDark,
2500,
);
const flippedDark = document.documentElement.classList.contains('dark');
const configAfterFlip = await window.desktop?.getConfig?.();
const themePersisted = configAfterFlip?.theme === (flippedDark ? 'dark' : 'light');
// 2) 侧边栏:点汉堡折叠 → collapse 态 + 宽度收窄 → 配置落盘 → 再展开还原
const asideBefore = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthBefore = asideBefore?.offsetWidth ?? 0;
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
const collapsed = await waitUntil(() => Boolean(document.querySelector('.el-menu--collapse')), 2500);
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthAfter = asideAfter?.offsetWidth ?? 0;
const configAfterCollapse = await window.desktop?.getConfig?.();
const collapsePersisted = configAfterCollapse?.sidebarCollapsed === true;
// 2.1) 折叠态的两条观感硬要求(用户反馈回归):
// a) 菜单里不允许残留任何**可见**的文字(Element Plus 靠宽度归零隐藏,一旦被外层样式
// 干扰就会留"半截字");
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px),否则看起来"没对齐"。
const centerXOf = (element: Element | null | undefined): number | null => {
if (!element) return null;
const rect = element.getBoundingClientRect();
return rect.width > 0 ? rect.left + rect.width / 2 : null;
};
const leftoverText: string[] = [];
for (const node of Array.from(
document.querySelectorAll('.aside .el-menu-item > span, .aside .el-sub-menu__title > span'),
)) {
const element = node as HTMLElement;
if (element.classList.contains('menu-icon-slot')) continue;
const text = (element.textContent ?? '').trim();
if (text && element.offsetParent !== null && element.getClientRects().length > 0) {
const rect = element.getBoundingClientRect();
// 宽高都归零但 display 不为 none 的,正是"0 宽溢出"的残影来源
if (rect.width < 1) leftoverText.push(`0宽:${text.slice(0, 6)}`);
else leftoverText.push(`可见:${text.slice(0, 6)}`);
}
}
const toggleCenter = centerXOf(document.querySelector('[data-test="sidebar-toggle"]'));
const iconCenters = Array.from(
document.querySelectorAll('.aside .el-menu--collapse .el-sub-menu__title .el-icon'),
).map((node) => centerXOf(node));
const iconDeltas = iconCenters
.filter((value): value is number => value !== null && toggleCenter !== null)
.map((value) => Math.abs(value - (toggleCenter as number)));
const iconsAligned = iconDeltas.length > 0 && Math.max(...iconDeltas) <= 2;
clickToggle('[data-test="sidebar-toggle"]');
await waitUntil(() => !document.querySelector('.el-menu--collapse'), 2500);
// 3) 侧栏不溢出:菜单区自己滚动(.menu 有 min-height:0 + overflow-y:auto),
// 底部「第 N 周 / 最近同步」始终留在窗口内 —— 窗口默认高 582px,
// 在加这条结构性保证之前,两个分组全展开会把 footer 挤出屏幕(用户反馈)。
const footer = document.querySelector<HTMLElement>('.aside-footer');
const footerRect = footer?.getBoundingClientRect();
const footerVisible =
Boolean(footer && footer.offsetParent !== null) &&
Boolean(footerRect && footerRect.bottom <= window.innerHeight + 1);
// 3) 还原主题,避免影响后续用例
clickToggle('[data-test="theme-toggle"]');
await waitUntil(() => document.documentElement.classList.contains('dark') === initialDark, 2500);
const ok =
flipped &&
themePersisted &&
collapsed &&
collapsePersisted &&
widthAfter < widthBefore &&
widthAfter <= 80 &&
leftoverText.length === 0 &&
iconsAligned &&
footerVisible;
return {
ok,
detail:
`主题翻转=${flipped} 落盘=${themePersisted}(${configAfterFlip?.theme ?? '-'}) ` +
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 落盘=${collapsePersisted} ` +
`文字残影=${leftoverText.length === 0 ? '无' : leftoverText.join(',')} ` +
`图标与汉堡同列=${iconsAligned}(中心差=${iconDeltas.map((d) => d.toFixed(1)).join('/') || '-'},要求 ≤2px) ` +
`侧栏底部可见=${footerVisible}`,
};
} catch (error) {
return {
ok: false,
detail: `外观件自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* 冒烟自检(不需要后端):关于页(/settings/about)。
* 断言页面渲染出标题与「鸣谢 / 诊断信息」区块,且 getAppInfo 返回了新增的 configPath
* (诊断信息依赖它)。
*/
export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
try {
const { router } = await import('../router/index.js');
await router.push('/settings/about');
const rendered = await waitUntil(
() => (document.querySelector('.page-title')?.textContent ?? '').includes('关于'),
6000,
);
if (!rendered) return { ok: false, detail: '关于页未挂载' };
const text = document.body.innerText.replace(/\s+/g, ' ');
const hasThanks = text.includes('鸣谢');
const hasDiagnostics = text.includes('诊断信息');
const info = await window.desktop?.getAppInfo?.();
const hasConfigPath = Boolean(info?.configPath);
await router.push('/schedule');
return {
ok: hasThanks && hasDiagnostics && hasConfigPath,
detail: `鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'}`,
};
} catch (error) {
return {
ok: false,
detail: `关于页自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 注册到 window,供主进程冒烟脚本调用 */ /** 注册到 window,供主进程冒烟脚本调用 */
export function registerSmokeHooks(): void { export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = { window.__classhelperSmoke__ = {
@@ -845,6 +1087,9 @@ export function registerSmokeHooks(): void {
islandRealtimeCleanup, islandRealtimeCleanup,
islandReadState, islandReadState,
islandHomeworkSuppressionCheck, islandHomeworkSuppressionCheck,
onboardingSelfTest,
layoutChromeSelfTest,
aboutPageSelfTest,
scheduleTimelineSelfTest, scheduleTimelineSelfTest,
homeworkBoardSelfTest, homeworkBoardSelfTest,
sessionCleanup, sessionCleanup,
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import { useOnboardingStore } from '../stores/onboarding.js';
/**
* 初次启动引导(六步向导)。
*
* - 首次启动自动弹出(App.vue 按 `onboardingDone` 判定);完成 / 跳过(右上角按钮 / Esc)
* 都算看过,由这里写入主进程配置,之后不再自动弹出。
* - 登录页与设置页的「使用引导」入口可随时重看(重看关闭时再写一次也无副作用)。
* - **用 v-if 自绘遮罩而不是 el-dialog**:关闭即从 DOM 卸载。冒烟断言"引导已关闭"时
* 不会踩 el-dialog「DOM 残留在文档里」的坑(AGENTS §7 第 22 条)。
* - 操作按钮用 `data-action` 标识(prev/next/finish/skip),冒烟点击不依赖按钮文案。
*/
const onboarding = useOnboardingStore();
const steps = [
{
icon: 'School',
title: '欢迎使用班级小助手',
lines: [
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
'第一次使用,先花半分钟看看怎么开始。',
],
},
{
icon: 'Link',
title: '第一步 · 连接服务器',
lines: [
'在登录页填写老师或管理员提供的服务器地址(通常形如 http://192.168.x.x:4000)。',
'点「测试连接」确认可达;以后也能在「设置」里随时修改。',
],
},
{
icon: 'Key',
title: '第二步 · 登录班级',
lines: [
'使用班级码 + 班级密码登录(向老师或管理员索取)。',
'登录后这台设备就代表全班:作业完成、通知已读都会按全班记录,不需要每个学生单独登录。',
],
},
{
icon: 'Calendar',
title: '登录之后',
lines: [
'左侧菜单切换「课表 / 作业 / 通知 / 成绩」四个页面,作业支持看板全屏展示。',
'断网时自动显示最近同步的缓存数据,联网后自动更新。',
],
},
{
icon: 'Bell',
title: '灵动岛提醒',
lines: [
'屏幕上方会有一条「灵动岛」浮窗:新通知、叫人、作业都会在那里弹出。',
'上课时段自动隐藏、下课自动弹出;外观与位置可在「设置 → 灵动岛」调整。',
],
},
{
icon: 'CircleCheckFilled',
title: '准备好了',
lines: [
'按上面的步骤登录就可以开始使用了。',
'侧边栏「设置」分组里有通用、外观(深色模式)、灵动岛等页面;这份引导可以在登录页或「设置 → 通用 → 使用引导」随时重看。',
],
},
];
const index = ref(0);
const current = computed(() => steps[index.value] ?? steps[0]);
const isFirst = computed(() => index.value === 0);
const isLast = computed(() => index.value === steps.length - 1);
/** 每次打开都从第一步开始(重看入口也走完整流程) */
watch(
() => onboarding.visible,
(visible) => {
if (visible) index.value = 0;
},
);
function goNext(): void {
if (isLast.value) finish();
else index.value += 1;
}
function goPrev(): void {
if (!isFirst.value) index.value -= 1;
}
/** 完成 / 跳过统一走这里:记「已看过」并关闭(重看时重复写一次无副作用) */
function finish(): void {
const bridge = window.desktop;
// 桥接不可用(浏览器调试)也要能正常关掉引导
if (!bridge) {
onboarding.close();
return;
}
void bridge
.saveConfig({ onboardingDone: true })
.then(() => onboarding.close())
.catch(() => onboarding.close());
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') finish();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onKeydown);
}
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
});
</script>
<template>
<Transition name="onboarding-fade">
<div
v-if="onboarding.visible"
class="onboarding-mask"
role="dialog"
aria-modal="true"
aria-label="初次启动引导"
>
<div class="onboarding-card onboarding-welcome">
<div class="onboarding-head">
<span class="onboarding-logo">
<el-icon :size="18"><School /></el-icon>
</span>
<div class="onboarding-heading">
<h2 class="onboarding-title">班级小助手</h2>
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
</div>
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
</div>
<div class="onboarding-body">
<span class="onboarding-icon">
<el-icon :size="30"><component :is="current.icon" /></el-icon>
</span>
<h3 class="onboarding-step-title">{{ current.title }}</h3>
<p v-for="(line, lineIndex) in current.lines" :key="lineIndex" class="onboarding-line">
{{ line }}
</p>
</div>
<div class="onboarding-foot">
<div class="onboarding-dots">
<span
v-for="(step, dotIndex) in steps"
:key="dotIndex"
class="dot"
:class="{ active: dotIndex === index }"
/>
</div>
<div class="onboarding-actions">
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
下一步
</el-button>
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.onboarding-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 30, 50, 0.55);
}
.onboarding-card {
width: 520px;
max-width: 92vw;
max-height: 86vh;
overflow-y: auto;
background: var(--ch-layer-solid);
border-radius: 16px;
padding: 22px 24px 18px;
box-shadow: 0 24px 64px rgba(15, 30, 50, 0.35);
}
.onboarding-head {
display: flex;
align-items: center;
gap: 10px;
}
.onboarding-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
}
.onboarding-heading {
flex: 1;
min-width: 0;
}
.onboarding-title {
margin: 0;
font-size: 15px;
line-height: 1.3;
}
.onboarding-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--ch-text-tertiary);
}
.onboarding-body {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 18px 4px 6px;
}
.onboarding-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 16px;
margin-bottom: 14px;
color: var(--ch-accent);
background: var(--ch-accent-soft);
}
.onboarding-step-title {
margin: 0 0 10px;
font-size: 18px;
}
.onboarding-line {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.8;
color: var(--ch-text-regular, #606266);
}
.onboarding-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--ch-divider);
}
.onboarding-dots {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--ch-border-strong);
transition: all 0.2s ease;
}
.dot.active {
width: 20px;
background: var(--ch-accent);
}
.onboarding-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 进出场:淡入 + 轻微上浮,跟随系统"动画"开关无强依赖 */
.onboarding-fade-enter-active,
.onboarding-fade-leave-active {
transition: opacity 0.22s ease;
}
.onboarding-fade-enter-active .onboarding-card,
.onboarding-fade-leave-active .onboarding-card {
transition: transform 0.22s ease;
}
.onboarding-fade-enter-from,
.onboarding-fade-leave-to {
opacity: 0;
}
.onboarding-fade-enter-from .onboarding-card,
.onboarding-fade-leave-to .onboarding-card {
transform: translateY(10px) scale(0.98);
}
</style>
@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js'; import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js'; import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
import { useUiStore } from '../stores/ui.js';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -15,18 +16,75 @@ const appStore = useAppStore();
const auth = useAuthStore(); const auth = useAuthStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const ui = useUiStore();
const menuItems = [ /**
{ path: '/schedule', title: '课表', icon: 'Calendar' }, * 主侧边栏:ClassIsland 式「分组 + 子菜单」。
{ path: '/homeworks', title: '作业', icon: 'Notebook' }, * - 学习:课表 / 作业 / 通知 / 成绩
{ path: '/notifications', title: '通知', icon: 'Bell' }, * - 设置:通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于(每个子项都是独立路由 /settings/*)
{ path: '/grades', title: '成绩', icon: 'Trophy' }, * 顶栏左侧汉堡按钮把整栏折叠成图标栏(状态持久化在主进程配置里)。
{ path: '/settings', title: '设置', icon: 'Setting' }, */
const menuGroups = [
{
title: '学习',
icon: 'Reading',
items: [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
],
},
{
title: '设置',
icon: 'Setting',
items: [
{ path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
],
},
]; ];
/** 未读红点挂在「通知」项的图标右上角 */
function showBadge(path: string): boolean {
return path === '/notifications' && notifications.unreadCount > 0;
}
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */ /** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
const activeMenu = computed(() => route.path); const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
return group?.title ?? '学习';
});
/**
* 分组展开控制:默认只展开**当前路由所在的分组**。
*
* 窗口默认高度与 ClassIsland 对齐(582px),两组全展开(10 个子项 + 2 个分组标题)
* 会超出可视高度、把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的
* 观感(它也只展开当前组),又让小窗口下的侧栏始终完整可见。
* 用户手动展开其它分组不受限制(可多个同时展开)。
*/
const menuRef = ref<MenuInstance>();
const openedGroups = ref<string[]>([activeGroupTitle.value]);
watch(
() => route.path,
async (path) => {
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
if (!group) return;
// 展开当前组(不主动关闭用户已展开的其它组)
await nextTick();
menuRef.value?.open(group.title);
},
);
/** /**
* 菜单点击回调。 * 菜单点击回调。
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径), * 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
@@ -44,9 +102,11 @@ function go(name: string): void {
/** 开发期自检:菜单路径必须存在于路由表中 */ /** 开发期自检:菜单路径必须存在于路由表中 */
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const knownPaths = new Set(router.getRoutes().map((item) => item.path)); const knownPaths = new Set(router.getRoutes().map((item) => item.path));
for (const item of menuItems) { for (const group of menuGroups) {
if (!knownPaths.has(item.path)) { for (const item of group.items) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`); if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
}
} }
} }
} }
@@ -106,29 +166,50 @@ onUnmounted(() => {
<template> <template>
<el-container class="layout"> <el-container class="layout">
<el-aside width="200px" class="aside ch-nav"> <el-aside :width="ui.sidebarCollapsed ? '64px' : '200px'" class="aside ch-nav">
<div class="brand"> <div class="brand">
<span class="brand-logo"> <button
<el-icon :size="16"><School /></el-icon> type="button"
</span> class="nav-toggle"
<span class="brand-text">班级小助手</span> aria-label="折叠或展开侧边栏"
</div> :aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect"> data-test="sidebar-toggle"
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path"> @click="ui.toggleSidebar()"
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) --> >
<span class="menu-icon-slot"> <el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
<el-icon><component :is="item.icon" /></el-icon> </button>
<el-badge <template v-if="!ui.sidebarCollapsed">
v-if="item.path === '/notifications' && notifications.unreadCount > 0" <span class="brand-logo">
:value="notifications.unreadCount" <el-icon :size="16"><School /></el-icon>
:max="99"
class="menu-badge"
/>
</span> </span>
<span>{{ item.title }}</span> <span class="brand-text">班级小助手</span>
</el-menu-item> </template>
</div>
<el-menu
ref="menuRef"
:default-active="activeMenu"
:default-openeds="openedGroups"
:collapse="ui.sidebarCollapsed"
:collapse-width="64"
class="menu"
@select="handleMenuSelect"
>
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
<template #title>
<el-icon><component :is="group.icon" /></el-icon>
<span>{{ group.title }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<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> </el-menu>
<div class="aside-footer"> <div v-if="!ui.sidebarCollapsed" class="aside-footer">
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div> <div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div> <div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
</div> </div>
@@ -142,6 +223,19 @@ onUnmounted(() => {
<span class="server-url">{{ appStore.serverUrl }}</span> <span class="server-url">{{ appStore.serverUrl }}</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- 黑夜/白天快捷切换:外观页里也有同样的设置,两处写同一份配置 -->
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
<el-button
class="theme-toggle"
text
circle
data-test="theme-toggle"
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
@click="ui.toggleTheme()"
>
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
</el-button>
</el-tooltip>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<span class="user-chip"> <span class="user-chip">
<el-icon><UserFilled /></el-icon> <el-icon><UserFilled /></el-icon>
@@ -150,7 +244,7 @@ onUnmounted(() => {
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="go('settings')"> <el-dropdown-item @click="go('settings-general')">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
设置 设置
</el-dropdown-item> </el-dropdown-item>
@@ -198,12 +292,38 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 10px; gap: 10px;
height: 52px; height: 52px;
padding: 0 16px; padding: 0 10px 0 12px;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
color: var(--ch-text); color: var(--ch-text);
} }
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
.nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: 0 0 auto;
border: none;
border-radius: var(--ch-radius-control);
background: transparent;
color: var(--ch-text-secondary);
cursor: pointer;
transition: background 0.15s ease;
}
.nav-toggle:hover {
background: var(--ch-hover-soft);
}
/* 折叠态:侧栏只剩 64px,汉堡按钮居中 */
.aside.ch-nav:has(.el-menu--collapse) .brand {
justify-content: center;
padding: 0;
}
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */ /* Fluent 品牌标:强调色圆角方块 + 白色图标 */
.brand-logo { .brand-logo {
display: inline-flex; display: inline-flex;
@@ -222,10 +342,14 @@ onUnmounted(() => {
.menu { .menu {
flex: 1; flex: 1;
/* min-height:0 是 flex 子项能滚动的关键:否则内容撑高、底部 footer 被挤出窗口 */
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-right: none; border-right: none;
background: transparent; background: transparent;
--el-menu-bg-color: transparent; --el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04); --el-menu-hover-bg-color: var(--ch-hover-soft);
--el-menu-text-color: var(--ch-text); --el-menu-text-color: var(--ch-text);
--el-menu-active-color: var(--ch-text); --el-menu-active-color: var(--ch-text);
} }
@@ -279,7 +403,7 @@ onUnmounted(() => {
.header { .header {
height: 52px; height: 52px;
background: rgba(255, 255, 255, 0.6); background: var(--ch-header-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border-bottom: 1px solid var(--ch-border); border-bottom: 1px solid var(--ch-border);
display: flex; display: flex;
@@ -313,7 +437,12 @@ onUnmounted(() => {
} }
.user-chip:hover { .user-chip:hover {
background: rgba(0, 0, 0, 0.04); background: var(--ch-hover-soft);
}
.theme-toggle {
margin-right: 2px;
color: var(--ch-text-secondary);
} }
.main { .main {
@@ -4,6 +4,8 @@ import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn'; import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue'; import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'element-plus/dist/index.css'; import 'element-plus/dist/index.css';
// 深色主题:html.dark 下的 --el-* 变量(品牌覆盖在 styles/index.css 的 html.dark 块)
import 'element-plus/theme-chalk/dark/css-vars.css';
import App from './App.vue'; import App from './App.vue';
import router from './router/index.js'; import router from './router/index.js';
@@ -41,11 +41,46 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/GradeView.vue'), component: () => import('../views/GradeView.vue'),
meta: { title: '成绩' }, meta: { title: '成绩' },
}, },
/* 设置:主侧边栏「设置」分组下的六个子页(ClassIsland 式 主菜单+子菜单) */
{ {
path: 'settings', path: 'settings',
name: 'settings', redirect: '/settings/general',
component: () => import('../views/SettingsView.vue'), },
meta: { title: '设置' }, {
path: 'settings/general',
name: 'settings-general',
component: () => import('../views/settings/GeneralSettingsView.vue'),
meta: { title: '通用设置' },
},
{
path: 'settings/appearance',
name: 'settings-appearance',
component: () => import('../views/settings/AppearanceView.vue'),
meta: { title: '外观' },
},
{
path: 'settings/island',
name: 'settings-island',
component: () => import('../views/settings/IslandSettingsView.vue'),
meta: { title: '灵动岛' },
},
{
path: 'settings/reminder',
name: 'settings-reminder',
component: () => import('../views/settings/ReminderSettingsView.vue'),
meta: { title: '提醒' },
},
{
path: 'settings/account',
name: 'settings-account',
component: () => import('../views/settings/AccountSettingsView.vue'),
meta: { title: '账号' },
},
{
path: 'settings/about',
name: 'settings-about',
component: () => import('../views/settings/AboutView.vue'),
meta: { title: '关于' },
}, },
], ],
}, },
@@ -0,0 +1,24 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
/**
* 初次启动引导的显示状态。
*
* 首次启动由 App.vue 按「配置里是否记过 onboardingDone」自动弹出;
* 登录页与设置页的「使用引导」入口调用 open() 随时重看。
* 单独一个 store 而不是组件内部 ref:触发入口(登录页 / 设置页)与
* 渲染点(App.vue 顶层)分处两棵互不相干的子树。
*/
export const useOnboardingStore = defineStore('onboarding', () => {
const visible = ref(false);
function open(): void {
visible.value = true;
}
function close(): void {
visible.value = false;
}
return { visible, open, close };
});
@@ -0,0 +1,73 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import type { DesktopStoredConfig } from '../../types/desktop.js';
/**
* 界面偏好(主题 / 侧边栏折叠)。
*
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
*/
const THEME_KEY = 'classhelper.theme';
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
function applyThemeClass(theme: 'light' | 'dark'): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
export const useUiStore = defineStore('ui', () => {
const theme = ref<'light' | 'dark'>('light');
const sidebarCollapsed = ref(false);
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
async function init(): Promise<void> {
if (window.desktop) {
const config = await window.desktop.getConfig().catch(() => null);
if (config) {
theme.value = config.theme === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = config.sidebarCollapsed === true;
}
} else {
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
}
applyThemeClass(theme.value);
}
/** 切换主题(外观页单选 / 顶栏快捷切换共用),写回配置 */
async function setTheme(next: 'light' | 'dark'): Promise<void> {
theme.value = next;
applyThemeClass(next);
if (window.desktop) {
await window.desktop.saveConfig({ theme: next }).catch(() => undefined);
} else {
localStorage.setItem(THEME_KEY, next);
}
}
function toggleTheme(): void {
void setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
/** 主侧边栏折叠/展开(汉堡按钮),写回配置 */
async function setSidebarCollapsed(collapsed: boolean): Promise<void> {
sidebarCollapsed.value = collapsed;
if (window.desktop) {
await window.desktop.saveConfig({ sidebarCollapsed: collapsed }).catch(() => undefined);
} else {
localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0');
}
}
function toggleSidebar(): void {
void setSidebarCollapsed(!sidebarCollapsed.value);
}
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
});
/** 供类型检查使用(仅引用类型) */
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
@@ -1,12 +1,19 @@
/* ============================================================================ /* ============================================================================
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格 * 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
* *
* 参考 ClassIsland(Windows 课件表应用)的观感: * 参考 ClassIsland(Windows 课件表应用)的观感:
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边 * - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条 * - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px * - 圆角:卡片 8px、控件 4~6px、胶囊 999px
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松 * - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分 * - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
*
* 主题机制:
* - 全部颜色走 --ch-* / --el-* 变量,:root 是浅色值;
* - 深色主题 = html 根元素挂 .dark 类(由 stores/ui.ts 按配置切换),
* Element Plus 的 dark/css-vars.css(main.ts 引入)提供 --el-* 深色默认,
* 下面的 html.dark 块覆盖品牌变量(特异性高于 :root,必定生效);
* - 组件里**不要写死颜色**,一律用变量——深色回归靠这条纪律守着。
* ========================================================================== */ * ========================================================================== */
:root { :root {
@@ -33,6 +40,13 @@
--ch-text-secondary: #5c5c5c; --ch-text-secondary: #5c5c5c;
--ch-text-tertiary: #8a8a8a; --ch-text-tertiary: #8a8a8a;
/* 交互态(悬停底色 / 侧栏与顶栏的亚克力底 / 滚动条) */
--ch-hover-soft: rgba(0, 0, 0, 0.04);
--ch-nav-bg: rgba(255, 255, 255, 0.65);
--ch-header-bg: rgba(255, 255, 255, 0.6);
--ch-scrollbar: rgba(0, 0, 0, 0.18);
--ch-scrollbar-hover: rgba(0, 0, 0, 0.3);
/* 圆角与阴影 */ /* 圆角与阴影 */
--ch-radius-card: 8px; --ch-radius-card: 8px;
--ch-radius-control: 4px; --ch-radius-control: 4px;
@@ -62,6 +76,55 @@
--el-font-size-base: 13px; --el-font-size-base: 13px;
} }
/* ---------------------------------------------------------------- 深色主题 */
html.dark {
--ch-accent: #5aabec;
--ch-accent-hover: #7cc0f4;
--ch-accent-soft: rgba(90, 171, 236, 0.16);
--ch-accent-soft-strong: rgba(90, 171, 236, 0.26);
--ch-bg: #161719;
--ch-layer: rgba(32, 33, 36, 0.72);
--ch-layer-solid: #1e1f23;
--ch-layer-alt: rgba(28, 29, 32, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.04);
--ch-border: rgba(255, 255, 255, 0.08);
--ch-border-strong: rgba(255, 255, 255, 0.14);
--ch-divider: rgba(255, 255, 255, 0.06);
--ch-text: #e8e8ea;
--ch-text-secondary: #b0b1b5;
--ch-text-tertiary: #7e7f85;
--ch-hover-soft: rgba(255, 255, 255, 0.07);
--ch-nav-bg: rgba(28, 29, 32, 0.65);
--ch-header-bg: rgba(30, 31, 34, 0.6);
--ch-scrollbar: rgba(255, 255, 255, 0.22);
--ch-scrollbar-hover: rgba(255, 255, 255, 0.36);
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.45);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.3);
--ch-sidebar: #1e1f23;
/* Element Plus 深色下的品牌色对齐(dark/css-vars.css 的默认蓝偏亮,这里对齐品牌) */
--el-color-primary: #4a9eff;
--el-color-primary-light-3: #337fd6;
--el-color-primary-light-5: #2563ab;
--el-color-primary-light-7: #1b4a82;
--el-color-primary-light-8: #163c6b;
--el-color-primary-light-9: #122f55;
--el-color-primary-dark-2: #6cb4ff;
--el-text-color-primary: #e8e8ea;
--el-text-color-regular: #c9cacd;
--el-text-color-secondary: #9a9b9f;
--el-border-color: rgba(255, 255, 255, 0.12);
--el-border-color-light: rgba(255, 255, 255, 0.08);
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -179,12 +242,12 @@ textarea {
} }
.el-button--default { .el-button--default {
background: rgba(255, 255, 255, 0.7) !important; background: var(--ch-layer) !important;
border-color: var(--ch-border-strong) !important; border-color: var(--ch-border-strong) !important;
} }
.el-button--default:hover { .el-button--default:hover {
background: rgba(0, 0, 0, 0.04) !important; background: var(--ch-hover-soft) !important;
} }
.el-button--primary { .el-button--primary {
@@ -195,7 +258,7 @@ textarea {
.el-textarea__inner, .el-textarea__inner,
.el-select__wrapper { .el-select__wrapper {
border-radius: var(--ch-radius-control) !important; border-radius: var(--ch-radius-control) !important;
background: rgba(255, 255, 255, 0.7) !important; background: var(--ch-layer) !important;
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important; box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
} }
@@ -224,12 +287,38 @@ textarea {
box-shadow: var(--ch-shadow-flyout) !important; box-shadow: var(--ch-shadow-flyout) !important;
} }
/* 关于页的折叠卡片:与 el-card 同一套观感(分割线代替卡片描边) */
.el-collapse {
--el-collapse-border-color: var(--ch-divider);
border: none !important;
}
.el-collapse-item__header {
background: var(--ch-layer) !important;
border: 1px solid var(--ch-border) !important;
border-radius: var(--ch-radius-card) !important;
padding: 0 16px;
margin-top: 8px;
font-weight: 600;
font-size: 13.5px;
}
.el-collapse-item__wrap {
background: transparent !important;
border-bottom: none !important;
}
.el-collapse-item__content {
padding: 12px 6px 4px !important;
color: var(--ch-text);
}
/* ---------------------------------------------------------------- 表格 */ /* ---------------------------------------------------------------- 表格 */
.el-table { .el-table {
--el-table-border-color: var(--ch-divider); --el-table-border-color: var(--ch-divider);
--el-table-header-bg-color: transparent; --el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03); --el-table-row-hover-bg-color: var(--ch-hover-soft);
background: transparent !important; background: transparent !important;
} }
@@ -251,12 +340,13 @@ textarea {
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */ /* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
.ch-nav { .ch-nav {
background: rgba(255, 255, 255, 0.65); background: var(--ch-nav-bg);
backdrop-filter: blur(24px) saturate(1.2); backdrop-filter: blur(24px) saturate(1.2);
border-right: 1px solid var(--ch-border); border-right: 1px solid var(--ch-border);
} }
.ch-nav .el-menu-item { .ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
height: 40px; height: 40px;
line-height: 40px; line-height: 40px;
margin: 2px 8px; margin: 2px 8px;
@@ -266,8 +356,19 @@ textarea {
position: relative; position: relative;
} }
.ch-nav .el-menu-item:hover { .ch-nav .el-menu-item:hover,
background: rgba(0, 0, 0, 0.04); .ch-nav .el-sub-menu__title:hover {
background: var(--ch-hover-soft);
}
/* 分组标题:比普通菜单项轻一档(分组是"容器",不是目的地) */
.ch-nav .el-sub-menu__title {
font-weight: 600;
color: var(--ch-text-secondary);
}
.ch-nav .el-sub-menu .el-menu {
background: transparent;
} }
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */ /* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
@@ -288,6 +389,58 @@ textarea {
background: var(--ch-accent); background: var(--ch-accent);
} }
/* 折叠态:图标居中(el-menu collapse 自带收窄,这里压掉分组缩进) */
.ch-nav .el-menu--collapse .el-menu-item,
.ch-nav .el-menu--collapse .el-sub-menu__title {
margin: 2px 8px;
padding: 0 !important;
}
/*
* 折叠态:把菜单文字**彻底不渲染**,只留图标槽。
*
* Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 + overflow",一旦外层样式
* (缩进、margin、transition)稍有干扰,文字就会以 0 宽溢出的形式留在画面上 ——
* 表现为折叠后菜单里残留"半截字"。这里直接 display:none,从根上消除残影。
* 图标槽(.menu-icon-slot)必须保留(它同时承载未读红点)。
*/
.ch-nav .el-menu--collapse .el-menu-item > span:not(.menu-icon-slot),
.ch-nav .el-menu--collapse .el-sub-menu__title > span:not(.menu-icon-slot) {
display: none !important;
}
/* 折叠态:图标在 64px 栏内严格水平居中,且与顶部的汉堡按钮同一竖列(中心都在 32px) */
.ch-nav .el-menu--collapse .el-menu-item,
.ch-nav .el-menu--collapse .el-sub-menu__title {
display: flex;
align-items: center;
justify-content: center;
}
/*
* 折叠态点分组图标会弹出子菜单浮层 —— 它被 teleport 到 body 下,
* 不受 .ch-nav 作用域影响,所以这些规则写在全局;不写的话深色主题下会是浅色浮层。
*/
.el-menu--popup {
background: var(--ch-layer-solid);
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
box-shadow: var(--ch-shadow-flyout);
}
.el-menu--popup .el-menu-item {
color: var(--ch-text);
}
.el-menu--popup .el-menu-item:hover {
background: var(--ch-hover-soft);
}
.el-menu--popup .el-menu-item.is-active {
background: var(--ch-accent-soft);
color: var(--ch-text);
}
/* ---------------------------------------------------------------- 课表时间轴 */ /* ---------------------------------------------------------------- 课表时间轴 */
.timetable-cell { .timetable-cell {
@@ -320,14 +473,14 @@ textarea {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18); background: var(--ch-scrollbar);
border: 3px solid transparent; border: 3px solid transparent;
background-clip: content-box; background-clip: content-box;
border-radius: 8px; border-radius: 8px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3); background: var(--ch-scrollbar-hover);
background-clip: content-box; background-clip: content-box;
} }
@@ -226,7 +226,7 @@ onUnmounted(() => {
<style scoped> <style scoped>
.stat-card { .stat-card {
background: #fff; background: var(--ch-layer-solid);
border: 1px solid var(--ch-border); border: 1px solid var(--ch-border);
border-radius: 10px; border-radius: 10px;
padding: 16px; padding: 16px;
@@ -234,7 +234,7 @@ onUnmounted(() => {
.stat-label { .stat-label {
font-size: 13px; font-size: 13px;
color: #909399; color: var(--ch-text-secondary);
} }
.stat-value { .stat-value {
@@ -246,7 +246,7 @@ onUnmounted(() => {
.stat-hint { .stat-hint {
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
color: #c0c4cc; color: var(--ch-text-tertiary);
} }
.level-list { .level-list {
@@ -916,7 +916,7 @@ onUnmounted(() => {
.content-block { .content-block {
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.75; line-height: 1.75;
background: #fafafa; background: var(--ch-layer-alt);
border-radius: 12px; border-radius: 12px;
padding: 12px; padding: 12px;
margin: 0; margin: 0;
@@ -1148,7 +1148,7 @@ onUnmounted(() => {
transform: translateX(-50%); transform: translateX(-50%);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #6b7280; color: var(--ch-text-tertiary);
white-space: nowrap; white-space: nowrap;
} }
@@ -1176,7 +1176,7 @@ onUnmounted(() => {
.submission-list { .submission-list {
max-height: 46vh; max-height: 46vh;
overflow-y: auto; overflow-y: auto;
border: 1px solid #ebeef5; border: 1px solid var(--ch-divider);
border-radius: 12px; border-radius: 12px;
padding: 12px; padding: 12px;
} }
@@ -5,12 +5,14 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { pingHealth, setApiBaseUrl } from '../api/http.js'; import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useOnboardingStore } from '../stores/onboarding.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js'; import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
const router = useRouter(); const router = useRouter();
const appStore = useAppStore(); const appStore = useAppStore();
const auth = useAuthStore(); const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
@@ -134,6 +136,14 @@ async function enterOffline(): Promise<void> {
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button> <el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
</div> </div>
<!-- 重看初次启动引导:首次启动会自动弹出过一次,这里供随时回看 -->
<div class="login-guide">
<el-button link type="primary" @click="onboarding.open()">
<el-icon><Guide /></el-icon>
查看使用引导
</el-button>
</div>
<el-alert <el-alert
v-if="testResult === 'ok'" v-if="testResult === 'ok'"
class="mt-12" class="mt-12"
@@ -177,7 +187,7 @@ async function enterOffline(): Promise<void> {
.login-card { .login-card {
width: 420px; width: 420px;
background: #fff; background: var(--ch-layer-solid);
border-radius: 14px; border-radius: 14px;
padding: 28px 26px 20px; padding: 28px 26px 20px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28); box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
@@ -198,7 +208,7 @@ async function enterOffline(): Promise<void> {
.login-sub { .login-sub {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
color: #909399; color: var(--ch-text-secondary);
} }
.login-actions { .login-actions {
@@ -210,6 +220,12 @@ async function enterOffline(): Promise<void> {
flex: 1; flex: 1;
} }
.login-guide {
display: flex;
justify-content: center;
margin-top: 10px;
}
.offline-button { .offline-button {
width: 100%; width: 100%;
} }
@@ -218,6 +234,6 @@ async function enterOffline(): Promise<void> {
margin: 16px 0 0; margin: 16px 0 0;
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #c0c4cc; color: var(--ch-text-tertiary);
} }
</style> </style>
@@ -165,11 +165,11 @@ onUnmounted(() => {
} }
.notice-item:hover { .notice-item:hover {
background: #f7f9fc; background: var(--ch-hover-soft);
} }
.notice-item.unread { .notice-item.unread {
background: #f0f7ff; background: var(--ch-accent-soft);
} }
.notice-item:last-child { .notice-item:last-child {
@@ -194,7 +194,7 @@ onUnmounted(() => {
.notice-content { .notice-content {
margin-top: 6px; margin-top: 6px;
font-size: 13px; font-size: 13px;
color: #606266; color: var(--ch-text-secondary);
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -220,7 +220,7 @@ onUnmounted(() => {
.content-block { .content-block {
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.8; line-height: 1.8;
background: #fafafa; background: var(--ch-layer-alt);
border-radius: 8px; border-radius: 8px;
padding: 14px; padding: 14px;
margin: 0 0 16px; margin: 0 0 16px;
@@ -460,7 +460,7 @@ onUnmounted(() => {
} }
.hero-status.status-idle { .hero-status.status-idle {
background: rgba(0, 0, 0, 0.03); background: var(--ch-hover-soft);
} }
.status-title { .status-title {
@@ -553,7 +553,7 @@ onUnmounted(() => {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: #fff; background: var(--ch-layer-solid);
border: 2px solid var(--ch-border-strong); border: 2px solid var(--ch-border-strong);
} }
@@ -574,7 +574,8 @@ onUnmounted(() => {
padding: 10px 14px; padding: 10px 14px;
border: 1px solid var(--ch-border); border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card); border-radius: var(--ch-radius-card);
background: rgba(255, 255, 255, 0.6); /* 走主题变量:浅色是白玻璃、深色是深玻璃(写死白色会在深色主题下变成"浅灰卡 + 浅字"看不清) */
background: var(--ch-layer);
transition: transition:
border-color 0.15s ease, border-color 0.15s ease,
background 0.15s ease, background 0.15s ease,
@@ -595,7 +596,7 @@ onUnmounted(() => {
} }
.timeline-item.is-past .timeline-card-inner { .timeline-item.is-past .timeline-card-inner {
background: rgba(255, 255, 255, 0.35); background: var(--ch-layer-alt);
color: var(--ch-text-tertiary); color: var(--ch-text-tertiary);
} }
@@ -1,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,182 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { DesktopAppInfo } from '../../../types/desktop.js';
import { useAppStore } from '../../stores/app.js';
/**
* 关于(ClassIsland 同款折叠卡片式):
* 应用信息(版本/运行时/常用链接)→ 诊断信息(排查问题用)→ 鸣谢。
* 所有外链走主进程 openExternal(只放行 http/https,见 main/ipc.ts 的白名单)。
*/
const appStore = useAppStore();
const appInfo = ref<DesktopAppInfo | null>(null);
/** 折叠面板默认展开第一项(应用信息),诊断信息默认收起 */
const expandedNames = ref(['app']);
const PROJECT_URL = 'https://github.com/LoveChengke/ClassHelper';
const ISSUES_URL = 'https://github.com/LoveChengke/ClassHelper/issues';
const collapseItems = [
{ name: 'app', title: '应用信息', icon: 'School' },
{ name: 'diagnostics', title: '查看诊断信息', icon: 'Warning' },
{ name: 'thanks', title: '鸣谢', icon: 'Pointer' },
];
async function openExternal(url: string): Promise<void> {
if (window.desktop?.openExternal) {
const handled = await window.desktop.openExternal(url);
if (handled) return;
}
window.open(url, '_blank');
}
onMounted(async () => {
if (!window.desktop) return;
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">关于</h2>
<p class="page-subtitle">版本、诊断信息与鸣谢</p>
</div>
</div>
<el-collapse v-model="expandedNames" class="about-collapse">
<!-- 应用信息 -->
<el-collapse-item name="app">
<template #title>
<span class="about-title">
<el-icon><School /></el-icon>
{{ collapseItems[0].title }}
</span>
<span class="about-extra">
班级小助手 v{{ appInfo?.appVersion ?? '-' }}
</span>
</template>
<div class="about-section">
<p class="about-copy">
Copyright © 2025-2026 班级小助手
<br />
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
</p>
<div class="about-links">
<span class="about-links-label">常用链接</span>
<el-button link type="primary" @click="openExternal(PROJECT_URL)">项目主页</el-button>
<el-button link type="primary" @click="openExternal(ISSUES_URL)">问题反馈</el-button>
</div>
<el-descriptions :column="1" border size="small" class="mt-12">
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="运行时">
Electron {{ appInfo?.electron ?? '-' }} · Chromium {{ appInfo?.chrome ?? '-' }} · Node
{{ appInfo?.node ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-collapse-item>
<!-- 诊断信息 -->
<el-collapse-item name="diagnostics">
<template #title>
<span class="about-title">
<el-icon><Warning /></el-icon>
{{ collapseItems[1].title }}
</span>
<span class="about-extra">报障时请附带本页信息</span>
</template>
<div class="about-section">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="服务器地址">{{ appStore.serverUrl }}</el-descriptions-item>
<el-descriptions-item label="连接状态">
<el-tag size="small" :type="appStore.serverReachable ? 'success' : 'danger'">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="缓存条目">{{ appStore.cacheItemCount }} 条</el-descriptions-item>
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="配置文件">{{ appInfo?.configPath ?? '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-collapse-item>
<!-- 鸣谢 -->
<el-collapse-item name="thanks">
<template #title>
<span class="about-title">
<el-icon><Pointer /></el-icon>
{{ collapseItems[2].title }}
</span>
</template>
<div class="about-section">
<ul class="about-thanks">
<li><strong>ClassIsland</strong> —— 界面风格、设置页与「今天」时间轴的交互参考</li>
<li><strong>WinIsland</strong> —— 灵动岛的窗口形态与弹簧动画参考</li>
<li><strong>Vue / Element Plus / Prisma / Express / Socket.IO</strong> —— 本项目赖以构建的开源基石</li>
</ul>
</div>
</el-collapse-item>
</el-collapse>
<p class="about-quote">谨以此应用,献给朝夕相伴的班级与同窗;愿时光流转,情谊常在。</p>
</div>
</template>
<style scoped>
.about-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.about-extra {
margin-left: auto;
margin-right: 12px;
font-size: 12px;
font-weight: 400;
color: var(--ch-text-tertiary);
}
.about-section {
padding: 0 4px;
}
.about-copy {
margin: 0 0 10px;
line-height: 1.8;
color: var(--ch-text-secondary);
font-size: 12.5px;
}
.about-links {
display: flex;
align-items: center;
gap: 4px;
}
.about-links-label {
font-size: 12.5px;
color: var(--ch-text-secondary);
margin-right: 4px;
}
.about-thanks {
margin: 0;
padding-left: 18px;
line-height: 2;
font-size: 12.5px;
color: var(--ch-text-secondary);
}
.about-quote {
margin: 18px 0 4px;
text-align: center;
font-size: 12px;
color: var(--ch-text-tertiary);
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '../../stores/auth.js';
import { useRealtimeStore } from '../../stores/realtime.js';
/**
* 账号设置:本机登录的班级账号信息 + 退出登录。
* 班级密码由管理员在 Web 管理端维护(本机按用户要求不提供改密入口)。
*/
const router = useRouter();
const auth = useAuthStore();
const realtime = useRealtimeStore();
async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
await router.replace('/login');
}
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">账号</h2>
<p class="page-subtitle">本机登录的班级账号</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
{{ auth.user?.username ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="登录方式">
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-16">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
<div class="text-muted mt-12">
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
班级密码重新登录。
</div>
</el-card>
</div>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { useUiStore } from '../../stores/ui.js';
/**
* 外观:黑夜 / 白天模式。
* 状态与持久化都在 stores/ui.ts(主进程配置 theme 字段),顶栏右上角的
* 日月按钮与本页写同一份配置,切换立即生效(html.dark + Element Plus dark css-vars)。
*/
const ui = useUiStore();
const options = [
{ value: 'light', label: '浅色', desc: '明亮界面,适合白天与投影环境' },
{ value: 'dark', label: '深色', desc: '黑夜模式,适合晚自习与暗教室' },
] as const;
async function selectTheme(value: 'light' | 'dark'): Promise<void> {
if (value === ui.theme) return;
await ui.setTheme(value);
ElMessage.success(value === 'dark' ? '已切换到深色模式' : '已切换到浅色模式');
}
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">外观</h2>
<p class="page-subtitle">黑夜与白天模式,切换立即生效并自动保存</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>界面主题</span></template>
<div class="theme-options">
<button
v-for="option in options"
:key="option.value"
type="button"
class="theme-option"
:class="[`theme-${option.value}`, { active: ui.theme === option.value }]"
:data-theme-option="option.value"
@click="selectTheme(option.value)"
>
<span class="theme-preview">
<span class="preview-side" />
<span class="preview-body">
<span class="preview-line" />
<span class="preview-line short" />
</span>
</span>
<span class="theme-name">
<el-icon><Sunny v-if="option.value === 'light'" /><Moon v-else /></el-icon>
{{ option.label }}
</span>
<span class="theme-desc">{{ option.desc }}</span>
</button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="顶栏右上角也有主题快捷切换按钮"
description="灵动岛始终为深色浮窗,不随主题变化;登录页与全屏作业看板为固定配色,亦不受影响。"
/>
</el-card>
</div>
</template>
<style scoped>
.theme-options {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.theme-option {
width: 200px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 12px;
border: 1px solid var(--ch-border-strong);
border-radius: var(--ch-radius-card);
background: var(--ch-layer);
cursor: pointer;
color: var(--ch-text);
text-align: left;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.theme-option:hover {
border-color: var(--ch-accent);
}
.theme-option.active {
border-color: var(--ch-accent);
box-shadow: 0 0 0 1px var(--ch-accent) inset;
}
/* 主题缩略预览:两块小色板直接表现各自配色 */
.theme-preview {
display: flex;
width: 100%;
height: 64px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--ch-border-strong);
}
.theme-light .theme-preview {
background: #f3f3f3;
}
.theme-dark .theme-preview {
background: #161719;
}
.preview-side {
width: 28px;
background: rgba(255, 255, 255, 0.65);
}
.theme-dark .preview-side {
background: rgba(32, 33, 36, 0.9);
}
.preview-body {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-line {
height: 8px;
border-radius: 4px;
width: 80%;
background: rgba(15, 108, 189, 0.55);
}
.preview-line.short {
width: 50%;
background: rgba(128, 128, 128, 0.35);
}
.theme-name {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.theme-desc {
font-size: 12px;
color: var(--ch-text-tertiary);
}
</style>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
formatDate,
} from '@classhelper/shared';
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
import { normalizeServerUrl } from '../../config.js';
import { useAppStore } from '../../stores/app.js';
import { useAuthStore } from '../../stores/auth.js';
import { useNotificationStore } from '../../stores/notifications.js';
import { useOnboardingStore } from '../../stores/onboarding.js';
import { useRealtimeStore } from '../../stores/realtime.js';
/**
* 通用设置:服务器连接、离线缓存与作业录入短语。
* (外观 / 灵动岛 / 提醒 / 账号 / 关于 各自是独立子页,见侧边栏「设置」分组。)
*/
const appStore = useAppStore();
const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
const saving = ref(false);
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function saveServer(): Promise<void> {
const normalized = normalizeServerUrl(serverInput.value);
if (!normalized) {
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
return;
}
saving.value = true;
try {
await appStore.applyServerUrl(normalized);
serverInput.value = normalized;
ElMessage.success('服务器地址已保存');
await testConnection();
if (auth.token) realtime.connect(normalized, auth.token);
} finally {
saving.value = false;
}
}
async function testConnection(): Promise<void> {
testing.value = true;
try {
const result = await pingHealth();
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function manualSync(): Promise<void> {
setApiBaseUrl(appStore.serverUrl);
await appStore.ping();
if (appStore.offline) {
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
return;
}
await notifications.load();
appStore.markSynced();
ElMessage.success('已同步最新数据');
}
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
/* ------------------------------------------------------------ 作业录入快捷短语 */
/**
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
*
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
*/
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
const phraseInput = ref('');
const savingPhrases = ref(false);
async function loadPhrases(): Promise<void> {
const config = await window.desktop?.getConfig?.();
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
}
async function savePhrases(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingPhrases.value = true;
try {
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
phrases.value = [...saved.homeworkPhrases];
} finally {
savingPhrases.value = false;
}
}
async function addPhrase(): Promise<void> {
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
if (!value) return;
if (phrases.value.includes(value)) {
ElMessage.warning('这条短语已经有了');
return;
}
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
return;
}
phrases.value = [...phrases.value, value];
phraseInput.value = '';
await savePhrases();
ElMessage.success('已添加');
}
async function removePhrase(value: string): Promise<void> {
phrases.value = phrases.value.filter((item) => item !== value);
await savePhrases();
}
async function resetPhrases(): Promise<void> {
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
await savePhrases();
ElMessage.success('已恢复默认');
}
onMounted(async () => {
await appStore.refreshCacheStats();
await loadPhrases();
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">通用</h2>
<p class="page-subtitle">服务器连接、离线缓存与作业录入</p>
</div>
<!-- 重看初次启动引导(首启时由 App.vue 自动弹出过一次) -->
<el-button @click="onboarding.open()">
<el-icon><Guide /></el-icon>
使用引导
</el-button>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>服务器连接</span></template>
<el-form label-position="top">
<el-form-item label="服务器地址">
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button @click="manualSync">立即同步</el-button>
</div>
<el-descriptions class="mt-16" :column="1" border size="small">
<el-descriptions-item label="当前状态">
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
{{ realtime.statusText }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>作业录入</span></template>
<p class="text-muted">
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
</p>
<div class="phrase-row">
<el-tag
v-for="item in phrases"
:key="item"
closable
type="info"
effect="plain"
@close="removePhrase(item)"
>
{{ item }}
</el-tag>
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
</div>
<div class="toolbar mt-12">
<el-input
v-model="phraseInput"
placeholder="新增短语,如 大本"
style="width: 200px"
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
@keyup.enter="addPhrase"
/>
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
<el-button @click="resetPhrases">恢复默认</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
show-icon
title="离线可用"
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.phrase-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.ml-8 {
margin-left: 8px;
}
</style>
@@ -0,0 +1,276 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
ISLAND_POSITION_LABELS,
ISLAND_POSITIONS,
ISLAND_STYLE_LABELS,
ISLAND_STYLES,
type IslandAppearance,
type IslandPosition,
type IslandStyle,
} from '@classhelper/shared';
/**
* 灵动岛个性化(原设置页「灵动岛 · 个性化」卡整块迁移)。
*
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
*/
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const savingIsland = ref(false);
/**
* 左右边距是否有效:只在停靠左 / 右时生效。
*
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
*/
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
const positionOptions = ISLAND_POSITIONS.map((value) => ({
value,
label: ISLAND_POSITION_LABELS[value],
}));
const styleOptions = ISLAND_STYLES.map((value) => ({
value,
label: ISLAND_STYLE_LABELS[value],
}));
/** 读取当前生效的外观(主进程为单一事实来源) */
async function loadIslandAppearance(): Promise<void> {
if (!window.desktop?.islandGetAppearance) return;
island.value = await window.desktop.islandGetAppearance();
}
/** 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘 */
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
island.value = { ...island.value, ...patch };
window.desktop?.islandSetAppearance({ ...island.value });
}
async function saveIslandAppearance(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingIsland.value = true;
try {
await window.desktop.saveConfig({ island: { ...island.value } });
ElMessage.success('个性化设置已保存');
} finally {
savingIsland.value = false;
}
}
async function resetIslandAppearance(): Promise<void> {
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
await saveIslandAppearance();
}
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
function testIsland(): void {
window.desktop?.islandPush?.({
notification: {
id: `appearance-test-${Date.now()}`,
title: '灵动岛外观预览',
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
priority: 'NORMAL',
createdAt: new Date().toISOString(),
courseName: null,
teacherName: '本地预览',
},
context: { inClass: false, currentPeriodEnd: null, preview: true },
});
}
onMounted(async () => {
await loadIslandAppearance();
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">灵动岛</h2>
<p class="page-subtitle">屏幕上方的消息浮窗:外观、停靠位置与动画</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>灵动岛 · 个性化</span></template>
<el-form label-width="96px" label-position="left">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
<el-form-item label="显示位置">
<el-select
:model-value="island.position"
style="width: 180px"
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
>
<el-option
v-for="item in positionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item :label="`左右边距 ${island.marginX}px`">
<el-slider
:model-value="island.marginX"
:min="RANGES.marginX.min"
:max="RANGES.marginX.max"
:step="2"
:disabled="!marginXApplies"
@input="(value: number) => applyIslandAppearance({ marginX: value })"
/>
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
<span v-else class="text-muted ml-8">
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
</span>
</el-form-item>
<el-form-item :label="`上下边距 ${island.marginY}px`">
<el-slider
:model-value="island.marginY"
:min="RANGES.marginY.min"
:max="RANGES.marginY.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ marginY: value })"
/>
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
</el-form-item>
<el-form-item label="跟随鼠标屏幕">
<el-switch
:model-value="island.followCursorDisplay"
@change="
(value: boolean | string | number) =>
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
"
/>
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
</el-form-item>
<el-form-item label="动画">
<el-switch
:model-value="island.animations"
@change="
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
"
/>
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
</el-form-item>
<el-form-item label="始终置顶">
<el-switch
:model-value="island.alwaysOnTop"
@change="
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
"
/>
</el-form-item>
<el-form-item label="空闲细缝">
<el-switch
:model-value="island.idleSliver"
@change="
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
"
/>
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">保存设置</el-button>
<el-button @click="testIsland">预览效果</el-button>
<el-button @click="resetIslandAppearance">恢复默认</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
/>
</el-card>
</div>
</template>
<style scoped>
.ml-8 {
margin-left: 8px;
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { classIslandStatusApi } from '../../api/index.js';
import { useAppStore } from '../../stores/app.js';
import { useAuthStore } from '../../stores/auth.js';
/**
* 提醒设置:这台教室机器把提醒弹在哪个端(客户端 / ClassIsland / 两端都弹)。
* 值同时落本地配置与班级记录(服务端据此决定是否推送 ClassIsland)。
*/
const appStore = useAppStore();
const auth = useAuthStore();
const channelSaving = ref(false);
/**
* 本班有没有接入 ClassIsland 设备。
*
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
const classIslandStatus = ref<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
} | null>(null);
const classIslandStatusText = computed(() => {
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
});
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
value,
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
disabled: value === 'classisland' && !classIslandConnected.value,
})),
);
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
channelSaving.value = true;
try {
const saved = await appStore.setNotificationChannel(auth.classId, value);
if (saved) ElMessage.success('提醒显示位置已更新');
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
} finally {
channelSaving.value = false;
}
}
onMounted(async () => {
await appStore.loadNotificationChannel(auth.classId);
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
if (auth.classId) {
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true;
}
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">提醒</h2>
<p class="page-subtitle">老师发通知 / 叫人时,提醒弹在哪个端</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>ClassIsland 联动</span></template>
<el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} /
{{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<p class="text-muted">
<strong>通知模式</strong>
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
:disabled="channelSaving"
class="channel-group"
@change="
(value: string | number | boolean | undefined) =>
applyChannel(value as ClassIslandNotificationChannel)
"
>
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
{{ item.label }}
</el-radio>
</el-radio-group>
<p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected">
<br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
ClassIsland 上弹")。
</template>
</p>
</el-card>
</div>
</template>
<style scoped>
.channel-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
}
.channel-hint {
margin-top: 10px;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+19
View File
@@ -47,6 +47,17 @@ export interface DesktopStoredConfig {
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。 * 与班级记录(服务端据此决定要不要推给 ClassIsland)。
*/ */
notificationChannel: ClassIslandNotificationChannel; notificationChannel: ClassIslandNotificationChannel;
/**
* 是否已看过「初次启动引导」。
*
* 首次启动自动弹出一次(完成或跳过即记为看过并写入配置,之后不再自动弹出);
* 登录页与设置页的「使用引导」入口可随时重看(重看不改变该值)。
*/
onboardingDone: boolean;
/** 界面主题:light=浅色(默认)/ dark=深色(黑夜模式) */
theme: 'light' | 'dark';
/** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */
sidebarCollapsed: boolean;
} }
export interface DesktopAppInfo { export interface DesktopAppInfo {
@@ -56,6 +67,8 @@ export interface DesktopAppInfo {
node: string; node: string;
platform: string; platform: string;
userDataPath: string; userDataPath: string;
/** 主进程配置文件(config.json)的绝对路径(关于页「诊断信息」展示) */
configPath: string;
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */ /** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */
smokeTest: boolean; smokeTest: boolean;
} }
@@ -209,6 +222,12 @@ declare global {
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>; homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */ /** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */
islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>; islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>;
/** 初次启动引导自检(首启自动出现 → 真实点击走完 → 完成状态写入配置) */
onboardingSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 主界面外观件自检(真实点击:主题切换 html.dark + 落盘、侧边栏折叠 + 落盘) */
layoutChromeSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 关于页自检(/settings/about 渲染 + getAppInfo 含 configPath) */
aboutPageSelfTest(): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>; sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
}; };
} }
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/server", "name": "@classhelper/server",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module", "type": "module",
@@ -25,6 +25,7 @@
"@classhelper/shared": "workspace:*", "@classhelper/shared": "workspace:*",
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0", "@prisma/adapter-libsql": "7.10.0",
"@prisma/adapter-mariadb": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"compression": "^1.8.2", "compression": "^1.8.2",
@@ -34,7 +35,9 @@
"express-rate-limit": "^8.7.0", "express-rate-limit": "^8.7.0",
"helmet": "^8.3.0", "helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"prisma": "7.10.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"typescript": "~5.9.3",
"xlsx": "0.18.5", "xlsx": "0.18.5",
"zod": "^4.6.2" "zod": "^4.6.2"
}, },
@@ -182,7 +182,7 @@ async function main() {
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次) // ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = { const reportBody = {
pluginVersion: '0.1.0.0', pluginVersion: '1.0.0.0',
classIslandVersion: '2.1.0.0', classIslandVersion: '2.1.0.0',
state: { state: {
inClass: true, inClass: true,
+224
View File
@@ -2339,6 +2339,230 @@ async function main() {
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`, `教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
); );
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
// ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
record(
'数据库状态(管理员):已连接且返回 14 张表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 14 &&
dbStatusData.tables.every((item) => item.count >= 0),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
}`,
);
const dbStatusTeacher = await api('/database/status', { token: teacherToken });
const dbBackupTeacher = await api('/database/backups', { method: 'POST', token: teacherToken });
const dbScheduleTeacher = await api('/database/backup-schedule', {
method: 'PUT',
token: teacherToken,
body: { enabled: true, intervalHours: 24, keepCount: 7 },
});
const dbSwitchTeacher = await api('/database/switch', {
method: 'POST',
token: teacherToken,
body: { provider: 'sqlite', url: 'file:./data/teacher-forbidden.db' },
});
record(
'数据库管理越权(教师全部 403)',
dbStatusTeacher.status === 403 &&
dbBackupTeacher.status === 403 &&
dbScheduleTeacher.status === 403 &&
dbSwitchTeacher.status === 403,
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
);
// 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支)
let testOkResult;
let testOkName;
if (dbStatusData.provider === 'sqlite') {
testOkResult = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'sqlite', url: 'file:./prisma/dev.db' },
});
testOkName = '连接测试(当前 SQLite 库可达)';
} else {
testOkResult = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'mysql', url: 'mysql://root:wrong-password@127.0.0.1:1/none' },
});
testOkName = '连接测试(不可达 MySQL 返回 ok=false)';
}
record(
testOkName,
testOkResult.status === 200 &&
(dbStatusData.provider === 'sqlite'
? testOkResult.payload?.data?.ok === true
: testOkResult.payload?.data?.ok === false),
`ok=${testOkResult.payload?.data?.ok} 版本=${testOkResult.payload?.data?.version} 详情=${testOkResult.payload?.data?.error ?? testOkResult.payload?.data?.note ?? '-'}`,
);
const dbTestRedis = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
});
record(
'Redis 作为主库被拒绝(schema 层面 422,产品口径:主库仅 SQLite/MySQL)',
dbTestRedis.status === 422,
`status=${dbTestRedis.status}`,
);
const badConnection = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'mysql', url: 'mysql://root:wrong@127.0.0.1:1/none' },
});
record(
'连接测试(不可达 MySQL → ok=false 且不抛 500)',
badConnection.status === 200 && badConnection.payload?.data?.ok === false,
`ok=${badConnection.payload?.data?.ok} error=${(badConnection.payload?.data?.error ?? '').slice(0, 60)}`,
);
// 切换校验分支:同库拒绝(真实切换不在此跑,避免改写 .env)
const switchSame =
dbStatusData.provider === 'sqlite'
? await api('/database/switch', {
method: 'POST',
token: adminToken,
body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked },
})
: { status: 0, payload: null };
const switchInvalid = await api('/database/switch', {
method: 'POST',
token: adminToken,
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
});
record(
'一键切换校验分支(同库 400 / 非法 provider 422,不真跑切换)',
(dbStatusData.provider === 'sqlite' ? switchSame.status === 400 : true) && switchInvalid.status === 422,
`同库=${switchSame.status}(${dbStatusData.provider === 'sqlite' ? 'sqlite 当前库' : 'mysql 跳过'}) redis=${switchInvalid.status}`,
);
// 备份 → 导出快照 → 造数据 → 快照导入 → 造数据 → 从备份恢复(三个恢复路径全验)
const dbBackup = await api('/database/backups', { method: 'POST', token: adminToken });
const backupName = dbBackup.payload?.data?.name ?? '';
record(
'手动备份(201,文件名符合约定格式)',
dbBackup.status === 201 && /^backup-manual-\d{8}-\d{6}\.json\.gz$/.test(backupName),
`name=${backupName} size=${dbBackup.payload?.data?.sizeBytes ?? '-'}`,
);
const dbExport = await fetch(`${BASE_URL}/api/database/export`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const snapshotJson = dbExport.status === 200 ? await dbExport.text() : '';
let snapshotParsed = null;
try {
snapshotParsed = JSON.parse(snapshotJson);
} catch {
snapshotParsed = null;
}
record(
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
);
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
const dbRoundtripNotice = await api('/notifications', {
method: 'POST',
token: adminToken,
body: { classId, title: '数据库roundtrip自检', content: '导入/恢复后应消失' },
});
const dbRoundtripNoticeId = dbRoundtripNotice.payload?.data?.id;
const dbImport = await api('/database/import', {
method: 'POST',
token: adminToken,
body: { data: Buffer.from(snapshotJson, 'utf8').toString('base64') },
});
const afterImport = await api(`/notifications?classId=${classId}`, { token: adminToken });
const roundtripNoticeGone = !(afterImport.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNoticeId,
);
record(
'快照导入(整库覆盖:导入后临时通知消失)',
dbImport.status === 200 && roundtripNoticeGone,
`导入=${dbImport.status} 通知已清除=${roundtripNoticeGone} 行数=${JSON.stringify(dbImport.payload?.data?.counts ?? {}).slice(0, 80)}`,
);
const dbRoundtripNotice2 = await api('/notifications', {
method: 'POST',
token: adminToken,
body: { classId, title: '数据库roundtrip自检2', content: '备份恢复后应消失' },
});
const dbRoundtripNotice2Id = dbRoundtripNotice2.payload?.data?.id;
const dbRestore = await api(`/database/backups/${backupName}/restore`, { method: 'POST', token: adminToken });
const afterRestore = await api(`/notifications?classId=${classId}`, { token: adminToken });
const restoreNoticeGone = !(afterRestore.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNotice2Id,
);
record(
'从备份恢复(整库覆盖:恢复后临时通知消失)',
dbRestore.status === 200 && restoreNoticeGone,
`恢复=${dbRestore.status} 通知已清除=${restoreNoticeGone}`,
);
if (dbRoundtripNoticeId) {
await api(`/notifications/${dbRoundtripNoticeId}`, { method: 'DELETE', token: adminToken });
}
if (dbRoundtripNotice2Id) {
await api(`/notifications/${dbRoundtripNotice2Id}`, { method: 'DELETE', token: adminToken });
}
const dbBackupList = await api('/database/backups', { token: adminToken });
const dbBackupDelete = await api(`/database/backups/${backupName}`, { method: 'DELETE', token: adminToken });
const dbBackupListAfter = await api('/database/backups', { token: adminToken });
record(
'备份列表与删除(创建后可见,删除后消失)',
dbBackupList.status === 200 &&
(dbBackupList.payload?.data ?? []).some((item) => item.name === backupName) &&
dbBackupDelete.status === 200 &&
!(dbBackupListAfter.payload?.data ?? []).some((item) => item.name === backupName),
`删除前=${(dbBackupList.payload?.data ?? []).length} 删除后=${(dbBackupListAfter.payload?.data ?? []).length}`,
);
const dbScheduleInvalid = await api('/database/backup-schedule', {
method: 'PUT',
token: adminToken,
body: { enabled: true, intervalHours: 0, keepCount: 7 },
});
const dbScheduleSave = await api('/database/backup-schedule', {
method: 'PUT',
token: adminToken,
body: { enabled: false, intervalHours: 24, keepCount: 7 },
});
record(
'定时备份配置(非法间隔 422 / 合法保存并回读)',
dbScheduleInvalid.status === 422 &&
dbScheduleSave.status === 200 &&
dbScheduleSave.payload?.data?.enabled === false &&
dbScheduleSave.payload?.data?.intervalHours === 24,
`非法=${dbScheduleInvalid.status} 保存=${dbScheduleSave.status} enabled=${dbScheduleSave.payload?.data?.enabled}`,
);
if (dbStatusData.provider === 'sqlite') {
const sqliteFile = await fetch(`${BASE_URL}/api/database/sqlite-file`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const sqliteBytes = sqliteFile.status === 200 ? (await sqliteFile.arrayBuffer()).byteLength : 0;
record(
'SQLite 数据文件下载(快捷导出 .db 文件)',
sqliteFile.status === 200 && sqliteBytes > 0,
`status=${sqliteFile.status} bytes=${sqliteBytes}`,
);
}
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号 // 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
const realtimeNoticeId = createdNotification.payload?.data?.id; const realtimeNoticeId = createdNotification.payload?.data?.id;
if (realtimeNoticeId) { if (realtimeNoticeId) {
+3 -3
View File
@@ -37,7 +37,7 @@ async function healthHandler(_req: Request, res: Response): Promise<void> {
res, res,
{ {
status: 'ok', status: 'ok',
version: '0.1.0', version: '1.0.0',
env: env.nodeEnv, env: env.nodeEnv,
uptimeSeconds: Math.round(process.uptime()), uptimeSeconds: Math.round(process.uptime()),
database: { database: {
@@ -68,7 +68,7 @@ function createRootHandler(webDistDir: string | null) {
res, res,
{ {
service: 'class-helper-server', service: 'class-helper-server',
version: '0.1.0', version: '1.0.0',
environment: env.nodeEnv, environment: env.nodeEnv,
apiPrefix: API_PREFIX, apiPrefix: API_PREFIX,
socket: '/socket.io', socket: '/socket.io',
@@ -115,7 +115,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<body> <body>
<div class="card"> <div class="card">
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1> <h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
<p class="sub">版本 0.1.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p> <p class="sub">版本 1.0.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub"> <p class="sub">
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> · 健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
存活 /healthz · 就绪 /readyz 存活 /healthz · 就绪 /readyz
+3
View File
@@ -9,6 +9,7 @@ import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
import { logger } from './lib/logger.js'; import { logger } from './lib/logger.js';
import { resolveWebDistDir } from './lib/web-static.js'; import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js'; import { initRealtime } from './realtime/socket.js';
import { startBackupScheduler } from './modules/database/database.service.js';
/** 数据库自检与可选的首次初始化 */ /** 数据库自检与可选的首次初始化 */
async function prepareDatabase(): Promise<void> { async function prepareDatabase(): Promise<void> {
@@ -85,6 +86,8 @@ httpServer.listen(env.port, env.host, () => {
} }
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`); logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`); logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
// 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json)
startBackupScheduler();
}); });
let shuttingDown = false; let shuttingDown = false;
+15 -9
View File
@@ -3,27 +3,33 @@ import { PrismaClient } from '../generated/prisma/client.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
/** 本项目支持的主数据库类型(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
export type DatabaseProvider = 'sqlite' | 'mysql';
/** /**
* Prisma 7 要求所有数据库都通过 driver adapter 接入: * Prisma 7 要求所有数据库都通过 driver adapter 接入:
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译) * - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md) * - MySQL -> @prisma/adapter-mariadb(依赖里常驻,切换数据库时直接可用)
* *
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时 * MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
* 也不会影响类型检查与 SQLite 模式的运行。 * 也不会影响类型检查与 SQLite 模式的运行。
*
* 该工厂独立于进程级单例:数据库管理模块的「连接测试」与跨库迁移的工具进程
* 都需要按任意 provider/url 构建临时客户端,而不是读 env 单例。
*/ */
async function createAdapter(): Promise<unknown> { export async function createPrismaClient(
if (env.databaseProvider === 'mysql') { provider: DatabaseProvider,
url: string,
): Promise<PrismaClient> {
if (provider === 'mysql') {
const adapterModule = '@prisma/adapter-mariadb'; const adapterModule = '@prisma/adapter-mariadb';
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown }; const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
logger.info('已加载 MySQL/MariaDB 驱动适配器'); return new PrismaClient({ adapter: new mod.PrismaMariaDb(url) as never });
return new mod.PrismaMariaDb(env.databaseUrl);
} }
return new PrismaLibSql({ url: env.databaseUrl }); return new PrismaClient({ adapter: new PrismaLibSql({ url }) as never });
} }
const adapter = await createAdapter(); export const prisma = await createPrismaClient(env.databaseProvider, env.databaseUrl);
export const prisma = new PrismaClient({ adapter: adapter as never });
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`); logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
+159
View File
@@ -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,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
View File
@@ -2,6 +2,7 @@ import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js'; import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js'; import { classesModule } from './classes/classes.module.js';
import { coursesModule } from './courses/courses.module.js'; import { coursesModule } from './courses/courses.module.js';
import { databaseModule } from './database/database.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js'; import { dashboardModule } from './dashboard/dashboard.module.js';
import { gradesModule } from './grades/grades.module.js'; import { gradesModule } from './grades/grades.module.js';
import { homeworksModule } from './homeworks/homeworks.module.js'; import { homeworksModule } from './homeworks/homeworks.module.js';
@@ -34,4 +35,5 @@ export const apiModules: ApiModule[] = [
studentsModule, studentsModule,
teachersModule, teachersModule,
dashboardModule, dashboardModule,
databaseModule,
].filter((module) => module.enabled !== false); ].filter((module) => module.enabled !== false);
@@ -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);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/generated/**/*.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/shared", "name": "@classhelper/shared",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数", "description": "班级小助手 - 三端共享的类型、常量与工具函数",
"type": "module", "type": "module",
+8
View File
@@ -25,6 +25,8 @@ export const API_PATHS = {
imports: '/imports', imports: '/imports',
/** ClassIsland 联动:设备接入、状态上报、通知下发 */ /** ClassIsland 联动:设备接入、状态上报、通知下发 */
integrations: '/integrations', integrations: '/integrations',
/** 数据库管理(仅管理员):状态/备份/导入导出/一键切换 */
database: '/database',
dashboard: '/dashboard/summary', dashboard: '/dashboard/summary',
} as const; } as const;
@@ -476,6 +478,12 @@ export const STORAGE_KEYS = {
serverUrl: 'classhelper.serverUrl', serverUrl: 'classhelper.serverUrl',
cachePrefix: 'classhelper.cache', cachePrefix: 'classhelper.cache',
lastSyncAt: 'classhelper.lastSyncAt', lastSyncAt: 'classhelper.lastSyncAt',
/**
* 新手引导「已看过」标记(Web 管理端 localStorage)。
* 值为引导版本号:改版引导内容后版本 +1,看过旧版的老用户会再收到一次新版引导。
* EXE 客户端不用这个键 —— 它的引导状态在主进程配置(`onboardingDone`)里。
*/
onboarding: 'classhelper.onboarding',
} as const; } as const;
/** 桌面客户端默认后端地址 */ /** 桌面客户端默认后端地址 */
+81
View File
@@ -1017,3 +1017,84 @@ export interface SocketAuthPayload {
token: string; token: string;
clientType?: 'admin' | 'desktop'; 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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/web-admin", "name": "@classhelper/web-admin",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)", "description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module", "type": "module",
+5
View File
@@ -62,4 +62,9 @@ export const api = {
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }), 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 }), 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 }), 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' }),
}; };
+38
View File
@@ -15,6 +15,11 @@ import {
type CreateNotificationRequest, type CreateNotificationRequest,
type CreateScheduleRequest, type CreateScheduleRequest,
type DashboardSummary, type DashboardSummary,
type DatabaseBackupMetaDto,
type DatabaseBackupScheduleDto,
type DatabaseConnectionTestDto,
type DatabaseStatusDto,
type DatabaseSwitchJobDto,
type GradeDto, type GradeDto,
type GradeStats, type GradeStats,
type HomeworkDaysDto, type HomeworkDaysDto,
@@ -328,3 +333,36 @@ export const integrationApi = {
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> => notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
api.post(`${API_PATHS.integrations}/classisland/notify`, payload), 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)}`),
};
+105 -4
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { STORAGE_KEYS } from '@classhelper/shared';
import { authApi } from '@/api'; import { authApi } from '@/api';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime'; import { useRealtimeStore } from '@/stores/realtime';
@@ -22,6 +23,7 @@ const { isMobile } = useResponsive();
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403) * (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
* - 作业 / 通知:所有教师 * - 作业 / 通知:所有教师
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮) * - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
*/ */
const menuItems = [ const menuItems = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] }, { path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
@@ -33,6 +35,7 @@ const menuItems = [
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] }, { path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] }, { path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] }, { path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
{ path: '/database', title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
]; ];
/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */ /** 当前账号可见的菜单(班级/学生管理仅管理员可见) */
@@ -141,12 +144,90 @@ async function submitPassword(): Promise<void> {
await auth.logout(); await auth.logout();
void router.push({ name: 'login' }); 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> </script>
<template> <template>
<el-container class="layout"> <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"> <div class="brand">
<el-icon :size="22"><School /></el-icon> <el-icon :size="22"><School /></el-icon>
<span class="brand-text">班级小助手</span> <span class="brand-text">班级小助手</span>
@@ -169,12 +250,13 @@ async function submitPassword(): Promise<void> {
class="nav-toggle" class="nav-toggle"
aria-label="打开菜单" aria-label="打开菜单"
:aria-expanded="drawerVisible ? 'true' : 'false'" :aria-expanded="drawerVisible ? 'true' : 'false'"
data-tour="nav-toggle"
@click="drawerVisible = true" @click="drawerVisible = true"
> >
<el-icon :size="20"><Menu /></el-icon> <el-icon :size="20"><Menu /></el-icon>
</button> </button>
<span class="header-title">{{ isMobile ? currentTitle : APP_TITLE }}</span> <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> <span class="header-conn">{{ connectionText }}</span>
</el-tag> </el-tag>
<!-- 小屏:连接正常时不显示任何圆点/标签,只在异常态显示带文字的紧凑标签 --> <!-- 小屏:连接正常时不显示任何圆点/标签,只在异常态显示带文字的紧凑标签 -->
@@ -194,7 +276,7 @@ async function submitPassword(): Promise<void> {
<div class="header-right"> <div class="header-right">
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<span class="user-chip"> <span class="user-chip" data-tour="user-chip">
<el-icon><UserFilled /></el-icon> <el-icon><UserFilled /></el-icon>
<template v-if="!isMobile"> <template v-if="!isMobile">
{{ auth.displayName }} {{ auth.displayName }}
@@ -209,6 +291,10 @@ async function submitPassword(): Promise<void> {
<el-icon><Lock /></el-icon> <el-icon><Lock /></el-icon>
修改密码 修改密码
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item divided @click="tourOpen = true">
<el-icon><Guide /></el-icon>
使用引导
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout"> <el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon> <el-icon><SwitchButton /></el-icon>
退出登录 退出登录
@@ -265,6 +351,21 @@ async function submitPassword(): Promise<void> {
<el-button type="primary" @click="submitPassword">确认修改</el-button> <el-button type="primary" @click="submitPassword">确认修改</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!--
新手引导:首次登录自动弹出(见 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> </el-container>
</template> </template>
+7
View File
@@ -68,6 +68,13 @@ const routes: RouteRecordRaw[] = [
// 与后端 requireRole('ADMIN','TEACHER') 对齐:学生角色不该看到这一页 // 与后端 requireRole('ADMIN','TEACHER') 对齐:学生角色不该看到这一页
meta: { title: 'ClassIsland 联动', icon: 'Connection', roles: ['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>
+71 -19
View File
@@ -96,6 +96,9 @@ importers:
'@prisma/adapter-libsql': '@prisma/adapter-libsql':
specifier: 7.10.0 specifier: 7.10.0
version: 7.10.0 version: 7.10.0
'@prisma/adapter-mariadb':
specifier: 7.10.0
version: 7.10.0
'@prisma/client': '@prisma/client':
specifier: 7.10.0 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) 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 +126,15 @@ importers:
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.3 specifier: ^9.0.3
version: 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: socket.io:
specifier: ^4.8.3 specifier: ^4.8.3
version: 4.8.3 version: 4.8.3
typescript:
specifier: ~5.9.3
version: 5.9.3
xlsx: xlsx:
specifier: 0.18.5 specifier: 0.18.5
version: 0.18.5 version: 0.18.5
@@ -148,18 +157,12 @@ importers:
'@types/node': '@types/node':
specifier: ^22.20.2 specifier: ^22.20.2
version: 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: socket.io-client:
specifier: ^4.8.3 specifier: ^4.8.3
version: 4.8.3 version: 4.8.3
tsx: tsx:
specifier: ^4.23.13 specifier: ^4.23.13
version: 4.23.13 version: 4.23.13
typescript:
specifier: ~5.9.3
version: 5.9.3
packages/shared: packages/shared:
devDependencies: devDependencies:
@@ -646,6 +649,9 @@ packages:
'@prisma/adapter-libsql@7.10.0': '@prisma/adapter-libsql@7.10.0':
resolution: {integrity: sha512-ANjUjJ/TrDQhR8jFU8HvYWRdwP17Lox1WL81zZvHcPVeFehZfNomAsPMNY24EBTLLZp0Xw4Na5Zf1S+EuKMKxw==} 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': '@prisma/client-runtime-utils@7.10.0':
resolution: {integrity: sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==} resolution: {integrity: sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==}
@@ -1194,6 +1200,10 @@ packages:
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'} 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: abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^20.17.0 || >=22.9.0}
@@ -2053,8 +2063,8 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
glob@7.2.3: glob@7.2.0:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} 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 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: global-agent@3.0.0:
@@ -2145,6 +2155,10 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'} 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: iconv-lite@0.7.3:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2413,6 +2427,9 @@ packages:
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
engines: {node: '>=8'} engines: {node: '>=8'}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@6.0.0: lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2428,6 +2445,10 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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: matcher@3.0.0:
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2684,6 +2705,10 @@ packages:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'} 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: postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2987,8 +3012,8 @@ packages:
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
engines: {node: '>=10.2.0'} engines: {node: '>=10.2.0'}
source-map-js@1.2.1: source-map-js@1.2.2:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
source-map-support@0.5.21: source-map-support@0.5.21:
@@ -3462,7 +3487,7 @@ snapshots:
'@electron/asar@3.4.1': '@electron/asar@3.4.1':
dependencies: dependencies:
commander: 5.1.0 commander: 5.1.0
glob: 7.2.3 glob: 7.2.0
minimatch: 3.1.5 minimatch: 3.1.5
'@electron/fuses@1.8.0': '@electron/fuses@1.8.0':
@@ -3513,7 +3538,7 @@ snapshots:
fs-extra: 10.1.0 fs-extra: 10.1.0
isbinaryfile: 4.0.10 isbinaryfile: 4.0.10
minimist: 1.2.8 minimist: 1.2.8
plist: 3.1.0 plist: 3.1.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3536,7 +3561,7 @@ snapshots:
dir-compare: 4.2.0 dir-compare: 4.2.0
fs-extra: 11.4.0 fs-extra: 11.4.0
minimatch: 9.0.9 minimatch: 9.0.9
plist: 3.1.0 plist: 3.1.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3831,6 +3856,11 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - 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-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)': '@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 +4413,7 @@ snapshots:
'@vue/shared': 3.5.42 '@vue/shared': 3.5.42
entities: 7.0.1 entities: 7.0.1
estree-walker: 2.0.2 estree-walker: 2.0.2
source-map-js: 1.2.1 source-map-js: 1.2.2
'@vue/compiler-dom@3.5.42': '@vue/compiler-dom@3.5.42':
dependencies: dependencies:
@@ -4400,7 +4430,7 @@ snapshots:
estree-walker: 2.0.2 estree-walker: 2.0.2
magic-string: 0.30.21 magic-string: 0.30.21
postcss: 8.5.28 postcss: 8.5.28
source-map-js: 1.2.1 source-map-js: 1.2.2
'@vue/compiler-ssr@3.5.42': '@vue/compiler-ssr@3.5.42':
dependencies: dependencies:
@@ -4469,6 +4499,8 @@ snapshots:
'@xmldom/xmldom@0.8.15': {} '@xmldom/xmldom@0.8.15': {}
'@xmldom/xmldom@0.9.12': {}
abbrev@4.0.0: {} abbrev@4.0.0: {}
accepts@1.3.8: accepts@1.3.8:
@@ -5502,7 +5534,7 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
glob@7.2.3: glob@7.2.0:
dependencies: dependencies:
fs.realpath: 1.0.0 fs.realpath: 1.0.0
inflight: 1.0.6 inflight: 1.0.6
@@ -5618,6 +5650,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.3: iconv-lite@0.7.3:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -5843,6 +5879,8 @@ snapshots:
lowercase-keys@2.0.0: {} lowercase-keys@2.0.0: {}
lru-cache@10.4.3: {}
lru-cache@6.0.0: lru-cache@6.0.0:
dependencies: dependencies:
yallist: 4.0.0 yallist: 4.0.0
@@ -5857,6 +5895,14 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@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: matcher@3.0.0:
dependencies: dependencies:
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
@@ -6089,6 +6135,12 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
xmlbuilder: 15.1.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: postcss-selector-parser@7.1.6:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
@@ -6098,7 +6150,7 @@ snapshots:
dependencies: dependencies:
nanoid: 3.3.19 nanoid: 3.3.19
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.2
postgres@3.4.7: {} postgres@3.4.7: {}
@@ -6243,7 +6295,7 @@ snapshots:
rimraf@2.6.3: rimraf@2.6.3:
dependencies: dependencies:
glob: 7.2.3 glob: 7.2.0
roarr@2.15.4: roarr@2.15.4:
dependencies: dependencies:
@@ -6442,7 +6494,7 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
source-map-js@1.2.1: {} source-map-js@1.2.2: {}
source-map-support@0.5.21: source-map-support@0.5.21:
dependencies: dependencies:
+10
View File
@@ -156,6 +156,16 @@ function buildRuntime() {
path.join(serverDir, 'prisma', 'migrations'), path.join(serverDir, 'prisma', 'migrations'),
path.join(staging, 'server', 'prisma', 'migrations'), path.join(staging, 'server', 'prisma', 'migrations'),
); );
// 数据库管理模块「一键切换」需要的 Prisma 配置三件套:
// schema.prisma —— 切换时改写 provider 后供 prisma generate / db push 使用
// prisma.config.ts —— Prisma 7 的 CLI 配置(连接串来自子进程环境变量)
// tsconfig.generate.json —— generate 产出的是 .ts,切换后用它编译进 dist/generated
for (const file of ['schema.prisma']) {
fs.copyFileSync(path.join(serverDir, 'prisma', file), path.join(staging, 'server', 'prisma', file));
}
for (const file of ['prisma.config.ts', 'tsconfig.generate.json']) {
fs.copyFileSync(path.join(serverDir, file), path.join(staging, 'server', file));
}
// 工作区共享包(tsc 产物) // 工作区共享包(tsc 产物)
const sharedTarget = path.join(staging, 'node_modules', '@classhelper', 'shared'); const sharedTarget = path.join(staging, 'node_modules', '@classhelper', 'shared');
fs.mkdirSync(sharedTarget, { recursive: true }); fs.mkdirSync(sharedTarget, { recursive: true });
+71 -1
View File
@@ -44,7 +44,7 @@ const MENU_ITEMS = [
]; ];
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */ /** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '教师管理']; const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '教师管理', '数据库管理'];
/** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */ /** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */
const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin'; const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin';
@@ -118,6 +118,54 @@ async function runAdminTeacherChecks(win) {
record('管理员登录(教师录入前置)', adminReady, `账号=${ADMIN_USERNAME}`); record('管理员登录(教师录入前置)', adminReady, `账号=${ADMIN_USERNAME}`);
if (!adminReady) return; if (!adminReady) return;
// 1.1) localStorage.clear() 把「已看过引导」的标记也一并清掉 → 管理员登录后引导会再次自动
// 弹出;先把它跳过再继续,避免遮罩留在屏幕上干扰后续真实点击(跳过即记「已看过」)。
// 出现/关闭判据与 3.5 相同:等 × 按钮渲染,关闭看 .el-tour__mask 卸载。
await win.webContents.executeJavaScript(`(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const deadline = Date.now() + 8000;
while (Date.now() < deadline && !document.querySelector('.el-tour__closebtn')) await sleep(120);
document.querySelector('.el-tour__closebtn')?.click();
const closeDeadline = Date.now() + 2000;
while (Date.now() < closeDeadline && document.querySelector('.el-tour__mask')) await sleep(100);
return true;
})()`);
// 1.2) 数据库管理页(仅管理员):点开菜单 → 状态卡与备份表渲染
const databasePage = await win.webContents.executeJavaScript(`(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
(node.textContent ?? '').trim().startsWith('数据库管理'),
);
if (!menu) return { ok: false, reason: '管理员菜单里没有「数据库管理」' };
menu.click();
const deadline = Date.now() + 6000;
while (Date.now() < deadline && location.pathname !== '/database') await sleep(80);
// 等状态卡与备份表渲染(状态卡含「当前数据库」标题,备份表在 el-table 里)
let hasStatusCard = false;
let hasBackupTable = false;
const tableDeadline = Date.now() + 8000;
while (Date.now() < tableDeadline && !(hasStatusCard && hasBackupTable)) {
hasStatusCard = Boolean(document.querySelector('.page-title'));
hasBackupTable = Boolean(document.querySelector('.el-table'));
await sleep(100);
}
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
return {
ok: location.pathname === '/database' && title === '数据库管理' && hasStatusCard && hasBackupTable,
reason: '',
path: location.pathname,
title,
hasStatusCard,
hasBackupTable,
};
})()`);
record(
'数据库管理页(仅管理员:状态卡与备份表渲染)',
databasePage?.ok === true,
`path=${databasePage?.path ?? '-'} 标题=${databasePage?.title ?? '-'} 状态卡=${databasePage?.hasStatusCard} 表=${databasePage?.hasBackupTable} ${databasePage?.reason ?? ''}`,
);
// 2) 打开「教师管理」→ 新建教师(表单 → 保存 → 列表出现) // 2) 打开「教师管理」→ 新建教师(表单 → 保存 → 列表出现)
const created = await win.webContents.executeJavaScript(`(async () => { const created = await win.webContents.executeJavaScript(`(async () => {
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) => const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
@@ -299,6 +347,28 @@ async function main() {
return; return;
} }
// 3.5 初次登录引导(新功能):首次登录自动弹出聚焦式引导,可跳过;
// 跳过(右上角 ×)后写入「已看过」标记,本次 profile 里不会再自动弹出。
// 判据说明:el-tour 关闭后内容元素会像 el-dialog 一样残留在 DOM(父级 .el-popper 被隐藏,
// AGENTS §7 第 22 条同款陷阱),所以「出现」等 × 按钮渲染、「关闭」看遮罩 .el-tour__mask 卸载。
const tour = await win.webContents.executeJavaScript(`(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 引导在布局挂载后延迟 ~600ms 弹出,且 popper 内容晚于外壳渲染,等 × 按钮真正出现
const deadline = Date.now() + 10000;
while (Date.now() < deadline && !document.querySelector('.el-tour__closebtn')) await sleep(120);
if (!document.querySelector('.el-tour__closebtn')) return { appeared: false, closed: false };
const close = document.querySelector('.el-tour__closebtn');
close.click();
const closeDeadline = Date.now() + 3000;
while (Date.now() < closeDeadline && document.querySelector('.el-tour__mask')) await sleep(120);
return { appeared: true, closed: !document.querySelector('.el-tour__mask') };
})()`);
record(
'首次登录展示新手引导且可跳过',
tour?.appeared === true && tour?.closed === true,
`出现=${tour?.appeared ?? '-'} 跳过后关闭=${tour?.closed ?? '-'}`,
);
// 4. 逐一点击侧边栏菜单 // 4. 逐一点击侧边栏菜单
const visited = []; const visited = [];
const failures = []; const failures = [];