Author SHA1 Message Date
LoveChengke 391e2c1591 docs(desktop,island): 修正被写成既成事实的"收回握手"机制描述
AGENTS.md 第 34 条把一个从未落地的设计当成了已实现的规则,还配上了"踩过的坑"与
"两条配套铁律",读起来像历史教训。实际核对:`island:closing`、`island:closing-done`、
`sendClosingDone`、`noteClosingDone`、`closingTimer`、`finishClosing` 六个符号在全部
源码中零命中,在 git 全部历史里也从未出现过。

真实实现是主进程单向的:handleAction() → dismissActive() / clearPendingForClose()
→ hide() → fadeOut(),状态同一帧写成 hidden,不等渲染进程任何回执。渲染侧只有本地
弹簧形变(spring.ts),是纯视觉动画,不参与淡出时机。

另,早先那层自研的"卡片缩回胶囊再淡出"快照动画(closing / closingSnapshot)确实存在过,
因在触摸屏上会露出旧帧/重影,已按用户要求整体删除 —— IslandApp.vue 顶部注释有记载。

改动:重写 AGENTS.md 第 34 条为真实机制,并注明该自研动画已移除、别再照着不存在的
握手机制改代码;README 三处同样表述一并修正;smoke.ts 里那句"渲染进程收敛上报"的
过时注释改为描述真实的淡出轮询。
2026-10-02 00:32:48 +08:00
LoveChengke 20fcca0388 feat(server,web,shared,desktop): 上传超限正确报错 + 两个登录入口都限流 + 作业按天深链,文档与验证脚本同步
服务端加固:
- express.json 上限 2mb → 12mb:表格导入把文件以 base64 放进 JSON(约 4/3 倍),
  8MB 的声明上限原先到 1.5MB 就被拦掉;超限现在由 error 中间件映射成
  413 IMPORT_TOO_LARGE(中文提示"请拆分后分批导入"),不再落进 500 兜底分支抛英文原文;
- loginRateLimiter 同时挂 /auth/login 与 /auth/class-login(班级密码默认 123456,
  班级登录曾是唯一没有暴力破解防护的入口),两个路径共享同一计数器;
- CORS_ORIGIN=* 时启动告警(不拦截启动,避免挡住教室机 file:// 的合法部署);
- 删除已无用的 DEFAULT_STUDENT_PASSWORD;班级 termWeeks 上限 40 → 30,
  与 MAX_TERM_WEEK / weekNumberSchema 对齐(原先对外承诺了系统兑现不了的学期长度)。

Web 端:
- 作业页 ?date= 深链在同路由再推 query 时也生效(watch route.query.date);
- 表格导入对话框适配 8MB 上限与 413 提示。

冒烟与文档:
- 新增 scripts/lib/smoke-class.mjs:验证用临时班级,避免探针通知弹到用户自己的客户端与灵动岛;
- README 与 AGENTS.md 同步本轮全部行为变更(含 schedule source、学生账号清理、凭据门禁)。
2026-10-02 00:25:21 +08:00
LoveChengke 3880eee08e feat(desktop,island,shared): 灵动岛支持多条通知竖排列表 + 触摸模式 + 收回动画,产物加凭据门禁
灵动岛列表:多条通知时展开为竖排列表,默认显示 3 条,点「展开更多」铺到屏幕容纳
上限,放不下的收进「更多请前往应用内操作」。卡片高度由主进程与渲染进程各算一次
同一个数 —— 度量表 ISLAND_LIST_METRICS 与排序权重 PRIORITY_RANK 放在 shared,
窗口包围盒随之长高(不够高会把底部按钮裁掉)。

触摸模式:触摸屏上手指既不产生 mousemove、也不移动系统光标,原先两条命中链路都会
让窗口永远停在穿透态、手指点不开岛。现按 navigator.maxTouchPoints 上报后,主进程把
窗口贴合岛体并始终接收输入;非触摸屏机器完全不走这条路。

收回动画:main 先发 island:closing,渲染进程形变收敛后回 closing-done 才淡出,
不再靠猜时长(rAF 被节流时会把卡片缩到一半窗口就没了)。

安全门禁:新增 scripts/check-bundle-secrets.mjs 并接进 build:desktop ——
dist 产物会被打进 app.asar 发到每台学生机、且可解包,冒烟凭据一律改由环境变量注入,
产物里出现种子口令即构建失败。
2026-10-02 00:25:10 +08:00
LoveChengke d27f9a4b9e feat(students,teachers,classes,auth,web): 学生个人账号彻底清理,学生只是名单记录
学生不再有登录能力:User.passwordHash 以空串占位,role=STUDENT 在密码校验之前
一律 403 并提示改用班级登录(否则「密码错 401 / 密码对但已停用 403」的差异会把
学生账号密码变成可离线验证的预言机)。成绩、未交名单、叫人、已读全部按名单记。

配套改动:
- 删除 POST /students/:id/reset-password(调用返回 404,e2e 有负断言);
- 新建学生不设密码,StudentsView 移除密码输入与「重置密码」入口,
  列表标识仍自动生成为学号式(student01…);
- class-account.ts / auth.service.ts 收紧班级会话语义;
- 经管「修改密码」入口收敛到 Web 端:教师改本人密码走顶栏下拉,
  班级密码由管理员在班级管理里维护,教师可用 POST /teachers/:id/reset-password 设初始密码。
2026-10-02 00:25:04 +08:00
LoveChengke 7ea0e0f7c9 feat(schedules,classisland,server,plugin,shared): 课表行加来源字段,插件上报可真正同步删除
课表行新增 Schedule.source(manual / classisland):插件每次全量上报时,服务端
只清理「source=classisland 且本次未再上报」的行。原先 merge 只做 upsert,老师在
ClassIsland 里删掉或挪走一节,服务端会永远留着那行幽灵课;现在能真正同步下来,
而老师在小助手这边手排的课(manual)一律不删。

配套改动:
- 插件上报时带回 machineKey,服务端回填到设备记录(避开 [classId, deviceKey] 唯一约束);
- 新增迁移 20260930120000_add_schedule_source,存量数据保守地按 manual 处理;
- verify:classisland 补「幽灵行清理」「手排课不被误删」用例。

另:.gitignore 补上 .zcode/ 与 .zcodeignore(AI 会话产物,不属于产品代码)。
2026-10-02 00:24:58 +08:00
LoveChengke 9c678b3730 fix(deploy,docs): 修好 Docker 构建(缺 prisma generate)+ 新增 SQLite 单容器 compose
- Dockerfile:构建阶段补 `pnpm --filter @classhelper/server run db:generate`。生成产物 packages/server/src/generated/prisma 是 gitignored 的,而 pnpm install 用了 --ignore-scripts 跳过 postinstall,少了这一步 tsc 会报 "Cannot find module ../generated/prisma/client.js"(从 git clone 构建必踩)
- 新增 deploy/docker-compose.sqlite.yml + deploy/.env.sqlite.example:只跑服务端一个容器(DATABASE_URL=file:/app/data/classhelper.db + 数据卷),首次启动靠 AUTO_MIGRATE=true 自动建表与建管理员。适合内存小的机器或与别的服务共用一台机器(MySQL 变体会多一个数据库进程)
- docs/production.md:补 2.1 单容器变体说明(用法、适用场景、端口/生产自检注意事项、备份命令)
2026-09-27 18:34:19 +08:00
LoveChengke 5c27586af9 fix(island,classisland,server,desktop,plugin): 灵动岛去掉整窗背景材质(修卡片外圈白底面板)+ 左右边距只在左/右停靠生效 + 镜像课表补课间
- 灵动岛:移除 win.setBackgroundMaterial() 与 applyBackgroundMaterial/getBackgroundMaterial。它把系统材质刷在整个窗口矩形上(岛是"固定大包围盒窗口",卡片外还有留白),且明暗跟随应用的浅色主题 → 卡片周围出现一圈浅色面板;更糟的是调用过一次(含 none)后窗口就回不到逐像素透明,从 glass 切到 black/tinted 会永久留一圈纯白。glass 因此改为纯 CSS 半透明深色卡(WinIsland 拿不到 host backdrop 时的降级色 rgba(32,32,36,.804))
- 回归用例:新增「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」——整屏截图取样,并在岛底下垫一块已知颜色底板,让"环 = 底板色"可被精确断言(webContents.capturePage() 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因)
- 边距:左右边距只在"左/右停靠"时生效(默认顶部居中时水平锚点就是屏幕中线),设置页把该滑块置灰并说明,避免被当成"调了不生效"
- 课间:镜像回 ClassIsland 的课表改为每两节之间补一段课间、最后一节之后不补(放学);服务端 synthesizeTimeLayout 同口径,不再依赖导入的时间表里有没有课间点
- 文档:README / AGENTS.md / docs/winisland-design-tokens.md / 插件 README 同步

门禁:pnpm lint 0 error、pnpm typecheck 四包通过。未跑:verify:desktop(需先 build:desktop)、verify:e2e / verify:classisland(需后端启动)。
2026-09-27 18:08:51 +08:00
LoveChengke 7536abfc95 feat(homeworks,island,classisland,desktop,web,server): 作业按天查看与教室机器录入 + 提醒上课时段暂存规则 + 灵动岛边距/跟随鼠标屏幕
- 作业:新增 Homework.assignDate(本地日期 YYYY-MM-DD;UTC 切片在晚上会把今天的作业算成明天),列表按天过滤与排序,新增 GET /homeworks?date= 与 GET /homeworks/days(日期高亮)供客户端与 Web 共用;客户端作业页改为"看作业 + 录作业"(不再显示完成状态),支持快捷短语(存本机配置,「设置 → 作业录入」可增删/恢复默认)
- 教室机器录入:POST /homeworks 去掉 requireRole,权限下沉到 homeworks.service(staff 或本班班级账号);班级账号的 sub 是班级 id,写 createdBy 前换成该班班主任,避免撞 User 外键
- 提醒类型:ClassIslandPush.kind(notification / call)。通知类上课时段暂存、下课补弹,叫人/紧急立即弹;客户端灵动岛与 ClassIsland 插件共用同一套规则
- 灵动岛:新增 marginX / marginY(默认仍 8px,不动老外观)与「跟随鼠标屏幕」;文案统一成"灵动岛 / ClassIsland"
- 设置页:新增「ClassIsland 联动」(联动状态 + 通知模式)与「作业录入」分区
- 校验:verify:e2e 增补 assignDate 默认当天 / ?date= 过滤 / days / 非法日期 422 / 班级账号录入 201 与跨班 403;verify:classisland 与插件静态契约增补上课时段暂存断言(57/57)
- 文档:README 与 AGENTS.md 同步(作业按天、上课时段策略、两个方向的开关表、新增踩坑)

门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 57/57 通过。
未跑:verify:e2e / verify:classisland(需后端启动)、verify:web / verify:desktop(需先构建产物)。
2026-09-27 16:34:01 +08:00
LoveChengke d0830f6830 feat(integrations,classisland,server,web,desktop): ClassIsland 联动插件——上报课表与上课状态、下发提醒并回执、教室端推送通道
教室机器上的 ClassIsland 现在可以直接和班级小助手互通:插件走 /api/integrations/classisland/*,用设备令牌(X-ClassIsland-Token,库里只存 sha256)独立鉴权,不占用 JWT 通道。

- 服务端:新增 integrations 模块(上报 / 待弹出 / 回执 / 课表镜像 / 回写),两个迁移(设备令牌表、Class.notificationChannel);通知、叫人、联动页下发统一复用 pushToClassIsland,避免"某个页面发的通知不联动"
- 插件:packages/classisland-plugin(.NET 8,独立于 pnpm workspace)——BridgeService 上报与提醒轮询、ScheduleMapper/ClassPlanWriter 课表镜像、Avalonia 设置页、ClassHelperNotificationProvider 在 ClassIsland 上弹出提醒;本地/内网地址强制直连,躲开系统代理返回的假 404
- 客户端:设置页可选提醒通道(both / client / classisland),并订阅相应实时事件
- Web 端:新增「ClassIsland 联动」页(设备管理与提醒下发)
- 校验与打包:verify:classisland(需后端)、verify:classisland-plugin(静态契约 54 项)、build/dist:classisland-plugin 产出 .cipx
- 文档:README 与 AGENTS.md 同步新增联动说明,docs/screenshots/classisland 留三张真机截图

门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 54/54 通过。运行期仍需在装了 ClassIsland 的机器上实测(步骤见 AGENTS.md §8)。
2026-09-27 14:59:12 +08:00
LoveChengke fe41ebb7d6 docs: 新增 AGENTS.md(面向 AI 编码代理的仓库说明与踩坑约定) 2026-09-27 10:44:00 +08:00
LoveChengke 14ac16ff29 fix(desktop,island): 设置页灵动岛外观改动即生效——IPC 只接受纯数据 + 真实 UI 冒烟回归
用户反馈"灵动岛设置无法生效并且无法实时预览"。根因是设置页把 ref 的响应式 Proxy 直接交给 contextBridge,结构化克隆在进入 preload 函数体之前就抛 `An object could not be cloned.`,界面数字照常变化却半静默失败。

- SettingsView:应用/保存外观时展开成纯对象({ ...island.value })
- desktop.d.ts / preload:显式写明"必须传纯数据",记录该失败模式
- smoke.ts:新增「拖拽设置页真实滑块」用例,同时断言页面数值与主进程外观都变化
2026-09-27 10:43:57 +08:00
LoveChengke d900280760 fix(island): 胶囊"点不动"的根因——窗口必须始终可激活(否则 Windows 丢掉 mousedown)
真机复现(打包版 + 真实 OS 点击 + 渲染进程鼠标事件计数):
- 胶囊态:渲染进程只收到 mouseup,**收不到 mousedown,因此永远不产生 click** → 点了没反应;
- 展开态:完整 mousedown + mouseup + click → 正常。
根因:窗口创建时 `focusable: false`(WS_EX_NOACTIVATE),Windows 在窗口非活动时会丢掉鼠标
**按下**事件。结果是"岛只有在已经展开时才能被点开",即胶囊态/重启后/切到别的窗口后点不动
(用户反馈的"点不动 / 要点两下")。这也解释了为什么此前冒烟全绿 —— 冒烟用 DOM `.click()`
和直接调动作,绕过了 OS 输入层。

修复:
- 窗口改为**始终可激活**(`focusable: true`);"不抢焦点"由 `showInactive()`(显示时不激活)保证;
- `applyFocusable`:只有展开态才 `focus()`,收起/隐藏时 `blur()`——不占用户键盘焦点,
  但保持可激活,点击才能送达;
- `hideImmediately` 不再自己 `setFocusable(false)`,可激活性统一由 `applyFocusable` 维护。

自建穷举真机审计(`.cache/island-probe/audit.mjs`,真实 OS 点击,非合成事件):23 个场景 ——
普通/紧急通知、作业、普通/紧急叫人 × 上课/非上课、6 个停靠位置、展开→收起→再点、
课中收起后再点、下课补发、联合通知、已读、知道了、胶囊超时、关闭动画、大尺寸/半透明/
毛玻璃/主题色、细缝态、预览态、运行中改位置尺寸、真实"点别处"失焦后再点、双广播、20s 回缩。
结果:dev 构建连续 3 轮 23/23,打包版 23/23。

回归:pnpm verify:desktop 82/82(新增回归用例「胶囊态窗口仍可激活(否则 Windows 丢掉
mousedown,胶囊"点不动")」,并把「点击屏幕任意位置回缩为胶囊」的断言改为"窗口始终可激活");
打包版 verify:packaged 82/82、packaged=true、二次启动 74/74,全绿。
2026-09-26 22:56:45 +08:00
LoveChengke 337d6eae53 fix(island): 幽灵胶囊自愈 + 叫人双广播不降级 + 淡出/帧限流兜底(继续修"点不动")
用户反馈"叫人点不动"复盘:状态机层面没问题(真机点击日志能看到"收到用户动作 expand"),
问题在窗口/渲染层面。本轮修掉三个真实缺陷:

1) 幽灵胶囊:岛渲染进程一旦崩溃/卡死,Windows 会把最后一帧留在屏幕上 —— 岛还挂在最上方、
   窗口也还在,但点击全部丢失,而客户端没有任何自愈机制(实测:崩掉渲染进程后点击不再产生
   任何动作)。现在渲染进程每 5s 上报心跳,主进程 20s 收不到、或收到 render-process-gone /
   unresponsive 时,**先隐藏死窗口**再重建并重新下发状态与外观;10 分钟内最多重建 3 次,
   超出则隐藏并告警,避免持续崩坏时无限重建。
2) 叫人双广播降级:服务端 createCall 同时发 notification:new(班级房间)与 call:new(user 房间),
   两条 DTO 同 id,后到的那条会把"叫人"覆盖成普通通知 —— 胶囊显示成
   "新消息 / 系统管理员 · 点击查看"(用户截图正是如此),紧急叫人还会被降级成"课中只排队、
   不立即展开"。现在按 id 合并:类型 call 优先、优先级取更高(URGENT > HIGH > NORMAL)。
3) 淡出竞态:岛重新变为可见时没有取消进行中的淡出,那次淡出会继续把透明度降到 0 并 hide()
   窗口,出现"状态是胶囊/展开、窗口却不可见"的错位(冒烟"岛外鼠标穿透"用例就是被它打红的)。
   现在可见路径总是 cancelFade + 恢复透明度。

另加两条兜底:
- 命中矩形兜底:渲染进程还没上报岛体矩形时,主进程按当前形态/停靠位置自己算一个,
  杜绝"岛可见却一直不接收鼠标"。
- 形变兜底:实测打包版某些时刻(细缝态、刚启动)rAF 会被 Chromium 压到约 1 帧/秒,
  此时"点击后形变"看起来就是没反应;现在形变开始 1.2s 后仍未收敛就直接 snap 到目标几何。

验证:pnpm verify:desktop 81/81(新增 3 条回归:双广播不降级 / 紧急叫人双广播 /
渲染进程崩溃后自愈;离线二次启动 73/73);打包版 verify:packaged 81/81、packaged=true、
二次启动 73/73,全绿。
顺带:细缝用例改为轮询等几何收敛(帧被限流时固定 sleep 会误判为"细缝尺寸不对"),
课表时间轴失败时打印各卡片状态(便于定位),并清理了库里 3 条遗留测试探针课表
(它们会污染客户端"今天"的课表)。
2026-09-26 20:47:01 +08:00
LoveChengke a7b0e9c2b3 fix(island): 修好"收起后再次点击灵动岛没反应"(假失焦缩回 + 命中状态互相覆盖)
根因一(假失焦):展开时 setFocusable(true)+focus() 会引发一次与用户无关的失焦
(实测 70~520ms 后到达)。原来的失焦处理器在 500ms 宽限期之后就把任何 blur 当成
"用户点了别处",于是刚展开的岛立刻缩回胶囊 —— 看起来就是"点了没反应"。
现在只在**指针确实离开岛体**时才认为用户点了别处(新增 isCursorOnIsland)。

根因二(命中抖动):渲染进程与主进程各自下发 setInteractive(true/false),而两边几何
逐帧不一致(弹簧形变 + 岛体矩形上报限流),窗口就在"接收鼠标 / 穿透"之间几毫秒翻转一次,
点击撞上穿透态即"点不动"(日志实测 true/false 成对翻转)。现在命中判定单一权威:
渲染进程只负责"立刻打开命中",关闭一律由主进程按真实光标轮询(120ms→60ms)决定;
同时去掉"窗口一可见就整块 476×282 包围盒接收鼠标"(会在屏幕上方留一块看不见却点不动的
区域)、淡出期间不接收鼠标、岛体矩形上报限流 100→50ms。

验证(真机真实点击,非合成事件):SetCursorPos + mouse_event 连续 4 轮
"点开 → 点卡片收起 → 再点胶囊"全部正常,展开后保持展开不再被假失焦缩回
(修复前 0.5~2s 内必缩回);pnpm verify:desktop 78/78(新增回归用例
「假失焦(指针仍在岛上)不回缩:展开后必须保持展开」)。

顺带修掉既有测试缺陷:通知菜单项带未读徽标时整项 textContent 变成 "2通知",
用 startsWith 匹配会误报"菜单项缺失",改为按文案层 span 精确匹配。
2026-09-26 19:32:47 +08:00
LoveChengke 70250151d0 feat(deploy,docs): Linux 原生部署(systemd 一键脚本 + unit 模板)+ 生产文档扩写
- deploy/install-linux.sh:Linux 一键部署(Node 版本检查 → 铺 /opt/classhelper → 生成 .env 与随机
  JWT_SECRET → 安装 systemd 服务 → 就绪探针;支持 --check/--port/--dir/--no-service/--uninstall)
- deploy/classhelper.service:systemd 单元模板(__NODE__/__DIR__/__USER__ 由脚本替换后安装)
- deploy/{.env.example,docker-compose.yml}:补充 DEFAULT_STUDENT/CLASS/TEACHER_PASSWORD 默认密码环境变量
- docs/production.md:新增形态 C(Linux systemd + SQLite)、Nginx + HTTPS、升级/备份/排障、探针与安全清单
- README.md:生产部署四形态对照表、目录树与验证表更新
2026-09-26 19:32:37 +08:00
LoveChengke 845fbdd863 feat(teachers,classes,schedules,web): 教师录入(仅管理员)+ 班主任可设置/更改 + 授课科目全校统一 + 登录页去示例
① 支持录入教师(与学生录入对齐),权限只有管理员
- 新增 Web「教师管理」页(`Views/TeachersView.vue`):单个录入(姓名/用户名/初始密码/角色)、
  导入名单(xlsx/csv:用户名、姓名、初始密码、角色)、编辑(改名/改角色)、重置密码、删除
- 服务端 `teachers` 模块补齐:GET / POST / PATCH /:id / POST /:id/reset-password / DELETE /:id,
  全部 `requireRole('ADMIN')`;表格导入新增 kind=teachers(`IMPORT_FIELDS.teachers`、`commitTeachers`、
  教师名单模板),教师调用预览/提交一律 403
- 删除护栏:还是班主任(`Class.teacherId`)或持有课程/班级分配的账号拒绝删除(409,提示先更换班主任)——
  教师是数据创建者,级联删除会连带删掉班级数据
- 前端路由加 `meta.roles`:`/classes`、`/students`、`/teachers` 仅 ADMIN,教师直接输网址会被挡回仪表盘
  (菜单隐藏只是"看不见",这次补上真正的守卫);新增环境变量 `DEFAULT_TEACHER_PASSWORD`

② 创建班级仅管理员 + 设置 / 更改班主任
- 新建班级时可直接指定班主任(不填则创建者本人),班级详情顶部「更换班主任」下拉随时改:
  新增 `PATCH /api/classes/:id/head-teacher`(仅管理员,校验目标账号是教师/管理员,广播 class:updated)
- 原班主任保留在协作教师名单里,避免换人后丢掉历史协作关系

③ 授课科目全校统一(用户澄清:"统一"指的是授课科目固定,不是各班课表相同)
- `SUBJECT_CATALOG` 改为用户给定的 18 科:语文/数学/英语/政治/历史/物理/化学/地理/生物/信息技术/
  体育/美术/音乐/班会/早读/晚读/通用技术/听力
- 课表「新增课表」与成绩「单条/批量录入」的科目下拉直接列出统一目录(该班还没有的进
  「统一科目(选中后自动建课)」分组),选中后自动为该班建同名课程再落库 —— 不必按班级一个个录科目
- 各班课表仍然各自独立(各班的课表本来就不一样)

④ 登录页去掉所有示例内容
- Web 登录页删除「演示账号(点击填充)」标签列表与"种子数据见 README"提示;客户端班级码占位符
  由「例如 G101(由老师在班级管理中提供)」改为「请输入班级码」
- `verify:web` 与 `verify:desktop` 各自断言登录页正文/占位符不含 `演示/示例/admin123/teacher123/G101`

⑤ 验证脚本不再依赖演示数据(并修掉自身残留)
- 清空服务端作业/通知后,联网自检改为**自建**一条作业/通知并在收尾删除(`seedSmokeContent`);
  权限用例需要"其它教师的班级/科任班级"时现建临时班级并在收尾删除
- e2e 新增清理:实时推送用例造的通知/作业、上课时段用例造的两条通知、教师名单/临时班级
  (此前这几条会留在库里);班级详情相关断言不再依赖种子班级布局
- ui-smoke 新增 7 项:登录页无示例、教师端访问 /teachers 被挡回、统一科目下拉 + 自动建课 + 收尾删除
  课表与课程、管理员真人录入教师 → 列表出现 → 删除
- 冒烟里"岛就绪"与"截图留档"改为轮询等待(偶发丢一张留档图会误判"形态尺寸递增")

实测:verify:e2e 163/163(安装实例)、verify:web 27/27、verify:desktop 77/77(离线复跑 69/69)、
verify:packaged 对 release/win-unpacked 与 D:\class\@classhelperdesktop-client 均 77/77 且 packaged=true;
typecheck / lint / format:check 全绿。服务端安装实例与客户端安装目录已就地更新。
2026-09-13 10:34:46 +08:00
LoveChengke 0ff2dc228b fix(homework,island,verify): 全屏作业看板自适应 + 已安装副本覆盖升级 + 新增打包版冒烟
① 全屏作业看板自适应(用户反馈:全屏模式没有自适应,卡片挤在左上角)
- 全屏看板按「可用宽 × 可用高」枚举 1..N 列,取**单卡面积最大且宽高比最接近 1.15** 的方案,
  注入 `--board-cols`(不再靠 CSS auto-fit 猜);列数变了再量一次缩放系数,
  `transform-origin: top center` 保证整体居中、不溢出屏幕
- 两个全屏弹窗各自持有 ref(原来共用同名 ref,后挂载的会覆盖前一个,量到的是另一个弹窗);
  列表型「单个科目全屏」补上 `transform: scale()`(该规则原先只作用于 `.board`,列表全屏从未缩放)
- 弹窗 `@opened` 后轮询等待布局就绪(最多 20×60ms)再计算;窗口 resize 时自动重算;
  另加一条全局 `.board-fullscreen .el-dialog__body` 兜底,保证内容区铺满整屏
- 实测:6 个科目 → 3 列、排版宽 1155px(视口 1187px)、可视底 678px(视口高 765px)

② 「灵动岛设置没办法生效」的真正原因:用户机器上的副本一直没被更新
- 学生端实际运行的是安装目录 `D:\class\@classhelperdesktop-client` 里的 `resources/app.asar`,
  而 `pnpm dist:win` 只更新 `release/`;只发新版 release 不动安装目录,用户看到的还是旧界面
- 已就地覆盖升级该目录,并用同一套冒烟实测**那个副本**:`packaged: true`、76/76 全绿
- README 补充「把修复交付到已安装的客户端」步骤(robocopy 覆盖 + verify:packaged 实测)

③ 新增/加固自动化验证
- `verify:desktop` 新增「作业看板全屏自适应(铺满屏幕、内容不溢出)」:进作业页 → 切看板 →
  打开全屏 → 断言排版宽铺满视口、可视底不超出屏幕;再把卡片补到 6 张触发 resize,
  要求列数自动变多(1 → 3)且仍不溢出
- 点击/命中这类异步链路由"固定 sleep 后读一次"改为**轮询等待**(曾偶发误判为"点不开"),
  失败时把渲染进程当前类名、窗口可见性、岛体矩形写进结论,便于区分真 bug 与竞态
- 新增 `pnpm verify:packaged`:对**打包后/已安装**的 EXE 跑同一套冒烟(含二次启动校验文件锁),
  避免"开发产物全绿但交付给学生的 EXE 是旧的"这类问题
- 实测:`verify:desktop` 76/76(离线复跑 68/68);`verify:packaged` 对
  `release/win-unpacked` 与 `D:\class\@classhelperdesktop-client` 均 76/76、`packaged: true`;
  `verify:e2e` 149/149(安装实例);`typecheck` / `lint` / `format:check` 全绿
2026-09-13 09:26:52 +08:00
LoveChengke 11008928a1 fix(island,homework,schedule,web): 16 项反馈修复(联合通知点不开 / 设置不生效 / 红点位置 / 看板当天且铺满 / 单双周与周数可调 / Web 手机端与批量通知等)
一、灵动岛
- ①「紧急通知后来了新作业,收起再点开不了新作业」根因有二,都已修:
  (a) active 仍是紧急那条,点击只会反复打开它 —— 现在点击胶囊优先打开**最新**那条未处理消息(队列里更晚的);
  (b) 上课时段 syncWindow 会把普通通知立刻隐藏(表现为点了没反应)—— 新增 explicitShowId,
      "用户主动点开"的那条守卫放行(岛完全隐藏时仍严格不打扰)。
  冒烟新增「紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)」。
- ② 设置实时预览/保存:补「设置页真实链路」断言(window.desktop.islandSetAppearance → IPC → 主进程 →
  渲染进程 CSS 变量 + 窗口尺寸),并断言"岛显示中改尺寸立即生效"(实测均通过)。
- ③ 未读红点移到「通知」图标右上角(此前在文字后面),并加 DOM 几何断言(dx>0 且 dy<0,
  没有未读时自动造一条未读通知再断言、断言后删除)。
- 冒烟稳定性:新增 island.setBlurCollapseEnabled()(默认开),测试期间关掉"失焦收起",
  避免系统抢焦点导致展开态被收回的随机失败;专门验证失焦的用例仍临时打开。

二、作业看板(学生端)
- ⑥ 只看当天:新增「只看今天」开关(默认开),按 createdAt 的本地日期过滤,昨天的过期作业不再混入。
- ⑧ 完整显示:卡片同时渲染标题 + 作业要求全文(列表模式也加「作业要求」列)。
- ⑨ 铺满不滚动:看板外层高度固定 + overflow hidden,按内容/可用高度算缩放系数用 transform: scale 适配
  (工具栏显示当前缩放比例),页面本身不出现滚动条。
- ⑦ 全屏看板标题栏用 #header 插槽:标题左对齐、当天日期时间绝对居中(受「显示时间」开关控制,每分钟刷新)。

三、课表与周数
- ⑤ 学期周数可调:新增 Class.termWeeks(默认 20,迁移 + 维护脚本),班主任在班级管理里改;
  GET /api/dashboard/term?classId= 返回该班 maxWeek,课表周次下拉与新建课表的默认 weekEnd 都按它来
  (不再固定 30 周)。
- ⑩ 课表管理只保留「导入 ClassIsland 课程表」入口(时间表由课程表 JSON 的 TimeLayouts 一并带入),
  「导入时间配置」按钮下线;ui-smoke 第 7.7 段改为断言新弹窗(解析 4 行 + 单双周标签 + 旧入口已下线)。

四、以班级为单位(⑬)与 Web 端
- 个人学生账号登录停用:POST /api/auth/login 对 role=STUDENT 返回 403(改用班级码 + 班级密码);
  学生行仍作为个人数据记录键由班级会话代全班读写;e2e 全面切换到班级会话并新增该断言。
- ④ 作业科目过少:新增共享 SUBJECT_CATALOG(16 个常见科目)+ 种子课程扩到全科目;
  Web 作业发布弹窗可选「其他科目(新建)」,提交前自动建课(同名复用)。
- ⑪ 通知支持多班批量发布(多选 + 逐班发布 + 汇总结果 + 二次确认覆盖所有选中班);
  ⑫ 单班只显示已读情况、跨班才显示已读率;⑭ 手机端课表可横向滚动看全 7 天;
  ⑮ 手机端已连接不再显示孤立绿点(仅未连接时显示文字标签);⑯ 手机端「叫人/发布通知」按钮同宽左对齐;
  ⑬ Web 学生管理去掉「用户名」列(新建自动生成学号式用户名,导入仍支持显式指定)。

验证:verify:e2e 149/149、verify:web 20/20、verify:desktop 75/75(复跑 68/68,打包便携版 75/75 packaged=true)、
typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
2026-09-13 08:39:11 +08:00
LoveChengke b8ef5d8dbb feat(island,homework,schedule): 灵动岛点开修复+胶囊类型汇总 · 作业看板与未交名单 · 单双周课表与 ClassIsland 课程表导入
一、灵动岛(用户反馈的三处"点不开"全部修复)
- 非紧急叫人:上课时段仍以胶囊保持可见、可点开(不再只进队列导致屏幕上什么都没有);
  新增 isOpenable():紧急消息 + 任何"叫人"都允许点开,但普通叫人不会自动展开打断课堂。
- 紧急叫人收起后再点:命中改为"主进程按光标位置兜底校正"(上一轮的 island:set-hit-rect 轮询),
  本轮补齐叫人类专项回归(展开 → 收起 → 再次点开)。
- 设置页预览:新增 preview 通道 —— 示例岛直接展开、失焦不收起、30 秒自行消失、关掉不残留。
- 收起态胶囊汇总消息类型:多条时显示「新消息:叫人/作业/通知(共 3 条)」+「点击查看」+ 类型小标签。
- 顺带修掉两个真问题:胶囊态收到新消息会把上一条静默顶掉(现在一律回队);
  syncWindow 的上课时段守卫只认"紧急"导致普通叫人被藏掉。
- 性能:岛体矩形上报限流(~10 次/秒 + 尾随补发),修掉每帧 IPC 导致的开合采样骤降。

二、作业(学生端 + Web 教师端)
- 新增**看板模式**:按科目分卡片、点卡片放大全屏、工具栏可切「看板/列表」、
  开关「显示时间」、拖「看板字号」(11–28px),偏好持久化到客户端配置 homeworkBoard。
- 去掉「全班标记完成」,改为**未交名单**:勾选没交的同学,其余自动标记已交;
  新增 GET/PATCH /api/homeworks/:id/submissions(教师与班级设备可用,普通学生 403)。

三、课表单双周 + ClassIsland 课程表导入
- Schedule 新增 weekParity(ALL/ODD/EVEN)+ 迁移;周视图与上课判定按「第 1 周=单周」过滤,
  历史数据默认 ALL 行为不变;Web 手动新增/编辑课表可选单双周,学生端课表打「单周/双周」标签。
- 修复既有 P0 bug:ClassIsland 真实档案里 TimeLayouts 是 "Guid → { Name, Layouts }" 字典,
  旧解析器只认数组/.items,导致**真实导出完全导不进来**;现已按真实形态解析(并兼容旧写法),
  同时补上 TimeType=3(行动)映射。
- 新增 ClassIsland 课程表(ClassPlan)导入:解析 TimeRule.WeekCountDiv/WeekCountDivTotal → 单双周,
  Classes[i] 对齐第 i 个 TimeType=0 的时间点,Subjects 映射科目(缺失自动补建课程),
  星期 0=周日 换算为 1=周一 … 7=周日;replace/merge 两种模式,预览只解析不写库。
- 新增维护脚本 packages/server/scripts/apply-parity-column.cjs(手工覆盖 dist 部署时补列用)。

验证:verify:e2e 148/148(新增单双周 12 项 + 课程表导入 5 项)、verify:desktop 71/71(复跑 65/65)、
verify:web 20/20、typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
2026-09-12 23:48:59 +08:00
LoveChengke 86430c5f4a fix(island): 修好"非紧急叫人 / 紧急叫人收起 / 设置页预览"三处点不开,并让胶囊汇总通知类型
用户反馈的三个场景:
1) 非紧急叫人(HIGH + kind=call)—— 上课时段原先只进队列、屏幕上看不到东西,
   自然"无法点开"。现在:上课时段仍以**胶囊**保持可见(不自动展开、不打断课堂),
   点一下即可展开详情;下课自动展开。新增 isOpenable():紧急消息与"任何叫人"都允许点开。
2) 紧急叫人点击缩回后点不开 —— 归因于固定包围盒窗口的命中丢失(上一轮已改为光标兜底轮询),
   本轮补上"展开 → 收起 → 再次展开"的叫人类专项回归。
3) 设置页"预览效果"示例岛无法正常展开 —— 预览通知现在走 preview 通道:直接展开、
   失焦不收起(用户拖滑块时主窗口一直是焦点)、30 秒后自行消失、点"知道了"不留残影。

顺带修掉两个真问题:
- pushNotification 在"胶囊态"收到新消息时会把上一条静默顶掉(只有展开态才回队);
  现在只要换了当前展示的消息,上一条一律回队列 —— 否则类型汇总与"还有 N 条"都会漏。
- syncWindow 的上课时段守卫只认"紧急",导致普通叫人被 hideImmediately 藏掉;改用 isOpenable。

胶囊类型汇总(用户要求):多条未处理消息时收起态显示
「新消息:叫人/作业/通知(共 3 条)」+「点击查看」,并把三种类型做成小标签;
单条时仍显示类型名(新作业 / 老师叫你 / 新消息)。

性能:岛体矩形上报加限流(~10 次/秒,尾随补发最终形态),修掉"每帧 IPC + 每帧读光标"
导致的渲染变慢(实测会让开合采样从 57 帧掉到 4 帧)。

验证:verify:desktop 71/71 + 离线复跑 65/65;typecheck/lint 全绿。
2026-09-12 22:36:37 +08:00
LoveChengke d8b0eb05c2 fix(island): 命中改为"光标位置兜底轮询",修复"点开再收起就再也点不开"
用户复现路径:点开灵动岛(展开)→ 收起为胶囊 → 再点就毫无反应。
根因:固定包围盒窗口默认整块穿透,命中只在渲染进程收到 mousemove 时才打开。
Windows 下 setIgnoreMouseEvents(true, { forward: true }) 的 mousemove 转发并不总是可靠,
一旦"展开 → 收起"后转发丢失,窗口就永远停在穿透状态 —— 看得到胶囊,却怎么点都没反应。

修法(双保险,命中不再依赖"必须先收到一次 mousemove"):
- 渲染进程把**岛体矩形**上报主进程(新 IPC island:set-hit-rect,随状态/外观/形变更新,
  彻底隐藏时上报 null);
- 主进程每 120ms 用 screen.getCursorScreenPoint() 与矩形比对校正命中(窗口不可见或无矩形
  则强制穿透),并在收到矩形时立即校正一次;
- 保留渲染进程 mousemove 的快速路径(即时响应),两者使用同一矩形,结论一致;
- 渲染进程仍记住最近指针位置,状态变化时按新几何重算命中。

冒烟新增/改造(新增可注入光标位置 setHitTestCursor,避免去挪用户物理鼠标):
- 「展开 → 收起 → 再次展开(用户复现路径)」:真实点击往返,并断言每个形态都上报了命中框;
- 「命中兜底:岛体矩形随形态更新」(展开 424x230 → 收起 268x44);
- 「彻底隐藏时撤销命中框(不吞桌面点击)」;
- 「空闲细缝态命中跟随光标」(细缝上可点、移开即穿透);
- 「岛外鼠标穿透」与「收起态点击可再次展开」改为注入光标位置做确定性断言。

验证:verify:desktop 65/65 + 离线复跑 59/59;打包便携版 65/65(packaged=true);
typecheck / lint / format 全绿。
2026-09-12 21:49:14 +08:00
LoveChengke 80a0efbcfe fix(smoke): 冒烟不再悄悄改掉演示班级密码(先试已知密码,必要时才重置并恢复)
- 现状问题:pnpm verify:desktop 会用管理员接口把被测班级密码改成 smoke123456 且不恢复,
  导致演示实例的「班级码 G101 / 123456」在跑完冒烟后登录失败(本次验证时实际踩到)。
- 现在先按 [冒烟密码, 种子默认 123456] 依次探测:命中就直接用,**完全不改数据**;
  只有当两者都不通时才临时重置,并在脚本收尾(含失败分支)恢复为 123456 并打印日志。
- README 冒烟章节补充说明;演示实例的 G101 密码已恢复为 123456。

验证:verify:desktop 62/62(日志显示「班级账号就绪:G101」即未改动数据)、离线复跑 56/56;
打包便携版 62/62(packaged=true)且跑完后 G101/123456 仍可登录。
2026-09-12 21:06:58 +08:00
LoveChengke b69f708aa2 feat(web,desktop,server): 灵动岛收起后可再次打开 · 下线作业截止时间 · Web 端圆角设计
灵动岛"收起后部分情况无法再次打开"(用户反馈):
- 收起不再等于消失:只要有未处理通知(当前展示的或队列里的),超时后保持胶囊常驻,
  只有"知道了 / 标为已读"、队列清空或进入上课时段才彻底隐藏。
- 收起态点击兜底:active 为空但队列还有未看通知时,点击胶囊会把它取出并展开;
  上课时段仍严格不显示(该兜底放在上课守卫之后,不影响"上课不打扰")。
- 命中测试自愈:渲染进程记住最近指针位置,状态/外观变化与形变每一帧都按新几何重算命中;
  岛可见时主进程也会重新下发可交互,避免"窗口还处于穿透 → 点了没反应"。
- 新增 3 条回归断言:收起后胶囊常驻、收起态点击可再次展开(命中恢复)、空闲细缝也可点开。

作业"截止时间"功能下线(用户要求):
- 接口/类型:HomeworkDto 与创建请求去掉 dueAt,homeworks schema/service 不再读写;
  仪表盘去掉"即将截止的作业"卡片与待截止统计口径(改为"未完成/待完成")。
- Web:作业发布表单移除截止时间选择、列表与详情移除截止时间列;
  学生端作业列表/详情移除截止时间与"已逾期"状态(只保留完成状态)。
- 灵动岛:作业卡不再显示"截止时间:…"副标题,改为只展示作业要求。
- DB 中的 Homework.dueAt 列**保留**(已在 schema 注明为遗留字段),避免对已安装库做破坏性迁移;
  种子数据不再写入该列。

Web 管理端圆角设计(用户要求):
- 新增设计令牌 --ch-radius-xl/lg/md/sm/pill 与两级柔和投影,并让 Element Plus 的
  --el-border-radius-* 对齐同一套令牌;卡片、弹窗、抽屉、按钮、输入、标签、分页、
  侧栏菜单项、表格表头统一改造,卡片去硬边框改投影 + 悬停微浮起,顶栏/侧栏做圆角收口。
- 未改组件源码,全部为 CSS 变量 + 类选择器覆盖;手机端媒体查询保持生效(截图已更新)。

验证:verify:desktop 62/62(含 3 条收起态回归)与离线复跑 56/56、打包便携版 62/62(packaged=true)、
verify:e2e 136/136(新增"作业接口不再返回 dueAt")、verify:web 20/20、typecheck/lint/format 全绿;
服务端与 Web 已重新打包并部署到本机安装目录。
2026-09-12 21:01:31 +08:00
LoveChengke 0690546307 fix(island,server): 连续圆角四角修正 + 叫人分紧急/普通两级
灵动岛圆角(用户反馈"左上角和右下角缺一块 / 圆角边缘割裂"):
- squircle.ts 改为"每个角独立圆心 + 角度参数"的超椭圆采样(原本四角共用同一个偏移向量,
  左上/右下被切掉一块);四角几何与 WinIsland 的连续超椭圆一致,r 与 h/2、w/2 自动收敛。
- verify:desktop 新增像素级断言「连续圆角四角一致」:从四角沿对角线找岛体边界,
  四角步进必须两两一致(展开 15/15/15/15、紧急 14/14/14/14、胶囊 5/5/5/5),
  并保留路径绕行 |winding|≈2π 断言(抓"角上往回折")。该断言即本次回归的守卫。
- 截图留档同时写入裁剪图与整窗图,便于人工复核角形。

叫人分紧急 / 普通两级(用户要求"叫人也分为紧急和普通"):
- shared:CreateCallRequest 新增 urgent;紧急叫人 → URGENT(无视上课时段立即展开),
  普通叫人(默认)→ HIGH(按普通通知排队)。
- desktop:isImmediate 只认 priority=URGENT,不再把 kind=call 一律视为立即消息;
  普通叫人回到与普通通知相同的排队 / 隐藏 / 下课弹出路径。
- web:通知发布 → 叫人、学生管理 → 叫人 两个弹窗新增"级别"单选(默认普通)+ 联动提示文案。

验证:verify:e2e 135/135(新增普通 / 紧急叫人级别断言)、verify:web 20/20(新增级别单选断言)、
verify:desktop 59/59 + 离线复跑 53/53、typecheck / lint / format 全绿。
2026-09-12 19:54:00 +08:00
LoveChengke 1b4dd14e4a docs(island): README 与设计文档补齐 WinIsland 架构对照(第 1 轮收尾)
- README 客户端冒烟验证表:新增两条断言(窗口全程恒定 + 形状与卡片框一致、岛外鼠标穿透),
  并把"开合不震动"改成按"岛"描述(窗口冻结 / 岛 0.00px / 无过冲);空闲细缝更新为 6x22
- README 个性化设置表与交付清单:去掉已失效的"Windows 36px 最小窗口高度"说明
- docs/winisland-design-tokens.md:新增"实际采纳了哪些"对照表
  (固定包围盒窗口、弹簧参数、连续圆角、交叉淡入、描边/投影、亚克力、命中控制、6 锚点、字号模型,
  以及明确未采纳的音乐/歌词/小组件部分)

验证:verify:desktop 54/54(离线复跑 48/48)、打包产物 portable 54/54、verify:e2e 133/133、verify:web 19/19
      lint / format:check / typecheck 全通过;服务端安装包同步重打(18:53)
2026-09-12 18:53:32 +08:00
LoveChengke e1e7e6ef5d docs(screenshots): 更新 WinIsland 架构下的灵动岛留档截图 2026-09-12 18:48:34 +08:00
LoveChengke 7a4668cb1b feat(island): 彻底照搬 WinIsland 架构重写灵动岛(固定包围盒窗口 + 窗口内弹簧形变)
背景:上一版仍沿用"每帧移动/缩放透明窗口"的做法,实机上会出现形变抖动与残影
(用户反馈"越改越烂、开合震动还在")。本次按 WinIsland 的真实做法重做架构。

主进程(packages/desktop-client/src/main/island.ts):
- 窗口一次创建为**固定包围盒**(最大形态 + ISLAND_SHADOW_PAD 阴影留白),
  开合过程中不再调用 setBounds;只在"尺寸/位置设置变化"时重排一次窗口
- 新增 setInteractive():默认 setIgnoreMouseEvents(true,{forward:true}) 整块穿透,
  由渲染层命中测试回调切换(照搬 WinIsland set_cursor_hittest),保证不吞桌面点击
- 移除逐帧几何代码(animateBounds/boundsFor/computeBounds/resolveAnchor 用于窗口定位的简化版保留)
- 空闲细缝不再受 Windows 36px 最小窗口高度限制(窗口固定,岛可真正缩到 6px 宽)

渲染层(IslandApp.vue + 新增 spring.ts):
- 岛是**单一元素**,宽/高/圆角由 WinIsland 弹簧逐帧逼近:
  force=(target-value)*0.10*dt → velocity=(velocity+force)*0.68^dt → value+=velocity*dt
  (dt 以帧计;换向保留 35% 动量、单帧位移 ≤ 剩余距离 20%),并加零过冲钳制
- 胶囊层与展开层常驻并交叉淡入(展开 progress²、胶囊 1-1.5×progress),形变全程无缩放回弹
- 形状层改为显式像素宽高 + preserveAspectRatio="none"(避免 viewBox 缩放导致形状错位/残留角)
- 修两个实机可见缺陷:① 展开层漏了 flex-direction: column(展开卡内容横排、元素散落);
  ② 隐藏且无细缝时不再把岛缩成细缝(否则下次弹出会"从细缝长大")
- 尺寸增量 ISLAND_SIZE_DELTA / 阴影留白移入 @classhelper/shared,主/渲染两端共用一份

验证(verify:desktop 53 → 54 项,全绿):
- 新增「开合过程中窗口全程恒定」(逐帧窗口签名恒为 476x282@482,-2,岛 386x230→…→268x44 单调收缩,
  SVG 形状 bbox 与卡片框一致 ±2px)
- 新增「岛外鼠标穿透」(岛内 interactive=true / 岛外 false)
- 开合不震动改为按"岛"判定:窗口冻结 + 岛锚点 0.00px + 无过冲;截图按岛矩形裁剪并统计岛外红像素=0
- 其余能力(通知/作业/叫人/紧急、标为已读、上课隐藏、6 锚点、字号可调、设置立即生效、空闲细缝)全部保留
2026-09-12 18:46:20 +08:00
LoveChengke 93447922dc fix(island): 失焦收起加 500ms 宽限期 + 冒烟不再改写班级码(打包产物验证稳定)
- 灵动岛窗口 blur 处理增加 BLUR_GRACE_MS = 500:激活后 500ms 内的失焦一律忽略。
  实机/打包产物上偶发"刚展开就被系统换前台造成的 blur 立刻收起",
  表现为"下课后自动弹出通知详情"变成 pill(截图比例也随之异常);
  宽限期后该用例稳定,且"点击屏幕任意位置收起"用例(展开后等 700ms 再触发 blur)仍正常。
- 桌面冒烟准备班级账号时**永不修改班级码**(只按需重置密码):
  班级码是给学生用的凭据,不应被测试工具改写;实测产物验证不再依赖固定班级码。

验证: verify:desktop 53/53(首轮)与 47/47(二次启动)全绿
2026-09-12 17:48:32 +08:00
LoveChengke a5c717e33e chore: 不再跟踪第三方参考包 WinIsland-master.zip(已提取设计 token 入库) 2026-09-12 17:41:12 +08:00
LoveChengke 3f2b135211 feat(island): 参考 WinIsland 重写灵动岛(squircle + 三种底 + 字号系数排版 + 空闲细缝)
参考: 工作目录 WinIsland-master.zip(Rust/Direct2D 的 Windows 动态岛),
     逐项提取的设计 token 归档在 docs/winisland-design-tokens.md。

视觉与造型:
- 连续圆角 squircle(新增 island/squircle.ts,超椭圆角,对齐 WinIsland utils/shape.rs),
  胶囊半径 = min(圆角, h/2)(满圆角),展开卡 = min(48*圆角/20, w/2, h/2)(WinIsland expanded_island_radius)
- 三种底: 纯黑(WinIsland default)/ 毛玻璃(win.setBackgroundMaterial('acrylic'),等价 HostBackdropBrush)/
  主题色渐变;描边 1px 白 alpha .118(纯黑)/.157(玻璃),展开投影 0 2px 3px rgba(0,0,0,.11)
- Apple 系统色: 强调蓝 #0a84ff、警告橙 #ff9f0a、危险红 #ff453a;白字 alpha 分级 .92/.72/.58
- 状态色只做"内层微染 + 描边 + 徽标",不外溢(保持外圈偏红 = 0)

排版(本次重点):
- 基础字号 11–20px,岛内**所有文本** = 基础字号 × 排版系数
  (标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),
  与 WinIsland 的 font_size 模型一致;实测 13→19px 全部文本倍率 1.462(=19/13)
- 图标、按钮高度、间距也由字号推导,改字号不会溢出

形态与设置:
- 尺寸基准对齐 WinIsland 比例(胶囊 216x34 基准、展开 +156/+186 等),卡片 = 窗口 - 2*5px 且水平居中
- 6 个停靠位(顶/底 × 左/中/右,对齐 WinIsland DockPosition)
- 新增"空闲细缝"(WinIsland hidden_width):空闲留一条 6px 宽竖条,来消息自动展开
- 所有设置依旧"改即生效";顺带修掉 hide() 用 skipWindow 绕过 syncWindow 导致细缝不生效的问题

验证(verify:desktop 49 → 53 项,全绿):
- 字号即时生效: 13px→19px 五项文本倍率全部 1.462
- 风格即时生效: 纯黑 fill=rgb(0,0,0)/材质 none;毛玻璃 fill=rgba(10,10,14,.59)/材质 acrylic;主题色 fill=rgb(11,11,15)
- 空闲细缝: 16x36 → 来消息 268x44;关闭后完全隐藏
- 6 锚点、开合不震动(0.00px / 无过冲)、截图 6 张(比例偏差 0.000、外圈偏红 0)全部保持
- verify:e2e 133/133、verify:web 19/19 不受影响;截图归档到 docs/screenshots/island/

工程发现: Windows 对透明窗口有约 36px 最小高度(请求 32px 实际 36px),
空闲细缝高度据此设下限并写入注释与 README。
2026-09-12 17:41:05 +08:00
LoveChengke 9c9ac69ac6 fix(island): 开合不再震动——去掉弹簧过冲 + 锚点整数化
问题(用户反馈"灵动岛开合的时候不要震动"):
之前虽然有「收回不抖动」断言,但它只看窗口宽度单调收缩,漏掉了两处真实回弹。
逐帧采样卡片真实屏幕矩形后复现:
- 展开时窗口冲到 411x329(目标 404x316)→ 主进程用了 easeOutBack 弹簧缓动,尺寸超过目标再弹回
- 卡片被放大到 400.6x311.6(目标 396x310)→ 卡片入场用 cubic-bezier(0.34,1.56,0.64,1) 缩放,先超过 1 再回落
- 卡片中心左右波动 1.00px、上边缘波动 2.88px → 逐帧 Math.round 在 0.5 边界跳变 + transform 位移

修复:
1. 缓动单调化: 删除 easeOutBack,展开/收回统一 easeOutCubic(窗口与卡片都不再过冲)
2. 锚点整数化且与尺寸解耦: 新增 resolveAnchor(),停靠位置决定不动的那条边/中心,动画前算一次
3. 卡片宽度取偶数 + boundsFor 统一推导窗口矩形(消除逐帧 0.5px 抖动)
4. 卡片入场去掉 scale/translateY,只做 180ms 淡入,形变完全交给窗口尺寸
5. 顺带修掉新探针暴露的真实缺陷: 隐藏后再弹出胶囊时透明度被硬编码为 1,忽略了个性化设置

验证(verify:desktop 49/49,修复前后对比):
- 展开: 卡片中心波动 0.00px / 上边缘 0.00px,最大窗口 404x316(=目标)、最大卡片 396x308,单调=true
- 收回: 0.00px / 0.00px,最小窗口 268,宽度序列单调无回跳
- 透明度 0.62 生效;四个停靠位置、置顶、动画开关/速度、持久化全部通过
- verify:e2e 133/133、verify:web 19/19 不受影响
文档: README 形变动画章节更新为「单调不过冲 + 整数锚点」,并记录修复前后实测数据
2026-09-12 16:41:01 +08:00
LoveChengke 97a89de944 feat(rbac): 成绩录入/导入放开到本班班主任(需求 6 与需求 7 口径统一)
依据: 需求 6 明确要求'成绩与表格导入……老师端与学生端班级账号均可用且不越权',
      需求 7 的权限清单并未把成绩收归管理员专属(只限制班级增删改、人员分配与科任权限)。

变更:
- shared/permissions: canManageGrades(classRole) = ADMIN | HEAD;canImport 按 kind 分发
  (成绩=ADMIN|HEAD、名单=仅管理员、课表时间=ADMIN|HEAD);PERMISSION_MATRIX 同步
- lib/access: assertCanManageGrades(user, classId) 异步按班级角色判定,科任/跨班给出针对性提示
- grades 服务与路由、imports 的模板/预览/提交路由放宽为 ADMIN|TEACHER,
  服务层按 kind + 班级二次校验(名单仍仅管理员,科任仍 403)
- Web 管理端: 成绩录入菜单对教师可见;写入/导入按钮仅对'管理员或所选班级班主任'显示

验证:
- verify:e2e 133/133(新增: 班主任录入本班成绩 201 + 跨班 403、班主任模板/预览/导入本班成绩可用、
  科任导入 403、班主任名单模板与导入 403、append 重复导入跳过)
- verify:web 19/19(新增: 班主任可见成绩导入入口并打开弹窗,越权入口仍隐藏、菜单 5 项)
- verify:desktop 47/47(未受影响)
文档: README 权限矩阵与'需求 6/7 口径统一'对照表、各项计数更新
2026-09-12 16:21:39 +08:00
LoveChengke 21485ea294 style: README 表格格式化 2026-09-12 16:14:14 +08:00
LoveChengke 910e520d09 fix(verify): e2e 班级码用后还原,避免把演示实例改成随机码
问题: verify:e2e 的班级账号段会临时把班级码改成 E2E####,结束时只还原了密码,
      导致演示实例(以及随后跑的打包产物冒烟)拿着文档默认码 G101 登录被 401。

修复:
- e2e 开始前记录该班原本的班级码,结束时连码带密码一起还原
- 新增断言'清理:班级码恢复为验证前的原值(不污染演示数据)',并断言用原码仍可登录
- README 权限矩阵段落补充需求 6/7 的成绩导入取舍说明与放开范围(6 处改动清单)

验收: verify:e2e 126/126(结束后实例仍为 G101/123456)
      打包产物(portable.exe) 47/47, packaged=true, login=高一(1)班 classSession=true
2026-09-12 16:13:58 +08:00
LoveChengke b07ee4f8d7 test(desktop): 个性化全参数 + 退出无残留的自动化验证(verify:desktop 40 → 47 项)
新增 7 项真实断言(全部读真实窗口属性,而非界面显示):
- 透明度 → win.getOpacity 实际 0.62
- 主题色 → 渲染进程 CSS 变量 --island-accent = #ff7043
- 停靠位置 → 左上 8,8 / 右上 1164,8 / 底部居中 586,800 / 顶部居中 586,8
- 置顶开关 → isAlwaysOnTop 关/开
- 动画开关 → 关闭后 60ms 内即到位(404x316),无逐帧过渡
- 动画速度 → speed=0.5 收回 435ms vs speed=2 收回 128ms
- 持久化 → saveConfig/getConfig 全字段往返一致

需求 5 的'退出无残留'改为进程退出后实测:
- 删除 userData 目录(SingletonLock/IndexedDB 文件锁)成功 → 无句柄残留
- 用同一配置目录二次启动成功并再次全绿 → 单实例锁与进程均已释放

验收: verify:desktop 47/47(二次启动 41/41), verify:e2e 125/125, verify:web 18/18
文档: README 客户端冒烟验证表刷新为最新实测值 + 新增退出无残留说明
2026-09-12 16:09:56 +08:00
LoveChengke d11f8e43a1 chore(release): 客户端班级账号收尾 + README/安装包产物更新
- 客户端设置页展示班级码与登录方式(班级账号 / 个人学生账号)
- 冒烟脚本复用已有班级凭据(现成的班级码 + 密码可直接登录时不再改密码,避免污染实例)
- README: 学生端班级账号章节、功能页面/API/验收/命令计数(verify:e2e 125)同步
- 安装包 README 默认账号补充班级码 G101/G102/G203 + 班级密码 123456

验收: pnpm verify:e2e 125/125, verify:web 18/18, verify:desktop 40/40,
      打包产物实测(portable.exe): 40/40, login=高一(1)班 classSession=true
2026-09-12 16:02:37 +08:00
LoveChengke 1073436ad6 feat(class-account): 学生端主体改为班级(班级码 + 班级密码)+ 数据按班级隔离
服务端:
- Class 新增 code(唯一)/passwordHash 与迁移(老数据回填 C00001 形式, 未设密码禁止登录)
- POST /auth/class-login: 班级码 + 班级密码 -> classSession 会话(sub=classId)
- PATCH /classes/:id/class-account: 管理员改班级码/重置密码(唯一性、格式校验)
- auth 中间件/socket 握手支持班级会话(sub 为 classId, 回查 Class 表, 加入 user:{classId} 房间)
- lib/session.ts: 班级设备代全班操作(通知已读/作业完成/成绩总览按全班读写), 普通学生账号行为不变
- 叫人消息同时广播到 user:{classId}, 班级设备立即上岛
- 班级列表/详情按权限返回班级码与密码状态(哈希不外泄)

客户端:
- 登录页改为班级码 + 班级密码(个人学生入口移除, /auth/login 接口保留兼容)
- 作业按钮/成绩标题按班级会话显示(全班标记完成 / 本班成绩)
- 冒烟脚本自动准备班级账号凭据(兼容全新与升级安装)

Web 管理端:
- 班级管理新增「班级账号」列与设置弹窗(改班级码/重置密码/随机生成)
- 新建班级可选自定义班级码, 默认由服务端生成

验证: verify:e2e 125/125(新增班级账号 18 项) verify:web 18/18 verify:desktop 40/40
      (login=高一(1)班 classSession=true)
文档: README 新增「学生端班级账号」章节、API/迁移/验收/默认账号更新; 安装包 README 同步
2026-09-12 15:51:08 +08:00
LoveChengke 8b85079d81 feat(imports): 成绩/名单表格导入 + ClassIsland 时间配置导入 + 安装版增量迁移
服务端:
- 新增 imports 模块: 模板下载(csv/xlsx)、表格解析预览、字段映射导入(append/upsert)、
  错误行号与结果统计; ClassIsland 时间配置容错解析 + replace/merge 导入 + 失败回滚(400 不写库)
- TimeLayout 模型 + 迁移; 修复 contentBase64 空值被 422 拦截, 改由 service 返回 IMPORT_EMPTY_FILE
- merge 语义改为按开始时间槽位覆盖, 同开始时间的旧节次不再被静默丢弃
- db-bootstrap 增加 _ch_migrations 账本: 覆盖安装升级也能自动补跑新增迁移(旧库实测通过)

Web 管理端:
- TableImportDialog(成绩/名单): 模板下载、预览、字段映射、重复处理模式、结果统计与错误行
- TimeLayoutImportDialog: 粘贴/选 .json、解析预览、覆盖/合并、已导入列表与删除
- 成绩录入/学生管理/课表管理接入入口(按权限矩阵显示)

验证: verify:e2e 107/107(新增导入 22 项) verify:web 18/18(新增时间配置导入弹窗真实点击)
      verify:desktop 40/40
文档: README 新增导入章节、增量迁移说明、API 表与验收对照更新
2026-09-12 15:35:01 +08:00
LoveChengke 0dcab6f369 feat(client): 灵动岛个性化设置(需求 2)、形变抖动修复(需求 4)、关闭到系统托盘与退出清理(需求 5)
## 需求 2:设置页个性化(实时生效 + 持久化)

- 新增 `IslandAppearance`(shared 类型)+ `DEFAULT_ISLAND_APPEARANCE` / `ISLAND_APPEARANCE_RANGES` /
  `ISLAND_POSITIONS`:高度 36–72、宽度 220–420、圆角 8–32、不透明度 0.4–1、字号 11–18、
  动画速度 0.5–2、主题色、显示位置(顶部居中/左上/右上/底部居中)、动画开关、始终置顶。
- 客户端 `config.json` 增加 `island` 字段(`normalizeIslandAppearance` 夹紧越界值;
  旧配置无该字段时自动补默认值,向后兼容);`SettingsView` 新增「灵动岛 · 个性化」面板:
  滑块/取色器/开关/位置选择 + 「保存设置 / 预览效果 / 恢复默认」,拖动即实时预览。
- 主进程 `island.setAppearance()`:窗口尺寸按外观联动(展开卡按差值同步放大,内容用 flex + 溢出滚动承载)、
  透明度、置顶、动画开关与速度、停靠位置实时生效,并把外观通过 `island:appearance` 下发给灵动岛渲染进程;
  渲染进程用 CSS 变量(`--island-w/-h/-radius/-font/-accent`)驱动卡片尺寸、圆角、字号与强调色。

## 需求 4:形变抖动修复

- 窗口尺寸动画改为**单条帧循环**(优先 `requestAnimationFrame`,主进程无该全局时退化为 ~60fps 定时器);
  新动画取消上一条并作废令牌;结束对齐最终尺寸(消除 1px 累积误差);**展开动画结束后才取焦点**。
- 卡片保持固定尺寸 + 顶部居中锚定(此前已修),只让窗口露出/裁切,形变时内容不参与重排。
- 冒烟新增断言:收回过程窗口宽度**单调收缩**(实测 362→330→305→288→274→270→268…)、
  卡片上边缘波动 **0px**、卡片布局宽度只取 `396/260` 两个固定值;连续 5 次快速开合后状态与尺寸稳定。

## 需求 5:关闭到系统托盘 + 退出清理

- 新增 `main/tray.ts`:托盘图标(`public/tray.png`,`pnpm icons` 会自动同步)、右键菜单
  「显示主窗口 / 隐藏到托盘 / 退出班级小助手」、左键单击/双击恢复窗口。
- `main/index.ts`:`close` → `preventDefault + hide`(关闭窗口不退出,后台继续接收通知,日志留痕);
  `window-all-closed` 不再退出;`quitApp()` 与 `before-quit`/`will-quit` 统一走
  `shutdownResources()`:销毁托盘 → 销毁灵动岛(帧循环/定时器)→ 销毁全部窗口
  (渲染进程随之释放 Socket.IO 连接与 IndexedDB 句柄)→ `app.quit()` + `app.exit(0)` 兜底。
- 冒烟新增断言:关闭后 `visible=false / destroyed=false / 进程存活=true`;清理后 `托盘=false / 灵动岛=false`。

## 验证

- `pnpm verify:desktop` **40/40** ✅(新增 6 项:个性化外观生效、调整尺寸后内容不溢出、收回不抖动、
  快速开合稳定、关闭到托盘、退出前资源清理)
- `pnpm verify:e2e` **82/82** ✅、`pnpm verify:web` **17/17** ✅(回归无破坏)
- `pnpm lint` / `format:check` / `typecheck` 全绿
- 顺带修复:托盘/关闭处理器此前挂在冒烟验证之后,导致冒烟模式关闭窗口会真的销毁窗口
  (现已调整为在冒烟前挂载);`smoke.mjs` 配置目录按进程号隔离,避免上次异常退出的目录占用导致启动失败。
2026-09-12 15:19:13 +08:00
LoveChengke 30e4c36809 feat(rbac): 落地角色权限模型(需求 7)——班级/人员/名单/成绩收紧到管理员,班主任管本班课表,科任仅作业·叫人·通知
## 权限矩阵(前后端共用一份)

- 新增 `packages/shared/src/permissions.ts`:`ClassRole`(ADMIN/HEAD/SUBJECT/STUDENT/NONE)、
  `resolveClassRole()`、`canManageClasses/AssignTeachers/Roster/Schedule/Courses/PublishContent/ManageGrades/Import`
  与 `PERMISSION_MATRIX`(README 与验收脚本据此展开)。
- 后端 `lib/access.ts`:新增 `resolveUserClassRole()`(`Class.teacherId` → 班主任,`ClassTeacher` → 科任)
  与 `assertCanManageClasses/AssignTeachers/Roster/Schedule/PublishContent/ManageGrades`;
  各 service 在**服务层**断言(路由仅做角色过滤),越权统一 403。
- 收紧项:班级增删改、班主任/科任分配、学生名单与账号 → 仅 ADMIN;
  课表/课程增删改 → ADMIN 或本班班主任;成绩录入/修改/批量 → 仅 ADMIN(按需求清单);
  作业/通知/叫人 → ADMIN、班主任、科任皆可。
- 读权限保持不变:教师仍可查看自己班级的课表/作业/通知/成绩列表与统计。

## 前端按同一矩阵隐藏入口

- 侧边栏:教师端不再显示「班级管理 / 学生管理 / 成绩录入」(管理员仍是 7 项)。
- 班级页:新建/编辑/删除按钮仅管理员可见;课表页:增删改按钮仅"管理员或本班班主任"可见;
  成绩页:录入/批量/删除按钮仅管理员可见。
- **叫人入口调整**:教师需要叫人,而「学生管理」是管理员专属,因此教师入口移到
  「通知发布 → 叫人」(选班级 → 选学生 → 快捷短语/自定义消息;名单走 `/classes/:id/students`,
  不使用管理员专属的 `/students`);管理员在学生管理页仍保留逐行「叫人」。

## 顺手修掉的两个真实缺陷

- `NotificationsView.openCreate()`:班级列表异步加载期间点「发布通知」会导致「目标班级」为空、
  校验失败且**静默不发布**;现在打开弹窗前会确保班级已加载(冒烟同步等待下拉就绪)。
- `desktop-client/scripts/smoke.mjs`:上一次冒烟被强杀后配置目录仍被句柄占用,`rmSync` 报 ENOTEMPTY
  直接让冒烟启动失败;改为按进程号使用独立目录并容错清理。

## 验证

- `pnpm verify:e2e` **82/82** ✅:新增 14 项角色权限断言(班主任/科任建班被拒、班主任删班被拒、
  班主任/科任分配人员被拒、管理员分配与取消成功、科任管理课表被拒、科任可布置作业、
  科任/班主任录入成绩被拒、管理员录入成功),并把历史用例中"仅管理员可执行"的操作切到管理员令牌。
- `pnpm verify:web` **17/17** ✅:新增「教师端隐藏无权入口」断言(可见菜单=仪表盘/课表/作业/通知,
  越权入口=[]),叫人入口改到通知页后仍验证弹窗、8 条快捷短语、自定义输入与可选中态。
- `pnpm verify:desktop` **34/34** ✅;`lint` / `format:check` / `typecheck` 全绿。
- 数据清理:清掉历史遗留的测试班级与 4 条探针课表,演示数据恢复为 3 个班级。
2026-09-12 15:02:02 +08:00
LoveChengke 71f43042ca docs: 客户端三套产物(setup / portable / win-unpacked)均按最新代码重打且打包后冒烟 28/28,同步 README 与灵动岛归档截图 2026-09-12 14:19:41 +08:00
LoveChengke 6b8563d0d3 feat(desktop-client): 课表改为 ClassIsland 风格今天时间轴(大时钟 + 当前课强调色高亮 + 进度条与倒计时 + 已结束/下一节标记),补充时间轴自动化校验与实拍 2026-09-12 14:15:00 +08:00
LoveChengke 219f6ae83a feat(desktop+web+server): ClassIsland 风格 UI、灵动岛动画/交互修复、作业上岛与"叫人"功能
## 灵动岛修复与动画优化(用户反馈)

- **收回不再出现"方框"闪烁**:卡片改为**固定逻辑尺寸 + 顶部居中锚定**,
  窗口只负责露出/裁切透明区域。此前卡片随窗口拉伸,形变过程中会看到被拉扁的圆角方块,
  胶囊里的铃铛/文字还会被垂直居中而"从上跳到下";两条问题同源,现已消除。
  冒烟用 15 次采样断言卡片尺寸恒为固定值(`396x308` / `260x38`),一旦出现中间值即失败。
- **点击屏幕任意位置收回**:展开态临时 `setFocusable(true)` 并取焦,点到别处即失焦 → 自动回缩为胶囊;
  收起后立即释放可聚焦性。断言覆盖 `focusable=true → 失焦后 mode=pill、focusable=false`。
- 动画继续打磨:危险/叫人形态改为**内部光晕**(卡片四周再留 4px 透明边,彻底避免窗口边界裁切出硬边)、
  尖角与圆角统一、形态尺寸各异(胶囊 268×44 / 详情 404×316 / 紧急 424×344 / 叫人 440×360)。

## 灵动岛"标为已读"真正同步通知中心

- 新增链路:灵动岛 `mark-read` → 主进程 `setMarkReadHandler` → 主窗口渲染进程 →
  `notificationApi.markRead` + 刷新 store(未读红点即时减少)。
- 真机链路断言:点击"标为已读"后 `read=false → true`、未读数 `1 → 0`(此前只从灵动岛队列移除)。

## 作业发布也上岛

- `homework:new` → 灵动岛 `kind=homework`:胶囊显示"新作业"(青蓝描边 + 书本图标),
  展开显示作业说明与"截止时间:…"。

## 新增"叫人"功能

- 服务端:新模块 `modules/calls`(`POST /api/calls`)——校验班级可写 + 学生属于该班,
  落库一条 HIGH 级通知(进通知中心、可用已读状态),广播 `notification:new` 到班级房间,
  并定向 `call:new` 给被叫学生。
- 共享层:`CALL_QUICK_PHRASES`(8 条快捷短语)、`buildCallTitle()`("请 XXX 同学找 XXX 老师",老师名已含"老师"不重复)、
  `ServerToClientEvents['call:new']`、`CreateCallRequest`。
- Web 管理端:学生管理列表新增 **叫人** 按钮 → 弹窗显示对象、快捷短语(点选/取消)、自定义消息(优先),发送即通知。
- 客户端:`call:new` → 灵动岛 `kind=call`,**上课时段也立即展开**(老师正在等这名学生),
  琥珀金卡片 +"叫人"徽标 +"收到"按钮,90 秒后自动收起。

## 客户端界面改为 ClassIsland 同款 Fluent 风格

- 主题令牌:Mica 浅底 `#f3f3f3`、亚克力卡片 `rgba(255,255,255,.7)` + `backdrop-filter`、
  强调色 `#0F6CBD`、半透明描边、卡片 8px / 控件 4px 圆角、极轻阴影。
- Element Plus 变量整体对齐(`--el-color-primary`、圆角、表头/行分隔、滚动条等)。
- 侧边导航改为 Fluent NavigationView 观感:白色半透明底、选中项强调色淡底 + 左侧 3px 强调条;
  品牌标改为强调色渐变圆角方块。附客户端五个页面实拍(`docs/screenshots/client/`)。

## 验证

- `pnpm verify:e2e` **68/68**(新增 6 项叫人:201 + 标题模板、自定义消息优先、上课时段允许、学生 403、通知中心可见、清理)
- `pnpm verify:desktop` **33/33**(新增:卡片固定尺寸、点击任意处收回、标为已读同步、作业上岛、叫人上课中立即展开、真实通知链路)
- `pnpm verify:web` **16/16**(新增叫人入口:弹窗、8 条快捷短语、自定义输入、短语可选中)
- `pnpm lint` / `format:check` / `typecheck` 全部通过;服务端安装包已重打并安装验证
2026-09-12 13:57:46 +08:00
181 changed files with 31503 additions and 1822 deletions

No files matched your search

+8
View File
@@ -38,3 +38,11 @@ eslint-report.json
.idea/
.DS_Store
Thumbs.db
# 参考用的第三方源码包(设计 token 已提取到 docs/winisland-design-tokens.md)
WinIsland-master.zip
releases/
# AI 浼氳瘽浜х墿锛圸Code 璁″垝/閰嶇疆锛屼笉灞炰簬浜у搧浠g爜锛�
.zcode/
.zcodeignore
+703
View File
@@ -0,0 +1,703 @@
# AGENTS.md — 班级小助手(Class Helper)
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
---
## 1. 这是什么
班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物:
| 交付物 | 位置 | 形态 |
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
核心链路:
```
教师在 Web 端发布内容
│ REST API(JWT 鉴权)
▼
后端服务(Express + Prisma)
│ 写库 + 按班级房间广播
▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
ClassIsland(教室机器)──► 班级小助手联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
▲ │
└──── 老师下发的提醒(上报返回值里顺带带回)◄┘
```
- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
- 默认分支:`master`
- 语言:**文档、注释、提交信息、界面文案一律中文**
---
## 2. 目录速查
```
package.json 根脚本(dev / build / db:* / verify:* / dist:* / icons)
pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白名单,勿随手删条目)
tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/
.prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格
build/icon.{ico,png} 应用图标(由 pnpm icons 生成)
scripts/
use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema)
generate-icons.mjs Electron 渲染 SVG → PNG/ICO
icons/render.cjs 图标渲染(CommonJS)
dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-server/)
nsis/server-installer.nsi 服务端 NSIS 安装脚本
verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟
ui-smoke/{run,main.cjs,live-probe.mjs} Web 管理端真实点击回归(Electron 驱动)
lib/{electron-env,node-runtime}.mjs 启动 Electron / 定位真实 node.exe
deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / install-linux.sh
classhelper.service / package.runtime.json / .env{,.sqlite}.example
docs/ production.md(生产部署)/ mysql.md(MySQL 切换)/ winisland-design-tokens.md
screenshots/{island,client,web-mobile,classisland}/
packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts
packages/server/
prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
prisma/migrations/ 10 个迁移(init / time_layout / class_account / schedule_week_parity /
class_term_weeks / classisland_integration / class_notification_channel /
homework_assign_date / push_kind / schedule_source)
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,实测 173 项)
scripts/verify-classisland.mjs 联动链路验收(实测 42 项)
scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用)
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
src/config/env.ts zod 环境变量校验 + 生产自检
src/lib/ access(RBAC) class-account db db-bootstrap http jwt logger mappers
password schemas session term web-static
src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
src/modules/ 13 个功能模块 + registry.ts + module.types.ts
packages/web-admin/src/ api stores router layouts views(11) components(4) composables styles config.ts
packages/desktop-client/
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / logger / smoke
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
src/island/ 灵动岛渲染进程:IslandApp.vue / spring.ts / squircle.ts / main.ts
src/renderer/ 学生端渲染进程:api / cache(IndexedDB) / stores / views / island / router
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=0.1.0.0
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs
src/Services/ BridgeService(上报·接收·镜像)/ ScheduleMapper / ClassPlanWriter
ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页
src/Interop/ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO
scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验,实测 68 项)
```
---
## 3. 本机环境(2026-09-30 实测)
| 项 | 状态 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node | **v24.19.0**(`D:\nodejs\node.exe`;要求 ≥ 20.19,Prisma 7 硬性要求) |
| pnpm | 11.8.0(`packageManager` 锁定 11.8.0),宿主就是上面这个 node |
| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
| 数据库 | SQLite `packages/server/prisma/dev.db`:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) |
| `shared/dist` | 已构建(`pnpm build:shared`)—— **它是三端与 seed 的前置产物,缺了会 `ERR_MODULE_NOT_FOUND`** |
| Electron 二进制 | 已手动装好(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`,246 MB) |
| .NET SDK | ✅ **8.0.425**(`C:\Program Files\dotnet\sdk`,`dotnet --list-sdks` 有输出)⇒ 插件可编译/打包;**别再照旧文档说"没装"**(`dotnet` 在 PATH 里,`--list-sdks` 才是权威判据) |
| ClassIsland | **2.1.0.1,装在 `D:\Classisland`**(启动器 `D:\Classisland\ClassIsland.exe`,实际进程 `D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe`) |
| ClassIsland 数据 | 数据根 **`D:\Classisland\data`**:`Config/`、`Profiles/`、`Plugins/`(当前为空,尚未装本插件)、`Logs/`、`Cache/`、`Settings.json` |
| 桌面会话 | 有(`ClassIsland.Desktop.exe` 跑在 session 1);**Electron GUI 在 AI 会话里也能跑**(需先修完整性标签,见 §7 第 7 条) |
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
### 从零重建环境(本机实测可行的顺序)
```bash
pnpm install # 需要下载 Electron 时加 NODE_OPTIONS=--use-system-ca
# 后端环境变量(.env 不入库,必须自己生成)
cp packages/server/.env.example packages/server/.env # 至少改 JWT_SECRET;TERM_START_DATE 建议 2026-09-07
pnpm db:generate # 生成 Prisma Client
pnpm --filter @classhelper/server db:deploy # 应用迁移(根 scripts 里没有 db:deploy,见 §7)
pnpm build:shared # 必须先构建 shared,否则 seed / 服务端启动会失败
pnpm db:seed # 写演示数据
# Electron 二进制不会随 pnpm install 下载,需要手动补:
# 工作目录 node_modules/.pnpm/electron@44.3.0/node_modules/electron
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js
# ClassIsland 插件(.NET 8 SDK 已装:8.0.425)
pnpm build:classisland-plugin
```
### 演示账号(`pnpm db:seed` 产出)
| 角色 | 用户名 | 密码 |
| -------------- | ------------------------------- | ------------ |
| 管理员 | `admin` | `admin123` |
| 教师 | `teacher1` / `teacher2` | `teacher123` |
| 学生端班级账号 | 班级码 `G101` / `G102` / `G203` | `123456` |
> 密码是**种子脚本里硬编码**的(`admin123` / `teacher123` / 班级 `123456`);
> `.env` 的 `DEFAULT_CLASS_PASSWORD` 只影响**新建**班级时的初始班级密码。
> **学生没有个人账号**(2026-10-01 清理):`student01…15` 只是名单记录(成绩/未交/叫人/已读按名单),
> `User.passwordHash` 为空串、不能登录,也没有任何密码接口;学生端统一用班级码 + 班级密码。
> 班级码 `G203` 是「高二(3)班」,不是 `G103`。
---
## 4. 常用命令
| 命令 | 说明 |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 |
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) |
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint |
| `pnpm format` / `pnpm format:check` | Prettier(`format:check` 在 master 基线上本就失败,见 §7 第 21 条) |
| `pnpm db:generate` | 生成 Prisma Client |
| `pnpm db:migrate` | `prisma migrate dev`(本机不可靠,别用;见 §7) |
| `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**本机推荐**;根 scripts 里没有 `db:deploy`) |
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
| `pnpm db:studio` | Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/mysql.md`) |
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-09-30 实测 173 项全过) |
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 42 项) |
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) |
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
### 端口
| 端口 | 用途 |
| ---- | --------------------------------------------------------- |
| 4000 | 后端 REST + Socket.IO + (生产/构建后)Web 管理端静态托管 |
| 5173 | Web 管理端开发服务器(`/api`、`/socket.io` 代理到 4000) |
| 5174 | 桌面客户端渲染进程开发服务器 |
### 交付产物归集
打包脚本各自输出到固定目录,**归集到仓库根 `releases/` 是本仓库的人工约定**(该目录 gitignore + eslint ignore):
```
release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序)
packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe)
releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/)
```
```bash
# 客户端:electron-builder 支持指定输出目录(dist-win.mjs 会把额外参数透传)
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
# 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/
pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
pnpm dist:classisland-plugin
```
---
## 5. 架构硬约定(改代码前必须知道)
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 13 个:`auth / classes / courses / schedules /
homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard`。
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
`@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/ STUDENT / NONE`,
权限判 `canManageSchedule` / `canPublishContent` / `canManageRoster` / `canManageGrades` 等
(人类可读矩阵是 `PERMISSION_MATRIX`,README 直接引用它),**不要在页面里另写一套规则**。
4. **学生端主体是「班级账号」。** 班级码 + 班级密码登录后,`classId` 即身份;该设备的「已读 / 完成」
代全班操作,个人学生登录已停用。API 里对此有专门分支,别按个人账号语义改。
5. **实时推送只走房间。** 服务端 `src/realtime/`(`SOCKET_ROOMS`:`class:{classId}`、`user:{id}`、
`teacher:{id}`、`role:{role}`、`students`、`teachers`;`bus.ts` 事件总线),客户端
`renderer/stores/realtime.ts` 订阅。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、
客户端订阅(灵动岛也依赖它)。事件名一律取自共享常量 `SOCKET_EVENTS`,不要手写字符串。
6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`,
改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。
7. **数据模型跨库通用。** `schema.prisma` 只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、
不用 `@db.*`;角色与优先级存字符串并在 API 层用 zod 校验(这样 SQLite / MySQL 行为一致)。
**只有一份 `schema.prisma`**:切库是改写它的 `provider`(`pnpm db:switch:mysql`),没有 `schema.mysql.prisma`。
改模型要同步 `prisma/migrations/`(本机用 `pnpm --filter @classhelper/server db:deploy` 应用)。
8. **周次口径唯一。** 当前教学周 = `shared.resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)`,
`MAX_TERM_WEEK = 30`,班级还有自己的 `Class.termWeeks`(默认 20,**上限也是 30**)。别在别处重新实现周次换算。
周次上限三处必须一致:`MAX_TERM_WEEK`、`lib/schemas.ts` 的 `weekNumberSchema`、`classes.schemas.ts` 的 `termWeeks`
(曾经班级能设到 40 而课表只到 30,等于对外承诺了系统兑现不了的学期长度)。
`Class.termWeeks` 只有**管理员**能改(`assertCanManageClasses`),班主任不行。
9. **Electron 安全边界。** `src/preload/` 只通过 contextBridge 暴露白名单方法,**不要暴露 `ipcRenderer` 本体**;
主进程 ↔ 渲染进程的类型统一写在 `src/types/desktop.d.ts`。
**跨桥接只能传纯数据。** contextBridge 在参数跨越「主窗口 → 隔离世界」时就做结构化克隆,
传 Vue 的 `ref.value` / `reactive()` 对象(Proxy)会抛 `An object could not be cloned.`——
而且它在**进入 preload 函数体之前**就失败了,所以桥接层**无法兜底**,只能由调用方展开成
字面量再传(`saveConfig({ island: { ...island.value } })`)。这类失败是"半静默"的:
界面数字照常变化、只多一行 console error,看起来就是"设置不生效"。设置页曾因此翻车,
`verify:desktop` 里的「设置页真实 UI:拖拽滑块即时改变灵动岛外观」就是为它加的回归。
10. **客户端离线优先。** 渲染进程数据先落 IndexedDB(`renderer/cache/`),断网回退缓存并在顶部提示离线;
新增页面/数据源要按这个模式接。
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
(`--ch-radius-xl/lg/md/sm/pill`、`--ch-shadow-sm/md`),组件里**不要写死数值**;Element Plus 通过覆盖它的
CSS 变量对齐。手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card`。
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256,
明文前缀为 `chci_`)调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。
新增插件接口时,路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
而且表现为"课表看着有内容、每节课都对不上",极难排查。
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
否则又会出现「某个页面发的通知不联动 ClassIsland」。
15. **提醒弹在哪个端由教室客户端选**,值存在 `Class.notificationChannel`(`both` / `client` / `classisland`):
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课),
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
服务端合成节次时间表同口径。
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);紧急(`URGENT`)同样立刻弹。
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。
19. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期
(`YYYY-MM-DD`,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。
UTC 切片(`toISOString().slice(0,10)`)在晚上的录入会算到第二天 —— 统一用 `@classhelper/shared` 的
`dayKeyLocal / isDayKey / shiftDayKey`。新增按天接口时同样用 `?date=YYYY-MM-DD` + zod 的 `isDayKey` 校验。
20. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在
`homeworks.service.createHomework` 里判(staff 或本班班级账号)。班级账号的 `sub` 是**班级 id**,
写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键。
21. **灵动岛不要用窗口背景材质(`win.setBackgroundMaterial()`)**。它看起来像 WinIsland 的
`DWMWA_USE_HOSTBACKDROPBRUSH`,其实是**整窗系统材质**:刷在整个窗口矩形上(岛是"固定大包围盒窗口",
卡片之外还有留白),且明暗跟随应用主题(本客户端是浅色 Fluent 主题 ⇒ 材质必然是浅色),
于是卡片周围会出现一圈浅色"白底面板"。更坑的是**调用过一次就再也回不去逐像素透明**
(含 `'none'`:Electron 会让窗口回落到默认不透明底色 `#FFF`),所以从 `glass` 切到
`black`/`tinted` 后会永久留一圈**纯白**。CSS `backdrop-filter` 也替代不了(透明窗口里采样不到桌面)。
`glass` 因此用 CSS 半透明深色卡(WinIsland 无 backdrop 时的降级色 `rgba(32,32,36,.804)`)。
回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它**整屏截图**取样
(`webContents.capturePage()` 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因),
并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。
22. **灵动岛位置由「屏幕工作区 + 边距」算,默认边距 8px(`DEFAULT_ISLAND_APPEARANCE.marginX/marginY`)**:
改默认值会直接改外观,`verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,一动就红。
新增边距类设置请只改"可调范围",别动默认值(夹紧规则收口在主进程 `normalizeIslandAppearance`)。
23. **课表行有"来源"字段 `Schedule.source`**(`manual` / `classisland`)。插件每次全量上报时,
服务端只清理「`source='classisland'` 且本次未再上报」的行 —— 这样老师在 ClassIsland 里删掉/挪动一节
能真正同步下来(原先 merge 只 upsert,服务端会永远留着幽灵课),**老师在小助手这边手排的课一律不删**。
因此:**新增写课表的入口要显式定 source**(Web 端录入/导入保持默认 `manual`,只有插件上报用 `classisland`),
否则要么幽灵课清不掉,要么手排的课被误删。回归用例见 `verify:classisland` 的「幽灵行清理」两条。
**`source` 只在 create 时确定,update 一律保持原值。** merge 的去重键是「星期 + 开始时间 + 单双周」,
老师在 Web 端手排的同一时间槽会被上报"收养";若收养时把来源改成 `classisland`,
下一次清理就会把这行手排的课删掉 —— 这个坑踩过一次:种子课表被删掉 2 行,
`verify:e2e` 的「上课状态接口」用例(AGENTS.md §7 已列为脆弱用例)随即报红。
同步时**不要**覆盖 `weekStart/weekEnd`:插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。
24. **两个登录入口都必须挂 `loginRateLimiter()`**(`app.ts` 用数组形式同时挂 `/auth/login` 与 `/auth/class-login`)。
班级密码默认是 `123456`、班级码是固定字符集,班级登录曾是唯一没有暴力破解防护的入口。
另外 `auth.service.login` 里**角色判定必须在密码校验之前**:否则"密码错(401) / 密码对但学生账号已停用(403)"
的差异会把学生账号密码变成可离线验证的预言机。
25. **凡是会渲染成链接的字段都要校验 scheme。** `Homework.attachmentUrl` 在服务端用
`attachmentUrlSchema`(只允许 `http(s)://` 或 `/` 开头的站内相对路径),Web 端渲染前再兜一层
(`HomeworksView.vue` 的 `attachmentHref`)。放行 `javascript:` / `data:text/html` 就是一条存储型 XSS。
26. **桌面端必须保留 CSP**(`index.html` / `island.html` 的 `meta http-equiv="Content-Security-Policy"`)。
渲染进程通过 preload 能拿到 `getConfig()` 的**明文 token**(代表全班身份),CSP 是唯一能拦住
"加载并执行远程脚本"的一层。配套的两条导航防线也别删:主窗口 `isAllowedNavigation`(按同源/同一文件精确比对,
不要退回 `startsWith`)、灵动岛窗口的 `setWindowOpenHandler` + `will-navigate` 全拒。
27. **桌面端产物里不得出现任何凭据,构建有门禁。** `dist/main/index.js` 与 `dist/renderer/assets/*.js`
会被 electron-builder 打进 `app.asar` 发给每台学生机,而 asar 可直接解包
(历史问题:冒烟/自检代码里写死了 `teacher1/teacher123`,`grep` 产物即命中)。
冒烟与自检用的账号口令、班级码/班级密码**一律通过环境变量注入**
(`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `ELECTRON_SMOKE_CLASS_CODE` / `..._CLASS_PASSWORD`):
preload 读环境变量 → `window.desktop.smokeCredentials` → `setSmokeTeacherCredentials()`,
由 `scripts/smoke.mjs` 与 `scripts/verify-packaged.mjs` 提供(那两个脚本不进安装包)。
**把静态 import 改成 `await import('./smoke.js')` 解决不了这个问题**:esbuild 在
`format:'cjs'` + `bundle:true` 下不做代码分割,动态 import 同样被内联进同一个产物(已实测)。
`pnpm build:desktop` 结尾会跑 `check-bundle-secrets`(`packages/desktop-client/scripts/check-bundle-secrets.mjs`),
产物里出现种子口令/把种子班级码当凭据的写法即构建失败 —— 别把这条门禁摘掉。
28. **表格导入的体积上限是"请求体 12MB ↔ 文件 8MB"**:文件以 base64 放进 JSON(约 4/3 倍),
因此 `express.json({ limit: '12mb' })` 必须留够余量,否则声明了 8MB 却连 1.5MB 的文件都传不进来。
超限由 `middleware/error.ts` 映射成 413 `IMPORT_TOO_LARGE`(**不要**让它落进 500 兜底分支:
原先用户看到的是一句英文的「服务器内部错误:request entity too large」)。
29. **删除类接口的护栏要与 schema 的级联关系对齐。** `deleteTeacher` 的检查项必须覆盖
`Class.teacherId` / `Course.teacherId` / `ClassTeacher.teacherId` / `Homework.createdBy` /
`Notification.createdBy` 五项(后两项曾经漏检,会静默删掉该教师在各班发过的作业与通知;
回归用例见 `verify:e2e` 的「删除教师护栏覆盖"发布过作业/通知"的账号」)。
**改 schema 时先看哪些外键指向 `User` 且是 Cascade,再回来核对这个清单。**
30. **灵动岛在触摸屏上必须走「触摸模式」(`touchMode`)。** 打开命中的两条链路
(渲染进程的 mousemove 转发、主进程读 `screen.getCursorScreenPoint()`)**都以"鼠标指针移动"
为前提**,而手指触摸既不产生 mousemove、也不移动系统光标 ⇒ 窗口永远停在
`setIgnoreMouseEvents(true)` 的穿透态,**手指点不开岛**(用户反馈:希沃白板)。
触摸屏上唯一的解法是"触摸点处窗口可命中":Electron 没有 `SetWindowRgn`,
所以渲染进程按 `navigator.maxTouchPoints > 0` 上报(`island:set-touch-mode`)后,
主进程把窗口**贴合岛体**(`windowBox` = 当前形态 + 2×阴影留白)并**始终接收输入**
(`setInteractive` 在触摸模式下把入参强制为 true)。三条护栏别动:
- **变小要等岛真的收小**(`cardFitsFormSize` + 渲染进程上报的岛体矩形)—— 立刻缩小会把形变中的卡片裁掉;
- `expectedHitRect` 必须按**窗口实际尺寸**算偏移(触摸模式下窗口不再是固定包围盒);
- 失焦宽限期用 `BLUR_GRACE_TOUCH_MS`(1200ms):触摸没有光标,`isCursorOnIsland()` 恒为 false,
而实测假失焦出现在 70~520ms(跨过 500ms 边界 ⇒ 会把刚展开的岛缩回胶囊)。
非触摸屏机器**完全不走这条路**(窗口仍是固定大包围盒 + 按光标命中),
`verify:desktop` 会先 `setTouchMode(false)` 再跑既有的光标注入用例,末尾单独断言触摸模式。
31. **"改密码"的入口只剩 Web 管理端两处,客户端一个都没有。** 教师/管理员改本人密码走
Web 顶栏下拉(`PATCH /api/auth/password`);班级密码由管理员在「班级管理 → 修改班级账号」
(`PATCH /classes/:id/class-account`)维护;教师设指定新密码用
`POST /teachers/:id/reset-password`(可选 `newPassword`,留空 = 默认初始密码)。
**2026-10-01 起学生个人账号整体清理**:学生只是名单(成绩/未交/叫人/已读按名单记录),
`POST /students/:id/reset-password` 已删除(调用返回 404,e2e 有负断言)、新建学生不设密码
(`User.passwordHash` 空串占位,`STUDENT` 的 403 登录判定在密码校验之前)、
`DEFAULT_STUDENT_PASSWORD` 已删;客户端设置页的「修改密码」按钮**是按用户要求主动移除的**
(防教室机器误改班级密码)——别当成"丢入口"的回归补回来。
32. **灵动岛卡片的投影(CSS `filter`)必须常驻,形态切换只能改参数。**
不要把 `filter: drop-shadow(...)` 只挂在 `.island-card.expanded` 上:**给元素增删 CSS filter**
会让 Chromium 新建/销毁它的渲染表面(effect node),首帧的合成可能早于新表面栅格化完成,
那一帧被当成空内容画出去 —— 而**卡片的底色恰好画在这个被过滤的元素上**(`.shape path` 的 `fill`),
于是开/合的那一瞬间底座会闪掉一帧、只剩未过滤的文字层(用户反馈的"开合时一瞬间的闪动")。
正确写法见 `IslandApp.vue` 的 `.shape path`:filter 常驻,胶囊态用 `0 0 0` 的退化投影
(硬轮廓正好压在路径底下,肉眼等同无投影,与 WinIsland「只有展开态有投影」的观感一致)。
回归用例:`verify:desktop` / `verify:packaged` 的「灵动岛任意形态的投影都常驻」。
33. **多条通知时展开是「竖排列表」,卡片高度由主进程与渲染进程**各算一次同一个数**。**
`@classhelper/shared` 的 `islandListLayout()`(度量表 `ISLAND_LIST_METRICS`)是唯一算法:
默认只显示 `defaultVisibleRows`(3)条 + `展开更多(还有 N 条)`,按重要程度排序
(`PRIORITY_RANK`,同级按时间新的在前);点"展开更多"后铺到 `maxCardHeight` 为止,
仍然放不下的改成「更多请前往应用内操作」——`maxCardHeight` 是主进程按工作区/任务栏算出的可用高度,
随状态下发。两条硬约束:
- **窗口包围盒必须跟着列表长高**(`IslandApp.vue` 的列表比任何普通形态都高,窗口不够高会把底部按钮裁掉);
长高发生在卡片形变**之前**(`setState` 里先 `relayout()` 再 `emit()`),用户看不到这一步;
- 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量**一一对应**,
改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。
回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。
写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**,
否则量到的是列表高度。
34. **"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。**
收起一律是**主进程单向**的:`handleAction()` 收到 `dismiss` / `dismiss-all` / `mark-read` /
`mark-all-read` → `dismissActive()`(还有下一条则退胶囊)或 `clearPendingForClose()`
→ `hide()` → `fadeOut()`(每帧 `setOpacity(-0.2)`,到 0.02 后 `win.hide()`)。
状态在**同一帧就写成** `hidden` / 清空,**不等渲染进程任何回执** —— 调用方与冒烟按"同步语义"断言
(`island.ts` 的 `clearPendingForClose()` 注释:「状态同步落地(渲染进程随之收起卡片,窗口由 `hide()` 淡出)」)。
渲染进程那侧只有本地弹簧形变(`spring.ts` 的 `targetSize/progress`),是纯视觉动画,不参与淡出时机。
**曾经有过一层自研的"收回快照"动画,已按用户要求整体删除。** 它叫 `closing` / `closingSnapshot`:
点"知道了"后把卡片按"胶囊摘要"收回再淡出,是自研的形变状态机,**实测在触摸屏上会露出旧帧/重影**
(触摸模式窗口贴合岛体、卡片形变时窗口跟着动),用户明确要求"动画就用仓库里那套",于是整段删掉 ——
现状见 `IslandApp.vue` 顶部注释。**别照着一份不存在的握手机制去改代码。**
(`smoke.ts` 里"收回动画是渲染进程收敛上报 → 主进程淡出"只是一句过时注释,代码里没有这条链路。)
多条通知时这两个按钮都是**整批**操作:`dismiss-all` / `mark-all-read`(作业卡的合成 id
`homework-<id>` 要在主进程按 kind 过滤掉,否则通知中心会去查一条不存在的通知 ——
见 `handleAction()` 里 `mark-all-read` 分支的 `.filter((item) => item.kind !== 'homework')`)。
回归用例:`verify:desktop` 的「多条通知点「知道了」:整批关闭(状态同步清空,窗口淡出隐藏)」。
排查"收回时闪一下"这类反馈时注意:**先看是不是自动化验证实例的叠影**(见第 41、42 条),
别往这套已经不存在的握手机制上找原因。
35. **本机录入的作业不再上灵动岛。** 服务端是**先广播 `homework:new`、后回响应**的,
等响应拿到 id 再登记就晚了(事件可能已经到了、岛已经弹出来了)——所以
`renderer/island/bridge.ts` 在**发请求之前**按内容登记指纹(班级 + 日期 + 标题 + 正文),
回执拿到 id 后再补记一次,投递前用 id / 指纹双重判定。改作业录入入口时别忘了在请求前调
`markHomeworkCreatedLocally()`。回归用例:「本机录入的作业不再上灵动岛」。
36. **触摸模式下收回动画期间,窗口必须一动不动**(`syncTouchLayout()` 开头 `if (this.closing) return;`)。
触摸模式窗口是"贴合岛体"的,而收回时卡片正在缩小:若跟着缩窗口,**中心停靠下窗口 x 还要右移**,
"让出去"的那块区域会留着上一帧(DWM 还没重画桌面)——同一帧里能看到"旧展开卡的左半截 + 新胶囊"
两份画面(用户拍照反馈的"收回一瞬间闪一下")。窗口尺寸一律等**隐藏之后**再对齐
(`syncWindow` 里"不可见时对齐包围盒"那条,不可见时改尺寸不会有残影)。
回归用例:「触摸模式收回:动画期间窗口不动」逐帧采样窗口矩形(只在"窗口可见 / 正在收回"时追究,
收完隐藏后对齐尺寸是正常的);「收回动画期间卡片不被窗口裁切」。
另:`cardFitsFormSize()` 的判据里**不能**再塞 `closing` —— 那条早退已经把收回整个排除了,
早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断");
而"隐藏态改边距不生效"那条修法仍要保留:`this.win.isVisible() && (mode !== 'hidden' || idleSliver)`。
40. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动;
胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
(用户反馈的"新消息那行字往右跳一下再缩回")。所以 `IslandApp.vue` 的胶囊层里套了一层
`.pill-inner`,**固定成胶囊自己的几何**(`width: var(--island-w)`),并按停靠方式对齐
(`data-anchor` = left/center/right → `justify-content` flex-start/center/flex-end):
左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。
回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。
41. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
`verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端
默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为
**"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。
实测踩过:用户连续两条反馈("收回去的时候闪一下/下半被截断")其实都是这个叠影造成的。
所以 `runIslandChecks` 开头把验证实例的岛做成**一眼可辨**:
`island.setAppearance({ accent: '#e91e8c', style: 'tinted' })`(洋红 = 验证实例;跑完不必还原,
验证用的是独立 profile),并且全程 `island.setTestInputPassthrough(true)` 让真实点击穿透。
**只改颜色、不要改停靠位置**:命中/几何断言都是按默认位置写的,把岛挪到角落会把
「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。
另外:冒烟会**真发通知**给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/
岛上会短暂出现几条「张老师 · *自检*」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`),
若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。
42. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟,
而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着,
他点到的是**冒烟这个窗口**:实测冒烟收到了人发出的 `collapse` / `mark-read`,把「点胶囊展开」
「点空白处收起」等用例整片弄红。开关只忽略**真实**输入:`cursorOverride`(`setHitTestCursor`)
一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」
这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。
37. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读
一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件
无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watch `route.query.date`
(`HomeworkView.vue`,与 `selectDate` 的 `router.replace` 形成环时靠"值相同就跳过"防死循环)。
同理,冒烟里"切到有作业的日期"的判据是**看板里没有 `.board-card`**,不是"没有 `.board-host`"
(空看板照样渲染容器,拿容器当判据兜底永远不会触发)。
38. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true`
(上课时段普通通知会被**正确地**暂存到下课、紧急通知不带确认会被 409 `URGENT_DURING_CLASS` 拒掉 ——
两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律**轮询**等待,
不要写死 sleep:`dismiss` 现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空
(「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。
39. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境):
① **后端起没起** —— 客户端配置指向 `127.0.0.1:4000`,后端不在就进离线模式、数据全空,
观感就是"软件坏了";② **托盘单实例锁** —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。
---
## 6. 代码风格
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
(写 `./foo.js`,即使源文件是 `foo.ts`)。`strict` 开启。
- **Prettier**(`.prettierrc.json`):单引号、`printWidth: 110`、`trailingComma: all`、`arrowParens: always`、
行尾 LF、缩进 2 空格。
- **ESLint**(`eslint.config.mjs`):`no-explicit-any` 为 warn;未使用变量为 warn,可用 `_` 前缀豁免;
`consistent-type-imports` 建议用 `import type`。`.cjs`(Electron 图标渲染、UI 冒烟)允许 `require`。
- **Vue**:视图命名 `XxxView.vue`,组件多单词;路由入口用 `meta.roles` 控制(如 `['ADMIN']`),
隐藏入口之外服务端也必须拦(前端隐藏 ≠ 权限)。
- **提交信息**:Conventional Commits + 模块 scope,描述用中文,可多 scope,例如
`fix(island): 胶囊"点不动"的根因——窗口必须始终可激活`、
`feat(teachers,classes,schedules,web): 教师录入(仅管理员)`。
- **改动交付形态 / 新增功能后**,同步更新 `README.md`(它兼作产品说明书与验收对照表)。
---
## 7. 本机环境已知坑(踩过,别再踩)
1. **系统代理会拦截 `127.0.0.1` 的自测请求。**
`Invoke-WebRequest http://127.0.0.1:4000/api/health` 会返回**别人的 404**(响应带 `Alt-Svc: h3=…`、空 body),
看起来像后端路由没挂上。实际是走了代理。自测请加 `-NoProxy`(`curl.exe` 用 `--noproxy '*'`)。
项目内的 Node 脚本(`verify:*`)直连,不受影响。
**同一个坑对 ClassIsland 插件同样成立**:.NET 的 `HttpClient` 默认跟随系统代理,
教室机器上表现为"填好令牌后立刻 HTTP 404"(其实是代理的错误页)。插件里
`ClassHelperClient` 用 `LocalAwareProxy` 对本地/内网地址强制直连,
`verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
2. **`db:deploy` 只在 `packages/server` 包里,根 `package.json` 没有这个脚本。**
根上跑 `pnpm db:deploy` 会报 `Command "db:deploy" not found`(还会附带一句莫名其妙的
`文件名、目录名或卷标语法不正确。`)。正确写法:`pnpm --filter @classhelper/server db:deploy`。
根上只有 `db:generate / db:migrate / db:seed / db:reset / db:studio`。
3. **`pnpm db:migrate`(= `prisma migrate dev`)在本机不可靠**:首次报 `Schema engine error:`(空消息),
数据库建好后再跑会**长时间挂起**(无输出,需手动结束进程)。日常用 `db:deploy`;
要写种子直接 `pnpm db:seed`,或 `node ./node_modules/tsx/dist/cli.mjs prisma/seed.ts`(cwd 为 `packages/server`)。
4. **先 `pnpm build:shared`,否则后面全崩**:`packages/shared/dist` 不存在时,`pnpm db:seed` 与任何
消费 `@classhelper/shared` 的进程都会 `ERR_MODULE_NOT_FOUND`(报错指向
`packages/server/node_modules/@classhelper/shared/dist/index.js`)。`pnpm dev*` / `build` / `typecheck`
脚本已内联 `build:shared`,单独跑 `db:seed` 时记得自己先构建。
5. **Electron 二进制要手动装**:`pnpm install` 实测**不会**下载它(`allowBuilds` 里虽然写了 `electron: true`,
但 postinstall 没跑;日志里只看到 esbuild / prisma)。缺失时 `pnpm dev:desktop` / `verify:desktop` /
`verify:web` / `dist:win` 全部失败。装法(本机已装好):
```bash
cd node_modules/.pnpm/electron@44.3.0/node_modules/electron
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ NODE_OPTIONS=--use-system-ca node install.js
```
6. **"AI 会话里启动 Electron GUI 会崩"是一个误诊 —— 真凶是下一条的 Low 完整性标签。**
2026-09-30 实测:把 Electron 发行目录的标签从 Low 改成 Medium 后,同一个 `electron.exe`
在 AI 会话里**正常启动**(窗口标题 `Electron`、userData 目录正常创建),
`pnpm verify:packaged` 也能完整跑完并出结果(86/90、77/78)。
⇒ `verify:desktop` / `verify:web` / `verify:packaged` / `dist:win` 在 AI 会话里**都能跑**;
一旦它们又"启动即崩",先按第 7 条查完整性标签,别急着甩锅给"会话环境"。
7. **交付物被打了 Low 完整性标签 ⇒ 客户端装不上、便捷版双击没反应、应用 0x80000003 秒崩。**
症状极易误判:`electron.exe --version` 都不打印就退出(0x80000003 STATUS_BREAKPOINT),
加 `--no-sandbox` 变成 0xC0000005,任何日志都写不出来(崩在日志初始化之前),
但 `ELECTRON_RUN_AS_NODE=1` 跑同一个 exe 完全正常、Edge 等系统 Chromium 也正常。
根因:**本仓库目录树被宿主沙箱打上了 `Mandatory Label\Low Mandatory Level:(OI)(CI)`(可继承)**,
于是仓库内构建出的一切都继承 Low 标签。后果是双重的:
- Windows 会把带 Low 标签的 exe **降级到 Low 完整性运行**:`setup.exe` 写不进 `%LOCALAPPDATA%`
(报"拒绝访问"→ 用户看到"装不上")、便捷版解不出 `%TEMP%`(→ 双击没反应);
- 即便装上了,落在 Low 标签目录里的应用文件也**读不了** —— Chromium 的 GPU/渲染子进程跑在
AppContainer(更低完整性)里,按"不能向上读"直接秒崩。
判据与修法(都是实测过的):
```bash
# 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签
icacls packages\desktop-client\release\win-unpacked\班级小助手.exe | findstr Mandatory
# 修法:把交付物改成 Medium。dist-win.mjs 收尾会自动做,手工补救用:
icacls packages\desktop-client\release /setintegritylevel Medium /T /C
# 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去
icacls packages\desktop-client\release /setintegritylevel "(OI)(CI)Low"
```
连带的三条经验:**归档内部的载荷标签无所谓**(装到仓库外得到的是默认 Medium 文件),
所以修标签必须在**打包完成之后**做,打包前改 appOutDir 反而会让 7za 读不到而失败;
**别把安装测试装进仓库树**(`install-test` 之类),那里的文件又会继承 Low;
**覆盖已存在的产物会被沙箱 ACL 拒绝**(`Can't open output file` / `拒绝访问`),
`dist-win.mjs` 因此会先清掉上一轮的 `*.exe / *.7z / *.blockmap` 再打包。
8. **.NET 8 SDK 已装(8.0.425)**,`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin` 都能跑
(2026-09-30 实测:`-t:Rebuild` 全量编译 8.8s,**1 个警告 0 个错误**;`.cipx` 打包成功,
包内 DLL 的 sha256 与 `bin/Release` 产物一致)。脚本会自动把
`NUGET_PACKAGES / TEMP / TMP / DOTNET_CLI_HOME` 指到仓库内 `.cache/`(`dotnet` 默认往 C 盘写,
C 盘紧张时会构建失败);手敲 `dotnet build` 时请自己带上这几个变量。
两个容易自我欺骗的地方:
- **光看输出会误判"编译通过"**:源码没变时 `dotnet build` 是**空转**(打印
`所有项目均是最新的,无法还原` + `已成功生成 / 0 警告 0 错误`),`CoreCompile` 根本没执行 ——
那行 `ClassHelper.ClassIslandPlugin -> …dll` 是拷贝回执,不是编译证据。
要证明"真的编译过",用 `node packages/classisland-plugin/scripts/build.mjs -t:Rebuild -v:n`
看有没有 `csc.dll` 调用,并核对 DLL 的 mtime 晚于所有 `.cs`。
- **那 1 个警告是预期的,别去"修"**:`AVLN3001: XAML resource
"…/BridgeSettingsPage.axaml" won't be reachable via runtime loader, as no public constructor was found`
—— 设置页只有 `BridgeSettingsPage(PluginSettings, BridgeService)` 这个 DI 构造函数、
没有公开无参构造,Avalonia 于是警告它无法走**运行时**加载器;本插件走的是编译期 XAML +
ClassIsland 的 DI 实例化,加个无参构造反而会把 DI 弄坏。
9. **`.cipx` 打包最后一步要 PowerShell**:SDK 的 `CreateCipx` 目标默认调 `pwsh`,
本机 PATH 里没有 pwsh 时会失败。构建脚本会探测并把结果透传给 MSBuild
(`-p:PowershellBinaryName=…`),直接跑 `dotnet build -p:CreateCipx=true` 则要自己加。
10. **仓库根 `.gitignore` 的第 42 行是 GBK 编码**(整文件 CRLF,只有那一行是 GBK 注释),
`apply_patch` 与 Prettier 都读不了它,按 UTF-8 读会看到 `\uFFFD`。要补忽略规则**只能用追加的方式**
(`printf 'xxx\n' >> .gitignore`,原有字节一个都别动),别试图整文件重写;临时性的可写
`.git/info/exclude`(本地生效、不入库)。
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore。)
11. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉;`release-server/` 同理)。
**它一度只在 ESLint 里被忽略、`.gitignore` 里漏了**(后者只有单数的 `release/` 和 `release-server/`):
归集一次客户端产物就有 225MB 的 EXE 变成未跟踪文件,`git add -A` 会直接把安装包提交入库。
2026-09-30 已在 `.gitignore` 末尾补上 `releases/`。
12. **ClassIsland 的数据根是 `D:\Classisland\data`**(**不是**旧文档写的 `F:\data`,F 盘已不存在):
- 插件 DLL 覆盖到 `D:\Classisland\data\Plugins\classhelper.classisland.bridge\`(**ClassIsland 运行时会锁住
DLL,必须先退出它再覆盖**:`Get-Process ClassIsland.Desktop | Stop-Process -Force`);
- 插件配置在 `D:\Classisland\data\Config\Plugins\<id>\Settings.json`,**ClassIsland 运行中改会被内存里的
副本随时回写覆盖**,要改请趁它退出时改;该文件里存着这台机器的服务器地址与设备令牌,**别覆盖**;
- 改完 DLL 或配置都要重启 ClassIsland,否则跑的还是旧副本。
13. **读 ClassIsland 控制台日志要用 GBK**:`.NET` 的控制台输出是**系统 ANSI(GBK)**编码,
用 `-Encoding UTF8` 读会变成乱码、**中文关键字一条都搜不到**(只能搜到 ASCII 的类名)。
正确姿势:`[System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::GetEncoding(936))`;
日志文件被运行中的进程占用时先 `Copy-Item` 一份再读。
14. **服务端改了代码要重启才生效**(`tsx src/index.ts` 不热载,`pnpm dev:server` 才有 watch)。
验证脚本报「某个断言突然不对」时,先确认后端跑的是不是最新代码 —— 这个坑踩过一次。
15. **后端启动时才发现 Web 产物**:`packages/web-admin/dist` 不存在就跳过静态托管,`http://127.0.0.1:4000/`
只会返回服务首页(文案「Web 管理端未随本服务托管」)。要跑 `verify:web`,先 `pnpm build` 再重启后端。
16. **打包版服务端有生产自检**:`NODE_ENV=production` 时拒绝 `dev.db`("生产环境仍在使用 dev.db")、
拒绝短 `JWT_SECRET` 与示例密钥;想用演示数据跑打包版就把库**复制**成 `data/classhelper.db` 再启动。
17. **makensis 建不了临时文件时,NSIS 那一步会失败(`dist:server` 与 `dist:win` 都会踩)。** 两种根因、
同一个排查动作 —— 先把 `TEMP` 指到仓库内一个**真实存在的 Windows 绝对路径**再重试:
- `pnpm dist:server` 报 `Internal compiler error #12345: error creating mmap the size of N`:模板用了
`SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件,盘紧就失败
(免安装目录其实已生成好,只是最后一步打不出安装包)。加 `--reuse-deps` 还能跳过依赖安装。
- `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**,
而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成
`D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。
`win-unpacked` 与 `release/*.nsis.7z` 此时都已生成,**只需重跑 electron-builder,不必重建前端**:
```bash
mkdir -p .cache/tmp
TEMP='D:\classhelper\.cache\tmp' TMP='D:\classhelper\.cache\tmp' \
node packages/desktop-client/scripts/dist-win.mjs
```
18. **依赖二进制下载需要 `NODE_OPTIONS=--use-system-ca`**(本机 TLS 中间人证书),否则报
`unable to verify the first certificate`。
19. **Prisma 版本锁死 7.10.0**:npm `latest` 已指向 8.x;生成器是 `prisma-client`,输出到
`src/generated/prisma`(gitignored),连接串在 `prisma.config.ts`,并且**必须走 driver adapter**
(`@prisma/adapter-libsql` + 预编译的 `@libsql/win32-x64-msvc`,避开 electron ABI 的原生编译坑)。
20. **服务端安装包里的 `.cmd` 提示是英文**:cmd.exe 按 GBK 解析脚本,中文会乱码,属有意为之
(中文在安装向导与 `README.txt`)。
21. **`pnpm format:check` 在 master 基线上本来就是失败的**(含 `tsconfig.base.json` 与 `scripts/*`),
不要拿它当门禁、也不要为了让它变绿去批量格式化。只检查你改过的文件:
`pnpm exec prettier --check <file>`。
**注意它会因为 CRLF 而"全面误报"**:`core.autocrlf=true` 让 git 里存 LF、工作区签出成 CRLF,
而 Prettier 配的是 `endOfLine: lf`,于是几乎每个文件都被标红。要判断是不是**你的**代码不合风格,
用 `sed 's/\r$//' <file> | pnpm exec prettier --check --stdin-filepath <file>`。
22. **写冒烟断言时别用"元素还在不在"判断 Element Plus 的弹框关没关。** `el-dialog` 关闭后
**DOM 会留在文档里**(Element Plus 只把 `.el-overlay` 置成 `display: none`),
`document.querySelector('.el-dialog')` 恒为真 —— `verify:desktop` / `verify:packaged` 里
「设置页『修改密码』对话框取消后未关闭」就是这么误报出来的。正确判据:
`getComputedStyle(dialog.closest('.el-overlay')).display !== 'none'`。
同理,断言岛体尺寸别写死"胶囊尺寸 + 留白":那一刻是胶囊还是卡片取决于前面用例留下的状态,
应改成**与形态无关的不变量**(如"窗口 = 卡片实测尺寸 + 2×阴影留白")。
### 已知脆弱用例(不是环境问题,别去"修环境")
| 用例 | 触发条件 |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) |
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
| 岛交互 / 岛动画 / 岛截图留档这几个用例 | **AI 会话里会间歇性失败**:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 |
---
## 8. 交付前检查
```bash
pnpm lint # ESLint(0 error 为底线)
pnpm typecheck # 全仓库类型检查
pnpm dev:server # 另开终端
pnpm verify:e2e # 后端端到端(2026-09-30 实测 173 项全过)
pnpm verify:classisland # ClassIsland 联动链路(实测 42 项)
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 68 项)
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
pnpm build && pnpm verify:web # Web 端真实点击回归(同上)
```
条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`、`ClassIsland 联动插件静态校验:N/N 项通过`),
文档不要写死容易过期的总数。
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
本机已装 ClassIsland 2.1.0.1(`D:\Classisland`)与 .NET 8 SDK(8.0.425),因此**真机验证是可行的**
(直接 `pnpm build:classisland-plugin` 编译,再按 §7 第 12 条部署):
```powershell
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
Get-Process ClassIsland.Desktop | Stop-Process -Force
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看日志里依次出现:
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
```
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 ClassIsland 的机器上实测"。
改动涉及打包/交付形态时,额外跑 `pnpm dist:all`(或 `dist:server` + `dist:win`),
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
开发产物通过不等于用户机器上的副本通过。
自测本地接口记得绕过代理(见 §7 第 1 条)。
+1134 -347
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -30,6 +30,11 @@ CORS_ORIGIN=*
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=admin123
# 各类账号的默认初始密码(新增/重置时使用)
DEFAULT_STUDENT_PASSWORD=123456
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
# 第 1 教学周的周一(用于"当前周次")
TERM_START_DATE=2026-08-31
+36
View File
@@ -0,0 +1,36 @@
# ---------------------------------------------------------------------------
# 班级小助手 · Docker 单容器(SQLite)环境变量
# 复制为 deploy/.env.sqlite 后填写:cp deploy/.env.sqlite.example deploy/.env.sqlite
# 配合 deploy/docker-compose.sqlite.yml 使用
# ---------------------------------------------------------------------------
# 服务端对外端口(宿主机)。注意与同机其他服务错开:80/443 常被反代占用
SERVER_PORT=4000
# 应用版本(镜像标签)
APP_VERSION=0.1.0
# JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词)
# 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=
# 令牌有效期
JWT_EXPIRES_IN=7d
# 允许的跨域来源;用域名访问时建议写成具体来源,例如 https://class.example.com
CORS_ORIGIN=*
# 首次启动创建的管理员账号(**部署后请立即登录修改密码**)
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=admin123
# 各类账号的默认初始密码(新增/重置时使用)
DEFAULT_STUDENT_PASSWORD=123456
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
# 第 1 教学周的周一(用于"当前周次")
TERM_START_DATE=2026-08-31
# 日志级别:debug | info | warn | error
LOG_LEVEL=info
+5
View File
@@ -33,7 +33,12 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
# 再拷贝源码并构建(只构建服务端与 Web 管理端,桌面客户端不参与)
COPY packages ./packages
COPY scripts ./scripts
# 必须先生成 Prisma Client:`pnpm install --ignore-scripts` 跳过了 postinstall,
# 而生成产物 `packages/server/src/generated/prisma` 是 gitignored 的(clone 下来没有),
# 少了这一步下面的 tsc 会报 "Cannot find module '../generated/prisma/client.js'"。
RUN pnpm build:shared \
&& pnpm --filter @classhelper/server run db:generate \
&& pnpm --filter @classhelper/server build \
&& pnpm --filter @classhelper/web-admin build
+55
View File
@@ -0,0 +1,55 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 服务端 systemd 单元(Linux)
#
# 这是**模板**:deploy/install-linux.sh 会把 __NODE__ / __DIR__ / __USER__ 替换成真实值后
# 安装到 /etc/systemd/system/classhelper.service。手动部署时直接改这三处即可:
#
# sudo sed -e 's#__NODE__#/usr/bin/node#' \
# -e 's#__DIR__#/opt/classhelper#' \
# -e 's#__USER__#classhelper#' \
# deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service
# sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
#
# 常用命令:
# systemctl status classhelper
# journalctl -u classhelper -f # 实时日志
# systemctl restart classhelper # 重启(改完 .env 需要)
# ---------------------------------------------------------------------------
[Unit]
Description=ClassHelper Server (班级小助手服务端)
Documentation=file://__DIR__/README.txt
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=__USER__
Group=__USER__
WorkingDirectory=__DIR__
# .env 由服务端自己从 WorkingDirectory 加载(工作目录即安装根目录)
ExecStart=__NODE__ server/dist/index.js
Environment=NODE_ENV=production
Restart=always
RestartSec=3
# Socket.IO 长连接较多:提高文件描述符上限
LimitNOFILE=65535
# 优雅退出:给在途请求与 websocket 收尾时间
KillSignal=SIGTERM
TimeoutStopSec=20
# 日志进 journald:journalctl -u classhelper
StandardOutput=journal
StandardError=journal
SyslogIdentifier=classhelper
# 基础加固(服务只需要读程序、写 data/logs)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=__DIR__/data __DIR__/logs
[Install]
WantedBy=multi-user.target
+56
View File
@@ -0,0 +1,56 @@
# 班级小助手 · Docker 单容器部署(SQLite + 数据卷)
#
# 适用:云服务器 / 与别的服务共用一台机器。相比 deploy/docker-compose.yml(MySQL 变体)
# 少跑一个数据库容器,内存占用小很多;数据落在 Docker 卷 classhelper-data 里。
#
# 使用:
# 1) cp deploy/.env.sqlite.example deploy/.env.sqlite 并填 JWT_SECRET / 管理员密码
# (生成密钥:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))")
# 2) docker compose -f deploy/docker-compose.sqlite.yml --env-file deploy/.env.sqlite up -d --build
# 3) 访问:http://<服务器IP>:${SERVER_PORT}/
#
# 首次启动(AUTO_MIGRATE=true)会自动执行 prisma/migrations 里的建表语句并创建管理员账号,
# 见 packages/server/src/lib/db-bootstrap.ts("Docker 挂载空数据卷"就是它的目标场景之一)。
#
# 备份:docker run --rm -v classhelper-sqlite_classhelper-data:/data -v "$PWD":/backup alpine \
# tar czf /backup/classhelper-db.tar.gz -C /data .
name: classhelper-sqlite
services:
server:
build:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
image: classhelper-server:${APP_VERSION:-0.1.0}
restart: unless-stopped
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4000
DATABASE_PROVIDER: sqlite
# 必须写绝对路径:容器里 serverRoot 是 /app/server,相对路径会解析到别处
DATABASE_URL: file:/app/data/classhelper.db
# 首启动自动建表 + 建管理员;生产自检会拒绝 dev.db 与过短的 JWT_SECRET
AUTO_MIGRATE: 'true'
STARTUP_DB_CHECK: 'true'
JWT_SECRET: ${JWT_SECRET:?请在 deploy/.env.sqlite 中设置(≥32 位随机串)}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
CORS_ORIGIN: ${CORS_ORIGIN:-*}
TRUST_PROXY: '1'
RATE_LIMIT_ENABLED: 'true'
LOG_LEVEL: ${LOG_LEVEL:-info}
TERM_START_DATE: ${TERM_START_DATE:-}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456}
DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456}
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
ports:
- '${SERVER_PORT:-4000}:4000'
volumes:
- classhelper-data:/app/data
volumes:
classhelper-data:
+3
View File
@@ -78,6 +78,9 @@ services:
TERM_START_DATE: ${TERM_START_DATE:-}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456}
DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456}
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
ports:
- '${SERVER_PORT:-4000}:4000'
depends_on:
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# 班级小助手 · 服务端 Linux 一键部署(systemd + SQLite,单机单实例)
#
# 干什么:
# 在 Linux 服务器上把服务端装成 systemd 服务,布局与已验证的 Windows 免安装目录完全一致:
# /opt/classhelper/
# ├── .env 全部配置(首次安装随机生成 JWT_SECRET)
# ├── server/dist 后端程序(入口 index.js)
# ├── server/prisma 迁移 SQL(AUTO_MIGRATE=true 时首启动自动执行)
# ├── web/ Web 管理端(由后端托管,单端口 4000)
# ├── node_modules/ 仅生产依赖(npm install --omit=dev)
# ├── data/ classhelper.db + server.pid
# └── logs/ 预留(日志默认进 journald)
#
# 前置条件:
# - 在**仓库根目录**已执行过构建:pnpm build:shared && pnpm --filter @classhelper/server build \
# && pnpm --filter @classhelper/web-admin build
# - 目标机装好 Node.js ≥ 20.19(推荐 22 LTS);本脚本不代装 Node
#
# 用法(在仓库根目录执行,需要 root):
# sudo bash deploy/install-linux.sh # 安装/升级,默认端口 4000、目录 /opt/classhelper
# sudo bash deploy/install-linux.sh --port 8080 # 换端口
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,不装 systemd(自己接管进程)
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node 版本/端口探针
# sudo bash deploy/install-linux.sh --uninstall # 停止并禁用服务(保留 data 与 .env)
#
# 升级:把新代码构建好后**再跑一次同样的命令**即可。脚本不会覆盖已有 .env(JWT 密钥与端口保持
# 不变,用户不用重新登录),只会补齐缺失项并覆盖程序文件。
#
# 注意:本脚本未在 Linux 实机验证过(开发机是 Windows,无 Docker/WSL 发行版)。
# 它复用的是**已验证**的两套布局:Windows 免安装目录(install/ 结构)与 deploy/Dockerfile
# 的 runtime 阶段(package.runtime.json + prisma/migrations + web/)。
# ---------------------------------------------------------------------------
set -euo pipefail
TARGET_DIR="/opt/classhelper"
SERVICE_USER="classhelper"
PORT="4000"
WITH_SERVICE=1
UNINSTALL=0
CHECK_ONLY=0
SERVICE_NAME="classhelper"
while [ $# -gt 0 ]; do
case "$1" in
--dir) TARGET_DIR="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--no-service) WITH_SERVICE=0; shift ;;
--check) CHECK_ONLY=1; shift ;;
--uninstall) UNINSTALL=1; shift ;;
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
*) echo "未知参数:$1(用 --help 看用法)" >&2; exit 2 ;;
esac
done
log() { printf '\033[32m[classhelper]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[classhelper]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[classhelper]\033[0m %s\n' "$*" >&2; exit 1; }
# 就绪探针:优先 curl(几乎每台 Linux 都有),否则退回 node。
# 注意不要用 `node -e "...process.exit()"` —— 在部分 Node 版本上于 promise 回调里硬退会触发
# libuv 断言崩溃(本机 Windows 实测退出码 -1073740791),改成设置 process.exitCode 让事件循环自然结束。
probe_ready() {
if command -v curl >/dev/null 2>&1; then
curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1
return $?
fi
"$NODE_BIN" -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>{process.exitCode=r.ok?0:1}).catch(()=>{process.exitCode=1})" >/dev/null 2>&1
}
[ "$(id -u)" = "0" ] || { [ "$CHECK_ONLY" = "1" ] || die "请用 root 执行:sudo bash deploy/install-linux.sh"; }
# ---------------------------------------------------------------- 卸载分支
if [ "$UNINSTALL" = "1" ]; then
if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then
systemctl disable --now "$SERVICE_NAME" || true
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
log "已停止并移除 systemd 服务 ${SERVICE_NAME}"
else
warn "没有找到 ${SERVICE_NAME}.service(可能没装成服务)"
fi
log "程序文件与数据保留在 ${TARGET_DIR};确认不需要后再手动删除"
exit 0
fi
# ---------------------------------------------------------------- 定位仓库与产物
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "$REPO_ROOT"
SERVER_DIST="packages/server/dist/index.js"
SERVER_PRISMA="packages/server/prisma"
WEB_DIST="packages/web-admin/dist/index.html"
SHARED_DIST="packages/shared/dist/index.js"
RUNTIME_PKG="deploy/package.runtime.json"
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG"; do
[ -e "$f" ] || die "缺少构建产物:$f
请先在仓库根目录执行:
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build"
done
[ -d "$SERVER_PRISMA/migrations" ] || die "缺少迁移文件目录:$SERVER_PRISMA/migrations"
# ---------------------------------------------------------------- Node 检查
NODE_BIN="$(command -v node || true)"
[ -n "$NODE_BIN" ] || die "未找到 node,请先安装 Node.js ≥ 20.19(推荐 22 LTS)"
NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]')"
NODE_MINOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[1]')"
if [ "$NODE_MAJOR" -lt 20 ] || { [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -lt 19 ]; }; then
die "Node 版本过低:$("$NODE_BIN" -v)(要求 ≥ 20.19)"
fi
NPM_BIN="$(command -v npm || true)"
[ -n "$NPM_BIN" ] || die "未找到 npm(随 Node 一起安装)"
log "Node $("$NODE_BIN" -v) → $NODE_BIN"
# ---------------------------------------------------------------- 只体检
if [ "$CHECK_ONLY" = "1" ]; then
echo
echo "构建产物:"
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG" "$SERVER_PRISMA/migrations"; do
printf ' %-42s %s\n' "$f" "OK"
done
printf ' %-42s %s\n' "迁移文件数量" "$(find "$SERVER_PRISMA/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
echo
echo "运行环境:"
printf ' %-42s %s\n' "node" "$("$NODE_BIN" -v)(要求 ≥ 20.19)"
printf ' %-42s %s\n' "npm" "$("$NPM_BIN" -v)"
printf ' %-42s %s\n' "systemd" "$(command -v systemctl >/dev/null 2>&1 && echo 可用 || echo '不可用(用 --no-service)')"
printf ' %-42s %s\n' "目标目录 ${TARGET_DIR}" "$([ -e "$TARGET_DIR" ] && echo '已存在(会就地升级,保留 .env)' || echo 尚未创建)"
printf ' %-42s %s\n' "端口 ${PORT} 上的既有服务" "$(probe_ready && echo '健康(就绪)' || echo '无响应')"
echo
log "--check 通过:构建产物与运行环境都满足,可以执行安装(sudo bash deploy/install-linux.sh)"
exit 0
fi
# ---------------------------------------------------------------- 用户与目录
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null \
|| useradd --system --shell /sbin/nologin "$SERVICE_USER"
log "已创建系统用户 ${SERVICE_USER}"
fi
mkdir -p "${TARGET_DIR}/server" "${TARGET_DIR}/web" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
log "安装目录:${TARGET_DIR}"
# ---------------------------------------------------------------- 拷贝产物
rm -rf "${TARGET_DIR}/server/dist"
cp -R "packages/server/dist" "${TARGET_DIR}/server/dist"
rm -rf "${TARGET_DIR}/server/prisma"
cp -R "$SERVER_PRISMA" "${TARGET_DIR}/server/prisma"
# 运行时不读 schema,但保留一份便于排查
[ -f packages/server/prisma/schema.prisma ] && cp packages/server/prisma/schema.prisma "${TARGET_DIR}/server/prisma/schema.prisma"
rm -rf "${TARGET_DIR}/web"
cp -R "packages/web-admin/dist" "${TARGET_DIR}/web"
cp "$RUNTIME_PKG" "${TARGET_DIR}/package.json"
cp -R deploy/classhelper.service "${TARGET_DIR}/classhelper.service.template"
# 生产依赖(与 Dockerfile 的 runtime 阶段同一份清单)
if [ ! -d "${TARGET_DIR}/node_modules" ] || [ "${FORCE_NPM_INSTALL:-0}" = "1" ]; then
log "安装生产依赖(npm install --omit=dev,约 1~3 分钟)..."
( cd "$TARGET_DIR" && "$NPM_BIN" install --omit=dev --no-audit --no-fund )
else
log "复用已有 node_modules(如需强制重装:FORCE_NPM_INSTALL=1 再跑一次)"
fi
# workspace 共享包(后端 import '@classhelper/shared'):拷进 node_modules 里
mkdir -p "${TARGET_DIR}/node_modules/@classhelper/shared"
rm -rf "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
cp -R "packages/shared/dist" "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
cp packages/shared/package.json "${TARGET_DIR}/node_modules/@classhelper/shared/package.json"
# ---------------------------------------------------------------- .env(已存在则不覆盖)
ENV_FILE="${TARGET_DIR}/.env"
if [ -f "$ENV_FILE" ]; then
log "检测到已有 .env:保留现有配置(含 JWT_SECRET),只补齐缺失项"
else
JWT_SECRET="$("$NODE_BIN" -e 'console.log(require("crypto").randomBytes(48).toString("hex"))')"
cat > "$ENV_FILE" <<EOF
# 班级小助手服务端(install-linux.sh 生成,请妥善保管)
# 修改后重启服务生效:systemctl restart ${SERVICE_NAME}
NODE_ENV=production
HOST=0.0.0.0
PORT=${PORT}
# 数据库:SQLite 文件放在安装根目录的 data 下(备份只需复制该目录)
DATABASE_PROVIDER=sqlite
DATABASE_URL="file:../data/classhelper.db"
# 空库首启动自动执行随包迁移并创建管理员
AUTO_MIGRATE=true
# 令牌签名密钥(随机生成;更换后所有用户需重新登录)
JWT_SECRET=${JWT_SECRET}
JWT_EXPIRES_IN=7d
# 允许的跨域来源:走域名/反代时建议改成具体来源,例如 https://class.example.com
CORS_ORIGIN=*
# 前面有 Nginx 等反向代理时设为 1,限流才会按真实客户端 IP 统计
TRUST_PROXY=1
RATE_LIMIT_ENABLED=true
STARTUP_DB_CHECK=true
PID_FILE=../data/server.pid
LOG_LEVEL=info
BCRYPT_ROUNDS=10
DEFAULT_STUDENT_PASSWORD=123456
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
TERM_START_DATE=
EOF
log "已生成 ${ENV_FILE}(含随机 JWT_SECRET)"
fi
# data/logs 必须可写;程序文件只读即可
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/node_modules"
chown "${SERVICE_USER}:${SERVICE_USER}" "$ENV_FILE"
chown -R root:root "${TARGET_DIR}/server" "${TARGET_DIR}/web"
chmod 600 "$ENV_FILE"
# ---------------------------------------------------------------- systemd
if [ "$WITH_SERVICE" = "1" ]; then
command -v systemctl >/dev/null 2>&1 || die "本机没有 systemd;请加 --no-service 自行托管进程"
sed -e "s#__NODE__#${NODE_BIN}#g" \
-e "s#__DIR__#${TARGET_DIR}#g" \
-e "s#__USER__#${SERVICE_USER}#g" \
deploy/classhelper.service > "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null
systemctl restart "$SERVICE_NAME"
log "等待服务就绪..."
ready=0
for _ in $(seq 1 60); do
if probe_ready; then
ready=1
break
fi
sleep 1
done
if [ "$ready" != "1" ]; then
warn "60 秒内没通过 /healthz,最近日志:"
journalctl -u "$SERVICE_NAME" -n 50 --no-pager || true
die "启动失败,请按上面的日志排查(常见:端口被占用、数据目录权限、JWT_SECRET 未生成)"
fi
log "服务已就绪 ✅"
else
log "已跳过 systemd(--no-service)。手动启动:"
echo " sudo -u ${SERVICE_USER} sh -c 'cd ${TARGET_DIR} && ${NODE_BIN} server/dist/index.js'"
fi
# ---------------------------------------------------------------- 结果
cat <<EOF
------------------------------------------------------------------
安装完成
访问管理端:http://<服务器IP>:${PORT}/
健康探针: http://<服务器IP>:${PORT}/healthz · /readyz
配置文件: ${ENV_FILE}
数据目录: ${TARGET_DIR}/data(备份只需复制这里)
查看日志: journalctl -u ${SERVICE_NAME} -f
重启服务: systemctl restart ${SERVICE_NAME}
首次启动会自动建表并创建管理员:admin / admin123(登录后请立即在右上角「修改密码」改掉)。
公网部署请在前面加 Nginx + HTTPS(deploy/nginx.conf 可直接用),并把 .env 里的
TRUST_PROXY 设为 1、CORS_ORIGIN 收敛到你的域名。
学生端客户端登录时填的「服务器地址」就是 https://你的域名(或 http://IP:${PORT})。
------------------------------------------------------------------
EOF
+1
View File
@@ -17,6 +17,7 @@
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"socket.io": "^4.8.3",
"xlsx": "0.18.5",
"zod": "^4.6.2"
}
}
+195 -20
View File
@@ -2,11 +2,12 @@
本文档覆盖三种上线形态,按适用场景选择:
| 形态 | 适用场景 | 产物 | 本机验证情况 |
| --------------------- | -------------------------------------------- | ------------------------------------------------------ | -------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **C. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(54 项端到端) |
| 形态 | 适用场景 | 产物 | 本机验证情况 |
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd + SQLite) | `deploy/install-linux.sh` + `deploy/classhelper.service` | ⚠️ 脚本已提供,本机无 Linux 未实测 |
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
---
@@ -124,9 +125,177 @@ docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
- 反向代理上启用 HTTPS:见 `deploy/nginx.conf`,并在服务端环境变量中设置
`TRUST_PROXY=1`、`CORS_ORIGIN=https://你的域名`。
### 2.1 单容器变体(SQLite,推荐给内存小的机器 / 与别的服务共用一台机器)
`deploy/docker-compose.sqlite.yml` 只跑**服务端一个容器**,不额外起 MySQL:数据落在 Docker 卷
`classhelper-data` 的 `/app/data/classhelper.db`,首次启动由 `AUTO_MIGRATE=true` 自动建表并创建管理员
(`packages/server/src/lib/db-bootstrap.ts` 的"空数据卷"分支)。
```bash
cp deploy/.env.sqlite.example deploy/.env.sqlite # 填 JWT_SECRET、管理员密码、端口
docker compose -f deploy/docker-compose.sqlite.yml --env-file deploy/.env.sqlite up -d --build
```
适用场景与注意点:
- 机器内存紧张(比如 2G 小机器上还跑着别的服务)时,比 MySQL 变体少一个数据库进程;
- 端口用 `SERVER_PORT` 指定,**别抢占 80/443**(那通常是同机反代占用的);
- 生产自检会拒绝 `dev.db` 与过短的 `JWT_SECRET`,因此 `DATABASE_URL` 必须是绝对路径且不含 `dev.db`;
- 备份就是整个卷:`docker run --rm -v classhelper-sqlite_classhelper-data:/data -v "$PWD":/backup alpine tar czf /backup/classhelper-db.tar.gz -C /data .`
> `deploy/Dockerfile` 的构建阶段会先跑 `pnpm --filter @classhelper/server run db:generate`:
> 生成产物 `packages/server/src/generated/prisma` 是 gitignored 的,而 `--ignore-scripts` 跳过了
> postinstall,少了这一步 `tsc` 会直接编译失败。
---
## 三、手动部署(形态 C)
## 三、Linux 原生部署(形态 C,systemd + SQLite)
适用:Ubuntu / Debian / CentOS / Rocky 等有 systemd 的服务器,单机跑一套(SQLite),
用 Nginx 挂 HTTPS 给校内网/公网访问。
### 3.1 一键脚本(推荐)
在**仓库根目录**先构建,然后执行脚本(需要 root):
```bash
# 1) 构建(Node ≥ 20.19,推荐 22 LTS;目标机没有 pnpm 时可在别的机器上构建后拷仓库过来)
corepack enable && corepack prepare pnpm@11.8.0 --activate
pnpm install
pnpm build:shared
pnpm --filter @classhelper/server build
pnpm --filter @classhelper/web-admin build
# 2) 安装成 systemd 服务
sudo bash deploy/install-linux.sh # 默认端口 4000、目录 /opt/classhelper
# 常用变体:
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node/端口探针
# sudo bash deploy/install-linux.sh --port 8080
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,自己托管进程
# sudo bash deploy/install-linux.sh --uninstall # 停服务并移除 unit(保留 data 与 .env)
```
> 建议先跑一次 `--check`:它只读地校验"构建产物齐不齐、Node 版本够不够、目标端口上有没有旧实例",
> 不需要 root,也不会改任何东西。输出示例见 `deploy/install-linux.sh` 头部注释。
脚本做的事:建系统用户 `classhelper` → 把后端产物、`prisma/migrations`、Web 管理端拷到
`/opt/classhelper` → `npm install --omit=dev`(清单就是 `deploy/package.runtime.json`,与
容器运行时同一份)→ 生成 `.env`(随机 `JWT_SECRET`、`AUTO_MIGRATE=true`)→ 装 systemd unit →
`enable --now` → 轮询 `/healthz` 就绪后打印地址与初始管理员。
安装后的目录(与已验证的 Windows 免安装目录同构,因此 `.env` 里的相对路径语义一致):
```
/opt/classhelper/
├── .env 全部配置(权限 600)
├── server/dist 后端程序(入口 index.js)
├── server/prisma 迁移 SQL(首启动自动执行)
├── web/ Web 管理端(后端托管,单端口)
├── node_modules/ 仅生产依赖
├── data/ classhelper.db + server.pid(备份只复制这里)
└── logs/
```
首次启动会自动建表并创建管理员 `admin / admin123`(**登录后立刻改密码**)。
### 3.2 手动部署(不想用脚本 / 没有 systemd)
等价的手工步骤,便于排查:
```bash
sudo useradd --system --shell /usr/sbin/nologin classhelper
sudo mkdir -p /opt/classhelper/{server,web,data,logs}
# 程序产物
sudo cp -R packages/server/dist /opt/classhelper/server/dist
sudo cp -R packages/server/prisma /opt/classhelper/server/prisma
sudo cp -R packages/web-admin/dist /opt/classhelper/web
sudo cp deploy/package.runtime.json /opt/classhelper/package.json
# 生产依赖 + 工作区共享包
cd /opt/classhelper && sudo npm install --omit=dev --no-audit --no-fund
sudo mkdir -p /opt/classhelper/node_modules/@classhelper/shared
sudo cp -R packages/shared/dist /opt/classhelper/node_modules/@classhelper/shared/dist
sudo cp packages/shared/package.json /opt/classhelper/node_modules/@classhelper/shared/package.json
# 配置(参考脚本生成的 .env,务必自己生成 JWT_SECRET)
sudo tee /opt/classhelper/.env >/dev/null <<'EOF'
NODE_ENV=production
HOST=0.0.0.0
PORT=4000
DATABASE_PROVIDER=sqlite
DATABASE_URL="file:../data/classhelper.db"
AUTO_MIGRATE=true
JWT_SECRET=换成 openssl rand -hex 48 的输出
JWT_EXPIRES_IN=7d
CORS_ORIGIN=https://class.example.com
TRUST_PROXY=1
RATE_LIMIT_ENABLED=true
STARTUP_DB_CHECK=true
PID_FILE=../data/server.pid
LOG_LEVEL=info
EOF
sudo chown -R classhelper:classhelper /opt/classhelper/{data,logs,node_modules} /opt/classhelper/.env
sudo chmod 600 /opt/classhelper/.env
# systemd
sudo sed -e 's#__NODE__#/usr/bin/node#' -e 's#__DIR__#/opt/classhelper#' -e 's#__USER__#classhelper#' \
deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service
sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
systemctl status classhelper --no-pager
```
也可以用 PM2 之类托管(`pm2 start /opt/classhelper/server/dist/index.js --name classhelper`),
但 systemd 更省心:`Restart=always`、日志进 journald、开机自启一次配好。
### 3.3 Nginx + HTTPS
```bash
sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
sudo nano /etc/nginx/conf.d/classhelper.conf # 改 server_name 与证书路径
sudo nginx -t && sudo systemctl reload nginx
```
`deploy/nginx.conf` 已经处理了最容易踩的两点:`/socket.io/` 的 WebSocket 升级头,
以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合 `.env` 里 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。
用 certbot 签证书:
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d class.example.com
```
### 3.4 升级 / 备份 / 排障
升级(不会动 `.env`,JWT 密钥不变,学生端不用重新登录):
```bash
git pull && pnpm install
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build
sudo bash deploy/install-linux.sh # 覆盖程序文件 + 自动补迁移(AUTO_MIGRATE)
journalctl -u classhelper -n 30 --no-pager
```
| 事项 | 命令 |
| -------- | ---------------------------------------------------------------------------------------------------------- |
| 备份 | `systemctl stop classhelper && cp -a /opt/classhelper/data /root/classhelper-data-$(date +%F)`(然后启动) |
| 恢复 | 停服务 → 用备份覆盖 `data/` → 启动 |
| 看日志 | `journalctl -u classhelper -f` |
| 重启 | `systemctl restart classhelper`(改 `.env` 后必须重启) |
| 探针 | `curl -s localhost:4000/healthz` / `/readyz`(就绪 200,数据库不可用 503) |
| 换端口 | 改 `.env` 的 `PORT` → 重启;同时改 Nginx 的 `upstream` |
| 换数据库 | 见 `docs/mysql.md`:切 provider + 装 `@prisma/adapter-mariadb` + `npx prisma migrate deploy` |
> 注意:本机(开发环境)是 Windows 且无 Docker/WSL 发行版,因此 **3.1 的脚本没有在 Linux 实机跑过**。
> 它复用的两套东西都已验证:Windows 免安装目录(同样的目录结构、`.env` 相对路径与
> `AUTO_MIGRATE` 首启动行为)和 `deploy/Dockerfile` 的 runtime 阶段(同样的依赖清单与产物布局)。
> 首次在服务器上执行后请按 3.4 的探针 + Web 登录 + `pnpm verify:e2e`(把 `VERIFY_BASE_URL` 指向服务器)确认。
---
## 四、手动部署(形态 D)
```bash
pnpm install
@@ -140,7 +309,7 @@ pnpm dist:server -- --reuse-deps # 得到 release-server/classhelper-server
```cmd
start.cmd :: Windows
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node)
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node,见上面的形态 C)
```
数据库初始化二选一:
@@ -150,7 +319,7 @@ start.cmd :: Windows
---
## 四、环境变量清单
## 五、环境变量清单
| 变量 | 默认 | 说明 |
| --------------------------------------------------- | ---------------------- | --------------------------------------------------------- |
@@ -168,15 +337,17 @@ start.cmd :: Windows
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增学生的默认密码 |
| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增/重置学生账号的默认密码 |
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 |
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
---
## 五、运维
## 六、运维
### 5.1 探针与监控
### 6.1 探针与监控
| 端点 | 用途 |
| ------------- | ---------------------------------------------------- |
@@ -184,22 +355,22 @@ start.cmd :: Windows
| `/readyz` | 就绪探针:数据库可连才 200,否则 503(负载均衡摘流) |
| `/api/health` | 详情:环境、数据库、已挂载模块、运行时长 |
### 5.2 日志
### 6.2 日志
- 输出到 stdout/stderr,安装包形态由启停脚本重定向到 `logs\server.log`(UTF-8)。
- 访问日志含方法、路径、状态码与耗时;5xx 记 error、4xx 记 warn。
- 容器形态建议交给 Docker 日志驱动或采集到 ELK/Loki。
### 5.3 备份与恢复
### 6.3 备份与恢复
| 形态 | 备份 | 恢复 |
| ------ | ------------------------------------------------------ | ------------------------ |
| SQLite | 停止服务后复制 `data/classhelper.db` | 替换该文件后启动 |
| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` |
| 形态 | 备份 | 恢复 |
| ------ | ---------------------------------------------------------------------- | ------------------------ |
| SQLite | 停止服务后复制 `data/classhelper.db`(Linux:`/opt/classhelper/data`) | 替换该文件后启动 |
| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` |
建议:每日自动备份 + 保留 7 份;变更 `JWT_SECRET` 会导致所有用户重新登录,请勿在无必要时更换。
### 5.4 安全清单(上线前逐项确认)
### 6.4 安全清单(上线前逐项确认)
- [ ] 修改 `admin` 与教师账号的默认密码
- [ ] `JWT_SECRET` 使用随机值(≥48 字节),且妥善保管
@@ -212,7 +383,7 @@ start.cmd :: Windows
---
## 六、故障排查
## 七、故障排查
| 现象 | 排查方向 |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -226,10 +397,14 @@ start.cmd :: Windows
| 升级后需要重新登录 | 说明 `.env` 被覆盖(JWT 密钥变化);覆盖安装应保留 `.env`,请检查安装目录权限 |
| 报 `does not provide an export named ...` | 安装包内的 `node_modules/@classhelper/shared/dist` 是旧构建(打包机没有先构建 shared)。重新执行 `pnpm dist:server`,该脚本会先构建 shared/server/web 并校验产物新鲜度 |
| 报 `node.exe` 不是有效应用 / 服务启动即退出 | 安装包内置的 `node.exe` 被误打成 Electron 可执行文件(打包机在 Electron 宿主里跑 pnpm 时会取到 `process.execPath`)。已由 `scripts/lib/node-runtime.mjs` 修正;可用 `install\node.exe -v` 确认能打印 Node 版本 |
| **Linux**:`systemctl start` 后立刻退出 | `journalctl -u classhelper -n 50`;常见原因:`.env` 里 `JWT_SECRET` 为空/太短、`node` 路径在 unit 里不对(脚本用 `command -v node` 的结果)、`data/` 不可写(`chown classhelper:classhelper`) |
| **Linux**:403/500 且日志提示 `EACCES` | `ProtectSystem=full` 下服务只能写 `ReadWritePaths`;若自定义了数据目录,记得同步改 unit 里的这两项 |
| **Linux**:`npm install` 报网络/代理错误 | 目标机需要能访问 npm registry;离线环境可在别的机器 `npm install --omit=dev` 后把整个 `node_modules` 一起拷过去 |
| **Linux**:SQLite 提示 `database is locked` | 同一数据库文件只允许一个服务实例;确认没有重复启动(`systemctl status` + `pgrep -af 'server/dist/index.js'`),需要并发就换 MySQL |
---
## 七、性能与容量参考
## 八、性能与容量参考
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 111 KiB

+593
View File
@@ -0,0 +1,593 @@
# WinIsland 视觉设计系统(Design Token 规格)
来源:`D:\classhelper\.cache\winisland-ref\WinIsland-master`(只读 checkout)。
所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。
关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**,
下文数值默认 scale = 1.0。文字颜色在渲染入口被硬编码为 `Color::WHITE`(`src/core/render.rs`
`draw_expanded_layer` / `draw_compact_layer` 传 `text_color: Color::WHITE`, `text_color_sec: Color::WHITE`),
所有"次级/次要"文字靠 **alpha** 或 **灰度通道值** 表达,而不是换颜色。
---
## 1. 几何(几何 token)
### 1.1 两个状态的尺寸
| 项 | compact / mini | expanded | 来源 |
| -------- | ------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- |
| 宽 | `base_width = 120.0` | `expanded_width = 360.0` | `src/core/config.rs` `AppConfig::default()` |
| 高 | `base_height = 27.0` | `expanded_height = 200.0` | 同上 |
| 圆角 r | `h / 2`(=13.5)→ 胶囊/体育场形 | `min(48*scale, w/2, h/2)` = **48.0** | `src/window/app/frame.rs` `default_target_r`;`src/utils/shape.rs` `expanded_island_radius()` |
| 全局缩放 | `compact_scale = 1.0` | `expanded_scale = 1.0` | `src/core/config.rs` |
圆角函数原文(`src/utils/shape.rs`):
```rust
pub(crate) fn expanded_island_radius(w: f32, h: f32, scale: f32) -> f32 {
(48.0 * scale).min(w / 2.0).min(h / 2.0).max(0.0)
}
```
compact 圆角:`IslandSprings::new` 里 `r = (base_height * compact_scale) / 2.0`(`src/window/app.rs`);
展开时 `default_target_r = compact_content_h / 2.0`(`src/window/app/frame.rs` `update_spring_targets`)。
### 1.2 compact 宽度的动态规则(不是固定 120)
`src/window/app/layout.rs` `compute_lyric_target_width()`:
- 无媒体 / 不在播放态:宽 = `base_width` = **120**
- 有媒体但无歌词:宽 = `base_width + 35.0` = **155**
- 有歌词(不滚动):`w = 60.0 + 文本宽`,再 `clamp(base_width+35, MAX_LYRIC_WIDTH)`
- `min_w = base_width + 35.0`、`MAX_LYRIC_WIDTH = 700.0`(`src/core/config.rs`)
- 有歌词(滚动模式,`lyrics_scroll = true`):`natural_w = 60.0 + text_w`,超过 `lyrics_scroll_max_width`(默认
`300.0`,`default_lyrics_scroll_max_width`)则固定为该上限;内部可用文本宽 = `(fixed_w - 59.0) * compact_scale`
- 二级歌词存在时:高度 = `max(base_height*scale, mini_lyric_pair_height)`,
其中 `lyric_pair_height = 主字号*(1 + 0.85 + 0.18) + 8*scale`
(`SECONDARY_LYRIC_SCALE = 0.85`、`LYRIC_PAIR_GAP_SCALE = 0.18` @ `src/core/render/mini.rs`)
→ 默认字号 12 时 = 12×2.03 + 8 = **32.36 px 高**,r ≈ 16.18
### 1.3 挂件(compact widget)导致的最小宽度
`src/ui/widget/compact/mod.rs`:
- `CONTENT_EDGE_INSET = 9.0`、`CONTENT_GAP = 7.0`
- `widget_width()`:`Time = 48.0`、`ResourceUsage = 132.0`
- 三组对齐宽度:`aligned_layout_width = 9*2 + left + center + right + 7*(占用组数-1)`
- 单侧扩展宽:`extension_width = CONTENT_EDGE_INSET + strip_width + CONTENT_GAP`
(例:左 Time → 9+48+7 = **64**;ResourceUsage → 9+132+7 = **148**)
- 最终 compact 宽 = `max(布局宽, base_width)`
### 1.4 compact overlay(通知 / 音量)会替换 base 尺寸
| overlay | 宽 | 高 | 圆角 | 来源 |
| ------- | ------------------------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
| 通知 | `max(base_width, 330.0) * scale` = **330** | `max(base_height, 82.0) * scale` = **82** | `h/2` = **41** | `src/ui/compact/notification.rs` `target_size()`;r 取自 `src/window/app/frame.rs`(`size.height/2.0`) |
| 音量 | `(base_width + 72.0) * scale` = **192** | `(base_height + 10.0) * scale` = **37** | `h/2` = **18.5** | `src/ui/compact/volume.rs` `target_size()` |
### 1.5 隐藏态尺寸
- `hidden_width` 默认 `5.0`(`default_hidden_width` @ `src/core/config.rs`),
- `MIN_HIDDEN_WIDTH = 0.0`、`MAX_HIDDEN_WIDTH = 400.0`
- 隐藏时高度收缩到 `hidden_width * compact_scale`,并保留 `hidden_reveal_h = 1.0`(1px 可点击边);
位移距离 `hide_distance = concealed_height + TOP_OFFSET`(`src/window/app/layout.rs`)
### 1.6 锚点(两种状态之间什么不动)
定位全部来自 `src/window/app/layout.rs` `compute_island_layout()` / `compute_window_position()`:
- **顶部居中停靠(默认)**:`island_y = TOP_OFFSET = 10.0`(**顶边固定**),
`offset_x = (os_w - w) / 2.0`(**水平中心固定**)
- **底部停靠**:`island_y = os_h - TOP_OFFSET - h`(**底边固定**,向上生长)
- **左/右停靠**:`offset_x = TOP_OFFSET` 或 `os_w - TOP_OFFSET - w`(对应侧边固定)
- `TOP_OFFSET: i32 = 10`(`src/core/config.rs`)= 岛顶边距屏幕顶 10 px
- 状态切换由三个弹簧同时插值:`w/h/r` 参数为 `stiffness = 0.10, damping = 0.68`(`src/window/app/frame.rs`),
页面横移 `view` 用 `0.12 / 0.68`;隐藏用 `(0.12, 0.70)` 隐藏 / `(0.08, 0.78)` 显示
- **compact 内容不跟随变大的岛做垂直居中重排**:mini 内容画在 `stable_island_y`(= 顶边)加上 `base_h` 带内居中
(`src/core/render/mini.rs`:封面 `y = stable_offset_y + (base_h - size)/2`,歌词 `center_y = stable_offset_y + base_h/2`)。
效果=岛向下长大,胶囊内容始终贴在顶部那条带里。
- 交叉淡出:`expanded_alpha = expansion_progress² * (1-hide_progress)`,
`mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1-hide_progress)`
(`MINI_FADE_RATE = 1.5` @ `src/core/render.rs`)→ 即进度 0~0.667 之间完成交叉淡出
### 1.7 有没有"挖孔/刘海"造型?
**没有。** 代码里没有 `SetWindowRgn` / `CreateRoundRectRgn` / notch / cutout 相关调用(已全仓 grep 确认),
也没有"顶部贴边 + 上方两角变直角"的特例分支。造型是:
- 一个**悬浮胶囊**(compact,r = h/2),**距屏幕顶 10 px**;
- 展开后是**四角一致的连续超椭圆**(squircle),r 最大 48。
圆角实现不是普通圆弧,而是 Flutter `RoundedSuperellipse` 的移植(`src/utils/shape.rs`,
注释标明 "Rounded-superellipse geometry adapted from Flutter under the BSD-3-Clause license"):
- `superellipse_parameters()`:11 项查表 `PARAMETERS`(如 `(2.0, 1.13276676) … (6.43023796, 2.98020421)`),
ratio > 5 时线性外推 `exponent = 6.43023796 + 1.559599389*(ratio-5.0)`、`factor = 2.98020421 + 0.522807185*(ratio-5.0)`
- `Octant::new()`:13 项 `BEZIER_FACTORS`(`(0.7078, 8.3194) … (0.9705, 1.2880)`),用 conic + cubic 拼四分之一角
- 路径缓存 `PATH_CACHE_CAPACITY = 64`
- **Web 复刻提示**:CSS `border-radius` 是正圆角,观感会偏"软";要还原请用 SVG path 或
`corner-shape: squircle`(若可用)/ `clip-path` 近似。**未找到**等价的简单圆角公式——真实几何就是上面那张表。
- **本仓库的落地实现**(`packages/desktop-client/src/island/squircle.ts`):不查表,用单参数超椭圆近似
`point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`,`p = 2/n`、`n = 4.2`,
四角分别以 `TR(w-r,r) → BR(w-r,h-r) → BL(r,h-r) → TL(r,r)`、起始角 `-π/2, 0, π/2, π` 各扫 `π/2`,
每角 14 段。**四个角必须各用自己的圆心 + 角度参数**:早期版本四个角共用同一个"偏移向量",
左上/右下被切成缺一块(用户反馈的"左上角和右下角怎么缺一块 / 圆角边缘割裂"),
现已由 `verify:desktop` 的「连续圆角四角一致」断言(四角沿对角线边界步进两两一致 + 路径绕行 \|winding\|≈2π)长期守住。
### 1.8 窗口留白 / 裁切
- `PADDING: f32 = 80.0`(`src/core/config.rs`);窗口尺寸 = `(岛宽 + 80, 岛高 + 80)`
(`src/window/app/layout.rs` `required_window_size()`)→ **岛四周各 40 px 透明留白**,供阴影/动画使用
- 绘制时 `canvas.clip_path(island_path, ClipOp::Intersect)`,所有内容裁在岛形内;
1px 描边在 `canvas.restore()` **之后**绘制(`src/core/render.rs` `draw_island()`)
- 挂件网格额外内缩,避免内容进圆角(`src/ui/widget/expanded/mod.rs` `widget_grid_layout`):
```rust
corner_inset = radius * (1.0 - FRAC_1_SQRT_2); // 48 * 0.29289 = 14.06
inset = (corner_inset + 8.0*scale).max(24.0*scale); // = 24.0(scale=1)
gap = 7.0 * scale;
```
### 1.9 expanded 内容网格
- `WIDGET_GRID_COLS = 6`、`WIDGET_GRID_ROWS = 3`、`WIDGET_GRID_SLOTS = 18`(`src/core/config.rs`)
- 槽位宽 = `(inner_w - 7*5) / 6`,高 = `(inner_h - 7*2) / 3`;
用 360×200、inset 24 计算:inner = 312×152 → **slot ≈ 46.17 × 46.0**(`src/ui/widget/expanded/mod.rs`)
- 组件占格 `WidgetKind::span()`:`Clock = (2,1)`、`ResourceUsage = (2,1)`、`Calendar = (2,2)`、`Settings = (1,1)`
(`src/core/config.rs`)
---
## 2. 配色(颜色 token)
### 2.1 岛背景(`src/core/render/background.rs` `draw_background()`)
| 样式 | 颜色值 | 说明 |
| --------------------------- | -------------------------------------------------------------- | -------------------- |
| `default`(`SOLID_STYLE`) | `Color::BLACK` = **#000000**,完全不透明 | `bg_color` |
| `glass` + host backdrop | `Color::from_argb(150, 10, 10, 14)` = **rgba(10,10,14,0.588)** | `draw_host_glass()` |
| `glass` 无 backdrop(回退) | `Color::from_argb(205, 32, 32, 36)` = **rgba(32,32,36,0.804)** | `fallback_color` |
| `dynamic` 底色 | `Color::from_rgb(32, 32, 36)` = **#202024**,不透明 | `draw_effect_base()` |
| `dynamic` 叠加层 | `Color::from_argb(120, 20, 20, 24)` = **rgba(20,20,24,0.471)** | 盖在旋转模糊封面之上 |
| `dynamic` 无封面回退 | 同 glass 分支(150/10,10,14 或 205/32,32,36) | |
`dynamic` 封面动效参数(同一函数):
- 集显:`rotate_speed = 0.015 rad/s, drift_speed_x = 0.075, drift_speed_y = 0.06, drift_amp_x = 10.0, drift_amp_y = 7.5`
- 独显:`rotate_speed = 0.03, drift_speed_x = 0.15, drift_speed_y = 0.12, drift_amp_x = 20.0, drift_amp_y = 15.0`
- 旋转矩形边长 `side_len = diagonal * 1.3`
**亚克力/backdrop 真实实现**(`src/window/d3d.rs` + `src/utils/win32.rs`):
Windows 11 `DWMWA_USE_HOSTBACKDROPBRUSH`,失败时退回 `SetWindowCompositionAttribute`
(`AccentPolicy { state: 5, flags: 0, gradient_color: 0 }`,state 5 = host backdrop);
再用 `Compositor::CreateHostBackdropBrush()` + `RoundedRectangleGeometry` 裁圆角
(`HOST_BACKDROP_INSET = 1.0`,几何 = `w-2, h-2, radius-1`)。
**未找到**任何模糊半径数值——模糊由系统 HostBackdropBrush 决定。
### 2.2 描边(`src/core/render.rs` `draw_island_border()`)
```rust
BORDER_WIDTH = 1.0; BORDER_INSET = BORDER_WIDTH / 2.0; // 0.5
SOLID_STYLE = "default"; SOLID_BORDER_ALPHA = 30; EFFECT_BORDER_ALPHA = 40;
```
- 颜色 = **纯白**,alpha:
- `default` 样式:`rgba(255,255,255, 30/255=0.118)`,**并再乘 `(1 - expansion_progress)`**(展开后描边消失)
- `glass` / `dynamic`:`rgba(255,255,255, 40/255=0.157)`,不随展开衰减
- 描边矩形 = `(x+0.5, y+0.5, w-1, h-1)`,圆角 `r-0.5`
### 2.3 阴影(`src/core/render.rs` `draw_expanded_shadow()`,仅展开时)
- 颜色 `Color::from_argb((28.0 * opacity) as u8, 0,0,0)` = **rgba(0,0,0,0.11)**
- `opacity = expansion_progress² * (1 - hide_progress)`,`opacity <= 0.01` 直接跳过
- 偏移 `offset_y = 2.0 * scale`;模糊 `sigma = min(3.0*scale, margin/3.0)`(margin = 到画布边界的余量)
- 用 `ClipOp::Difference` 只画在岛形之外
- **未找到内阴影**(没有任何 inner shadow 实现)
### 2.4 分隔线
| 位置 | 颜色 | 尺寸 | 来源 |
| -------------------- | -------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------- |
| compact 挂件之间 | `rgba(255,255,255, 0.14*alpha)` | 宽 `max(scale,1)`,从 `top+25%h` 到 `bottom-25%h` | `src/ui/widget/compact/mod.rs` `draw_separator()` |
| compact 资源占用内部 | `rgba(255,255,255, 0.10*alpha)` | 宽 `max(0.75*scale, 0.75)`,半高 `DIVIDER_HALF_HEIGHT = 6.0*scale` | `src/ui/widget/compact/resource_usage.rs` |
| expanded 资源环分隔 | `rgba(白, 0.09*alpha)` | 宽 `scale.max(0.75)`,从 `y+0.2h` 到 `y+0.8h` | `src/ui/widget/expanded/resource_usage.rs` |
| 设置窗口分隔线 | `rgba(255,255,255,26)` 暗 / `rgba(20,28,40,18)` 亮 | — | `src/utils/color.rs` |
### 2.5 文字颜色与不透明度分层
主文字一律 **#FFFFFF**;次级用 alpha:
| 元素 | 有效颜色 | 来源 |
| ------------------ | ------------------------------------------------------------ | ---------------------------------------------------- |
| 通知 app 名 | `rgba(255,255,255, 0.65*alpha)` | `src/ui/compact/notification.rs` |
| 通知标题 | `rgba(255,255,255, alpha)` | 同上 |
| 通知详情 | `rgba(255,255,255, 0.72*alpha)` | 同上 |
| 音量标签 | `rgba(255,255,255, 0.9*alpha)` | `src/ui/compact/volume.rs` |
| expanded 歌手 | `rgba(白, alpha*0.6)` | `src/ui/expanded/music_view.rs` `draw_track_text()` |
| 迷你歌词主行 | 白 alpha | `src/core/render/mini.rs` |
| 迷你歌词次行 | `rgb(176,176,176)`(`SECONDARY_LYRIC_CHANNEL = 176`) | 同上 |
| 逐字歌词"未唱"部分 | `rgb(190,190,190)`(`PENDING_LYRIC_CHANNEL = 190`) | 同上 |
| 进度条时间 | 白,`PROGRESS_TIME_IDLE_ALPHA = 0.5` → hover 1.0(线性插值) | `src/ui/expanded/music_view.rs` |
| 挂件时钟 | 白 alpha(bold) | `src/ui/widget/expanded/time.rs` |
| 挂件日历 月 | `0.78*alpha` | `src/ui/widget/expanded/calendar.rs` |
| 挂件日历 日 | `1.0*alpha` | 同上 |
| 挂件日历 星期 | `0.62*alpha` | 同上 |
| 挂件资源 数值 | `1.0*alpha`(bold) | `src/ui/widget/expanded/resource_usage.rs` |
| 挂件资源 标签 | `0.58*alpha` | 同上 |
| 封面占位块 | `rgba(白, 0.15*alpha)` | `src/ui/expanded/music_view.rs` `draw_placeholder()` |
### 2.6 强调色 / 状态色
岛内(`src/ui/widget/resource_usage.rs`):
```rust
CPU_COLOR = rgb(50, 190, 246) // #32BEF6
RAM_COLOR = rgb(175, 82, 222) // #AF52DE
WARNING_COLOR = rgb(255, 159, 10) // #FF9F0A
CRITICAL_COLOR = rgb(255, 69, 58) // #FF453A
```
阈值曲线 `usage_color(base, usage)`:
- `usage <= 0.75` → base
- `0.75 < usage <= 0.9` → base ↔ WARNING 线性混合,`t = (usage-0.75)/0.15`
- `0.9 < usage` → WARNING ↔ CRITICAL 线性混合,`t = (usage-0.9)/0.1`
采样间隔 `SAMPLE_INTERVAL = 1s`;无数值时显示 `—`。
进度条/音量条(`src/ui/expanded/music_view.rs`、`src/ui/compact/volume.rs`):
```rust
PROGRESS_TRACK_ALPHA = 0.15; // 轨道 rgba(白, 0.15*alpha)
PROGRESS_TIME_IDLE_ALPHA = 0.5; // 时间文字静止透明度
// 音量轨道 rgba(255,255,255, 0.28*alpha),填充纯白 alpha
```
音量的"填充"就是**白色**(不是强调色),图标仍是白色。
可视化条颜色**来自专辑封面**(`src/ui/expanded/music_view/palette.rs`):
- 把封面画到 8×8 的 GPU surface(`PALETTE_SAMPLE_SIZE = 8`)读回求平均 RGB
- `primary = brighten(avg, 1.3)`、`secondary = brighten(avg, 1.5)`
- `brighten` 用 BT.601 亮度 `0.299R + 0.587G + 0.114B`;若 < 80 则整体加 `80 - brightness`(抬到最低亮度 80)
- 调色板 = `[primary, secondary, primary]`,缓存上限 50 条
- 无封面默认值(`default_media_palette`):`rgb(180,180,180)` + `rgb(100,100,100)`
- 采样完全失败的兜底:`rgb(200,200,200)`
### 2.7 不同状态是否不同配色?
- **同一套**:底 + 白字 + alpha 分层;通知 / 音量 / 音乐 / 挂件没有各自的独立配色,
视觉区分靠 **尺寸(330×82 / 192×37 / 120×27)** 与 **内容排版**,而不是换色。
- 差异只在两处:**可视化条用封面取色**(2.6),**资源占用用 CPU/RAM/警告/危险四色**(2.6)。
- 岛背景三样式(default/glass/dynamic)才是真正的配色分支(2.1)。
### 2.8 设置窗口色板(另一套,`src/utils/color.rs`,供参考)
暗色主题 `dark_settings_theme()`:
```
win_bg #1C1C1E sidebar_bg #242426 group_bg #2C2C2E card_highlight #48484A
accent #0A84FF text_pri #F5F5F7 text_sec #AEAEB2 disabled #636366
danger #E6372D toggle_on #30D158 toggle_off #636366
sidebar_hover rgba(255,255,255,20) separator rgba(255,255,255,26)
popup_bg #323234 popup_border rgba(255,255,255,40) popup_shadow rgba(0,0,0,60)
popup_separator rgba(255,255,255,30) control_bg #3A3A3C control_hover #48484A
control_disabled #303032 control_border rgba(255,255,255,36)
group_border rgba(255,255,255,24) shadow rgba(0,0,0,45) scrollbar rgba(255,255,255,60)
```
亮色主题 `light_settings_theme()`:
```
win_bg #F4F5F7 sidebar_bg #FAFAFC group_bg #FFFFFF card_highlight #EFF2F7
text_pri #1F2228 text_sec #5B626E disabled #9297A1 accent #0067C0
danger #C42B1C toggle_on #0078D4 toggle_off #B0B5BE
selection_bg #E0EEFA selection_text #005599
sidebar_hover rgba(20,32,48,14) separator rgba(24,32,44,18)
popup_bg #FFFFFF popup_border rgba(20,28,40,24) popup_shadow rgba(20,28,40,20)
control_bg #F7F8FA control_hover #EBEFF4 control_disabled #F1F2F5
control_border rgba(20,28,40,24) group_border rgba(20,28,40,14)
shadow rgba(20,28,40,20) scrollbar rgba(52,58,68,52)
```
设置窗口另有"岛预览"配色(与真实渲染略有差异,`src/utils/settings_ui/renderer/widget_preview.rs`):
glass 预览 `rgba(24,24,28,220)`;dynamic 预览为线性渐变 **`rgb(18,12,36)` → `rgb(8,24,48)`**(左上→右下);
default 预览 `rgb(10,10,10)`;预览描边 alpha `52`(glass) / `38`(其他);预览阴影 `rgba(0,0,0,72)`,偏移 +4。
→ 若前端需要一张**静态渐变**替代 `dynamic`,这两个色值就是现成可抄的。
---
## 3. 排版(字体 token)
### 3.1 字体族(`src/utils/font.rs`)
1. 用户自定义字体:`config.custom_font_path` → `FontMgr::new_from_data()`(优先于全部)
2. ASCII 文本:`"Microsoft YaHei"` → `"Segoe UI"` → 系统默认(`legacy_make_typeface`)
3. 非 ASCII:逐字符匹配 `match_family_style_character("", style, &["zh-CN","ja-JP","en-US"], ch)`,
再依次尝试 `"Segoe UI Emoji"` → `"Microsoft YaHei"` → `"Segoe UI Symbol"` → `"Segoe UI"`
4. 合成粗体:`needs_synthetic_bold()` = 请求 `weight >= 600` 且字体本身 `weight < 600` → `font.set_embolden(true)`
5. 亚像素渲染始终开启 `font.set_subpixel(true)`
字重只有两档:`FontStyle::bold()`(700)与 `FontStyle::normal()`(400);
滚动文本判定粗体用 `style.weight() >= 700`(`src/utils/scroll.rs`)。
**字距 / 行高**:代码里**未找到** letter-spacing 或 line-height 参数;
行距全部通过显式 y 偏移实现(见下表)。文本绘制走 Skia `draw_str`,
大字文本(标题/歌手)`render_as_paths = true`(`src/utils/scroll.rs` 调用点)。
### 3.2 字号表(默认 `config.font_size = 0.0` 时用内置值;>0 时用户值参与计算)
| 用途 | 字号 | 字重 | 说明 / 来源 |
| -------------------- | -------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------- |
| mini 歌词主行 | `font_size*0.8*scale` 或 **12.0\*scale** | normal | `src/core/render/mini.rs` `lyric_font_size()` |
| mini 歌词次行 | 主字号 × `SECONDARY_LYRIC_SCALE = 0.85` | normal | 同上 |
| mini 歌词对高度 | `主*(1+0.85+0.18) + 8*scale` | — | `lyric_pair_height()` |
| mini 插件文字(主) | `font_size*0.7*scale` 或 **11.0\*scale** | **bold** | `PLUGIN_FONT_SCALE = 0.7`、`DEFAULT_PLUGIN_FONT_SIZE = 11.0` |
| mini 插件文字(次) | 主 × `0.8` | normal | `PLUGIN_SECONDARY_FONT_SCALE = 0.8`,行距 `font_size*1.3`(`PLUGIN_SECONDARY_LINE_SPACING = 1.3`) |
| expanded 曲名 | `font_size*scale` 或 **15.0\*scale** | **bold** | `src/ui/expanded/music_view.rs` |
| expanded 歌手 | 同上 **15.0\*scale** | normal | 基线 = 曲名基线 + `22.0*scale` |
| 进度时间 | `font_size*0.67*scale` 或 **10.0\*scale** | normal | `PROGRESS_TIME_FONT_SCALE = 0.67`、`DEFAULT_PROGRESS_TIME_FONT_SIZE = 10.0` |
| 通知 app 名 | **11.0\*scale** | normal | `src/ui/compact/notification.rs` |
| 通知标题 | **13.0\*scale** | **bold** | 同上 |
| 通知详情 | **11.0\*scale** | normal | 行间距 `DETAIL_LINE_GAP = 21.0*scale` |
| 音量标签 | **12.0\*scale** | normal | `src/ui/compact/volume.rs` |
| compact 时钟挂件 | `clamp(h*0.43, 10, 12)` | **bold** | `src/ui/widget/compact/time.rs` |
| compact 资源—标签 | `METRIC_LABEL_SIZE = 7.0*scale` | **bold** | `src/ui/widget/compact/resource_usage.rs` |
| compact 资源—数值 | `METRIC_VALUE_SIZE = 10.0*scale` | **bold** | 同上 |
| expanded 时钟挂件 | `min(h*0.60, w*0.31)`,最小 `13.0*scale` | **bold** | `src/ui/widget/expanded/time.rs` |
| expanded 日历—月 | `clamp(h*0.14, 9, 15)*scale` | **bold** | `src/ui/widget/expanded/calendar.rs` |
| expanded 日历—日 | `min(h*0.53, w*0.70)`,最小 `26.0*scale` | **bold** | 同上 |
| expanded 日历—星期 | `clamp(h*0.12, 8, 12)*scale` | normal | 同上 |
| expanded 资源—圆环值 | `clamp(d*0.27, 7, 10.5*scale)`,d = 圆环直径 | **bold** | `src/ui/widget/expanded/resource_usage.rs` |
| expanded 资源—标签 | `clamp(h*0.12, 6, 8*scale)` | **bold** | 同上 |
| 设置窗口—行标签/值 | **13.0** | normal | `src/utils/settings_ui/renderer/items.rs` |
| 设置窗口—页签标签 | **11.5** | normal(选中为 bold) | `src/window/settings/renderer.rs` |
| 设置窗口—弹窗项 | **12.0** | normal | 同上 |
| 设置窗口—Markdown | `BODY_SIZE = 12.0`,`LINE_HEIGHT = 18.0` | — | `src/window/settings/pages/plugins/markdown.rs` |
字体基线规则(值得照抄):
- 居中对齐用 **字形包围盒** 居中:`baseline_y = bounds.top + (bounds.h - glyph.h)/2 - glyph.top`
(`src/ui/widget/expanded/mod.rs` `draw_widget_text_centered()`)
- 时间文字基线 = 进度条中心 + `0.35*字号`(`PROGRESS_TIME_BASELINE_SCALE = 0.35`)
- 迷你歌词单行时 `primary_y = center_y + size/3`;双行时按堆叠高度对齐(见 5.1)
### 3.3 是否随窗口尺寸 / 用户设置缩放
- 随 `compact_scale` / `expanded_scale` 缩放(乘在所有尺寸上)
- 字号受 `config.font_size`(默认 0 = 用内置值)影响:只有 mini 歌词、mini 插件、expanded 曲名/歌手、进度时间用它
- 挂件字号是**几何驱动**的(按自身矩形宽高 clamp),与 `font_size` 无关
- 设置窗口有自己的 `settings_theme`(`system` / 暗 / 亮)
---
## 4. 层级与效果
### 4.1 圆角
见 1.7。总结:连续超椭圆(squircle),compact 满圆角(h/2),expanded 最大 48。
挂件卡片 `widget_corner_radius = min(12.0*scale, min(w,h)/2)`(`src/ui/widget/expanded/mod.rs`)。
迷你封面 `MINI_COVER_RADIUS = 5.0`,expanded 封面 `16.0*scale`,通知图标 `11.0*scale`。
### 4.2 阴影 / 发光
- **外阴影**:仅 expanded,`rgba(0,0,0,0.11)`,y+2,blur σ = `min(3*scale, margin/3)`(见 2.3)
- **内阴影**:**未找到**
- **发光**:只出现在资源占用圆环——`glow` 圆半径 `diameter*0.57`,颜色 `accent`,alpha `0.07*alpha`
(`src/ui/widget/expanded/resource_usage.rs`)
- 设置窗口:窗口阴影 `rgba(0,0,0,90)`、弹出菜单阴影 `theme.popup_shadow` + 偏移 +2(`src/window/settings/renderer.rs`);
拖拽幽灵阴影 `rgba(0,0,0,90)`,偏移 +4
### 4.3 模糊半径(全部数值)
| 场景 | 参数 | 来源 |
| ----------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------- |
| 运动模糊(整体) | 横向 = ` | w速度 | *0.3 + | view速度 | *w*0.4`,纵向 = ` | h速度 | *0.3`;上限集显 `(4.0, 3.5)`、独显 `(12.0, 10.0)`;仅在 σ>0.1 时 `saveLayer` | `src/utils/blur.rs` `calculate_blur_sigmas()`、`src/core/render.rs` `MIN_BLUR_SIGMA = 0.1` |
| 封面翻转 | `σ = (1- | cos | )^0.6 * 8.0 * scale`,x 方向再 ×0.3 | `src/ui/expanded/music_view.rs` |
| 暂停按钮速度模糊 | ` | 弹簧速度 | * 16.0 * scale`,上限 `2.5*scale` | `PAUSE_CONTROL_BLUR_SCALE = 16.0`、`PAUSE_CONTROL_MAX_BLUR = 2.5` |
| 快进/快退按钮 | `(1 - t/0.3).max(0) * 6.0 * scale`,x×0.3 | 同文件 `draw_skip_button()` |
| 歌词切换(Blur 模式) | `σ = 6.0*scale`,x 压缩 `LYRIC_TRANSITION_BLUR_X_SCALE = 0.25`,位移 `LYRIC_TRANSITION_OFFSET = 10.0*scale` | `src/core/render/mini.rs` |
| dynamic 背景封面 | 先降采样到 **64×64**,再 `image_filters::blur((8.0, 8.0), TileMode::Clamp)` | `src/utils/backdrop.rs` |
| host backdrop(亚克力) | **未找到数值**(由系统 HostBackdropBrush 决定,仅 `HOST_BACKDROP_INSET = 1.0`) | `src/window/d3d.rs` |
### 4.4 不透明度分层与可见性阈值
```
MIN_VISIBLE_OPACITY = 0.01 // 低于此不绘制
MIN_BLUR_SIGMA = 0.1
MINI_FADE_RATE = 1.5 // mini 淡出速率
COLLAPSED_VISUALIZER_HEIGHT_SCALE = 0.45 // 收起时可视化高度 = 45%
```
- `expanded_alpha = expansion_progress² * (1 - hide_progress)`(平方 → 展开"迟入场")
- `mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1 - hide_progress)`
- 可视化高度比例 `visualizer_height_scale = 0.45 + 0.55 * expansion_progress`
- 歌词额外淡出 `LYRIC_EXPANSION_FADE_RATE = 2.5`(`src/core/render/mini.rs`)
- 展开页横向滑动:`page_shift = view_offset * current_w`;
音乐页 `translate(-page_shift)`,挂件页 `translate(current_w - page_shift)`
(`src/core/render/expanded.rs`);`view` 弹簧 `0.12/0.68`
### 4.5 卡片四周留白 / 裁切
见 1.8:窗口 = 岛 + 80(每侧 40 透明);内容 `clip_path(Intersect)`;
描边在裁切外画;挂件网格 inset 24。
---
## 5. 布局骨架
### 5.1 compact(mini)120×27
`src/core/render/mini.rs`:
```
MIN_MINI_CONTENT_WIDTH = 45.0 // 可用宽小于此不画内容
MINI_COVER_SIZE = 18.0 // 封面尺寸
MINI_COVER_LEFT_INSET = 10.0 // 封面左边距
MINI_COVER_RADIUS = 5.0 // 封面圆角
MINI_VISUALIZER_RIGHT_INSET = 17.0
MINI_VISUALIZER_WIDTH_SCALE = 0.55
MINI_VISUALIZER_SMOOTHING = (0.6, 0.08) // (上升, 下降)
LYRIC_LEFT_INSET = 30.0
LYRIC_RIGHT_INSET = 29.0
```
- **左**:18×18 封面,x = 10,y 垂直居中(`(base_h - 18)/2 = 4.5`),圆角 5,中心裁剪
- **中**:歌词,可用区 x ∈ [30, w-29],垂直居中;主行居中(滚动模式改为左对齐并从 x=30 起滚动),
次行始终水平居中;主行单行时 `y = center_y + size/3`,双行时
`stack_h = size + size*0.85 + size*0.18`,主行 `y = center_y - stack_h/2 + size*0.8`,
次行 `y = center_y + stack_h/2 - secondary*0.2`
- **右**:可视化条,中心 x = `w - 17`,中心 y = `base_h/2`
- 无媒体时改由 compact 挂件条接管:Left / Center / Right 三组;
有 mini 内容时只画 Left 与(Center+Right)两组并贴两端,并在内容两侧画竖分隔线
(`src/ui/widget/compact/mod.rs` `draw()`)
- 挂件宽:时间 **48**、资源 **132**;边缘 inset **9**;组内间距 **7**
### 5.2 expanded 音乐页 360×200
`src/ui/expanded/music_view.rs` 常量全表:
```
CONTENT_PADDING = 24.0 // 四周内边距
COVER_SIZE = 64.0 // 封面 64×64,圆角 16.0*scale,位置 (24,24)
TRACK_TEXT_GAP = 18.0 // 封面右到文字左
TRACK_TEXT_RIGHT_INSET = 70.0 // 文字右侧预留(给可视化条)
TRACK_TITLE_BASELINE_OFFSET= 26.0 // 曲名基线 y = 24 + 26 = 50
PROGRESS_TOP_GAP = 24.0 // 进度条中心 y = 24 + 64 + 24 = 112
PROGRESS_TIME_WIDTH = 28.0 // 两端时间文字预留宽
PROGRESS_TIME_GAP = 4.0
PROGRESS_BAR_HEIGHT = 6.5 // 常态条高
PROGRESS_BAR_HOVER_GROWTH = 3.5 // hover 增至 10.0
PLAYBACK_CONTROLS_TOP_GAP = 42.0 // 控制行中心 y = 112 + bar_h/2 + 42 ≈ 157.25
SKIP_BUTTON_GAP = 75.0 // 上一首/下一首中心距播放键 75
PAGE_ARROW_RIGHT_INSET = 7.5 // 右侧翻页条 x = w - 7.5,y = h/2
PAGE_ARROW_FADE_RATE = 5.0
COLLAPSED_VISUALIZER_INSET = 17.0 // 视觉条右侧 inset 随展开 17 → 37
EXPANDED_VISUALIZER_INSET = 37.0
VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
```
- **左**:64 封面;**中**:曲名(15 bold)/歌手(15, α0.6) 两行,x = 24+64+18 = **106**;**右**:可视化条
- 进度条:左右各内缩 24,两端 28 宽时间 + 4 间距;条圆角 = bar_h/2;
填充宽度 `max(bar_total_w*progress, bar_h)`;填充只圆左端(RRect 左上/左下半径 = bar_h/2,右侧 0)
- 控制行中心 x = 180;播放键在中心、上一首 105、下一首 255
- 点击热区(`src/ui/expanded/music_view/controls.rs`):播放 **40×40**,上/下一首 **36×36**,
进度条热区高 **16.0\*scale**,封面整块可双击(激活媒体 App)
- 动画参数:`PROGRESS_SMOOTHING = 0.15`、`PROGRESS_HOVER_SMOOTHING = 0.18`、
`PROGRESS_HOVER_SNAP_THRESHOLD = 0.005`、`PROGRESS_START_THRESHOLD = 0.02`、`PROGRESS_JUMP_THRESHOLD = 0.3`、
`PLAY_STATE_RESPONSE = 0.18`、`SKIP_ANIMATION_DURATION_SECS = 0.5`、封面翻转 0.6s、
`PAUSE_CONTROL_STIFFNESS = 0.18`、`PAUSE_CONTROL_DAMPING = 0.64`、`PAUSE_CONTROL_MIN_SCALE = 0.8`、
`PAUSE_CONTROL_MAX_SCALE = 1.03`、`PAUSE_CONTROL_PRESS_VELOCITY = -0.18`
- 播放/暂停过渡:`cover_scale = 0.85 + 0.15*pause_t`、`cover_brightness = 0.75 + 0.25*pause_t`
(即暂停时封面缩小到 85% 并压暗到 75%),图标用 `smoothstep` 交叉淡出
### 5.3 expanded 挂件页(同 360×200)
- 6×3 网格,inset 24,gap 7(见 1.9)
- 卡片:背景 `rgba(28,28,30, 0.05*alpha)`,描边 `1px rgba(255,255,255, 0.16*alpha)`,圆角 `min(12*scale, min(w,h)/2)`
- 左侧翻页条 x = **7.5**,y = `h/2`(3×14 圆角矩形,α0.4);右侧同(x = w-7.5)
- 内置挂件各视图排布:
- **时钟**:整卡居中,字号 `min(h*0.6, w*0.31)`(≥13),bold,`HH:MM` 格式
- **日历**:月 `y+0.08h..0.26h`(bold,α0.78)/日 `y+0.27h..0.75h`(bold,字号 `min(0.53h,0.70w)`,≥26)/
星期 `y+0.78h..0.92h`(normal,α0.62)
- **资源占用(2×1)**:中间竖分隔(α0.09,`y+0.2h..0.8h`);两个圆环各占一半:
环心 `(center_x, top+0.43h)`,直径 `min(0.58h, 0.62w)`(≥20),描边 `min(3*scale, d*0.12)` 圆头,
底环 α0.13,进度弧从 **-90°** 起扫 `usage*360°`(α0.92),值文字 bold 居中,标签在 `top+0.76h`(α0.58)
- **设置(1×1)**:整卡居中齿轮图标,`icon_scale = min(w,h)*0.38/24`,α0.72
### 5.4 compact 各业务视图差异
| 视图 | 尺寸 | 排布 |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| 时间挂件 | 48×27 | 居中 `HH:MM`,字号 `clamp(0.43h, 10..12)` bold,基线 `center_y + size*0.36` |
| 资源挂件 | 132×27 | 两个 metric 各 `(w-8)/2` 宽;标签 7px bold 在左(accent α0.78),数值 10px bold 在右(白); |
| 文字基线 `center_y + scale`;下方 2px 进度轨(y = 基线+5),轨 α0.13,填充 accent α0.9;中间分隔 α0.1,半高 6 |
| 通知 | 330×82 | 图标 42×42 圆角 11 @ x+18 垂直居中;文本左 = 有图标 `x+72` / 无图标 `x+20`;右内缩 18; |
| app 名基线 `top+22`(11px α0.65);标题 13px bold,基线 `top+34`(无 app 名)/ `top+43`(有 app 名)/ 垂直居中 `top+(h+13)/2`(两者皆无);详情 11px α0.72 在标题下 21px |
| 音量 | 192×37 | 喇叭图标 20px,中心 `x = left+21`;标签 12px @ `left+37`(α0.9);轨道从 `label_x + label_w + 26` 到 `right-14`,高 4 圆角 2,轨 α0.28,填充白 α1.0 |
### 5.5 动画时长(通知 / 音量)
- 通知:`DISPLAY_DURATION = 5s`,`ENTER_DURATION = 220ms`(`ease_out_cubic = 1-(1-t)³`),
`FADE_DURATION = 280ms`;入场位移 `(1-enter)*7.0*scale`(从上滑下 7px)
- 音量:`DISPLAY_DURATION = 1600ms`,`FADE_DURATION = 240ms`,
`VOLUME_CHANGE_THRESHOLD = 0.002`(音量变化阈值,低于此不显示)
- 歌词切换进度用 `smoothstep`:`p*p*(3-2p)`(`src/core/render/mini.rs`)
- 长文本跑马灯(`src/utils/scroll.rs`):先停 **2.0s**,再以 **35.0\*scale px/s** 滚动,
循环间隔 `full_w + 50*scale`,裁剪区 `(x, y-size*1.2, max_w, size*1.5)`
---
## 6. 图标
**体系**:全部是 Skia 内的 **SVG path 数据 + 填充(`PaintStyle::Fill`)**,没有线性描边图标体系,
也没有 icon font。颜色一律单色:由 `text_color`(= 白)+ alpha 决定;只有资源环用 accent 色。
**未找到**线宽(stroke-width)概念——唯一的描边图标是设置窗口的翻页箭头(`1.8px`, round cap/join)。
| 图标 | 路径坐标系 | 缩放 | 实际视觉尺寸 | 来源 |
| ---------------- | ------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 播放 ▶ | 640 单位(128..544) | `0.083 * scale` | ≈ 34.5 × 37 px | `src/icons/controls.rs` `draw_play_button` |
| 暂停 ⏸ | 640 单位 | `0.083 * scale` | ≈ 34.5 × 37 px | `draw_pause_button` |
| 快退/快进三角 | 640 单位(中心 336,320) | `path_scale * global_scale`,静止 `0.050`(左)与 `0.055`(右);动画中 `0.050~0.055` | 约 21 × 22 px | `draw_control_triangle`,`src/ui/expanded/music_view.rs` `draw_skip_button`(中心 ±10.92*scale) |
| 音乐(封面占位) | 1024 单位 | `scale*1.8`,内部 `0.024` | 随卡片 | `src/icons/music.rs` |
| 设置齿轮 | 1024 单位 | `min(w,h)*0.38/24`,内部 `0.024` | 卡片 38% 短边 | `src/icons/settings.rs` |
| 音量(4 档) | 25.75 单位 | `size / 25.75`,偏移 `(-11.089286, -7.883929)` | 传 20.0*scale | `src/icons/volume.rs`;档位 `level<=0→静音, <=1/3→低, <=2/3→中, else→高` |
| 翻页指示条 | 不是 path | — | `3.0*scale × 14.0*scale` 圆角矩形,圆角 `2.0*scale`,α×0.4 | `src/icons/arrows.rs` |
其他图标使用点:
- 通知 App 图标:`AppDisplayInfo::GetLogo(Size{64,64})`,按 alpha>8 计算可见包围盒,等比缩放塞进 42×42,圆角 11 裁切
- 封面:64×64(expanded)与 18×18(mini),中心裁剪,`FilterMode::Linear + MipmapMode::Linear`
- 迷你封面圆角 5、expanded 封面圆角 16(连续超椭圆路径,不是普通 `clip_rrect`)
---
## 7. 明确「未找到」的项
1. **刘海/挖孔造型**:不存在(无 `SetWindowRgn`、无顶部直角特例)
2. **内阴影 / inset shadow**:代码中无任何实现
3. **字距(letter-spacing)与行高倍数**:无;行距全靠显式 y 偏移
4. **亚克力的模糊半径/透明度数值**:由 Windows `HostBackdropBrush` 系统决定,代码只给 `HOST_BACKDROP_INSET = 1.0`
→ Electron 侧**没有**等价物:`setBackgroundMaterial('acrylic')` 是整窗系统材质(跟随应用主题、覆盖窗口矩形),
不能像 `DWMWA_USE_HOSTBACKDROPBRUSH` 那样只画在圆角形状里;本项目因此改用无 backdrop 的降级色(见 §8)
5. **强调色用于岛内 UI**:岛内几乎没有强调色(只有资源占用的 CPU/RAM/警告/危险色);
统一强调色 `#0A84FF` 仅存在于**设置窗口**(`COLOR_ACCENT`)
6. **岛自身的亮/暗主题**:岛屿永远是深底白字;明暗主题只在设置窗口
7. **固定渐变色背景**:真实 `dynamic` 背景是封面图,没有固定渐变;
仅设置页预览用 `rgb(18,12,36) → rgb(8,24,48)`(可当静态替代)
8. **CSS 等价的超椭圆圆角公式**:真实实现是 Flutter 超椭圆查表(`src/utils/shape.rs`),没有简单的 border-radius 参数
---
## 8. 给 Web/Electron 复刻的映射速查
- 岛容器:定位在屏幕顶上 `top: 10px`、水平居中,尺寸与圆角用 spring 插值
(`cubic-bezier` 或 JS 弹簧,参考 `stiffness 0.10 / damping 0.68`);`overflow: hidden` 做裁切
- compact:`120×27`(有歌词时宽度跟着文本走,上限 700;只有媒体无歌词时 155),`border-radius: 13.5px`(胶囊)
- expanded:`360×200`,`border-radius: 48px`(想要 iOS 味请用 SVG 超椭圆路径)
- 背景:`#000` 不透明;"glass" **不要**照搬 WinIsland 的 host backdrop —— Electron 的
`setBackgroundMaterial()` 是"整窗系统材质",覆盖范围是窗口矩形而不是卡片形状,且明暗跟随应用主题,
在浅色主题下会给卡片垫出一圈浅色面板(详见 `packages/desktop-client/src/main/island.ts`);
我们改用 WinIsland 自己的**无 backdrop 降级色** `rgba(32,32,36,.804)` 画在卡片上;
"dynamic" 用封面模糊层 + `rgba(20,20,24,.471)` 叠加
- 描边:`1px solid rgba(255,255,255,.118)`(纯色样式,展开时淡出)/ `.157`(glass、dynamic)
- 阴影:仅展开 `0 2px σ rgba(0,0,0,.11)`(σ ≈ 3px)
- 文字:`-apple-system / "Microsoft YaHei" / "Segoe UI"`,白 + alpha 分层(.65 / .72 / .9 / .6 / .5 / .58 / .62)
- 图标:内联 SVG,`fill: currentColor`,播放/暂停按 640 坐标系、尺寸 ≈ 34.5×37
---
## 附:班级小助手实际采纳了哪些(本次重写对照表)
| WinIsland 做法 | 出处 | 我们的落地 | 验证 |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| **窗口一次建大包围盒,形变发生在窗口内** | `window/app/layout.rs` `required_window_size()` + `PADDING=80` | 窗口固定为「最大形态 + `ISLAND_SHADOW_PAD(10)`」,开合期间**一帧都不 setBounds** | 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种) |
| 尺寸/圆角由弹簧驱动 | `utils/physics.rs` + `app.rs` `IslandSprings`(w/h/r = `0.10/0.68`) | `src/island/spring.ts` 同参数同更新式(dt 以帧计、`damping^dt`、换向保留 35% 动量 + 20% 距离上限),并加**零过冲钳制** | 岛中心/边缘波动 0.00px、无过冲 |
| 连续圆角(超椭圆) | `utils/shape.rs` `continuous_rounded_rect_path` / `expanded_island_radius = min(48*scale, w/2, h/2)` | `src/island/squircle.ts` 超椭圆角路径;展开半径 `min(48×圆角/20, w/2, h/2)` | 形状 bbox 与卡片框一致(±2px) |
| 内容交叉淡入 `expanded=progress²`、`mini=1-1.5×progress` | `core/render.rs`(`MINI_FADE_RATE=1.5`) | 两层常驻、透明度按同一公式 | 截图与 DOM 断言 |
| 描边 1px 白 alpha 30(纯色)/ 40(glass) | `core/render.rs` `BORDER_*_ALPHA` | `--wn-border` 取同值(`.118` / `.157`) | 计算样式断言 |
| 展开投影 `rgba(0,0,0,.11)`、y+2、σ=3 | `draw_expanded_shadow` | `drop-shadow(0 2px 3px rgba(0,0,0,.11))` | 截图 |
| 纯黑底 / 亚克力(HostBackdropBrush) | `background.rs` + `win32.rs` `DWMWA_USE_HOSTBACKDROPBRUSH` | 纯黑底 / **glass 用 CSS 半透明深色卡**(WinIsland 无 backdrop 降级色 `rgba(32,32,36,.804)`);**不调用** `setBackgroundMaterial()` | 计算样式 + 「卡片外圈透出桌面」整屏截图像素断言 |
| `set_cursor_hittest` 命中控制 | `frame.rs`(`set_cursor_hittest`) | 默认 `setIgnoreMouseEvents(true,{forward:true})`,渲染层命中测试后切换 | 岛内 true / 岛外 false |
| 空闲细缝 `hidden_width = 5` | `core/config.rs` | 6px 宽竖条(窗口固定,不再受 Windows 最小窗口高度限制) | 空闲 `6x22` → 来消息 `268x44` |
| 6 个停靠位 `DockPosition` | `core/config.rs` | 顶/底 × 左/中/右 6 锚点,岛(而非窗口)精确落在锚点 | 6 锚点坐标断言 |
| `font_size` = 基础字号 × 各文本系数 | `core/render/mini.rs`、`ui/expanded/music_view.rs` | 基础字号 11–20px × 系数表(标题 1.08/正文 0.95/次要 0.78/徽标与按钮 0.76/胶囊 0.92、0.74) | 13→19px 全部文本倍率 1.462 |
| 未采纳:音乐/歌词/小组件/音量浮窗 | `ui/expanded/**`、`ui/widget/**` | 业务不同(我们做通知/作业/叫人/紧急) | — |
+2
View File
@@ -26,6 +26,8 @@ export default tseslint.config(
'**/dist-electron/**',
'**/release/**',
'**/release-server/**',
// 交付产物归集目录(pnpm dist:* 的输出,见 AGENTS.md §4):里面是构建后的压缩 JS,不该进 lint
'**/releases/**',
'**/build/**',
'**/coverage/**',
'**/src/generated/**',
+6 -1
View File
@@ -15,6 +15,7 @@
"dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev",
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build",
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
@@ -30,11 +31,15 @@
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
"verify:web": "node scripts/ui-smoke/run.mjs",
"verify:packaged": "node scripts/verify-packaged.mjs",
"verify:classisland": "pnpm --filter @classhelper/server verify:classisland",
"verify:classisland-plugin": "node packages/classisland-plugin/scripts/verify.mjs",
"icons": "node scripts/generate-icons.mjs",
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
"dist:all": "pnpm dist:server && pnpm dist:win"
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin"
},
"devDependencies": {
"concurrently": "^10.0.5",
+4
View File
@@ -0,0 +1,4 @@
# .NET 构建产物(与仓库根的 .gitignore 分开:根 .gitignore 是 GBK 编码,apply_patch 读不了)
bin/
obj/
cipx/
@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!--
ClassIsland 2.0 / 2.1.0.x 面向 net8.0;2.1.1 起改为 net10.0。
本插件按 net8.0 交付,因此 SDK 版本固定到最后一个 net8.0 的版本(2.1.0.1),
这样 ClassIsland 2.0.x 与 2.1.0.x 都能加载(apiVersion 见 manifest.yml)。
-->
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
<RootNamespace>ClassHelper.ClassIslandPlugin</RootNamespace>
<AssemblyName>ClassHelper.ClassIslandPlugin</AssemblyName>
<ClassIslandPluginSdkVersion>2.1.0.1</ClassIslandPluginSdkVersion>
<!-- 插件由 ClassIsland 加载,不需要生成自带运行时(ExcludeAssets 见下) -->
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<!-- 设置页是 .axaml:需要让 Avalonia 的默认项把 **/*.axaml 收成 AvaloniaXaml 并编译 -->
<EnableDefaultItems>true</EnableDefaultItems>
<!-- 绑定走运行时解析:插件与主程序各自编译,编译期绑定会因看不到对方的类型而失败 -->
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<!-- 运行时与原生资源由 ClassIsland 本体提供,插件只编译期依赖 -->
<PackageReference Include="ClassIsland.PluginSdk" Version="$(ClassIslandPluginSdkVersion)">
<ExcludeAssets>runtime;native</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Update="manifest.yml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="README.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
{
"profiles": {
"ClassIsland 插件": {
"commandName": "Executable",
"executablePath": "$(ClassIsland_DebugBinaryFile)",
"commandLineArgs": "-epp $(TargetDir)",
"workingDirectory": "$(ClassIsland_DebugBinaryDirectory)"
}
}
}
+163
View File
@@ -0,0 +1,163 @@
# 班级小助手联动插件
把 [ClassIsland](https://classisland.tech) 与 [班级小助手](https://github.com/LoveChengke/ClassHelper) 连起来:
让班级小助手**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
## 它能做什么
| 方向 | 说明 |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| ClassIsland → 班级小助手 | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
| 班级小助手 → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
| 班级小助手 → ClassIsland(可选) | 把班级小助手上排好的课表**镜像**回 ClassIsland(新建 `班级小助手-<班级名>` 档案课表,不改动老师原有的课表) |
## 安装
1. 把插件放进 ClassIsland 的插件目录,二选一:
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
2. 重启 ClassIsland,在「设置 → 插件」里确认「班级小助手联动」已启用。
3. 打开「设置 → 班级小助手联动」,填入:
- **服务器地址**:班级小助手后端地址,例如 `http://127.0.0.1:4000`;
- **设备令牌**:在班级小助手 Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
## 配置项(全部在「设置 → 班级小助手联动」里)
| 配置项 | 说明 |
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
| 服务器地址 | 班级小助手后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
| 上报课表到班级小助手 | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
| 接收班级小助手提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
| 显示调试信息 | 展开设备标识、与服务端的时钟差、配置目录等,排查「为什么同步不上」时打开 |
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
> **上课时段策略(与 ClassHelper 客户端同一套规则)**:
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
> 暂存期间不回执,服务端会保留这条待提醒。
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
> 不依赖班级小助手那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
> **两个方向各自需要的开关**:
>
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开;
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败;
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
> **提醒到底会不会弹,取决于两个开关:**
>
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
> 2. 本插件的「接收班级小助手提醒」关掉时,即使推过来也不弹。
>
> 两者都开着,提醒才会在 ClassIsland 上出现。
## 工作原理(为什么这么写)
- **不用跨进程 IPC,插件内直接读课程服务**:ClassIsland 的 IPC 暴露的是缓存数据,不足以还原课表;
插件直接读 `ILessonsService`(当前科目 / 状态 / 时间点)与 `IProfileService`(档案里的课表、时间表、科目),
拿到的才是「教室此刻真实的状态」。
- **「上报即拉取」,不引入 Socket 依赖**:每次上报的返回值里会顺带带一条「尚未确认的提醒」,
插件收到就交给提醒提供方显示;应用启动时额外拉一次 `pending`,
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
「班级小助手提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
**第 i 个 `TimeType == 0`(上课)的时间点**一一对应,
因此镜像接口为每条课目带上 `startTime` / `endTime`,插件据此重建时间表
(课间点从服务端的时间表里按起始时刻对齐)。少一个时间点,整份课表就会错位。
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
- **非破坏性**:镜像只创建 / 更新名字以 `班级小助手-` 开头的课表与时间表;
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
## 上机联调记录(ClassIsland 2.1.0.1 实测)
下面几条都是把插件装进真实 ClassIsland 跑出来的问题与结论,改动理由就写在这里,避免以后被“顺手优化”回去:
1. **提醒内容必须在 UI 线程构造并显示。** 上报回调在线程池上,而提醒模板里的图标是 Avalonia 对象
(`LucideIconSource → IconSource → AvaloniaObject` 的构造函数会 `Dispatcher.VerifyAccess()`),
在线程池上直接构造会抛 `Call from invalid thread`,表现就是“ClassHelper 客户端收到了、ClassIsland 毫无反应”。
现在统一用 `Dispatcher.UIThread.Post` 切回 UI 线程。
2. **同一条提醒在播放期间要按 id 去重。** 服务端在收到回执前会一直把提醒算作“待弹出”,
而轮询间隔(默认 10 秒)可能小于提醒时长(最长 120 秒)—— 不去重就会一遍遍重复弹。
只有播完(`Completed`/`Canceled`)才把 id 从“正在播放”里移除并回执。
3. **不要自己拼图标串。** 双图标遮罩模板的默认图标由模板自己创建(`new LucideIconSource("\ue0ff")` 这种**字形字符**),
自己传 `"fluent(...)"` 之类既可能格式不对、也可能字形不存在。紧急提醒改用 ClassIsland 自带的**滚动文本**正文来表达。
4. **`Subject.Fallback` 是占位符(名字就是 `???`)。** 没有课表时 ClassIsland 会把它挂到
`CurrentSubject`/`NextClassSubject` 上,直接上报会让 Web 端显示“正在上 ???”,因此上报前归一化成“没有科目”。
实测截图(真机弹出提醒的遮罩):[普通提醒](../../docs/screenshots/classisland/02-normal-mask.png)、
[紧急提醒](../../docs/screenshots/classisland/01-urgent-mask.png)。
## 构建与打包
```bash
pnpm build:classisland-plugin # Release 构建(产物:packages/classisland-plugin/bin/Release)
pnpm dist:classisland-plugin # 追加打包 .cipx 并归集到 releases/classisland-plugin/
pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性 / 注册完整性 / DTO ↔ 服务端契约)
```
构建脚本会把 NuGet 缓存与临时目录指到仓库内的 `.cache/`(避免写满 C 盘),
并在打包后自检「清单 / 程序集名 / 版本号」三者一致。
## 开发提示
- 需要 **.NET 8 SDK**;SDK 包固定 `ClassIsland.PluginSdk 2.1.0.1`(2.1.1 起改为 net10.0,
本插件按 net8.0 交付,因此 2.0.x / 2.1.0.x 都能加载,见 `manifest.yml` 的 `apiVersion`)。
- 设置页是 Avalonia 的 `.axaml`(继承 `SettingsPageBase`);绑定走运行时解析
(`AvaloniaUseCompiledBindingsByDefault=false`),因为插件与主程序各自编译,编译期绑定看不到对方类型。
- **开发机没有安装 ClassIsland 本体**,所以插件只能做到编译 + 打包 + 静态校验;
运行期行为需要在装了 ClassIsland 的机器上按上面的「安装」步骤实测。
## 常见问题
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是班级小助手的标准格式)**
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
如果仍然报 404,请依次检查:
1. 服务器地址是否写成了别的端口 / 别的机器(提示里会带上实际请求的完整 URL,照着核对最快);
2. 该地址在浏览器里打开 `<服务器地址>/api/health` 是否返回 `{"success":true,...}`;
3. 反向代理(Nginx 等)是否把 `/api/integrations` 也转发到了后端。
**上报失败,提示「设备令牌无效」**
在 Web 端重置该设备的令牌,然后把新令牌填回插件设置(旧令牌会立即失效)。
**发布提醒后 ClassIsland 没反应,或者要等一会儿才弹**
插件不持有长连接,提醒是**轮询取回**的:默认最多 10 秒(设置页「提醒轮询间隔」可调到 5 秒)。超过这个时间还没弹,按下面逐条排查。
**ClassIsland 上完全不弹提醒**
1. 确认插件设置里打开了「接收提醒」;
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
**课表上报了但班级小助手上没变化**
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
**镜像开关打开了,但 ClassIsland 的课表没变**
镜像需要 Web 端该设备开启「镜像课表」且班级里已有课表;插件只在**上报时**顺路拉取镜像,
所以打开开关后点一下「立即上报」或等一个上报间隔即可。镜像不会覆盖老师原有的课表。
## 许可
与班级小助手仓库一致。
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+19
View File
@@ -0,0 +1,19 @@
# 班级小助手 ClassIsland 联动插件 —— 清单文件
#
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
id: classhelper.classisland.bridge
name: 班级小助手联动
description: |-
把 ClassIsland 的课表与上课状态同步到班级小助手,并在 ClassIsland 上显示老师下发的提醒。
功能:
1. 上报当前课表与节次时间(班级小助手 Web 端与桌面客户端据此显示课程);
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
3. 接收老师在班级小助手上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
version: 0.1.0.0
apiVersion: 2.0.0.0
author: 班级小助手
@@ -0,0 +1,174 @@
/**
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
*
* 用法:
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
* node scripts/build.mjs --cipx # 追加打包 .cipx(ClassIsland 插件包)并归集到 releases/classisland-plugin/
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
*
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const cacheDir = path.join(repoRoot, '.cache');
const releasesDir = path.join(repoRoot, 'releases', 'classisland-plugin');
const argv = process.argv.slice(2);
const withCipx = argv.includes('--cipx');
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
const log = (message) => console.log(`[classisland-plugin] ${message}`);
/* ------------------------------------------------------------ 1. 环境准备 */
const env = { ...process.env };
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
}
const setDefault = (key, value) => {
if (!env[key]) env[key] = value;
};
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
setDefault('TEMP', path.join(cacheDir, 'tmp'));
setDefault('TMP', env.TEMP);
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
env.DOTNET_NOLOGO = '1';
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
function resolveExecutable(envKey, names, fallbacks) {
const candidates = [];
if (env[envKey]) candidates.push(env[envKey]);
const finder = process.platform === 'win32' ? 'where' : 'which';
for (const name of names) {
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
if (found.status === 0) {
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
}
}
candidates.push(...fallbacks);
for (const candidate of candidates) {
if (!candidate) continue;
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch {
/* 继续试下一个 */
}
}
return null;
}
const dotnet = resolveExecutable(
'DOTNET_EXE',
['dotnet'],
process.platform === 'win32'
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
);
if (!dotnet) {
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
}
const powerShell = resolveExecutable(
'POWERSHELL_EXE',
['pwsh', 'powershell'],
process.platform === 'win32'
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
: [],
);
/* ------------------------------------------------------------ 2. 构建 */
const buildArgs = ['build', '-c', 'Release'];
if (withCipx) {
buildArgs.push('-p:CreateCipx=true');
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
}
buildArgs.push(...passthrough);
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
/* ------------------------------------------------------------ 3. 产物自检 */
const outputDir = path.join(pluginDir, 'bin', 'Release');
const manifestPath = path.join(outputDir, 'manifest.yml');
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
if (!fs.existsSync(required)) {
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
}
}
const manifest = fs.readFileSync(manifestPath, 'utf8');
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
if (entrance !== path.basename(assemblyPath)) {
throw new Error(
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
);
}
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
if (!manifestVersion || manifestVersion !== codeVersion) {
throw new Error(
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
);
}
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
/* ------------------------------------------------------------ 4. 打包归集 */
if (!withCipx) {
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
process.exit(0);
}
const cipxDir = path.join(pluginDir, 'cipx');
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
if (!fs.existsSync(cipxFile)) {
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
}
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库)
fs.rmSync(releasesDir, { recursive: true, force: true });
fs.mkdirSync(releasesDir, { recursive: true });
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile)));
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
fs.mkdirSync(portableDir, { recursive: true });
for (const name of fs.readdirSync(outputDir)) {
const from = path.join(outputDir, name);
if (!fs.statSync(from).isFile()) continue;
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
continue;
}
fs.copyFileSync(from, path.join(portableDir, name));
}
for (const file of fs.readdirSync(cipxDir)) {
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file));
}
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`);
for (const file of fs.readdirSync(releasesDir)) {
log(` ${file}`);
}
@@ -0,0 +1,362 @@
/**
* ClassIsland 联动插件静态校验:`pnpm verify:classisland-plugin`
*
* 为什么是"静态"校验:插件要跑起来必须有 ClassIsland 本体,而本机(以及 CI)都没有,
* 所以这里校验"装不上 / 对不上"的那几类问题 —— 它们不需要运行就能发现:
*
* 1. 清单三件套一致性:manifest.yml ↔ csproj ↔ 代码里的版本号;
* 2. 注册完整性:插件入口、提醒提供方、设置页是否都挂上了(挂漏了功能会"静默消失");
* 3. **接口契约**:C# DTO 的 JSON 字段名 ↔ 服务端路由 / 请求字段,
* 这类错误(比如 dayOfWeek 写成 dayofweek)在运行时只会表现为"上报成功但课表是空的",
* 最难排查,所以专门盯住。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const results = [];
const check = (name, ok, detail = '') => results.push({ name, ok: Boolean(ok), detail });
const read = (relative) => fs.readFileSync(path.join(pluginDir, relative), 'utf8');
const readRepo = (relative) => fs.readFileSync(path.join(repoRoot, relative), 'utf8');
const manifest = read('manifest.yml');
const csproj = read('ClassHelper.ClassIslandPlugin.csproj');
const pluginSource = read('src/Plugin.cs');
const providerSource = read('src/Services/ClassHelperNotificationProvider.cs');
const bridgeSource = read('src/Services/BridgeService.cs');
const clientSource = read('src/Interop/ClassHelperClient.cs');
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
const mapperSource = read('src/Services/ScheduleMapper.cs');
const settingsSource = read('src/Models/PluginSettings.cs');
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
const serverService = readRepo('packages/server/src/modules/integrations/integrations.service.ts');
const sharedTypes = readRepo('packages/shared/src/types.ts');
const sharedConstants = readRepo('packages/shared/src/constants.ts');
/* ------------------------------------------------------------ 1. 清单一致性 */
/** 取 YAML 里某个顶层字段的值(prettier 会把字符串统一成单引号,两种引号都要能剥掉) */
function field(name) {
const raw = new RegExp('^' + name + ':\\s*(.+?)\\s*$', 'm').exec(manifest)?.[1];
return raw?.replace(/^['"]|['"]$/g, '');
}
check('manifest.yml 有插件 id', Boolean(field('id')));
check('manifest.yml 声明了入口程序集', Boolean(field('entranceAssembly')));
check('manifest.yml 声明了 apiVersion', /\d+\.\d+\.\d+\.\d+/.test(field('apiVersion') ?? ''));
const assemblyName = /<AssemblyName>([^<]+)<\/AssemblyName>/.exec(csproj)?.[1];
check(
'entranceAssembly 与 csproj 的 AssemblyName 一致',
field('entranceAssembly') === assemblyName + '.dll',
'entranceAssembly=' + field('entranceAssembly') + ' AssemblyName=' + assemblyName,
);
const manifestVersion = field('version');
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
check(
'插件版本号在清单与代码里一致',
Boolean(manifestVersion) && manifestVersion === codeVersion,
'manifest=' + manifestVersion + ' code=' + codeVersion,
);
check(
'图标与自述文件按清单约定命名(icon.png / README.md)',
fs.existsSync(path.join(pluginDir, 'icon.png')) && fs.existsSync(path.join(pluginDir, 'README.md')),
);
/* ------------------------------------------------------------ 2. 注册完整性 */
check(
'插件入口带 [PluginEntrance] 且继承 PluginBase',
/\[PluginEntrance\]/.test(pluginSource) && /:\s*PluginBase/.test(pluginSource),
);
check(
'初始化时注册了提醒提供方',
/AddNotificationProvider<\s*ClassHelperNotificationProvider\s*>/.test(pluginSource),
);
check('初始化时注册了设置页', /AddSettingsPage<\s*BridgeSettingsPage\s*>/.test(pluginSource));
check('初始化时把联动服务注册为单例', /AddSingleton<\s*BridgeService\s*>/.test(pluginSource));
check('应用启动后才接课程服务(不能更早)', /AppStarted/.test(pluginSource));
check(
'设置以 JSON 持久化到插件配置目录',
/PluginConfigFolder/.test(pluginSource) && /Settings\.json/.test(pluginSource),
);
const providerGuid = /ProviderGuidValue = "([0-9A-Fa-f-]{36})"/.exec(providerSource)?.[1];
check('提醒提供方声明了合法 GUID', Boolean(providerGuid));
check(
'提醒提供方属性引用同一个 GUID 常量',
/NotificationProviderInfo\(\s*ProviderGuidValue/.test(providerSource),
);
check(
'提醒提供方会在播完后回执给服务端',
/Completed/.test(providerSource) && /AckAsync/.test(providerSource),
);
check(
'设置页带 [SettingsPageInfo] 且声明了页面 id',
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"班级小助手联动"/.test(settingsPageSource),
);
check(
'设置页 XAML 的 x:Class 与代码后置类一致',
settingsPageXaml.includes('x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"') &&
/namespace ClassHelper\.ClassIslandPlugin\.Views;/.test(settingsPageSource),
);
check('设置页 XAML 继承 ClassIsland 的设置页基类', /SettingsPageBase/.test(settingsPageXaml));
/* ------------------------------------------------------------ 3. 接口契约 */
// 3.1 路由:插件调用的路径必须都在服务端注册过
const clientPaths = [...clientSource.matchAll(/"(\/classisland\/[a-z-]+)"/g)].map((item) => item[1]);
const registeredPaths = new Set(
[...serverModule.matchAll(/router\.(?:get|post|patch|delete)\(\s*'(\/classisland\/[a-z-]+)'/g)].map(
(item) => item[1],
),
);
for (const route of [
'/classisland/report',
'/classisland/pending',
'/classisland/ack',
'/classisland/class-plan',
]) {
check('服务端注册了 ' + route, registeredPaths.has(route));
}
check(
'插件调用的路由都存在于服务端',
clientPaths.length > 0 && clientPaths.every((item) => registeredPaths.has(item)),
'插件调用=[' + clientPaths.join(',') + ']',
);
check('插件把接口前缀拼成 /api/integrations', clientSource.includes('/api/integrations'));
check('插件用设备令牌头鉴权', clientSource.includes('X-ClassIsland-Token'));
// 3.2 上报请求字段:服务端 zod schema 里出现的字段名,插件必须真的会发
const reportBlock = /export const classIslandReportSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
const requestFields = [
'pluginVersion',
'classIslandVersion',
'state',
'schedule',
'timeLayout',
'inClass',
'subject',
'nextSubject',
'timeState',
'periodStart',
'periodEnd',
'classPlanLoaded',
'clientTime',
'mode',
'entries',
'items',
];
const missingRequestFields = requestFields.filter(
(name) => reportBlock.includes(name) && !new RegExp('\\b' + name + '\\b').test(clientSource),
);
check(
'上报请求字段都出现在插件 DTO 里',
missingRequestFields.length === 0,
missingRequestFields.length > 0 ? '缺少=' + missingRequestFields.join(',') : '',
);
// 3.3 课表条目字段:这些是"上报成功但课表为空"的典型来源
const entrySchema = /export const reportScheduleEntrySchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
const entryFields = [
'dayOfWeek',
'startTime',
'endTime',
'subject',
'teacherName',
'weekParity',
'weekCountDiv',
'weekCountDivTotal',
'planName',
];
const csharpJsonNames = new Set(
[...clientSource.matchAll(/\[JsonPropertyName\("([^"]+)"\)\]/g)].map((item) => item[1]),
);
const missingEntryFields = entryFields.filter(
(name) => entrySchema.includes(name) && !csharpJsonNames.has(name),
);
check(
'课表条目字段服务端 zod 与插件 DTO 对齐',
missingEntryFields.length === 0,
missingEntryFields.length > 0 ? '插件缺少=' + missingEntryFields.join(',') : '',
);
// 3.4 插件 DTO 里的 JSON 字段名必须能在共享契约里找到(防错拼)
const knownLocalOnly = new Set(['data', 'success', 'message', 'code']);
const unknownJsonNames = [...csharpJsonNames].filter((name) => {
if (knownLocalOnly.has(name)) return false;
return !new RegExp('\\b' + name + '\\b').test(sharedTypes);
});
check(
'插件 DTO 的 JSON 字段名都能在 @classhelper/shared 里找到',
unknownJsonNames.length === 0,
unknownJsonNames.length > 0 ? '疑似拼写错误=' + unknownJsonNames.join(',') : '',
);
// 3.5 课表镜像:服务端给了 Classes[i] 对齐所需的时间,插件必须读
for (const name of ['startTime', 'endTime', 'timeLayouts', 'profileName', 'weekDay', 'weekCountDivTotal']) {
check('镜像契约字段 ' + name + ' 在插件侧存在', csharpJsonNames.has(name));
}
// 3.6 提醒下发字段(Web 端 → 服务端 → 插件)
const notifySchema = /export const sendNotificationSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
for (const name of [
'classId',
'title',
'content',
'durationSeconds',
'speech',
'speechContent',
'saveToNotifications',
'priority',
]) {
check('下发提醒字段 ' + name + ' 在服务端 schema 里', notifySchema.includes(name));
}
check(
'提醒时长有上界(避免老师发出永不消失的提醒)',
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(serverSchemas) &&
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(sharedConstants),
);
// 3.7 实时事件:Web 端订阅的事件名必须在共享契约里声明过
check(
'共享契约声明了 classisland 实时事件',
sharedTypes.includes("'classisland:state'") && sharedTypes.includes("'classisland:notification'"),
);
check(
'服务端广播事件名取自共享常量(不是手写字符串)',
/SOCKET_EVENTS\.classislandState/.test(serverService),
);
// 3.8 网络层:教室机器(以及开发机)常配了系统代理,而 Windows 的代理设置默认连 127.0.0.1
// 也会走代理,表现为"填了令牌立刻 HTTP 404"(其实是代理自己的错误页,跟本服务无关)。
// 插件必须对本地/内网地址绕开系统代理,否则内网部署必然失败。
check(
'HttpClient 显式配置代理(不裸用默认行为)',
/SocketsHttpHandler\s*\{[^}]*Proxy\s*=/.test(clientSource),
);
check(
'本地/内网地址绕开系统代理',
/class LocalAwareProxy/.test(clientSource) && /IsBypassed\(Uri host\)/.test(clientSource),
);
check(
'代理判定覆盖回环与内网网段',
/IsLoopback/.test(clientSource) &&
/10\.0\.0\.0\/8/.test(clientSource) &&
/192\.168\.0\.0\/16/.test(clientSource),
);
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
check(
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
providerSource.includes('Dispatcher.UIThread.Post'),
);
check(
'同一条提醒在播放期间按 id 去重(否则轮询会让它反复弹)',
providerSource.includes('_playing') && providerSource.includes('HashSet<string>'),
);
check(
'不再自造图标串(用模板默认图标,紧急用滚动文本表达)',
// 不再给 CreateTwoIconsMask 传 leftIcon(自造图标串会踩格式/字形坑)
!providerSource.includes('leftIcon') && providerSource.includes('CreateRollingTextContent'),
);
check(
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
mapperSource.includes('Subject.Fallback'),
);
check(
'上课时段暂存非主动通知(与客户端灵动岛同一套规则)',
providerSource.includes('_deferred') &&
providerSource.includes('IsImmediate') &&
providerSource.includes('FlushDeferred') &&
providerSource.includes('OnBreakingTime'),
);
check(
'主动通知(紧急 / 叫人)上课时段也立即弹',
providerSource.includes("\"call\"") && providerSource.includes('notification.Urgent'),
);
check(
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
bridgeSource.includes('上报课表到班级小助手') && bridgeSource.includes('开关已关闭'),
);
check(
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
settingsSource.includes('NotificationPollSeconds') &&
bridgeSource.includes('_notifyTimer') &&
bridgeSource.includes('NotificationPollSeconds'),
);
/* ------------------------------------------------------------ 8. 回归护栏:本次修复过的坑 */
check(
'课表配对用原始 TimeType==0 列表,**不用** ValidTimeLayoutItems(后者会剔除被停用的节次,导致整份课表错位)',
// 只查代码形态(`.Count`),因为源码注释里特意点名了这个属性来说明"为什么不能用它"
mapperSource.includes('classPoints[index]') && !mapperSource.includes('.ValidTimeLayoutItems.Count'),
);
check(
'上报字段按服务端 zod 上限截断(一个超长名字会让整批上报 400,连状态与心跳一起丢掉)',
mapperSource.includes('Truncate(') &&
mapperSource.includes('SubjectMaxLength') &&
mapperSource.includes('PlanNameMaxLength'),
);
check(
'课表镜像在 UI 线程改写档案(线程池上改 Observable 集合会与落盘并发,可能写坏档案)',
bridgeSource.includes('Dispatcher.UIThread.InvokeAsync') && bridgeSource.includes('SaveProfile()'),
);
check(
'镜像内容未变化时跳过(否则每次上报都重建课表并全量落盘)',
bridgeSource.includes('_lastMirrorSignature') && bridgeSource.includes('MirrorSignature('),
);
check(
'上报异常有 catch(fire-and-forget 的异常会被静默吞掉)',
bridgeSource.includes('catch (Exception exception)') && bridgeSource.includes('上报异常'),
);
check(
'镜像前过滤时间非法的条目(避免档案里出现 00:00 的课次,且必须与 Classes 同步过滤)',
read('src/Services/ClassPlanWriter.cs').includes('IsValidTime'),
);
check(
'上报体带上 deviceKey,且服务端 schema 也声明了该字段(回填机器码的契约两端一致)',
clientSource.includes('"deviceKey"') && serverSchemas.includes('deviceKey:'),
);
check(
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
clientSource.includes('响应不是班级小助手的标准格式') &&
clientSource.includes('MaxResponseContentBufferSize'),
);
check(
'提醒提供方惰性获取课程服务并重试(拿不到服务时"上课暂存"会永久失效且静默)',
providerSource.includes('EnsureLessons') && providerSource.includes('_lessonsHooked'),
);
check(
'设置落盘先写临时文件再原子替换(逐字符写盘 + 截断式写入会留下坏 JSON)',
pluginSource.includes('File.Move(temp, path, overwrite: true)'),
);
check(
'服务器地址只接受 http/https(裸 Uri.TryCreate 会放过 file:// / ftp://)',
settingsSource.includes('TryGetServerUri') && settingsSource.includes('UriSchemeHttp'),
);
/* ------------------------------------------------------------ 结果 */
const failed = results.filter((item) => !item.ok);
for (const item of results) {
console.log((item.ok ? '√ ' : '× ') + item.name + (item.detail ? ' (' + item.detail + ')' : ''));
}
console.log('');
console.log(
'ClassIsland 联动插件静态校验:' + (results.length - failed.length) + '/' + results.length + ' 项通过',
);
if (failed.length > 0) {
console.log('未通过:\n - ' + failed.map((item) => item.name).join('\n - '));
process.exit(1);
}
@@ -0,0 +1,446 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClassHelper.ClassIslandPlugin.Models;
namespace ClassHelper.ClassIslandPlugin.Interop;
/* ---------------------------------------------------------------- 服务端契约(与 packages/shared 保持一致) */
/// <summary>插件上报的课表条目。</summary>
public class ScheduleEntryDto
{
[JsonPropertyName("dayOfWeek")] public int DayOfWeek { get; set; }
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
/// <summary>ALL / ODD / EVEN;留空则由服务端按 weekCountDiv 推导。</summary>
[JsonPropertyName("weekParity")] public string? WeekParity { get; set; }
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
[JsonPropertyName("planName")] public string? PlanName { get; set; }
}
/// <summary>插件上报的节次时间。</summary>
public class TimeLayoutItemDto
{
[JsonPropertyName("index")] public int Index { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
/// <summary>class / break / divider / action</summary>
[JsonPropertyName("type")] public string Type { get; set; } = "class";
[JsonPropertyName("skipped")] public bool Skipped { get; set; }
}
/// <summary>本机运行状态快照。</summary>
public class StateDto
{
[JsonPropertyName("inClass")] public bool InClass { get; set; }
[JsonPropertyName("subject")] public string? Subject { get; set; }
[JsonPropertyName("nextSubject")] public string? NextSubject { get; set; }
[JsonPropertyName("timeState")] public string? TimeState { get; set; }
[JsonPropertyName("periodStart")] public string? PeriodStart { get; set; }
[JsonPropertyName("periodEnd")] public string? PeriodEnd { get; set; }
[JsonPropertyName("week")] public int? Week { get; set; }
[JsonPropertyName("classPlanLoaded")] public bool ClassPlanLoaded { get; set; }
[JsonPropertyName("clientTime")] public string ClientTime { get; set; } = "";
}
public class SchedulePayloadDto
{
[JsonPropertyName("mode")] public string Mode { get; set; } = "merge";
[JsonPropertyName("entries")] public List<ScheduleEntryDto> Entries { get; set; } = new();
}
public class TimeLayoutPayloadDto
{
[JsonPropertyName("name")] public string Name { get; set; } = "ClassIsland 时间表";
[JsonPropertyName("mode")] public string Mode { get; set; } = "replace";
[JsonPropertyName("items")] public List<TimeLayoutItemDto> Items { get; set; } = new();
}
public class ReportRequestDto
{
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
/// <summary>本机机器码:设备创建时服务端还不知道它,靠这里回填(见服务端 syncDeviceKey)。</summary>
[JsonPropertyName("deviceKey")] public string DeviceKey { get; set; } = "";
[JsonPropertyName("state")] public StateDto? State { get; set; }
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
}
public class ReportSettingsDto
{
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
}
public class ReportResultDto
{
[JsonPropertyName("scheduleApplied")] public bool ScheduleApplied { get; set; }
[JsonPropertyName("scheduleCreated")] public int ScheduleCreated { get; set; }
[JsonPropertyName("scheduleUpdated")] public int ScheduleUpdated { get; set; }
[JsonPropertyName("courses")] public List<string> Courses { get; set; } = new();
[JsonPropertyName("timeLayoutApplied")] public bool TimeLayoutApplied { get; set; }
[JsonPropertyName("week")] public int Week { get; set; }
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
[JsonPropertyName("settings")] public ReportSettingsDto? Settings { get; set; }
[JsonPropertyName("pendingNotification")] public PushNotificationDto? PendingNotification { get; set; }
}
/// <summary>服务端下发的提醒。</summary>
public class PushNotificationDto
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
[JsonPropertyName("content")] public string Content { get; set; } = "";
[JsonPropertyName("durationSeconds")] public int DurationSeconds { get; set; } = 8;
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
[JsonPropertyName("urgent")] public bool Urgent { get; set; }
/// <summary>提醒类型:notification(默认)/ call(叫人,算"主动通知",上课时段也立刻弹)</summary>
[JsonPropertyName("kind")] public string? Kind { get; set; }
[JsonPropertyName("classId")] public string ClassId { get; set; } = "";
[JsonPropertyName("className")] public string? ClassName { get; set; }
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
}
public class PendingResultDto
{
[JsonPropertyName("notifications")] public List<PushNotificationDto> Notifications { get; set; } = new();
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
[JsonPropertyName("week")] public int Week { get; set; }
}
/* ---------------------------------------------------------------- 课表镜像(服务端 → ClassIsland) */
public class ClassPlanEntryMirrorDto
{
[JsonPropertyName("weekDay")] public int WeekDay { get; set; }
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
/// <summary>起止时间(HH:mm)。Classes[i] 必须与第 i 个上课时间点对齐,靠它重建时间表。</summary>
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("timeLayoutId")] public string TimeLayoutId { get; set; } = "";
}
public class TimeLayoutMirrorDto
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("layouts")] public List<TimeLayoutItemMirrorDto> Layouts { get; set; } = new();
}
public class TimeLayoutItemMirrorDto
{
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("timeType")] public int TimeType { get; set; }
}
public class ClassPlanMirrorDto
{
[JsonPropertyName("entries")] public List<ClassPlanEntryMirrorDto> Entries { get; set; } = new();
[JsonPropertyName("timeLayouts")] public List<TimeLayoutMirrorDto> TimeLayouts { get; set; } = new();
[JsonPropertyName("profileName")] public string ProfileName { get; set; } = "";
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
}
public class PullResultDto
{
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
[JsonPropertyName("week")] public int Week { get; set; }
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
}
/* ---------------------------------------------------------------- 统一响应体 */
internal class ApiEnvelope<T>
{
[JsonPropertyName("success")] public bool Success { get; set; }
[JsonPropertyName("data")] public T? Data { get; set; }
[JsonPropertyName("message")] public string Message { get; set; } = "";
[JsonPropertyName("code")] public string? Code { get; set; }
}
/// <summary>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</summary>
public class ApiCallResult
{
public bool Ok { get; init; }
public string Message { get; init; } = "";
public bool NetworkError { get; init; }
public static ApiCallResult Success(string message) => new() { Ok = true, Message = message };
public static ApiCallResult Failure(string message) => new() { Ok = false, Message = message };
public static ApiCallResult Offline(string message) => new() { Ok = false, Message = message, NetworkError = true };
}
/// <summary>
/// 班级小助手后端客户端。
///
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
/// (见 https://docs.classisland.tech/dev/plugins/basics.html 的程序集隔离说明)。
/// </summary>
public sealed class ClassHelperClient : IDisposable
{
/// <summary>
/// 单次请求超时。教室机器可能是内网慢链路,但也不该卡住上报线程太久。
/// </summary>
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
/// <summary>
/// 响应体上限(4MB)。默认是 2GB —— 服务器地址填错(指到一个会吐大页面的地址)时,
/// 一次请求就能在 12 秒内把内存吃光。
/// </summary>
private const long MaxResponseBytes = 4 * 1024 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>
/// 与班级小助手通信的 HttpClient。
///
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
/// 那个 404 其实是**代理自己的错误页**,跟本服务毫无关系。
/// 因此这里用 <see cref="LocalAwareProxy"/>:本地/内网地址直连,公网地址照旧走系统代理。
/// </summary>
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
{
Timeout = Timeout,
MaxResponseContentBufferSize = MaxResponseBytes,
};
public string LastError { get; private set; } = "";
public DateTimeOffset? LastSuccessAt { get; private set; }
/// <summary>
/// 包装系统代理:**本地/内网地址标记为"绕过"**,其余交给系统代理决定。
///
/// 判定为本地/内网的情形:localhost、回环地址、IPv4/IPv6 私有段与链路本地段,
/// 以及不含点的主机名(`server` / `classhelper` 这类校内机器名,DNS 也可能解析不出来)。
/// 判定为公网时保持原行为,避免"云服务器 + 公司代理"这种场景被我们改坏。
/// </summary>
private sealed class LocalAwareProxy : IWebProxy
{
private readonly IWebProxy _inner = HttpClient.DefaultProxy;
// 只读透传即可:HttpClient.DefaultProxy 是全局单例,改它的凭据会波及宿主进程里的其它请求
public ICredentials? Credentials { get; set; }
public Uri? GetProxy(Uri destination) => _inner.GetProxy(destination);
public bool IsBypassed(Uri host) => IsLocalHost(host) || _inner.IsBypassed(host);
/// <summary>该地址是否属于"不该走代理"的本地/内网范围。</summary>
internal static bool IsLocalHost(Uri uri)
{
var host = uri.Host;
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true;
// 不含点的主机名(校内机器名)走直连:解析成内网 IP 的可能性远大于公网
if (!host.Contains('.')) return true;
if (!IPAddress.TryParse(host, out var address)) return false;
if (IPAddress.IsLoopback(address)) return true;
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = address.GetAddressBytes();
return bytes[0] == 10 // 10.0.0.0/8
|| (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) // 172.16.0.0/12
|| (bytes[0] == 192 && bytes[1] == 168) // 192.168.0.0/16
|| (bytes[0] == 169 && bytes[1] == 254); // 169.254.0.0/16(链路本地)
}
// IPv6:fc00::/7(唯一本地地址)与 fe80::/10(链路本地)
var first = address.GetAddressBytes()[0];
return (first & 0xFE) == 0xFC || (first & 0xC0) == 0x80;
}
}
/// <summary>拼接 API 地址,容忍用户填了带/不带末尾斜杠、甚至带 /api 后缀的地址。</summary>
private static string BuildUrl(string serverUrl, string path)
{
var baseUrl = (serverUrl ?? "").Trim().TrimEnd('/');
if (baseUrl.EndsWith("/api", StringComparison.OrdinalIgnoreCase))
{
baseUrl = baseUrl[..^4];
}
return $"{baseUrl}/api/integrations{path}";
}
private HttpRequestMessage BuildRequest(HttpMethod method, string serverUrl, string path, string token)
{
var request = new HttpRequestMessage(method, BuildUrl(serverUrl, path));
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.TryAddWithoutValidation("X-ClassIsland-Token", token);
}
return request;
}
private async Task<ApiEnvelope<T>?> SendAsync<T>(
HttpMethod method,
string serverUrl,
string path,
string token,
object? body,
CancellationToken cancellationToken)
{
try
{
using var request = BuildRequest(method, serverUrl, path, token);
if (body is not null)
{
var json = JsonSerializer.Serialize(body, JsonOptions);
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
}
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
ApiEnvelope<T>? envelope = null;
try
{
envelope = JsonSerializer.Deserialize<ApiEnvelope<T>>(text, JsonOptions);
}
catch (JsonException)
{
// 非 JSON 响应(例如被网关拦截)——交给下面的兜底分支
}
if (!response.IsSuccessStatusCode)
{
var message = envelope?.Message;
if (string.IsNullOrWhiteSpace(message))
{
// 响应不是本服务的统一响应体:最常见的原因就是被系统代理 / 网关拦下来
// (典型表现:HTTP 404 且 body 是一张 HTML 错误页)。把 URL 一起报出来,
// 老师截图给我们就能一眼定位。
message = envelope is null
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" +
"或该系统代理是否拦截了内网请求"
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
}
LastError = message!;
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
}
if (envelope is null)
{
// 2xx 但响应不是本服务的统一响应体(网关/反代的 HTML 错误页、地址指到了别的服务等)。
// 必须留下原因:否则调用方拿到的是"成功但无数据 + 空错误消息",
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
LastError =
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
return new ApiEnvelope<T> { Success = false, Message = LastError };
}
LastError = "";
LastSuccessAt = DateTimeOffset.Now;
return envelope;
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
LastError = "请求超时(请检查服务器地址与网络)";
return null;
}
catch (HttpRequestException exception)
{
LastError = $"无法连接班级小助手服务:{exception.Message}";
return null;
}
catch (Exception exception)
{
LastError = exception.Message;
return null;
}
}
/// <summary>上报状态 +(可选)课表。</summary>
public async Task<(ApiCallResult Result, ReportResultDto? Data)> ReportAsync(
PluginSettings settings,
ReportRequestDto payload,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<ReportResultDto>(
HttpMethod.Post, settings.ServerUrl, "/classisland/report", settings.DeviceToken, payload, cancellationToken);
if (envelope is null)
{
return (ApiCallResult.Offline(LastError), null);
}
if (!envelope.Success || envelope.Data is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
}
return (ApiCallResult.Success(envelope.Message), envelope.Data);
}
/// <summary>拉取尚未确认的下发提醒(插件启动/重连时补齐)。</summary>
public async Task<(ApiCallResult Result, List<PushNotificationDto> Items)> PendingAsync(
PluginSettings settings,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<PendingResultDto>(
HttpMethod.Get, settings.ServerUrl, "/classisland/pending", settings.DeviceToken, null, cancellationToken);
if (envelope is null) return (ApiCallResult.Offline(LastError), new List<PushNotificationDto>());
if (!envelope.Success || envelope.Data is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError),
new List<PushNotificationDto>());
}
return (ApiCallResult.Success(envelope.Message), envelope.Data.Notifications);
}
/// <summary>确认提醒已弹出(之后服务端不再补发)。</summary>
public async Task<ApiCallResult> AckAsync(PluginSettings settings, string id, CancellationToken cancellationToken)
{
var envelope = await SendAsync<JsonElement>(
HttpMethod.Post, settings.ServerUrl, "/classisland/ack", settings.DeviceToken, new { id }, cancellationToken);
if (envelope is null) return ApiCallResult.Offline(LastError);
return envelope.Success
? ApiCallResult.Success("已确认")
: ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError);
}
/// <summary>拉取班级课表(用于镜像回 ClassIsland)。未开启镜像时服务端返回 data=null。</summary>
public async Task<(ApiCallResult Result, ClassPlanMirrorDto? Plan)> PullClassPlanAsync(
PluginSettings settings,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<PullResultDto>(
HttpMethod.Get, settings.ServerUrl, "/classisland/class-plan", settings.DeviceToken, null, cancellationToken);
if (envelope is null) return (ApiCallResult.Offline(LastError), null);
if (!envelope.Success)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
}
if (envelope.Data?.ClassPlan is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0
? envelope.Message
: "该设备未开启「把班级课表镜像到 ClassIsland」"), null);
}
return (ApiCallResult.Success(envelope.Message), envelope.Data.ClassPlan);
}
public void Dispose() => _http.Dispose();
}
@@ -0,0 +1,176 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClassHelper.ClassIslandPlugin.Models;
/// <summary>
/// 插件设置。
///
/// 存储由 ClassIsland 负责:插件入口把它保存在 <c>PluginConfigFolder/Settings.json</c>
/// (**不要**放在插件安装目录,那里不会随档案备份、升级时还会被清掉)。
/// 因此这里必须继承 <see cref="ObservableObject"/> 并让每个属性都走 <c>SetProperty</c>:
/// ClassIsland 通过监听 <c>PropertyChanged</c> 来即时保存。
/// </summary>
public class PluginSettings : ObservableObject
{
private string _serverUrl = "http://127.0.0.1:4000";
private string _deviceToken = "";
private string _deviceKey = "";
private bool _autoReport = true;
private int _reportIntervalSeconds = 60;
private bool _uploadSchedule = true;
private bool _mirrorSchedule = false;
private bool _receiveNotifications = true;
private int _notificationDurationSeconds = 8;
private int _notificationPollSeconds = 10;
private bool _speechEnabled = false;
private bool _showDebugInfo = false;
/// <summary>
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。
/// </summary>
public string ServerUrl
{
get => _serverUrl;
set => SetProperty(ref _serverUrl, value);
}
/// <summary>
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。
/// </summary>
public string DeviceToken
{
get => _deviceToken;
// 在 setter 里就去掉首尾空白:粘贴令牌时带前导空格非常常见,
// 而 Normalize() 只在启动加载时跑一次 —— 运行期粘进来的脏值会让 IsConfigured
// 一直为 false,用户看着明明填好了却查不出原因。
set => SetProperty(ref _deviceToken, (value ?? "").Trim());
}
/// <summary>
/// 本机标识(首次生成后固定):服务端据此把同一台机器识别为同一设备,重装插件不会产生重复设备。
/// </summary>
public string DeviceKey
{
get => _deviceKey;
set => SetProperty(ref _deviceKey, value);
}
/// <summary>
/// 是否自动上报:启动后、课表变化时、以及每隔 <see cref="ReportIntervalSeconds"/> 秒各上报一次。
/// </summary>
public bool AutoReport
{
get => _autoReport;
set => SetProperty(ref _autoReport, value);
}
/// <summary>
/// 定时上报间隔(秒),最小 15 秒。
/// </summary>
public int ReportIntervalSeconds
{
get => _reportIntervalSeconds;
set => SetProperty(ref _reportIntervalSeconds, Math.Clamp(value, 15, 3600));
}
/// <summary>
/// 是否把 ClassIsland 的课表同步到班级小助手。
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。
/// </summary>
public bool UploadSchedule
{
get => _uploadSchedule;
set => SetProperty(ref _uploadSchedule, value);
}
/// <summary>
/// 是否把班级小助手上排好的课表镜像回 ClassIsland
/// (新建一份名为「班级小助手-&lt;班级名&gt;」的档案课表,不覆盖老师原有的课表)。
/// </summary>
public bool MirrorSchedule
{
get => _mirrorSchedule;
set => SetProperty(ref _mirrorSchedule, value);
}
/// <summary>
/// 是否接收老师在班级小助手上发布的提醒,并在 ClassIsland 上弹出。
/// </summary>
public bool ReceiveNotifications
{
get => _receiveNotifications;
set => SetProperty(ref _receiveNotifications, value);
}
/// <summary>
/// 提醒轮询间隔(秒),最小 5 秒。
///
/// 插件与后端之间没有长连接(刻意不引入 Socket 依赖),提醒是"轮询取回"的,
/// 因此这个值就是**老师按下发送到教室弹出之间的最大延迟**。
/// 单独一个轻量轮询(只查待提醒,不上传课表)比把上报间隔改小划算得多。
/// </summary>
public int NotificationPollSeconds
{
get => _notificationPollSeconds;
set => SetProperty(ref _notificationPollSeconds, Math.Clamp(value, 5, 600));
}
/// <summary>
/// 默认提醒显示时长(秒);老师在下发时可以单独指定更长的时长。
/// </summary>
public int NotificationDurationSeconds
{
get => _notificationDurationSeconds;
set => SetProperty(ref _notificationDurationSeconds, Math.Clamp(value, 1, 120));
}
/// <summary>
/// 提醒弹出时是否语音朗读(沿用 ClassIsland 的语音设置与语音提供方)。
/// </summary>
public bool SpeechEnabled
{
get => _speechEnabled;
set => SetProperty(ref _speechEnabled, value);
}
/// <summary>
/// 是否在设置页显示调试信息(最后一次上报结果、课表条目数等),排查问题时打开。
/// </summary>
public bool ShowDebugInfo
{
get => _showDebugInfo;
set => SetProperty(ref _showDebugInfo, value);
}
/// <summary>
/// 兼容旧配置:反序列化后把 DeviceKey 补齐(首次运行时由服务生成)。
/// </summary>
public void Normalize()
{
ServerUrl = (ServerUrl ?? "").Trim().TrimEnd('/');
DeviceToken = (DeviceToken ?? "").Trim();
DeviceKey = (DeviceKey ?? "").Trim();
ReportIntervalSeconds = Math.Clamp(ReportIntervalSeconds, 15, 3600);
NotificationDurationSeconds = Math.Clamp(NotificationDurationSeconds, 1, 120);
NotificationPollSeconds = Math.Clamp(NotificationPollSeconds, 5, 600);
}
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
public bool IsConfigured =>
TryGetServerUri(out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
/// <summary>
/// 解析服务器地址,**只接受 http/https 绝对地址**。
///
/// 不用裸的 `Uri.TryCreate(..., UriKind.Absolute)`:它会接受 `file://` / `ftp://`,
/// 那种地址会在发请求时抛一句难以理解的 NotSupportedException,用户根本猜不到是地址填错了。
/// </summary>
public bool TryGetServerUri(out Uri uri)
{
uri = null!;
if (!Uri.TryCreate((ServerUrl ?? "").Trim(), UriKind.Absolute, out var parsed)) return false;
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) return false;
uri = parsed;
return true;
}
}
+171
View File
@@ -0,0 +1,171 @@
using System.Text.Json;
using ClassHelper.ClassIslandPlugin.Models;
using ClassHelper.ClassIslandPlugin.Services;
using ClassHelper.ClassIslandPlugin.Views;
using ClassIsland.Core;
using ClassIsland.Core.Abstractions;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Extensions.Registry;
using ClassIsland.Shared;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin;
/// <summary>
/// 插件入口。
///
/// 职责只有三件:读配置 → 注册扩展(提醒提供方 / 设置页 / 联动服务)→ 在应用启动后接上课程服务。
/// 真正的联动逻辑都在 <see cref="BridgeService"/> 里,入口保持"看一眼就知道装了哪些东西"。
/// </summary>
[PluginEntrance]
public class Plugin : PluginBase
{
/// <summary>插件设置文件名。存在 <see cref="PluginBase.PluginConfigFolder"/> 下:随档案备份走,升级插件不丢。</summary>
private const string SettingsFileName = "Settings.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>插件设置。注册成单例后,设置页与 <see cref="BridgeService"/> 拿到的是同一个对象。</summary>
public PluginSettings Settings { get; private set; } = new();
/// <summary>联动只接一次(AppStarted 与"运行中启用"两条路都会走到这里)</summary>
private bool _bridgeStarted;
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
Settings = LoadSettings();
// 设置页与联动服务共用一个实例:设置页改了值,服务立刻能看到(不需要重启 ClassIsland)
services.AddSingleton(Settings);
services.AddSingleton<BridgeService>();
services.AddSettingsPage<BridgeSettingsPage>();
services.AddNotificationProvider<ClassHelperNotificationProvider>();
// 初始化阶段课程服务还没准备好(它在主机启动流程里才被创建),
// 因此这里先挂一个"应用已启动"的回调,等到那时再取 ILessonsService 并开始上报。
var application = AppBase.Current;
if (application is null)
{
Logger?.LogError("班级小助手联动:拿不到 ClassIsland 应用实例,联动未启动");
return;
}
application.AppStarted += (_, _) => StartBridge();
// 插件是"运行中才被启用"时,AppStarted 早就过去了 —— 主窗口已经存在就直接接一次
if (application.MainWindow is not null) StartBridge();
}
private void StartBridge()
{
if (_bridgeStarted) return;
try
{
var lessons = IAppHost.TryGetService<ILessonsService>();
if (lessons is null)
{
Logger?.LogError("班级小助手联动:拿不到课程服务,联动已停用");
return;
}
var bridge = IAppHost.TryGetService<BridgeService>();
if (bridge is null)
{
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用");
return;
}
_bridgeStarted = true;
bridge.Attach(lessons);
bridge.RestartTimer();
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
// 不这么做的话,最长要等一个上报间隔才会弹出来
_ = bridge.PullPendingAsync();
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
}
catch (Exception exception)
{
// 联动失败绝不能把 ClassIsland 本体拖下水
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败");
}
}
private ILogger<Plugin>? Logger => IAppHost.TryGetService<ILogger<Plugin>>();
/// <summary>
/// 读取设置。首次运行时 <c>DeviceKey</c> 为空,这里补一个随机值并立即落盘 ——
/// 服务端用它把同一台机器识别为同一设备,重装插件不会堆出重复设备。
/// </summary>
private PluginSettings LoadSettings()
{
var path = Path.Combine(PluginConfigFolder, SettingsFileName);
var settings = new PluginSettings();
try
{
if (File.Exists(path))
{
var loaded = JsonSerializer.Deserialize<PluginSettings>(File.ReadAllText(path), JsonOptions);
if (loaded is not null) settings = loaded;
}
}
catch (Exception exception)
{
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path);
}
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
settings.Normalize();
if (needGenerateKey)
{
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车。
// 注意长度:原先写的是 `[..Math.Min(24, machine.Length + 33)]`,而 machine.Length + 33
// 恒大于 24,所以实际**永远只取前 24 个字符** —— 机器名稍长就把随机段整个切掉,
// "同名机器不撞车"的保障就没了。这里改成"机器名按需截短 + 保留 8 位随机段"。
var machine = Environment.MachineName.ToLowerInvariant();
var suffix = Guid.NewGuid().ToString("N")[..8];
var maxMachineLength = Math.Max(4, 24 - suffix.Length - 1);
if (machine.Length > maxMachineLength) machine = machine[..maxMachineLength];
settings.DeviceKey = $"{machine}-{suffix}";
}
Settings = settings;
// 落盘:一是固定首次生成的 DeviceKey,二是把缺字段的旧配置补齐成完整文件
SaveSettings(settings, path);
// 设置变化时即时保存
settings.PropertyChanged += (_, _) => SaveSettings(settings, path);
return settings;
}
private static void SaveSettings(PluginSettings settings, string path)
{
try
{
var folder = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
// 先写临时文件再原子替换。
//
// 设置页的地址/令牌输入框是 TwoWay 绑定(逐字符提交),直接 `File.WriteAllText`
// 会就地截断原文件;一旦此刻进程被杀或断电,留下的是半截 JSON,而 LoadSettings 的
// catch 会**静默回退到默认设置** —— 用户看到的是"令牌和地址莫名其妙没了",
// 而且原文件已被覆盖,无从恢复。
var temp = path + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(settings, JsonOptions));
File.Move(temp, path, overwrite: true);
}
catch (Exception exception)
{
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"班级小助手联动:保存设置失败({Path})", path);
}
}
}
@@ -0,0 +1,628 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text;
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassHelper.ClassIslandPlugin.Models;
using ClassIsland.Core;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Core.Models.Notification;
using ClassIsland.Shared;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 联动主循环。三件事:
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手;
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 ClassIsland 档案。
///
/// 为什么用"上报即拉取"而不是长连接:
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
/// 而教室机器的上报间隔本身就是心跳 —— 顺路把待提醒带回来,依赖更少、故障面更小。
///
/// 线程约定:HttpClient 回调在线程池上,因此所有跨线程可见的状态都用 <c>lock</c> 或
/// 赋值引用(<c>string</c> / <c>int</c>)保护,属性变化通过 <see cref="PropertyChanged"/> 通知设置页。
/// </summary>
public sealed class BridgeService : INotifyPropertyChanged, IDisposable
{
private readonly PluginSettings _settings;
private readonly ILogger<BridgeService> _logger;
private readonly ClassHelperClient _client = new();
private readonly object _gate = new();
private ILessonsService? _lessons;
private Timer? _timer;
private Timer? _notifyTimer;
private bool _disposed;
private bool _reporting;
private bool _pulling;
private bool _eventsHooked;
/// <summary>
/// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘,
/// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。
/// </summary>
private volatile string? _lastMirrorSignature;
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
{
_settings = settings;
_logger = logger;
}
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>上报结果变化时触发(设置页据此刷新)。</summary>
public event EventHandler? StateChanged;
/// <summary>需要把提醒丢给提醒提供方去显示。</summary>
public event EventHandler<PushNotificationDto>? NotificationReceived;
/* ---------------------------------------------------------------- 供设置页绑定的状态 */
private string _lastReportMessage = "尚未上报";
/// <summary>最近一次上报的结果描述。</summary>
public string LastReportMessage
{
get => _lastReportMessage;
private set => SetField(ref _lastReportMessage, value);
}
private bool _lastReportOk;
public bool LastReportOk
{
get => _lastReportOk;
private set => SetField(ref _lastReportOk, value);
}
private DateTimeOffset? _lastReportAt;
public DateTimeOffset? LastReportAt
{
get => _lastReportAt;
private set
{
if (SetField(ref _lastReportAt, value)) Raise(nameof(LastReportAtText));
}
}
/// <summary>供界面直接显示的"最后上报时间"。</summary>
public string LastReportAtText => LastReportAt is null
? "从未"
: LastReportAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
private int _lastScheduleEntryCount;
/// <summary>最近一次上报里的课表条目数。</summary>
public int LastScheduleEntryCount
{
get => _lastScheduleEntryCount;
private set => SetField(ref _lastScheduleEntryCount, value);
}
private int _lastTimeLayoutItemCount;
/// <summary>最近一次上报里的节次数量。</summary>
public int LastTimeLayoutItemCount
{
get => _lastTimeLayoutItemCount;
private set => SetField(ref _lastTimeLayoutItemCount, value);
}
private int? _lastServerWeek;
/// <summary>服务端返回的教学周。</summary>
public int? LastServerWeek
{
get => _lastServerWeek;
private set
{
if (SetField(ref _lastServerWeek, value)) Raise(nameof(ClientTimeOffsetText));
}
}
private int _notificationCount;
/// <summary>已弹出并回执的提醒数量。</summary>
public int NotificationCount
{
get => _notificationCount;
private set => SetField(ref _notificationCount, value);
}
private bool _busy;
/// <summary>是否正在上报("立即上报"按钮据此禁用)。</summary>
public bool Busy
{
get => _busy;
private set => SetField(ref _busy, value);
}
private TimeSpan? _serverTimeOffset;
/// <summary>服务端与本机的时钟差(诊断用:差得多说明教室机器时间不对)。</summary>
public string ClientTimeOffsetText => _serverTimeOffset is null
? "—"
: $"{_serverTimeOffset.Value.TotalSeconds:+0;-0;0} 秒";
/// <summary>当前配置是否完整(设置页据此提示"还没填令牌")。</summary>
public bool IsConfigured => _settings.IsConfigured;
/// <summary>是否已接入课程服务(拿到 ILessonsService 才算真正跑起来)。</summary>
public bool IsAttached
{
get { lock (_gate) return _lessons is not null; }
}
/* ---------------------------------------------------------------- 生命周期 */
/// <summary>接入课程服务并订阅课程事件(由插件入口在 AppStarted 时调用)。</summary>
public void Attach(ILessonsService lessons)
{
lock (_gate)
{
_lessons = lessons;
}
if (!_eventsHooked)
{
// 上课 / 下课 / 放学 / 状态变化时立刻上报一次,不等到下一个定时点。
// 这些事件在 ClassIsland 的公开约定里是在**主线程**触发的,可以直接订阅。
lessons.OnClass += (_, _) => RequestReport("上课");
lessons.OnBreakingTime += (_, _) => RequestReport("下课");
lessons.OnAfterSchool += (_, _) => RequestReport("放学");
lessons.CurrentTimeStateChanged += (_, _) => RequestReport("状态变化");
_eventsHooked = true;
}
Raise(nameof(IsAttached));
_logger.LogInformation("班级小助手联动:已订阅课程事件");
RequestReport("应用启动");
}
/// <summary>按设置重建定时器(设置页改间隔后调用)。</summary>
public void RestartTimer()
{
lock (_gate)
{
_timer?.Dispose();
_timer = null;
_notifyTimer?.Dispose();
_notifyTimer = null;
if (!_settings.IsConfigured) return;
if (_settings.AutoReport)
{
var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.ReportIntervalSeconds, 15, 3600));
_timer = new Timer(_ => RequestReport("定时"), null, interval, interval);
}
// 提醒单独用一个轻量轮询(只查待提醒,不上传课表):
// 老师按下发送到教室弹出之间的延迟就是这个间隔,因此默认 10 秒,不跟随上报间隔。
if (_settings.ReceiveNotifications)
{
var poll = TimeSpan.FromSeconds(Math.Clamp(_settings.NotificationPollSeconds, 5, 600));
_notifyTimer = new Timer(_ => _ = PullPendingAsync(), null, poll, poll);
}
}
}
/// <summary>发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。</summary>
public void RequestReport(string reason)
{
_ = ReportAsync(reason);
}
/// <summary>
/// 执行一次上报并返回是否真的发出去了。
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
/// </summary>
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true)
{
if (_disposed) return false;
ILessonsService? lessons;
lock (_gate) lessons = _lessons;
if (lessons is null) return false;
if (!_settings.IsConfigured)
{
SetState(false, "尚未配置服务器地址或设备令牌", 0, 0, null);
return false;
}
lock (_gate)
{
if (_reporting) return false;
_reporting = true;
}
Busy = true;
try
{
var profile = ResolveProfile();
var request = new ReportRequestDto
{
PluginVersion = PluginVersion,
ClassIslandVersion = SafeAppVersion(),
// 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上
DeviceKey = _settings.DeviceKey,
State = BuildState(lessons),
};
var entryCount = 0;
var layoutCount = 0;
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
if (includeSchedule && !_settings.UploadSchedule)
{
_logger.LogInformation(
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" +
"需要把教室课表同步到班级小助手请打开该开关");
}
if (includeSchedule && _settings.UploadSchedule && profile is not null)
{
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
entryCount = entries.Count;
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
// 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。
if (entries.Count == 0)
{
request.Schedule = null;
_logger.LogWarning(
"班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份");
}
var layout = ResolvePrimaryTimeLayout(profile);
if (layout is not null)
{
var items = ScheduleMapper.MapTimeLayout(layout);
layoutCount = items.Count;
request.TimeLayout = new TimeLayoutPayloadDto
{
Name = ScheduleMapper.Truncate(
string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
ScheduleMapper.PlanNameMaxLength)!,
Mode = "replace",
Items = items,
};
}
}
var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None)
.ConfigureAwait(false);
if (!result.Ok || data is null)
{
_logger.LogWarning("班级小助手联动上报失败({Reason}):{Message}", reason, result.Message);
SetState(false, result.Message, entryCount, layoutCount, null);
return false;
}
LastServerWeek = data.Week;
UpdateClockOffset(data.ServerTime);
if (data.ScheduleApplied)
{
_logger.LogInformation(
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})",
data.ScheduleCreated, data.ScheduleUpdated);
}
var detail = entryCount > 0
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
: _settings.UploadSchedule
? "仅状态(ClassIsland 里还没有启用的课表)"
: "仅状态(本机「上报课表到班级小助手」开关已关闭)";
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
entryCount, layoutCount, data.Week);
// 顺路把"还没弹出过的"提醒带回来
var pending = data.PendingNotification;
if (pending is not null && _settings.ReceiveNotifications)
{
NotificationReceived?.Invoke(this, pending);
}
if (_settings.MirrorSchedule)
{
await MirrorScheduleAsync().ConfigureAwait(false);
}
return true;
}
catch (Exception exception)
{
// ReportAsync 是通过 `_ = ReportAsync(reason)` 这种 fire-and-forget 方式发起的
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
_logger.LogError(exception, "班级小助手联动:上报异常({Reason})", reason);
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
return false;
}
finally
{
Busy = false;
lock (_gate) _reporting = false;
}
}
/// <summary>
/// 拉取本班尚未弹出过的提醒并逐条交给提醒提供方(用于"应用刚起来还没到第一个上报点"的场景)。
/// </summary>
public async Task PullPendingAsync()
{
if (_disposed) return;
if (!_settings.IsConfigured || !_settings.ReceiveNotifications) return;
// 轮询与"上报顺路拉取"可能同时到达:同一时刻只放一个请求过去
lock (_gate)
{
if (_pulling) return;
_pulling = true;
}
try
{
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
if (!result.Ok)
{
_logger.LogInformation("班级小助手联动:拉取待提醒失败({Message})", result.Message);
return;
}
foreach (var item in items)
{
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
NotificationReceived?.Invoke(this, item);
}
}
finally
{
lock (_gate) _pulling = false;
}
}
/// <summary>把服务端的一条提醒标记为已弹出。</summary>
public async Task AckAsync(string id)
{
var result = await _client.AckAsync(_settings, id, CancellationToken.None).ConfigureAwait(false);
if (result.Ok)
{
NotificationCount += 1;
_logger.LogInformation("班级小助手联动:已确认提醒 {Id}", id);
}
else
{
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message);
}
}
/// <summary>把班级小助手上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
public async Task MirrorScheduleAsync()
{
var profile = ResolveProfile();
if (profile is null) return;
var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false);
if (!result.Ok || plan is null)
{
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
_logger.LogInformation(
"班级小助手联动:跳过课表镜像({Message})。" +
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
result.Message);
return;
}
// 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要
// 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。
var signature = MirrorSignature(plan);
if (signature == _lastMirrorSignature)
{
_logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像");
return;
}
try
{
// **必须在 UI 线程上改写档案**:ClassIsland 的档案是 ObservableDictionary /
// ObservableCollection,落盘是"属性变化即整体序列化 + FileStream 就地截断"。
// 而本方法是从线程池线程继续执行的(ReportAsync 里 ConfigureAwait(false) 之后,
// 定时器回调本就在线程池上),在线程池上改集合会与 UI 线程的保存/绑定并发:
// 轻则 InvalidOperationException(Collection was modified),重则两次写入互相截断,
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
{
var applied = ClassPlanWriter.Apply(profile, plan);
if (!applied.Applied)
{
return applied;
}
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
return applied;
});
if (!outcome.Applied)
{
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
return;
}
_lastMirrorSignature = signature;
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
foreach (var warning in outcome.Warnings)
{
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
}
}
catch (Exception exception)
{
_logger.LogError(exception, "班级小助手联动:课表镜像失败");
}
}
/// <summary>镜像载荷的内容指纹(只用于"内容没变就跳过",不参与任何业务判断)</summary>
private static string MirrorSignature(ClassPlanMirrorDto plan)
{
var builder = new StringBuilder();
foreach (var entry in plan.Entries)
{
builder.Append(entry.WeekDay).Append('|').Append(entry.WeekCountDiv).Append('|')
.Append(entry.WeekCountDivTotal).Append('|').Append(entry.Subject).Append('|')
.Append(entry.StartTime).Append('|').Append(entry.EndTime).Append(';');
}
foreach (var layout in plan.TimeLayouts)
{
builder.Append('#').Append(layout.Id).Append(':');
foreach (var item in layout.Layouts)
{
builder.Append(item.StartTime).Append('-').Append(item.EndTime).Append('@')
.Append(item.TimeType).Append(',');
}
}
return builder.ToString();
}
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "0.1.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
{
try
{
return AppBase.AppVersion ?? "";
}
catch
{
return "";
}
}
/// <summary>
/// 取得当前状态快照。
/// <c>CurrentTimeLayoutItem</c> 在"没有课表"时是 <see cref="ClassIsland.Shared.Models.Profile.TimeLayoutItem.Empty"/>,
/// 语义上等价于"没有节次",因此要转成 null 而不是 00:00。
/// </summary>
private StateDto BuildState(ILessonsService lessons)
{
try
{
return ScheduleMapper.BuildState(lessons, LastServerWeek);
}
catch (Exception exception)
{
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
_logger.LogWarning(exception, "班级小助手联动:读取课程状态失败,本次只上报心跳");
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
}
}
/// <summary>
/// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。
///
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
/// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。
/// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。
/// </summary>
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
ClassIsland.Shared.Models.Profile.Profile profile)
{
if (profile.TimeLayouts.Count == 0) return null;
var best = profile.TimeLayouts
.Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0)))
.Where(item => item.ClassPoints > 0)
.OrderByDescending(item => item.ClassPoints)
.ToList();
if (best.Count > 0) return best[0].Value;
// 兜底:档案里被激活的时间表,最后才是任意一个
return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated)
?? profile.TimeLayouts.Values.First();
}
private static ClassIsland.Shared.Models.Profile.ClassPlan? CurrentClassPlan(
ClassIsland.Shared.Models.Profile.Profile profile)
{
// 用课程服务拿"今天该上哪张课表"最准
try
{
var lessons = IAppHost.TryGetService<ILessonsService>();
return lessons?.CurrentClassPlan;
}
catch
{
return null;
}
}
private ClassIsland.Shared.Models.Profile.Profile? ResolveProfile()
{
try
{
// 档案由 ProfileService 持有;这里用 TryGetService 避免服务未注册时抛异常
return IAppHost.TryGetService<IProfileService>()?.Profile;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "班级小助手联动:读取档案失败");
return null;
}
}
/// <summary>用服务端时间校准本机时钟差(只影响设置页展示,不改系统时间)。</summary>
private void UpdateClockOffset(string serverTime)
{
if (!DateTimeOffset.TryParse(serverTime, out var parsed)) return;
_serverTimeOffset = parsed - DateTimeOffset.Now;
Raise(nameof(ClientTimeOffsetText));
}
private void SetState(bool ok, string message, int entryCount, int layoutCount, int? week)
{
LastReportOk = ok;
LastReportMessage = message;
LastReportAt = DateTimeOffset.Now;
LastScheduleEntryCount = entryCount;
LastTimeLayoutItemCount = layoutCount;
if (week.HasValue) LastServerWeek = week;
StateChanged?.Invoke(this, EventArgs.Empty);
}
private void Raise(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
Raise(propertyName!);
return true;
}
public void Dispose()
{
_disposed = true;
lock (_gate)
{
_timer?.Dispose();
_timer = null;
_notifyTimer?.Dispose();
_notifyTimer = null;
}
_client.Dispose();
}
}
@@ -0,0 +1,260 @@
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassHelper.ClassIslandPlugin.Models;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Shared;
using ClassIsland.Shared.Enums;
using ClassIsland.Core.Abstractions.Services.NotificationProviders;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Models.Notification;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。
///
/// 与 <see cref="BridgeService"/> 的分工:
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
/// 全屏特效 / 语音 / 音效那一整套流程。
///
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里,
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
/// </summary>
// 第 3 个参数是 Fluent 图标字体里的字形字符(ClassIsland 内置提醒提供方用的也是字形字符,
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
[NotificationProviderInfo(
ProviderGuidValue,
"班级小助手提醒",
"\uE8BD",
"显示老师从班级小助手下发的提醒。")]
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
{
/// <summary>
/// 提醒提供方 GUID(全局唯一,别改:改了等于换了一个提供方,用户原有的提醒设置会失联)。
/// 名字带 Value 后缀是为了不和基类的 <see cref="NotificationProviderBase.ProviderGuid"/> 属性撞名。
/// </summary>
public const string ProviderGuidValue = "7C4B1F6E-2A85-4A1F-9E3C-5B7D0C1A9E42";
private readonly BridgeService _bridge;
private readonly PluginSettings _settings;
private readonly ILogger<ClassHelperNotificationProvider> _logger;
/// <summary>
/// 正在播放(尚未回执)的提醒 id。
///
/// 服务端在提醒被回执前会**一直**把它算作"待弹出",而插件是按固定间隔轮询取回的,
/// 因此时长超过轮询间隔的提醒会被反复取到 —— 没有这层去重就会一遍遍重复弹。
/// 只有播完(或用户关掉)才从集合里移除,那一刻才回执。
/// </summary>
private readonly HashSet<string> _playing = new();
/// <summary>
/// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。
///
/// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。
/// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致,
/// 否则学生在客户端看不到、却在 ClassIsland 上被打断。
///
/// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。
/// </summary>
private readonly List<PushNotificationDto> _deferred = new();
private ILessonsService? _lessons;
private bool _lessonsHooked;
public ClassHelperNotificationProvider(
BridgeService bridge,
PluginSettings settings,
ILogger<ClassHelperNotificationProvider> logger)
{
_bridge = bridge;
_settings = settings;
_logger = logger;
_bridge.NotificationReceived += OnNotificationReceived;
// 尽力而为地先订阅一次;拿不到也没关系,后面每次需要判定时都会重试
// (本类的构造函数可能在课程服务建好之前就被 DI 调用)
EnsureLessons();
}
/// <summary>
/// 惰性获取课程服务并**只订阅一次**课程事件(下课后补弹上课时段暂存的提醒)。
///
/// 为什么不能只在构造函数里订阅:提供方是随主机启动被构造的,而课程服务在启动流程里的
/// 创建时机并不固定。一旦构造函数里拿到 null,原先的实现既不重试、也不打日志,
/// 于是 `IsInClass()` 恒为 false —— "上课时段先暂存、下课再补弹"这条规则**永久失效**,
/// 老师在课上发的通知会直接全屏打断课堂,而且完全静默、无从排查。
/// </summary>
private void EnsureLessons()
{
if (_lessonsHooked) return;
try
{
_lessons = IAppHost.TryGetService<ILessonsService>();
if (_lessons is null) return;
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessonsHooked = true;
_logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
}
catch (Exception exception)
{
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试");
}
}
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
private bool IsInClass()
{
EnsureLessons();
try
{
return _lessons?.CurrentState == TimeState.OnClass;
}
catch
{
return false;
}
}
/// <summary>
/// 这条提醒是否必须立刻弹("主动通知"):
/// 紧急提醒,或叫人(老师正在等学生)。
/// </summary>
private static bool IsImmediate(PushNotificationDto notification) =>
notification.Urgent || string.Equals(notification.Kind, "call", StringComparison.OrdinalIgnoreCase);
/// <summary>下课后补弹暂存的提醒(按进入顺序)</summary>
private void FlushDeferred()
{
if (IsInClass()) return; // 还在上课(例如只是状态在 OnClass 内部变化)
if (_deferred.Count == 0) return;
var pending = _deferred.ToList();
_deferred.Clear();
_logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
foreach (var item in pending) ShowOnUiThread(item);
}
private void OnNotificationReceived(object? sender, PushNotificationDto notification)
{
if (!_settings.ReceiveNotifications) return;
if (string.IsNullOrWhiteSpace(notification.Id)) return;
// 同一条提醒在播放期间会被后续轮询反复取回,这里按 id 去重(详见 _playing 的说明)
lock (_playing)
{
if (!_playing.Add(notification.Id))
{
_logger.LogDebug("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
return;
}
}
// 必须切回 UI 线程:提醒内容里的图标、以及提醒主机本身都是 Avalonia 的 UI 对象,
// 而本方法是从上报回调(线程池)里被调用的 —— 在线程池上直接显示会静默失败/抛异常。
Dispatcher.UIThread.Post(() => ShowOnUiThread(notification));
}
private void ShowOnUiThread(PushNotificationDto notification)
{
try
{
// 上课时段:非"主动通知"先收着(与客户端灵动岛同一套规则),下课后补弹
if (IsInClass() && !IsImmediate(notification))
{
_deferred.Add(notification);
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
return;
}
var request = BuildRequest(notification);
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
AckWhenFinished(request, notification);
ShowNotification(request);
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)",
notification.Title, notification.DurationSeconds);
}
catch (Exception exception)
{
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
lock (_playing) _playing.Remove(notification.Id);
_logger.LogError(exception, "班级小助手联动:显示提醒「{Title}」失败", notification.Title);
}
}
/// <summary>
/// 把服务端下发的提醒转成 ClassIsland 的提醒请求。
///
/// 时长口径:老师指定的时长给**正文**(正文才是持续显示的那一段),遮罩只停留几秒 ——
/// 遮罩占满整个时长会把正文压没,不如"遮罩几秒 + 正文 N 秒"清楚。
/// </summary>
private NotificationRequest BuildRequest(PushNotificationDto notification)
{
var seconds = Math.Clamp(
notification.DurationSeconds > 0 ? notification.DurationSeconds : _settings.NotificationDurationSeconds,
1,
120);
var duration = TimeSpan.FromSeconds(seconds);
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
var title = string.IsNullOrWhiteSpace(notification.Title) ? "班级小助手" : notification.Title;
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
var speech = _settings.SpeechEnabled ? notification.SpeechContent : null;
var speechEnabled = !string.IsNullOrWhiteSpace(speech);
// 遮罩用双图标模板的**默认图标**:这两个图标由模板自己创建(LucideIconSource),
// 自己塞图标串很容易踩到"图标表达式格式不对/字形不存在",没必要冒这个险。
var mask = NotificationContent.CreateTwoIconsMask(title, factory: content_ =>
{
content_.Duration = maskDuration;
content_.SpeechContent = speech ?? string.Empty;
// 没勾朗读时明确关掉,否则 ClassIsland 会按默认设置把标题也念出来
content_.IsSpeechEnabled = speechEnabled;
});
// 正文:普通提醒用静态文本;紧急提醒改用**滚动文本**(ClassIsland 自带的抢注意力模板),
// 这样"紧急"在视觉上真的有区别,而不是只靠一个可能不存在的图标。
NotificationContent overlay = notification.Urgent
? NotificationContent.CreateRollingTextContent(content, factory: content_ =>
{
content_.Duration = duration;
content_.SpeechContent = speech ?? string.Empty;
content_.IsSpeechEnabled = speechEnabled;
})
: NotificationContent.CreateSimpleTextContent(content, factory: content_ =>
{
content_.Duration = duration;
content_.SpeechContent = speech ?? string.Empty;
content_.IsSpeechEnabled = speechEnabled;
});
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
}
/// <summary>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</summary>
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
{
void Handler(object? sender, EventArgs args)
{
request.Completed -= Handler;
request.Canceled -= Handler;
// 播完再放行:即使回执失败(例如断网)也移除,让后续轮询能重新投递一次,
// 避免"回执丢了 + 不再重试"把提醒彻底吞掉
lock (_playing) _playing.Remove(notification.Id);
_ = _bridge.AckAsync(notification.Id);
}
request.Completed += Handler;
request.Canceled += Handler;
}
}
@@ -0,0 +1,282 @@
using System.Collections.ObjectModel;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassIsland.Shared.Models.Profile;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。
///
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 7 天与单双周。
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
///
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
/// <c>TimeLayout.Layouts</c> 里第 i 个 <c>TimeType == 0</c>(上课)的点一一对应。
/// 与其去"匹配服务端时间表里的时间点"(一旦对不齐整份课表就错位),
/// 这里**按分组内的条目顺序直接重建时间点**:每条课目生成一个上课点,
/// 并在其结束后接上服务端时间表里同一起始时刻的课间点。这样索引关系天然成立。
///
/// **非破坏性**:
/// - 只创建/更新名字带 <see cref="PlanNamePrefix"/> 前缀的课表与时间表;
/// - 老师手动建的课表一律不删除、不改名(同名星期的冲突会在日志里警告);
/// - 重复镜像时原地更新,不会越积越多。
/// </summary>
public static class ClassPlanWriter
{
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
public const string PlanNamePrefix = "班级小助手-";
/// <summary>时间表名字后缀。</summary>
public const string TimeLayoutNameSuffix = "(班级小助手)";
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
public sealed class ApplyResult
{
public List<string> PlanNames { get; } = new();
public List<string> Warnings { get; } = new();
public bool Applied { get; set; }
}
/// <summary>
/// 应用服务端下发的课表。
/// </summary>
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source)
{
var result = new ApplyResult();
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
{
result.Warnings.Add("服务端返回的课表为空,未改动 ClassIsland 档案");
return result;
}
var layout = source.TimeLayouts[0];
// 1) 补齐科目(同名的复用,避免每次镜像堆出新的 Subject)
EnsureSubjects(profile, source);
// 2) 按(星期几 + 单双周)分组,每组一份课表
var groups = source.Entries
.GroupBy(entry => new
{
entry.WeekDay,
entry.WeekCountDiv,
entry.WeekCountDivTotal,
})
.Select(group => new
{
group.Key.WeekDay,
group.Key.WeekCountDiv,
group.Key.WeekCountDivTotal,
// 时间点顺序 = 课表顺序:`Classes[i]` 靠这个顺序与上课时间点对齐,
// 「HH:mm」是定长字符串,字典序就是时间序
Entries = group.OrderBy(entry => entry.StartTime, StringComparer.Ordinal).ToList(),
})
.OrderBy(group => group.WeekDay)
.ToList();
foreach (var group in groups)
{
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
group.WeekCountDivTotal, groups.Count);
// 时间非法的条目必须**在这里**就整批丢掉:`Classes` 与时间表里的上课点必须等长且同序,
// 只在一处跳过会让整份课表错位。原先 ParseTime 把无法解析的值悄悄变成 00:00,
// 于是档案里凭空出现 00:00-00:00 的课次(时间轴显示与当前节次判断都会乱),还没有任何日志。
var usable = group.Entries
.Where(entry => IsValidTime(entry.StartTime) && IsValidTime(entry.EndTime))
.ToList();
if (usable.Count < group.Entries.Count)
{
result.Warnings.Add(
$"「{planName}」有 {group.Entries.Count - usable.Count} 节的时间无法解析(需要 HH:mm),已跳过");
}
if (usable.Count == 0) continue;
var planId = FindPlanId(profile, planName);
var (timeLayoutId, timeLayout) = BuildTimeLayout(profile, planName, usable);
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
? existing
: new ClassPlan();
plan.Name = planName;
plan.TimeLayoutId = timeLayoutId;
plan.TimeRule = new TimeRule
{
WeekDay = group.WeekDay,
WeekCountDiv = group.WeekCountDiv,
WeekCountDivTotal = group.WeekCountDivTotal,
};
// Classes[i] ↔ 第 i 个上课点:按分组内条目的时间顺序直接构造,索引天然一致
// ClassIsland 的 `ClassInfo.CurrentTimeLayoutItem` = CurrentTimeLayout 里第 Index 个上课点,
// 因此这里必须显式把 Index 与 CurrentTimeLayout 一起写上 —— 只在构造时依赖
// `RefreshClassesList()` 是不够的:更新已有课表时 `TimeLayouts` 引用没变,
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
var classes = new ObservableCollection<ClassInfo>();
var index = 0;
foreach (var entry in usable)
{
var subjectId = profile.Subjects
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
classes.Add(new ClassInfo
{
Index = index++,
CurrentTimeLayout = timeLayout,
SubjectId = subjectId,
IsEnabled = true,
});
}
plan.Classes = classes;
plan.IsEnabled = true;
profile.ClassPlans[planId] = plan;
result.PlanNames.Add(planName);
// 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表
var conflicts = profile.ClassPlans
.Where(pair => pair.Key != planId
&& pair.Value.IsEnabled
&& !pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)
&& pair.Value.TimeRule.WeekDay == group.WeekDay
&& pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal
&& pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv)
.Select(pair => pair.Value.Name)
.ToList();
if (conflicts.Count > 0)
{
result.Warnings.Add(
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
"两者重叠时以班级小助手同步的课表为准");
}
}
// 3) 停用上一次镜像留下来的、本次不再需要的课表(只动自己创建的)
var currentNames = new HashSet<string>(result.PlanNames);
foreach (var pair in profile.ClassPlans)
{
if (!pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)) continue;
if (currentNames.Contains(pair.Value.Name)) continue;
pair.Value.IsEnabled = false;
}
result.Applied = true;
return result;
}
private static void EnsureSubjects(Profile profile, ClassPlanMirrorDto source)
{
foreach (var name in source.Entries.Select(entry => entry.Subject).Distinct())
{
if (string.IsNullOrWhiteSpace(name)) continue;
if (profile.Subjects.Any(pair => pair.Value.Name == name)) continue;
profile.Subjects[Guid.NewGuid()] = new Subject { Name = name };
}
}
private static Guid FindPlanId(Profile profile, string planName)
{
var existing = profile.ClassPlans.FirstOrDefault(pair => pair.Value.Name == planName);
return existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
}
/// <summary>
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
///
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
/// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表,
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
/// </summary>
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
Profile profile,
string planName,
IReadOnlyList<ClassPlanEntryMirrorDto> entries)
{
var layoutName = planName + TimeLayoutNameSuffix;
var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName);
var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
var layout = new TimeLayout { Name = layoutName, IsActivated = true };
for (var index = 0; index < entries.Count; index++)
{
var entry = entries[index];
layout.Layouts.Add(new TimeLayoutItem
{
TimeType = 0, // 上课
StartTime = ParseTime(entry.StartTime),
EndTime = ParseTime(entry.EndTime),
});
// 课间:下一节开始晚于本节结束才有空隙;最后一节之后(放学)不插
if (index + 1 >= entries.Count) continue;
var next = entries[index + 1];
var breakStart = ParseTime(entry.EndTime);
var breakEnd = ParseTime(next.StartTime);
if (breakEnd <= breakStart) continue;
layout.Layouts.Add(new TimeLayoutItem
{
TimeType = 1, // 课间
StartTime = breakStart,
EndTime = breakEnd,
});
}
profile.TimeLayouts[id] = layout;
return (id, layout);
}
/// <summary>
/// 课表名:单组时用「班级小助手-&lt;班级名&gt;」;多组时补上星期与单双周以免互相覆盖。
/// </summary>
private static string BuildPlanName(
string profileName,
int weekDay,
int weekCountDiv,
int weekCountDivTotal,
int groupCount)
{
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : profileName.Trim();
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
{
baseName = PlanNamePrefix + baseName;
}
if (groupCount <= 1) return baseName;
var weekday = weekDay switch
{
0 => "周日",
1 => "周一",
2 => "周二",
3 => "周三",
4 => "周四",
5 => "周五",
6 => "周六",
_ => $"星期{weekDay}",
};
var parity = weekCountDivTotal <= 1
? "每周"
: $"第{weekCountDiv}/{weekCountDivTotal}周";
return $"{baseName} · {weekday} · {parity}";
}
/// <summary>"HH:mm" / "HH:mm:ss" → TimeSpan(解析失败回退到 0 点)。</summary>
private static TimeSpan ParseTime(string value)
{
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
}
/// <summary>
/// 时间字符串是否可用(HH:mm / HH:mm:ss)。
///
/// 服务端输出侧没有校验 `Schedule.startTime`(老数据/异常数据可能是空串),
/// 而 <see cref="ParseTime"/> 会把非法值变成 0 点 —— 因此调用方必须先用本方法过滤,
/// 并把 Classes 与时间表**同时**按过滤后的列表构造,保证索引仍然一致。
/// </summary>
private static bool IsValidTime(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
return TimeSpan.TryParse(value, out var parsed)
&& parsed >= TimeSpan.Zero
&& parsed < TimeSpan.FromDays(1);
}
}
@@ -0,0 +1,272 @@
using System.Collections.ObjectModel;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Shared;
using ClassIsland.Shared.Enums;
using ClassIsland.Shared.Models.Profile;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 课表 / 状态 → 上报载荷 的换算层。
///
/// 这里集中全部"ClassIsland 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着
/// ClassIsland 的模型变化而修改:
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
/// - 节次:ClassIsland <c>TimeLayoutItem.TimeType</c> 0=上课 1=课间 2=分割线 3=行动。
/// </summary>
public static class ScheduleMapper
{
/// <summary>
/// ClassIsland 的 WeekDay(0=周日…6=周六)→ 本系统 dayOfWeek(1=周一…7=周日)。
/// </summary>
public static int ToServerDayOfWeek(int weekDay)
{
var normalized = ((weekDay % 7) + 7) % 7;
return normalized == 0 ? 7 : normalized;
}
/// <summary>
/// ClassIsland 的 WeekCountDiv/Total → 本系统单双周。
/// 与 <c>packages/shared/src/utils.ts</c> 的 <c>weekParityFromDiv</c> 完全一致:
/// 3 周及以上的循环本系统无法表达,降级为「每周」。
/// </summary>
public static string ToWeekParity(int weekCountDiv, int weekCountDivTotal)
{
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return "ALL";
var position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
if (weekCountDivTotal == 2) return position == 1 ? "ODD" : "EVEN";
return "ALL";
}
/// <summary>ClassIsland TimeType → 本系统节次类型。</summary>
public static string ToTimeLayoutType(int timeType) => timeType switch
{
1 => "break",
2 => "divider",
3 => "action",
_ => "class",
};
/// <summary>TimeSpan → "HH:mm"(跨天的时间点做钳制,避免出现 24:xx)。</summary>
public static string ToHhMm(TimeSpan time)
{
var totalMinutes = (int)Math.Round(time.TotalMinutes);
totalMinutes = Math.Clamp(totalMinutes, 0, 24 * 60 - 1);
return $"{totalMinutes / 60:D2}:{totalMinutes % 60:D2}";
}
/// <summary>
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
///
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,课间 / 分割线 / 行动不占位。
///
/// **注意不要用 `plan.ValidTimeLayoutItems`**:它的口径与 `Classes` 不同 ——
/// `GetValidTimeLayoutItems()`(ClassIsland ClassPlan.cs)会正反两遍扫描,
/// 把 `ClassInfo.IsEnabled == false`(老师在课表编辑里"停用"这一节)的课**连同其后的课间**
/// 一起从结果里剔除;而 `RefreshClassesList()` 只按 `TimeType == 0` 的**总数**对齐
/// `Classes` 的长度,`ClassInfo.CurrentTimeLayoutItem` 也是按原始列表的第 `Index` 个取的。
/// 于是只要停用过任意一节,用 ValidTimeLayoutItems 配对就会让索引整体前移:
/// 第 i 节配上第 i+1 节的时间点、最后一节丢失,且 `Math.Min` 会把缺口静默吃掉 ——
/// 表现就是"课表看着有内容、每节课都对不上",极难排查。
/// </summary>
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
{
warning = null;
var entries = new List<ScheduleEntryDto>();
var timeLayout = ResolveTimeLayout(plan, profile);
if (timeLayout is null)
{
warning = $"课表「{plan.Name}」找不到对应的时间表,已跳过";
return entries;
}
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致(见上面注释)
var classPoints = timeLayout.Layouts.Where(item => item.TimeType == 0).ToList();
var weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
if (plan.TimeRule.WeekCountDivTotal > 2)
{
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理";
}
var classes = plan.Classes;
var count = Math.Min(classes.Count, classPoints.Count);
for (var index = 0; index < count; index++)
{
var info = classes[index];
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
var point = classPoints[index];
if (info is null) continue;
// 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课)
if (!info.IsEnabled) continue;
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
var subject = ResolveSubjectName(info.SubjectId, profile);
if (string.IsNullOrWhiteSpace(subject)) continue;
entries.Add(new ScheduleEntryDto
{
DayOfWeek = weekDay,
StartTime = ToHhMm(point.StartTime),
EndTime = ToHhMm(point.EndTime),
// 服务端对这几个字段有 zod 长度上限,且是**整批**校验:一个超长名字会让整次上报
// 400,连"现在上什么课"的状态与心跳都不更新,Web 端把教室显示成离线。
Subject = Truncate(subject, SubjectMaxLength)!,
TeacherName = Truncate(ResolveSubjectTeacher(info.SubjectId, profile), NameMaxLength),
WeekParity = parity,
WeekCountDiv = plan.TimeRule.WeekCountDiv,
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
PlanName = Truncate(plan.Name, PlanNameMaxLength),
});
}
return entries;
}
/// <summary>服务端 zod 对各类名称的长度上限(超出会让整批上报 400,所以要截断) */
public const int SubjectMaxLength = 40;
public const int NameMaxLength = 40;
public const int PlanNameMaxLength = 60;
/// <summary>按服务端上限截断(null / 空串原样返回)</summary>
public static string? Truncate(string? value, int max)
{
if (string.IsNullOrEmpty(value)) return value;
var trimmed = value.Trim();
return trimmed.Length <= max ? trimmed : trimmed[..max];
}
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
{
var items = new List<TimeLayoutItemDto>();
// ClassIsland 的上课时间点没有独立名称,这里按"它之前有几个上课点"编出"第 N 节"
var classPointIndex = 0;
for (var index = 0; index < layout.Layouts.Count; index++)
{
var point = layout.Layouts[index];
items.Add(new TimeLayoutItemDto
{
Index = index + 1,
// 节次名同样是服务端有上限的字段(max 40),超长会让整批上报失败
Name = Truncate(BuildTimePointName(point, ref classPointIndex), NameMaxLength)!,
StartTime = ToHhMm(point.StartTime),
EndTime = ToHhMm(point.EndTime),
Type = ToTimeLayoutType(point.TimeType),
Skipped = false,
});
}
return items;
}
/// <summary>
/// 节次名称:ClassIsland 只有课间有名字(BreakName),上课时间点没有独立名称,
/// 因此按类型生成"第 N 节 / 课间 / 分割线",保证本系统的时间轴可读。
/// </summary>
private static string BuildTimePointName(TimeLayoutItem point, ref int classPointIndex)
{
switch (point.TimeType)
{
case 1:
return string.IsNullOrWhiteSpace(point.BreakName) ? "课间" : point.BreakName!;
case 2:
return "分割线";
case 3:
return "行动";
default:
classPointIndex++;
return $"第 {classPointIndex} 节";
}
}
/// <summary>取出课表使用的时间表:优先按 TimeLayoutId,其次用档案里第一个。</summary>
public static TimeLayout? ResolveTimeLayout(ClassPlan plan, Profile profile)
{
if (profile.TimeLayouts.Count == 0) return null;
if (plan.TimeLayoutId != Guid.Empty && profile.TimeLayouts.TryGetValue(plan.TimeLayoutId, out var byId))
{
return byId;
}
return plan.TimeLayout ?? profile.TimeLayouts.Values.FirstOrDefault();
}
/// <summary>科目名(找不到时返回空字符串,由调用方决定是否跳过)。</summary>
public static string? ResolveSubjectName(Guid subjectId, Profile profile)
{
if (subjectId == Guid.Empty) return null;
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.Name : null;
}
/// <summary>科目任课老师(ClassIsland 在 Subject 上记录 TeacherName)。</summary>
public static string? ResolveSubjectTeacher(Guid subjectId, Profile profile)
{
if (subjectId == Guid.Empty) return null;
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.TeacherName : null;
}
/// <summary>
/// 收集档案里今天(按当前周次与星期)应该上的课表,
/// 用于"只上报今天"这类轻量场景;当前实现为上报全部课表(服务端按周次并存)。
/// </summary>
public static List<ScheduleEntryDto> MapAllClassPlans(Profile profile, out List<string> warnings)
{
warnings = new List<string>();
var all = new List<ScheduleEntryDto>();
foreach (var plan in profile.ClassPlans.Values)
{
if (!plan.IsEnabled) continue;
if (plan.IsOverlay) continue; // 临时层课表不下发,避免把临时调整固化到班级课表
var entries = MapClassPlan(plan, profile, out var warning);
if (!string.IsNullOrWhiteSpace(warning) && !warnings.Contains(warning!))
{
warnings.Add(warning!);
}
all.AddRange(entries);
}
return all;
}
/// <summary>
/// 科目名归一化。
///
/// 没有课表 / 没有当前科目时,ClassIsland 会把 <see cref="Subject.Fallback"/> 挂到
/// `CurrentSubject` / `NextClassSubject` 上(见 LessonsService:
/// `CurrentSubject = currentSubject ?? Subject.Fallback`),而它的名字就是占位符本身。
/// 直接上报会让 Web 端显示成"正在上 ???",因此这里统一折算成"没有科目"。
/// </summary>
private static string? NormalizeSubjectName(Subject? subject)
{
if (subject is null) return null;
if (ReferenceEquals(subject, Subject.Fallback)) return null;
var name = subject.Name?.Trim();
if (string.IsNullOrEmpty(name)) return null;
// 兜底:万一拿到的是另一个同名占位符实例(不是同一个引用)
if (string.Equals(name, Subject.Fallback.Name, StringComparison.Ordinal)) return null;
return name;
}
/// <summary>当前状态快照:服务端据此在 Web 端展示"现在上什么课"。</summary>
public static StateDto BuildState(ILessonsService lessons, int? currentWeek)
{
var current = lessons.CurrentTimeLayoutItem;
var state = lessons.CurrentState;
return new StateDto
{
InClass = state == TimeState.OnClass,
Subject = NormalizeSubjectName(lessons.CurrentSubject),
NextSubject = NormalizeSubjectName(lessons.NextClassSubject),
TimeState = state.ToString(),
PeriodStart = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.StartTime),
PeriodEnd = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.EndTime),
Week = currentWeek,
ClassPlanLoaded = lessons.IsClassPlanLoaded,
ClientTime = DateTimeOffset.Now.ToString("o"),
};
}
}
@@ -0,0 +1,171 @@
<controls:SettingsPageBase x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:ClassHelper.ClassIslandPlugin.Views"
xmlns:controls="clr-namespace:ClassIsland.Core.Abstractions.Controls;assembly=ClassIsland.Core"
xmlns:ci="clr-namespace:ClassIsland.Core.Controls;assembly=ClassIsland.Core"
xmlns:fa="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
d:DataContext="{d:DesignInstance local:BridgeSettingsPage}"
Loaded="OnLoaded"
Unloaded="OnUnloaded">
<ScrollViewer>
<StackPanel Classes="settings-container animated-intro" Spacing="0">
<!-- 连接 -->
<TextBlock Text="连接班级小助手"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
<TextBox Watermark="http://127.0.0.1:4000"
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
<TextBox Margin="0 8 0 0"
Watermark="chci_..."
PasswordChar="●"
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
<!-- 令牌是设备级长期凭证(只有管理员在 Web 端重置才会失效),而 ClassIsland 的插件配置
是明文 JSON 且会随档案备份/迁移一起被带走,所以这里明确提示用户别外传。 -->
<TextBlock Margin="0 6 0 0" TextWrapping="Wrap" Opacity="0.7"
Text="令牌是这台教室机器的长期凭证(仅管理员在 Web 端重置才会失效),会明文保存在本机配置文件里并随 ClassIsland 档案备份一起复制,请不要外传或提交到版本库。" />
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="检查连接" Command="{Binding TestCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="拉取待提醒" Command="{Binding PullCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="重建档案课表" Command="{Binding MirrorCommand}"
IsEnabled="{Binding Settings.MirrorSchedule}" />
</StackPanel>
<!-- 状态 -->
<Border Margin="0 16 0 0" Padding="12" CornerRadius="4"
Background="{DynamicResource SystemFillColorNeutralBackgroundBrush}">
<StackPanel Spacing="4">
<TextBlock Text="{Binding StatusText}" TextWrapping="Wrap" FontWeight="SemiBold" />
<TextBlock Text="{Binding Bridge.LastReportMessage}" TextWrapping="Wrap" />
<TextBlock Text="{Binding DetailText}" TextWrapping="Wrap" Opacity="0.8" />
</StackPanel>
</Border>
<!-- 同步 -->
<TextBlock Margin="0 24 0 0" Text="同步内容"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" />
<fa:SettingsExpander Header="上报课表到班级小助手"
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8A5;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.UploadSchedule, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8F1;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.MirrorSchedule, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<fa:SettingsExpander Header="自动上报"
Description="应用启动、课表变化以及每隔一段时间各上报一次;关闭后只能用上面的「立即上报」手动触发。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE895;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.AutoReport, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="上报间隔(秒)" />
<TextBlock Text="最小 15 秒;教室机器建议 60 秒。" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="15" Maximum="3600" Increment="15"
FormatString="0"
IsEnabled="{Binding Settings.AutoReport}"
Value="{Binding Settings.ReportIntervalSeconds, Mode=TwoWay}" />
</Grid>
<!-- 提醒 -->
<TextBlock Margin="0 24 0 0" Text="提醒"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
<fa:SettingsExpander Header="接收班级小助手提醒"
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8BD;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.ReceiveNotifications, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<fa:SettingsExpander Header="语音朗读"
Description="提醒弹出时按 ClassIsland 的语音设置为全机朗读提醒内容。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8D6;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.SpeechEnabled, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="默认显示时长(秒)" />
<TextBlock Text="老师在下发单条提醒时可以另外指定更长的时长。" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="1" Maximum="120" Increment="1"
FormatString="0"
Value="{Binding Settings.NotificationDurationSeconds, Mode=TwoWay}" />
</Grid>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="提醒轮询间隔(秒)" />
<TextBlock Text="老师按下发送到教室弹出之间的最大延迟(插件不持有长连接,是轮询取回提醒)。默认 10 秒。"
Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="5" Maximum="600" Increment="5"
FormatString="0"
IsEnabled="{Binding Settings.ReceiveNotifications}"
Value="{Binding Settings.NotificationPollSeconds, Mode=TwoWay}" />
</Grid>
<!-- 调试 -->
<TextBlock Margin="0 24 0 0" Text="调试"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<fa:SettingsExpander Header="显示调试信息"
Description="显示设备标识、时钟差等细节,排查「为什么同步不上」时打开。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE7EF;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.ShowDebugInfo, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<StackPanel Margin="0 8 0 0" Spacing="2" IsVisible="{Binding Settings.ShowDebugInfo}">
<TextBlock Text="{Binding DebugText}" TextWrapping="Wrap" FontFamily="Consolas,monospace" />
</StackPanel>
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
Text="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
</StackPanel>
</ScrollViewer>
</controls:SettingsPageBase>
@@ -0,0 +1,198 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Models;
using ClassHelper.ClassIslandPlugin.Services;
using ClassIsland.Core.Abstractions.Controls;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Enums.SettingsWindow;
using CommunityToolkit.Mvvm.Input;
namespace ClassHelper.ClassIslandPlugin.Views;
/// <summary>
/// 「班级小助手联动」设置页。
///
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
/// 不需要再套一层 ViewModel,改配置项时也少一处"两边同步"的机会。
/// </summary>
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
[SettingsPageInfo(
"classhelper.classisland.bridge.settings",
"班级小助手联动",
"\uE71B",
"\uE71B",
SettingsPageCategory.External)]
public partial class BridgeSettingsPage : SettingsPageBase, INotifyPropertyChanged
{
public BridgeSettingsPage(PluginSettings settings, BridgeService bridge)
{
Settings = settings;
Bridge = bridge;
ReportCommand = new AsyncRelayCommand(ReportAsync);
TestCommand = new AsyncRelayCommand(TestAsync);
PullCommand = new AsyncRelayCommand(PullAsync);
MirrorCommand = new AsyncRelayCommand(MirrorAsync);
InitializeComponent();
DataContext = this;
Raise(nameof(StatusText));
}
public PluginSettings Settings { get; }
public BridgeService Bridge { get; }
public IAsyncRelayCommand ReportCommand { get; }
public IAsyncRelayCommand TestCommand { get; }
public IAsyncRelayCommand PullCommand { get; }
public IAsyncRelayCommand MirrorCommand { get; }
private PropertyChangedEventHandler? _propertyChanged;
/// <summary>
/// 显式实现 <see cref="INotifyPropertyChanged"/>:<c>AvaloniaObject</c> 自带一个同名但类型不同的事件,
/// 直接声明会遮蔽基类成员。显式实现既不影响 Avalonia 的绑定查找(它按接口取),也不会有歧义。
/// </summary>
event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
{
add => _propertyChanged += value;
remove => _propertyChanged -= value;
}
/* ---------------------------------------------------------------- 绑定用的展示文本 */
/// <summary>一句话结论:还没配 / 已配置 / 已连接 / 连接异常。</summary>
public string StatusText
{
get
{
if (!Settings.IsConfigured) return "尚未配置:请填写服务器地址与设备令牌";
if (!Bridge.IsAttached) return "已填写配置,等待 ClassIsland 课程服务就绪";
return Bridge.LastReportOk ? "已连接" : "连接异常";
}
}
public string DetailText
{
get
{
var week = Bridge.LastServerWeek?.ToString() ?? "—";
return $"最后上报:{Bridge.LastReportAtText} 教学周:{week} " +
$"课表 {Bridge.LastScheduleEntryCount} 节 / 节次 {Bridge.LastTimeLayoutItemCount} 条 " +
$"已弹出提醒:{Bridge.NotificationCount} 条";
}
}
/// <summary>调试信息(设置页里打开"显示调试信息"才可见)。</summary>
public string DebugText
{
get
{
var lines = new List<string>
{
$"服务器地址:{(string.IsNullOrWhiteSpace(Settings.ServerUrl) ? "(未填)" : Settings.ServerUrl)}",
$"设备令牌:{(string.IsNullOrWhiteSpace(Settings.DeviceToken) ? "(未填)" : Mask(Settings.DeviceToken))}",
$"设备标识:{(string.IsNullOrWhiteSpace(Settings.DeviceKey) ? "(未生成)" : Settings.DeviceKey)}",
$"课程服务:{(Bridge.IsAttached ? "已接入" : "未接入")}",
$"与服务端时钟差:{Bridge.ClientTimeOffsetText}",
$"ClassIsland 版本:{SafeVersion()}",
$"配置目录:{PluginConfigFolder}",
};
return string.Join(Environment.NewLine, lines);
}
}
/// <summary>设置页所在的插件配置目录(从插件入口拿;拿不到时留空)。</summary>
private string PluginConfigFolder =>
ClassIsland.Shared.IAppHost.TryGetService<ClassIsland.Core.Abstractions.PluginBase>()?.PluginConfigFolder ?? "—";
private static string SafeVersion()
{
try { return ClassIsland.Core.AppBase.AppVersion ?? "—"; }
catch { return "—"; }
}
/// <summary>令牌只露头尾,避免设置页截图时把可用凭证泄露出去。</summary>
private static string Mask(string token) =>
token.Length <= 14 ? token : $"{token[..12]}…{token[^4..]}";
/* ---------------------------------------------------------------- 交互 */
private async Task ReportAsync()
{
await Bridge.ReportAsync("手动").ConfigureAwait(true);
}
/// <summary>不带课表上报一次:用来快速验证"地址 + 令牌"是否可用,不会因为课表为空而出错。</summary>
private async Task TestAsync()
{
await Bridge.ReportAsync("连接检查", includeSchedule: false).ConfigureAwait(true);
}
private async Task PullAsync()
{
await Bridge.PullPendingAsync().ConfigureAwait(true);
}
private async Task MirrorAsync()
{
await Bridge.MirrorScheduleAsync().ConfigureAwait(true);
}
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs args)
{
// 影响上报链路的改动需要立刻重建定时器;纯界面项(如"显示调试信息")不用管
if (args.PropertyName is nameof(PluginSettings.ServerUrl)
or nameof(PluginSettings.DeviceToken)
or nameof(PluginSettings.AutoReport)
or nameof(PluginSettings.ReportIntervalSeconds)
or nameof(PluginSettings.ReceiveNotifications)
or nameof(PluginSettings.NotificationPollSeconds))
{
Bridge.RestartTimer();
}
Raise(nameof(StatusText));
Raise(nameof(DebugText));
}
private void OnBridgeChanged(object? sender, PropertyChangedEventArgs args)
{
// Bridge 的属性变化可能来自线程池(HttpClient 回调),绑定必须在 UI 线程上更新
if (Dispatcher.UIThread.CheckAccess())
{
RaiseStateProperties();
}
else
{
Dispatcher.UIThread.Post(RaiseStateProperties);
}
}
private void RaiseStateProperties()
{
Raise(nameof(StatusText));
Raise(nameof(DetailText));
Raise(nameof(DebugText));
}
private void OnLoaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
// 改完地址/令牌/间隔要重建定时器,否则"改了设置但上报节奏还是旧的"
Settings.PropertyChanged += OnSettingsChanged;
// 运行状态(上一次上报结果 / 待弹出提醒数)变了要刷新状态卡片
Bridge.PropertyChanged += OnBridgeChanged;
}
private void OnUnloaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
Settings.PropertyChanged -= OnSettingsChanged;
Bridge.PropertyChanged -= OnBridgeChanged;
}
private void Raise([CallerMemberName] string? propertyName = null) =>
_propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
@@ -6,6 +6,11 @@ directories:
output: release
buildResources: build
# 注意:这里**不要**加 afterPack 去改 appOutDir 的完整性标签。
# 归档内部的载荷标签不会被还原(装到正常位置拿到的是默认 Medium 文件),
# 而在打包前把 appOutDir 改成 Medium 会让 7za(自身带 Low 标签、进程被降到 Low)
# 因"不能向上读"而无法生成归档。交付物的标签统一在 scripts/dist-win.mjs 收尾时修正。
# 渲染进程产物已由 Vite 打包、主进程/preload 已由 esbuild 打包,
# 运行时不需要任何 node_modules(electron 模块由 Electron 运行时提供)。
files:
+18 -1
View File
@@ -3,7 +3,24 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Electron 渲染进程安全策略:只允许加载本地资源与后端 HTTP/WS 连接 -->
<!--
内容安全策略(Electron 渲染进程)。
为什么必须有:渲染进程通过 preload 桥能拿到 `getConfig()` 返回的**明文登录令牌**
(代表该班全班身份),因此一旦出现一次注入(未来某个 v-html、某个依赖被投毒),
脚本就能把令牌发到外网。CSP 是唯一能拦住"加载并执行远程脚本"的那一层。
各指令口径:
- default-src/script-src 'self':只允许执行本应用自己的脚本(挡住远程脚本注入);
- style-src 需要 'unsafe-inline':Vue SFC 与 Element Plus 运行时会注入行内样式;
- connect-src 放开 http/https/ws/wss:服务器地址由用户在设置页填写(可能是内网 IP、域名、
HTTPS 反代),无法预先枚举,且连接本身不是"执行代码"的入口;
- object-src/frame-src/form-action 一律关掉:本应用不需要插件、内嵌页面与表单提交。
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
/>
<title>班级小助手</title>
</head>
<body>
+5
View File
@@ -3,6 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 与主窗口同一套 CSP(理由见 index.html):灵动岛同样是持有 preload 桥的渲染进程 -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
/>
<title>班级小助手 · 灵动岛</title>
<style>
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
+2 -1
View File
@@ -7,9 +7,10 @@
"main": "dist/main/index.js",
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "npm run build:main && npm run build:renderer",
"build": "npm run build:main && npm run build:renderer && npm run check:secrets",
"build:main": "node scripts/build-main.mjs",
"build:renderer": "vite build",
"check:secrets": "node scripts/check-bundle-secrets.mjs",
"start": "electron .",
"smoke": "node scripts/smoke.mjs",
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* 构建产物凭据自检:`dist` 里不允许出现种子账号 / 口令字面量。
*
* 为什么单独立一条门禁:`dist/main/index.js` 与 `dist/renderer/assets/*.js` 会被 electron-builder
* 打进 `app.asar`,随安装包发到每台学生机,而 **asar 可以直接解包**。
* 冒烟/自检代码里写死 `teacher1 / teacher123` 这类字面量,等于把教师账号随安装包一起发出去
* (历史上真的发生过:`grep teacher123 dist/main/index.js` 命中 3 次)。
*
* 凭据一律由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供
* (`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `ELECTRON_SMOKE_CLASS_CODE` / `..._CLASS_PASSWORD`),
* 那两个脚本不进安装包。
*
* 命中即构建失败(exit 1)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const distDir = path.join(root, 'dist');
/** 明确的种子口令:任何情况下都不该出现在产物里 */
const FORBIDDEN_STRINGS = ['teacher123', 'admin123', 'student123'];
/** 组合形态:把种子班级码 / 默认班级密码当凭据使用的写法 */
const FORBIDDEN_PATTERNS = [
{ label: '种子班级码当凭据', pattern: /["'](?:code|classCode)["']\s*[:=]\s*["']G(?:101|102|203)["']/ },
{
label: '默认班级密码当凭据',
pattern: /["'](?:password|classPassword)["']\s*[:=]\s*["']123456["']/,
},
];
/** 递归收集文件(跳过 sourcemap 与二进制) */
function collectFiles(dir) {
const found = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
found.push(...collectFiles(full));
} else if (/\.(?:js|mjs|cjs|html|json|css)$/i.test(entry.name)) {
found.push(full);
}
}
return found;
}
if (!fs.existsSync(distDir)) {
console.log('[check-secrets] 未找到 dist(尚未构建),跳过凭据自检');
process.exit(0);
}
const hits = [];
for (const file of collectFiles(distDir)) {
const content = fs.readFileSync(file, 'utf8');
const relative = path.relative(root, file);
for (const needle of FORBIDDEN_STRINGS) {
if (content.includes(needle)) hits.push(`${relative}:出现种子口令「${needle}」`);
}
for (const { label, pattern } of FORBIDDEN_PATTERNS) {
if (pattern.test(content)) hits.push(`${relative}:${label}`);
}
}
if (hits.length > 0) {
console.error('[check-secrets] 构建产物里出现了种子凭据(asar 可被解包,不能随包分发):');
for (const hit of hits) console.error(` - ${hit}`);
console.error(
'\n请把凭据改为从环境变量读取(见 scripts/smoke.mjs 的 ELECTRON_SMOKE_USER 等),不要写进源码。',
);
process.exit(1);
}
console.log(`[check-secrets] 通过:${collectFiles(distDir).length} 个产物文件里没有任何种子凭据字面量`);
+55 -1
View File
@@ -12,7 +12,7 @@
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked
*/
import { spawn } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -24,8 +24,17 @@ const args = process.argv.slice(2);
const dirOnly = args.includes('--dir');
const passthrough = args.filter((item) => item !== '--dir');
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
// `!tempfile: Unable to create temporary file!`,NSIS 那一步就失败(磁盘并不紧张)。
// 统一指到仓库内的真实目录,避开宿主终端带来的环境差异。
const tmpDir = path.join(repoRoot, '.cache', 'tmp');
fs.mkdirSync(tmpDir, { recursive: true });
const env = {
...process.env,
TEMP: tmpDir,
TMP: tmpDir,
ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/',
ELECTRON_BUILDER_BINARIES_MIRROR:
process.env.ELECTRON_BUILDER_BINARIES_MIRROR ??
@@ -60,11 +69,56 @@ console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
const runtime = resolveNodeRuntime();
console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
// 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)),
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
const outputDir = path.join(root, 'release');
if (fs.existsSync(outputDir)) {
for (const name of fs.readdirSync(outputDir)) {
if (!/\.(exe|7z|blockmap|yml)$/i.test(name)) continue;
try {
fs.rmSync(path.join(outputDir, name), { force: true });
} catch (error) {
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
}
}
console.log('[dist] 已清理上一轮的安装包 / 归档');
}
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
/**
* 把交付产物的完整性标签修正为 Medium(Windows)。
*
* 背景(踩过一次,代价是"客户端装不上、便捷版也双击没反应"):
* 本仓库目录树被宿主沙箱打上了 `Low Mandatory Level` 标签(可继承),
* 因此在仓库内构建出的 exe 自身也带 Low 标签。Windows 会把这类进程降到 Low 完整性,
* 于是 setup.exe 写不进 `%LOCALAPPDATA%`(报"拒绝访问"→ 装不上)、
* 便捷版解不出 `%TEMP%`(→ 起不来);即使装上了,Low 标签的应用文件也会让
* Chromium 的 AppContainer 子进程读不到(0x80000003 秒崩)。
*
* 要点:**只有交付物自身的标签要紧**。归档内部的载荷标签不会被还原
* (装到正常位置得到的是默认 Medium 文件),所以打包完成后再统一修正即可 ——
* 反过来说,不能在打包前把 appOutDir 改成 Medium:那样 7za(自身 Low)
* 会因"不能向上读/写"而无法生成归档。
*/
function normalizeIntegrityLabel(dir) {
if (process.platform !== 'win32') return;
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
try {
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
// 目录自身恢复为"继承父目录的 Low",保证下次构建时 Low 的 7za 仍能写入归档
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
console.log('[dist] 已修正交付物完整性标签为 Medium,目录恢复继承:', dir);
} catch (error) {
console.warn('[dist] 完整性标签修正跳过:', error.message);
}
}
child.on('exit', (code) => {
if (code === 0) {
const output = path.join(root, 'release');
normalizeIntegrityLabel(output);
console.log('\n[dist] 打包完成,产物目录:', output);
}
process.exit(code ?? 1);
+184 -16
View File
@@ -26,9 +26,15 @@ if (missing.length > 0) {
console.log('[smoke] 启动 Electron 冒烟验证...\n');
// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)
const profileDir = path.join(repoRoot, '.cache', 'desktop-smoke-profile');
fs.rmSync(profileDir, { recursive: true, force: true });
// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)。
// 目录按进程号区分:上一次冒烟若被强杀,目录可能仍被句柄占用(rmSync 会 ENOTEMPTY),
// 用独立目录可以彻底避免互相干扰。
const profileDir = path.join(repoRoot, '.cache', `desktop-smoke-profile-${process.pid}`);
try {
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
} catch (error) {
console.warn(`[smoke] 复用/清理配置目录失败(忽略):${error?.message ?? error}`);
}
console.log(`[smoke] 独立配置目录:${profileDir}`);
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
@@ -51,17 +57,179 @@ if (online === undefined) {
);
}
const child = spawn(electronPath, ['.'], {
cwd: root,
stdio: 'inherit',
env: resolveElectronEnv({
ELECTRON_SMOKE_TEST: '1',
ELECTRON_SMOKE_ONLINE: online,
ELECTRON_SMOKE_PROFILE: profileDir,
}),
});
/**
* 准备一个已知可用的班级账号(学生端已改为班级码 + 班级密码登录):
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
*/
async function provisionClassAccount() {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
const username = process.env.ELECTRON_SMOKE_ADMIN ?? 'admin';
const adminPassword = process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
const password = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456';
const CANDIDATES = [...new Set([password, process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456'])];
const tryLogin = async (code, candidate) => {
const response = await fetch(`${base}/auth/class-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, password: candidate }),
});
return response.ok;
};
try {
const loginResponse = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: adminPassword }),
});
const login = await loginResponse.json();
const token = login?.data?.token;
if (!token) return null;
child.on('exit', (code) => {
console.log(`\n[smoke] Electron 退出,code=${code ?? 1}`);
process.exit(code ?? 1);
});
const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders });
const classes = (await classesResponse.json())?.data ?? [];
const target = classes[0];
if (!target) return null;
// 冒烟**永不修改班级码**(班级码是给学生用的,不应被测试工具改写):
// 只有"现有班级码 + 已知密码全部登录失败"时才重置密码,班级码原样保留。
const code = target.code;
if (!code) return null;
for (const candidate of CANDIDATES) {
if (await tryLogin(code, candidate)) return { code, password: candidate, className: target.name };
}
const patchResponse = await fetch(`${base}/classes/${target.id}/class-account`, {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify({ code, password }),
});
if (!patchResponse.ok) return null;
const patched = (await patchResponse.json())?.data;
// 记下"需要恢复",脚本收尾时改回种子默认密码(已知密码都不通时无法得知原密码)
patchedAccount = { id: target.id, code: patched?.code ?? code, base, authHeaders };
console.log(
`[smoke] 已知密码均不可用,已把班级 ${target.name} 的密码临时重置为 ${password}` +
`(结束时恢复为 ${CANDIDATES[1]})`,
);
return { code: patched?.code ?? code, password, className: target.name };
} catch {
return null;
}
}
/** 冒烟临时重置过的班级账号(结束时恢复) */
let patchedAccount = null;
/** 恢复被冒烟临时改过的班级密码(失败只告警,不影响冒烟结论) */
async function restoreClassAccount() {
if (!patchedAccount) return;
try {
const response = await fetch(`${patchedAccount.base}/classes/${patchedAccount.id}/class-account`, {
method: 'PATCH',
headers: patchedAccount.authHeaders,
body: JSON.stringify({
code: patchedAccount.code,
password: process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456',
}),
});
console.log(
response.ok
? '[smoke] 已恢复班级密码为种子默认 123456(冒烟不留副作用)'
: `[smoke] [WARN] 班级密码恢复失败:status=${response.status},请到后台重新设置`,
);
} catch (error) {
console.log(`[smoke] [WARN] 班级密码恢复失败:${error?.message ?? error}`);
}
}
let classCredentials = null;
if (online === '1') {
classCredentials = await provisionClassAccount();
console.log(
classCredentials
? `[smoke] 班级账号就绪:${classCredentials.code}(${classCredentials.className})`
: '[smoke] 未能准备班级账号,联网集成将使用种子默认凭据',
);
}
/** 启动一次冒烟(返回退出码) */
function launchSmoke(extraEnv) {
return new Promise((resolve) => {
const child = spawn(electronPath, ['.'], {
cwd: root,
stdio: 'inherit',
env: resolveElectronEnv({
ELECTRON_SMOKE_TEST: '1',
ELECTRON_SMOKE_ONLINE: online,
ELECTRON_SMOKE_PROFILE: profileDir,
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
// 教师凭据只从这里传入:主进程的冒烟代码从环境变量读,
// 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包)
ELECTRON_SMOKE_USER: process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
ELECTRON_SMOKE_PASSWORD: process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
...extraEnv,
}),
});
child.on('exit', (code) => resolve(code ?? 1));
});
}
const code = await launchSmoke();
console.log(`\n[smoke] Electron 退出,code=${code}`);
await restoreClassAccount();
if (code !== 0) process.exit(code);
/**
* 需求 5 的"退出不留残留"实测(在进程真正退出之后做,才有意义):
* 1) 配置目录(含 Chromium 的 SingletonLock / IndexedDB 文件锁)必须能被删除
* —— 删不掉说明还有句柄被占用,即存在残留进程;
* 2) 用同一个配置目录再启动一次:单实例锁与端口/句柄都已释放才会真正跑起来
* (若还有残留进程占着锁,第二次启动会静默退出且不产出结果文件)。
*/
let lockReleased = true;
try {
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
} catch (error) {
lockReleased = false;
console.error(`[smoke] [FAIL] 退出后配置文件锁未释放:${error?.message ?? error}`);
}
const relaunchResult = path.join(repoRoot, '.cache', `desktop-smoke-relaunch-${process.pid}.json`);
try {
fs.rmSync(relaunchResult, { force: true });
} catch {
// 忽略
}
console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
const relaunchCode = await launchSmoke({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: relaunchResult });
let relaunchPassed = 0;
let relaunchTotal = 0;
try {
const payload = JSON.parse(fs.readFileSync(relaunchResult, 'utf8'));
relaunchPassed = payload.passed ?? 0;
relaunchTotal = payload.total ?? 0;
} catch {
relaunchPassed = -1;
}
const resourcesOk =
lockReleased && relaunchCode === 0 && relaunchTotal > 0 && relaunchPassed === relaunchTotal;
console.log(
resourcesOk
? `[smoke] [PASS] 退出后无残留:配置文件锁已释放,二次启动成功(${relaunchPassed}/${relaunchTotal})`
: `[smoke] [FAIL] 退出后可能仍有残留:文件锁释放=${lockReleased} 二次启动 code=${relaunchCode} 结果=${relaunchPassed}/${relaunchTotal}`,
);
try {
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
} catch {
// 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放)
}
process.exit(resourcesOk ? 0 : 1);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,115 @@
/**
* WinIsland 同款弹簧(照搬 `src/utils/physics.rs` 与 `src/window/app/frame.rs` 的参数与更新式)。
*
* 原始实现(Rust,每帧调用一次,`dt` 以**帧**为单位):
* ```text
* force = (target - value) * stiffness * dt
* velocity = (velocity + force) * damping^dt
* value += velocity * dt
* ```
* 主岛尺寸用的参数:`stiffness = 0.10`、`damping = 0.68`(阻尼比 ζ≈0.977,接近临界阻尼,
* 理论过冲 ~1e-6,肉眼不可见)。换向时还有两个限制(`redirect_velocity_towards`):
* 反向动量只保留 35%、单帧位移不超过剩余距离的 20%。
*
* 我们在此基础上**额外加了零过冲钳制**(越过目标立刻吸附并清零速度):
* 即便某帧卡顿导致 dt 变大,也绝不会出现"超过目标再弹回来"的震动。
*/
export interface SpringOptions {
/** 刚度(WinIsland 主岛尺寸:0.10) */
stiffness?: number;
/** 速度保留率(WinIsland 主岛尺寸:0.68) */
damping?: number;
/** 吸附阈值(px) */
snapThreshold?: number;
}
const MOMENTUM_RETENTION = 0.35;
const MAX_DISTANCE_RATIO = 0.2;
const MAX_DT = 6;
/** 一帧按 60fps 折算(与 WinIsland 的 dt 单位一致) */
const FRAME_MS = 1000 / 60;
export class SpringValue {
value: number;
velocity = 0;
target: number;
private readonly stiffness: number;
private readonly damping: number;
private readonly snapThreshold: number;
constructor(initial: number, options: SpringOptions = {}) {
this.value = initial;
this.target = initial;
this.stiffness = options.stiffness ?? 0.1;
this.damping = options.damping ?? 0.68;
this.snapThreshold = options.snapThreshold ?? 0.05;
}
setTarget(target: number): void {
if (target === this.target) return;
this.redirectVelocity(target);
this.target = target;
}
/** 直接落位(关闭动画 / 首次同步时使用) */
snap(target: number): void {
this.target = target;
this.value = target;
this.velocity = 0;
}
get settled(): boolean {
return Math.abs(this.target - this.value) <= this.snapThreshold && Math.abs(this.velocity) <= 0.01;
}
/**
* 换向限速(WinIsland):反向时只保留 35% 动量,并把速度幅值限制在"剩余距离的 20%"以内,
* 保证不会因为方向切换产生突跳。
*/
private redirectVelocity(target: number): void {
const distance = target - this.value;
if (distance === 0) {
this.velocity = 0;
return;
}
const sameDirection = Math.sign(distance) === Math.sign(this.velocity);
if (!sameDirection) this.velocity *= MOMENTUM_RETENTION;
const maxVelocity = Math.abs(distance) * MAX_DISTANCE_RATIO;
if (Math.abs(this.velocity) > maxVelocity) {
this.velocity = Math.sign(this.velocity) * maxVelocity;
}
}
/** 推进一帧;返回是否仍在运动 */
step(deltaMs: number): boolean {
const dt = Math.min(MAX_DT, Math.max(0.1, deltaMs / FRAME_MS));
const distance = this.target - this.value;
if (distance === 0 && Math.abs(this.velocity) <= 0.01) {
this.velocity = 0;
this.value = this.target;
return false;
}
const force = distance * this.stiffness * dt;
this.velocity = (this.velocity + force) * Math.pow(this.damping, dt);
const previous = this.value;
this.value += this.velocity * dt;
// 零过冲钳制:越过目标(或已足够接近)就吸附到目标并停止
const crossed = (this.target - previous) * (this.target - this.value) <= 0;
if (crossed || Math.abs(this.target - this.value) <= this.snapThreshold) {
this.value = this.target;
this.velocity = 0;
return false;
}
if (!Number.isFinite(this.value) || !Number.isFinite(this.velocity)) {
this.value = this.target;
this.velocity = 0;
return false;
}
return true;
}
}
@@ -0,0 +1,81 @@
/**
* 连续圆角(squircle / Apple 连续曲率圆角)路径生成。
*
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表构造
* `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡半径取
* `expanded_island_radius = min(48 * scale, w/2, h/2)`。
*
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、没有方向歧义。
*
* 早期版本用「相对角起点的偏移向量」拼角,而四个角需要的偏移向量**并不相同**
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`)——
* 于是左上与右下被画成了向内凹的形状,视觉上就是这两个角**缺一块**。
*
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),因此圆角半径恒定、不被拉伸。
*/
/** 超椭圆指数:2 = 标准圆弧圆角,越大越"方"但曲率越连续(Apple 观感约 4~5) */
const SUPERELLIPSE_EXPONENT = 4.2;
/** 每个角的采样段数(越多越平滑;4 个角共 4x 段) */
const CORNER_SEGMENTS = 14;
const round = (value: number): number => Math.round(value * 100) / 100;
/**
* 生成连续圆角矩形路径。
*
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、不会出现方向歧义。
*
* 早期版本用「相对起点的偏移向量」拼角,而四个角需要的偏移向量并不相同
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`),
* 于是左上与右下被画成了"向内凹"的形状——视觉上就是这两个角**缺一块**。
*
* @param width 卡片宽度(px)
* @param height 卡片高度(px)
* @param radius 四角基准半径(px,会被裁剪到 min(w/2, h/2))
*/
export function squirclePath(width: number, height: number, radius: number): string {
const w = Math.max(1, Math.round(width * 100) / 100);
const h = Math.max(1, Math.round(height * 100) / 100);
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
if (r <= 0.5) return `M0,0 H${round(w)} V${round(h)} H0 Z`;
const p = 2 / SUPERELLIPSE_EXPONENT;
const centers = [
{ cx: w - r, cy: r }, // 右上
{ cx: w - r, cy: h - r }, // 右下
{ cx: r, cy: h - r }, // 左下
{ cx: r, cy: r }, // 左上
];
const startAngles = [-Math.PI / 2, 0, Math.PI / 2, Math.PI];
const parts: string[] = [];
for (let corner = 0; corner < 4; corner += 1) {
const center = centers[corner] as { cx: number; cy: number };
const startAngle = startAngles[corner] as number;
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
const angle = startAngle + (index / CORNER_SEGMENTS) * (Math.PI / 2);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const px = center.cx + r * Math.sign(cos) * Math.pow(Math.abs(cos), p);
const py = center.cy + r * Math.sign(sin) * Math.pow(Math.abs(sin), p);
parts.push(`${index === 0 && corner === 0 ? 'M' : 'L'}${round(px)},${round(py)}`);
}
}
parts.push('Z');
return parts.join(' ');
}
/** 展开卡半径(与 WinIsland 的 expanded_island_radius 同构:min(48*scale, w/2, h/2)) */
export function expandedRadius(width: number, height: number, radius: number): number {
return Math.max(0, Math.min(Math.max(radius, 24), width / 2, height / 2));
}
+169 -5
View File
@@ -1,8 +1,26 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
import type { DesktopStoredConfig } from '../types/desktop.js';
import {
CLASSISLAND_NOTIFICATION_CHANNELS,
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
DEFAULT_ISLAND_APPEARANCE,
DEFAULT_SERVER_URL,
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
type ClassIslandNotificationChannel,
type IslandAppearance,
} from '@classhelper/shared';
import { logger } from './logger.js';
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
const DEFAULT_HOMEWORK_BOARD: HomeworkBoardSettings = {
mode: 'board',
showTime: false,
fontSize: 15,
};
interface PersistedConfig {
serverUrl: string;
@@ -10,6 +28,14 @@ interface PersistedConfig {
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
token: string | null;
tokenEncrypted: boolean;
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
island?: Partial<IslandAppearance>;
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
homeworkBoard?: Partial<HomeworkBoardSettings>;
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[];
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -17,8 +43,95 @@ const DEFAULT_CONFIG: PersistedConfig = {
username: '',
token: null,
tokenEncrypted: false,
island: { ...DEFAULT_ISLAND_APPEARANCE },
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
};
/**
* 作业快捷短语的合法化:去空白、去重、限长限量。
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
*/
function normalizeHomeworkPhrases(input?: string[]): string[] {
if (!Array.isArray(input)) return [...HOMEWORK_PHRASE_DEFAULTS];
const seen = new Set<string>();
const result: string[] = [];
for (const raw of input) {
if (typeof raw !== 'string') continue;
const value = raw.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
if (!value || seen.has(value)) continue;
seen.add(value);
result.push(value);
if (result.length >= HOMEWORK_PHRASE_MAX_COUNT) break;
}
return result;
}
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
function normalizeNotificationChannel(
input?: ClassIslandNotificationChannel,
): ClassIslandNotificationChannel {
return CLASSISLAND_NOTIFICATION_CHANNELS.includes(input as ClassIslandNotificationChannel)
? (input as ClassIslandNotificationChannel)
: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL;
}
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
const mode = input?.mode === 'list' ? 'list' : 'board';
const rawFont =
typeof input?.fontSize === 'number' && Number.isFinite(input.fontSize)
? input.fontSize
: DEFAULT_HOMEWORK_BOARD.fontSize;
return {
mode,
showTime: input?.showTime === true,
fontSize: Math.min(28, Math.max(11, Math.round(rawFont))),
};
}
/** 外观字段的合法区间校验(越界值直接夹紧,避免用户配置破坏灵动岛布局) */
function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandAppearance {
const clamp = (value: unknown, min: number, max: number, fallback: number): number => {
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
return Math.min(max, Math.max(min, Math.round(numeric * 100) / 100));
};
const positions: IslandAppearance['position'][] = [
'top-center',
'top-left',
'top-right',
'bottom-center',
'bottom-left',
'bottom-right',
];
const styles: IslandAppearance['style'][] = ['black', 'glass', 'tinted'];
return {
height: clamp(input?.height, 36, 72, DEFAULT_ISLAND_APPEARANCE.height),
width: clamp(input?.width, 220, 420, DEFAULT_ISLAND_APPEARANCE.width),
radius: clamp(input?.radius, 8, 32, DEFAULT_ISLAND_APPEARANCE.radius),
opacity: clamp(input?.opacity, 0.4, 1, DEFAULT_ISLAND_APPEARANCE.opacity),
accent:
typeof input?.accent === 'string' && /^#[0-9a-fA-F]{6}$/.test(input.accent)
? input.accent
: DEFAULT_ISLAND_APPEARANCE.accent,
fontSize: clamp(input?.fontSize, 11, 20, DEFAULT_ISLAND_APPEARANCE.fontSize),
animations: input?.animations !== false,
speed: clamp(input?.speed, 0.5, 2, DEFAULT_ISLAND_APPEARANCE.speed),
position: positions.includes(input?.position as IslandAppearance['position'])
? (input?.position as IslandAppearance['position'])
: DEFAULT_ISLAND_APPEARANCE.position,
alwaysOnTop: input?.alwaysOnTop !== false,
style: styles.includes(input?.style as IslandAppearance['style'])
? (input?.style as IslandAppearance['style'])
: DEFAULT_ISLAND_APPEARANCE.style,
idleSliver: input?.idleSliver === true,
marginX: clamp(input?.marginX, 0, 200, DEFAULT_ISLAND_APPEARANCE.marginX),
marginY: clamp(input?.marginY, 0, 160, DEFAULT_ISLAND_APPEARANCE.marginY),
followCursorDisplay: input?.followCursorDisplay === true,
};
}
function configFilePath(): string {
return path.join(app.getPath('userData'), 'config.json');
}
@@ -30,8 +143,15 @@ function encryptToken(token: string | null): { token: string | null; tokenEncryp
return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true };
}
} catch {
// 某些环境下 DPAPI 不可用,降级为明文存储(仅本地开发场景)
// 见下方降级分支
}
// 降级为明文存储(DPAPI 不可用,一般只在本地开发环境出现)。
// 必须留下日志:明文 token 代表该班的全班身份,任何能读用户目录的进程/备份/同步盘都能拿到,
// 静默降级会让"为什么换个环境就得重新登录/凭据是怎么泄露的"无从查起。
logger.warn(
`系统加密存储不可用,登录令牌将以**明文**写入配置(${configFilePath()});` +
'如需加密请确认 Windows 凭据保护(DPAPI)可用',
);
return { token, tokenEncrypted: false };
}
@@ -52,11 +172,15 @@ function readPersisted(): PersistedConfig {
return {
serverUrl:
typeof parsed.serverUrl === 'string' && parsed.serverUrl
? parsed.serverUrl
? (normalizeServerUrl(parsed.serverUrl) ?? DEFAULT_CONFIG.serverUrl)
: DEFAULT_CONFIG.serverUrl,
username: typeof parsed.username === 'string' ? parsed.username : '',
token: typeof parsed.token === 'string' ? parsed.token : null,
tokenEncrypted: parsed.tokenEncrypted === true,
island: normalizeIslandAppearance(parsed.island),
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
};
} catch {
return { ...DEFAULT_CONFIG };
@@ -69,6 +193,25 @@ function writePersisted(config: PersistedConfig): void {
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
}
/**
* 服务器地址只接受 http/https 绝对地址。
*
* 渲染进程的输入框走 `normalizeServerUrl` 校验过,但 IPC(`saveConfig`)与手工改过的
* config.json 都会绕过它,而该值随后会作为 axios baseURL 与 Socket.IO 地址使用 ——
* 因此必须在**主进程这一侧也收口**,非法值一律丢弃(保留原值)。
*/
function normalizeServerUrl(input: string): string | null {
const trimmed = input.trim().replace(/\/+$/, '');
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return trimmed;
} catch {
return null;
}
}
/** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig {
const persisted = readPersisted();
@@ -76,6 +219,10 @@ export function getConfig(): DesktopStoredConfig {
serverUrl: persisted.serverUrl,
username: persisted.username,
token: decryptToken(persisted),
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
};
}
@@ -84,11 +231,28 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
const persisted = readPersisted();
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
persisted.serverUrl = patch.serverUrl.trim().replace(/\/+$/, '');
const normalized = normalizeServerUrl(patch.serverUrl);
if (normalized) persisted.serverUrl = normalized;
else logger.warn(`忽略非法的服务器地址(只接受 http/https):${patch.serverUrl}`);
}
if (typeof patch.username === 'string') {
persisted.username = patch.username;
}
if (patch.island !== undefined) {
persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island });
}
if (patch.homeworkBoard !== undefined) {
persisted.homeworkBoard = normalizeHomeworkBoard({
...persisted.homeworkBoard,
...patch.homeworkBoard,
});
}
if (patch.homeworkPhrases !== undefined) {
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
}
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
+115 -8
View File
@@ -1,9 +1,11 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron';
import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js';
import { runSmokeTest } from './smoke.js';
import { createTray, destroyTray, isTrayReady } from './tray.js';
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
@@ -19,6 +21,33 @@ if (smokeProfile) app.setPath('userData', smokeProfile);
let mainWindow: BrowserWindow | null = null;
/** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */
let isQuitting = false;
/** 生产环境下主窗口加载的本页地址(用于导航白名单的精确比对) */
const prodRendererUrl = pathToFileURL(path.join(currentDir, '../renderer/index.html')).href;
/**
* 是否允许窗口导航到这个地址。
*
* 为什么不能用 `url.startsWith(...)`:
* - 开发态 `url.startsWith(devServerUrl)` 没有主机边界,`http://localhost:5174.evil.com` 也会被放行;
* - 生产态 `url.startsWith('file://')` 等于放行**任意本地页面**。
* 而 preload 是挂在窗口上的(不是挂在 URL 上):一旦窗口被导航到别处,那个页面照样持有
* `window.desktop`(含 `getConfig` 返回的明文 token)。因此这里按"精确同源 / 精确同一文件"比对。
*/
function isAllowedNavigation(url: string): boolean {
try {
const target = new URL(url);
if (devServerUrl) return target.origin === new URL(devServerUrl).origin;
target.hash = '';
target.search = '';
return target.href === prodRendererUrl;
} catch {
return false;
}
}
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1200,
@@ -45,11 +74,10 @@ function createWindow(): BrowserWindow {
return { action: 'deny' };
});
win.webContents.on('will-navigate', (event, url) => {
const allowed = devServerUrl ? url.startsWith(devServerUrl) : url.startsWith('file://');
if (!allowed) {
event.preventDefault();
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
}
if (isAllowedNavigation(url)) return;
event.preventDefault();
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
logger.warn(`主窗口拒绝了页面导航:${url}`);
});
win.on('closed', () => {
@@ -97,10 +125,39 @@ if (!gotLock) {
mainWindow.show();
mainWindow.focus();
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
// 关闭主窗口 → 隐藏到系统托盘(需求 5),只有托盘"退出"才真正结束进程
// 注意:必须在冒烟验证之前挂载,否则 close() 会真的销毁窗口
mainWindow?.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
mainWindow?.hide();
logger.info('主窗口已隐藏到系统托盘(后台继续运行,左键托盘图标可恢复)');
});
try {
createTray({
onShow: () => showMainWindow(),
onHide: () => mainWindow?.hide(),
onQuit: () => quitApp(),
});
} catch (error) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
@@ -111,13 +168,63 @@ if (!gotLock) {
}
});
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
function showMainWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
mainWindow = createWindow();
void loadRenderer(mainWindow);
}
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
/**
* 退出前的统一资源清理(需求 5:托盘 / 定时器 / 网络连接 / 窗口都不留残留)。
* 幂等:托盘退出、before-quit、will-quit 都可能触发。
*/
function shutdownResources(): string {
const done: string[] = [];
if (isTrayReady()) {
destroyTray();
done.push('托盘已销毁');
}
if (island.isReady()) {
island.destroy();
done.push('灵动岛已销毁');
}
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
// 渲染进程随之结束:其中的 Socket.IO 连接、定时器、IndexedDB 句柄一并释放
win.destroy();
done.push('窗口已销毁');
}
}
return done.length > 0 ? done.join('、') : '无需要清理的资源';
}
/** 真正退出:先清理资源,再 app.quit,并用 app.exit 兜底(确保不留进程) */
function quitApp(): void {
if (isQuitting) return;
isQuitting = true;
logger.info(`正在退出:${shutdownResources()}`);
app.quit();
setTimeout(() => app.exit(0), 1500);
}
app.on('window-all-closed', () => {
island.destroy();
if (process.platform !== 'darwin') app.quit();
// 有托盘常驻:窗口全关也不退出,继续后台接收通知
logger.info('所有窗口已关闭,程序继续在系统托盘后台运行');
});
app.on('before-quit', () => {
island.destroy();
isQuitting = true;
shutdownResources();
});
app.on('will-quit', () => {
const summary = shutdownResources();
logger.info(`应用退出清理:${summary}`);
});
app.on('activate', async () => {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
import fs from 'node:fs';
import path from 'node:path';
import { Menu, Tray, app, nativeImage } from 'electron';
import { logger } from './logger.js';
/**
* 系统托盘(需求 5):关闭主窗口不退出程序,而是隐藏到托盘后台运行;
* 托盘图标可恢复窗口,并提供"退出"入口(退出前统一清理资源)。
*/
let tray: Tray | null = null;
export interface TrayHandlers {
onShow(): void;
onHide(): void;
onQuit(): void;
}
/** 托盘图标:优先用打包进 dist/renderer 的 tray.png,其次资源目录 */
function resolveTrayIcon(): string | null {
const candidates = [
path.join(__dirname, '../renderer/tray.png'),
path.join(process.resourcesPath ?? '', 'tray.png'),
];
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) return candidate;
}
return null;
}
export function createTray(handlers: TrayHandlers): Tray | null {
if (tray && !tray.isDestroyed()) return tray;
const iconPath = resolveTrayIcon();
const image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
tray = new Tray(image);
tray.setToolTip('班级小助手(后台运行中)');
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => handlers.onShow() },
{ label: '隐藏到托盘', click: () => handlers.onHide() },
{ type: 'separator' },
{ label: '退出班级小助手', click: () => handlers.onQuit() },
]),
);
// 左键单击/双击托盘图标:恢复窗口(用户习惯)
tray.on('click', () => handlers.onShow());
tray.on('double-click', () => handlers.onShow());
logger.info(iconPath ? `系统托盘已就绪:${iconPath}` : '系统托盘已就绪(未找到图标文件,使用空图标)');
return tray;
}
export function destroyTray(): void {
if (!tray) return;
try {
tray.destroy();
} catch {
/* 忽略销毁异常 */
}
tray = null;
logger.info('系统托盘已销毁');
}
export function isTrayReady(): boolean {
return Boolean(tray && !tray.isDestroyed());
}
/** 供"关于/设置"展示(也可用于冒烟断言) */
export function getTrayState(): { ready: boolean; hasIcon: boolean; appPath: string } {
return {
ready: isTrayReady(),
hasIcon: Boolean(resolveTrayIcon()),
appPath: app.getPath('exe'),
};
}
@@ -12,9 +12,24 @@ import type { IslandNotification } from '@classhelper/shared';
/**
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
*
* 注意:contextBridge 在参数跨越"主世界 → 隔离世界"时就做结构化克隆,
* 因此**桥接层无法替调用方兜底**:传 Vue 响应式对象(Proxy)会在进入这里的函数体
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/
const bridge: DesktopBridge = {
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
smokeCredentials:
process.env.ELECTRON_SMOKE_TEST === '1'
? {
username: process.env.ELECTRON_SMOKE_USER ?? '',
password: process.env.ELECTRON_SMOKE_PASSWORD ?? '',
classCode: process.env.ELECTRON_SMOKE_CLASS_CODE ?? '',
classPassword: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
}
: undefined,
getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
@@ -29,6 +44,18 @@ const bridge: DesktopBridge = {
ipcRenderer.send('island:class-state', payload);
},
islandGetState: () => ipcRenderer.invoke('island:get-state'),
islandSetAppearance: (appearance): void => {
ipcRenderer.send('island:set-appearance', appearance);
},
islandGetAppearance: () => ipcRenderer.invoke('island:get-appearance'),
/** 灵动岛点了"标为已读":主进程转交渲染进程同步通知中心 */
onIslandMarkRead: (handler: (id: string) => void): void => {
ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id));
},
/** 灵动岛点了"标为已读"(多条通知的整批):一次同步这一批 */
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
},
};
contextBridge.exposeInMainWorld('desktop', bridge);
+45 -3
View File
@@ -1,14 +1,40 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { IslandState } from '@classhelper/shared';
import type { IslandAppearance, IslandState } from '@classhelper/shared';
/**
* 灵动岛窗口的预加载脚本。
* 只暴露「订阅状态」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
* 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
*/
export interface IslandBridge {
onState(handler: (state: IslandState) => void): void;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
sendAction(
action:
| 'expand'
| 'expand-list'
| 'collapse'
| 'dismiss'
| 'dismiss-all'
| 'mark-read'
| 'mark-all-read'
| 'open-app',
id?: string,
): void;
getState(): Promise<IslandState>;
/** 订阅个性化外观(设置页改动后实时生效) */
onAppearance(handler: (appearance: IslandAppearance) => void): void;
getAppearance(): Promise<IslandAppearance>;
/** 命中提示:指针在岛上 → 让窗口接收鼠标(只上报 true,关闭由主进程轮询决定) */
setInteractive(interactive: boolean): void;
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
/**
* 上报"本机是触摸屏":主进程据此把窗口改为贴合岛体并始终接收输入。
* 触摸屏上按光标判定命中的那套永远打不开(触摸不产生 mousemove、也不动系统光标),
* 不上报的话手指点不到岛(希沃白板等触摸设备)。
*/
setTouchMode(enabled: boolean): void;
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
alive(): void;
}
const bridge: IslandBridge = {
@@ -19,6 +45,22 @@ const bridge: IslandBridge = {
ipcRenderer.send('island:action', { action, id });
},
getState: () => ipcRenderer.invoke('island:get-state'),
onAppearance: (handler) => {
ipcRenderer.on('island:appearance', (_event, appearance: IslandAppearance) => handler(appearance));
},
getAppearance: () => ipcRenderer.invoke('island:get-appearance'),
setInteractive: (interactive) => {
ipcRenderer.send('island:set-interactive', interactive);
},
setHitRect: (rect) => {
ipcRenderer.send('island:set-hit-rect', rect);
},
setTouchMode: (enabled) => {
ipcRenderer.send('island:set-touch-mode', enabled);
},
alive: () => {
ipcRenderer.send('island:alive');
},
};
contextBridge.exposeInMainWorld('island', bridge);
+18 -1
View File
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { startIslandBridge, stopIslandBridge } from './island/bridge.js';
import {
startIslandBridge,
stopIslandBridge,
subscribeIslandMarkAllRead,
subscribeIslandMarkRead,
} from './island/bridge.js';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useRealtimeStore } from './stores/realtime.js';
@@ -25,6 +30,14 @@ onMounted(async () => {
bootText.value = '正在恢复登录状态...';
const session = await auth.restore();
// 通知显示位置:先用本地配置兜底(断网也要按上次的选择决定本机弹不弹),
// 登录态就绪后再向服务器对齐一次(服务端才是单一事实来源)
const storedConfig = await window.desktop?.getConfig?.();
if (storedConfig?.notificationChannel) {
appStore.applyLocalNotificationChannel(storedConfig.notificationChannel);
}
void appStore.loadNotificationChannel(auth.classId);
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
void appStore.init(session.serverUrl);
@@ -33,6 +46,10 @@ onMounted(async () => {
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
if (auth.token) startIslandBridge();
// 灵动岛"标为已读" → 同步通知中心(未读红点);多条通知时是整批已读
subscribeIslandMarkRead();
subscribeIslandMarkAllRead();
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
} finally {
@@ -2,10 +2,14 @@ import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type ClassIslandNotificationChannel,
type CourseDto,
type HomeworkDaysDto,
type DashboardSummary,
type GradeDto,
type HomeworkDto,
type HomeworkStatusDto,
type HomeworkSubmissionsDto,
type LoginRequest,
type LoginResponse,
type NotificationDto,
@@ -17,9 +21,22 @@ import { api } from './http.js';
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
export const authApi = {
/** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
api.post('/auth/class-login', payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
/**
* 修改密码(设置页「账号信息」里的入口)。
* 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 ——
* 因此这台机器无论以哪种身份登录,改密入口都走这一条。
*/
changePassword: (payload: {
currentPassword: string;
newPassword: string;
}): Promise<{ changed: boolean }> => api.patch(API_PATHS.auth.changePassword, payload),
};
export const classApi = {
@@ -27,6 +44,36 @@ export const classApi = {
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
};
/**
* 通知显示位置(both / client / classisland)。
*
* 由**这台教室机器自己**决定"提醒弹在 ClassHelper 还是 ClassIsland":
* 本地存一份用于本机的弹窗/上岛判断,同时写回班级记录,服务端据此决定要不要推 ClassIsland。
*/
export const classChannelApi = {
get: (classId: string): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
api.get(`${API_PATHS.classes}/${classId}/notification-channel`),
set: (
classId: string,
notificationChannel: ClassIslandNotificationChannel,
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
};
/** 本班 ClassIsland 联动状态(教室客户端「设置 → ClassIsland 联动」展示) */
export const classIslandStatusApi = {
get: (
classId: string,
): Promise<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
}> => api.get(`${API_PATHS.classes}/${classId}/classisland-status`),
};
export const scheduleApi = {
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params),
@@ -34,20 +81,48 @@ export const scheduleApi = {
api.get(`${API_PATHS.schedules}/grid`, params),
};
export const courseApi = {
list: (classId?: string): Promise<CourseDto[]> =>
api.get(API_PATHS.courses, classId ? { classId } : undefined),
};
export const homeworkApi = {
list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise<HomeworkDto[]> =>
api.get(API_PATHS.homeworks, params),
list: (params?: {
classId?: string;
pendingOnly?: boolean;
courseId?: string;
/** 只看某一天(YYYY-MM-DD,按作业所属日期) */
date?: string;
}): Promise<HomeworkDto[]> => api.get(API_PATHS.homeworks, params),
/** 哪些天有作业(日期选择器高亮) */
days: (params: { classId?: string; from?: string; to?: string; days?: number }): Promise<HomeworkDaysDto> =>
api.get(`${API_PATHS.homeworks}/days`, params),
/** 在教室机器上直接录入作业(班级账号可调用;也可选所属日期) */
create: (payload: {
classId: string;
courseId?: string | null;
title: string;
content: string;
assignDate?: string | null;
}): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }),
/** 提交名单 / 未交名单(教师与班级设备可用) */
submissions: (id: string): Promise<HomeworkSubmissionsDto> =>
api.get(`${API_PATHS.homeworks}/${id}/submissions`),
saveSubmissions: (id: string, notSubmittedUserIds: string[]): Promise<HomeworkSubmissionsDto> =>
api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedUserIds }),
};
export const notificationApi = {
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
api.get(API_PATHS.notifications, params),
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
// id 来自服务端或灵动岛(作业卡是本地合成的 `homework-…`),一律 encodeURIComponent:
// 否则含 `/` 或 `..` 的 id 会改变实际请求的路径
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> =>
api.post(`${API_PATHS.notifications}/${id}/read`, {}),
api.post(`${API_PATHS.notifications}/${encodeURIComponent(id)}/read`, {}),
markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
};
@@ -57,5 +132,6 @@ export const gradeApi = {
export const dashboardApi = {
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard),
term: (): Promise<{ currentWeek: number; maxWeek: number }> => api.get('/dashboard/term'),
term: (classId?: string): Promise<{ currentWeek: number; maxWeek: number }> =>
api.get('/dashboard/term', classId ? { classId } : undefined),
};
+616 -6
View File
@@ -6,6 +6,31 @@ export interface SmokeCheckResult {
detail: string;
}
/**
* 冒烟用的教师凭据:**由 preload 从环境变量注入,不写死在代码里**。
*
* 本文件会进渲染进程产物(`dist/renderer/assets/*.js`)→ `app.asar` → 每台学生机,
* 而 asar 可以直接解包。因此任何口令字面量都不能留在这里;
* 凭据由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供
* (见 `main/smoke.ts` 顶部关于 app.asar 的完整说明)。
*/
let smokeTeacherCredentials: { username: string; password: string } = { username: '', password: '' };
/** 由 main.ts 在注册冒烟钩子时注入(来源:ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD) */
export function setSmokeTeacherCredentials(next: { username: string; password: string }): void {
smokeTeacherCredentials = {
username: next?.username ?? '',
password: next?.password ?? '',
};
}
/** 教师登录请求体;未注入凭据时返回 null(调用方据此跳过并说明原因) */
function teacherLoginBody(): { username: string; password: string } | null {
return smokeTeacherCredentials.username && smokeTeacherCredentials.password
? { username: smokeTeacherCredentials.username, password: smokeTeacherCredentials.password }
: null;
}
/**
* 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。
* 由主进程通过 webContents.executeJavaScript 调用。
@@ -85,9 +110,14 @@ const EXPECTED_MENU: Array<{ label: string; path: string }> = [
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
const { router } = await import('../router/index.js');
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
const findMenuItem = (label: string): HTMLElement | undefined =>
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
(element.textContent ?? '').trim().startsWith(label),
Array.from(element.querySelectorAll('span')).some(
(span) => (span.textContent ?? '').trim() === label,
),
);
if (!findMenuItem('课表')) {
@@ -123,11 +153,109 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
};
}
/** 自检临时造的作业/通知(收尾必须删掉,避免污染用户的库) */
let temporaryContent: { token: string; homeworks: string[]; notifications: string[] } | null = null;
/**
* 服务端业务数据可能被管理员清空(用户明确要求过"删掉所有作业和通知"),
* 因此联网自检在列表为空时**自建**一条作业/通知来验证链路,收尾(sessionCleanup)删除。
*/
async function seedSmokeContent(
classId: string | null,
need: { homework: boolean; notification: boolean },
): Promise<SmokeCheckResult> {
if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' };
const serverUrl = 'http://127.0.0.1:4000';
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
try {
// 凭据由环境变量注入(理由见文件顶部):没给就明确跳过,而不是拿种子账号兜底
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法自建测试数据',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(loginBody),
}).then((response) => response.json());
const token: string | undefined = login?.data?.token;
if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
const homeworks: string[] = [];
const notifications: string[] = [];
if (need.homework) {
const created = await fetch(`${serverUrl}/api/homeworks`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
classId,
courseId: null,
title: `自动化验证作业 ${stamp}`,
content: '服务端没有作业数据时由冒烟自检临时创建,验证结束后立即删除,不会留在库里。',
}),
}).then((response) => response.json());
const id: string | undefined = created?.data?.id;
if (!id) return { ok: false, detail: `自建作业失败:${JSON.stringify(created).slice(0, 160)}` };
homeworks.push(id);
}
if (need.notification) {
const created = await fetch(`${serverUrl}/api/notifications`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
classId,
title: `自动化验证通知 ${stamp}`,
content: '服务端没有通知数据时由冒烟自检临时创建,验证结束后立即删除,不会留在库里。',
priority: 'NORMAL',
}),
}).then((response) => response.json());
const id: string | undefined = created?.data?.id;
if (!id) return { ok: false, detail: `自建通知失败:${JSON.stringify(created).slice(0, 160)}` };
notifications.push(id);
}
temporaryContent = { token, homeworks, notifications };
return { ok: true, detail: `作业+${homeworks.length} 通知+${notifications.length}` };
} catch (error) {
return { ok: false, detail: `自建数据异常:${error instanceof Error ? error.message : String(error)}` };
}
}
/** 删除自检临时造的作业/通知 */
async function cleanupTemporaryContent(): Promise<string> {
if (!temporaryContent) return '';
const { token, homeworks, notifications } = temporaryContent;
temporaryContent = null;
const serverUrl = 'http://127.0.0.1:4000';
const removals = [
...homeworks.map((id) => `/api/homeworks/${id}`),
...notifications.map((id) => `/api/notifications/${id}`),
];
let failed = 0;
for (const path of removals) {
const response = await fetch(`${serverUrl}${path}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${token}` },
}).catch(() => null);
if (!response?.ok) failed += 1;
}
return `自检临时数据已清理 ${removals.length - failed}/${removals.length} 条`;
}
/**
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
*/
export async function onlineScenario(): Promise<SmokeCheckResult> {
export async function onlineScenario(credentials?: {
code: string;
password: string;
}): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
import('../stores/auth.js'),
import('../stores/app.js'),
@@ -144,8 +272,22 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
const detail: string[] = [];
try {
await auth.login(serverUrl, 'student01', 'student123');
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
// 这里**不再回退到种子班级码**(G101/123456 也是真实可用的登录凭据,不能写进安装包):
// 没有凭据就明确跳过,由 scripts/smoke.mjs 打印"未能准备班级账号"。
const code = credentials?.code ?? '';
const password = credentials?.password ?? '';
if (!code) {
return {
ok: false,
detail:
'未收到班级凭据(ELECTRON_SMOKE_CLASS_CODE / ELECTRON_SMOKE_CLASS_PASSWORD),已跳过联网集成自检',
};
}
await auth.login(serverUrl, code, password);
detail.push(`login=${auth.user?.name ?? '-'}`);
detail.push(`classSession=${auth.isClassSession}`);
// 进入主布局(后续的侧边栏点击测试依赖布局已挂载)
const { router } = await import('../router/index.js');
@@ -156,12 +298,31 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
await appStore.init(serverUrl);
detail.push(`reachable=${appStore.serverReachable}`);
const [classes, homeworks, notifications, grades] = await Promise.all([
const [classes, initialHomeworks, initialNotifications, grades] = await Promise.all([
api.classApi.list(),
api.homeworkApi.list(),
api.notificationApi.list(),
api.gradeApi.my(),
]);
/**
* 自检**不允许依赖演示种子数据**:管理员随时可能清空业务数据
* (例如"删掉服务端所有作业和通知"),此时这几条联网断言不能变成"数据被清空就失败"。
* 因此列表为空时用教师账号造一条**本次自检专用**的作业/通知,收尾在 sessionCleanup 里删除。
*/
let homeworks = initialHomeworks;
let notifications = initialNotifications;
if (homeworks.length === 0 || notifications.length === 0) {
const seeded = await seedSmokeContent(auth.classId ?? null, {
homework: homeworks.length === 0,
notification: notifications.length === 0,
});
detail.push(`自建数据(${seeded.detail})`);
if (seeded.ok) {
[homeworks, notifications] = await Promise.all([api.homeworkApi.list(), api.notificationApi.list()]);
}
}
const term = await api.dashboardApi.term();
const schedules = await api.scheduleApi.list({ week: term.currentWeek });
@@ -215,16 +376,459 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
}
}
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
/**
* 冒烟自检 5(需后端在线):真实通知链路 → 灵动岛。
*
* 前面的灵动岛用例直接调 IPC 验证"状态机",这一条走**真机链路**:
* 教师账号给"当前登录学生所在班级"发一条通知 → 服务端 Socket.IO 广播 →
* 客户端 realtime store → bridge → 主进程灵动岛。主进程随后断言灵动岛弹出胶囊,
* 并继续验证"在灵动岛点标为已读 → 通知中心同步为已读"。
*/
export async function islandRealtimeScenario(): Promise<{
ok: boolean;
detail: string;
notificationId?: string;
teacherToken?: string;
title?: string;
classId?: string;
inClass?: boolean;
}> {
const [{ useAuthStore }, { getIslandClassContext }] = await Promise.all([
import('../stores/auth.js'),
import('../island/bridge.js'),
]);
const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000';
const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
const title = `灵动岛真机链路自检 ${Date.now()}`;
try {
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法投递通知',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(loginBody),
}).then((response) => response.json());
const teacherToken: string | undefined = login?.data?.token;
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
const created = await fetch(`${serverUrl}/api/notifications`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` },
body: JSON.stringify({
classId,
title,
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
// 普通优先级:冒烟客户端登录的是**临时班级**(无课表 → 判定不在上课),
// 普通通知也会立刻弹出胶囊,链路断言与真实上课时段无关。
priority: 'NORMAL',
}),
}).then((response) => response.json());
const notificationId: string | undefined = created?.data?.id;
if (!notificationId)
return { ok: false, detail: `发布通知失败:${JSON.stringify(created).slice(0, 160)}` };
const context = getIslandClassContext();
return {
ok: true,
detail: `classId=${classId} 通知=${notificationId} 客户端判定上课中=${context.inClass}`,
notificationId,
teacherToken,
title,
classId,
inClass: context.inClass,
};
} catch (error) {
return { ok: false, detail: `投递失败:${error instanceof Error ? error.message : String(error)}` };
}
}
/** 冒烟收尾:删除真实链路自检产生的通知 */
export async function islandRealtimeCleanup(
notificationId: string,
teacherToken: string,
): Promise<SmokeCheckResult> {
try {
const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${teacherToken}` },
});
return { ok: response.ok, detail: `清理通知 ${notificationId} → HTTP ${response.status}` };
} catch (error) {
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
}
}
/**
* 读取某条通知在本地通知中心里的已读状态。
* 用于验证"灵动岛点标为已读 → 通知中心也变成已读、未读数下降"。
*/
export async function islandReadState(notificationId: string): Promise<{
found: boolean;
read: boolean;
unreadCount: number;
title: string;
}> {
const { useNotificationStore } = await import('../stores/notifications.js');
const store = useNotificationStore();
if (store.items.length === 0) await store.load().catch(() => undefined);
const target = store.items.find((item) => item.id === notificationId);
return {
found: Boolean(target),
read: Boolean(target?.read),
unreadCount: store.unreadCount,
title: target?.title ?? '',
};
}
/**
* 冒烟自检 6(需后端在线):ClassIsland 风格"今天"时间轴。
*
* 造三节覆盖今天的课(已结束 / 正在上 / 即将开始),切到课表页读取 DOM,
* 断言:时间轴渲染出卡片、正在上的那张是"进行中"卡片并带倒计时与进度条、
* "已结束"/"下一节"标记存在、大时钟在走。用后删除探针课表。
*/
export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { router }] = await Promise.all([
import('../stores/auth.js'),
import('../router/index.js'),
]);
const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000';
const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法构造课表' };
const toHHmm = (minutes: number): string => {
const clamped = Math.min(23 * 60 + 59, Math.max(0, minutes));
return `${String(Math.floor(clamped / 60)).padStart(2, '0')}:${String(clamped % 60).padStart(2, '0')}`;
};
const created: string[] = [];
const failures: string[] = [];
let teacherToken = '';
try {
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法构造课表',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(loginBody),
}).then((response) => response.json());
teacherToken = login?.data?.token ?? '';
if (!teacherToken) return { ok: false, detail: '教师登录失败' };
const courses = await fetch(`${serverUrl}/api/courses?classId=${classId}`, {
headers: { authorization: `Bearer ${teacherToken}` },
}).then((response) => response.json());
const courseId = courses?.data?.[0]?.id;
if (!courseId) return { ok: false, detail: '该班级没有课程,无法构造课表' };
const nowMinutes = new Date().getHours() * 60 + new Date().getMinutes();
const dayOfWeek = new Date().getDay() === 0 ? 7 : new Date().getDay();
const probes = [
{ start: toHHmm(nowMinutes - 180), end: toHHmm(nowMinutes - 120), location: '时间轴自检 · 已结束' },
{ start: toHHmm(nowMinutes - 20), end: toHHmm(nowMinutes + 25), location: '时间轴自检 · 正在上课' },
{ start: toHHmm(nowMinutes + 60), end: toHHmm(nowMinutes + 105), location: '时间轴自检 · 下一节' },
// 深夜/凌晨时相对时间会越过 23:59 / 00:00,被夹紧后 start >= end(服务端 422)。
// 这种探针构造不出来就跳过,"下一节"的断言按实际创建数量放宽。
].filter((probe) => probe.start < probe.end);
for (const probe of probes) {
const response = await fetch(`${serverUrl}/api/schedules`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` },
body: JSON.stringify({
classId,
courseId,
dayOfWeek,
startTime: probe.start,
endTime: probe.end,
weekStart: 1,
weekEnd: 30,
location: probe.location,
}),
});
const payload = await response.json().catch(() => null);
const id = payload?.data?.id;
if (id) created.push(id);
// 记录失败原因(限流 429 / 时间非法 400 等),否则只看到一个"2/3"没法排查
else {
failures.push(
`${probe.start}-${probe.end} status=${response.status} ${payload?.message ?? ''}`.trim(),
);
}
}
if (created.length < probes.length) {
return {
ok: false,
detail: `探针课表创建失败(${created.length}/${probes.length})${failures.length > 0 ? `:${failures.join(';')}` : ''}`,
};
}
await router.push('/schedule');
let dom: {
count: number;
hasCurrent: boolean;
currentText: string;
hasProgress: boolean;
clock: string;
headline: string;
tags: { state: string; text: string }[];
} | null = null;
for (let attempt = 0; attempt < 24; attempt += 1) {
const items = Array.from(document.querySelectorAll('.timeline-item'));
const current = document.querySelector('.timeline-item.is-current');
const snapshot = {
count: items.length,
hasCurrent: Boolean(current),
currentText: (current?.textContent ?? '').replace(/\s+/g, ' ').trim(),
hasProgress: Boolean(current?.querySelector('.lesson-progress')),
clock: document.querySelector('.clock-main')?.textContent?.trim() ?? '',
headline: document.querySelector('.status-title')?.textContent?.trim() ?? '',
tags: items.map((node) => ({
state: node.getAttribute('data-state') ?? '',
text: (node.textContent ?? '').replace(/\s+/g, ' ').trim(),
})),
};
if (snapshot.hasCurrent && snapshot.count >= probes.length) {
dom = snapshot;
break;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
if (!dom) return { ok: false, detail: '时间轴未渲染出"进行中"卡片' };
const hasPast = dom.tags.some((tag) => tag.state === 'past' && tag.text.includes('已结束'));
const hasNext = dom.tags.some((tag) => tag.state === 'next' && tag.text.includes('下一节'));
const countdown = /距下课\s*\d+/.test(dom.currentText) || dom.currentText.includes('即将下课');
// 深夜/凌晨可能只构造出 2 个探针("下一节"跨过 23:59),此时不要求存在该卡片
const expectNextProbe = probes.length >= 3;
const ok =
dom.count >= probes.length &&
dom.hasCurrent &&
dom.hasProgress &&
countdown &&
hasPast &&
(!expectNextProbe || hasNext) &&
/^\d{2}:\d{2}$/.test(dom.clock);
return {
ok,
detail:
`卡片=${dom.count} 进行中=${dom.hasCurrent} 进度条=${dom.hasProgress} 倒计时=${countdown} ` +
`已结束=${hasPast} 下一节=${hasNext} 大时钟=${dom.clock} 摘要="${dom.headline}"` +
// 失败时能看出"到底渲染了哪几节、各自什么状态",否则只看到"已结束=false"无法定位
` 卡片状态=[${dom.tags.map((tag) => `${tag.state}:${tag.text.slice(0, 24)}`).join(' | ')}]`,
};
} catch (error) {
return { ok: false, detail: `时间轴自检异常:${error instanceof Error ? error.message : String(error)}` };
} finally {
for (const id of created) {
await fetch(`${serverUrl}/api/schedules/${id}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${teacherToken}` },
}).catch(() => undefined);
}
}
}
/**
* 冒烟自检 8(需后端在线):作业看板**全屏自适应**。
*
* 用户反馈"全屏模式没有自适应":点开全屏后卡片挤在左上角、内容显示不全。
* 这里走真实界面链路:进入作业页 → 切到「看板」→ 点「全屏看板」→
* 断言 ①布局宽度铺满视口(列数由算法注入 `--board-cols`)②缩放后可视底部不超出屏幕(无需滚动)。
*/
export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
const { router } = await import('../router/index.js');
try {
await router.push('/homeworks');
if (!(await waitUntil(() => Boolean(document.querySelector('.homework-page')), 6000))) {
return { ok: false, detail: '作业页未挂载' };
}
// 必须是「看板」模式(列表模式没有全屏看板入口)
const boardTab = Array.from(document.querySelectorAll('.el-radio-button')).find((node) =>
(node.textContent ?? '').includes('看板'),
);
if (!(boardTab instanceof HTMLElement)) return { ok: false, detail: '找不到「看板」切换按钮' };
boardTab.click();
await waitUntil(() => Boolean(document.querySelector('.board-config')), 3000);
// 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。
// 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。
// 注意判据是「看板里没有卡片」而不是「没有 board-host 容器」——空看板照样渲染容器,
// 拿容器当判据会导致切日期的兜底永远不触发(今天的日期没作业时看板必然为空)。
if (!document.querySelector('.board-card')) {
const [{ homeworkApi }, { useAuthStore }] = await Promise.all([
import('../api/index.js'),
import('../stores/auth.js'),
]);
const classId = useAuthStore().classId ?? undefined;
const days = await homeworkApi.days({ classId }).catch(() => ({ days: [] }));
const latest = days.days.length > 0 ? days.days[days.days.length - 1].date : null;
if (latest) {
await router.push({ path: '/homeworks', query: { date: latest } });
}
}
const hasBoard = await waitUntil(() => Boolean(document.querySelector('.board-host')), 4000);
const fullscreenButton = Array.from(document.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').includes('全屏看板'),
);
if (!(fullscreenButton instanceof HTMLElement)) return { ok: false, detail: '找不到「全屏看板」按钮' };
fullscreenButton.click();
const dialogReady = await waitUntil(
() => Boolean(document.querySelector('.board-fullscreen .board-lg')),
6000,
);
if (!dialogReady) return { ok: false, detail: '全屏看板弹窗未出现' };
// 等自适应轮询跑完(最多 20 次 × 60ms)
await new Promise((resolve) => setTimeout(resolve, 1600));
const board = document.querySelector('.board-fullscreen .board-lg');
const host = document.querySelector('.board-fullscreen .board-host-lg');
if (!(board instanceof HTMLElement) || !(host instanceof HTMLElement)) {
return { ok: false, detail: '全屏看板容器缺失' };
}
const rect = board.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const layoutWidth = board.offsetWidth;
const columns = Number(board.style.getPropertyValue('--board-cols') || 0);
const cards = board.querySelectorAll('.board-card').length;
const scale = Number((getComputedStyle(board).transform.match(/matrix\(([-\d.]+)/) ?? [])[1] ?? 1);
const fillsWidth = layoutWidth >= viewportWidth * 0.9;
const fitsScreen = rect.bottom <= viewportHeight + 2 && rect.width <= viewportWidth + 2;
// 第二阶段:模拟"科目很多"(真实班级看板常有 5~8 个科目)。
// 克隆卡片到 6 张 → 触发 resize 让自适应算法重算 → 断言列数自动变多且仍然铺满、不溢出。
let manyCards = '';
let manyOk = false;
const sample = board.querySelector('.board-card');
if (sample instanceof HTMLElement) {
while (board.querySelectorAll('.board-card').length < 6) {
const clone = sample.cloneNode(true) as HTMLElement;
clone.setAttribute('data-smoke-filler', '1');
board.appendChild(clone);
}
window.dispatchEvent(new Event('resize'));
await new Promise((resolve) => setTimeout(resolve, 1500));
const cardsMany = board.querySelectorAll('.board-card').length;
const columnsMany = Number(board.style.getPropertyValue('--board-cols') || 0);
const layoutWidthMany = board.offsetWidth;
const rectMany = board.getBoundingClientRect();
manyOk =
cardsMany === 6 &&
columnsMany >= 2 &&
columnsMany <= cardsMany &&
layoutWidthMany >= viewportWidth * 0.9 &&
rectMany.bottom <= viewportHeight + 2;
manyCards =
`多科目:卡片=${cardsMany} 列数=${columnsMany} 排版宽=${layoutWidthMany} ` +
`可视底=${Math.round(rectMany.bottom)} 通过=${manyOk}`;
}
const closeButton = document.querySelector('.board-fullscreen .el-dialog__headerbtn');
if (closeButton instanceof HTMLElement) closeButton.click();
return {
ok: hasBoard && cards > 0 && columns >= 1 && columns <= cards && fillsWidth && fitsScreen && manyOk,
detail:
`卡片=${cards} 列数=${columns} 缩放=${Number.isFinite(scale) ? scale.toFixed(3) : '-'} ` +
`排版宽=${layoutWidth}(视口宽=${viewportWidth})可视底=${Math.round(rect.bottom)}(视口高=${viewportHeight}) ` +
`铺满宽=${fillsWidth} 不超出屏幕=${fitsScreen};${manyCards}`,
};
} catch (error) {
return {
ok: false,
detail: `看板自适应自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* 冒烟自检(不需要后端):**本机录入的作业不再上灵动岛**(用户要求)。
*
* 场景:教室机器上录完作业,服务端照样广播 `homework:new`,但"自己通知自己"没有意义。
* 判定按**内容指纹 + id**(发起请求前就登记,避免"广播晚于响应"漏判),因此这里两条都要验:
* 1) 登记过的这条:投递后灵动岛状态不变;
* 2) 没登记过的同形作业(别班/别的标题):照常上岛。
*/
export async function islandHomeworkSuppressionCheck(): Promise<SmokeCheckResult> {
const [{ markHomeworkCreatedLocally, isLocalHomework, pushHomeworkToIsland }, { useAuthStore }] =
await Promise.all([import('../island/bridge.js'), import('../stores/auth.js')]);
try {
const classId = useAuthStore().classId ?? 'smoke-class';
const assignDate = '2030-01-01';
const draft = {
id: 'smoke-homework-suppressed',
classId,
title: '本机录入的作业',
content: '这条不应该弹上岛',
assignDate,
};
markHomeworkCreatedLocally(draft);
const recognized = isLocalHomework(draft);
// 同 id、同内容 → 必须认出来;换个标题 → 不算本机录入
const recognizedByFingerprint = isLocalHomework({ ...draft, id: '' });
const foreign = isLocalHomework({ ...draft, id: 'other', title: '别的作业' });
const before = await window.desktop?.islandGetState?.();
pushHomeworkToIsland({
...draft,
courseId: null,
attachmentUrl: null,
createdBy: '',
createdAt: new Date().toISOString(),
} as never);
await new Promise((resolve) => setTimeout(resolve, 400));
const after = await window.desktop?.islandGetState?.();
const untouched =
(before?.active?.id ?? null) === (after?.active?.id ?? null) &&
(before?.queued.length ?? 0) === (after?.queued.length ?? 0);
return {
ok: recognized && recognizedByFingerprint && !foreign && untouched,
detail:
`按 id 认出=${recognized} 按内容指纹认出=${recognizedByFingerprint} 别的作业误判=${foreign} ` +
`投递后灵动岛未变=${untouched}`,
};
} catch (error) {
return {
ok: false,
detail: `本机作业不上岛自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
import('../stores/auth.js'),
import('../stores/realtime.js'),
]);
try {
const cleaned = await cleanupTemporaryContent();
useRealtimeStore().disconnect();
await useAuthStore().logout();
return { ok: true, detail: '已断开实时通道并清理登录态' };
return { ok: true, detail: `已断开实时通道并清理登录态${cleaned ? `;${cleaned}` : ''}` };
} catch (error) {
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
}
@@ -237,6 +841,12 @@ export function registerSmokeHooks(): void {
offlineScenario,
layoutNavigationSelfTest,
onlineScenario,
islandRealtimeScenario,
islandRealtimeCleanup,
islandReadState,
islandHomeworkSuppressionCheck,
scheduleTimelineSelfTest,
homeworkBoardSelfTest,
sessionCleanup,
};
}
@@ -2,6 +2,7 @@ import {
SOCKET_EVENTS,
resolveClassStatus,
type ClassPeriod,
type HomeworkDto,
type NotificationDto,
type ScheduleDto,
} from '@classhelper/shared';
@@ -9,6 +10,7 @@ import { scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.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';
/**
@@ -82,20 +84,165 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
createdAt: notification.createdAt,
courseName: currentPeriod?.courseName ?? null,
teacherName: notification.creator?.name ?? null,
kind: 'notification',
},
context: getIslandClassContext(),
});
}
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
export function pushHomeworkToIsland(homework: HomeworkDto): void {
// 本机刚录入的作业不再上岛:教室机器上录作业的人就是眼前这台设备的主人,
// 再弹一张"新作业"卡片纯属自己通知自己(用户反馈)。
if (isLocalHomework(homework)) {
return;
}
window.desktop?.islandPush({
notification: {
id: `homework-${homework.id}`,
title: homework.title,
content: homework.content?.trim() || '(老师没有填写作业说明)',
priority: 'NORMAL',
createdAt: homework.createdAt,
courseName: homework.course?.name ?? null,
teacherName: homework.creator?.name ?? null,
kind: 'homework',
},
context: getIslandClassContext(),
});
}
/* ------------------------------------------------------------ 本机录入的作业 */
/**
* 本机刚录入的作业指纹。
*
* 为什么按指纹而不只按 id:`homework:new` 是服务端**先广播、后回响应**的,
* 录入接口返回的 id 很可能比实时事件到得还晚 —— 只记 id 会漏掉"事件先到"的那一半,
* 于是本机录的作业照样弹上岛。因此在发请求**之前**就按内容记一份指纹,
* 回来后补记 id(覆盖事件晚到的情况)。指纹带 TTL,避免长期占用内存。
*/
const localHomeworkKeys = new Set<string>();
let localHomeworkKeysAt = 0;
const LOCAL_HOMEWORK_TTL_MS = 60_000;
/** 作业内容指纹:同一班、同一天、同标题同正文 = 就是本机刚录的那条 */
function homeworkFingerprint(homework: {
classId?: string | null;
title?: string | null;
content?: string | null;
assignDate?: string | null;
}): string {
return [
homework.classId ?? '',
homework.assignDate ?? '',
(homework.title ?? '').trim(),
(homework.content ?? '').trim(),
].join('|');
}
/** 录入前/后登记"这条作业是本机录的"(id 可后补) */
export function markHomeworkCreatedLocally(homework: {
id?: string | null;
classId?: string | null;
title?: string | null;
content?: string | null;
assignDate?: string | null;
}): void {
const now = Date.now();
if (now - localHomeworkKeysAt > LOCAL_HOMEWORK_TTL_MS) localHomeworkKeys.clear();
localHomeworkKeysAt = now;
localHomeworkKeys.add(homeworkFingerprint(homework));
if (homework.id) localHomeworkKeys.add(homework.id);
}
/** 这条作业是不是本机刚录的(供投递前判定与冒烟验证) */
export function isLocalHomework(homework: {
id?: string | null;
classId?: string | null;
title?: string | null;
content?: string | null;
assignDate?: string | null;
}): boolean {
if (homework.id && localHomeworkKeys.has(homework.id)) return true;
return localHomeworkKeys.has(homeworkFingerprint(homework));
}
/**
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
* 两种都带"叫人"类型(徽标/「收到」按钮/更大的展开卡)。
*/
export function pushCallToIsland(call: NotificationDto): void {
window.desktop?.islandPush({
notification: {
id: call.id,
title: call.title,
content: call.content?.trim() || '老师正在等你,请尽快前往。',
priority: call.priority,
createdAt: call.createdAt,
courseName: null,
teacherName: call.creator?.name ?? null,
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
},
context: getIslandClassContext(),
});
}
/**
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
* 由 App.vue 在挂载时订阅一次。
*/
export function subscribeIslandMarkRead(): void {
window.desktop?.onIslandMarkRead?.((id) => {
void (async () => {
const store = useNotificationStore();
try {
await store.markRead(id);
} catch {
// 离线或通知已被删除:本地先记为已读,等下次同步纠正
const target = store.items.find((item) => item.id === id);
if (target) target.read = true;
}
})();
});
}
/**
* 灵动岛点了"标为已读"(多条通知的整批)→ 把这一批都在通知中心里标记已读。
* 与单条版本同一套兜底:离线时先在本地记为已读。
*/
export function subscribeIslandMarkAllRead(): void {
window.desktop?.onIslandMarkAllRead?.((ids) => {
void (async () => {
const store = useNotificationStore();
await Promise.all(
(ids ?? []).map(async (id) => {
try {
await store.markRead(id);
} catch {
const target = store.items.find((item) => item.id === id);
if (target) target.read = true;
}
}),
);
})();
});
}
/** 具名的课表变更处理器:匿名函数在停止时无法 off 掉,会随"登出→登录"越挂越多 */
function onScheduleUpdated(): void {
void refreshIslandSchedules().catch(() => undefined);
}
export function startIslandBridge(): void {
if (tickTimer) return;
void refreshIslandSchedules().catch(() => undefined);
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, () => {
void refreshIslandSchedules().catch(() => undefined);
});
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
refreshTimer = setInterval(() => {
@@ -103,9 +250,31 @@ export function startIslandBridge(): void {
}, REFRESH_INTERVAL_MS);
}
/**
* 停止桥接:退出登录 / 切换账号时必须调用。
*
* 只清定时器是不够的:
* - 订阅要 off,否则每次"登出 → 登录"都会多挂一份监听;
* - 本地课表缓存要清空,否则登出后仍会按**上一个班**的课表继续给主进程下发"上课/下课"
* (灵动岛按陈旧课表隐藏或弹出),换班登录时第一轮刷新回来之前用的也是旧课表。
*/
export function stopIslandBridge(): void {
if (tickTimer) clearInterval(tickTimer);
if (refreshTimer) clearInterval(refreshTimer);
tickTimer = null;
refreshTimer = null;
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
schedules = [];
currentPeriod = null;
inClass = false;
// 把"不在上课"同步给主进程:否则主进程还记着上一个会话的"上课中",
// 重新登录后若第一轮课表刷新失败(离线且无缓存),灵动岛会继续按上课态把通知压着不弹,
// 直到下一次刷新成功为止。这里主动对齐一次,代价是登出瞬间可能补弹一条此前被暂存的通知。
window.desktop?.islandSetClassState({
inClass: false,
currentPeriodEnd: null,
week: useAppStore().currentWeek,
});
}
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
@@ -70,10 +71,14 @@ async function handleLogout(): Promise<void> {
void router.push({ name: 'login' });
}
/** 通知实时推送:更新列表 + 红点 + 缓存 */
/**
* 通知实时推送:更新通知中心列表与红点。
*
* 这里**不再**额外弹一条"收到新通知"的 toast —— `stores/realtime.ts` 收到
* notification:new 时已经弹过带优先级与标题的那条,两条堆在屏幕上纯属噪音。
*/
function onNotification(payload: unknown): void {
void notifications.pushRealtime(payload as NotificationDto);
ElMessage({ message: '收到新通知,点击「通知」查看', type: 'info', duration: 3000 });
}
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
@@ -87,30 +92,40 @@ onMounted(async () => {
await notifications.refreshUnreadCount();
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
appStore.onServerRecovered(onRecovered);
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
if (auth.token) startIslandBridge();
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
appStore.offServerRecovered(onRecovered);
stopIslandBridge();
});
</script>
<template>
<el-container class="layout">
<el-aside width="190px" class="aside">
<el-aside width="200px" class="aside ch-nav">
<div class="brand">
<el-icon :size="20"><School /></el-icon>
<span>班级小助手</span>
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</span>
<el-badge
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
:value="notifications.unreadCount"
class="menu-badge"
/>
</el-menu-item>
</el-menu>
<div class="aside-footer">
@@ -170,11 +185,10 @@ onUnmounted(() => {
<style scoped>
.layout {
height: 100vh;
background: var(--ch-bg);
}
.aside {
background: var(--ch-sidebar);
color: #fff;
display: flex;
flex-direction: column;
}
@@ -182,53 +196,97 @@ onUnmounted(() => {
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 54px;
gap: 10px;
height: 52px;
padding: 0 16px;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-size: 14px;
color: var(--ch-text);
}
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
.brand-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
}
.brand-text {
letter-spacing: 0.2px;
}
.menu {
flex: 1;
border-right: none;
background: transparent;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #fff;
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
--el-menu-text-color: var(--ch-text);
--el-menu-active-color: var(--ch-text);
}
.menu :deep(.el-menu-item) {
margin: 2px 8px;
border-radius: 6px;
}
.menu :deep(.el-menu-item.is-active) {
background: #409eff;
/* 图标槽:作为未读红点的定位父级(红点在图标右上角) */
.menu-icon-slot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
/*
* 未读红点:定位到图标"右上角外侧"。
* el-badge 默认把角标中线压在包裹元素右上角(translateY(-50%) translateX(100%)),
* 这里显式覆盖成相对图标槽的负 top / 负 right,保证红点在图标右上方而不是右侧或下方。
*/
.menu-badge {
margin-left: 10px;
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
margin: 0;
pointer-events: none;
}
.menu-badge :deep(.el-badge__content) {
position: absolute;
top: -7px;
right: -11px;
transform: none;
height: 16px;
min-width: 16px;
padding: 0 4px;
line-height: 16px;
font-size: 11px;
border: none;
}
.aside-footer {
padding: 10px 16px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid var(--ch-divider);
}
.aside-meta {
font-size: 12px;
color: #8fa3b8;
line-height: 1.8;
color: var(--ch-text-tertiary);
line-height: 1.9;
}
.header {
background: #fff;
height: 52px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--ch-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px;
}
.header-left {
@@ -240,15 +298,22 @@ onUnmounted(() => {
.server-url {
font-size: 12px;
color: #909399;
color: var(--ch-text-tertiary);
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: var(--ch-radius-control);
cursor: pointer;
outline: none;
transition: background 0.15s ease;
}
.user-chip:hover {
background: rgba(0, 0, 0, 0.04);
}
.main {
+7 -2
View File
@@ -7,7 +7,7 @@ import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router/index.js';
import { registerSmokeHooks } from './cache/self-test.js';
import { registerSmokeHooks, setSmokeTeacherCredentials } from './cache/self-test.js';
import './styles/index.css';
const app = createApp(App);
@@ -20,8 +20,13 @@ app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)。
// 教师凭据由 preload 从环境变量透传(**不写在代码里**:渲染产物会进 app.asar 发给学生机)。
if (window.desktop?.smokeTest) {
setSmokeTeacherCredentials({
username: window.desktop.smokeCredentials?.username ?? '',
password: window.desktop.smokeCredentials?.password ?? '',
});
registerSmokeHooks();
}
@@ -1,7 +1,13 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
import {
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
STORAGE_KEYS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
import { classChannelApi } from '../api/index.js';
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
@@ -19,6 +25,13 @@ export const useAppStore = defineStore('app', () => {
const currentWeek = ref(1);
const maxWeek = ref(20);
const initialized = ref(false);
/**
* 通知显示位置:这台教室机器把提醒弹在哪个端。
*
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。
*/
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL);
const offline = computed(() => !serverReachable.value);
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
@@ -108,6 +121,53 @@ export const useAppStore = defineStore('app', () => {
void refreshCacheStats();
}
/* ------------------------------------------------------------ 通知显示位置 */
/** 启动时用本地配置兜底(此时可能还没登录/断网) */
function applyLocalNotificationChannel(value: ClassIslandNotificationChannel): void {
notificationChannel.value = value;
}
/**
* 向服务器对齐显示位置。
* 登录后调用:以服务端为准(多台机器/老师改过之后,本地缓存可能已经过期)。
*/
async function loadNotificationChannel(classId: string | null): Promise<void> {
if (!classId) return;
try {
const result = await classChannelApi.get(classId);
notificationChannel.value = result.notificationChannel;
await window.desktop?.saveConfig({ notificationChannel: result.notificationChannel });
} catch {
// 离线/无权限:保留本地值,不影响展示
}
}
/**
* 改显示位置:先落本地(立刻生效),再写回班级记录。
* 服务端写失败时返回 false,由设置页提示"已保存在本机,联网后请重试"。
*/
async function setNotificationChannel(
classId: string | null,
value: ClassIslandNotificationChannel,
): Promise<boolean> {
notificationChannel.value = value;
await window.desktop?.saveConfig({ notificationChannel: value });
if (!classId) return false;
try {
const result = await classChannelApi.set(classId, value);
notificationChannel.value = result.notificationChannel;
return true;
} catch {
return false;
}
}
/** 本次提醒是否要在本机弹(弹窗 + 灵动岛);"只在 ClassIsland 上弹"时为 false */
function shouldPopupLocally(): boolean {
return notificationChannel.value !== 'classisland';
}
return {
serverUrl,
serverReachable,
@@ -119,9 +179,14 @@ export const useAppStore = defineStore('app', () => {
currentWeek,
maxWeek,
initialized,
notificationChannel,
init,
ping,
applyServerUrl,
applyLocalNotificationChannel,
loadNotificationChannel,
setNotificationChannel,
shouldPopupLocally,
refreshCacheStats,
clearCache,
markSynced,
@@ -1,6 +1,6 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, type LoginResponse, type StudentDto } from '@classhelper/shared';
import { STORAGE_KEYS, type LoginResponse, type SessionUser } from '@classhelper/shared';
import { authApi } from '../api/index.js';
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
import { cacheGet, cacheSet } from '../cache/db.js';
@@ -15,13 +15,17 @@ const PROFILE_KEY = 'me';
*/
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null);
const user = ref<StudentDto | null>(null);
const user = ref<SessionUser | null>(null);
const loading = ref(false);
const offlineSession = ref(false);
const isAuthenticated = computed(() => Boolean(token.value));
const displayName = computed(() => user.value?.name ?? '未登录');
const classId = computed(() => user.value?.classId ?? null);
/** true 表示当前是「班级账号(班级设备)」会话:个人数据按全班读写 */
const isClassSession = computed(() => user.value?.classSession === true);
/** 班级码(登录账号,便于设置页展示) */
const classCode = computed(() => user.value?.classCode ?? '');
setTokenProvider(() => token.value);
@@ -39,20 +43,26 @@ export const useAuthStore = defineStore('auth', () => {
await window.desktop.saveConfig({ serverUrl, username, token: nextToken });
}
/** 登录:写主进程配置 + 缓存资料 */
async function login(serverUrl: string, username: string, password: string): Promise<void> {
/**
* 班级账号登录(学生端主入口):班级码 + 班级密码。
*
* 需求 1:学生端以「班级」为主体,个人学生不再是登录主体;
* 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。
* 保存的 username 字段记录班级码,便于下次预填。
*/
async function login(serverUrl: string, code: string, password: string): Promise<void> {
const normalized = normalizeServerUrl(serverUrl);
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
setApiBaseUrl(normalized);
loading.value = true;
try {
const payload: LoginResponse = await authApi.login({ username, password });
const payload: LoginResponse = await authApi.classLogin({ code, password });
applyToken(payload.token);
user.value = payload.user as StudentDto;
user.value = payload.user;
offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, user.value);
await persistSession(normalized, username, payload.token);
await persistSession(normalized, code, payload.token);
} finally {
loading.value = false;
}
@@ -80,7 +90,7 @@ export const useAuthStore = defineStore('auth', () => {
}
}
const cachedProfile = await cacheGet<StudentDto>('profile', PROFILE_KEY).catch(() => null);
const cachedProfile = await cacheGet<SessionUser>('profile', PROFILE_KEY).catch(() => null);
if (cachedProfile) user.value = cachedProfile.value;
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
@@ -122,6 +132,8 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated,
displayName,
classId,
isClassSession,
classCode,
login,
restore,
refreshProfile,
@@ -11,7 +11,8 @@ import {
type ServerToClientEvents,
} from '@classhelper/shared';
import { socketUrlOf } from '../config.js';
import { pushNotificationToIsland } from '../island/bridge.js';
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
import { useAppStore } from './app.js';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
@@ -22,6 +23,18 @@ type EventHandler = (payload: unknown) => void;
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
*/
export const useRealtimeStore = defineStore('realtime', () => {
const app = useAppStore();
/**
* 本机要不要弹这次提醒。
*
* 教室在设置页选了「只在 ClassIsland 上弹」时,客户端仍然把通知收进通知中心(列表里有记录、
* 未读红点照常),但**不再弹窗、也不上岛** —— 否则老师会看到两个地方同时炸出来。
*/
function popupLocally(): boolean {
return app.shouldPopupLocally();
}
const connected = ref(false);
const connecting = ref(false);
const lastEventName = ref<string | null>(null);
@@ -65,23 +78,42 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.notificationNew);
ElMessage({
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
type: payload.priority === 'URGENT' ? 'error' : 'info',
duration: 5000,
showClose: true,
});
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
pushNotificationToIsland(payload);
if (popupLocally()) {
ElMessage({
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
type: payload.priority === 'URGENT' ? 'error' : 'info',
duration: 5000,
showClose: true,
});
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
pushNotificationToIsland(payload);
}
dispatch(SOCKET_EVENTS.notificationNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
mark(SOCKET_EVENTS.homeworkNew);
ElMessage.success(`新作业:${payload.title}`);
// 作业发布也上岛:胶囊提示"新作业",点击展开看作业要求
pushHomeworkToIsland(payload);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.callNew);
if (popupLocally()) {
ElMessage({
message: payload.title,
type: 'warning',
duration: 8000,
showClose: true,
});
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
pushCallToIsland(payload);
}
dispatch(SOCKET_EVENTS.callNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto & { deleted?: boolean }) => {
mark(SOCKET_EVENTS.homeworkUpdated);
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
@@ -1,7 +1,65 @@
/* ============================================================================
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
*
* 参考 ClassIsland(Windows 课件表应用)的观感:
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
* ========================================================================== */
:root {
--ch-bg: #f5f7fa;
--ch-border: #e4e7ed;
--ch-sidebar: #1f2d3d;
/* Fluent 强调色 */
--ch-accent: #0f6cbd;
--ch-accent-hover: #115ea3;
--ch-accent-soft: rgba(15, 108, 189, 0.08);
--ch-accent-soft-strong: rgba(15, 108, 189, 0.14);
/* 层次底色(Mica / Layer) */
--ch-bg: #f3f3f3;
--ch-layer: rgba(255, 255, 255, 0.7);
--ch-layer-solid: #ffffff;
--ch-layer-alt: rgba(249, 249, 249, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.5);
/* 描边(Fluent 用半透明黑,避免生硬的灰线) */
--ch-border: rgba(0, 0, 0, 0.06);
--ch-border-strong: rgba(0, 0, 0, 0.1);
--ch-divider: rgba(0, 0, 0, 0.05);
/* 文本 */
--ch-text: #1b1b1b;
--ch-text-secondary: #5c5c5c;
--ch-text-tertiary: #8a8a8a;
/* 圆角与阴影 */
--ch-radius-card: 8px;
--ch-radius-control: 4px;
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
/* 兼容旧变量名 */
--ch-sidebar: #ffffff;
/* Element Plus 变量对齐 Fluent */
--el-color-primary: #0f6cbd;
--el-color-primary-light-3: #4d94d1;
--el-color-primary-light-5: #7fb1de;
--el-color-primary-light-7: #b3d0ec;
--el-color-primary-light-8: #d0e2f4;
--el-color-primary-light-9: #e7f0f9;
--el-color-primary-dark-2: #115ea3;
--el-border-radius-base: 4px;
--el-border-radius-small: 3px;
--el-text-color-primary: #1b1b1b;
--el-text-color-regular: #3a3a3a;
--el-text-color-secondary: #5c5c5c;
--el-border-color: rgba(0, 0, 0, 0.1);
--el-border-color-light: rgba(0, 0, 0, 0.06);
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
--el-fill-color-blank: transparent;
--el-font-size-base: 13px;
}
* {
@@ -17,11 +75,15 @@ body,
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-family:
'Segoe UI Variable Text', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
background: var(--ch-bg);
color: #303133;
color: var(--ch-text);
/* 桌面端禁用整页选中,更接近原生应用体验 */
user-select: none;
-webkit-font-smoothing: antialiased;
}
input,
@@ -29,6 +91,8 @@ textarea {
user-select: text;
}
/* ---------------------------------------------------------------- 页面骨架 */
.page {
padding: 16px 20px 24px;
}
@@ -39,25 +103,26 @@ textarea {
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
margin-bottom: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
font-size: 20px;
font-weight: 600;
letter-spacing: 0.2px;
}
.page-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
font-size: 12.5px;
color: var(--ch-text-secondary);
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-wrap: wrap;
}
@@ -70,13 +135,161 @@ textarea {
}
.text-muted {
color: #909399;
color: var(--ch-text-tertiary);
}
.offline-banner {
margin: 0 0 12px;
}
/* ---------------------------------------------------------------- 卡片 / 分组 */
/* 亚克力卡片:半透明底 + 极细描边 + 极轻阴影(ClassIsland 的内容卡片观感) */
.el-card {
border-radius: var(--ch-radius-card) !important;
border: 1px solid var(--ch-border) !important;
background: var(--ch-layer) !important;
backdrop-filter: blur(20px) saturate(1.1);
box-shadow: var(--ch-shadow-card) !important;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.el-card:hover {
border-color: var(--ch-border-strong) !important;
}
.el-card__header {
padding: 12px 16px !important;
border-bottom: 1px solid var(--ch-divider) !important;
font-weight: 600;
font-size: 13.5px;
}
.el-card__body {
padding: 14px 16px !important;
}
/* ---------------------------------------------------------------- 控件 */
.el-button {
border-radius: var(--ch-radius-control) !important;
font-weight: 500;
}
.el-button--default {
background: rgba(255, 255, 255, 0.7) !important;
border-color: var(--ch-border-strong) !important;
}
.el-button--default:hover {
background: rgba(0, 0, 0, 0.04) !important;
}
.el-button--primary {
box-shadow: 0 1px 2px rgba(15, 108, 189, 0.2);
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
border-radius: var(--ch-radius-control) !important;
background: rgba(255, 255, 255, 0.7) !important;
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--ch-accent) inset !important;
}
.el-tag {
border-radius: var(--ch-radius-control) !important;
}
.el-dialog,
.el-drawer {
border-radius: var(--ch-radius-card) !important;
background: var(--ch-layer-solid) !important;
}
.el-message-box {
border-radius: var(--ch-radius-card) !important;
}
.el-popper,
.el-dropdown__popper {
border-radius: var(--ch-radius-card) !important;
box-shadow: var(--ch-shadow-flyout) !important;
}
/* ---------------------------------------------------------------- 表格 */
.el-table {
--el-table-border-color: var(--ch-divider);
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
background: transparent !important;
}
.el-table th.el-table__cell {
background: transparent !important;
font-weight: 600;
color: var(--ch-text-secondary);
}
.el-table td.el-table__cell,
.el-table th.el-table__cell {
border-bottom: 1px solid var(--ch-divider) !important;
}
.el-table__inner-wrapper::before {
display: none;
}
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
.ch-nav {
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(24px) saturate(1.2);
border-right: 1px solid var(--ch-border);
}
.ch-nav .el-menu-item {
height: 40px;
line-height: 40px;
margin: 2px 8px;
border-radius: var(--ch-radius-control);
color: var(--ch-text);
font-weight: 500;
position: relative;
}
.ch-nav .el-menu-item:hover {
background: rgba(0, 0, 0, 0.04);
}
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
.ch-nav .el-menu-item.is-active {
background: var(--ch-accent-soft);
color: var(--ch-text);
}
.ch-nav .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--ch-accent);
}
/* ---------------------------------------------------------------- 课表时间轴 */
.timetable-cell {
min-height: 30px;
}
@@ -87,7 +300,7 @@ textarea {
.timetable-meta {
font-size: 12px;
color: #909399;
color: var(--ch-text-tertiary);
}
.unread-dot {
@@ -95,19 +308,27 @@ textarea {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
background: #d13438;
margin-right: 6px;
}
/* 滚动条美化(Windows 默认滚动条较粗) */
/* ---------------------------------------------------------------- 滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: #c8ccd4;
border-radius: 4px;
background: rgba(0, 0, 0, 0.18);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
background-clip: content-box;
}
::-webkit-scrollbar-track {
@@ -12,9 +12,12 @@ import {
import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore();
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */
const auth = useAuthStore();
const realtime = useRealtimeStore();
const loading = ref(false);
@@ -130,9 +133,10 @@ onUnmounted(() => {
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">我的成绩</h2>
<h2 class="page-title">{{ auth.isClassSession ? '本班成绩' : '我的成绩' }}</h2>
<p class="page-subtitle">
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
<span v-if="auth.isClassSession">· 班级账号:显示全班成绩总览</span>
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
File diff suppressed because it is too large. Load diff
@@ -14,7 +14,7 @@ const auth = useAuthStore();
const realtime = useRealtimeStore();
const formRef = ref<FormInstance>();
const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' });
const form = reactive({ serverUrl: DEFAULT_SERVER, code: '', password: '' });
const testing = ref(false);
const testResult = ref<'ok' | 'fail' | null>(null);
@@ -29,7 +29,7 @@ const rules: FormRules = {
trigger: 'blur',
},
],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
code: [{ required: true, message: '请输入班级码', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
@@ -38,7 +38,7 @@ onMounted(async () => {
if (window.desktop) {
const config = await window.desktop.getConfig();
if (config.serverUrl) form.serverUrl = config.serverUrl;
if (config.username) form.username = config.username;
if (config.username) form.code = config.username;
}
});
@@ -66,10 +66,12 @@ async function submit(): Promise<void> {
if (!valid) return;
try {
await auth.login(form.serverUrl, form.username.trim(), form.password);
await auth.login(form.serverUrl, form.code.trim(), form.password);
await appStore.init(form.serverUrl);
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
await appStore.loadNotificationChannel(auth.classId);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
ElMessage.success(`欢迎回来,${auth.displayName}`);
ElMessage.success(`已进入 ${auth.displayName}`);
await router.replace('/schedule');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败');
@@ -92,7 +94,7 @@ async function enterOffline(): Promise<void> {
<el-icon :size="30" color="#409eff"><School /></el-icon>
<div>
<h1 class="login-title">班级小助手</h1>
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p>
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
</div>
</div>
@@ -105,19 +107,19 @@ async function enterOffline(): Promise<void> {
</el-input>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit">
<el-form-item label="班级码" prop="code">
<el-input v-model="form.code" placeholder="请输入班级码" clearable @keyup.enter="submit">
<template #prefix>
<el-icon><User /></el-icon>
<el-icon><School /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-form-item label="班级密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
placeholder="班级密码"
show-password
@keyup.enter="submit"
>
@@ -156,9 +158,9 @@ async function enterOffline(): Promise<void> {
</el-button>
<p class="login-tip">
演示账号:student01 / student123(高一(1)班)
班级码与班级密码由管理员在 Web 管理端「班级管理 → 班级账号」中设置或重置。
<br />
数据来源:教师或管理员在 Web 管理端发布的内容。
登录后本机即代表整个班级:作业完成、通知已读都会按全班记录。
</p>
</div>
</div>
@@ -4,8 +4,11 @@ import {
SOCKET_EVENTS,
WEEKDAYS,
WEEKDAY_LABELS,
WEEK_PARITY_LABELS,
buildWeekOptions,
formatWeekRange,
resolveClassStatus,
timeToMinutes,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
@@ -26,6 +29,118 @@ const week = ref(appStore.currentWeek);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const weekOptions = ref<number[]>(buildWeekOptions(20));
/** 时间轴 / 周视图 切换(默认时间轴,对齐 ClassIsland 的"今天"观感) */
const activeTab = ref<'today' | 'week'>('today');
/** 当前时刻(每秒刷新,用于大时钟与"进行中/倒计时") */
const now = ref(new Date());
let clockTimer: ReturnType<typeof setInterval> | null = null;
const todayWeekday = computed(() => {
const day = now.value.getDay();
return day === 0 ? 7 : day;
});
const clockText = computed(() =>
now.value.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }),
);
const clockSeconds = computed(() => String(now.value.getSeconds()).padStart(2, '0'));
const dateText = computed(() =>
now.value.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }),
);
const weekdayText = computed(() => WEEKDAY_LABELS[todayWeekday.value] ?? '');
/** 查看的是否为当前教学周(只有当前周才计算"进行中") */
const isCurrentWeek = computed(() => week.value === appStore.currentWeek);
const todayItems = computed(() =>
[...items.value]
.filter((item) => item.dayOfWeek === todayWeekday.value)
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)),
);
const classStatus = computed(() =>
isCurrentWeek.value
? resolveClassStatus(todayItems.value, now.value, week.value)
: { inClass: false, current: null, next: null },
);
type LessonState = 'past' | 'current' | 'next' | 'upcoming';
interface TimelineLesson {
id: string;
start: string;
end: string;
course: string;
location: string;
weekRange: string;
/** 单双周标签('单周' / '双周';每周的课为空串) */
parityLabel: string;
state: LessonState;
/** 进行中:距下课剩余;下一节:距上课剩余(分钟) */
remainMinutes: number;
/** 进行中:本节课进度 0~100 */
progress: number;
}
const timeline = computed<TimelineLesson[]>(() =>
todayItems.value.map((item) => {
const start = timeToMinutes(item.startTime);
const end = timeToMinutes(item.endTime);
const nowMinutes = now.value.getHours() * 60 + now.value.getMinutes();
const isCurrent =
isCurrentWeek.value && classStatus.value.current?.scheduleId === item.id && nowMinutes < end;
const isNext = isCurrentWeek.value && classStatus.value.next?.scheduleId === item.id;
const state: LessonState = isCurrent
? 'current'
: nowMinutes >= end
? 'past'
: isNext
? 'next'
: 'upcoming';
return {
id: item.id,
start: item.startTime,
end: item.endTime,
course: item.course?.name ?? '课程',
location: item.location ?? '未填地点',
weekRange: formatWeekRange(item.weekStart, item.weekEnd),
/** 单双周标签(每周的课不显示) */
parityLabel: (item.weekParity ?? 'ALL') === 'ALL' ? '' : WEEK_PARITY_LABELS[item.weekParity],
state,
remainMinutes: state === 'current' ? end - nowMinutes : start - nowMinutes,
progress: state === 'current' ? Math.round(((nowMinutes - start) / Math.max(1, end - start)) * 100) : 0,
};
}),
);
/** 头部摘要:正在上课 / 下一节 */
const headline = computed(() => {
const status = classStatus.value;
if (status.current) {
return {
title: `正在上 ${status.current.courseName}`,
detail: `${status.current.startTime}-${status.current.endTime}${status.current.location ? ` · ${status.current.location}` : ''}`,
remain: timeline.value.find((item) => item.state === 'current')?.remainMinutes ?? 0,
kind: 'current' as const,
};
}
if (status.next) {
const remain = timeline.value.find((item) => item.state === 'next')?.remainMinutes ?? 0;
return {
title: `下一节 ${status.next.courseName}`,
detail: `${status.next.startTime} 开始${status.next.location ? ` · ${status.next.location}` : ''}`,
remain,
kind: 'next' as const,
};
}
return {
title: todayItems.value.length > 0 ? '今天的课都上完了' : '今天没有课程安排',
detail: todayItems.value.length > 0 ? '好好休息一下,别忘了复习' : '可以看看作业和通知',
remain: 0,
kind: 'idle' as const,
};
});
const periods = computed(() => {
const buckets = new Map<string, ScheduleDto[]>();
@@ -47,15 +162,17 @@ function cellItems(period: { items: ScheduleDto[] }, dayOfWeek: number): Schedul
return period.items.filter((item) => item.dayOfWeek === dayOfWeek);
}
const todayWeekday = computed(() => {
const day = new Date().getDay();
return day === 0 ? 7 : day;
});
const courseCount = computed(() => new Set(items.value.map((item) => item.courseId)).size);
function remainText(minutes: number): string {
if (minutes <= 0) return '即将下课';
if (minutes < 60) return `${minutes} 分钟`;
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`;
}
async function loadTerm(): Promise<void> {
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(), {
// 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周)
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(auth.classId ?? undefined), {
currentWeek: 1,
maxWeek: 20,
});
@@ -110,11 +227,16 @@ function onRecovered(): void {
onMounted(async () => {
await loadTerm();
await loadSchedules();
clockTimer = setInterval(() => {
now.value = new Date();
}, 1000);
realtime.on(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
if (clockTimer) clearInterval(clockTimer);
clockTimer = null;
realtime.off(SOCKET_EVENTS.scheduleUpdated, onScheduleEvent);
appStore.offServerRecovered(onRecovered);
});
@@ -126,47 +248,424 @@ onUnmounted(() => {
<div>
<h2 class="page-title">我的课表</h2>
<p class="page-subtitle">
第 {{ week }} 周 · 共 {{ items.length }} 节课 · {{ courseCount }} 门课程
<el-tag v-if="fromCache" class="mt-12" size="small" type="warning" effect="plain">离线缓存</el-tag>
第 {{ week }} 周 · 今天 {{ todayItems.length }} 节 · 本周共 {{ items.length }} 节 ·
{{ courseCount }} 门课程
<el-tag v-if="fromCache" class="ml-8" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
<div class="toolbar">
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)">上一周</el-button>
<el-select v-model="week" style="width: 120px" @change="loadSchedules">
<el-select v-model="week" style="width: 118px" @change="loadSchedules">
<el-option v-for="item in weekOptions" :key="item" :label="`第 ${item} 周`" :value="item" />
</el-select>
<el-button :icon="'ArrowLeft'" :disabled="week <= 1" @click="changeWeek(-1)" />
<el-button :disabled="week >= appStore.maxWeek" @click="changeWeek(1)">
下一周
<el-icon class="el-icon--right"><ArrowRight /></el-icon>
<el-icon><ArrowRight /></el-icon>
</el-button>
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
</div>
</div>
<el-card v-loading="loading" shadow="never">
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
<el-table v-else :data="periods" border size="small" :cell-class-name="() => ''">
<el-table-column label="节次" width="110" fixed>
<template #default="{ row }">
<div>{{ row.start }}</div>
<div class="timetable-meta">{{ row.end }}</div>
</template>
</el-table-column>
<el-table-column
v-for="day in WEEKDAYS"
:key="day"
:label="`${WEEKDAY_LABELS[day]}${day === todayWeekday ? ' · 今天' : ''}`"
min-width="132"
>
<template #default="{ row }">
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
<div class="timetable-course">{{ item.course?.name ?? '课程' }}</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
<el-tabs v-model="activeTab" class="schedule-tabs">
<!-- ---------------------------------------------------- 今天(时间轴) -->
<el-tab-pane label="今天" name="today">
<div class="hero">
<div class="hero-clock">
<span class="clock-main">{{ clockText }}</span>
<span class="clock-seconds">{{ clockSeconds }}</span>
</div>
<div class="hero-meta">
<div class="hero-date">{{ dateText }} · {{ weekdayText }}</div>
<div class="hero-week">第 {{ week }} 教学周{{ isCurrentWeek ? '' : '(查看中)' }}</div>
</div>
<div class="hero-status" :class="`status-${headline.kind}`">
<div class="status-title">{{ headline.title }}</div>
<div class="status-detail">{{ headline.detail }}</div>
<div v-if="headline.kind !== 'idle'" class="status-remain">
{{ headline.kind === 'current' ? '距下课' : '距上课' }} {{ remainText(headline.remain) }}
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</div>
<el-card v-loading="loading" shadow="never" class="timeline-card">
<el-empty v-if="timeline.length === 0" description="今天没有课程安排" :image-size="90" />
<ul v-else class="timeline">
<li
v-for="lesson in timeline"
:key="lesson.id"
class="timeline-item"
:class="`is-${lesson.state}`"
:data-state="lesson.state"
>
<div class="timeline-time">
<span class="time-start">{{ lesson.start }}</span>
<span class="time-end">{{ lesson.end }}</span>
</div>
<div class="timeline-rail" aria-hidden="true"><span class="rail-dot"></span></div>
<div class="timeline-card-inner">
<div class="lesson-head">
<span class="lesson-course">{{ lesson.course }}</span>
<el-tag v-if="lesson.state === 'current'" size="small" effect="dark" class="lesson-tag">
正在上课
</el-tag>
<el-tag
v-else-if="lesson.state === 'next'"
size="small"
type="warning"
effect="plain"
class="lesson-tag"
>
下一节 · {{ remainText(lesson.remainMinutes) }}后
</el-tag>
<el-tag
v-else-if="lesson.state === 'past'"
size="small"
type="info"
effect="plain"
class="lesson-tag"
>
已结束
</el-tag>
</div>
<div class="lesson-meta">
<span>{{ lesson.location }}</span>
<span class="lesson-sep">·</span>
<span>{{ lesson.weekRange }}</span>
<!-- 单双周:只有单周/双周上的课才打标签,避免"每周"的课被噪音刷屏 -->
<template v-if="lesson.parityLabel">
<span class="lesson-sep">·</span>
<el-tag size="small" effect="plain" class="lesson-parity">
{{ lesson.parityLabel }}
</el-tag>
</template>
<template v-if="lesson.state === 'current'">
<span class="lesson-sep">·</span>
<span class="lesson-remain">距下课 {{ remainText(lesson.remainMinutes) }}</span>
</template>
</div>
<el-progress
v-if="lesson.state === 'current'"
class="lesson-progress"
:percentage="lesson.progress"
:stroke-width="4"
:show-text="false"
color="#ffffff"
/>
</div>
</li>
</ul>
</el-card>
</el-tab-pane>
<!-- ---------------------------------------------------- 本周(周视图) -->
<el-tab-pane label="本周" name="week">
<el-card v-loading="loading" shadow="never" class="table-card">
<el-empty v-if="periods.length === 0" description="该周暂无课程安排" />
<el-table v-else :data="periods" border size="small">
<el-table-column label="节次" width="110" fixed>
<template #default="{ row }">
<div>{{ row.start }}</div>
<div class="timetable-meta">{{ row.end }}</div>
</template>
</el-table-column>
<el-table-column
v-for="day in WEEKDAYS"
:key="day"
:label="`${WEEKDAY_LABELS[day]}${day === todayWeekday ? ' · 今天' : ''}`"
min-width="132"
>
<template #default="{ row }">
<div v-for="item in cellItems(row, day)" :key="item.id" class="timetable-cell">
<div class="timetable-course">{{ item.course?.name ?? '课程' }}</div>
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.schedule-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
/* ------------------------------------------------------------ 顶部概览卡(ClassIsland 的"当前课"卡片) */
.hero {
display: flex;
align-items: center;
gap: 18px;
padding: 16px 20px;
margin-bottom: 12px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
background: var(--ch-layer);
backdrop-filter: blur(20px) saturate(1.1);
box-shadow: var(--ch-shadow-card);
}
.hero-clock {
display: flex;
align-items: baseline;
gap: 2px;
min-width: 118px;
}
.clock-main {
font-size: 40px;
font-weight: 600;
letter-spacing: -1px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.clock-seconds {
font-size: 14px;
color: var(--ch-text-tertiary);
font-variant-numeric: tabular-nums;
}
.hero-meta {
flex: 0 0 auto;
padding-right: 18px;
border-right: 1px solid var(--ch-divider);
}
.hero-date {
font-size: 13.5px;
font-weight: 600;
}
.hero-week {
margin-top: 4px;
font-size: 12px;
color: var(--ch-text-secondary);
}
.hero-status {
flex: 1;
min-width: 0;
padding: 10px 14px;
border-radius: var(--ch-radius-control);
background: var(--ch-accent-soft);
}
.hero-status.status-current {
background: linear-gradient(135deg, var(--ch-accent) 0%, #2b7fc4 100%);
color: #fff;
}
.hero-status.status-idle {
background: rgba(0, 0, 0, 0.03);
}
.status-title {
font-size: 15px;
font-weight: 600;
}
.status-detail {
margin-top: 3px;
font-size: 12.5px;
opacity: 0.85;
}
.status-remain {
margin-top: 6px;
font-size: 12.5px;
font-weight: 600;
}
.hero-status.status-current .status-detail,
.hero-status.status-current .status-remain {
opacity: 0.92;
}
/* ------------------------------------------------------------ 时间轴 */
.timeline-card :deep(.el-card__body) {
padding: 8px 16px 12px !important;
}
.timeline {
margin: 0;
padding: 0;
list-style: none;
}
.timeline-item {
display: flex;
align-items: stretch;
gap: 0;
padding: 8px 0;
}
.timeline-time {
flex: 0 0 66px;
padding-top: 10px;
text-align: right;
padding-right: 12px;
}
.time-start {
display: block;
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.time-end {
display: block;
font-size: 11.5px;
color: var(--ch-text-tertiary);
font-variant-numeric: tabular-nums;
}
/* 竖向轨道 + 节点:ClassIsland 时间轴的标志性观感 */
.timeline-rail {
position: relative;
flex: 0 0 18px;
display: flex;
justify-content: center;
}
.timeline-rail::before {
content: '';
position: absolute;
top: 0;
bottom: -16px;
width: 2px;
background: var(--ch-divider);
}
.timeline-item:last-child .timeline-rail::before {
bottom: 50%;
}
.rail-dot {
position: relative;
z-index: 1;
margin-top: 14px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
border: 2px solid var(--ch-border-strong);
}
.timeline-item.is-current .rail-dot {
border-color: var(--ch-accent);
background: var(--ch-accent);
box-shadow: 0 0 0 4px var(--ch-accent-soft-strong);
}
.timeline-item.is-past .rail-dot {
border-color: var(--ch-border);
background: var(--ch-bg);
}
.timeline-card-inner {
flex: 1;
min-width: 0;
padding: 10px 14px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
background: rgba(255, 255, 255, 0.6);
transition:
border-color 0.15s ease,
background 0.15s ease,
transform 0.15s ease;
}
.timeline-item:hover .timeline-card-inner {
border-color: var(--ch-border-strong);
transform: translateY(-1px);
}
/* 正在进行:强调色实底(ClassIsland 用它突出"当前课") */
.timeline-item.is-current .timeline-card-inner {
border-color: transparent;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #2b7fc4 100%);
box-shadow: 0 4px 12px rgba(15, 108, 189, 0.28);
}
.timeline-item.is-past .timeline-card-inner {
background: rgba(255, 255, 255, 0.35);
color: var(--ch-text-tertiary);
}
.lesson-head {
display: flex;
align-items: center;
gap: 8px;
}
.lesson-course {
font-size: 15px;
font-weight: 600;
}
.lesson-tag {
margin-left: auto;
}
.lesson-meta {
margin-top: 5px;
font-size: 12.5px;
color: var(--ch-text-secondary);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.timeline-item.is-current .lesson-meta {
color: rgba(255, 255, 255, 0.9);
}
.timeline-item.is-past .lesson-meta {
color: var(--ch-text-tertiary);
}
.lesson-sep {
opacity: 0.5;
}
/* 单双周标签:比正文小一号,颜色跟随主题色,避免抢标题 */
.lesson-parity {
height: 18px;
padding: 0 6px;
font-size: 11px;
color: var(--ch-accent, #409eff);
border-color: currentColor;
}
.lesson-remain {
font-weight: 600;
}
.lesson-progress {
margin-top: 8px;
}
.lesson-progress :deep(.el-progress-bar__outer) {
background: rgba(255, 255, 255, 0.28);
}
@media (max-width: 720px) {
.hero {
flex-wrap: wrap;
gap: 12px;
}
.hero-meta {
border-right: none;
padding-right: 0;
}
}
</style>
@@ -2,8 +2,27 @@
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate } from '@classhelper/shared';
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';
@@ -102,9 +121,211 @@ async function logout(): Promise<void> {
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>
@@ -150,15 +371,271 @@ onMounted(async () => {
</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.user?.username ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
{{ auth.user?.username ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="登录方式">
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<el-button class="mt-16" type="danger" plain @click="logout">退出登录</el-button>
<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>
@@ -205,6 +682,26 @@ onMounted(async () => {
</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;
}
+137 -4
View File
@@ -2,7 +2,27 @@
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
* 渲染进程只依赖这些最小 API,不接触 Node.js。
*/
import type { IslandNotification, IslandState } from '@classhelper/shared';
import type {
ClassIslandNotificationChannel,
IslandAppearance,
IslandNotification,
IslandState,
} from '@classhelper/shared';
/** 作业页展示偏好(看板 / 列表,看板外观) */
export interface HomeworkBoardSettings {
/** 展示模式:board = 按科目卡片看板,list = 默认表格 */
mode: 'board' | 'list';
/** 看板条目是否显示时间(同时也控制"今日作业看板"标题栏中间的当天时间) */
showTime: boolean;
/** 看板字号(px) */
fontSize: number;
/**
* @deprecated 已由「按天查看 + 日期选择器」取代(作业页不再有"只看今天"开关)。
* 保留字段只为兼容旧配置文件,读写都忽略它。
*/
todayOnly?: boolean;
}
export interface DesktopStoredConfig {
/** 后端服务地址,例如 http://127.0.0.1:4000 */
@@ -11,6 +31,22 @@ export interface DesktopStoredConfig {
username: string;
/** 登录令牌(主进程用 safeStorage 加密后落盘,读回时自动解密) */
token: string | null;
/** 个性化设置:灵动岛外观(高度/宽度/圆角/透明度/主题色/字号/动画/位置/置顶) */
island: IslandAppearance;
/** 作业页展示偏好(看板/列表、显示时间、看板字号) */
homeworkBoard: HomeworkBoardSettings;
/**
* 作业录入的快捷短语(客户端「设置 → 作业录入」里增删,默认见 shared 的 HOMEWORK_PHRASE_DEFAULTS)。
* 录入作业时点一下就追加到标题/内容里,少打字(例如 P、大本、背诵)。
*/
homeworkPhrases: string[];
/**
* 通知显示位置(both / client / classisland)。
*
* 这台教室机器"提醒到底弹在哪个端"由它决定:值同时存本地(决定本机要不要弹窗/上岛)
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
*/
notificationChannel: ClassIslandNotificationChannel;
}
export interface DesktopAppInfo {
@@ -31,6 +67,8 @@ export interface IslandPushContext {
/** 当前这节课的结束时间(HH:mm) */
currentPeriodEnd?: string | null;
week?: number;
/** 设置页"预览效果":直接展开示例岛、失焦不收起、30 秒后自行消失 */
preview?: boolean;
}
export interface IslandClassStatePayload {
@@ -42,26 +80,91 @@ export interface IslandClassStatePayload {
export interface DesktopBridge {
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
smokeTest: boolean;
/**
* 冒烟凭据(仅冒烟模式下发,否则 `undefined`)。
*
* 为什么要从环境变量绕一圈:这些凭据被渲染进程的冒烟钩子用来"造测试数据",
* 而渲染产物(`dist/renderer/assets/*.js`)会进 `app.asar` 发给每台学生机 —— asar 可直接解包,
* 把账号口令写在代码里等于随安装包一起发出去。凭据只由
* `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供(这两个脚本不进安装包)。
*/
smokeCredentials?: {
username: string;
password: string;
classCode: string;
classPassword: string;
};
getConfig(): Promise<DesktopStoredConfig>;
/**
* 局部更新配置。
*
* **必须传纯数据(普通对象 / 基本类型)**:传 Vue 的 `ref.value` / `reactive()` 对象
* 会在 contextBridge 跨隔离世界时被结构化克隆拒绝,抛
* `Error: An object could not be cloned.`,而且调用点看起来"只是没生效"。
* 页面里请写成 `saveConfig({ island: { ...island.value } })` 这种展开后的字面量。
*/
saveConfig(patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig>;
clearConfig(): Promise<DesktopStoredConfig>;
getAppInfo(): Promise<DesktopAppInfo>;
openExternal(url: string): Promise<boolean>;
/* 灵动岛 */
/** 把一条通知投递到灵动岛 */
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
islandSetClassState(payload: IslandClassStatePayload): void;
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
islandGetState(): Promise<IslandState>;
/**
* 应用灵动岛外观设置(实时生效)。
*
* 同 saveConfig:**必须是纯数据**。设置页曾把 `island`(`ref`)的响应式 Proxy 直接传进来,
* 结果 contextBridge 抛 `An object could not be cloned.`,主进程收不到任何东西 ——
* 现象就是"拖了滑块、数字在变,灵动岛毫无反应,保存也不生效"。
*/
islandSetAppearance(appearance: Partial<IslandAppearance>): void;
/** 读取当前生效的外观设置 */
islandGetAppearance(): Promise<IslandAppearance>;
/** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */
onIslandMarkRead(handler: (id: string) => void): void;
/** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */
onIslandMarkAllRead(handler: (ids: string[]) => void): void;
}
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
export interface IslandRendererBridge {
onState(handler: (state: IslandState) => void): void;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
/** 订阅外观设置(CSS 变量实时生效) */
onAppearance(handler: (appearance: IslandAppearance) => void): void;
getAppearance(): Promise<IslandAppearance>;
sendAction(
action:
| 'expand'
| 'expand-list'
| 'collapse'
| 'dismiss'
| 'dismiss-all'
| 'mark-read'
| 'mark-all-read'
| 'open-app',
id?: string,
): void;
getState(): Promise<IslandState>;
/**
* 命中提示:指针在岛体上时应让固定大窗口接收鼠标(比主进程 60ms 轮询更快)。
* **只会上报 true**:关闭命中(穿透)一律由主进程按真实光标决定,
* 两边都下发会让窗口在"接收/穿透"之间抖动(用户反馈的"点了没反应")。
*/
setInteractive(interactive: boolean): void;
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
/**
* 上报"本机是触摸屏"(`navigator.maxTouchPoints > 0`):主进程据此让窗口贴合岛体并始终接收
* 输入,否则触摸屏上手指永远点不到岛(触摸不产生 mousemove、也不移动系统光标)。
*/
setTouchMode(enabled: boolean): void;
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
alive(): void;
}
declare global {
@@ -75,7 +178,37 @@ declare global {
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(credentials?: {
code: string;
password: string;
}): Promise<{ ok: boolean; detail: string }>;
/** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */
islandRealtimeScenario(): Promise<{
ok: boolean;
detail: string;
notificationId?: string;
teacherToken?: string;
title?: string;
classId?: string;
inClass?: boolean;
}>;
islandRealtimeCleanup(
notificationId: string,
teacherToken: string,
): Promise<{ ok: boolean; detail: string }>;
/** 读取某条通知在通知中心里的已读状态(验证灵动岛"标为已读"链路) */
islandReadState(notificationId: string): Promise<{
found: boolean;
read: boolean;
unreadCount: number;
title: string;
}>;
/** ClassIsland 风格「今天」时间轴自检(造课 → 读 DOM → 清理) */
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 作业看板全屏自适应自检(切看板 → 打开全屏 → 量尺寸) */
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */
islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
};
}
-1
View File
@@ -30,7 +30,6 @@ CORS_ORIGIN=*
BCRYPT_ROUNDS=10
# 教师新增学生账号时未指定密码所使用的初始密码
DEFAULT_STUDENT_PASSWORD=123456
# 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算
TERM_START_DATE=2026-02-23
+3 -1
View File
@@ -18,7 +18,8 @@
"db:seed": "prisma db seed",
"db:reset": "prisma migrate reset --force",
"db:studio": "prisma studio",
"verify:e2e": "node scripts/verify-e2e.mjs"
"verify:e2e": "node scripts/verify-e2e.mjs",
"verify:classisland": "node scripts/verify-classisland.mjs"
},
"dependencies": {
"@classhelper/shared": "workspace:*",
@@ -34,6 +35,7 @@
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"socket.io": "^4.8.3",
"xlsx": "0.18.5",
"zod": "^4.6.2"
},
"devDependencies": {
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "TimeLayout" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'classisland',
"items" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "TimeLayout_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "TimeLayout_classId_idx" ON "TimeLayout"("classId");
@@ -0,0 +1,20 @@
-- 班级账号(班级码 + 班级密码):学生端以「班级」为主体登录
--
-- 说明:
-- 1) code 是新增的必填唯一列。SQLite 无法直接给已有表加 "NOT NULL 且无默认值" 的列,
-- 因此先带默认值 '' 加上,再用 rowid 回填一版可用且唯一的班级码,最后建唯一索引。
-- 2) passwordHash 允许为 NULL:尚未设置班级密码的班级不允许班级登录,
-- 管理员可在「班级管理」里设置或重置(bcrypt 哈希由服务端生成)。
-- 3) 迁移逐条执行且忽略"已存在"错误,因此本文件重复执行是安全的。
-- AlterTable
ALTER TABLE "Class" ADD COLUMN "code" TEXT NOT NULL DEFAULT '';
-- AlterTable
ALTER TABLE "Class" ADD COLUMN "passwordHash" TEXT;
-- Backfill:老数据按 rowid 生成 C00001 形式的班级码(管理员可随时改)
UPDATE "Class" SET "code" = 'C' || substr('00000' || CAST(rowid AS TEXT), -5) WHERE "code" = '';
-- CreateIndex
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
@@ -0,0 +1,4 @@
-- 课表支持单双周:新增 weekParity(ALL=每周 / ODD=单周 / EVEN=双周)
-- 旧数据默认为 'ALL',行为与升级前完全一致(向后兼容)。
ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL';
@@ -0,0 +1,2 @@
-- 班级教学周数(班主任可调):默认 20 周,旧数据行为不变(原来固定按 20 周展示,见 seed/导入默认值)
ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20;
@@ -0,0 +1,58 @@
-- ClassIsland 联动:设备接入 + 提醒下发
--
-- 说明:
-- 1) IntegrationDevice 保存「班级 ↔ 一台装有联动插件的机器」的绑定关系,
-- 令牌只存 sha256 哈希,明文仅在创建/重置接口返回一次。
-- 2) 同一班级内 deviceKey 唯一:插件重装后带同一机器码会重绑原记录,避免设备列表堆重复项。
-- 3) ClassIslandPush 落库是为了「设备离线时通知不丢」:插件重连后拉取未确认的一条立即弹出。
-- 4) 本文件重复执行是安全的(逐条执行、忽略"已存在"错误)。
CREATE TABLE "IntegrationDevice" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"deviceKey" TEXT NOT NULL,
"mode" TEXT NOT NULL DEFAULT 'plugin',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"tokenHash" TEXT NOT NULL,
"tokenHint" TEXT NOT NULL,
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT true,
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT false,
"classIslandVersion" TEXT,
"pluginVersion" TEXT,
"lastSeenAt" DATETIME,
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
"currentSubject" TEXT,
"currentTimeState" TEXT,
"currentPeriodStart" TEXT,
"currentPeriodEnd" TEXT,
"nextSubject" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
CREATE TABLE "ClassIslandPush" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"deviceId" TEXT,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"durationSeconds" INTEGER NOT NULL DEFAULT 8,
"speechContent" TEXT,
"urgent" BOOLEAN NOT NULL DEFAULT false,
"createdBy" TEXT,
"notificationId" TEXT,
"ackedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
CONSTRAINT "ClassIslandPush_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ClassIslandPush_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "IntegrationDevice" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "ClassIslandPush_classId_createdAt_idx" ON "ClassIslandPush"("classId", "createdAt");
CREATE INDEX "ClassIslandPush_deviceId_ackedAt_idx" ON "ClassIslandPush"("deviceId", "ackedAt");
@@ -0,0 +1,3 @@
-- 通知显示位置(由教室的班级客户端设置):both / client / classisland
-- 旧数据默认 both:行为与之前一致(客户端照常收,同时推给 ClassIsland)
ALTER TABLE "Class" ADD COLUMN "notificationChannel" TEXT NOT NULL DEFAULT 'both';
@@ -0,0 +1,7 @@
-- 作业所属日期(YYYY-MM-DD,本地日期):客户端/Web 的按天查看与日期高亮都用它
ALTER TABLE "Homework" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT '';
-- 回填历史数据:按 UTC+8 把 createdAt 折算成当天(本产品面向国内教室,服务器在 CST)
UPDATE "Homework" SET "assignDate" = date("createdAt", '+8 hours') WHERE "assignDate" = '';
CREATE INDEX "Homework_classId_assignDate_idx" ON "Homework"("classId", "assignDate");
@@ -0,0 +1,2 @@
-- 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立即弹出)
ALTER TABLE "ClassIslandPush" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'notification';
@@ -0,0 +1,7 @@
-- 课表行来源:manual=手工建立(Web 端录入 / JSON 导入)/ classisland=ClassIsland 插件上报同步。
--
-- 用途:插件每次全量上报课表时,只清理「source=classisland 且本次未再上报」的行。
-- 这样"老师在 ClassIsland 里删掉一节"能被真正同步下来(原先 merge 只 upsert,
-- 服务端会永远留着那行幽灵课),同时老师在小助手这边手排的课不受影响。
-- 存量数据默认按 manual 处理(保守:宁可留下也不误删)。
ALTER TABLE "Schedule" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual';
+123 -2
View File
@@ -54,6 +54,15 @@ model Class {
name String
grade String
teacherId String
/// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写)
code String @unique
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
passwordHash String?
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20)
/// 通知显示位置(由教室的班级客户端在设置页里选):
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
notificationChannel String @default("both")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -66,6 +75,9 @@ model Class {
notifications Notification[]
grades Grade[]
enrollments Enrollment[]
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
@@index([teacherId])
}
@@ -132,8 +144,15 @@ model Schedule {
weekStart Int @default(1)
/// 学期结束周次(含)
weekEnd Int @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
weekParity String @default("ALL")
/// 该行的来源:manual=手工建立(Web 端录入 / ClassIsland JSON 导入)
/// / classisland=插件上报同步得到。
/// 插件每次全量上报时只清理「classisland 来源且本次未再上报」的行(解决"老师在 ClassIsland
/// 里删掉一节、服务端却永远留着"的幽灵课),老师手排的课一律不删。
source String @default("manual")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@ -149,7 +168,11 @@ model Homework {
courseId String?
title String
content String
/// 作业所属日期(YYYY-MM-DD,本地日期,不带时区):客户端/Web 的按天查看与日期高亮都用它
assignDate String @default("")
attachmentUrl String?
/// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。
/// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。
dueAt DateTime?
createdBy String
createdAt DateTime @default(now())
@@ -161,6 +184,7 @@ model Homework {
statuses HomeworkStatus[]
@@index([classId, createdAt])
@@index([classId, assignDate])
@@index([courseId])
@@index([dueAt])
}
@@ -212,6 +236,24 @@ model NotificationRead {
@@index([userId])
}
/// 课表时间配置(ClassIsland 同款 JSON 导入后的节次时间表)
model TimeLayout {
id String @id @default(cuid())
classId String
name String
/// 来源:classisland | manual
source String @default("classisland")
/// 节次数组(JSON 字符串):[{ index, name, startTime, endTime, type, skipped }]
items String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@index([classId])
}
/// 成绩
model Grade {
id String @id @default(cuid())
@@ -233,3 +275,82 @@ model Grade {
@@index([classId, examName])
@@index([courseId])
}
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
///
/// 说明:
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
/// 避免设备列表里堆满重复项;
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
model IntegrationDevice {
id String @id @default(cuid())
classId String
/// 设备显示名(默认取机器名)
name String
/// 设备唯一标识(插件上报的机器码)
deviceKey String
/// 联动方式:plugin=实时插件联动 / import=仅一次性导入
mode String @default("plugin")
/// 是否接入(关闭后插件上报与下发一律 403)
enabled Boolean @default(true)
/// 设备令牌的 sha256(明文只在创建/重置时返回一次)
tokenHash String
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
tokenHint String
/// 是否允许插件把 ClassIsland 的课表回传到本服务
syncScheduleToServer Boolean @default(true)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
mirrorScheduleToClassIsland Boolean @default(false)
classIslandVersion String?
pluginVersion String?
lastSeenAt DateTime?
/// 以下为最后一次上报的快照
classPlanLoaded Boolean @default(false)
currentSubject String?
currentTimeState String?
currentPeriodStart String?
currentPeriodEnd String?
nextSubject String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
pushes ClassIslandPush[]
@@unique([classId, deviceKey])
@@index([classId])
@@index([tokenHash])
}
/// 下发给 ClassIsland 的提醒:教师发通知 → 学生机器全屏弹出。
/// 落库是为了「设备离线时通知不丢」——插件重连后拉取未确认的一条立即弹出。
model ClassIslandPush {
id String @id @default(cuid())
classId String
deviceId String?
title String
content String
/// 显示时长(秒):映射到 ClassIsland 的 NotificationContent.Duration
durationSeconds Int @default(8)
/// 语音朗读内容;null = 不朗读
speechContent String?
urgent Boolean @default(false)
/// 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立刻弹)
kind String @default("notification")
/// 发起人的用户 id(教师/管理员)
createdBy String?
/// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
notificationId String?
/// 插件确认弹出的时间(确认后不再重复下发)
ackedAt DateTime?
createdAt DateTime @default(now())
/// 过期时间:超过后不再补发(避免学生几天后突然弹出旧提醒)
expiresAt DateTime
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
device IntegrationDevice? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
@@index([classId, createdAt])
@@index([deviceId, ackedAt])
}
+45 -18
View File
@@ -10,6 +10,7 @@
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
* student01..student15 / student123
*/
import { SUBJECT_CATALOG } from '@classhelper/shared';
import { disconnectPrisma, prisma } from '../src/lib/db.js';
import { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.js';
@@ -33,7 +34,8 @@ const STUDENT_NAMES = [
'胡一鸣',
];
const COURSE_NAMES = ['语文', '数学', '英语', '物理', '化学'];
// 课程目录:与 shared 的 SUBJECT_CATALOG 保持一致(演示数据覆盖全部常见科目)
const COURSE_NAMES = [...SUBJECT_CATALOG];
/** 每天的上课节次模板(dayOfWeek 1-5) */
const PERIOD_TEMPLATE = [
@@ -84,10 +86,12 @@ async function main(): Promise<void> {
const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次
const [adminHash, teacherHash, studentHash] = await Promise.all([
// 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'),
hashPassword('teacher123'),
hashPassword('student123'),
// 班级账号默认密码(学生端「班级登录」)
hashPassword('123456'),
]);
const admin = await prisma.user.create({
@@ -101,16 +105,23 @@ async function main(): Promise<void> {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
const classSeeds = [
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id },
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id },
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id },
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
];
const classes = [];
for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({
data: { name: seed.name, grade: seed.grade, teacherId: seed.teacherId },
data: {
name: seed.name,
grade: seed.grade,
teacherId: seed.teacherId,
code: seed.code,
passwordHash: classPasswordHash,
},
});
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
}
@@ -131,7 +142,8 @@ async function main(): Promise<void> {
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
role: 'STUDENT',
classId: targetClass.id,
passwordHash: studentHash,
// 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到
passwordHash: '',
},
});
students.push(student);
@@ -160,6 +172,8 @@ async function main(): Promise<void> {
if (!course) continue;
// 前 2 节限制在第 1 周(演示按周筛选)
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
// 周二第 3 节演示"单双周":单周上次课表里的科目、双周上一门别的科目
const paritySlot = dayOfWeek === 2 && periodIndex === 2;
await prisma.schedule.create({
data: {
classId: targetClass.id,
@@ -170,44 +184,60 @@ async function main(): Promise<void> {
location: targetClass.room,
weekStart: 1,
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
weekParity: paritySlot ? 'ODD' : 'ALL',
},
});
if (paritySlot) {
// 双周同一节换另一门课,用于演示"单双周课表"(第 1 周单周、第 2 周双周)
const evenCourse = classCourses[(dayOfWeek + periodIndex + 2) % classCourses.length];
if (evenCourse) {
await prisma.schedule.create({
data: {
classId: targetClass.id,
courseId: evenCourse.id,
dayOfWeek,
startTime: period.startTime,
endTime: period.endTime,
location: targetClass.room,
weekStart: 1,
weekEnd: MAX_TERM_WEEK,
weekParity: 'EVEN',
},
});
}
}
}
}
}
// ---------------------------------------------------------------- 作业
// 注:作业"截止时间"功能已下线,种子数据不再写入 dueAt(库字段保留以兼容已装库)
const now = Date.now();
const day = 86_400_000;
const homeworkSeeds = [
{
title: '《劝学》全文背诵',
content: '背诵《劝学》并录制音频上传到班级群,注意断句。',
dueInDays: 3,
courseIndex: 0,
},
{
title: '数学必修一 3.2 习题',
content: '完成课本 P78 习题 3.2 全部题目,第 12 题选做。',
dueInDays: 1,
courseIndex: 1,
},
{
title: '英语周记一篇',
content: '以"My School Life"为题写一篇 150 词左右的英语周记。',
dueInDays: 5,
courseIndex: 2,
},
{
title: '物理实验报告',
content: '整理"探究匀变速直线运动"实验数据,撰写实验报告。',
dueInDays: -2,
courseIndex: 3,
},
{
title: '化学方程式默写',
content: '默写第一至第三章全部化学方程式,家长签字。',
dueInDays: -1,
courseIndex: 4,
},
];
@@ -225,7 +255,6 @@ async function main(): Promise<void> {
content: seed.content,
attachmentUrl:
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
dueAt: new Date(now + seed.dueInDays * day),
createdBy: targetClass.teacherId,
},
});
@@ -348,10 +377,8 @@ async function main(): Promise<void> {
console.log(`管理员 admin / admin123 (${admin.name})`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
console.log('学生 student01 / student123 (高一(1)班)');
console.log('学生 student06 / student123 (高一(2)班)');
console.log('学生 student11 / student123 (高二(3)班)');
console.log('学生 student15 / student123 (高二(3)班)');
console.log('学生端(班级账号):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)');
console.log('==========================================\n');
}
@@ -0,0 +1,82 @@
/**
* 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。
*
* 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表);
* 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist**
* 部署,就需要显式跑一次本脚本。
*
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
*
* node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js"
*
* 环境变量(与服务端一致):
* DATABASE_PROVIDER=sqlite
* DATABASE_URL="file:<绝对路径>/classhelper.db"
*
* 幂等:已存在的列会跳过。
*/
const { pathToFileURL } = require('node:url');
const dbEntry = process.argv[2];
if (!dbEntry) {
console.error('用法:node apply-column-migrations.cjs <已编译的 lib/db.js 绝对路径>');
process.exit(2);
}
/** 需要保证存在的列(新增迁移时在这里追加一行) */
const COLUMNS = [
{
table: 'Schedule',
column: 'weekParity',
sql: `ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL'`,
note: '课表单双周(ALL/ODD/EVEN)',
},
{
table: 'Class',
column: 'termWeeks',
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`,
note: '班级教学周数(班主任可调,默认 20)',
},
];
(async () => {
let prisma;
try {
const module = await import(pathToFileURL(dbEntry).href);
prisma = module.prisma ?? module.default?.prisma;
} catch (error) {
console.error(
'[migrate] 无法加载服务端的 Prisma 客户端:',
error && error.message ? error.message : error,
);
process.exitCode = 1;
return;
}
if (!prisma) {
console.error('[migrate] lib/db.js 里没有导出 prisma');
process.exitCode = 1;
return;
}
try {
for (const item of COLUMNS) {
const columns = await prisma.$queryRawUnsafe(`PRAGMA table_info("${item.table}")`);
if (columns.some((column) => column.name === item.column)) {
console.log(`[migrate] ${item.table}.${item.column} 已存在(跳过)`);
continue;
}
await prisma.$executeRawUnsafe(item.sql);
console.log(`[migrate] 已新增 ${item.table}.${item.column} —— ${item.note}`);
}
for (const table of [...new Set(COLUMNS.map((item) => item.table))]) {
const after = await prisma.$queryRawUnsafe(`PRAGMA table_info("${table}")`);
console.log(`[migrate] ${table} 列:${after.map((column) => column.name).join(', ')}`);
}
} catch (error) {
console.error('[migrate] 失败:', error && error.message ? error.message : error);
process.exitCode = 1;
} finally {
await prisma.$disconnect?.();
}
})();
@@ -0,0 +1,621 @@
#!/usr/bin/env node
/**
* ClassIsland 联动验证(插件侧接口 + 下发链路)。
*
* 覆盖范围:
* 1) 设备令牌鉴权:无令牌 / 错令牌一律 401,令牌作废后立即失效
* 2) 插件上报 → 服务端写库(课表 + 节次时间 + 状态快照)幂等
* 3) 教师下发提醒 → 落库 → 插件 pending 拉到 → ack 后不再补发
* 4) 停用设备后插件上报被 403 拦下
* 5) 课表镜像拉取(mirrorScheduleToClassIsland)返回 ClassIsland 口径的数据
*
* 用法:
* 1) 另开终端启动后端:pnpm dev:server
* 2) 执行:node packages/server/scripts/verify-classisland.mjs
*
* 环境变量:
* VERIFY_BASE_URL 默认 http://127.0.0.1:4000(脚本直连,不受系统代理影响)
*
* 注意:本脚本只操作「新建设备 + 该设备对应班级」的数据,
* 结束时会把新建的设备删除;上报的课表会写进该班(与导入模块语义一致,可被覆盖)。
*/
const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
const results = [];
function record(name, ok, detail = '') {
results.push({ name, ok, detail });
console.log(`${ok ? '\u2713' : '\u2717'} ${name}${detail ? ` (${detail})` : ''}`);
}
async function api(path, { method = 'GET', token, deviceToken, body } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
if (deviceToken) headers['X-ClassIsland-Token'] = deviceToken;
const response = await fetch(`${BASE_URL}/api${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
return { status: response.status, payload };
}
/** 造一份插件会上报的课表(周一/周二,含单双周) */
function sampleSchedule() {
return {
mode: 'merge',
entries: [
{
dayOfWeek: 1,
startTime: '08:00',
endTime: '08:45',
subject: '语文',
teacherName: '王老师',
weekParity: 'ALL',
weekCountDiv: 0,
weekCountDivTotal: 0,
planName: '周一课表',
},
{
dayOfWeek: 1,
startTime: '08:55',
endTime: '09:40',
subject: '数学',
teacherName: '李老师',
weekParity: 'ODD',
weekCountDiv: 1,
weekCountDivTotal: 2,
planName: '周一课表',
},
{
dayOfWeek: 2,
startTime: '08:00',
endTime: '08:45',
subject: '英语',
teacherName: '张老师',
weekParity: 'ALL',
weekCountDiv: 0,
weekCountDivTotal: 0,
planName: '周二课表',
},
],
};
}
function sampleTimeLayout() {
return {
name: 'ClassIsland 验证时间表',
mode: 'replace',
items: [
{ index: 1, name: '第 1 节', startTime: '08:00', endTime: '08:45', type: 'class', skipped: false },
{ index: 2, name: '课间', startTime: '08:45', endTime: '08:55', type: 'break', skipped: false },
{ index: 3, name: '第 2 节', startTime: '08:55', endTime: '09:40', type: 'class', skipped: false },
],
};
}
/** 按候选密码依次登录,返回可用 token(不同环境的默认密码可能不同) */
async function loginWithCandidates(username, passwords) {
for (const password of passwords) {
const result = await api('/auth/login', { method: 'POST', body: { username, password } });
const token = result.payload?.data?.token;
if (token) return token;
}
return null;
}
async function main() {
console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
const health = await api('/health');
if (health.status !== 200) {
console.log('后端未启动,请先运行 pnpm dev:server\n');
process.exit(1);
}
const modules = health.payload?.data?.modules ?? [];
record('integrations 模块已挂载', modules.includes('integrations'), modules.join(', '));
if (!modules.includes('integrations')) process.exit(1);
// ---------------------------------------------------------------- 登录
// 演示库里的教师密码由 .env 的 DEFAULT_TEACHER_PASSWORD 决定(种子脚本用它建号),
// 因此这里按候选列表尝试,避免「换了环境就整脚本失败」。
const teacherToken = await loginWithCandidates('teacher1', ['teacher123', '123456']);
record('教师登录 teacher1', Boolean(teacherToken));
if (!teacherToken) process.exit(1);
// 管理员用于取班级码(只有管理员/班主任能看到班级码),学生隔离用例需要它
const adminToken = await loginWithCandidates('admin', ['admin123']);
const classes = await api('/classes', { token: teacherToken });
const target = (classes.payload?.data ?? [])[0];
record('取到可管理的班级', Boolean(target?.id), target ? `${target.name} (${target.id})` : '无');
if (!target?.id) process.exit(1);
// 叫人用例需要一位学生;班级账号用来验证"教室客户端自己选显示位置"
const students = await api(`/classes/${target.id}/students`, { token: teacherToken });
const firstStudent = (students.payload?.data ?? [])[0];
/** 用班级码 + 班级密码登录,拿班级账号 token(教室机器的身份) */
async function classLoginToken() {
if (adminToken) {
const detail = await api(`/classes/${target.id}`, { token: adminToken });
const code = detail.payload?.data?.code;
if (code) {
for (const password of ['123456', 'class123']) {
const result = await api('/auth/class-login', {
method: 'POST',
body: { code, password },
});
const token = result.payload?.data?.token;
if (token) return token;
}
}
}
return null;
}
// ---------------------------------------------------------------- 1. 新建设备
const created = await api('/integrations/devices', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, name: '验证用设备', syncScheduleToServer: true },
});
const deviceToken = created.payload?.data?.token;
const deviceId = created.payload?.data?.device?.id;
record(
'新建设备并返回设备令牌',
created.status === 201 && typeof deviceToken === 'string' && deviceToken.startsWith('chci_'),
`status=${created.status} hint=${created.payload?.data?.device?.tokenHint ?? '-'}`,
);
if (!deviceToken || !deviceId) process.exit(1);
// ---------------------------------------------------------------- 2. 令牌鉴权
const noToken = await api('/integrations/classisland/report', { method: 'POST', body: {} });
record('无令牌上报被拒(401)', noToken.status === 401, `status=${noToken.status}`);
const badToken = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken: 'chci_deadbeef',
body: {},
});
record('错误令牌上报被拒(401)', badToken.status === 401, `status=${badToken.status}`);
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = {
pluginVersion: '0.1.0.0',
classIslandVersion: '2.1.0.0',
state: {
inClass: true,
subject: '语文',
nextSubject: '数学',
timeState: 'OnClass',
periodStart: '08:00',
periodEnd: '08:45',
week: 1,
classPlanLoaded: true,
clientTime: new Date().toISOString(),
},
schedule: sampleSchedule(),
timeLayout: sampleTimeLayout(),
};
const report1 = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: reportBody,
});
const r1 = report1.payload?.data;
record(
'插件上报课表写入成功',
report1.status === 200 && r1?.scheduleApplied === true && r1.scheduleCreated + r1.scheduleUpdated === 3,
`新增=${r1?.scheduleCreated} 更新=${r1?.scheduleUpdated} 周=${r1?.week}`,
);
record('插件上报节次时间写入成功', r1?.timeLayoutApplied === true, `applied=${r1?.timeLayoutApplied}`);
record('上报回传服务端周次与时间', typeof r1?.serverTime === 'string' && Number.isInteger(r1?.week));
// 幂等:重复上报不应产生重复行
const report2 = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: reportBody,
});
const r2 = report2.payload?.data;
record(
'重复上报幂等(不新增、只更新)',
r2?.scheduleCreated === 0 && r2?.scheduleUpdated === 3,
`新增=${r2?.scheduleCreated} 更新=${r2?.scheduleUpdated}`,
);
// 设备列表里的状态快照
const devices = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
const listed = (devices.payload?.data ?? []).find((item) => item.id === deviceId);
record(
'设备状态快照已更新(Web 端可展示)',
Boolean(listed?.lastSeenAt) &&
listed?.currentSubject === '语文' &&
listed?.currentTimeState === 'OnClass',
`科目=${listed?.currentSubject} 状态=${listed?.currentTimeState} ClassIsland=${listed?.classIslandVersion}`,
);
// ---------------------------------------------------------------- 4. 下发提醒
const notify = await api('/integrations/classisland/notify', {
method: 'POST',
token: teacherToken,
body: {
classId: target.id,
title: '请到讲台领资料',
content: '下课后来讲台领取期中复习资料',
durationSeconds: 12,
speech: true,
},
});
const n1 = notify.payload?.data;
record(
'教师下发提醒(落库 + 标记设备数)',
notify.status === 200 && n1?.targetCount >= 1 && Boolean(n1?.notificationId),
`设备数=${n1?.targetCount} 通知id=${n1?.notificationId ? '有' : '无'}`,
);
const pending = await api('/integrations/classisland/pending', { deviceToken });
const items = pending.payload?.data?.notifications ?? [];
const picked = items.find((item) => item.title === '请到讲台领资料');
record(
'插件拉取到待提醒(离线补齐链路可用)',
pending.status === 200 && Boolean(picked),
`待提醒数=${items.length} 时长=${picked?.durationSeconds}s 朗读=${Boolean(picked?.speechContent)}`,
);
if (picked) {
const ack = await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: picked.id },
});
record(
'插件确认提醒已弹出',
ack.status === 200 && ack.payload?.data?.acked === true,
`status=${ack.status}`,
);
const pending2 = await api('/integrations/classisland/pending', { deviceToken });
const again = (pending2.payload?.data?.notifications ?? []).some((item) => item.id === picked.id);
record('确认后不再补发(避免重复弹窗)', !again, `仍在待提醒中=${again}`);
}
// ---------------------------------------------------------------- 4.5 其他发布入口也会联动 ClassIsland
// 需求:"在其他页面发通知也可以联动 ClassIsland"。通知发布页 / 叫人走的是各自的接口,
// 这里逐个验证它们真的会生成待弹出提醒(而不是只有联动页那个专用接口才联动)。
const classChannel = await api(`/classes/${target.id}/notification-channel`, { token: teacherToken });
record(
'班级有"通知显示位置"(默认两端都弹)',
classChannel.status === 200 && classChannel.payload?.data?.notificationChannel === 'both',
`channel=${classChannel.payload?.data?.notificationChannel}`,
);
const published = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '通知页联动验证', content: '从通知发布页发的通知', priority: 'NORMAL' },
});
const pendingAfterPublish = await api('/integrations/classisland/pending', { deviceToken });
const publishPush = (pendingAfterPublish.payload?.data?.notifications ?? []).find(
(item) => item.title === '通知页联动验证',
);
record(
'通知发布页发的通知也会推送到 ClassIsland',
published.status === 201 && Boolean(publishPush),
`status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
);
// kind 决定插件"上课时段弹不弹":通知类会被暂存,叫人属于主动通知要立刻弹
record(
'通知类推送的 kind=notification(上课时段插件会暂存)',
publishPush?.kind === 'notification',
`kind=${publishPush?.kind}`,
);
if (publishPush) {
await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: publishPush.id },
});
}
const call = await api('/calls', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, studentId: firstStudent?.id, message: '叫人联动验证' },
});
const pendingAfterCall = await api('/integrations/classisland/pending', { deviceToken });
const callPush = (pendingAfterCall.payload?.data?.notifications ?? []).find(
(item) => item.content === '叫人联动验证',
);
record(
'叫人也会推送到 ClassIsland',
Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
);
record(
'叫人推送的 kind=call(算主动通知,上课时段也立刻弹)',
callPush?.kind === 'call',
`kind=${callPush?.kind}`,
);
if (callPush) {
await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: callPush.id },
});
}
// 教室在客户端里选"只在 ClassHelper 客户端显示"后,任何入口都不该再打扰 ClassIsland
const setClient = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: teacherToken,
body: { notificationChannel: 'client' },
});
record(
'可把"通知显示位置"改成只在客户端显示',
setClient.status === 200 && setClient.payload?.data?.notificationChannel === 'client',
`channel=${setClient.payload?.data?.notificationChannel}`,
);
const mutedPublish = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '不该上 ClassIsland 的通知', content: '教室选择了只在客户端显示' },
});
const mutedNotify = await api('/integrations/classisland/notify', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '联动页也不该下发', content: '同样被教室的设置拦下' },
});
const pendingMuted = await api('/integrations/classisland/pending', { deviceToken });
const mutedItems = pendingMuted.payload?.data?.notifications ?? [];
record(
'显示位置=client 时通知不再推送 ClassIsland',
mutedPublish.status === 201 && !mutedItems.some((item) => item.title === '不该上 ClassIsland 的通知'),
`待提醒=${mutedItems.length}`,
);
record(
'显示位置=client 时联动页的下发也会被跳过(并说明原因)',
mutedNotify.status === 200 &&
mutedNotify.payload?.data?.skipped === 'channel-client' &&
String(mutedNotify.payload?.message ?? '').includes('只在 ClassHelper 客户端显示'),
`skipped=${mutedNotify.payload?.data?.skipped} message=${mutedNotify.payload?.message}`,
);
// 班级账号(教室机器)自己就能读写这项设置 —— 需求就是"由 ClassIsland 之外的客户端自己选"
const classSessionToken = await classLoginToken();
const readByClass = await api(`/classes/${target.id}/notification-channel`, {
token: classSessionToken,
});
record(
'班级账号可读取通知显示位置',
readByClass.status === 200 && readByClass.payload?.data?.notificationChannel === 'client',
`channel=${readByClass.payload?.data?.notificationChannel}`,
);
const setByClass = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: classSessionToken,
body: { notificationChannel: 'both' },
});
record(
'班级账号可修改通知显示位置(由教室客户端自己选)',
setByClass.status === 200 && setByClass.payload?.data?.notificationChannel === 'both',
`channel=${setByClass.payload?.data?.notificationChannel}`,
);
const badChannel = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: teacherToken,
body: { notificationChannel: 'nope' },
});
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
// ---------------------------------------------------------------- 5. 课表镜像(默认关闭 → 打开 → 拉取)
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
record(
'镜像开关关闭时不下发课表',
mirrorOff.status === 200 && mirrorOff.payload?.data === null,
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
);
const enabled = await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { mirrorScheduleToClassIsland: true },
});
record(
'打开「镜像课表到 ClassIsland」',
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
`status=${enabled.status}`,
);
const mirror = await api('/integrations/classisland/class-plan', { deviceToken });
const plan = mirror.payload?.data?.classPlan;
const weekDays = (plan?.entries ?? []).map((item) => item.weekDay);
record(
'拉取课表为 ClassIsland 口径(0=周日 … 6=周六)',
mirror.status === 200 &&
Array.isArray(plan?.entries) &&
plan.entries.length >= 3 &&
weekDays.every((day) => day >= 0 && day <= 6),
`条目=${plan?.entries?.length} weekDay=[${weekDays.join(',')}] 档案名=${plan?.profileName}`,
);
record(
'单双周换算为 WeekCountDiv/Total',
(plan?.entries ?? []).some((item) => item.weekCountDivTotal === 2),
(plan?.entries ?? [])
.map((item) => `${item.subject}:${item.weekCountDiv}/${item.weekCountDivTotal}`)
.join(' '),
);
// ClassInfo.Index 与"第 i 个上课时间点"一一对应,因此镜像条目必须带起止时间,
// 否则插件只能按数组顺序硬排,一旦某天少一节整份课表就错位(这是最容易漏的一处契约)
const timeOk = (plan?.entries ?? []).every(
(item) => /^\d{2}:\d{2}$/.test(item.startTime ?? '') && /^\d{2}:\d{2}$/.test(item.endTime ?? ''),
);
record(
'镜像条目带起止时间(插件据此重建上课时间点)',
(plan?.entries ?? []).length > 0 && timeOk,
(plan?.entries ?? [])
.slice(0, 3)
.map((item) => `${item.subject} ${item.startTime}-${item.endTime}`)
.join(' | '),
);
const classPoints = (plan?.timeLayouts?.[0]?.layouts ?? []).filter((item) => item.timeType === 0);
record(
'镜像时间表含上课时间点(与课表条目对得上)',
classPoints.length > 0 && (plan?.entries ?? []).length >= classPoints.length / 2,
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
);
/* ---------------------------------------------------------------- 5.1 课表同步:幽灵行清理 + 机器码回填 */
// 老师在 Web 端手排的一节课(source=manual):插件同步时**绝不能**删它
const courseList = await api(`/courses?classId=${target.id}`, { token: teacherToken });
const courseId = (courseList.payload?.data ?? [])[0]?.id;
const manualCreated = await api('/schedules', {
method: 'POST',
token: teacherToken,
body: {
classId: target.id,
courseId,
dayOfWeek: 7,
startTime: '21:00',
endTime: '21:45',
weekStart: 1,
weekEnd: 20,
weekParity: 'ALL',
},
});
const manualScheduleId = manualCreated.payload?.data?.id;
record(
'(准备)Web 端手排一节课(source=manual)',
manualCreated.status === 201 && Boolean(manualScheduleId),
`status=${manualCreated.status}`,
);
const entry = (startTime, endTime) => ({
dayOfWeek: 6,
startTime,
endTime,
subject: '语文',
weekParity: 'ALL',
});
/** 该班课表里"周六 19:xx"的行(插件上报写入的那些) */
const listSaturday = async () => {
const list = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
return (list.payload?.data ?? []).filter(
(item) => item.dayOfWeek === 6 && item.startTime.startsWith('19:'),
);
};
// 第一次上报:两节(第二节随后会被"老师在 ClassIsland 里删掉")
const ghostReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: {
deviceKey: 'smoke-machine-01',
schedule: { mode: 'merge', entries: [entry('19:00', '19:45'), entry('19:50', '20:35')] },
},
});
record(
'插件上报两节课(其中一节随后会被删除)',
ghostReport.payload?.data?.scheduleApplied === true,
`status=${ghostReport.status}`,
);
const beforePrune = await listSaturday();
record('上报后两节都在库里', beforePrune.length === 2, `周六 19 点=${beforePrune.length} 节`);
// 第二次上报:只报第二节 → 第一节应当被清理。
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
// 学生端与 Web 端会一直显示一节实际上已经不上的课。
const pruneReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: {
deviceKey: 'smoke-machine-01',
schedule: { mode: 'merge', entries: [entry('19:50', '20:35')] },
},
});
const afterPrune = await listSaturday();
record(
'插件侧删除的课被同步清理(不再留幽灵课)',
pruneReport.payload?.data?.scheduleApplied === true &&
afterPrune.length === 1 &&
afterPrune[0]?.startTime === '19:50',
`周六 19 点=${afterPrune.map((item) => item.startTime).join(',') || '空'}`,
);
// 只清理"插件同步出来的行":老师手排的课必须活着
const scheduleList = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
const manualAlive = (scheduleList.payload?.data ?? []).some((item) => item.id === manualScheduleId);
record('Web 端手排的课不被插件同步删除', manualAlive, `手排行 id=${manualScheduleId ?? '-'}`);
// 机器码回填:设备列表里不再是 pending-…
const deviceList = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
const backfilledKey =
(deviceList.payload?.data ?? []).find((item) => item.id === deviceId)?.deviceKey ?? '';
record(
'插件上报的机器码已回填到设备记录',
backfilledKey === 'smoke-machine-01',
`deviceKey=${backfilledKey}`,
);
// 清理:把手排的课与本次上报写进去的课都删掉,避免污染演示数据
if (manualScheduleId) {
await api(`/schedules/${manualScheduleId}`, { method: 'DELETE', token: teacherToken });
}
for (const row of await listSaturday()) {
await api(`/schedules/${row.id}`, { method: 'DELETE', token: teacherToken });
}
record('清理:本次验证写入的课表与手排课已删除', (await listSaturday()).length === 0, '周六 19 点已清空');
// ---------------------------------------------------------------- 6. 停用设备
await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { enabled: false },
});
const blocked = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: { state: { inClass: false, clientTime: new Date().toISOString() } },
});
record('停用设备后插件上报被拒(403)', blocked.status === 403, `status=${blocked.status}`);
// ---------------------------------------------------------------- 7. 权限隔离
// 班级码只有管理员/班主任可见,班级密码也是默认值,因此这里按管理员信息补齐
let classCode = target.code ?? null;
if (!classCode && adminToken) {
const all = await api('/classes', { token: adminToken });
classCode = (all.payload?.data ?? []).find((item) => item.id === target.id)?.code ?? null;
}
const studentLogin = await api('/auth/class-login', {
method: 'POST',
body: { code: classCode ?? '', password: '123456' },
});
const studentToken = studentLogin.payload?.data?.token;
if (studentToken) {
const forbidden = await api('/integrations/devices', { token: studentToken });
record('学生无法查看联动设备列表(403)', forbidden.status === 403, `status=${forbidden.status}`);
} else {
record('班级账号登录(跳过学生隔离用例)', false, `status=${studentLogin.status}`);
}
// ---------------------------------------------------------------- 清理
const removed = await api(`/integrations/devices/${deviceId}`, { method: 'DELETE', token: teacherToken });
record('删除验证设备(清理)', removed.status === 200, `status=${removed.status}`);
const failed = results.filter((item) => !item.ok);
console.log(`\n=== 结果:${results.length - failed.length}/${results.length} 通过 ===`);
if (failed.length > 0) {
console.log('失败用例:');
for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
process.exit(1);
}
console.log('ClassIsland 联动链路全部通过。\n');
}
main().catch((error) => {
console.error('验证脚本异常:', error);
process.exit(1);
});
File diff suppressed because it is too large. Load diff
+13 -3
View File
@@ -145,8 +145,15 @@ export function createApp(): Express {
app.use(securityHeaders());
app.use(compression());
app.use(cors({ origin: env.corsOrigins, credentials: true }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
/**
* 请求体上限:表格导入是把文件以 **base64** 放进 JSON 的,base64 约为原文件的 4/3,
* 再算上 JSON 包装,8MB 的表格文件约需 11MB 请求体。
* 这里必须留够余量,否则「导入模板写的 8MB 上限」永远到不了(文件到 1.5MB 就被 body-parser 拦掉),
* 而且用户看到的是一句 500「服务器内部错误:request entity too large」——
* 与 `table-import.service.ts` 的 MAX_IMPORT_BYTES(8MB) 校验完全对不上。
*/
app.use(express.json({ limit: '12mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(requestLogger());
// 探针(不参与限流)
@@ -173,7 +180,10 @@ export function createApp(): Express {
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
app.use(API_PREFIX, apiRateLimiter());
app.use(`${API_PREFIX}/auth/login`, loginRateLimiter());
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
for (const module of apiModules) {
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more