Author SHA1 Message Date
LoveChengke 9a38f86cd3 feat(server,web,desktop,shared,deploy,docs): Linux 一键安装与 classhelper 运维命令、更新检查、图表去 echarts,版本统一 1.1.0
Linux 交付链(新增)
- deploy/install.sh:一键安装器(CLASSHELPER 大字 TUI、native/docker 双形态、向导选安装目录/
  端口/数据库/初始管理员密码);识别 apt/yum/dnf 只补缺的依赖、建独立系统用户、下载内置 Node
  运行时(glibc < 2.28 的 CentOS 7 自动改用 unofficial-builds 的 glibc-217 构建)、幂等覆盖安装
  (保留 config.env 与 data/,不重置密钥)、全程写 /var/log/classhelper/install.log;
  `--check` 只体检(不需要 root)、`--yes` 无人值守、`--package` 支持内网离线分发。
- deploy/classhelper:运维命令 —— status / doctor / password / backup(list,restore,schedule) /
  upgrade / rollback / start|stop|restart|reload / logs / config / port / key rotate / uninstall /
  completion / help。特权操作一律校验 EUID,关键动作写 /var/log/classhelper/audit.log。
- deploy/tools/admin-cli.mjs:离线账号工具(改密、列账号、VACUUM INTO 一致性快照、哈希回滚),
  直连数据库 —— 服务停机也能救急,不需要先有一个能登录的账号。
- deploy/{classhelper.service,profile.d,logrotate.d,systemd}/ 模板 + Dockerfile.linux-package。
- scripts/dist-server.mjs 增加 --platform linux(根脚本 `pnpm dist:server:linux`),产物
  classhelper-server-linux-x64-<版本>.tar.gz + 同名 .sha256;新增 GitHub Actions workflow 在
  ubuntu runner 上构建并挂 Release(@libsql/linux-* 这类平台相关依赖只能在 Linux 上装)。
- docs/linux-deploy.md:安装 / 运维命令 / 升级回滚 / 备份恢复 / 排障 / 安全清单 /
  Ubuntu 22.04 与 Rocky 9 的验收清单。deploy/install-linux.sh 改为转发壳,不留两份实现。

三处必须知道的取舍(已写进 AGENTS.md §5 第 59 条)
- 配置真身在 /etc/classhelper/config.env,安装目录里的 .env 是指向它的软链:Web 端
  「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是同一份配置 ——
  两处不是同一个文件时,切换会「看起来成功、重启后又连回旧库」。classhelper doctor 有专门断言。
- 升级/回滚替换程序文件用「改名 + 就位」而不是 rm+cp:升级时正在运行的就是 bin/classhelper
  自己,直接删掉同名文件会让 bash 读到半个脚本。
- 交叉构建(在非 Linux 机器上打 Linux 包)会在包内写 .cross-built 标记、doctor 据此提示:
  已核对 @libsql/linux-x64-gnu 与 -musl 就位且无 win32 残留,但 prisma CLI 的原生 schema engine
  是宿主平台的,Web 端「数据库一键切换」那一步存疑;正式产物一律走 CI。

更新检查(此前只在工作区,本次一并提交)
- 服务端 modules/update:由服务端转发 GitHub Releases(Web 端 CSP 不允许直连),离线/限流/超时
  一律收敛成 ok:false + 一句人话,失败结果同样进缓存;?force=1 只对管理员生效(避免刷爆匿名限流)。
- 版本比较放 @classhelper/shared(compareVersions / isNewerVersion),三端共用;服务端版本号
  改由 lib/version.ts 读 package.json,不再在 app.ts 里写字面量。
- Web 端顶栏「检查更新」弹窗、客户端「关于」页的检查更新与诊断信息。

Web 端 / 客户端
- 成绩图表改用自包含的轻量组件(ScoreBarChart / ScoreLineChart),不再全量引入 echarts
  (原先 echarts 占桌面端渲染产物 1.12MB、Web 端 1.13MB);柱状图是纯 HTML/CSS,折线图是
  viewBox SVG,均无 resize 监听。
- Element Plus 图标改为按需注册(两端各一份 icons.ts),新增 scripts/check-icons.mjs 静态门禁
  并挂进两端 build —— 漏注册的后果是界面静默渲染空白,只在控制台一条 warning。

版本号统一 1.1.0
- 5 份 package.json、deploy/package.runtime.json、.env.example 的 APP_VERSION、
  docker-compose 与两个 Dockerfile 的镜像标签、Linux 安装包名示例、README 首页版本;
- ClassIsland 插件 manifest.yml 与 BridgeService.PluginVersion 同步(verify:classisland-plugin
  有 manifest ↔ code 的一致性断言);
- 刻意保持不变的是**历史性表述与举例**:docs/mysql.md 的「自 v1.0.0 起内置 MySQL 适配器」、
  shared/utils.ts 讲版本比较用的 1.0.0 / 1.0.0-fix、README 验收表里的实测记录。
2026-10-05 16:39:38 +08:00
LoveChengke aad22df4fd docs: 重写 README —— 按 1.0.0 现状重排为「产品说明书 + 验收对照表」
结构重排:组成部分 → 交付产物与部署形态 → 快速开始 → 功能说明(Web 端 / 客户端 / 灵动岛 / 作业 /
叫人 / 上课时段 / 导入 / ClassIsland 联动 / 数据库管理)→ 技术参考(目录结构 / 技术栈 / REST API /
WebSocket / RBAC / 模块化 / 数据库与迁移)→ 验收 → 打包与交付 → 常见问题 → 后续增强。
1594 行 → 1236 行,去掉的是与 AGENTS.md 重复的踩坑叙述与「按阶段的交付清单」文件流水(后者改由目录结构
与 git 历史承载)。

修正过期内容:
- 迁移数 9 → 10(补回 add_schedule_source),迁移清单同步
- verify:e2e 173 项 → 186 项;verify:web 30/31、verify:desktop 99/102、插件静态契约 68/68 按最近一次实测重写
- 删掉两条与现状相反的验收行(「客户端设置页提供修改密码」),改为「客户端有意不提供改密入口」,
  与 AGENTS.md §5 第 31 条、冒烟断言「改密入口存在=false」一致
- 服务端安装包体积口径统一为 ~71MB(切库依赖内置后);插件版本 1.0.0.0;正文标注当前版本 1.0.0

新增与加强:
- 补 1.0.0 的内容:数据库管理(仅管理员)、新手引导(Web 端 el-tour + 客户端六步向导)、
  设置拆 6 个子路由、深色模式、客户端主窗口与 ClassIsland 对齐(1242x582)、
  课表 source 字段与「幽灵行清理」口径
- 6.3「已知的时序 / 环境相关用例」:写明 verify:desktop 那 3 项在「当前正在上课」时必然失败的原因,
  并给出各验证脚本覆盖凭据的方式(ELECTRON_SMOKE_* / UI_SMOKE_* 支持环境变量,verify:e2e 不支持)

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

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

注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
2026-10-02 11:18:47 +08:00
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
230 changed files with 42875 additions and 2326 deletions

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
name: 发布 Linux 服务端安装包
# 为什么单独有这个 workflow:
# Linux 包的依赖(@libsql/linux-x64-gnu、@prisma/adapter-libsql 等)是平台相关的,
# 在 Windows 开发机上 npm install 出来的 node_modules 装到 Linux 跑不起来,
# 所以 Linux 包必须在 Linux 上构建。这个 workflow 用 ubuntu runner 产出
# classhelper-server-linux-x64-<版本>.tar.gz 并挂到 GitHub Release 上,
# 服务器上的 `classhelper upgrade` 就是从这里下载 + 校验 sha256 的。
#
# 用法:
# 1. 先在本地按惯例打出 Windows 包并发 Release(vX.Y.Z);
# 2. 到 Actions 页面手动运行本 workflow,填 tag(例如 v1.1.0);
# 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。
#
# 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。
on:
workflow_dispatch:
inputs:
tag:
description: '要构建并挂载的 Release tag(例如 v1.1.0)'
required: true
type: string
attach:
description: '构建后上传到该 Release'
required: false
default: true
type: boolean
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-linux-package:
runs-on: ubuntu-22.04
timeout-minutes: 40
steps:
- name: 确定 tag
id: ref
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
echo "attach=${{ inputs.attach }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
echo "attach=true" >> "$GITHUB_OUTPUT"
fi
- name: 检出代码
uses: actions/checkout@v4
with:
ref: ${{ steps.ref.outputs.tag }}
- name: 安装 Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: 安装 pnpm
uses: pnpm/action-setup@v4
with:
version: 11.8.0
run_install: false
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 构建 Linux 安装包
run: pnpm dist:server:linux
- name: 展示产物
run: ls -la release-server/
- name: 上传构建产物(Artifacts)
uses: actions/upload-artifact@v4
with:
name: classhelper-server-linux-x64
path: |
release-server/classhelper-server-linux-x64-*.tar.gz
release-server/classhelper-server-linux-x64-*.tar.gz.sha256
if-no-files-found: error
retention-days: 30
- name: 挂到 Release 并更新 SHA256SUMS
if: steps.ref.outputs.attach == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.ref.outputs.tag }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
TARBALL="release-server/classhelper-server-linux-x64-${VERSION}.tar.gz"
SUMS="release-server/SHA256SUMS-${VERSION}.txt"
if [ ! -f "$TARBALL" ]; then
echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)"
exit 1
fi
# 取回已有的校验清单(Windows 包那几行),把本次的 Linux 包追加/替换进去
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir release-server --clobber || true
DOWNLOADED="$(ls release-server/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)"
if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi
grep -v "classhelper-server-linux-x64-${VERSION}.tar.gz" "$SUMS" 2>/dev/null > "${SUMS}.tmp" || true
cat "${SUMS}.tmp" > "$SUMS" 2>/dev/null || true
rm -f "${SUMS}.tmp"
sha256sum "$TARBALL" | awk '{print $1" "$2}' | sed "s#release-server/##" >> "$SUMS"
sort -u -k2 "$SUMS" -o "$SUMS"
gh release upload "$TAG" "$TARBALL" "$SUMS" --clobber
echo "已上传:"
cat "$SUMS"
+10
View File
@@ -38,3 +38,13 @@ eslint-report.json
.idea/ .idea/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# 参考用的第三方源码包(设计 token 已提取到 docs/winisland-design-tokens.md)
WinIsland-master.zip
releases/
# AI 浼氳瘽浜х墿锛圸Code 璁″垝/閰嶇疆锛屼笉灞炰簬浜у搧浠g爜锛�
.zcode/
.zcodeignore
packages/server/prisma/backups/
packages/server/prisma/database-settings.json
+1052
View File
File diff suppressed because it is too large. Load diff
+1105 -504
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -7,7 +7,7 @@
SERVER_PORT=4000 SERVER_PORT=4000
# 应用版本(镜像标签) # 应用版本(镜像标签)
APP_VERSION=0.1.0 APP_VERSION=1.1.0
# MySQL 配置 # MySQL 配置
MYSQL_ROOT_PASSWORD=请改成强密码_root MYSQL_ROOT_PASSWORD=请改成强密码_root
@@ -30,6 +30,11 @@ CORS_ORIGIN=*
INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=admin123 INITIAL_ADMIN_PASSWORD=admin123
# 各类账号的默认初始密码(新增/重置时使用)
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
# 第 1 教学周的周一(用于"当前周次") # 第 1 教学周的周一(用于"当前周次")
TERM_START_DATE=2026-08-31 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=1.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_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
# 第 1 教学周的周一(用于"当前周次")
TERM_START_DATE=2026-08-31
# 日志级别:debug | info | warn | error
LOG_LEVEL=info
+7 -2
View File
@@ -5,13 +5,13 @@
# runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小 # runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小
# #
# 构建(在仓库根目录执行): # 构建(在仓库根目录执行):
# docker build -f deploy/Dockerfile -t classhelper-server:0.1.0 . # docker build -f deploy/Dockerfile -t classhelper-server:1.1.0 .
# #
# 运行(SQLite + 数据卷): # 运行(SQLite + 数据卷):
# docker run -d --name classhelper -p 4000:4000 \ # docker run -d --name classhelper -p 4000:4000 \
# -e JWT_SECRET=<48字节随机串> \ # -e JWT_SECRET=<48字节随机串> \
# -v classhelper-data:/app/data \ # -v classhelper-data:/app/data \
# classhelper-server:0.1.0 # classhelper-server:1.1.0
# #
# 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代) # 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代)
@@ -33,7 +33,12 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
# 再拷贝源码并构建(只构建服务端与 Web 管理端,桌面客户端不参与) # 再拷贝源码并构建(只构建服务端与 Web 管理端,桌面客户端不参与)
COPY packages ./packages COPY packages ./packages
COPY scripts ./scripts 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 \ RUN pnpm build:shared \
&& pnpm --filter @classhelper/server run db:generate \
&& pnpm --filter @classhelper/server build \ && pnpm --filter @classhelper/server build \
&& pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/web-admin build
+45
View File
@@ -0,0 +1,45 @@
# ---------------------------------------------------------------------------
# 班级小助手服务端 · Docker 形态镜像(由 install.sh --mode docker 使用)
#
# 与仓库根 deploy/Dockerfile 的区别:
# - 那份是"从源码构建"(两阶段、需要 pnpm 与全量源码),适合开发与自己打包镜像;
# - 这份是"从安装包构建":安装包里的 server/dist、web、node_modules 已经是**linux-x64 的真实目录**,
# 所以这里只做 COPY,不需要联网装依赖、不需要构建工具,几十秒就能出镜像。
#
# 构建(install.sh 自动执行,也可手工):
# docker build -t classhelper-server:1.1.0 -f Dockerfile .
# 运行:
# docker run -d --name classhelper-server -p 4000:4000 \
# --env-file /etc/classhelper/config.env -v classhelper-data:/app/data \
# classhelper-server:1.1.0
# ---------------------------------------------------------------------------
FROM node:22-slim
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4000
WORKDIR /app
# 运行时会用到的全部内容(安装包里就是这套布局)
COPY package.json ./
COPY node_modules ./node_modules
COPY server ./server
COPY web ./web
COPY tools ./tools
# 数据目录(compose 里挂成卷)
RUN mkdir -p /app/data /app/logs
# 说明:不用 `USER node`
# - 镜像只在本机跑,容器本身就是隔离边界;
# - 数据卷里的文件需要与宿主机上的 classhelper backup / restore 互通,
# 用 root 可以避免 uid 不一致导致的权限问题。
EXPOSE 4000
# 健康检查用 Node 自己发请求:slim 镜像里没有 curl/wget
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server/dist/index.js"]
+1580
View File
File diff suppressed because it is too large. Load diff
+81
View File
@@ -0,0 +1,81 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 服务端 systemd 单元(Linux)
#
# 这是**模板**:`deploy/install.sh` 会把占位符替换成真实值后安装到
# /etc/systemd/system/classhelper.service。手动部署时照着替换这四处即可:
#
# __NODE__ Node 可执行文件的绝对路径(内置运行时:<安装目录>/runtime/node/bin/node)
# __DIR__ 安装目录(默认 /opt/classhelper)
# __USER__ 运行服务的系统用户(默认 classhelper)
# __CONFIG__ 环境变量文件(默认 /etc/classhelper/config.env)
# __CONFIGDIR__ __CONFIG__ 所在目录(需要写权限,见下面的 ReadWritePaths)
#
# sudo sed -e 's#__NODE__#/opt/classhelper/runtime/node/bin/node#' \
# -e 's#__DIR__#/opt/classhelper#' \
# -e 's#__USER__#classhelper#' \
# -e 's#__CONFIGDIR__#/etc/classhelper#' \
# -e 's#__CONFIG__#/etc/classhelper/config.env#' \
# deploy/classhelper.service > /tmp/classhelper.service
# sudo install -m 644 /tmp/classhelper.service /etc/systemd/system/classhelper.service
# sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
#
# 常用命令(也有对应的 classhelper 封装):
# classhelper status / start / stop / restart / logs -f
# systemctl status classhelper
# journalctl -u classhelper -f
# ---------------------------------------------------------------------------
[Unit]
Description=ClassHelper Server (班级小助手服务端)
Documentation=https://github.com/LoveChengke/ClassHelper
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=__USER__
Group=__USER__
WorkingDirectory=__DIR__
# 配置从这里注入(唯一的配置真身;安装目录里的 .env 是指向它的软链,见 docs/linux-deploy.md)。
# systemd 会剥掉值两侧的引号,所以同一份文件给 dotenv / shell source / systemd 三种读法都能用。
# 前面不加 "-":文件被删掉时直接启动失败,比拿着一份假配置跑起来更容易发现。
EnvironmentFile=__CONFIG__
ExecStart=__NODE__ server/dist/index.js
Restart=always
RestartSec=3
# Socket.IO 长连接较多:提高文件描述符上限
LimitNOFILE=65535
# 优雅退出:给在途请求与 websocket 收尾时间(index.ts 处理 SIGTERM/SIGINT)
KillSignal=SIGTERM
TimeoutStopSec=20
# 日志进 journald:journalctl -u classhelper
StandardOutput=journal
StandardError=journal
SyslogIdentifier=classhelper
# ---------------------------------------------------------------- 加固
# 为什么 ReadWritePaths 要给整个 __DIR__ 与配置目录,而不是只给 data/logs:
# 1) Web 端「数据库管理 → 一键切换」会在运行中改写 server/prisma/schema.prisma,
# 并把重新生成的 Prisma Client 编译进 server/dist/generated —— 那两处都在 __DIR__ 里;
# 2) 同一功能切换成功后要改写 __CONFIG__(DATABASE_PROVIDER / DATABASE_URL 两行)。
# ProtectSystem=full 默认把 /etc 与 /usr 设成只读,ReadWritePaths 是官方的"打洞"方式。
# 服务本来就以 __USER__ 身份运行,这里给的写权限没有超出它的既有边界。
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadWritePaths=__DIR__ __CONFIGDIR__
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
# 需要 AF_NETLINK:glibc 解析主机名(MySQL 主机 / GitHub API)时会用到
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
[Install]
WantedBy=multi-user.target
+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:-1.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_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:
+4 -1
View File
@@ -60,7 +60,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-0.1.0} image: classhelper-server:${APP_VERSION:-1.1.0}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
@@ -78,6 +78,9 @@ services:
TERM_START_DATE: ${TERM_START_DATE:-} TERM_START_DATE: ${TERM_START_DATE:-}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456}
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
ports: ports:
- '${SERVER_PORT:-4000}:4000' - '${SERVER_PORT:-4000}:4000'
depends_on: depends_on:
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# 兼容壳:早期文档、脚本与 README 里写的是 deploy/install-linux.sh,
# 现在安装器统一在 deploy/install.sh(避免两份实现漂移)。
#
# 两者参数完全一致:
# sudo bash deploy/install-linux.sh --port 8080
# 等价于
# sudo bash deploy/install.sh --port 8080
# ---------------------------------------------------------------------------
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
printf '\033[33m[classhelper]\033[0m install-linux.sh 已更名为 install.sh,本次自动转发(后续请直接用 install.sh)\n' >&2
exec bash "${DIR}/install.sh" "$@"
+1119
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 日志轮转
# 由 deploy/install.sh 安装到 /etc/logrotate.d/classhelper
#
# copytruncate 而不是 create+信号:安装器与 classhelper 都是"追加写完就关"
# (`>>` 打开、写完立即关闭),没有常驻 fd 可通知,copytruncate 是这里唯一正确的做法。
# ---------------------------------------------------------------------------
/var/log/classhelper/*.log {
weekly
rotate 8
missingok
notifempty
compress
delaycompress
copytruncate
create 0600 root root
su root root
}
+5 -1
View File
@@ -1,12 +1,13 @@
{ {
"name": "classhelper-server-runtime", "name": "classhelper-server-runtime",
"version": "0.1.0", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "班级小助手服务端运行时(安装包 / 容器内置)", "description": "班级小助手服务端运行时(安装包 / 容器内置)",
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0", "@prisma/adapter-libsql": "7.10.0",
"@prisma/adapter-mariadb": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"compression": "^1.8.2", "compression": "^1.8.2",
@@ -16,7 +17,10 @@
"express-rate-limit": "^8.7.0", "express-rate-limit": "^8.7.0",
"helmet": "^8.3.0", "helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"prisma": "7.10.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"typescript": "~5.9.3",
"xlsx": "0.18.5",
"zod": "^4.6.2" "zod": "^4.6.2"
} }
} }
+28
View File
@@ -0,0 +1,28 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 登录 shell 环境变量
#
# 由 deploy/install.sh 安装到 /etc/profile.d/classhelper.sh(占位符替换成真实路径)。
# 生效方式:重新登录,或 `source /etc/profile.d/classhelper.sh`。
#
# 说明:`classhelper` 命令本身装在 /usr/local/bin(软链到 $CLASSHELPER_HOME/bin/classhelper),
# 不依赖这个文件也能用;这里提供的是路径类变量,方便写脚本时引用。
# ---------------------------------------------------------------------------
# 只在目录真实存在时导出:卸载后残留的 profile.d 不应该污染别人的环境
if [ -d '__DIR__' ]; then
export CLASSHELPER_HOME='__DIR__'
export CLASSHELPER_CONFIG='__CONFIG__'
export CLASSHELPER_LOG_DIR='__LOGDIR__'
export CLASSHELPER_BACKUP_DIR='__BACKUPDIR__'
export CLASSHELPER_SERVICE='classhelper'
case ":${PATH}:" in
*":${CLASSHELPER_HOME}/bin:"*) ;;
*) export PATH="${PATH}:${CLASSHELPER_HOME}/bin" ;;
esac
fi
# 让 `classhelper` 在非 root 登录时也能补全(bash 补全由 `classhelper completion bash` 输出)
if [ -n "${BASH_VERSION:-}" ] && [ -r /usr/share/bash-completion/completions/classhelper ]; then
. /usr/share/bash-completion/completions/classhelper
fi
+22
View File
@@ -0,0 +1,22 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 定时备份(systemd timer)
# 由 `classhelper backup schedule daily|weekly` 安装(占位符 __BIN__ / __KEEP__ 由命令替换)
#
# 手工安装:
# sudo sed -e 's#__BIN__#/usr/local/bin/classhelper#' -e 's#__KEEP__#7#' \
# deploy/systemd/classhelper-backup.service > /tmp/x.service ...
# 然后 systemctl daemon-reload && systemctl enable --now classhelper-backup.timer
# ---------------------------------------------------------------------------
[Unit]
Description=ClassHelper 定时备份
Documentation=https://github.com/LoveChengke/ClassHelper
After=classhelper.service
[Service]
Type=oneshot
ExecStart=__BIN__ backup --quiet --keep __KEEP__
# 备份失败时不要静默:journalctl -u classhelper-backup 里能看到退出码与原因
StandardOutput=journal
StandardError=journal
SyslogIdentifier=classhelper-backup
+19
View File
@@ -0,0 +1,19 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 定时备份(触发器)
# 由 `classhelper backup schedule daily|weekly` 安装;OnCalendar 行由命令按选择替换
# ---------------------------------------------------------------------------
[Unit]
Description=ClassHelper 定时备份触发器
Documentation=https://github.com/LoveChengke/ClassHelper
[Timer]
OnCalendar=__ONCALENDAR__
# 错开整点:避免所有装了本服务的机器在同一秒打盘
RandomizedDelaySec=30m
# 机器关机期间错过的备份在开机后补一次
Persistent=true
Unit=classhelper-backup.service
[Install]
WantedBy=timers.target
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env node
/**
* 班级小助手 · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs)
*
* 为什么不用 HTTP API 改密码:`classhelper password` 要能在**服务停机**时救急,
* 而调 API 必须先有一个能登录的账号 —— 管理员忘了密码时就是死锁。
* 因此这里直连数据库,复用服务端自己的 Prisma Client 与 bcrypt 参数(`server/dist/lib/*`),
* 保证写入的哈希与登录校验用的是同一套实现。
*
* 安全约定(务必保持):
* - **密码只从 stdin 读**,绝不接受 argv / 环境变量形式的密码(否则会进 shell history 与 ps 输出);
* - 本工具自己**不打印任何密码**,也不写任何日志;
* - `dump-hash` 会输出 bcrypt 哈希 —— 仅由 root 调用的 `classhelper password` 用于改前备份,
* 调用方负责把结果落到 600 权限的文件里。
*
* 用法(全部由 classhelper 封装,一般不需要手工调):
* node tools/admin-cli.mjs info
* node tools/admin-cli.mjs list [--json]
* node tools/admin-cli.mjs dump-hash <用户名|class:班级码>
* node tools/admin-cli.mjs set-password <用户名> # 新密码从 stdin 读
* node tools/admin-cli.mjs set-class-password <班级码> # 新密码从 stdin 读
* node tools/admin-cli.mjs verify <用户名> # 待校验密码从 stdin 读
* node tools/admin-cli.mjs restore-hash <备份json路径>
* node tools/admin-cli.mjs snapshot <目标db文件> # SQLite 一致性快照(VACUUM INTO)
*
* 退出码:0 成功 / 1 通用失败 / 2 用法错误 / 3 目标不存在 / 4 密码不合规 / 5 数据库不可用
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
/** 安装根目录(tools/ 的上一级) */
const installDir = path.resolve(scriptDir, '..');
const serverDist = path.join(installDir, 'server', 'dist');
const EXIT = { OK: 0, FAIL: 1, USAGE: 2, NOT_FOUND: 3, WEAK: 4, NO_DB: 5 };
function fail(message, code = EXIT.FAIL) {
process.stderr.write(`admin-cli: ${message}\n`);
process.exitCode = code;
return null;
}
/** 一次性读完 stdin(去掉行尾换行;保留内部空白,密码里可以有空格) */
async function readSecretFromStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
}
/** 与服务端一致的密码强度规则(classhelper 里还有一份交互式版本,两边保持同样口径) */
function checkStrength(password, username) {
const problems = [];
if (password.length < 8) problems.push('长度至少 8 位');
if (password.length > 128) problems.push('长度不要超过 128 位');
const kinds = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
if (kinds < 3) problems.push('需包含小写字母 / 大写字母 / 数字 / 符号 中至少 3 类');
if (username && password.toLowerCase() === username.toLowerCase()) problems.push('不能与用户名相同');
if (/^(123456|password|admin|classhelper|qwerty|111111|abc123)/i.test(password) && password.length < 12) {
problems.push('过于常见,请换一个');
}
return problems;
}
/** 懒加载服务端模块:先做"库在不在"的检查,避免 libSQL 在连接时凭空建出一个空库 */
async function loadServerModules() {
const envFile = path.join(serverDist, 'config', 'env.js');
if (!fs.existsSync(envFile)) {
fail(`找不到服务端产物:${envFile}`, EXIT.NO_DB);
return null;
}
const env = await import(pathToFileURL(envFile).href);
if (env.env.databaseProvider === 'sqlite') {
// classhelper 做备份时会用 CLASSHELPER_SNAPSHOT_DB 显式指定数据文件(跳过硬编码路径假设)
const dbFile = process.env.CLASSHELPER_SNAPSHOT_DB || env.env.sqliteFilePath;
if (!dbFile || !fs.existsSync(dbFile)) {
fail(
`数据库文件不存在:${dbFile || '(未配置)'}\n` +
' 提示:全新安装请先 systemctl start classhelper 完成建表(AUTO_MIGRATE);',
EXIT.NO_DB,
);
return null;
}
}
const db = await import(pathToFileURL(path.join(serverDist, 'lib', 'db.js')).href);
const password = await import(pathToFileURL(path.join(serverDist, 'lib', 'password.js')).href);
return { env: env.env, prisma: db.prisma, disconnectPrisma: db.disconnectPrisma, ...password };
}
/** 目标账号:普通用户按 username,班级账号按 Class.code(学生端登录用的是班级码) */
async function resolveTarget(prisma, key) {
if (key.startsWith('class:')) {
const code = key.slice('class:'.length).toUpperCase();
const klass = await prisma.class.findUnique({ where: { code } });
if (!klass) return { error: `班级码不存在:${code}`, code: EXIT.NOT_FOUND };
return { kind: 'class', id: klass.id, label: `班级 ${klass.name}(码 ${klass.code})`, hash: klass.passwordHash };
}
const user = await prisma.user.findUnique({ where: { username: key } });
if (!user) return { error: `用户名不存在:${key}`, code: EXIT.NOT_FOUND };
if (user.role === 'STUDENT') {
return {
error: `学生没有个人账号(${key} 只是名单记录),学生端用「班级码 + 班级密码」登录,请用 --class 改班级密码`,
code: EXIT.NOT_FOUND,
};
}
return { kind: 'user', id: user.id, label: `${user.name}(${user.username} / ${user.role})`, hash: user.passwordHash };
}
/* ------------------------------------------------------------------ 子命令 */
/**
* SQLite 一致性快照:`VACUUM INTO` 在服务运行中也能拿到自洽的副本
* (直接 cp 数据文件可能抓到写入中的中间状态)。
* 目标文件必须不存在 —— 这是 SQLite 的硬要求,先删掉。
*/
async function cmdSnapshot(modules, dest) {
const { env, prisma } = modules;
if (env.databaseProvider !== 'sqlite') {
return fail('snapshot 只支持 SQLite;MySQL 请用 mysqldump', EXIT.FAIL);
}
const target = path.resolve(dest);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.rmSync(target, { force: true });
// VACUUM INTO 不接受参数占位符,只能拼字面量 —— 单引号按 SQL 规则翻倍转义
await prisma.$executeRawUnsafe(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
if (!fs.existsSync(target)) return fail('快照未生成(VACUUM INTO 未报错但没有产出文件)', EXIT.FAIL);
process.stdout.write(`${target}\n`);
}
async function cmdInfo(modules) {
const { env, prisma } = modules;
const counts = {};
for (const model of ['user', 'class', 'course', 'schedule', 'homework', 'notification', 'grade']) {
counts[model] = await prisma[model].count();
}
const payload = {
provider: env.databaseProvider,
sqliteFile: env.sqliteFilePath || null,
serverRoot: env.serverRoot,
termStartDate: env.termStartDate || null,
counts,
};
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
async function cmdList(modules, asJson) {
const { prisma } = modules;
const users = await prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: { username: true, name: true, role: true, classId: true, updatedAt: true },
orderBy: [{ role: 'asc' }, { username: 'asc' }],
});
const classes = await prisma.class.findMany({
select: { code: true, name: true, passwordHash: true },
orderBy: { code: 'asc' },
});
if (asJson) {
process.stdout.write(
`${JSON.stringify(
{
users,
classes: classes.map((item) => ({ code: item.code, name: item.name, hasPassword: !!item.passwordHash })),
},
null,
2,
)}\n`,
);
return;
}
process.stdout.write('可登录账号(教师 / 管理员):\n');
for (const user of users) {
process.stdout.write(` ${user.role.padEnd(8)} ${user.username.padEnd(16)} ${user.name}\n`);
}
process.stdout.write('\n班级账号(学生端用「班级码 + 班级密码」登录):\n');
for (const item of classes) {
const state = item.passwordHash ? '已设置' : '⚠ 未设置密码(该班当前无法登录)';
process.stdout.write(` ${item.code.padEnd(8)} ${item.name.padEnd(16)} ${state}\n`);
}
}
async function cmdDumpHash(modules, key) {
const { prisma } = modules;
const target = await resolveTarget(prisma, key);
if (target.error) return fail(target.error, target.code);
if (!target.hash) return fail(`${target.label} 当前没有密码(passwordHash 为空)`, EXIT.NOT_FOUND);
// 只给调用方(root)看的原始数据,不落到本工具的日志里
process.stdout.write(`${JSON.stringify({ target: key, kind: target.kind, id: target.id, hash: target.hash })}\n`);
}
async function cmdSetPassword(modules, key, readSecret) {
const { prisma, hashPassword } = modules;
const target = await resolveTarget(prisma, key);
if (target.error) return fail(target.error, target.code);
const secret = await readSecret();
const problems = checkStrength(secret, key);
if (problems.length > 0) return fail(`密码不合规:${problems.join(';')}`, EXIT.WEAK);
const hash = await hashPassword(secret);
if (target.kind === 'class') {
await prisma.class.update({ where: { id: target.id }, data: { passwordHash: hash } });
} else {
await prisma.user.update({ where: { id: target.id }, data: { passwordHash: hash } });
}
// 写完立刻复核一次:曾经出现过"bcrypt 参数不一致导致写进去的哈希验证不过"的排查成本
const ok = await modules.verifyPassword(secret, hash);
if (!ok) return fail('写入后复核失败:哈希无法通过校验,请用 restore-hash 回滚', EXIT.FAIL);
process.stdout.write(`已更新密码:${target.label}\n`);
}
async function cmdVerify(modules, key, readSecret) {
const { prisma, verifyPassword } = modules;
const target = await resolveTarget(prisma, key);
if (target.error) return fail(target.error, target.code);
const secret = await readSecret();
const ok = !!target.hash && (await verifyPassword(secret, target.hash));
process.stdout.write(`${ok ? 'match' : 'mismatch'}\n`);
if (!ok) process.exitCode = EXIT.FAIL;
}
async function cmdRestoreHash(modules, backupFile) {
const { prisma } = modules;
if (!fs.existsSync(backupFile)) return fail(`备份文件不存在:${backupFile}`, EXIT.NOT_FOUND);
let payload;
try {
payload = JSON.parse(fs.readFileSync(backupFile, 'utf8'));
} catch (error) {
return fail(`备份文件不是合法 JSON:${error.message}`);
}
const { target, hash } = payload;
if (typeof target !== 'string' || typeof hash !== 'string') {
return fail('备份文件缺少 target / hash 字段,拒绝回滚');
}
if (target.startsWith('class:')) {
await prisma.class.update({ where: { code: target.slice('class:'.length).toUpperCase() }, data: { passwordHash: hash } });
} else {
await prisma.user.update({ where: { username: target }, data: { passwordHash: hash } });
}
process.stdout.write(`已回滚密码哈希:${target}\n`);
}
/* ------------------------------------------------------------------ 入口 */
const argv = process.argv.slice(2);
const command = argv[0] ?? 'help';
const args = argv.slice(1);
const wantsJson = args.includes('--json');
const positional = args.filter((item) => !item.startsWith('--'));
function usage() {
process.stdout.write(
[
'classhelper 离线账号工具(一般由 classhelper 命令调用)',
'',
' info 输出数据库与表行数(JSON)',
' list [--json] 列出可登录账号与班级账号',
' dump-hash <用户名|class:班级码> 输出当前密码哈希(供改密前备份)',
' set-password <用户名> 从 stdin 读新密码并写入',
' set-class-password <班级码> 从 stdin 读新密码并写入班级账号',
' verify <用户名> 从 stdin 读密码,输出 match / mismatch',
' restore-hash <备份json路径> 用备份文件里的哈希回滚',
' snapshot <目标db文件> 把 SQLite 库一致性快照到指定路径(VACUUM INTO)',
'',
].join('\n'),
);
}
if (command === 'help' || command === '--help' || command === '-h') {
usage();
} else if (
![
'info',
'list',
'dump-hash',
'set-password',
'set-class-password',
'verify',
'restore-hash',
'snapshot',
].includes(command)
) {
fail(`未知子命令:${command}\n`, EXIT.USAGE);
usage();
} else {
const modules = await loadServerModules();
if (!modules) {
// 已经在 loadServerModules 里设了 exitCode
} else {
try {
switch (command) {
case 'info':
await cmdInfo(modules);
break;
case 'list':
await cmdList(modules, wantsJson);
break;
case 'dump-hash':
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
else await cmdDumpHash(modules, positional[0]);
break;
case 'set-password':
case 'set-class-password': {
const key = command === 'set-class-password' ? `class:${positional[0] ?? ''}` : (positional[0] ?? '');
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
else await cmdSetPassword(modules, key, readSecretFromStdin);
break;
}
case 'verify':
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
else await cmdVerify(modules, positional[0], readSecretFromStdin);
break;
case 'restore-hash':
if (!positional[0]) fail('缺少备份文件路径', EXIT.USAGE);
else await cmdRestoreHash(modules, positional[0]);
break;
case 'snapshot':
if (!positional[0]) fail('缺少目标文件路径', EXIT.USAGE);
else await cmdSnapshot(modules, positional[0]);
break;
default:
usage();
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
} finally {
// 不用 process.exit():在顶层 await 里硬退会踩 libuv 断言(见 AGENTS.md §7)
await modules.disconnectPrisma();
}
}
}
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# 班级小助手 · Linux 安装与运维链路验收(在**已安装的服务器上**运行)
#
# 覆盖:安装结果 → 服务可用性 → API 登录 → 改密(含回滚与审计)→ 备份 →
# 配置读写 → key rotate →(可选)升级 / 回滚 / 卸载
#
# 用法(在服务器上,需要 root):
# sudo bash verify-linux.sh # 基础链路
# sudo bash verify-linux.sh --full --package /tmp/xxx.tar.gz # 追加升级/回滚
# sudo bash verify-linux.sh --admin-password-stdin # 密码从 stdin 读一行(不进 argv/history)
#
# 说明:
# - 本脚本会**真的**改一次管理员密码(换成随机强密码,再改回去),并真的发一条备份;
# - 除了 --purge 的卸载测试,不会删除任何数据;
# - 全程不需要外网(升级测试用 --package 指向本地包)。
# ---------------------------------------------------------------------------
set -uo pipefail
INSTALL_DIR="/opt/classhelper"
CONFIG_FILE="/etc/classhelper/config.env"
LOG_DIR="/var/log/classhelper"
BACKUP_DIR="/var/backups/classhelper"
PORT=""
ADMIN_USER="admin"
ADMIN_PASSWORD=""
ADMIN_PASSWORD_FROM_STDIN=0
DO_FULL=0
PACKAGE=""
TOTAL=0; PASS=0; FAIL=0
FAILED=()
while [ $# -gt 0 ]; do
case "$1" in
--dir) INSTALL_DIR="$2"; shift 2 ;;
--config) CONFIG_FILE="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--user) ADMIN_USER="$2"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
--admin-password-stdin) ADMIN_PASSWORD_FROM_STDIN=1; shift ;;
--package) PACKAGE="$2"; shift 2 ;;
--full) DO_FULL=1; shift ;;
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
*) echo "未知参数:$1" >&2; exit 2 ;;
esac
done
if [ -t 1 ]; then G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; D=$'\033[2m'; B=$'\033[1m'; N=$'\033[0m'; else G=; R=; Y=; D=; B=; N=; fi
section() { printf '\n%s▶ %s%s\n' "$B" "$1" "$N"; }
check() { # $1=描述 $2=0 通过
TOTAL=$((TOTAL + 1))
if [ "$2" = "0" ]; then
PASS=$((PASS + 1)); printf ' %s✔%s %s\n' "$G" "$N" "$1"
else
FAIL=$((FAIL + 1)); FAILED+=("$1"); printf ' %s✘%s %s\n' "$R" "$N" "$1"
fi
}
note() { printf ' %s· %s%s\n' "$D" "$1" "$N"; }
[ "$(id -u)" = "0" ] || { echo "请用 root 运行:sudo bash verify-linux.sh" >&2; exit 1; }
if [ ! -f "$CONFIG_FILE" ]; then
echo "找不到安装:${CONFIG_FILE} 不存在(用 --config 指定,或先跑 install.sh)" >&2
exit 1
fi
read_cfg() { sed -n "s/^[[:space:]]*$1=//p" "$CONFIG_FILE" | tail -n 1 | tr -d '"'; }
[ -n "$PORT" ] || PORT="$(read_cfg PORT)"
[ -n "$PORT" ] || PORT=4000
CLASSHELPER="${INSTALL_DIR}/bin/classhelper"
API="http://127.0.0.1:${PORT}"
CURL=(curl -fsS --noproxy '*')
if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then IFS= read -r ADMIN_PASSWORD || true; fi
printf '%s班级小助手 · Linux 安装验收%s\n' "$B" "$N"
note "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_FILE}|端口 ${PORT}"
# ---------------------------------------------------------------- 1. 安装结果
section "安装结果"
check "安装目录存在且含 server/dist/index.js" "$([ -f "${INSTALL_DIR}/server/dist/index.js" ] && echo 0 || echo 1)"
check "版本文件 VERSION 可读" "$([ -s "${INSTALL_DIR}/VERSION" ] && echo 0 || echo 1)"
check "安装元数据 .installed.json 存在" "$([ -f "${INSTALL_DIR}/.installed.json" ] && echo 0 || echo 1)"
cfg_mode="$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || echo '?')"
check "配置文件权限 600(实际 ${cfg_mode})" "$([ "$cfg_mode" = "600" ] && echo 0 || echo 1)"
dir_mode="$(stat -c '%a' "$(dirname "$CONFIG_FILE")" 2>/dev/null || echo '?')"
check "配置目录权限 700(实际 ${dir_mode})" "$([ "$dir_mode" = "700" ] && echo 0 || echo 1)"
env_link="${INSTALL_DIR}/.env"
check "安装目录 .env 是指向配置真身的软链(Web 端切库依赖它)" \
"$([ -L "$env_link" ] && [ "$(readlink "$env_link")" = "$CONFIG_FILE" ] && echo 0 || echo 1)"
node_bin="${INSTALL_DIR}/runtime/node/bin/node"
node_ver="$([ -x "$node_bin" ] && "$node_bin" -v 2>/dev/null || echo '')"
check "内置 Node 可用(${node_ver:-未找到})" "$([ -n "$node_ver" ] && echo 0 || echo 1)"
check "classhelper 命令已装到 /usr/local/bin" "$([ -x /usr/local/bin/classhelper ] && echo 0 || echo 1)"
check "安装日志 ${LOG_DIR}/install.log 存在" "$([ -s "${LOG_DIR}/install.log" ] && echo 0 || echo 1)"
check "审计日志 ${LOG_DIR}/audit.log 存在" "$([ -f "${LOG_DIR}/audit.log" ] && echo 0 || echo 1)"
check "审计/安装日志权限不放行他人(≤640)" \
"$([ "$(stat -c '%a' "${LOG_DIR}/audit.log" 2>/dev/null || echo 644)" -le 640 ] && echo 0 || echo 1)"
check "/etc/profile.d/classhelper.sh 已写入" "$([ -f /etc/profile.d/classhelper.sh ] && echo 0 || echo 1)"
profile_home="$(bash -lc 'echo "${CLASSHELPER_HOME:-}"' 2>/dev/null)"
check "新登录 shell 里 CLASSHELPER_HOME 生效(${profile_home:-空})" "$([ -n "$profile_home" ] && echo 0 || echo 1)"
# ---------------------------------------------------------------- 2. 服务
section "服务与探针"
if command -v systemctl >/dev/null 2>&1 && [ -f /etc/systemd/system/classhelper.service ]; then
check "systemd 单元已安装" 0
check "服务处于 active" "$(systemctl is-active --quiet classhelper && echo 0 || echo 1)"
check "服务开机自启(enabled)" "$(systemctl is-enabled --quiet classhelper 2>/dev/null && echo 0 || echo 1)"
else
check "systemd 单元已安装" 1
fi
check "/healthz 返回 200" "$("${CURL[@]}" -o /dev/null "${API}/healthz" 2>/dev/null && echo 0 || echo 1)"
status_json="$("$CLASSHELPER" status --json 2>/dev/null || true)"
check "classhelper status --json 是合法 JSON" "$(printf '%s' "$status_json" | grep -q '"healthy"' && echo 0 || echo 1)"
check "status 报告 healthy=yes" "$(printf '%s' "$status_json" | grep -q '"healthy": "yes"' && echo 0 || echo 1)"
check "status 报告的端口与实际一致" "$(printf '%s' "$status_json" | grep -q "\"port\": ${PORT}" && echo 0 || echo 1)"
check "classhelper help 可执行(非 root 也能看)" "$("$CLASSHELPER" help >/dev/null 2>&1 && echo 0 || echo 1)"
check "classhelper completion bash 有输出" "$("$CLASSHELPER" completion bash 2>/dev/null | grep -q 'classhelper' && echo 0 || echo 1)"
doctor_out="$("$CLASSHELPER" doctor 2>&1 || true)"
check "classhelper doctor 无待处理项" "$(printf '%s' "$doctor_out" | grep -q '体检通过' && echo 0 || echo 1)"
if ! printf '%s' "$doctor_out" | grep -q '体检通过'; then
printf '%s' "$doctor_out" | sed 's/^/ /'
fi
# ---------------------------------------------------------------- 3. API
section "API 与登录"
TOKEN=""
login() { # $1=密码 → 输出 token(失败则空)
"${CURL[@]}" -X POST "${API}/api/auth/login" -H 'content-type: application/json' \
-d "{\"username\":\"${ADMIN_USER}\",\"password\":\"$1\"}" 2>/dev/null \
| sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
}
if [ -z "$ADMIN_PASSWORD" ]; then
note "未提供 --admin-password(-stdin),跳过登录与改密用例"
else
TOKEN="$(login "$ADMIN_PASSWORD")"
check "管理员用提供的密码能登录并拿到 token" "$([ -n "$TOKEN" ] && echo 0 || echo 1)"
if [ -n "$TOKEN" ]; then
classes="$("${CURL[@]}" -H "Authorization: Bearer ${TOKEN}" "${API}/api/classes" 2>/dev/null || true)"
check "带 token 能读业务接口 /api/classes" "$(printf '%s' "$classes" | grep -q '"data"' && echo 0 || echo 1)"
me="$("${CURL[@]}" -H "Authorization: Bearer ${TOKEN}" "${API}/api/auth/me" 2>/dev/null || true)"
check "/api/auth/me 返回的是 ADMIN" "$(printf '%s' "$me" | grep -q '"ADMIN"' && echo 0 || echo 1)"
fi
fi
# ---------------------------------------------------------------- 4. 改密
if [ -n "$TOKEN" ]; then
section "password 子命令(改密 → 回滚 → 审计)"
# 生成一个一定满足强度要求的密码(大小写+数字+符号,且不落在常见口令里)
GENERATED="Ch$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')!a9Z"
before_count="$(grep -c 'action=password' "${LOG_DIR}/audit.log" 2>/dev/null || echo 0)"
# 密码经管道进 stdin,不出现在 argv / 环境变量 / shell history
out="$(printf '%s\n%s\n' "$GENERATED" "$GENERATED" | "$CLASSHELPER" password "$ADMIN_USER" 2>&1)"
check "classhelper password 执行成功" "$(printf '%s' "$out" | grep -q '密码已更新' && echo 0 || echo 1)"
new_token="$(login "$GENERATED")"
check "新密码能登录" "$([ -n "$new_token" ] && echo 0 || echo 1)"
old_token="$(login "$ADMIN_PASSWORD")"
check "旧密码已失效(登录被拒)" "$([ -z "$old_token" ] && echo 0 || echo 1)"
hash_backup="$(ls -1t "${BACKUP_DIR}"/password-*.json 2>/dev/null | head -n 1 || true)"
check "改密前备份了原哈希(${hash_backup:-无})" "$([ -n "$hash_backup" ] && echo 0 || echo 1)"
check "哈希备份权限 600" \
"$([ -n "$hash_backup" ] && [ "$(stat -c '%a' "$hash_backup")" = "600" ] && echo 0 || echo 1)"
after_count="$(grep -c 'action=password' "${LOG_DIR}/audit.log" 2>/dev/null || echo 0)"
check "审计日志新增了 password 记录" "$([ "${after_count:-0}" -gt "${before_count:-0}" ] && echo 0 || echo 1)"
check "审计日志里没有新密码明文" "$(grep -q -- "$GENERATED" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
check "审计日志里没有原密码明文" "$(grep -q -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
check "弱密码被拒绝(长度/种数不足)" \
"$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 | grep -q '不合规' && echo 0 || echo 1)"
check "密码不合规时不会卡死(stdin 结束即退出)" \
"$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)"
check "password --list 能列出账号" \
"$("$CLASSHELPER" password --list 2>&1 | grep -q "$ADMIN_USER" && echo 0 || echo 1)"
# 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠
# (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为)
if [ -n "$hash_backup" ]; then
restore_hash_out="$(LOG_LEVEL=error DOTENV_CONFIG_QUIET=true "$node_bin" "${INSTALL_DIR}/tools/admin-cli.mjs" restore-hash "$hash_backup" 2>&1 || true)"
check "restore-hash 能把原哈希回滚回来" "$(printf '%s' "$restore_hash_out" | grep -q '已回滚密码哈希' && echo 0 || echo 1)"
check "原密码重新可用" "$([ -n "$(login "$ADMIN_PASSWORD")" ] && echo 0 || echo 1)"
fi
else
note "跳过改密用例(没有可用的登录会话)"
fi
# ---------------------------------------------------------------- 5. 备份
section "backup 子命令"
backup_out="$("$CLASSHELPER" backup --keep 5 2>&1)"
check "classhelper backup 执行成功" "$(printf '%s' "$backup_out" | grep -q '备份完成' && echo 0 || echo 1)"
latest="$(ls -1dt "${BACKUP_DIR}"/*/ 2>/dev/null | head -n 1)"
check "生成了新的备份目录" "$([ -n "$latest" ] && echo 0 || echo 1)"
if [ -n "$latest" ]; then
check "备份含 config.env" "$([ -f "${latest}config.env" ] && echo 0 || echo 1)"
check "备份 meta.json 带版本号" "$(grep -q '"version"' "${latest}meta.json" 2>/dev/null && echo 0 || echo 1)"
db_kind="$(read_cfg DATABASE_PROVIDER)"
if [ "$db_kind" = "sqlite" ]; then
snap="${latest}classhelper.db"
check "备份含 SQLite 快照" "$([ -s "$snap" ] && echo 0 || echo 1)"
# 一致性快照必须是完整的 SQLite 文件(VACUUM INTO 产物)
check "快照是合法 SQLite 文件(文件头正确)" \
"$([ -n "$snap" ] && [ "$(head -c 15 "$snap" 2>/dev/null)" = "SQLite format 3" ] && echo 0 || echo 1)"
check "快照权限 600" "$([ -n "$snap" ] && [ "$(stat -c '%a' "$snap")" = "600" ] && echo 0 || echo 1)"
else
note "数据库是 MySQL,跳过 SQLite 快照用例"
fi
check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)"
fi
check "backup list 能列出备份" "$("$CLASSHELPER" backup list 2>&1 | grep -qE '^ [0-9]{8}-' && echo 0 || echo 1)"
# ---------------------------------------------------------------- 6. 配置
section "config 子命令"
check "config get PORT 返回当前端口" "$([ "$("$CLASSHELPER" config get PORT)" = "$PORT" ] && echo 0 || echo 1)"
masked="$("$CLASSHELPER" config show 2>/dev/null | grep '^JWT_SECRET=')"
check "config show 对 JWT_SECRET 打码" "$(printf '%s' "$masked" | grep -q '\*\*\*\*' && echo 0 || echo 1)"
check "config show 没打印出真实密钥" \
"$(secret="$(read_cfg JWT_SECRET)"; [ -n "$secret" ] && printf '%s' "$masked" | grep -q -- "$secret" && echo 1 || echo 0)"
# 用一个无害的键做读写往返(不重启服务)
printf 'CH_VERIFY_PROBE=1\n' >>"$CONFIG_FILE"
probe="$("$CLASSHELPER" config get CH_VERIFY_PROBE 2>/dev/null)"
check "config get 能读到新增键" "$([ "$probe" = "1" ] && echo 0 || echo 1)"
sed -i '/^CH_VERIFY_PROBE=/d' "$CONFIG_FILE"
# ---------------------------------------------------------------- 7. key rotate
if [ -n "$TOKEN" ]; then
section "key rotate"
if [ "${CLASSHELPER_VERIFY_SKIP_KEY_ROTATE:-0}" = "1" ]; then
note "按环境变量跳过"
else
old_secret="$(read_cfg JWT_SECRET)"
# key rotate 会问一次确认;无 tty 时从 stdin 读,因此用管道喂 yes
printf 'yes\n' | CLASSHELPER_NO_TTY=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true
new_secret="$(read_cfg JWT_SECRET)"
check "key rotate 改变了 JWT_SECRET" "$([ -n "$new_secret" ] && [ "$new_secret" != "$old_secret" ] && echo 0 || echo 1)"
check "新密钥长度 ≥32" "$([ "${#new_secret}" -ge 32 ] && echo 0 || echo 1)"
check "轮换后服务仍可用" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
check "轮换前的 token 已失效(/api/auth/me 返回 401)" \
"$(code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${TOKEN}" "${API}/api/auth/me")"; [ "$code" = "401" ] && echo 0 || echo 1)"
# 轮换后要重新登录,否则后面的用例会带着失效 token 跑
TOKEN="$(login "$ADMIN_PASSWORD")"
check "轮换后重新登录成功" "$([ -n "$TOKEN" ] && echo 0 || echo 1)"
fi
fi
# ---------------------------------------------------------------- 8. 升级 / 回滚
if [ "$DO_FULL" = "1" ]; then
section "upgrade / rollback(--full)"
if [ -z "$PACKAGE" ]; then
note "未提供 --package,跳过升级用例"
else
before_version="$("$CLASSHELPER" version 2>/dev/null | awk '{print $2}')"
sha="$(sha256sum "$PACKAGE" | awk '{print $1}')"
up_out="$("$CLASSHELPER" upgrade --url "$PACKAGE" --sha256 "$sha" --force 2>&1)"
check "upgrade 执行成功" "$(printf '%s' "$up_out" | grep -q '升级完成' && echo 0 || echo 1)"
check "升级后服务仍就绪" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
check "升级后配置未被改动(JWT_SECRET 保持)" \
"$([ "$(read_cfg JWT_SECRET)" = "${new_secret:-$(read_cfg JWT_SECRET)}" ] && echo 0 || echo 1)"
check "产生了程序快照(可回滚)" "$(ls -1 "${BACKUP_DIR}/rollback" 2>/dev/null | grep -q . && echo 0 || echo 1)"
check "坏包被拒绝(sha256 不匹配时不改动任何文件)" \
"$("$CLASSHELPER" upgrade --url "$PACKAGE" --sha256 0000000000000000000000000000000000000000000000000000000000000000 2>&1 | grep -q '校验失败' && echo 0 || echo 1)"
check "校验失败后服务仍在运行" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
note "(回滚需要交互确认,这里用 yes 管道)"
rb_out="$(printf 'yes\n' | "$CLASSHELPER" rollback 2>&1)"
check "rollback 执行成功" "$(printf '%s' "$rb_out" | grep -qE '已回滚到|回滚' && echo 0 || echo 1)"
check "回滚后服务仍就绪" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
note "升级前版本 ${before_version}|升级后 $("$CLASSHELPER" version 2>/dev/null | awk '{print $2}')"
fi
fi
# ---------------------------------------------------------------- 结果
printf '\n'
if [ "$FAIL" = "0" ]; then
printf '%s=== 结果:%s/%s 项通过 ===%s\n' "$G" "$PASS" "$TOTAL" "$N"
else
printf '%s=== 结果:%s/%s 项通过,%s 项失败 ===%s\n' "$R" "$PASS" "$TOTAL" "$FAIL" "$N"
printf '%s失败项:%s\n' "$Y" "$N"
for item in "${FAILED[@]}"; do printf ' · %s\n' "$item"; done
fi
[ "$FAIL" = "0" ] || exit 1
+536
View File
@@ -0,0 +1,536 @@
# Linux 部署与运维指南
本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。
不想看长文的话,最短路径是这两条命令:
```bash
sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.0.tar.gz
sudo classhelper status
```
---
## 1. 三种形态怎么选
| 形态 | 适合 | 命令 | 说明 |
| --- | --- | --- | --- |
| **直接安装(native)** | 有一台 Linux 服务器/Mini 主机,单机跑 | `install.sh` 选 1 | systemd 管理,自带 Node 运行时,SQLite 或 MySQL |
| **直接安装 + MySQL** | 多终端共享数据、已有 MySQL | `install.sh` 选 2 | 连接串写在配置里;库要先建好 |
| **Docker** | 机器上已有 Docker、想与其他服务隔离 | `install.sh` 选 3 | 用安装包里随带的 Dockerfile 构建镜像 + compose 起容器 |
> 只想先看看这台机器能不能装:`bash install.sh --check`(**不需要 root**,不改动任何东西)。
> Windows 机器用服务端安装包(`pnpm dist:server` 的产物),不需要本文。
---
## 2. 安装包从哪来
Linux 安装包命名固定为:
```
classhelper-server-linux-x64-<版本>.tar.gz ← install.sh / classhelper upgrade 都认这个名字
classhelper-server-linux-x64-<版本>.tar.gz.sha256 ← 旁边那份校验值
```
它发布在 [GitHub Releases](https://github.com/LoveChengke/ClassHelper/releases) 上。
**为什么 Linux 包要单独出一个**:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 这些依赖是平台相关的,
Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不起来,因此 Linux 包必须在 Linux 上构建。
仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml)
就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上):
1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.0`);
2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行;
3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。
内网离线分发也可以:在能上网的机器上 `pnpm dist:server:linux`,把 tar.gz 拷进去,
用 `install.sh --package /path/to/xxx.tar.gz --sha256 <哈希>` 安装。
### 没有 Linux 机器时(交叉构建)
`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64`
按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。
适合本地快速打一个包去验证安装与运维流程。已核对过的结论:
| 项 | 结论 |
| --- | --- |
| SQLite 驱动 | ✅ 包内是 `@libsql/linux-x64-gnu` + `linux-x64-musl`,**没有任何 win32 残留** |
| 服务端本体 | ✅ Node + libsql + express + socket.io 都是纯 JS 或已就位的 Linux 原生包 |
| 运维命令 | ✅ `status` / `doctor` / `password` / `backup` / `upgrade` / `rollback` 都不依赖平台差异 |
| Web 端「一键切换数据库」 | ⚠ **可能失败**:这条链路要调 prisma CLI 的 schema engine,CLI 在运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径,而交叉包里只有宿主平台的 `schema-engine-windows.exe` |
所以交叉包**能装、能跑、能验收**,只是那一个 Web 端功能存疑 —— 它会在包内写入 `.cross-built` 标记,
`classhelper doctor` 会据此提示你(不会静默)。**正式发布一律用 GitHub Actions 那条路。**
```bash
# 开发机(Windows 也可以)
pnpm dist:server:linux
# → release-server/classhelper-server-linux-x64-<版本>.tar.gz(+ .sha256)
# 重跑很快:node scripts/dist-server.mjs --platform linux --reuse-deps(复用已装的 node_modules)
```
---
## 3. 一键安装
### 3.1 交互式(推荐第一次用)
```bash
sudo bash install.sh
```
TUI 会依次问:形态 → 安装目录 → 端口 → 数据库 → 初始管理员密码 → 是否要 Nginx 配置 / 放行防火墙。
每一问都有默认值,直接回车即可。
### 3.2 非交互(自动化 / 批量部署)
```bash
# 全部默认:/opt/classhelper、4000 端口、SQLite、内置 Node
sudo bash install.sh --yes --package ./classhelper-server-linux-x64-1.1.0.tar.gz
# 指定目录与端口,从 stdin 传初始管理员密码(不进 argv / shell history)
printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \
--dir /srv/classhelper --port 8080 --admin-password-stdin \
--package ./classhelper-server-linux-x64-1.1.0.tar.gz
# MySQL 形态
sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \
--mysql-user classhelper --mysql-password-stdin \
--package ./classhelper-server-linux-x64-1.1.0.tar.gz <<< 'MySQL密码'
```
完整参数见 `bash install.sh --help`。要点:
| 参数 | 作用 |
| --- | --- |
| `--package <url|file://路径>` | 安装包地址(默认从 GitHub Release 取最新版) |
| `--sha256 <哈希>` | 强制校验安装包哈希 |
| `--admin-password-stdin` | 从 stdin 读一行作为初始管理员密码 |
| `--node-source bundled|system|tarball` | 内置 Node / 系统 Node / 指定压缩包 |
| `--node-mirror <url>` | Node 下载镜像(默认 `https://npmmirror.com/mirrors/node`) |
| `--node-tarball <url|路径>` | 内网离线时直接用本地 Node 包 |
| `--with-nginx` / `--open-firewall` | 顺带生成反代配置 / 放行端口 |
| `--yes` | 全默认、不再交互 |
| `--check` | 只体检(无需 root) |
| `--force` | 覆盖已有配置;在无 systemd 的机器上只铺文件不装服务 |
**幂等性**:重复执行就是覆盖安装。已有的 `config.env`(含 `JWT_SECRET`)与 `data/` 一律保留,
用户不会被踢下线;端口与数据库形态也沿用旧配置,不会被改回默认值。
### 3.3 安装器做了什么
1. 识别发行版与包管理器,补齐 `curl / tar / gzip / ca-certificates`(只装缺的);
2. 创建系统用户与组 `classhelper`(`--system`,nologin);
3. 建目录、收权限(见下一节);
4. 下载安装包 → 校验 sha256 → 解压铺文件;
5. 下载 Node(默认走 npmmirror 镜像,从 `SHASUMS256.txt` 同时取版本号与哈希);
6. 生成 `/etc/classhelper/config.env`(随机 `JWT_SECRET`,权限 600);
7. 装 systemd 单元、`/usr/local/bin/classhelper`、`/etc/profile.d/classhelper.sh`、logrotate;
8. 启动服务并轮询 `/healthz`,通过后才算安装成功;
9. 全过程写 `/var/log/classhelper/install.log`。
### 3.4 Docker 形态
`install.sh` 选 3(或 `--mode docker`):用安装包里随带的 `Dockerfile` 直接构建镜像
(包的 `node_modules` 已经是 linux 的真实目录,**构建时不需要联网装依赖**,几十秒出镜像),
然后写一份 `/etc/classhelper/compose.yml` 拉起容器。
```bash
classhelper status # 走 docker compose ps / logs
classhelper restart # 重建容器
classhelper password admin # 通过 docker exec 在容器里执行同一份 admin-cli
classhelper backup # 容器内做一致性快照再取出来
```
Docker 形态与直接安装的差异:
| | 直接安装 | Docker |
| --- | --- | --- |
| 数据位置 | `<安装目录>/data` | Docker 卷 `classhelper-data` |
| 配置 | `/etc/classhelper/config.env`(systemd 注入) | 同一份文件(compose `env_file`) |
| 升级 / 回滚 | 替换程序文件后重启服务 | 同一套流程:替换构建上下文 → 重建镜像(`classhelper-server:local`)→ `compose up -d --force-recreate` |
| 恢复数据 | `classhelper backup restore <目录>` | 需手工 `docker cp` 回容器(命令里有提示) |
| `backup schedule` | 支持(systemd timer) | 用宿主机 cron 调 `classhelper backup --quiet` |
> Docker 形态的镜像用**稳定标签** `classhelper-server:local`(另打一个 `classhelper-server:<版本>` 便于追溯)。
> 版本号写在 compose 里的话,升级替换完程序文件后那个标签就过期了,`compose up` 会一直沿用旧镜像 ——
> 表现为"升级成功但行为没变"。`classhelper` 自己按 `VERSION` 决定要不要重建镜像。
---
## 4. 目录、权限与配置
```
/opt/classhelper/ 安装目录(--dir 可改)
├── server/ 后端程序(入口 server/dist/index.js)
│ ├── dist/ prisma/ 产物 + 迁移 SQL(首启动 AUTO_MIGRATE 自动执行)
│ ├── prisma.config.ts tsconfig.generate.json 数据库「一键切换」用
├── web/ Web 管理端(由后端托管)
├── node_modules/ 生产依赖(真实目录,非软链)
├── tools/admin-cli.mjs 离线改密 / 数据库快照工具
├── bin/classhelper 运维命令本体(/usr/local/bin/classhelper 软链到它)
├── runtime/node/ 内置 Node 运行时
├── systemd/ logrotate.d/ profile.d/ 模板(安装时拷到 /etc 下)
├── Dockerfile Docker 形态构建用
├── data/ 数据库 + Web 端自建快照(备份就是拷这里)
├── logs/ 预留
├── VERSION 当前版本
├── .installed.json 安装元数据(形态/版本/端口,classhelper 靠它决策)
└── .env → /etc/classhelper/config.env (软链,见下)
/etc/classhelper/ 目录 700,属主 classhelper
└── config.env 配置文件,**权限 600**,唯一真身
/var/log/classhelper/ 安装日志 install.log + 审计日志 audit.log
/var/backups/classhelper/ 备份 + 程序快照(回滚用)
```
### 4.1 为什么安装目录里的 `.env` 是软链
Web 端「数据库管理 → 一键切换数据库」在运行中改写的是 **`<安装目录>/.env`**
(`packages/server/src/modules/database/database.service.ts`),而 systemd 是通过
`EnvironmentFile=/etc/classhelper/config.env` 注入环境变量的。dotenv 不覆盖已存在的环境变量 →
如果这两处不是同一份文件,**切换看起来成功、重启后又连回旧库**。
所以安装器把 `<安装目录>/.env` 做成指向 `config.env` 的软链:两种读法落到同一个真身。
`classhelper doctor` 会专门检查这条软链,别手工删掉。
### 4.2 配置项
配置就是一份 `KEY=VALUE` 的 env 文件,systemd / dotenv / `source` 三种读法都兼容
(含空白或 `#` 的值请加双引号)。最常见的几项:
| KEY | 默认 | 说明 |
| --- | --- | --- |
| `PORT` / `HOST` | `4000` / `0.0.0.0` | 监听端口与地址 |
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
| `DATABASE_URL` | `file:../data/classhelper.db` | SQLite 相对 `<安装目录>/server` 解析;MySQL 是连接串 |
| `JWT_SECRET` | 随机 | 登录令牌签名密钥。**换掉=所有用户重新登录**(`classhelper key rotate`) |
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
| `CORS_ORIGIN` | `*` | 走域名/反代时建议收敛成 `https://你的域名` |
| `TRUST_PROXY` | `1` | 前面有 Nginx 时保持 1,限流才按真实 IP 统计 |
| `AUTO_MIGRATE` | `true` | 空库首启动自动建表 + 建管理员 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | **只在空库首次初始化时生效**,之后改密码用 `classhelper password` |
| `DEFAULT_CLASS_PASSWORD` / `DEFAULT_TEACHER_PASSWORD` | `123456` | 新建班级 / 重置教师时的初始密码 |
| `TERM_START_DATE` | 空 | 第 1 教学周的周一(`YYYY-MM-DD`),影响"当前周次" |
改配置的三种方式(都会先自动备份、改完问你是否重启):
```bash
sudo classhelper config show # 敏感值打码查看
sudo classhelper config get PORT
sudo classhelper config set CORS_ORIGIN https://class.example.com # 敏感项会改成交互输入
sudo classhelper config edit # 用 $EDITOR 直接编辑
sudo classhelper port 8080 # 改端口的快捷方式
```
> 手工改也可以(`vim /etc/classhelper/config.env`),但记得改完 `classhelper restart`,
> 并确认权限仍是 600。
---
## 5. `classhelper` 运维命令
安装后可直接使用(`/usr/local/bin/classhelper`)。除 `help` / `version` / `completion` 外都需要 root。
### 5.1 查看与诊断
```bash
classhelper status # 服务状态、版本、端口、数据库、各类路径
classhelper status --json # 同上,机器可读(接监控用)
classhelper doctor # 完整体检:权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份
classhelper logs -f # 实时日志(= journalctl -u classhelper -f)
classhelper logs -n 200 --since "1 hour ago"
classhelper logs --unit classhelper-backup # 看定时备份的日志
```
`doctor` 会把每一项标成 `✔` / `✘`,失败项后面直接跟修复建议,例如:
```
✘ 安装目录 .env 是指向配置真身的软链
✔ Node 运行时可用且 ≥20.19(v22.20.0)
✘ 最近 7 天内有备份(无)
建议依次处理:
· ln -sfn /etc/classhelper/config.env /opt/classhelper/.env(Web 端「一键切换数据库」依赖它)
· classhelper backup;长期可用 classhelper backup schedule daily
```
### 5.2 改密码
```bash
sudo classhelper password # 改 admin
sudo classhelper password teacher1 # 改指定教师
sudo classhelper password --list # 列出可登录账号与班级账号(含是否已设密码)
sudo classhelper password --class G101 # 改某个班的班级密码(学生端登录用)
```
安全约定(照要求逐条落实):
- 密码**交互输入、不回显**;不接受命令行传参,因此不会进 shell history、也不出现在 `ps` 里;
- 强度校验:≥8 位,小写/大写/数字/符号至少 3 类,不得与用户名相同,常见弱口令直接拒绝;
需二次确认,连续 3 次不合规即退出(密码不变);
- 改前把**原密码哈希**备份到 `/var/backups/classhelper/password-<时间戳>.json`(权限 600),
写入失败或写入后复核不通过会**自动回滚**原哈希;
- 全过程写审计日志,**审计里只有"谁对谁做了什么、结果如何",没有密码**;
- 自动化可以用管道传入(仍不进 history):
```bash
printf '%s\n%s\n' "$NEW_PW" "$NEW_PW" | sudo classhelper password admin
```
> 顺带说明:改密码不需要重启服务(登录时读库校验);已登录的会话仍有效,
> 要让所有会话立刻失效用 `classhelper key rotate`。
### 5.3 备份与恢复
```bash
sudo classhelper backup # 备份配置 + 数据库
sudo classhelper backup --note "升级前" --keep 7
sudo classhelper backup list
sudo classhelper backup restore 20261005-033000 # 用某个备份覆盖当前配置与数据(会先停服务)
sudo classhelper backup schedule daily # 每天 03:30 自动备份(systemd timer,保留 7 份)
sudo classhelper backup schedule off
```
- 备份落在 `/var/backups/classhelper/<时间戳>[-说明]/`:`config.env` + `classhelper.db` + `meta.json`,
目录 700、文件 600;
- SQLite 用 `VACUUM INTO` 做**一致性快照**(服务在跑也安全,比直接 `cp` 可靠);
- MySQL 用 `mysqldump --single-transaction`(密码经 `MYSQL_PWD` 传给子进程,不进 argv);
- `restore` 前会先自动备份现状,恢复对象是配置与数据(不是程序);
- 程序文件快照(回滚用)在 `/var/backups/classhelper/rollback/`,是另一类东西,`backup list` 会分开列。
### 5.4 升级与回滚
```bash
sudo classhelper upgrade --check # 只检查有没有新版本(查 GitHub Release)
sudo classhelper upgrade # 升到最新版
sudo classhelper upgrade --version 1.2.0 # 升到指定版本
sudo classhelper upgrade --url ./classhelper-server-linux-x64-1.0.1.tar.gz --sha256 <哈希> # 内网离线
sudo classhelper upgrade --url <包> --force # 强制重装同一版本
sudo classhelper rollback --list # 看有哪些程序快照
sudo classhelper rollback # 回滚到最近一份(会问一次确认)
```
`upgrade` 的完整链路(任一步失败都不改动程序文件):
```
解析目标版本 → 下载 → 校验 sha256 → 备份数据与配置 → 快照当前程序文件 →
停服 → 替换程序文件(mv 原子替换,data/ 配置/ 运行时原地不动)→ 起服 → 轮询 /healthz
↘ 失败:自动用刚才的快照回滚并重新起服,再告诉你失败原因
```
两点必须知道:
1. **升级不会回滚数据库结构**。如果新版执行过迁移,回滚程序前要确认旧版还能读新库,
必要时用 `classhelper backup restore` 连数据一起回退;
2. 替换程序文件用的是"改名 + 就位"而不是删除重写 —— 升级时正在运行的就是 `bin/classhelper` 自己,
必须先改名再放新文件,运行中的进程继续读旧 inode,否则会读到半个脚本。
### 5.5 服务与配置
```bash
sudo classhelper start | stop | restart | reload # reload 与 restart 同义(服务端不支持热重载)
sudo classhelper port # 看当前端口
sudo classhelper port 8080 # 改端口(顺带提示放行防火墙)
sudo classhelper config show [--reveal]
sudo classhelper config set KEY VALUE
sudo classhelper key rotate # 轮换 JWT_SECRET(所有用户需重新登录)
```
### 5.6 其它
```bash
classhelper version
classhelper help
source <(classhelper completion bash) # 补全(安装时已装到 /usr/share/bash-completion)
```
### 5.7 卸载
```bash
sudo classhelper uninstall
```
会依次问:保留配置?保留数据?保留备份?默认全部保留 —— 数据与配置会被**搬到**
`/var/backups/classhelper/uninstall-<时间戳>/`,然后才删除程序目录,不会悄悄丢数据。
```bash
sudo classhelper uninstall --yes # 不问,用默认(保留配置/数据/备份)
sudo classhelper uninstall --purge # 全删(含备份与数据),不可恢复
```
---
## 6. 环境变量文件
安装器会写 `/etc/profile.d/classhelper.sh`(内容见
[`deploy/profile.d/classhelper.sh`](../deploy/profile.d/classhelper.sh)):
```sh
export CLASSHELPER_HOME=/opt/classhelper
export CLASSHELPER_CONFIG=/etc/classhelper/config.env
export CLASSHELPER_LOG_DIR=/var/log/classhelper
export CLASSHELPER_BACKUP_DIR=/var/backups/classhelper
export PATH=$PATH:$CLASSHELPER_HOME/bin
```
新登录的 shell 自动生效;当前会话要手工 `source /etc/profile.d/classhelper.sh`。
`classhelper` 命令本身装在 `/usr/local/bin`(软链),所以**不登录也照样能用**。
---
## 7. 反向代理与 HTTPS(公网部署必做)
```bash
sudo bash install.sh --with-nginx # 或者安装后手工:
sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
```
要点:
- `client_max_body_size 12m`(表格导入最大 8MB,base64 后约 11MB);
- Socket.IO 需要 `Upgrade` / `Connection` 头与较长的 `proxy_read_timeout`(模板里已配);
- 配置里保持 `TRUST_PROXY=1`,并把 `CORS_ORIGIN` 收敛成你的域名;
- 证书用 certbot 或你自己的证书,装好后 `nginx -t && nginx -s reload`。
---
## 8. 故障排查
| 现象 | 先查什么 |
| --- | --- |
| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 |
| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 |
| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) |
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
| `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` |
| 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) |
| 学生端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 |
| CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" |
---
## 9. 已知限制
### CentOS 7 / glibc < 2.28
Node 官方的 linux-x64 二进制要求 **glibc ≥ 2.28**,而 CentOS 7 是 2.17 —— 直接装官方包会表现成
"装完服务立刻退出、日志里什么也没有"。安装器会自动检测 glibc,低于 2.28 时改用
[unofficial-builds](https://unofficial-builds.nodejs.org/) 的 **glibc-217** 构建(功能一致,只是编译基线更低)。
但即便这样,个别原生依赖仍可能不兼容,CentOS 7 支持属于**尽力而为**:
- CentOS 7 已于 2024-06 EOL,**新部署请用 Rocky Linux 9 / AlmaLinux 9 / Ubuntu 22.04+**;
- 若坚持用 CentOS 7,建议装完立刻跑一遍 `classhelper doctor` 与本文第 10 节的验收清单。
### 其它
- 直接安装形态要求 **systemd**(容器里没有 `/run/systemd/system` 时会明确拒绝;
容器场景请用 Docker 形态)。
- Docker 形态的数据恢复需要手工 `docker cp`(数据在容器卷里,宿主机上看不到那个文件);
`classhelper backup restore` 会给出具体命令。
- `classhelper reload` 等价于 restart(服务端没有 SIGHUP 处理)。
- 升级包只发 x64;arm64 需要自行构建(`node scripts/dist-server.mjs --platform linux` 跑在 arm64 机器上)。
---
## 10. 验收清单
下面这套在干净的 **Ubuntu 22.04** 与 **Rocky Linux 9** 上各跑一遍。
`deploy/verify-linux.sh` 会把第 3~8 步自动跑完并打印 `=== 结果:N/N 项通过 ===`。
### 10.0 准备
```bash
# 在开发机:做出 Linux 包(或在 Actions 里下载)
pnpm dist:server:linux
scp release-server/classhelper-server-linux-x64-*.tar.gz root@<服务器>:/tmp/
scp deploy/install.sh deploy/verify-linux.sh root@<服务器>:/tmp/
```
### 10.1 体检(不需要 root)
```bash
bash /tmp/install.sh --check
```
期望:系统/包管理器/glibc/docker/端口/磁盘逐项列出,末尾「体检通过」。
端口被占用、缺 curl、没有 systemd 都会在这里被点出来。
### 10.2 安装
```bash
sudo bash /tmp/install.sh --yes \
--package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz \
--admin-password-stdin <<< 'Verify-Pass-2026!'
```
期望:末尾出现「安装完成」与「服务已就绪 ✅」,并打印管理端地址。
### 10.3 自动验收
```bash
sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'Verify-Pass-2026!'
```
覆盖项:
| 分组 | 检查内容 |
| --- | --- |
| 安装结果 | 程序文件、`VERSION`、`.installed.json`、`config.env` 600、配置目录 700、`.env` 软链、内置 Node、`/usr/local/bin/classhelper`、安装/审计日志、profile.d 生效 |
| 服务 | systemd 单元、active、enabled、`/healthz` 200、`status --json` 合法且 healthy |
| 体检 | `classhelper doctor` 无待处理项、`help` / `completion` 可用 |
| API | 用初始密码登录拿 token、带 token 读 `/api/classes` 与 `/api/auth/me` |
| 改密 | 改密成功 → 新密码可登录 → **旧密码被拒** → 原哈希已备份且 600 → 审计有记录且**不含密码明文** → 弱密码被拒 → stdin 结束不卡死 → `restore-hash` 把原密码回滚回来 |
| 备份 | 备份目录/文件/权限、SQLite 快照是合法库文件、`backup list` |
| 配置 | `config get/show`、敏感值打码、读写往返 |
| key rotate | 密钥确实变了且 ≥32 位、服务仍可用、**旧 token 变 401**、重新登录成功 |
追加升级/回滚链路(会真的替换一次程序文件):
```bash
sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x64-1.0.1.tar.gz \
--admin-password-stdin <<< 'Verify-Pass-2026!'
```
覆盖:升级成功 → 服务恢复 → 配置未被改动 → 产生程序快照 → **坏哈希被拒绝且服务不受影响** →
`rollback` 回到旧版且服务可用。
### 10.4 手工补验(自动化覆盖不到的)
```bash
# ① 浏览器打开 http://<IP>:4000/ ,用 admin 登录,改一次密码再登录
# ② 学生端客户端填 http://<IP>:4000 用班级码 G101/123456 登录,收到一条老师发的通知
# ③ 重跑安装器(幂等性):sudo bash /tmp/install.sh --yes --package <同一个包>
# 期望:配置与数据保留、JWT_SECRET 不变(已登录用户不被踢下线)
# ④ 卸载:sudo classhelper uninstall --yes
# 期望:服务停止、单元删除、/usr/local/bin/classhelper 消失、
# 数据与配置被搬到 /var/backups/classhelper/uninstall-*/
# ⑤ 重启机器后服务自动起来(systemctl is-enabled classhelper)
```
### 10.5 Rocky 9 的差异点
- 包管理器走 `dnf`,依赖包名与 apt 不同(`install.sh` 已按 `PKG` 分支处理);
- 默认没有 `curl` 之外的工具时会被补装,注意首次安装需要能访问 dnf 源;
- 防火墙默认是 `firewalld`:用 `--open-firewall` 或手工
`firewall-cmd --add-port=4000/tcp --permanent && firewall-cmd --reload`;
- SELinux(Enforcing):服务读写 `/opt/classhelper` 与 `/etc/classhelper` 一般不受影响,
若日志里出现 `Permission denied` 且路径正常,先 `ausearch -m avc -ts recent` 看是不是 SELinux,
可以用 `semanage fcontext` 加规则,或临时 `setenforce 0` 验证判断。
---
## 11. 安全清单
- [ ] 改掉默认管理员密码:`classhelper password admin`(**公网部署第一件事**)
- [ ] 公网服务前面加 Nginx + HTTPS,`CORS_ORIGIN` 收敛到具体域名
- [ ] 定期备份:`classhelper backup schedule daily`,并确认备份能恢复(`backup restore` 演练一次)
- [ ] 配置与备份权限:`config.env` 600、`/etc/classhelper` 700、备份目录 700
- [ ] `classhelper doctor` 无待处理项;关注"最近 7 天内有备份"
- [ ] 审计日志(`/var/log/classhelper/audit.log`)随日志轮转保留,必要时接集中日志
- [ ] 只在需要时使用 `--purge`;卸载默认保留数据与备份
- [ ] 升级包一律校验 sha256(`upgrade` 内网分发时也要带 `--sha256`)
+53 -9
View File
@@ -19,28 +19,62 @@ MySQL 兼容方式设计,可平滑切换到云端 MySQL / MariaDB 统一存储
| 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 | | 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 |
| 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 | | 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 |
## 切换步骤 ## 切换到 MySQL
### 1. 修改 provider ### 方式一:Web 管理端「数据库管理 → 一键切换」(推荐,v1.0.0 新增)
管理员在 Web 管理端打开 **数据库管理** 页(`/database`,仅 ADMIN 可见),在「一键切换数据库」卡片里:
1. 选目标类型(SQLite ⇄ MySQL)并填**空库**连接串(有表的库会被拒绝,防误覆盖);
2. 输入「确认迁移」→ 点「开始迁移」。
界面会自动依次执行(带步骤进度条):
1. **备份当前数据库**(gzip JSON 快照存 `<数据目录>/backups/`,失败可回滚);
2. 导出当前数据为快照;
3. 测试目标库连接;
4. 检查目标库为空;
5. 改写 `schema.prisma` 的 provider;
6. 子进程 `prisma generate` 生成新方言的 Prisma 客户端;
7. (打包形态)把生成的 `.ts` 客户端编译进 `dist/generated`;
8. 子进程 `prisma db push` 在目标库建表;
9. 子进程 `dist/tools/apply-snapshot.js` 把快照写入目标库;
10. 全部成功后改写 `.env` 的 `DATABASE_PROVIDER` / `DATABASE_URL`。
**完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库。
中途任一步失败会**回滚 `schema.prisma`**,`.env` 不被改写 —— 现有数据库与配置保持可用。
> 同一页还提供:连接状态检测(版本 / 体积 / 各表行数)、连接测试、手动与**定时备份**、
> 备份恢复、快照导出导入、SQLite 数据库文件下载。备份与导入导出共用同一种 JSON 快照格式,
> 因此**任意备份都能恢复回任意一种受支持的数据库**。
>
> 只支持 SQLite 与 MySQL 两种主库:Redis 等键值库不是 Prisma 支持的主数据库(无法建表/迁移),
> 接口层直接拒绝。
### 方式二:命令行手工切换
#### 1. 修改 provider
```bash ```bash
# 自动把 prisma/schema.prisma 的 provider 改成 mysql # 自动把 prisma/schema.prisma 的 provider 改成 mysql
node scripts/use-database.mjs mysql node scripts/use-database.mjs mysql
``` ```
### 2. 安装 MySQL 驱动适配器 #### 2. MySQL 驱动适配器(自 v1.0.0 起已随包内置)
Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb` Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb`
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。 (该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。**它已在 `packages/server/package.json` 的
依赖里**(数据库管理模块的连接测试/迁移需要),因此无需再手动安装:
```bash ```bash
# 仅当从旧版本升级、依赖里还没有时才需要
pnpm --filter @classhelper/server add @prisma/adapter-mariadb pnpm --filter @classhelper/server add @prisma/adapter-mariadb
``` ```
`packages/server/src/lib/db.ts` 已经内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器, `packages/server/src/lib/db.ts` 内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
因此只需要安装依赖 + 配置环境变量,服务端代码无需改动。 服务端代码无需改动。
### 3. 配置环境变量 #### 3. 配置环境变量
编辑 `packages/server/.env`: 编辑 `packages/server/.env`:
@@ -52,7 +86,7 @@ DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper"
> 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址; > 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址;
> 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。 > 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。
### 4. 重建迁移并生成客户端 #### 4. 重建迁移并生成客户端
SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**: SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**:
@@ -71,7 +105,11 @@ pnpm db:seed
pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql
``` ```
### 5. 回到 SQLite > 只搬数据、不要演示数据时,用界面的「下载快照 / 导入快照」更省事:
> 旧库导出 JSON → 切库建表 → 新库导入,一份快照也可用 CLI 迁移:
> `node server/dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json`
#### 5. 回到 SQLite
```bash ```bash
node scripts/use-database.mjs sqlite node scripts/use-database.mjs sqlite
@@ -80,6 +118,8 @@ rm -rf packages/server/prisma/migrations # 若之前已切换成 MySQL 迁移
pnpm db:migrate pnpm db:migrate
``` ```
> 用界面「一键切换」回 SQLite 时,目标库同样必须是空文件:删除旧 `.db` 或指定一个新路径。
## 生产部署注意事项 ## 生产部署注意事项
1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置, 1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置,
@@ -88,3 +128,7 @@ pnpm db:migrate
三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。 三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。
3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。 3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。
4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。 4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。
界面「定时备份」把快照写进 `<数据目录>/backups/`(MySQL 模式下即安装根 `data/backups/`),
**服务端进程不运行时不会补跑**;关键环境建议同时用数据库自带的备份策略。
5. **安装包体积**:切换链路需要随包内置 `prisma` CLI 与 `typescript` 编译器(Prisma 7 生成器产出
`.ts`,切换后需在目标机编译进 `dist/generated`),服务端安装包因此约 34MB → 71MB。
+173 -22
View File
@@ -2,11 +2,12 @@
本文档覆盖三种上线形态,按适用场景选择: 本文档覆盖三种上线形态,按适用场景选择:
| 形态 | 适用场景 | 产物 | 本机验证情况 | | 形态 | 适用场景 | 产物 | 本机验证情况 |
| --------------------- | -------------------------------------------- | ------------------------------------------------------ | -------------------------------------- | | --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) | | **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 | | **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **C. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(54 项端到端) | | **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](../deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
--- ---
@@ -24,7 +25,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
| 路径 | 说明 | | 路径 | 说明 |
| ----------------------------------------------------- | ----------------------------------------------- | | ----------------------------------------------------- | ----------------------------------------------- |
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 | | `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
| `release-server/班级小助手服务端-0.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) | | `release-server/班级小助手服务端-1.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) |
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、 打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。 生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
@@ -34,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
双击安装程序即可,**无需管理员权限**(当前用户级安装): 双击安装程序即可,**无需管理员权限**(当前用户级安装):
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer` - 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
- 静默安装:`"班级小助手服务端-0.1.0-x64-setup.exe" /S` - 静默安装:`"班级小助手服务端-1.1.0-x64-setup.exe" /S`
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
- 桌面:`班级小助手服务端.lnk`(打开管理端) - 桌面:`班级小助手服务端.lnk`(打开管理端)
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`) - 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
@@ -124,9 +125,154 @@ docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
- 反向代理上启用 HTTPS:见 `deploy/nginx.conf`,并在服务端环境变量中设置 - 反向代理上启用 HTTPS:见 `deploy/nginx.conf`,并在服务端环境变量中设置
`TRUST_PROXY=1`、`CORS_ORIGIN=https://你的域名`。 `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,推荐一键安装器)
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky / AlmaLinux,任何有 systemd 的服务器
(容器环境用下面的 Docker 形态)。**完整指南见 [linux-deploy.md](linux-deploy.md)**,这里是速查。
### 3.1 一键安装(推荐)
Linux 包由 GitHub Actions 在 ubuntu runner 上构建(平台相关的依赖只能在 Linux 上装),
产物挂在 Release 上;也可以本地 `pnpm dist:server:linux` 后把 tar.gz 拷到服务器。
安装器脚本两个文件都在仓库的 `deploy/` 下:
```bash
# 开发机:做出 Linux 包并连同脚本一起拷到服务器
pnpm dist:server:linux
scp release-server/classhelper-server-linux-x64-*.tar.gz deploy/install.sh deploy/verify-linux.sh \
root@<服务器>:/tmp/
# 服务器上
bash /tmp/install.sh --check # 只体检(不需要 root,不改动系统)
sudo bash /tmp/install.sh # 交互式:选形态 → 选目录 → 选库 → 设初始管理员密码
```
非交互(批量部署 / 验收用):
```bash
printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
--dir /srv/classhelper --port 8080 --admin-password-stdin \
--package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz
```
安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 →
铺文件(`server/` `web/` `node_modules/` `tools/` `bin/`)→ 下载内置 Node 运行时 →
写 `/etc/classhelper/config.env`(随机 `JWT_SECRET`,权限 600)→ 装 systemd 单元与
`/usr/local/bin/classhelper` → 启动并轮询 `/healthz` 通过为止。全过程写
`/var/log/classhelper/install.log`。
重跑就是**覆盖安装**:`config.env`(含密钥)与 `data/` 保留,已登录用户不会被踢下线。
### 3.2 安装后的布局与运维
```
/opt/classhelper/ 程序、node_modules、内置 Node、data/、tools/、bin/
/etc/classhelper/config.env 唯一配置真身(600);安装目录里的 .env 是指向它的软链
/var/log/classhelper/ install.log + audit.log
/var/backups/classhelper/ 备份与程序快照(回滚用)
```
> 为什么安装目录的 `.env` 是软链:Web 端「数据库管理 → 一键切换」改写的是 `<安装目录>/.env`,
> 而 systemd 用 `EnvironmentFile=` 注入同一份配置;两处不是同一个文件时,切换会"看起来成功、
> 重启后又连回旧库"。软链把两者收成一份真身(`classhelper doctor` 会检查它)。
日常运维全部走 `classhelper`(`classhelper help` 看全部):
| 事项 | 命令 |
| ------ | --------------------------------------------------------------------------------------- |
| 状态 | `classhelper status [--json]` |
| 体检 | `classhelper doctor`(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) |
| 改密码 | `classhelper password [用户名]`(交互输入、不回显、不进 shell history) |
| 备份 | `classhelper backup`;每日自动:`classhelper backup schedule daily` |
| 升级 | `classhelper upgrade [--check]`(下载 + 校验 sha256 + 备份 + 替换 + 探针,失败自动回滚) |
| 回滚 | `classhelper rollback [--list]` |
| 日志 | `classhelper logs -f` |
| 改配置 | `classhelper config set KEY VALUE` / `config show` / `port 8080` |
| 换密钥 | `classhelper key rotate`(所有用户需重新登录) |
| 卸载 | `classhelper uninstall`(默认保留配置、数据与备份) |
| 服务 | `classhelper start|stop|restart|reload` |
### 3.3 手动部署(不想用安装器 / 没有 systemd)
没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程:
```bash
sudo bash /tmp/install.sh --yes --force --package /tmp/classhelper-server-linux-x64-1.1.0.tar.gz
sudo -u classhelper /opt/classhelper/runtime/node/bin/node /opt/classhelper/server/dist/index.js
```
完全手工也可以(等价步骤,便于排查):解包后把 `server/` `web/` `node_modules/` `tools/` 铺到安装目录,
配置写到 `/etc/classhelper/config.env`(`JWT_SECRET` 用 `openssl rand -hex 48`),再用
`deploy/classhelper.service` 模板生成单元 —— 注意模板有 5 个占位符
(`__NODE__` `__DIR__` `__USER__` `__CONFIG__` `__CONFIGDIR__`):
```bash
sudo sed -e "s#__NODE__#/opt/classhelper/runtime/node/bin/node#" \
-e "s#__DIR__#/opt/classhelper#" -e "s#__USER__#classhelper#" \
-e "s#__CONFIG__#/etc/classhelper/config.env#" -e "s#__CONFIGDIR__#/etc/classhelper#" \
deploy/classhelper.service > /tmp/classhelper.service
sudo install -m 644 /tmp/classhelper.service /etc/systemd/system/classhelper.service
sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
```
### 3.4 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` 透传(配合配置里的 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。
用 certbot 签证书:
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d class.example.com
```
### 3.5 验收
```bash
sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'StrongPass!2026'
# 追加升级 / 回滚链路(会真的替换一次程序文件):
sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x64-1.0.1.tar.gz \
--admin-password-stdin <<< 'StrongPass!2026'
```
覆盖安装结果、服务探针、API 登录、改密(含审计与回滚)、备份、配置读写、key rotate、
升级与回滚整条链路,末尾打印 `=== 结果:N/N 项通过 ===`。逐条清单见
[linux-deploy.md](linux-deploy.md) 第 10 节。
---
## 四、手动部署(形态 D)
```bash ```bash
pnpm install pnpm install
@@ -140,7 +286,7 @@ pnpm dist:server -- --reuse-deps # 得到 release-server/classhelper-server
```cmd ```cmd
start.cmd :: Windows start.cmd :: Windows
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node) # 或直接:node server/dist/index.js (Linux/macOS 用系统 node,见上面的形态 C)
``` ```
数据库初始化二选一: 数据库初始化二选一:
@@ -150,7 +296,7 @@ start.cmd :: Windows
--- ---
## 四、环境变量清单 ## 五、环境变量清单
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
| --------------------------------------------------- | ---------------------- | --------------------------------------------------------- | | --------------------------------------------------- | ---------------------- | --------------------------------------------------------- |
@@ -168,15 +314,16 @@ start.cmd :: Windows
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 | | `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) | | `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | | `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增学生的默认密码 | | `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 |
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 | | `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 | | `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 |
--- ---
## 五、运维 ## 六、运维
### 5.1 探针与监控 ### 6.1 探针与监控
| 端点 | 用途 | | 端点 | 用途 |
| ------------- | ---------------------------------------------------- | | ------------- | ---------------------------------------------------- |
@@ -184,22 +331,22 @@ start.cmd :: Windows
| `/readyz` | 就绪探针:数据库可连才 200,否则 503(负载均衡摘流) | | `/readyz` | 就绪探针:数据库可连才 200,否则 503(负载均衡摘流) |
| `/api/health` | 详情:环境、数据库、已挂载模块、运行时长 | | `/api/health` | 详情:环境、数据库、已挂载模块、运行时长 |
### 5.2 日志 ### 6.2 日志
- 输出到 stdout/stderr,安装包形态由启停脚本重定向到 `logs\server.log`(UTF-8)。 - 输出到 stdout/stderr,安装包形态由启停脚本重定向到 `logs\server.log`(UTF-8)。
- 访问日志含方法、路径、状态码与耗时;5xx 记 error、4xx 记 warn。 - 访问日志含方法、路径、状态码与耗时;5xx 记 error、4xx 记 warn。
- 容器形态建议交给 Docker 日志驱动或采集到 ELK/Loki。 - 容器形态建议交给 Docker 日志驱动或采集到 ELK/Loki。
### 5.3 备份与恢复 ### 6.3 备份与恢复
| 形态 | 备份 | 恢复 | | 形态 | 备份 | 恢复 |
| ------ | ------------------------------------------------------ | ------------------------ | | ------ | ---------------------------------------------------------------------- | ------------------------ |
| SQLite | 停止服务后复制 `data/classhelper.db` | 替换该文件后启动 | | SQLite | 停止服务后复制 `data/classhelper.db`(Linux:`/opt/classhelper/data`) | 替换该文件后启动 |
| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` | | MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` |
建议:每日自动备份 + 保留 7 份;变更 `JWT_SECRET` 会导致所有用户重新登录,请勿在无必要时更换。 建议:每日自动备份 + 保留 7 份;变更 `JWT_SECRET` 会导致所有用户重新登录,请勿在无必要时更换。
### 5.4 安全清单(上线前逐项确认) ### 6.4 安全清单(上线前逐项确认)
- [ ] 修改 `admin` 与教师账号的默认密码 - [ ] 修改 `admin` 与教师账号的默认密码
- [ ] `JWT_SECRET` 使用随机值(≥48 字节),且妥善保管 - [ ] `JWT_SECRET` 使用随机值(≥48 字节),且妥善保管
@@ -212,7 +359,7 @@ start.cmd :: Windows
--- ---
## 六、故障排查 ## 七、故障排查
| 现象 | 排查方向 | | 现象 | 排查方向 |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -226,10 +373,14 @@ start.cmd :: Windows
| 升级后需要重新登录 | 说明 `.env` 被覆盖(JWT 密钥变化);覆盖安装应保留 `.env`,请检查安装目录权限 | | 升级后需要重新登录 | 说明 `.env` 被覆盖(JWT 密钥变化);覆盖安装应保留 `.env`,请检查安装目录权限 |
| 报 `does not provide an export named ...` | 安装包内的 `node_modules/@classhelper/shared/dist` 是旧构建(打包机没有先构建 shared)。重新执行 `pnpm dist:server`,该脚本会先构建 shared/server/web 并校验产物新鲜度 | | 报 `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 版本 | | 报 `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 项接口与实时推送全部达标。 - 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播, - 多校区 / 高并发建议切 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/**', '**/dist-electron/**',
'**/release/**', '**/release/**',
'**/release-server/**', '**/release-server/**',
// 交付产物归集目录(pnpm dist:* 的输出,见 AGENTS.md §4):里面是构建后的压缩 JS,不该进 lint
'**/releases/**',
'**/build/**', '**/build/**',
'**/coverage/**', '**/coverage/**',
'**/src/generated/**', '**/src/generated/**',
+9 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "class-helper", "name": "class-helper",
"version": "0.1.0", "version": "1.1.0",
"private": true, "private": true,
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
@@ -15,6 +15,8 @@
"dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev", "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": "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:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
"check:icons": "node scripts/check-icons.mjs",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck", "typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
@@ -30,11 +32,16 @@
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e", "verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke", "verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
"verify:web": "node scripts/ui-smoke/run.mjs", "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", "icons": "node scripts/generate-icons.mjs",
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win", "dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir", "dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs", "dist: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:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.5", "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: 1.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 = "1.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));
}
@@ -2,10 +2,27 @@ appId: com.classhelper.desktop
productName: 班级小助手 productName: 班级小助手
copyright: Copyright © 2026 Class Helper copyright: Copyright © 2026 Class Helper
# 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后),
# 而本应用界面文案只有中文,其余 54 个语言包纯属白占体积(`win-unpacked` 372MB → 约 324MB)。
# 这是 electron-builder 的官方选项:`app-builder-lib` 的 removeUnusedLanguagesIfNeeded()
# 会对 `locales/*.pak` 做精确匹配删除,只留 `zh-CN.pak`(0.6MB)。
#
# 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件,
# 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退,
# 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。
# 另外安装包有硬底:`班级小助手.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
electronLanguages:
- zh-CN
directories: directories:
output: release output: release
buildResources: build buildResources: build
# 注意:这里**不要**加 afterPack 去改 appOutDir 的完整性标签。
# 归档内部的载荷标签不会被还原(装到正常位置拿到的是默认 Medium 文件),
# 而在打包前把 appOutDir 改成 Medium 会让 7za(自身带 Low 标签、进程被降到 Low)
# 因"不能向上读"而无法生成归档。交付物的标签统一在 scripts/dist-win.mjs 收尾时修正。
# 渲染进程产物已由 Vite 打包、主进程/preload 已由 esbuild 打包, # 渲染进程产物已由 Vite 打包、主进程/preload 已由 esbuild 打包,
# 运行时不需要任何 node_modules(electron 模块由 Electron 运行时提供)。 # 运行时不需要任何 node_modules(electron 模块由 Electron 运行时提供)。
files: files:
+18 -1
View File
@@ -3,7 +3,24 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 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> <title>班级小助手</title>
</head> </head>
<body> <body>
+5
View File
@@ -3,6 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 与主窗口同一套 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> <title>班级小助手 · 灵动岛</title>
<style> <style>
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */ /* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
+3 -3
View File
@@ -1,15 +1,16 @@
{ {
"name": "@classhelper/desktop-client", "name": "@classhelper/desktop-client",
"version": "0.1.0", "version": "1.1.0",
"private": true, "private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper", "author": "Class Helper",
"main": "dist/main/index.js", "main": "dist/main/index.js",
"scripts": { "scripts": {
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"build": "npm run build:main && npm run build:renderer", "build": "node ../../scripts/check-icons.mjs && npm run build:main && npm run build:renderer && npm run check:secrets",
"build:main": "node scripts/build-main.mjs", "build:main": "node scripts/build-main.mjs",
"build:renderer": "vite build", "build:renderer": "vite build",
"check:secrets": "node scripts/check-bundle-secrets.mjs",
"start": "electron .", "start": "electron .",
"smoke": "node scripts/smoke.mjs", "smoke": "node scripts/smoke.mjs",
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
@@ -20,7 +21,6 @@
"@classhelper/shared": "workspace:*", "@classhelper/shared": "workspace:*",
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.20.0", "axios": "^1.20.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.5", "element-plus": "^2.14.5",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
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 # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked * 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -24,8 +24,17 @@ const args = process.argv.slice(2);
const dirOnly = args.includes('--dir'); const dirOnly = args.includes('--dir');
const passthrough = args.filter((item) => item !== '--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 = { const env = {
...process.env, ...process.env,
TEMP: tmpDir,
TMP: tmpDir,
ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/', ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/',
ELECTRON_BUILDER_BINARIES_MIRROR: ELECTRON_BUILDER_BINARIES_MIRROR:
process.env.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(); const runtime = resolveNodeRuntime();
console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`); 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 }); 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) => { child.on('exit', (code) => {
if (code === 0) { if (code === 0) {
const output = path.join(root, 'release'); const output = path.join(root, 'release');
normalizeIntegrityLabel(output);
console.log('\n[dist] 打包完成,产物目录:', output); console.log('\n[dist] 打包完成,产物目录:', output);
} }
process.exit(code ?? 1); process.exit(code ?? 1);
+184 -16
View File
@@ -26,9 +26,15 @@ if (missing.length > 0) {
console.log('[smoke] 启动 Electron 冒烟验证...\n'); console.log('[smoke] 启动 Electron 冒烟验证...\n');
// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出) // 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)。
const profileDir = path.join(repoRoot, '.cache', 'desktop-smoke-profile'); // 目录按进程号区分:上一次冒烟若被强杀,目录可能仍被句柄占用(rmSync 会 ENOTEMPTY),
fs.rmSync(profileDir, { recursive: true, force: true }); // 用独立目录可以彻底避免互相干扰。
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}`); console.log(`[smoke] 独立配置目录:${profileDir}`);
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试 // 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
@@ -51,17 +57,179 @@ if (online === undefined) {
); );
} }
const child = spawn(electronPath, ['.'], { /**
cwd: root, * 准备一个已知可用的班级账号(学生端已改为班级码 + 班级密码登录):
stdio: 'inherit', * 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
env: resolveElectronEnv({ * 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
ELECTRON_SMOKE_TEST: '1', * 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
ELECTRON_SMOKE_ONLINE: online, */
ELECTRON_SMOKE_PROFILE: profileDir, 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) => { const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
console.log(`\n[smoke] Electron 退出,code=${code ?? 1}`); const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders });
process.exit(code ?? 1); 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));
}
+344 -22
View File
@@ -1,8 +1,26 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { app, safeStorage } from 'electron'; import { app, safeStorage } from 'electron';
import { DEFAULT_SERVER_URL } from '@classhelper/shared'; import {
import type { DesktopStoredConfig } from '../types/desktop.js'; 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 { interface PersistedConfig {
serverUrl: string; serverUrl: string;
@@ -10,6 +28,22 @@ interface PersistedConfig {
/** 加密后的 token(base64)或明文(当系统不支持加密时) */ /** 加密后的 token(base64)或明文(当系统不支持加密时) */
token: string | null; token: string | null;
tokenEncrypted: boolean; tokenEncrypted: boolean;
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
island?: Partial<IslandAppearance>;
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
homeworkBoard?: Partial<HomeworkBoardSettings>;
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[];
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
onboardingDone?: boolean;
/** 界面主题(向后兼容:旧配置没有该字段用浅色) */
theme?: 'light' | 'dark';
/** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */
sidebarCollapsed?: boolean;
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
ignoredUpdateVersion?: string;
} }
const DEFAULT_CONFIG: PersistedConfig = { const DEFAULT_CONFIG: PersistedConfig = {
@@ -17,8 +51,104 @@ const DEFAULT_CONFIG: PersistedConfig = {
username: '', username: '',
token: null, token: null,
tokenEncrypted: false, tokenEncrypted: false,
island: { ...DEFAULT_ISLAND_APPEARANCE },
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
onboardingDone: false,
theme: 'light',
sidebarCollapsed: false,
ignoredUpdateVersion: '',
}; };
/** 主题白名单:非法值一律回落浅色 */
function normalizeTheme(input?: 'light' | 'dark'): 'light' | 'dark' {
return input === 'dark' ? 'dark' : 'light';
}
/**
* 作业快捷短语的合法化:去空白、去重、限长限量。
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
*/
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 { function configFilePath(): string {
return path.join(app.getPath('userData'), 'config.json'); return path.join(app.getPath('userData'), 'config.json');
} }
@@ -30,8 +160,15 @@ function encryptToken(token: string | null): { token: string | null; tokenEncryp
return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true }; return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true };
} }
} catch { } catch {
// 某些环境下 DPAPI 不可用,降级为明文存储(仅本地开发场景) // 见下方降级分支
} }
// 降级为明文存储(DPAPI 不可用,一般只在本地开发环境出现)。
// 必须留下日志:明文 token 代表该班的全班身份,任何能读用户目录的进程/备份/同步盘都能拿到,
// 静默降级会让"为什么换个环境就得重新登录/凭据是怎么泄露的"无从查起。
logger.warn(
`系统加密存储不可用,登录令牌将以**明文**写入配置(${configFilePath()});` +
'如需加密请确认 Windows 凭据保护(DPAPI)可用',
);
return { token, tokenEncrypted: false }; return { token, tokenEncrypted: false };
} }
@@ -45,64 +182,249 @@ function decryptToken(raw: PersistedConfig): string | null {
} }
} }
function readPersisted(): PersistedConfig { function readPersistedFromDisk(): PersistedConfig {
try { try {
const content = fs.readFileSync(configFilePath(), 'utf8'); const content = fs.readFileSync(configFilePath(), 'utf8');
const parsed = JSON.parse(content) as Partial<PersistedConfig>; const parsed = JSON.parse(content) as Partial<PersistedConfig>;
return { return {
serverUrl: serverUrl:
typeof parsed.serverUrl === 'string' && parsed.serverUrl typeof parsed.serverUrl === 'string' && parsed.serverUrl
? parsed.serverUrl ? (normalizeServerUrl(parsed.serverUrl) ?? DEFAULT_CONFIG.serverUrl)
: DEFAULT_CONFIG.serverUrl, : DEFAULT_CONFIG.serverUrl,
username: typeof parsed.username === 'string' ? parsed.username : '', username: typeof parsed.username === 'string' ? parsed.username : '',
token: typeof parsed.token === 'string' ? parsed.token : null, token: typeof parsed.token === 'string' ? parsed.token : null,
tokenEncrypted: parsed.tokenEncrypted === true, tokenEncrypted: parsed.tokenEncrypted === true,
island: normalizeIslandAppearance(parsed.island),
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
onboardingDone: parsed.onboardingDone === true,
theme: normalizeTheme(parsed.theme),
sidebarCollapsed: parsed.sidebarCollapsed === true,
ignoredUpdateVersion:
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
}; };
} catch { } catch {
return { ...DEFAULT_CONFIG }; return { ...DEFAULT_CONFIG };
} }
} }
function writePersisted(config: PersistedConfig): void { /* ------------------------------------------------------------ 内存缓存 + 异步合并写
const target = configFilePath(); *
fs.mkdirSync(path.dirname(target), { recursive: true }); * 为什么不能每次 saveConfig 都同步读写盘(原来的写法):
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8'); * 侧栏折叠(`stores/ui.ts` 的 toggleSidebar)、主题切换、通知渠道、作业看板偏好、登录/登出
* 全都走 `saveConfig`,而它一次调用要做「readFileSync + JSON.parse + 8 个 normalize」**两遍**
* (开头读一遍、结尾 `getConfig()` 又读一遍)+ `writeFileSync` 一遍 + DPAPI 解密一遍,
* **全部同步跑在主进程主线程上**。主进程是 Electron 的消息泵进程,被它摁住的那一拍
* 渲染进程也跟着掉帧 —— 用户反馈的"点汉堡展开/收回侧栏时卡一下"就是这一拍。
*
* 现在:配置常驻内存(连已解密的 token 一起缓存),写盘改成**异步 + 合并** ——
* 同一拍内的多次改动只落最后一次,写入串行不会互相覆盖;退出前由 `flushConfigSync()` 兜底。
*
* **语义变化(有意为之)**:`saveConfig()` 返回时数据可能还没落盘(延迟 CONFIG_WRITE_DEBOUNCE_MS)。
* 正常退出(托盘 → 退出)会走 `flushConfigSync()`,不会丢最后一次改动;只有进程被强杀
* 才可能丢这最后 120ms 内的改动。
*/
/** 写入合并窗口:拖动同类控件(滑块/连续点击)时的连续改动只落最后一次 */
const CONFIG_WRITE_DEBOUNCE_MS = 120;
interface ConfigCache {
config: PersistedConfig;
/** 已解密的登录令牌(null = 未登录 / 解密失败)。缓存它是为了不再每次都走一遍 DPAPI */
token: string | null;
}
/**
* 内存配置。**必须懒初始化**:`main/index.ts` 会先 `app.setPath('userData', smokeProfile)`
* 把配置目录指到冒烟专用 profile,之后才第一次读配置;在模块顶层立即读盘会读错文件。
*/
let cache: ConfigCache | null = null;
function loadCache(): ConfigCache {
if (cache) return cache;
const config = readPersistedFromDisk();
cache = { config, token: decryptToken(config) };
return cache;
}
/** 待写入的配置快照(合并窗口内只保留最后一次) */
let pendingWrite: PersistedConfig | null = null;
let writeTimer: ReturnType<typeof setTimeout> | null = null;
/** 是否有一次异步写入正在进行(用它把写入串行化,避免两次写互相覆盖) */
let writing = false;
/** 排一次落盘。合并窗口内的多次调用只会写最后一次;`immediate` 用于不该被延迟的场景 */
function scheduleWrite(config: PersistedConfig, immediate = false): void {
pendingWrite = config;
if (immediate) {
if (writeTimer !== null) {
clearTimeout(writeTimer);
writeTimer = null;
}
void flushConfigAsync();
return;
}
// 已有待写任务(定时器在跑或正在写):内容已经记在 pendingWrite 里,等它自己收尾即可。
// 正在写的那次结束后会再检查一次 pendingWrite(见 flushConfigAsync 的 finally)。
if (writeTimer !== null || writing) return;
writeTimer = setTimeout(() => {
writeTimer = null;
void flushConfigAsync();
}, CONFIG_WRITE_DEBOUNCE_MS);
}
/** 异步落盘(不阻塞主进程消息泵)。失败只记日志 —— 与 readPersistedFromDisk 的容错口径一致 */
async function flushConfigAsync(): Promise<void> {
if (writing) return;
const target = pendingWrite;
if (!target) return;
pendingWrite = null;
writing = true;
// 先把内容序列化下来:写入期间的后续改动由 pendingWrite 负责补一次,
// 不会出现"这一次写的是半新半旧的对象"
const payload = JSON.stringify(target, null, 2);
try {
const file = configFilePath();
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(file, payload, 'utf8');
} catch (error) {
logger.warn(`写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`);
} finally {
writing = false;
// 写入期间又有改动:再写一次(内容已被 pendingWrite 合并成最后一次)
if (pendingWrite) void flushConfigAsync();
}
}
/**
* 退出前同步落地一次(`main/index.ts` 的 shutdownResources 调用)。
*
* 即使没有待写内容也把内存里的配置重写一遍:那一时刻可能正有一次异步写在进行中,
* 它未必来得及完成;退出时多写一次的代价可以忽略。
*/
export function flushConfigSync(): void {
if (writeTimer !== null) {
clearTimeout(writeTimer);
writeTimer = null;
}
const target = pendingWrite ?? cache?.config ?? null;
if (!target) return;
pendingWrite = null;
try {
const file = configFilePath();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(target, null, 2), 'utf8');
} catch (error) {
logger.warn(
`退出前写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`,
);
}
}
/**
* 服务器地址只接受 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;
}
}
/** 由内存缓存组装对渲染进程暴露的配置(不再读盘、不再解密) */
function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
const persisted = state.config;
return {
serverUrl: persisted.serverUrl,
username: persisted.username,
token: state.token,
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
onboardingDone: persisted.onboardingDone === true,
theme: normalizeTheme(persisted.theme),
sidebarCollapsed: persisted.sidebarCollapsed === true,
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
};
} }
/** 读取配置(token 已解密),供渲染进程使用 */ /** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig { export function getConfig(): DesktopStoredConfig {
const persisted = readPersisted(); return toDesktopConfig(loadCache());
return {
serverUrl: persisted.serverUrl,
username: persisted.username,
token: decryptToken(persisted),
};
} }
/** 局部更新配置:只覆盖传入的字段 */ /** 局部更新配置:只覆盖传入的字段 */
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig { export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
const persisted = readPersisted(); const state = loadCache();
const persisted = state.config;
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) { 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') { if (typeof patch.username === 'string') {
persisted.username = patch.username; 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);
}
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
if (patch.onboardingDone === true) {
persisted.onboardingDone = true;
}
if (patch.theme !== undefined) {
persisted.theme = normalizeTheme(patch.theme);
}
if (patch.sidebarCollapsed !== undefined) {
persisted.sidebarCollapsed = patch.sidebarCollapsed === true;
}
// 空串是合法值(= 没有忽略任何版本),因此这里只判类型不判空
if (typeof patch.ignoredUpdateVersion === 'string') {
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
}
if (patch.token !== undefined) { if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token); const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token; persisted.token = encrypted.token;
persisted.tokenEncrypted = encrypted.tokenEncrypted; persisted.tokenEncrypted = encrypted.tokenEncrypted;
// 明文一并进缓存:既省掉一次 DPAPI 解密,也保证紧接着的 getConfig() 读到的就是刚存的令牌
state.token = patch.token;
} }
writePersisted(persisted); scheduleWrite(persisted);
return getConfig(); return toDesktopConfig(state);
} }
/** 清空为默认配置(退出登录 / 重置) */ /** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */
export function clearConfig(): DesktopStoredConfig { export function clearConfig(): DesktopStoredConfig {
writePersisted({ ...DEFAULT_CONFIG }); const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null };
return getConfig(); cache = state;
scheduleWrite(state.config, true);
return toDesktopConfig(state);
} }
export function getConfigPath(): string { export function getConfigPath(): string {
+134 -13
View File
@@ -1,9 +1,13 @@
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron'; import { BrowserWindow, app, shell } from 'electron';
import { flushConfigSync, getConfig } from './config.js';
import { registerIpcHandlers } from './ipc.js'; import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js'; import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { runSmokeTest } from './smoke.js'; import { runSmokeTest } from './smoke.js';
import { createTray, destroyTray, isTrayReady } from './tray.js';
import { scheduleStartupUpdateCheck } from './update.js';
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname // 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html) // (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
@@ -19,17 +23,48 @@ if (smokeProfile) app.setPath('userData', smokeProfile);
let mainWindow: BrowserWindow | null = null; 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 { function createWindow(): BrowserWindow {
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
const theme = getConfig().theme;
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, // 初始尺寸与 ClassIsland 主窗口对齐(实测其可视区 1242x582),保持同一观感
height: 800, width: 1242,
minWidth: 980, height: 582,
minHeight: 640, minWidth: 1000,
minHeight: 540,
center: true,
title: '班级小助手', title: '班级小助手',
// 冒烟验证时不弹窗,避免打扰使用者 // 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest, show: !isSmokeTest,
autoHideMenuBar: true, autoHideMenuBar: true,
backgroundColor: '#f5f7fa', backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: { webPreferences: {
preload: path.join(currentDir, '../preload/index.js'), preload: path.join(currentDir, '../preload/index.js'),
contextIsolation: true, contextIsolation: true,
@@ -45,11 +80,10 @@ function createWindow(): BrowserWindow {
return { action: 'deny' }; return { action: 'deny' };
}); });
win.webContents.on('will-navigate', (event, url) => { win.webContents.on('will-navigate', (event, url) => {
const allowed = devServerUrl ? url.startsWith(devServerUrl) : url.startsWith('file://'); if (isAllowedNavigation(url)) return;
if (!allowed) { event.preventDefault();
event.preventDefault(); if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
if (/^https?:\/\//i.test(url)) void shell.openExternal(url); logger.warn(`主窗口拒绝了页面导航:${url}`);
}
}); });
win.on('closed', () => { win.on('closed', () => {
@@ -97,10 +131,42 @@ if (!gotLock) {
mainWindow.show(); mainWindow.show();
mainWindow.focus(); 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) { } catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', 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}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
if (isSmokeTest) { if (isSmokeTest) {
try { try {
await runSmokeTest(mainWindow); await runSmokeTest(mainWindow);
@@ -111,13 +177,68 @@ 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[] = [];
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
flushConfigSync();
done.push('配置已落盘');
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', () => { app.on('window-all-closed', () => {
island.destroy(); // 有托盘常驻:窗口全关也不退出,继续后台接收通知
if (process.platform !== 'darwin') app.quit(); logger.info('所有窗口已关闭,程序继续在系统托盘后台运行');
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
island.destroy(); isQuitting = true;
shutdownResources();
});
app.on('will-quit', () => {
const summary = shutdownResources();
logger.info(`应用退出清理:${summary}`);
}); });
app.on('activate', async () => { app.on('activate', async () => {
+20
View File
@@ -1,5 +1,7 @@
import { app, ipcMain, shell } from 'electron'; import { app, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared';
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js'; import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js'; import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */ /** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
@@ -9,6 +11,8 @@ export const IPC_CHANNELS = {
clearConfig: 'classhelper:config:clear', clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info', appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external', openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
} as const; } as const;
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
@@ -30,6 +34,7 @@ export function registerIpcHandlers(): void {
node: process.versions.node ?? '', node: process.versions.node ?? '',
platform: `${process.platform} ${process.arch}`, platform: `${process.platform} ${process.arch}`,
userDataPath: app.getPath('userData'), userDataPath: app.getPath('userData'),
configPath: getConfigPath(),
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1', smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
})); }));
@@ -38,6 +43,21 @@ export function registerIpcHandlers(): void {
await shell.openExternal(url); await shell.openExternal(url);
return true; return true;
}); });
/**
* 检查更新(走主进程直连 GitHub,见 main/update.ts 的说明)。
*
* `force` 为 true 时绕过主进程缓存 —— 对应「关于」页里用户主动点的那次检查;
* 启动自动检查一律用缓存(避免同一分钟内重复请求打满匿名限流)。
*/
ipcMain.handle(IPC_CHANNELS.updateCheck, (_event, force?: boolean): Promise<UpdateInfo> => {
return checkForUpdates(force === true);
});
/** 忽略某个版本的更新提示(记入 config.json,启动自动检查不再提示它) */
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
if (typeof version === 'string') ignoreUpdateVersion(version);
});
} }
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } { export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
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'),
};
}
+215
View File
@@ -0,0 +1,215 @@
import { app, type BrowserWindow } from 'electron';
import {
GITHUB_RELEASES_LATEST_API,
GITHUB_RELEASES_PAGE,
UPDATE_CHECK_CACHE_TTL_MS,
UPDATE_CHECK_TIMEOUT_MS,
UPDATE_CHECK_USER_AGENT,
isNewerVersion,
normalizeVersion,
type UpdateAssetDto,
type UpdateInfo,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { logger } from './logger.js';
/**
* 更新检查(桌面客户端)。
*
* **为什么在主进程做**:渲染进程通过 preload 能拿到 `getConfig()` 的明文 token(代表全班身份),
* 它的 CSP 与导航防线是整个安全模型里最要紧的一层;让它额外去连一个外部域名、
* 解析外部 JSON,等于把"外部数据"请进最有价值的那一侧。主进程本来就在做网络与文件,
* 放这里也顺带复用 Node 的 fetch(无需额外依赖)。
*
* 与 Web 管理端的差别:Web 端受服务端 CSP 的 `connect-src` 限制,只能走 `/api/update/check` 转发;
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
*/
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
const STARTUP_DELAY_MS = 5_000;
interface GithubReleasePayload {
tag_name?: unknown;
name?: unknown;
published_at?: unknown;
body?: unknown;
html_url?: unknown;
assets?: unknown;
}
let cache: { at: number; info: UpdateInfo } | null = null;
let startupTimer: ReturnType<typeof setTimeout> | null = null;
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下必须是 true) */
let startupCheckSkipped = false;
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
function failure(error: string): UpdateInfo {
return {
ok: false,
error,
currentVersion: app.getVersion(),
latestVersion: null,
hasUpdate: false,
releaseUrl: GITHUB_RELEASES_PAGE,
releaseName: null,
publishedAt: null,
notes: null,
assets: [],
checkedAt: new Date().toISOString(),
};
}
function toAssets(raw: unknown): UpdateAssetDto[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
.filter(
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
)
.map((item) => ({
name: item.name,
downloadUrl: item.browser_download_url,
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
}));
}
function toInfo(payload: GithubReleasePayload, currentVersion: string): UpdateInfo {
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
if (!latestVersion) {
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
return failure('GitHub 返回的版本号无法识别');
}
return {
ok: true,
error: null,
currentVersion,
latestVersion,
hasUpdate: isNewerVersion(currentVersion, latestVersion),
releaseUrl:
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
releaseName: typeof payload.name === 'string' ? payload.name : null,
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
notes: typeof payload.body === 'string' ? payload.body : null,
assets: toAssets(payload.assets),
checkedAt: new Date().toISOString(),
};
}
/** 把各种失败翻译成一句用户能看懂的话(教室里最常见的就是"这台机器没有外网") */
function describeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.name === 'TimeoutError') {
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
}
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate|net::/i.test(message)) {
return '无法连接 GitHub,本机可能没有外网';
}
return `检查更新失败:${message}`;
}
/**
* 检查 GitHub 上有没有比本机新的版本(结果带缓存)。
*
* 缓存的意义:匿名调用 GitHub API 的限流是 60 次/小时/IP,而「关于」页的按钮可以随便点。
* 失败结果同样进缓存 —— 否则离线时每点一次都要白等一个超时。
*/
export async function checkForUpdates(force = false): Promise<UpdateInfo> {
const currentVersion = app.getVersion();
const now = Date.now();
if (!force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
return cache.info;
}
let info: UpdateInfo;
try {
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
headers: {
// GitHub API 强制要求 User-Agent,不带会直接 403
'User-Agent': UPDATE_CHECK_USER_AGENT,
Accept: 'application/vnd.github+json',
},
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
});
if (!response.ok) {
info =
response.status === 403 || response.status === 429
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
: failure(`GitHub 返回 HTTP ${response.status}`);
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${currentVersion})`);
} else {
info = toInfo((await response.json()) as GithubReleasePayload, currentVersion);
logger.info(
`更新检查:本机 ${currentVersion} / 最新 ${info.latestVersion ?? '-'}` +
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
);
}
} catch (error) {
info = failure(describeFailure(error));
logger.warn(`更新检查失败:${info.error}(本机版本 ${currentVersion})`);
}
cache = { at: now, info };
return info;
}
/** 「知道了 / 忽略此版本」:记到配置里,同一个版本不再重复弹提示 */
export function ignoreUpdateVersion(version: string): void {
const normalized = normalizeVersion(version);
if (!normalized) return;
saveConfig({ ignoredUpdateVersion: normalized });
logger.info(`已忽略版本 ${normalized} 的更新提示`);
}
/** 该版本是否已被用户忽略 */
export function isUpdateIgnored(version: string): boolean {
return normalizeVersion(getConfig().ignoredUpdateVersion) === normalizeVersion(version);
}
/**
* 启动后的自动检查:延迟 `STARTUP_DELAY_MS` 查一次,有新版本(且没被忽略)就通知渲染进程弹提示。
*
* **冒烟模式直接跳过**:自动化验证不该每次都发真实网络请求(既抖动又依赖外网),
* 而且凭空多出来的提示条会撞坏既有 UI 断言。冒烟用例改为直接调 IPC 验证返回结构。
*/
export function scheduleStartupUpdateCheck(getWindow: () => BrowserWindow | null): void {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
startupCheckSkipped = true;
logger.info('更新检查:冒烟模式下跳过启动自动检查');
return;
}
if (startupTimer) clearTimeout(startupTimer);
startupTimer = setTimeout(() => {
startupTimer = null;
void (async () => {
try {
const info = await checkForUpdates();
if (!info.ok || !info.hasUpdate || !info.latestVersion) return;
if (isUpdateIgnored(info.latestVersion)) {
logger.info(`更新检查:${info.latestVersion} 已被用户忽略,本次不提示`);
return;
}
const win = getWindow();
if (!win || win.isDestroyed()) return;
win.webContents.send(UPDATE_AVAILABLE_CHANNEL, info);
logger.info(`更新检查:已提示新版本 ${info.latestVersion}`);
} catch (error) {
// 自动检查失败不该打扰用户(手动检查才需要看到原因)
logger.warn(`启动自动检查更新失败:${error instanceof Error ? error.message : error}`);
}
})();
}, STARTUP_DELAY_MS);
// 不阻止进程退出:这个定时器只是"顺便查一下"
startupTimer.unref?.();
}
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下应为 true —— 否则每次验证都会发真实网络请求) */
export function wasStartupCheckSkipped(): boolean {
return startupCheckSkipped;
}
+38 -1
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../main/ipc.js'; import { IPC_CHANNELS } from '../main/ipc.js';
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js';
import type { import type {
DesktopAppInfo, DesktopAppInfo,
DesktopBridge, DesktopBridge,
@@ -7,14 +8,29 @@ import type {
IslandClassStatePayload, IslandClassStatePayload,
IslandPushContext, IslandPushContext,
} from '../types/desktop.js'; } from '../types/desktop.js';
import type { IslandNotification } from '@classhelper/shared'; import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
/** /**
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下, * 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
* 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。 * 只向渲染进程暴露一组最小的、显式声明的能力(不暴露 ipcRenderer 本体)。
*
* 注意:contextBridge 在参数跨越"主世界 → 隔离世界"时就做结构化克隆,
* 因此**桥接层无法替调用方兜底**:传 Vue 响应式对象(Proxy)会在进入这里的函数体
* 之前就抛 `An object could not be cloned.`。调用方必须传纯数据,见 desktop.d.ts。
*/ */
const bridge: DesktopBridge = { const bridge: DesktopBridge = {
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1', 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), getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> => saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch), ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
@@ -22,6 +38,15 @@ const bridge: DesktopBridge = {
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo), getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url), openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url),
/* 更新检查(主进程直连 GitHub,见 main/update.ts) */
checkForUpdates: (force = false): Promise<UpdateInfo> =>
ipcRenderer.invoke(IPC_CHANNELS.updateCheck, force),
ignoreUpdateVersion: (version: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.updateIgnore, version),
onUpdateAvailable: (handler: (info: UpdateInfo) => void): void => {
ipcRenderer.on(UPDATE_AVAILABLE_CHANNEL, (_event, info: UpdateInfo) => handler(info));
},
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => { islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
ipcRenderer.send('island:push', payload); ipcRenderer.send('island:push', payload);
}, },
@@ -29,6 +54,18 @@ const bridge: DesktopBridge = {
ipcRenderer.send('island:class-state', payload); ipcRenderer.send('island:class-state', payload);
}, },
islandGetState: () => ipcRenderer.invoke('island:get-state'), 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); contextBridge.exposeInMainWorld('desktop', bridge);
+45 -3
View File
@@ -1,14 +1,40 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import type { IslandState } from '@classhelper/shared'; import type { IslandAppearance, IslandState } from '@classhelper/shared';
/** /**
* 灵动岛窗口的预加载脚本。 * 灵动岛窗口的预加载脚本。
* 只暴露「订阅状态」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。 * 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
*/ */
export interface IslandBridge { export interface IslandBridge {
onState(handler: (state: IslandState) => void): void; 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>; 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 = { const bridge: IslandBridge = {
@@ -19,6 +45,22 @@ const bridge: IslandBridge = {
ipcRenderer.send('island:action', { action, id }); ipcRenderer.send('island:action', { action, id });
}, },
getState: () => ipcRenderer.invoke('island:get-state'), 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); contextBridge.exposeInMainWorld('island', bridge);
+92 -2
View File
@@ -1,19 +1,77 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'; import { h, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { startIslandBridge, stopIslandBridge } from './island/bridge.js'; import { ElButton, ElNotification } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared';
import {
startIslandBridge,
stopIslandBridge,
subscribeIslandMarkAllRead,
subscribeIslandMarkRead,
} from './island/bridge.js';
import OnboardingWelcome from './components/OnboardingWelcome.vue';
import { useAppStore } from './stores/app.js'; import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js'; import { useAuthStore } from './stores/auth.js';
import { useOnboardingStore } from './stores/onboarding.js';
import { useRealtimeStore } from './stores/realtime.js'; import { useRealtimeStore } from './stores/realtime.js';
import { useUiStore } from './stores/ui.js';
const router = useRouter(); const router = useRouter();
const appStore = useAppStore(); const appStore = useAppStore();
const auth = useAuthStore(); const auth = useAuthStore();
const onboarding = useOnboardingStore();
const ui = useUiStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const booting = ref(true); const booting = ref(true);
const bootText = ref('正在读取本地配置...'); const bootText = ref('正在读取本地配置...');
/**
* 启动自动检查发现新版本时的提示(主进程查出后推过来,见 main/update.ts)。
*
* **为什么订阅放在 App.vue**:`onUpdateAvailable` 底层是 `ipcRenderer.on`,
* 桥接层没有对应的 off —— 挂在会随"登出 → 登录"反复挂载的 ClientLayout 里,
* 处理器会越堆越多(与灵动岛订阅同一个坑)。App.vue 整个应用生命周期只挂载一次。
*
* 提示是**一次性**且可永久消音:点「忽略此版本」写进 config.json,同一版本不再提示;
* 「关于」页里仍可随时手动检查。
*/
function showUpdateAvailable(info: UpdateInfo): void {
if (!info.latestVersion) return;
ElNotification({
title: `发现新版本 v${info.latestVersion}`,
type: 'info',
// 不自动消失:这是需要用户决定的事(去下载 / 忽略),滑过去就只能靠「关于」页再想起来
duration: 0,
message: h('div', { style: 'line-height: 1.7' }, [
h('p', { style: 'margin: 0 0 10px' }, `当前版本 ${info.currentVersion},可前往下载更新。`),
h('div', { style: 'display: flex; gap: 8px' }, [
h(
ElButton,
{
size: 'small',
type: 'primary',
onClick: () => {
void window.desktop?.openExternal(info.releaseUrl);
},
},
() => '前往下载',
),
h(
ElButton,
{
size: 'small',
onClick: () => {
void window.desktop?.ignoreUpdateVersion(info.latestVersion ?? '');
},
},
() => '忽略此版本',
),
]),
]),
});
}
/** /**
* 启动流程: * 启动流程:
* 1) 从主进程配置恢复服务器地址与登录令牌 * 1) 从主进程配置恢复服务器地址与登录令牌
@@ -22,9 +80,21 @@ const bootText = ref('正在读取本地配置...');
*/ */
onMounted(async () => { onMounted(async () => {
try { try {
bootText.value = '正在读取本地配置...';
// 主题与侧栏折叠最先恢复:在首屏渲染前挂好 html.dark,避免"先白后黑"闪一下
await ui.init();
bootText.value = '正在恢复登录状态...'; bootText.value = '正在恢复登录状态...';
const session = await auth.restore(); const session = await auth.restore();
// 通知显示位置:先用本地配置兜底(断网也要按上次的选择决定本机弹不弹),
// 登录态就绪后再向服务器对齐一次(服务端才是单一事实来源)
const storedConfig = await window.desktop?.getConfig?.();
if (storedConfig?.notificationChannel) {
appStore.applyLocalNotificationChannel(storedConfig.notificationChannel);
}
void appStore.loadNotificationChannel(auth.classId);
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据) // 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
void appStore.init(session.serverUrl); void appStore.init(session.serverUrl);
@@ -33,8 +103,26 @@ onMounted(async () => {
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出) // 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
if (auth.token) startIslandBridge(); if (auth.token) startIslandBridge();
// 灵动岛"标为已读" → 同步通知中心(未读红点);多条通知时是整批已读
subscribeIslandMarkRead();
subscribeIslandMarkAllRead();
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
if (auth.isAuthenticated) await router.replace('/schedule'); if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login'); else await router.replace('/login');
// 初次启动引导:配置里没记「已看过」就自动弹一次(完成/跳过后由引导组件写入配置)。
// 稍等一拍再弹:让登录页/主界面先渲染完,避免和启动过渡的收尾抢帧。
window.setTimeout(() => {
void window.desktop
?.getConfig()
.then((config) => {
if (config.onboardingDone !== true) onboarding.open();
})
.catch(() => undefined);
}, 400);
} finally { } finally {
booting.value = false; booting.value = false;
} }
@@ -52,6 +140,8 @@ onUnmounted(() => {
<p class="boot-text">{{ bootText }}</p> <p class="boot-text">{{ bootText }}</p>
</div> </div>
<router-view v-else /> <router-view v-else />
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
<OnboardingWelcome v-if="!booting" />
</template> </template>
<style scoped> <style scoped>
@@ -2,10 +2,14 @@ import {
API_PATHS, API_PATHS,
type ClassDetailDto, type ClassDetailDto,
type ClassDto, type ClassDto,
type ClassIslandNotificationChannel,
type CourseDto,
type HomeworkDaysDto,
type DashboardSummary, type DashboardSummary,
type GradeDto, type GradeDto,
type HomeworkDto, type HomeworkDto,
type HomeworkStatusDto, type HomeworkStatusDto,
type HomeworkSubmissionsDto,
type LoginRequest, type LoginRequest,
type LoginResponse, type LoginResponse,
type NotificationDto, type NotificationDto,
@@ -17,9 +21,22 @@ import { api } from './http.js';
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */ /** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
export const authApi = { export const authApi = {
/** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload), 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, {}), logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me), 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 = { export const classApi = {
@@ -27,6 +44,36 @@ export const classApi = {
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`), 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 = { export const scheduleApi = {
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> => list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params), api.get(API_PATHS.schedules, params),
@@ -34,20 +81,48 @@ export const scheduleApi = {
api.get(`${API_PATHS.schedules}/grid`, params), 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 = { export const homeworkApi = {
list: (params?: { classId?: string; pendingOnly?: boolean; courseId?: string }): Promise<HomeworkDto[]> => list: (params?: {
api.get(API_PATHS.homeworks, 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}`), detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> => updateStatus: (id: string, completed: boolean): Promise<HomeworkStatusDto> =>
api.patch(`${API_PATHS.homeworks}/${id}/status`, { completed }), 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 = { export const notificationApi = {
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> => list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
api.get(API_PATHS.notifications, params), api.get(API_PATHS.notifications, params),
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`), unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
// id 来自服务端或灵动岛(作业卡是本地合成的 `homework-…`),一律 encodeURIComponent:
// 否则含 `/` 或 `..` 的 id 会改变实际请求的路径
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> => 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`, {}), markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
}; };
@@ -57,5 +132,6 @@ export const gradeApi = {
export const dashboardApi = { export const dashboardApi = {
summary: (): Promise<DashboardSummary> => api.get(API_PATHS.dashboard), 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),
}; };
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import { useOnboardingStore } from '../stores/onboarding.js';
/**
* 初次启动引导(六步向导)。
*
* - 首次启动自动弹出(App.vue 按 `onboardingDone` 判定);完成 / 跳过(右上角按钮 / Esc)
* 都算看过,由这里写入主进程配置,之后不再自动弹出。
* - 登录页与设置页的「使用引导」入口可随时重看(重看关闭时再写一次也无副作用)。
* - **用 v-if 自绘遮罩而不是 el-dialog**:关闭即从 DOM 卸载。冒烟断言"引导已关闭"时
* 不会踩 el-dialog「DOM 残留在文档里」的坑(AGENTS §7 第 22 条)。
* - 操作按钮用 `data-action` 标识(prev/next/finish/skip),冒烟点击不依赖按钮文案。
*/
const onboarding = useOnboardingStore();
const steps = [
{
icon: 'School',
title: '欢迎使用班级小助手',
lines: [
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
'第一次使用,先花半分钟看看怎么开始。',
],
},
{
icon: 'Link',
title: '第一步 · 连接服务器',
lines: [
'在登录页填写老师或管理员提供的服务器地址(通常形如 http://192.168.x.x:4000)。',
'点「测试连接」确认可达;以后也能在「设置」里随时修改。',
],
},
{
icon: 'Key',
title: '第二步 · 登录班级',
lines: [
'使用班级码 + 班级密码登录(向老师或管理员索取)。',
'登录后这台设备就代表全班:作业完成、通知已读都会按全班记录,不需要每个学生单独登录。',
],
},
{
icon: 'Calendar',
title: '登录之后',
lines: [
'左侧菜单切换「课表 / 作业 / 通知 / 成绩」四个页面,作业支持看板全屏展示。',
'断网时自动显示最近同步的缓存数据,联网后自动更新。',
],
},
{
icon: 'Bell',
title: '灵动岛提醒',
lines: [
'屏幕上方会有一条「灵动岛」浮窗:新通知、叫人、作业都会在那里弹出。',
'上课时段自动隐藏、下课自动弹出;外观与位置可在「设置 → 灵动岛」调整。',
],
},
{
icon: 'CircleCheckFilled',
title: '准备好了',
lines: [
'按上面的步骤登录就可以开始使用了。',
'侧边栏「设置」分组里有通用、外观(深色模式)、灵动岛等页面;这份引导可以在登录页或「设置 → 通用 → 使用引导」随时重看。',
],
},
];
const index = ref(0);
const current = computed(() => steps[index.value] ?? steps[0]);
const isFirst = computed(() => index.value === 0);
const isLast = computed(() => index.value === steps.length - 1);
/** 每次打开都从第一步开始(重看入口也走完整流程) */
watch(
() => onboarding.visible,
(visible) => {
if (visible) index.value = 0;
},
);
function goNext(): void {
if (isLast.value) finish();
else index.value += 1;
}
function goPrev(): void {
if (!isFirst.value) index.value -= 1;
}
/** 完成 / 跳过统一走这里:记「已看过」并关闭(重看时重复写一次无副作用) */
function finish(): void {
const bridge = window.desktop;
// 桥接不可用(浏览器调试)也要能正常关掉引导
if (!bridge) {
onboarding.close();
return;
}
void bridge
.saveConfig({ onboardingDone: true })
.then(() => onboarding.close())
.catch(() => onboarding.close());
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') finish();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onKeydown);
}
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
});
</script>
<template>
<Transition name="onboarding-fade">
<div
v-if="onboarding.visible"
class="onboarding-mask"
role="dialog"
aria-modal="true"
aria-label="初次启动引导"
>
<div class="onboarding-card onboarding-welcome">
<div class="onboarding-head">
<span class="onboarding-logo">
<el-icon :size="18"><School /></el-icon>
</span>
<div class="onboarding-heading">
<h2 class="onboarding-title">班级小助手</h2>
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
</div>
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
</div>
<div class="onboarding-body">
<span class="onboarding-icon">
<el-icon :size="30"><component :is="current.icon" /></el-icon>
</span>
<h3 class="onboarding-step-title">{{ current.title }}</h3>
<p v-for="(line, lineIndex) in current.lines" :key="lineIndex" class="onboarding-line">
{{ line }}
</p>
</div>
<div class="onboarding-foot">
<div class="onboarding-dots">
<span
v-for="(step, dotIndex) in steps"
:key="dotIndex"
class="dot"
:class="{ active: dotIndex === index }"
/>
</div>
<div class="onboarding-actions">
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
下一步
</el-button>
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.onboarding-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 30, 50, 0.55);
}
.onboarding-card {
width: 520px;
max-width: 92vw;
max-height: 86vh;
overflow-y: auto;
background: var(--ch-layer-solid);
border-radius: 16px;
padding: 22px 24px 18px;
box-shadow: 0 24px 64px rgba(15, 30, 50, 0.35);
}
.onboarding-head {
display: flex;
align-items: center;
gap: 10px;
}
.onboarding-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
}
.onboarding-heading {
flex: 1;
min-width: 0;
}
.onboarding-title {
margin: 0;
font-size: 15px;
line-height: 1.3;
}
.onboarding-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--ch-text-tertiary);
}
.onboarding-body {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 18px 4px 6px;
}
.onboarding-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 16px;
margin-bottom: 14px;
color: var(--ch-accent);
background: var(--ch-accent-soft);
}
.onboarding-step-title {
margin: 0 0 10px;
font-size: 18px;
}
.onboarding-line {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.8;
color: var(--ch-text-regular, #606266);
}
.onboarding-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--ch-divider);
}
.onboarding-dots {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--ch-border-strong);
transition: all 0.2s ease;
}
.dot.active {
width: 20px;
background: var(--ch-accent);
}
.onboarding-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 进出场:淡入 + 轻微上浮,跟随系统"动画"开关无强依赖 */
.onboarding-fade-enter-active,
.onboarding-fade-leave-active {
transition: opacity 0.22s ease;
}
.onboarding-fade-enter-active .onboarding-card,
.onboarding-fade-leave-active .onboarding-card {
transition: transform 0.22s ease;
}
.onboarding-fade-enter-from,
.onboarding-fade-leave-to {
opacity: 0;
}
.onboarding-fade-enter-from .onboarding-card,
.onboarding-fade-leave-to .onboarding-card {
transform: translateY(10px) scale(0.98);
}
</style>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed } from 'vue';
/**
* 轻量柱状图(纯 HTML/CSS,不依赖任何图表库)。
*
* 为什么不用 echarts:成绩页只需要「分组柱 + 数值标签 + 坐标轴」这点东西,
* 而 `import * as echarts from 'echarts'` 会给渲染产物加 1.1MB(客户端里最大的一块),
* 按需引入 echarts/core 也仍有约 500KB。
*
* 实现要点(决定了这里为什么不需要 resize 监听):
* - 柱高用**百分比**:`.bar-track` 是行方向 flex 里 `flex:1` 的子项,高度确定,
* 柱子的 `height: X%` 直接按它解析;
* - 刻度与网格线用**零高度 flex 行 + `justify-content: space-between`**:
* 零高度元素的中心正好落在 i/(N-1) 处,与柱状区上下边界精确对齐;
* - 纵轴刻度列与绘图区共用同一套上下留白(数值行 18px / 标签行 30px),因此永远对齐。
*/
interface BarItem {
label: string;
value: number;
}
const props = withDefaults(
defineProps<{
/** 每根柱:分类名 + 数值(0 ~ max) */
items: BarItem[];
/** 柱色 */
color?: string;
/** 纵轴上限 */
max?: number;
/** 数值单位(显示在刻度与柱顶数值后面) */
unit?: string;
/** 图表高度(px) */
height?: number;
}>(),
{ color: '#409eff', max: 100, unit: '%', height: 300 },
);
/** 纵轴刻度(自上而下):100 / 75 / 50 / 25 / 0(按 max 换算) */
const ticks = computed(() => [1, 0.75, 0.5, 0.25, 0].map((ratio) => Math.round(props.max * ratio)));
function barHeight(value: number): string {
const ratio = props.max > 0 ? value / props.max : 0;
return `${Math.min(1, Math.max(0, ratio)) * 100}%`;
}
function formatValue(value: number): string {
return `${Number.isInteger(value) ? value : value.toFixed(1)}${props.unit}`;
}
</script>
<template>
<div class="bar-chart" :style="{ height: `${height}px` }">
<div v-if="items.length === 0" class="bar-chart-empty">暂无数据</div>
<template v-else>
<div class="axis-y" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="tick">{{ tick }}{{ unit }}</span>
</div>
<div class="plot">
<div class="gridlines" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="gridline"></span>
</div>
<div class="bars">
<div v-for="item in items" :key="item.label" class="bar-col">
<div class="bar-value">{{ formatValue(item.value) }}</div>
<div class="bar-track">
<div
class="bar"
:style="{ height: barHeight(item.value), background: color }"
:title="`${item.label}:${formatValue(item.value)}`"
></div>
</div>
<div class="bar-label" :title="item.label">{{ item.label }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.bar-chart {
display: grid;
grid-template-columns: 46px 1fr;
width: 100%;
}
.bar-chart-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 13px;
color: var(--ch-text-tertiary);
}
/* 纵轴刻度:上下留白与 .bars 里的数值行 / 标签行一一对应 */
.axis-y {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 18px 0 30px;
}
.tick {
height: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
font-size: 11px;
line-height: 1;
white-space: nowrap;
color: var(--ch-text-tertiary);
}
.plot {
position: relative;
min-width: 0;
}
.gridlines {
position: absolute;
inset: 18px 0 30px;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.gridline {
height: 0;
border-top: 1px solid var(--ch-border);
}
.bars {
display: flex;
align-items: stretch;
gap: 10px;
height: 100%;
padding: 0 6px;
}
.bar-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.bar-value {
flex: none;
height: 18px;
font-size: 11px;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
color: var(--ch-text-secondary);
}
.bar-track {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bar {
width: 100%;
max-width: 46px;
border-radius: 4px 4px 0 0;
transition: height 0.2s ease;
}
.bar-label {
flex: none;
height: 30px;
padding-top: 6px;
font-size: 11px;
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ch-text-secondary);
}
</style>
@@ -0,0 +1,94 @@
import type { App } from 'vue';
import {
AlarmClock,
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Brush,
Calendar,
CircleCheckFilled,
Connection,
Expand,
Fold,
FullScreen,
Guide,
InfoFilled,
Key,
Link,
Loading,
Lock,
MagicStick,
Moon,
Notebook,
Plus,
Pointer,
Refresh,
School,
Setting,
Sunny,
SwitchButton,
Tools,
Trophy,
UserFilled,
Warning,
} from '@element-plus/icons-vue';
/**
* Element Plus 图标注册表(桌面客户端渲染进程)。
*
* **为什么不 `import * as ElementPlusIconsVue` 全量注册**:
* 图标库有 293 个组件(实测全量打包 minified 203KB,实际用到的只有 33 个),
* 全量注册既把死代码打进主 chunk,又要在启动时执行 293 次 `app.component()`。
*
* **为什么不改成模板里逐个 import**:本项目的图标是按**字符串名**引用的
* (`ClientLayout.vue` 的菜单配置 `icon: 'Setting'`、`OnboardingWelcome.vue` 的引导步骤、
* 按钮上的 `:icon="'FullScreen'"`、`<component :is="item.icon">`),Element Plus 会用
* `<component :is="'Setting'">` 去解析**全局组件名**,因此图标必须以全局组件存在。
*
* 新增视图时若用了新图标,`scripts/check-icons.mjs` 会在构建前报错,
* 不会静默渲染成空白。
*/
const APP_ICONS = {
AlarmClock,
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Brush,
Calendar,
CircleCheckFilled,
Connection,
Expand,
Fold,
FullScreen,
Guide,
InfoFilled,
Key,
Link,
Loading,
Lock,
MagicStick,
Moon,
Notebook,
Plus,
Pointer,
Refresh,
School,
Setting,
Sunny,
SwitchButton,
Tools,
Trophy,
UserFilled,
Warning,
} as const;
/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */
export const APP_ICON_NAMES: readonly string[] = Object.keys(APP_ICONS).sort();
export function registerAppIcons(app: App): void {
for (const [name, component] of Object.entries(APP_ICONS)) {
app.component(name, component);
}
}
@@ -2,6 +2,7 @@ import {
SOCKET_EVENTS, SOCKET_EVENTS,
resolveClassStatus, resolveClassStatus,
type ClassPeriod, type ClassPeriod,
type HomeworkDto,
type NotificationDto, type NotificationDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -9,6 +10,7 @@ import { scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
/** /**
@@ -82,20 +84,165 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
createdAt: notification.createdAt, createdAt: notification.createdAt,
courseName: currentPeriod?.courseName ?? null, courseName: currentPeriod?.courseName ?? null,
teacherName: notification.creator?.name ?? null, teacherName: notification.creator?.name ?? null,
kind: 'notification',
}, },
context: getIslandClassContext(), 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 { export function startIslandBridge(): void {
if (tickTimer) return; if (tickTimer) return;
void refreshIslandSchedules().catch(() => undefined); void refreshIslandSchedules().catch(() => undefined);
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏" // 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, () => { useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
void refreshIslandSchedules().catch(() => undefined);
});
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS); tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
@@ -103,9 +250,31 @@ export function startIslandBridge(): void {
}, REFRESH_INTERVAL_MS); }, REFRESH_INTERVAL_MS);
} }
/**
* 停止桥接:退出登录 / 切换账号时必须调用。
*
* 只清定时器是不够的:
* - 订阅要 off,否则每次"登出 → 登录"都会多挂一份监听;
* - 本地课表缓存要清空,否则登出后仍会按**上一个班**的课表继续给主进程下发"上课/下课"
* (灵动岛按陈旧课表隐藏或弹出),换班登录时第一轮刷新回来之前用的也是旧课表。
*/
export function stopIslandBridge(): void { export function stopIslandBridge(): void {
if (tickTimer) clearInterval(tickTimer); if (tickTimer) clearInterval(tickTimer);
if (refreshTimer) clearInterval(refreshTimer); if (refreshTimer) clearInterval(refreshTimer);
tickTimer = null; tickTimer = null;
refreshTimer = null; refreshTimer = null;
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
schedules = [];
currentPeriod = null;
inClass = false;
// 把"不在上课"同步给主进程:否则主进程还记着上一个会话的"上课中",
// 重新登录后若第一轮课表刷新失败(离线且无缓存),灵动岛会继续按上课态把通知压着不弹,
// 直到下一次刷新成功为止。这里主动对齐一次,代价是登出瞬间可能补弹一条此前被暂存的通知。
window.desktop?.islandSetClassState({
inClass: false,
currentPeriodEnd: null,
week: useAppStore().currentWeek,
});
} }
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js'; import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
import { useUiStore } from '../stores/ui.js';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -14,18 +16,77 @@ const appStore = useAppStore();
const auth = useAuthStore(); const auth = useAuthStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const ui = useUiStore();
const menuItems = [ /**
* 主侧边栏。
*
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
*
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
*/
const primaryItems = [
{ path: '/schedule', title: '课表', icon: 'Calendar' }, { path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' }, { path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' }, { path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' }, { path: '/grades', title: '成绩', icon: 'Trophy' },
{ path: '/settings', title: '设置', icon: 'Setting' },
]; ];
const menuGroups = [
{
title: '设置',
icon: 'Setting',
items: [
{ path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
],
},
];
/** 未读红点挂在「通知」项的图标右上角 */
function showBadge(path: string): boolean {
return path === '/notifications' && notifications.unreadCount > 0;
}
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */ /** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
const activeMenu = computed(() => route.path); const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
return group?.title ?? null;
});
/**
* 分组展开控制:默认只展开**当前路由所在的分组**(一级项不属于任何分组,此时一个都不展开)。
*
* 窗口默认高度与 ClassIsland 对齐(582px),设置组 6 个子项展开后会超出可视高度、
* 把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的观感(它也只展开当前组),
* 又让小窗口下的侧栏始终完整可见。用户手动展开其它分组不受限制(可多个同时展开)。
*/
const menuRef = ref<MenuInstance>();
const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.value] : []);
watch(
() => route.path,
async (path) => {
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
if (!group) return;
// 展开当前组(不主动关闭用户已展开的其它组)
await nextTick();
menuRef.value?.open(group.title);
},
);
/** /**
* 菜单点击回调。 * 菜单点击回调。
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径), * 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
@@ -43,9 +104,11 @@ function go(name: string): void {
/** 开发期自检:菜单路径必须存在于路由表中 */ /** 开发期自检:菜单路径必须存在于路由表中 */
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const knownPaths = new Set(router.getRoutes().map((item) => item.path)); const knownPaths = new Set(router.getRoutes().map((item) => item.path));
for (const item of menuItems) { for (const group of menuGroups) {
if (!knownPaths.has(item.path)) { for (const item of group.items) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`); if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
}
} }
} }
} }
@@ -70,10 +133,14 @@ async function handleLogout(): Promise<void> {
void router.push({ name: 'login' }); void router.push({ name: 'login' });
} }
/** 通知实时推送:更新列表 + 红点 + 缓存 */ /**
* 通知实时推送:更新通知中心列表与红点。
*
* 这里**不再**额外弹一条"收到新通知"的 toast —— `stores/realtime.ts` 收到
* notification:new 时已经弹过带优先级与标题的那条,两条堆在屏幕上纯属噪音。
*/
function onNotification(payload: unknown): void { function onNotification(payload: unknown): void {
void notifications.pushRealtime(payload as NotificationDto); void notifications.pushRealtime(payload as NotificationDto);
ElMessage({ message: '收到新通知,点击「通知」查看', type: 'info', duration: 3000 });
} }
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */ /** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
@@ -87,32 +154,86 @@ onMounted(async () => {
await notifications.refreshUnreadCount(); await notifications.refreshUnreadCount();
realtime.on(SOCKET_EVENTS.notificationNew, onNotification); realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
appStore.onServerRecovered(onRecovered); appStore.onServerRecovered(onRecovered);
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
if (auth.token) startIslandBridge();
}); });
onUnmounted(() => { onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotification); realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
appStore.offServerRecovered(onRecovered); appStore.offServerRecovered(onRecovered);
stopIslandBridge();
}); });
</script> </script>
<template> <template>
<el-container class="layout"> <el-container class="layout">
<el-aside width="190px" class="aside"> <el-aside
:width="ui.sidebarCollapsed ? '64px' : '200px'"
class="aside ch-nav"
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
>
<div class="brand"> <div class="brand">
<el-icon :size="20"><School /></el-icon> <button
<span>班级小助手</span> type="button"
class="nav-toggle"
aria-label="折叠或展开侧边栏"
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
data-test="sidebar-toggle"
@click="ui.toggleSidebar()"
>
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
</button>
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</div> </div>
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect"> <el-menu
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path"> ref="menuRef"
<el-icon><component :is="item.icon" /></el-icon> :default-active="activeMenu"
:default-openeds="openedGroups"
class="menu"
@select="handleMenuSelect"
>
<!-- 一级项:直接铺在侧栏上(不再套「学习」分组)。
下面 el-sub-menu 里的菜单项与本段是同一套标记(图标槽 + 未读红点 + 标题),
改动时两处要一起改。 -->
<el-menu-item v-for="item in primaryItems" :key="item.path" :index="item.path">
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="showBadge(item.path)"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</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-item>
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
<template #title>
<el-icon><component :is="group.icon" /></el-icon>
<span>{{ group.title }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="showBadge(item.path)"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</span>
</el-menu-item>
</el-sub-menu>
</el-menu> </el-menu>
<!-- 同上:折叠时不删节点,只淡出并收掉内边距 -->
<div class="aside-footer"> <div class="aside-footer">
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div> <div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div> <div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
@@ -127,6 +248,19 @@ onUnmounted(() => {
<span class="server-url">{{ appStore.serverUrl }}</span> <span class="server-url">{{ appStore.serverUrl }}</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- 黑夜/白天快捷切换:外观页里也有同样的设置,两处写同一份配置 -->
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
<el-button
class="theme-toggle"
text
circle
data-test="theme-toggle"
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
@click="ui.toggleTheme()"
>
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
</el-button>
</el-tooltip>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<span class="user-chip"> <span class="user-chip">
<el-icon><UserFilled /></el-icon> <el-icon><UserFilled /></el-icon>
@@ -135,7 +269,7 @@ onUnmounted(() => {
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="go('settings')"> <el-dropdown-item @click="go('settings-general')">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
设置 设置
</el-dropdown-item> </el-dropdown-item>
@@ -170,65 +304,217 @@ onUnmounted(() => {
<style scoped> <style scoped>
.layout { .layout {
height: 100vh; height: 100vh;
background: var(--ch-bg);
} }
.aside { .aside {
background: var(--ch-sidebar);
color: #fff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 展开/收回动画:宽度来自 el-aside 的内联 --el-aside-width,这里只负责过渡。
overflow:hidden 是必需的 —— 收窄过程中文字要被栏边裁掉,不能溢出到主区域;
菜单自己的滚动由内部的 .menu(flex:1 + min-height:0 + overflow-y:auto)负责。 */
overflow: hidden;
transition: width var(--nav-dur) var(--nav-ease);
} }
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
height: 54px; height: 52px;
padding: 0 16px; padding: 0 10px 0 12px;
font-weight: 600; font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); font-size: 14px;
color: var(--ch-text);
transition:
padding-left var(--nav-dur) var(--nav-ease),
gap var(--nav-dur) var(--nav-ease);
}
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
.nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: 0 0 auto;
border: none;
border-radius: var(--ch-radius-control);
background: transparent;
color: var(--ch-text-secondary);
cursor: pointer;
transition: background 0.15s ease;
}
.nav-toggle:hover {
background: var(--ch-hover-soft);
}
/* 折叠态:侧栏只剩 64px,汉堡按钮水平居中。
用**位移 padding-left** 而不是 `justify-content: center` —— 后者不可过渡,会在动画中途"跳"一下。
汉堡宽 28px,padding-left 18px ⇒ 中心 = 18 + 14 = 32px,正好是 64/2,与折叠后的图标导轨同列
(冒烟断言中心差 ≤ 2px)。
这里刻意用**自己的类名**(`.is-collapsed`)而不是 `:has(.el-menu--collapse)`:
`:has()` 的失效会让 Chromium 标记整棵文档树重新计算样式。 */
.aside.ch-nav.is-collapsed .brand {
padding-left: 18px;
}
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
.brand-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex: 0 0 auto;
border-radius: 6px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
overflow: hidden;
transition:
width var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/*
* 品牌文字 / 菜单文字在折叠时**不靠 max-width 归零**(那要么写死一个魔法数字,要么
* `none → 0` 根本不可过渡),而是:
* - `min-width: 0` 让这个 flex 子项能被压缩(默认的 `min-width: auto` 会撑住内容宽度);
* - 宽度自然跟着栏宽收缩,多出来的字被 `overflow: hidden` 裁掉;
* - 再叠一层 opacity 淡出,避免在中间帧里留下"半截字"。
* 于是文字宽度是**跟着侧栏宽度连续变化**的,不需要给它单独设动画终点。
*/
.brand-text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
letter-spacing: 0.2px;
transition: opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 折叠:品牌区收成 0 宽并淡出(不是删节点,理由见模板上的注释) */
.aside.ch-nav.is-collapsed .brand {
gap: 0;
}
/*
* 图标导轨里**不显示滚动条**:所有图标应当一眼看全(用户要求"不要用滚轮、删掉滚动条")。
* 导轨之所以放得下,靠的是页脚在折叠态把高度也收成 0(见 .is-collapsed .aside-footer)——
* 不再有那 ~70px 的留白,11 行图标(462px)在 540px 的最小窗口里也够。
* 这里只隐藏滚动条本身,`overflow-y: auto` 保留:窗口被拉到极矮时还能滚,不至于把图标直接裁掉。
* 展开态**不隐藏** —— 那一栏确实会超(设置组展开时约 606px > 582px),
* 滚动条是"下面还有内容"的唯一提示,不能拿掉。
*/
.aside.ch-nav.is-collapsed .menu {
scrollbar-width: none;
}
.aside.ch-nav.is-collapsed .brand-logo {
width: 0;
opacity: 0;
}
.aside.ch-nav.is-collapsed .brand-text {
opacity: 0;
} }
.menu { .menu {
flex: 1; flex: 1;
/* min-height:0 是 flex 子项能滚动的关键:否则内容撑高、底部 footer 被挤出窗口 */
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-right: none; border-right: none;
background: transparent; background: transparent;
--el-menu-text-color: #cbd5e1; --el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08); --el-menu-hover-bg-color: var(--ch-hover-soft);
--el-menu-active-color: #fff; --el-menu-text-color: var(--ch-text);
--el-menu-active-color: var(--ch-text);
} }
.menu :deep(.el-menu-item) { /* 图标槽:作为未读红点的定位父级(红点在图标右上角) */
margin: 2px 8px; .menu-icon-slot {
border-radius: 6px; position: relative;
} display: inline-flex;
align-items: center;
.menu :deep(.el-menu-item.is-active) { justify-content: center;
background: #409eff;
} }
/*
* 未读红点:定位到图标"右上角外侧"。
* el-badge 默认把角标中线压在包裹元素右上角(translateY(-50%) translateX(100%)),
* 这里显式覆盖成相对图标槽的负 top / 负 right,保证红点在图标右上方而不是右侧或下方。
*/
.menu-badge { .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 { .aside-footer {
padding: 10px 16px 14px; padding: 10px 16px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.08); border-top: 1px solid var(--ch-divider);
overflow: hidden;
/* max-height 是给折叠动画用的终点:`* { box-sizing: border-box }`,展开态给一个略高于
内容(约 70px)的值,折叠时归零 —— 导轨里**一个像素都不给页脚留**。
不这么做的话,文字淡出后仍占着 ~70px,10 个图标在 540px 的最小窗口里就放不下、被迫滚动
(用户反馈:"图标无法完全显示、需要上下滑动、滚动条碍眼")。 */
max-height: 96px;
transition:
opacity calc(var(--nav-dur) * 0.7) var(--nav-ease),
padding var(--nav-dur) var(--nav-ease),
max-height var(--nav-dur) var(--nav-ease),
border-top-color var(--nav-dur) var(--nav-ease);
}
/* 折叠:页脚淡出、内边距与高度一起收到 0,分隔线也透明掉。
`min-height: 0` 是必需的:flex 子项默认 `min-height: auto`(= 内容高度),
而 CSS 里 min-height 优先于 max-height —— 不显式归零的话 max-height:0 根本压不下去。 */
.aside.ch-nav.is-collapsed .aside-footer {
opacity: 0;
padding-top: 0;
padding-bottom: 0;
max-height: 0;
min-height: 0;
border-top-color: transparent;
pointer-events: none;
} }
.aside-meta { .aside-meta {
font-size: 12px; font-size: 12px;
color: #8fa3b8; color: var(--ch-text-tertiary);
line-height: 1.8; line-height: 1.9;
} }
.header { .header {
background: #fff; height: 52px;
background: var(--ch-header-bg);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--ch-border); border-bottom: 1px solid var(--ch-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 0 16px;
} }
.header-left { .header-left {
@@ -240,15 +526,38 @@ onUnmounted(() => {
.server-url { .server-url {
font-size: 12px; font-size: 12px;
color: #909399; color: var(--ch-text-tertiary);
}
.header-right {
/* 必须是 flex:Element Plus 的按钮是 `vertical-align: middle`、下拉容器是 `vertical-align: top`,
放在普通 block 容器里两个 inline-level 子元素会按各自的垂直对齐规则落位,主题切换按钮与
用户名牌就会错开几像素(用户反馈的"深色模式没对齐",其实两种主题下都偏,只是深色下更显眼)。
与 Web 管理端 AdminLayout 的同名容器保持同一套写法。 */
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
} }
.user-chip { .user-chip {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 8px;
border-radius: var(--ch-radius-control);
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: background 0.15s ease;
}
.user-chip:hover {
background: var(--ch-hover-soft);
}
.theme-toggle {
/* 与用户名牌的间距由 .header-right 的 gap 负责,这里不再自带 margin */
color: var(--ch-text-secondary);
} }
.main { .main {
+18 -6
View File
@@ -2,26 +2,38 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import ElementPlus from 'element-plus'; import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn'; import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'element-plus/dist/index.css'; import 'element-plus/dist/index.css';
// 深色主题:html.dark 下的 --el-* 变量(品牌覆盖在 styles/index.css 的 html.dark 块)
import 'element-plus/theme-chalk/dark/css-vars.css';
import App from './App.vue'; import App from './App.vue';
import router from './router/index.js'; import router from './router/index.js';
import { registerSmokeHooks } from './cache/self-test.js'; import { registerAppIcons } from './icons.js';
import './styles/index.css'; import './styles/index.css';
const app = createApp(App); const app = createApp(App);
for (const [name, component] of Object.entries(ElementPlusIconsVue)) { // 按需注册图标(见 icons.ts:只注册用到的那些,而不是全量 293 个)
app.component(name, component); registerAppIcons(app);
}
app.use(createPinia()); app.use(createPinia());
app.use(router); app.use(router);
app.use(ElementPlus, { locale: zhCn }); app.use(ElementPlus, { locale: zhCn });
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在) // 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)。
//
// **动态 import 是刻意的**:`cache/self-test.ts` 有 1000 多行自检代码,
// 静态 import 会把它打进渲染进程入口 chunk,每次启动都要解析一遍。改成动态 import 后
// Vite/Rollup 会把它切成独立 chunk,只在冒烟模式下才加载(`verify:desktop` /
// `verify:packaged` 的那条路径)。
//
// 教师凭据由 preload 从环境变量透传(**不写在代码里**:渲染产物会进 app.asar 发给学生机)。
if (window.desktop?.smokeTest) { if (window.desktop?.smokeTest) {
const { registerSmokeHooks, setSmokeTeacherCredentials } = await import('./cache/self-test.js');
setSmokeTeacherCredentials({
username: window.desktop.smokeCredentials?.username ?? '',
password: window.desktop.smokeCredentials?.password ?? '',
});
registerSmokeHooks(); registerSmokeHooks();
} }
@@ -41,11 +41,46 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/GradeView.vue'), component: () => import('../views/GradeView.vue'),
meta: { title: '成绩' }, meta: { title: '成绩' },
}, },
/* 设置:主侧边栏「设置」分组下的六个子页(ClassIsland 式 主菜单+子菜单) */
{ {
path: 'settings', path: 'settings',
name: 'settings', redirect: '/settings/general',
component: () => import('../views/SettingsView.vue'), },
meta: { title: '设置' }, {
path: 'settings/general',
name: 'settings-general',
component: () => import('../views/settings/GeneralSettingsView.vue'),
meta: { title: '通用设置' },
},
{
path: 'settings/appearance',
name: 'settings-appearance',
component: () => import('../views/settings/AppearanceView.vue'),
meta: { title: '外观' },
},
{
path: 'settings/island',
name: 'settings-island',
component: () => import('../views/settings/IslandSettingsView.vue'),
meta: { title: '灵动岛' },
},
{
path: 'settings/reminder',
name: 'settings-reminder',
component: () => import('../views/settings/ReminderSettingsView.vue'),
meta: { title: '提醒' },
},
{
path: 'settings/account',
name: 'settings-account',
component: () => import('../views/settings/AccountSettingsView.vue'),
meta: { title: '账号' },
},
{
path: 'settings/about',
name: 'settings-about',
component: () => import('../views/settings/AboutView.vue'),
meta: { title: '关于' },
}, },
], ],
}, },
@@ -1,7 +1,13 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; 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 { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
import { classChannelApi } from '../api/index.js';
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js'; import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js'; import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
@@ -19,6 +25,13 @@ export const useAppStore = defineStore('app', () => {
const currentWeek = ref(1); const currentWeek = ref(1);
const maxWeek = ref(20); const maxWeek = ref(20);
const initialized = ref(false); const initialized = ref(false);
/**
* 通知显示位置:这台教室机器把提醒弹在哪个端。
*
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。
*/
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL);
const offline = computed(() => !serverReachable.value); const offline = computed(() => !serverReachable.value);
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步')); const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
@@ -108,6 +121,53 @@ export const useAppStore = defineStore('app', () => {
void refreshCacheStats(); 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 { return {
serverUrl, serverUrl,
serverReachable, serverReachable,
@@ -119,9 +179,14 @@ export const useAppStore = defineStore('app', () => {
currentWeek, currentWeek,
maxWeek, maxWeek,
initialized, initialized,
notificationChannel,
init, init,
ping, ping,
applyServerUrl, applyServerUrl,
applyLocalNotificationChannel,
loadNotificationChannel,
setNotificationChannel,
shouldPopupLocally,
refreshCacheStats, refreshCacheStats,
clearCache, clearCache,
markSynced, markSynced,
@@ -1,6 +1,6 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; 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 { authApi } from '../api/index.js';
import { setApiBaseUrl, setTokenProvider } from '../api/http.js'; import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
import { cacheGet, cacheSet } from '../cache/db.js'; import { cacheGet, cacheSet } from '../cache/db.js';
@@ -15,13 +15,17 @@ const PROFILE_KEY = 'me';
*/ */
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null); const token = ref<string | null>(null);
const user = ref<StudentDto | null>(null); const user = ref<SessionUser | null>(null);
const loading = ref(false); const loading = ref(false);
const offlineSession = ref(false); const offlineSession = ref(false);
const isAuthenticated = computed(() => Boolean(token.value)); const isAuthenticated = computed(() => Boolean(token.value));
const displayName = computed(() => user.value?.name ?? '未登录'); const displayName = computed(() => user.value?.name ?? '未登录');
const classId = computed(() => user.value?.classId ?? null); const classId = computed(() => user.value?.classId ?? null);
/** true 表示当前是「班级账号(班级设备)」会话:个人数据按全班读写 */
const isClassSession = computed(() => user.value?.classSession === true);
/** 班级码(登录账号,便于设置页展示) */
const classCode = computed(() => user.value?.classCode ?? '');
setTokenProvider(() => token.value); setTokenProvider(() => token.value);
@@ -39,20 +43,26 @@ export const useAuthStore = defineStore('auth', () => {
await window.desktop.saveConfig({ serverUrl, username, token: nextToken }); 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); const normalized = normalizeServerUrl(serverUrl);
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000'); if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
setApiBaseUrl(normalized); setApiBaseUrl(normalized);
loading.value = true; loading.value = true;
try { try {
const payload: LoginResponse = await authApi.login({ username, password }); const payload: LoginResponse = await authApi.classLogin({ code, password });
applyToken(payload.token); applyToken(payload.token);
user.value = payload.user as StudentDto; user.value = payload.user;
offlineSession.value = false; offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, user.value); await cacheSet('profile', PROFILE_KEY, user.value);
await persistSession(normalized, username, payload.token); await persistSession(normalized, code, payload.token);
} finally { } finally {
loading.value = false; 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; if (cachedProfile) user.value = cachedProfile.value;
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据 // 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
@@ -122,6 +132,8 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated, isAuthenticated,
displayName, displayName,
classId, classId,
isClassSession,
classCode,
login, login,
restore, restore,
refreshProfile, refreshProfile,
@@ -0,0 +1,24 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
/**
* 初次启动引导的显示状态。
*
* 首次启动由 App.vue 按「配置里是否记过 onboardingDone」自动弹出;
* 登录页与设置页的「使用引导」入口调用 open() 随时重看。
* 单独一个 store 而不是组件内部 ref:触发入口(登录页 / 设置页)与
* 渲染点(App.vue 顶层)分处两棵互不相干的子树。
*/
export const useOnboardingStore = defineStore('onboarding', () => {
const visible = ref(false);
function open(): void {
visible.value = true;
}
function close(): void {
visible.value = false;
}
return { visible, open, close };
});
@@ -11,7 +11,8 @@ import {
type ServerToClientEvents, type ServerToClientEvents,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { socketUrlOf } from '../config.js'; 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 RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown) => void;
@@ -22,6 +23,18 @@ type EventHandler = (payload: unknown) => void;
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。 * 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
*/ */
export const useRealtimeStore = defineStore('realtime', () => { export const useRealtimeStore = defineStore('realtime', () => {
const app = useAppStore();
/**
* 本机要不要弹这次提醒。
*
* 教室在设置页选了「只在 ClassIsland 上弹」时,客户端仍然把通知收进通知中心(列表里有记录、
* 未读红点照常),但**不再弹窗、也不上岛** —— 否则老师会看到两个地方同时炸出来。
*/
function popupLocally(): boolean {
return app.shouldPopupLocally();
}
const connected = ref(false); const connected = ref(false);
const connecting = ref(false); const connecting = ref(false);
const lastEventName = ref<string | null>(null); const lastEventName = ref<string | null>(null);
@@ -65,23 +78,42 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => { instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.notificationNew); mark(SOCKET_EVENTS.notificationNew);
ElMessage({ if (popupLocally()) {
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`, ElMessage({
type: payload.priority === 'URGENT' ? 'error' : 'info', message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
duration: 5000, type: payload.priority === 'URGENT' ? 'error' : 'info',
showClose: true, duration: 5000,
}); showClose: true,
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出 });
pushNotificationToIsland(payload); // 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
pushNotificationToIsland(payload);
}
dispatch(SOCKET_EVENTS.notificationNew, payload); dispatch(SOCKET_EVENTS.notificationNew, payload);
}); });
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => { instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
mark(SOCKET_EVENTS.homeworkNew); mark(SOCKET_EVENTS.homeworkNew);
ElMessage.success(`新作业:${payload.title}`); ElMessage.success(`新作业:${payload.title}`);
// 作业发布也上岛:胶囊提示"新作业",点击展开看作业要求
pushHomeworkToIsland(payload);
dispatch(SOCKET_EVENTS.homeworkNew, 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 }) => { instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto & { deleted?: boolean }) => {
mark(SOCKET_EVENTS.homeworkUpdated); mark(SOCKET_EVENTS.homeworkUpdated);
dispatch(SOCKET_EVENTS.homeworkUpdated, payload); dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
@@ -0,0 +1,73 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import type { DesktopStoredConfig } from '../../types/desktop.js';
/**
* 界面偏好(主题 / 侧边栏折叠)。
*
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
*/
const THEME_KEY = 'classhelper.theme';
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
function applyThemeClass(theme: 'light' | 'dark'): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
export const useUiStore = defineStore('ui', () => {
const theme = ref<'light' | 'dark'>('light');
const sidebarCollapsed = ref(false);
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
async function init(): Promise<void> {
if (window.desktop) {
const config = await window.desktop.getConfig().catch(() => null);
if (config) {
theme.value = config.theme === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = config.sidebarCollapsed === true;
}
} else {
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
}
applyThemeClass(theme.value);
}
/** 切换主题(外观页单选 / 顶栏快捷切换共用),写回配置 */
async function setTheme(next: 'light' | 'dark'): Promise<void> {
theme.value = next;
applyThemeClass(next);
if (window.desktop) {
await window.desktop.saveConfig({ theme: next }).catch(() => undefined);
} else {
localStorage.setItem(THEME_KEY, next);
}
}
function toggleTheme(): void {
void setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
/** 主侧边栏折叠/展开(汉堡按钮),写回配置 */
async function setSidebarCollapsed(collapsed: boolean): Promise<void> {
sidebarCollapsed.value = collapsed;
if (window.desktop) {
await window.desktop.saveConfig({ sidebarCollapsed: collapsed }).catch(() => undefined);
} else {
localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0');
}
}
function toggleSidebar(): void {
void setSidebarCollapsed(!sidebarCollapsed.value);
}
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
});
/** 供类型检查使用(仅引用类型) */
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
@@ -1,7 +1,138 @@
/* ============================================================================
* 班级小助手客户端主题 —— 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)),层次主要靠描边与底色区分
*
* 主题机制:
* - 全部颜色走 --ch-* / --el-* 变量,:root 是浅色值;
* - 深色主题 = html 根元素挂 .dark 类(由 stores/ui.ts 按配置切换),
* Element Plus 的 dark/css-vars.css(main.ts 引入)提供 --el-* 深色默认,
* 下面的 html.dark 块覆盖品牌变量(特异性高于 :root,必定生效);
* - 组件里**不要写死颜色**,一律用变量——深色回归靠这条纪律守着。
* ========================================================================== */
:root { :root {
--ch-bg: #f5f7fa; /* Fluent 强调色 */
--ch-border: #e4e7ed; --ch-accent: #0f6cbd;
--ch-sidebar: #1f2d3d; --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-hover-soft: rgba(0, 0, 0, 0.04);
--ch-nav-bg: rgba(255, 255, 255, 0.65);
--ch-header-bg: rgba(255, 255, 255, 0.6);
--ch-scrollbar: rgba(0, 0, 0, 0.18);
--ch-scrollbar-hover: rgba(0, 0, 0, 0.3);
/* 圆角与阴影 */
--ch-radius-card: 8px;
--ch-radius-control: 4px;
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
/*
* 侧栏收放动画。组件(ClientLayout 的 .aside/.brand/.brand-logo/.aside-footer)
* 与全局的菜单导轨规则(下面的 .ch-nav.is-collapsed …)共用这一份参数,
* 改时长/缓动只改这里,两边不会漂。
*/
--nav-dur: 260ms;
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
/* 图标与文字的间距(折叠时收到 0) */
--nav-gap: 8px;
/* 兼容旧变量名 */
--ch-sidebar: #ffffff;
/* 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;
}
/* ---------------------------------------------------------------- 深色主题 */
html.dark {
--ch-accent: #5aabec;
--ch-accent-hover: #7cc0f4;
--ch-accent-soft: rgba(90, 171, 236, 0.16);
--ch-accent-soft-strong: rgba(90, 171, 236, 0.26);
--ch-bg: #161719;
--ch-layer: rgba(32, 33, 36, 0.72);
--ch-layer-solid: #1e1f23;
--ch-layer-alt: rgba(28, 29, 32, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.04);
--ch-border: rgba(255, 255, 255, 0.08);
--ch-border-strong: rgba(255, 255, 255, 0.14);
--ch-divider: rgba(255, 255, 255, 0.06);
--ch-text: #e8e8ea;
--ch-text-secondary: #b0b1b5;
--ch-text-tertiary: #7e7f85;
--ch-hover-soft: rgba(255, 255, 255, 0.07);
--ch-nav-bg: rgba(28, 29, 32, 0.65);
--ch-header-bg: rgba(30, 31, 34, 0.6);
--ch-scrollbar: rgba(255, 255, 255, 0.22);
--ch-scrollbar-hover: rgba(255, 255, 255, 0.36);
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.45);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.3);
--ch-sidebar: #1e1f23;
/* Element Plus 深色下的品牌色对齐(dark/css-vars.css 的默认蓝偏亮,这里对齐品牌) */
--el-color-primary: #4a9eff;
--el-color-primary-light-3: #337fd6;
--el-color-primary-light-5: #2563ab;
--el-color-primary-light-7: #1b4a82;
--el-color-primary-light-8: #163c6b;
--el-color-primary-light-9: #122f55;
--el-color-primary-dark-2: #6cb4ff;
--el-text-color-primary: #e8e8ea;
--el-text-color-regular: #c9cacd;
--el-text-color-secondary: #9a9b9f;
--el-border-color: rgba(255, 255, 255, 0.12);
--el-border-color-light: rgba(255, 255, 255, 0.08);
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
} }
* { * {
@@ -17,11 +148,15 @@ body,
} }
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); background: var(--ch-bg);
color: #303133; color: var(--ch-text);
/* 桌面端禁用整页选中,更接近原生应用体验 */ /* 桌面端禁用整页选中,更接近原生应用体验 */
user-select: none; user-select: none;
-webkit-font-smoothing: antialiased;
} }
input, input,
@@ -29,6 +164,8 @@ textarea {
user-select: text; user-select: text;
} }
/* ---------------------------------------------------------------- 页面骨架 */
.page { .page {
padding: 16px 20px 24px; padding: 16px 20px 24px;
} }
@@ -39,25 +176,26 @@ textarea {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 14px; margin-bottom: 12px;
} }
.page-title { .page-title {
margin: 0; margin: 0;
font-size: 18px; font-size: 20px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.2px;
} }
.page-subtitle { .page-subtitle {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 13px; font-size: 12.5px;
color: #909399; color: var(--ch-text-secondary);
} }
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -70,13 +208,286 @@ textarea {
} }
.text-muted { .text-muted {
color: #909399; color: var(--ch-text-tertiary);
} }
.offline-banner { .offline-banner {
margin: 0 0 12px; 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: var(--ch-layer) !important;
border-color: var(--ch-border-strong) !important;
}
.el-button--default:hover {
background: var(--ch-hover-soft) !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: var(--ch-layer) !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-card 同一套观感(分割线代替卡片描边) */
.el-collapse {
--el-collapse-border-color: var(--ch-divider);
border: none !important;
}
.el-collapse-item__header {
background: var(--ch-layer) !important;
border: 1px solid var(--ch-border) !important;
border-radius: var(--ch-radius-card) !important;
padding: 0 16px;
margin-top: 8px;
font-weight: 600;
font-size: 13.5px;
}
.el-collapse-item__wrap {
background: transparent !important;
border-bottom: none !important;
}
.el-collapse-item__content {
padding: 12px 6px 4px !important;
color: var(--ch-text);
}
/* ---------------------------------------------------------------- 表格 */
.el-table {
--el-table-border-color: var(--ch-divider);
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: var(--ch-hover-soft);
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: var(--ch-nav-bg);
backdrop-filter: blur(24px) saturate(1.2);
border-right: 1px solid var(--ch-border);
}
.ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
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,
.ch-nav .el-sub-menu__title:hover {
background: var(--ch-hover-soft);
}
/* 分组标题:比普通菜单项轻一档(分组是"容器",不是目的地) */
.ch-nav .el-sub-menu__title {
font-weight: 600;
color: var(--ch-text-secondary);
}
.ch-nav .el-sub-menu .el-menu {
background: transparent;
}
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
.ch-nav .el-menu-item.is-active {
background: var(--ch-accent-soft);
color: var(--ch-text);
}
.ch-nav .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--ch-accent);
}
/* ---------------------------------------------------------------- 折叠态:图标导轨
*
* 折叠**不再用** el-menu 的 `:collapse`:那会把每个分组的子项卸载进浮层(一次结构切换),
* 并让整条菜单重排一遍 —— 既做不出连续动画,也是"点汉堡卡一下"的渲染侧来源。
* 现在改成纯粹的 CSS 收拢:栏宽、文字宽度、图标位置全是可过渡属性,全程没有结构变化,
* 折叠后就是一条图标导轨(分组标题与子项都以图标列出)。
*
* 几何(64px 栏、菜单项左右各 8px margin):
* 展开 一级项 padding-left 20px、二级项 40px(Element Plus 自带)→ 图标中心 40 / 60px
* 折叠 所有项 padding-left / padding-right 都是 12px → 图标中心 8 + 12 + 12 = 32px,
* 正好与顶部汉堡按钮的居中位置同列(冒烟断言中心差 ≤ 2px)。
* 图标槽(.menu-icon-slot)与分组标题里的裸图标都是 Element Plus 给的 24px 宽。
*/
/* 图标与文字的间距改由文字的 margin-left 负责:Element Plus 给图标挂了 margin-right:5px,
那会让图标在 24px 的槽里偏左 2.5px(折叠时肉眼可见地不居中)。 */
.ch-nav .el-menu-item [class^='el-icon'],
.ch-nav .el-sub-menu__title > [class^='el-icon'] {
margin-right: 0;
}
.ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
transition:
padding-left var(--nav-dur) var(--nav-ease),
padding-right var(--nav-dur) var(--nav-ease),
border-color var(--el-transition-duration),
background-color var(--el-transition-duration),
color var(--el-transition-duration);
}
/*
* 文字:**不给它单独设动画终点**,宽度靠 flex 被压缩 —— `min-width:0` 让这个 flex 子项
* 能被压到 0(默认的 `min-width:auto` 会撑住内容宽度),多出来的字由 `overflow:hidden` 裁掉。
* 于是文字宽度天然跟着栏宽连续变化,不需要写什么魔法 max-width;再叠一层 opacity 淡出,
* 避免中间帧留下"半截字"(`display:none` 没法过渡,用不了)。
*/
.ch-nav .el-menu-item > span:not(.menu-icon-slot),
.ch-nav .el-sub-menu__title > span:not(.menu-icon-slot) {
min-width: 0;
margin-left: var(--nav-gap);
overflow: hidden;
white-space: nowrap;
transition:
margin-left var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 分组标题右侧的折叠箭头:导轨里没有它的位置 */
.ch-nav .el-sub-menu__icon-arrow {
transition: opacity calc(var(--nav-dur) * 0.5) var(--nav-ease);
}
.ch-nav.is-collapsed .el-menu-item > span:not(.menu-icon-slot),
.ch-nav.is-collapsed .el-sub-menu__title > span:not(.menu-icon-slot) {
margin-left: 0;
opacity: 0;
pointer-events: none;
}
.ch-nav.is-collapsed .el-sub-menu__icon-arrow {
opacity: 0;
}
/*
* 折叠后的内边距。
*
* 这三条选择器形状都是**照抄 Element Plus 的**,不是冗余 —— 它按层级用三个不同的选择器算缩进
* (一级项 20px、分组标题 20px、分组内子项 40px),特异度分别是 0-4-0 / 0-4-0 / 0-5-0:
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu__title`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu .el-menu-item`
* 写成 `.ch-nav.is-collapsed .el-sub-menu__title` 这种(0-3-0)压不住任何一条 —— 实测踩过两次:
* 第一版漏了分组标题(偏 8.0px),把「学习」拆成一级项后又漏了一级项(同样偏 8.0px)。
* 下面两条(0-5-0 / 0-6-0)把它们全覆盖,且不用 !important。
*/
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-menu-item,
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu__title {
padding-left: 12px;
padding-right: 12px;
}
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu .el-menu-item {
padding-left: 12px;
padding-right: 12px;
}
/* ---------------------------------------------------------------- 课表时间轴 */
.timetable-cell { .timetable-cell {
min-height: 30px; min-height: 30px;
} }
@@ -87,7 +498,7 @@ textarea {
.timetable-meta { .timetable-meta {
font-size: 12px; font-size: 12px;
color: #909399; color: var(--ch-text-tertiary);
} }
.unread-dot { .unread-dot {
@@ -95,19 +506,27 @@ textarea {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #f56c6c; background: #d13438;
margin-right: 6px; margin-right: 6px;
} }
/* 滚动条美化(Windows 默认滚动条较粗) */ /* ---------------------------------------------------------------- 滚动条 */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 10px;
height: 8px; height: 10px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #c8ccd4; background: var(--ch-scrollbar);
border-radius: 4px; border: 3px solid transparent;
background-clip: content-box;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ch-scrollbar-hover);
background-clip: content-box;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import * as echarts from 'echarts';
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
averageOf, averageOf,
@@ -11,18 +10,20 @@ import {
} from '@classhelper/shared'; } from '@classhelper/shared';
import { gradeApi } from '../api/index.js'; import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
import ScoreBarChart from '../components/ScoreBarChart.vue';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore(); const appStore = useAppStore();
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */
const auth = useAuthStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const loading = ref(false); const loading = ref(false);
const grades = ref<GradeDto[]>([]); const grades = ref<GradeDto[]>([]);
const fromCache = ref(false); const fromCache = ref(false);
const updatedAt = ref<number | null>(null); const updatedAt = ref<number | null>(null);
const chartRef = ref<HTMLDivElement>();
let chart: ReturnType<typeof echarts.init> | null = null;
const averagePercent = computed(() => const averagePercent = computed(() =>
grades.value.length === 0 grades.value.length === 0
@@ -50,6 +51,21 @@ const levelCounts = computed(() => {
return levels; return levels;
}); });
/**
* 按考试聚合平均得分率(最近 8 次考试),交给 ScoreBarChart 渲染。
* 这里原先调的是 `echarts.setOption(...)`;改用纯 CSS 柱状图是为了去掉
* echarts(渲染产物里最大的一块,1.1MB)。聚合口径与之前完全一致。
*/
const examAverages = computed(() => {
const byExam = new Map<string, number[]>();
for (const item of grades.value) {
const bucket = byExam.get(item.examName) ?? [];
bucket.push(gradePercent(item.score, item.totalScore));
byExam.set(item.examName, bucket);
}
return [...byExam.entries()].slice(-8).map(([label, percents]) => ({ label, value: averageOf(percents) }));
});
async function loadGrades(): Promise<void> { async function loadGrades(): Promise<void> {
loading.value = true; loading.value = true;
try { try {
@@ -58,50 +74,11 @@ async function loadGrades(): Promise<void> {
fromCache.value = result.fromCache; fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt; updatedAt.value = result.updatedAt;
if (!result.fromCache) appStore.markSynced(); if (!result.fromCache) appStore.markSynced();
renderChart();
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
function renderChart(): void {
if (!chartRef.value) return;
chart ??= echarts.init(chartRef.value);
// 按考试聚合平均得分率(最近 8 次考试)
const byExam = new Map<string, number[]>();
for (const item of grades.value) {
const bucket = byExam.get(item.examName) ?? [];
bucket.push(gradePercent(item.score, item.totalScore));
byExam.set(item.examName, bucket);
}
const exams = [...byExam.entries()].slice(-8);
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 44, right: 18, top: 30, bottom: 34 },
xAxis: {
type: 'category',
data: exams.map(([name]) => name),
axisLabel: { interval: 0, rotate: exams.length > 4 ? 20 : 0, fontSize: 11 },
},
yAxis: { type: 'value', max: 100, name: '得分率%' },
series: [
{
type: 'bar',
barMaxWidth: 46,
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top', formatter: '{c}%', fontSize: 11 },
data: exams.map(([, percents]) => averageOf(percents)),
},
],
});
}
function handleResize(): void {
chart?.resize();
}
function onGradeEvent(): void { function onGradeEvent(): void {
void loadGrades(); void loadGrades();
} }
@@ -112,15 +89,11 @@ function onRecovered(): void {
onMounted(async () => { onMounted(async () => {
await loadGrades(); await loadGrades();
window.addEventListener('resize', handleResize);
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent); realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.onServerRecovered(onRecovered); appStore.onServerRecovered(onRecovered);
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize);
chart?.dispose();
chart = null;
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent); realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.offServerRecovered(onRecovered); appStore.offServerRecovered(onRecovered);
}); });
@@ -130,9 +103,10 @@ onUnmounted(() => {
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">我的成绩</h2> <h2 class="page-title">{{ auth.isClassSession ? '本班成绩' : '我的成绩' }}</h2>
<p class="page-subtitle"> <p class="page-subtitle">
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}% 共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
<span v-if="auth.isClassSession">· 班级账号:显示全班成绩总览</span>
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag> <el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p> </p>
</div> </div>
@@ -168,7 +142,7 @@ onUnmounted(() => {
<el-col :xs="24" :md="16"> <el-col :xs="24" :md="16">
<el-card v-loading="loading" shadow="never"> <el-card v-loading="loading" shadow="never">
<template #header><span>各次考试平均得分率</span></template> <template #header><span>各次考试平均得分率</span></template>
<div ref="chartRef" class="chart"></div> <ScoreBarChart :items="examAverages" />
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
@@ -222,7 +196,7 @@ onUnmounted(() => {
<style scoped> <style scoped>
.stat-card { .stat-card {
background: #fff; background: var(--ch-layer-solid);
border: 1px solid var(--ch-border); border: 1px solid var(--ch-border);
border-radius: 10px; border-radius: 10px;
padding: 16px; padding: 16px;
@@ -230,7 +204,7 @@ onUnmounted(() => {
.stat-label { .stat-label {
font-size: 13px; font-size: 13px;
color: #909399; color: var(--ch-text-secondary);
} }
.stat-value { .stat-value {
@@ -242,7 +216,7 @@ onUnmounted(() => {
.stat-hint { .stat-hint {
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
color: #c0c4cc; color: var(--ch-text-tertiary);
} }
.level-list { .level-list {
@@ -251,9 +225,4 @@ onUnmounted(() => {
gap: 6px; gap: 6px;
margin-top: 8px; margin-top: 8px;
} }
.chart {
width: 100%;
height: 300px;
}
</style> </style>
File diff suppressed because it is too large. Load diff
@@ -5,16 +5,18 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { pingHealth, setApiBaseUrl } from '../api/http.js'; import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useOnboardingStore } from '../stores/onboarding.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js'; import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
const router = useRouter(); const router = useRouter();
const appStore = useAppStore(); const appStore = useAppStore();
const auth = useAuthStore(); const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' }); const form = reactive({ serverUrl: DEFAULT_SERVER, code: '', password: '' });
const testing = ref(false); const testing = ref(false);
const testResult = ref<'ok' | 'fail' | null>(null); const testResult = ref<'ok' | 'fail' | null>(null);
@@ -29,7 +31,7 @@ const rules: FormRules = {
trigger: 'blur', trigger: 'blur',
}, },
], ],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], code: [{ required: true, message: '请输入班级码', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}; };
@@ -38,7 +40,7 @@ onMounted(async () => {
if (window.desktop) { if (window.desktop) {
const config = await window.desktop.getConfig(); const config = await window.desktop.getConfig();
if (config.serverUrl) form.serverUrl = config.serverUrl; if (config.serverUrl) form.serverUrl = config.serverUrl;
if (config.username) form.username = config.username; if (config.username) form.code = config.username;
} }
}); });
@@ -66,10 +68,12 @@ async function submit(): Promise<void> {
if (!valid) return; if (!valid) return;
try { 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); await appStore.init(form.serverUrl);
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
await appStore.loadNotificationChannel(auth.classId);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token); if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
ElMessage.success(`欢迎回来,${auth.displayName}`); ElMessage.success(`已进入 ${auth.displayName}`);
await router.replace('/schedule'); await router.replace('/schedule');
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败'); ElMessage.error(error instanceof Error ? error.message : '登录失败');
@@ -92,7 +96,7 @@ async function enterOffline(): Promise<void> {
<el-icon :size="30" color="#409eff"><School /></el-icon> <el-icon :size="30" color="#409eff"><School /></el-icon>
<div> <div>
<h1 class="login-title">班级小助手</h1> <h1 class="login-title">班级小助手</h1>
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p> <p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
</div> </div>
</div> </div>
@@ -105,19 +109,19 @@ async function enterOffline(): Promise<void> {
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="用户名" prop="username"> <el-form-item label="班级码" prop="code">
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit"> <el-input v-model="form.code" placeholder="请输入班级码" clearable @keyup.enter="submit">
<template #prefix> <template #prefix>
<el-icon><User /></el-icon> <el-icon><School /></el-icon>
</template> </template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="班级密码" prop="password">
<el-input <el-input
v-model="form.password" v-model="form.password"
type="password" type="password"
placeholder="密码" placeholder="班级密码"
show-password show-password
@keyup.enter="submit" @keyup.enter="submit"
> >
@@ -132,6 +136,14 @@ async function enterOffline(): Promise<void> {
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button> <el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
</div> </div>
<!-- 重看初次启动引导:首次启动会自动弹出过一次,这里供随时回看 -->
<div class="login-guide">
<el-button link type="primary" @click="onboarding.open()">
<el-icon><Guide /></el-icon>
查看使用引导
</el-button>
</div>
<el-alert <el-alert
v-if="testResult === 'ok'" v-if="testResult === 'ok'"
class="mt-12" class="mt-12"
@@ -156,9 +168,9 @@ async function enterOffline(): Promise<void> {
</el-button> </el-button>
<p class="login-tip"> <p class="login-tip">
演示账号:student01 / student123(高一(1)班) 班级码与班级密码由管理员在 Web 管理端「班级管理 → 班级账号」中设置或重置。
<br /> <br />
数据来源:教师或管理员在 Web 管理端发布的内容。 登录后本机即代表整个班级:作业完成、通知已读都会按全班记录。
</p> </p>
</div> </div>
</div> </div>
@@ -175,7 +187,7 @@ async function enterOffline(): Promise<void> {
.login-card { .login-card {
width: 420px; width: 420px;
background: #fff; background: var(--ch-layer-solid);
border-radius: 14px; border-radius: 14px;
padding: 28px 26px 20px; padding: 28px 26px 20px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28); box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
@@ -196,7 +208,7 @@ async function enterOffline(): Promise<void> {
.login-sub { .login-sub {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
color: #909399; color: var(--ch-text-secondary);
} }
.login-actions { .login-actions {
@@ -208,6 +220,12 @@ async function enterOffline(): Promise<void> {
flex: 1; flex: 1;
} }
.login-guide {
display: flex;
justify-content: center;
margin-top: 10px;
}
.offline-button { .offline-button {
width: 100%; width: 100%;
} }
@@ -216,6 +234,6 @@ async function enterOffline(): Promise<void> {
margin: 16px 0 0; margin: 16px 0 0;
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #c0c4cc; color: var(--ch-text-tertiary);
} }
</style> </style>
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more