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(需后端启动)。
This commit is contained in:
LoveChengke committed 2026-09-27 18:08:51 +08:00
1 parent 7536abfc95
commit 5c27586af9
12 files changed
+648 -111

No files matched your search

+19 -3
View File
@@ -255,17 +255,33 @@ pnpm dist:classisland-plugin
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处: 服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。 客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
16. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段 16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课),
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
服务端 `synthesizeTimeLayout` 同口径。
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);`urgent` 同样立刻弹。 客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);`urgent` 同样立刻弹。
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件), 插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。 客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。
17. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期 19. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期
(`YYYY-MM-DD`,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。 (`YYYY-MM-DD`,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。
UTC 切片(`toISOString().slice(0,10)`)在晚上的录入会算到第二天 —— 统一用 `@classhelper/shared` 的 UTC 切片(`toISOString().slice(0,10)`)在晚上的录入会算到第二天 —— 统一用 `@classhelper/shared` 的
`dayKeyLocal / isDayKey / shiftDayKey`。新增按天接口时同样用 `?date=YYYY-MM-DD` + zod 的 `isDayKey` 校验。 `dayKeyLocal / isDayKey / shiftDayKey`。新增按天接口时同样用 `?date=YYYY-MM-DD` + zod 的 `isDayKey` 校验。
18. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在 20. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在
`homeworks.service.createHomework` 里判(staff 或本班班级账号)。班级账号的 `sub` 是**班级 id**, `homeworks.service.createHomework` 里判(staff 或本班班级账号)。班级账号的 `sub` 是**班级 id**,
写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键。 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键。
21. **灵动岛不要用窗口背景材质(`win.setBackgroundMaterial()`)**。它看起来像 WinIsland 的
`DWMWA_USE_HOSTBACKDROPBRUSH`,其实是**整窗系统材质**:刷在整个窗口矩形上(岛是"固定大包围盒窗口",
卡片之外还有留白),且明暗跟随应用主题(本客户端是浅色 Fluent 主题 ⇒ 材质必然是浅色),
于是卡片周围会出现一圈浅色"白底面板"。更坑的是**调用过一次就再也回不去逐像素透明**
(含 `'none'`:Electron 会让窗口回落到默认不透明底色 `#FFF`),所以从 `glass` 切到
`black`/`tinted` 后会永久留一圈**纯白**。CSS `backdrop-filter` 也替代不了(透明窗口里采样不到桌面)。
`glass` 因此用 CSS 半透明深色卡(WinIsland 无 backdrop 时的降级色 `rgba(32,32,36,.804)`)。
回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它**整屏截图**取样
(`webContents.capturePage()` 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因),
并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。
## 6. 代码风格 ## 6. 代码风格
+12 -6
View File
@@ -426,7 +426,7 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
> 冒烟用 DOM 几何断言(`dx > 0 且 dy < 0`)守住。 > 冒烟用 DOM 几何断言(`dx > 0 且 dy < 0`)守住。
> **视觉与排版参考 [WinIsland](https://github.com/WinIslandProject/WinIsland)(Rust/Direct2D 的 Windows 动态岛)重写**: > **视觉与排版参考 [WinIsland](https://github.com/WinIslandProject/WinIsland)(Rust/Direct2D 的 Windows 动态岛)重写**:
> 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/亚克力毛玻璃/主题色三种底、 > 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/半透明毛玻璃/主题色三种底、
> Apple 系统色强调色、白字 alpha 分级、**所有文本 = 基础字号 × 排版系数**、 > Apple 系统色强调色、白字 alpha 分级、**所有文本 = 基础字号 × 排版系数**、
> 胶囊 `h/2` 满圆角 + 展开卡 `min(48, w/2, h/2)`(WinIsland `expanded_island_radius`)、 > 胶囊 `h/2` 满圆角 + 展开卡 `min(48, w/2, h/2)`(WinIsland `expanded_island_radius`)、
> 顶/底 × 左/中/右 **6 个停靠位**、可选"空闲细缝"(WinIsland `hidden_width`)。 > 顶/底 × 左/中/右 **6 个停靠位**、可选"空闲细缝"(WinIsland `hidden_width`)。
@@ -537,6 +537,9 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
### 个性化设置与系统托盘 ### 个性化设置与系统托盘
- **设置 → 灵动岛 · 个性化**新增:左右 / 上下**边距**(贴边距离,任务栏在侧面时调大)、 - **设置 → 灵动岛 · 个性化**新增:左右 / 上下**边距**(贴边距离,任务栏在侧面时调大)、
**跟随鼠标屏幕**(多显示器教室电脑:岛出现在鼠标所在那块屏)。
> 注意:**左右边距只在"左 / 右停靠"时生效**;默认的"居中停靠"下水平锚点就是屏幕中线,
> 设置页会把该滑块置灰并说明(否则会以为"调了没反应")。
- **设置 → ClassIsland 联动**:联动状态(设备名 / 最后上报)+ 通知模式(两者都弹 / 灵动岛 / ClassIsland)。 - **设置 → ClassIsland 联动**:联动状态(设备名 / 最后上报)+ 通知模式(两者都弹 / 灵动岛 / ClassIsland)。
- **设置 → 作业录入**:作业快捷短语的增删与恢复默认。 - **设置 → 作业录入**:作业快捷短语的增删与恢复默认。
@@ -545,7 +548,7 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
**设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效): **设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效):
| 设置项 | 区间/取值 | 说明 | | 设置项 | 区间/取值 | 说明 |
| ------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+186/+202/+218px`),内容用 flex 承载且不溢出 | | 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+186/+202/+218px`),内容用 flex 承载且不溢出 |
| 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动(`+156/+172/+188px`) | | 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动(`+156/+172/+188px`) |
| 圆角 | 8–32px | 胶囊取 `min(圆角, h/2)`(WinIsland 胶囊是满圆角);展开卡取 `min(48×圆角/20, w/2, h/2)` | | 圆角 | 8–32px | 胶囊取 `min(圆角, h/2)`(WinIsland 胶囊是满圆角);展开卡取 `min(48×圆角/20, w/2, h/2)` |
@@ -553,7 +556,7 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
| **字号** | **11–20px** | **基础字号**:岛内所有文本 = 基础字号 × 排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),改一处整体等比缩放 | | **字号** | **11–20px** | **基础字号**:岛内所有文本 = 基础字号 × 排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),改一处整体等比缩放 |
| 动画速度 | 0.5×–2× | 形变/淡入时长倍率 | | 动画速度 | 0.5×–2× | 形变/淡入时长倍率 |
| 主题色 | 取色器(默认 Apple 系统蓝 `#0a84ff`) | 用于高亮、按钮、进度条、主题色风格(CSS 变量下发到灵动岛渲染进程) | | 主题色 | 取色器(默认 Apple 系统蓝 `#0a84ff`) | 用于高亮、按钮、进度条、主题色风格(CSS 变量下发到灵动岛渲染进程) |
| **视觉风格** | **纯黑 / 毛玻璃(亚克力)/ 主题色渐变** | 纯黑 = WinIsland `default`;毛玻璃调用 `win.setBackgroundMaterial('acrylic')`(真·桌面模糊,等价 HostBackdropBrush) | | **视觉风格** | **纯黑 / 毛玻璃(半透明)/ 主题色渐变** | 纯黑 = WinIsland `default`;毛玻璃 = **CSS 半透明深色卡**(WinIsland 无 host backdrop 时的降级色 `rgba(32,32,36,.804)`)。**不用** `setBackgroundMaterial()`:它是整窗系统材质,会跟随应用主题把卡片外那一圈垫成白底(详见 AGENTS.md §5 第 21 条) |
| **显示位置** | **顶部/底部 × 左/中/右(6 个锚点)** | 按 `workArea` 计算;顶/底决定不动的是上边还是下边,左/中/右决定水平锚点 | | **显示位置** | **顶部/底部 × 左/中/右(6 个锚点)** | 按 `workArea` 计算;顶/底决定不动的是上边还是下边,左/中/右决定水平锚点 |
| 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) | | 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) |
| 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` | | 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` |
@@ -670,7 +673,8 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| **紧急叫人(URGENT + 叫人)** | ✅ 上课中也立即展开:`mode=expanded kind=call reason=call 徽标=叫人 按钮=收到` | | **紧急叫人(URGENT + 叫人)** | ✅ 上课中也立即展开:`mode=expanded kind=call reason=call 徽标=叫人 按钮=收到` |
| **普通叫人(HIGH + 叫人)** | ✅ 上课只进队列:`mode=hidden active≠smoke-call-normal queued=[…smoke-call-normal]`;下课后自动弹出 `active=smoke-call-normal kind=call` | | **普通叫人(HIGH + 叫人)** | ✅ 上课只进队列:`mode=hidden active≠smoke-call-normal queued=[…smoke-call-normal]`;下课后自动弹出 `active=smoke-call-normal kind=call` |
| **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** | | **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** |
| **WinIsland 风格:三种底即时切换** | ✅ 纯黑 `fill=rgb(0,0,0)`/材质 `none`;毛玻璃 `fill=rgba(10,10,14,.59)`/材质 **`acrylic`**;主题色 `fill=rgb(11,11,15)` | | **WinIsland 风格:三种底即时切换** | ✅ 纯黑 `fill=rgb(0,0,0)`;毛玻璃 `fill=rgba(32,32,36,.804)`;主题色 `fill=rgb(11,11,15)` |
| **卡片外圈不透底(无白底面板)** | ✅ 三种风格都**整屏截图**取证(岛下垫已知色底板):环亮度 = 底板亮度、差 0、近场白底像素 0;把 `setBackgroundMaterial()` 加回去立刻复现 环=255/148/255(差 231/124/252、白底像素 7000+) |
| **WinIsland 空闲细缝** | ✅ 空闲时岛收成 `6x22`(窗口固定,不再受 Windows 36px 最小窗口高度限制)→ 来消息自动回到 `268x44`;关闭细缝后空闲 `isVisible=false` | | **WinIsland 空闲细缝** | ✅ 空闲时岛收成 `6x22`(窗口固定,不再受 Windows 36px 最小窗口高度限制)→ 来消息自动回到 `268x44`;关闭细缝后空闲 `isVisible=false` |
| **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` | | **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` |
| **个性化:停靠位置(6 个锚点)** | ✅ 左上 `8,8` / 右上 `1216,8` / 底部居中 `586,800` / 底部左 / 底部右 / 顶部居中 `586,8` | | **个性化:停靠位置(6 个锚点)** | ✅ 左上 `8,8` / 右上 `1216,8` / 底部居中 `586,800` / 底部左 / 底部右 / 顶部居中 `586,8` |
@@ -704,6 +708,8 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
教室机器上只做"看作业 + 录作业"(服务端的完成数据与统计接口保持不变,Web 端照旧可用)。 教室机器上只做"看作业 + 录作业"(服务端的完成数据与统计接口保持不变,Web 端照旧可用)。
- 录入时可以点**快捷短语**(默认 `P` / `大本` / `小本` / `卷子` / `背诵` / `默写` / `听写` / `预习` / `订正` / `读书`), - 录入时可以点**快捷短语**(默认 `P` / `大本` / `小本` / `卷子` / `背诵` / `默写` / `听写` / `预习` / `订正` / `读书`),
点一下追加到标题或内容;短语在客户端「设置 → 作业录入」里增删,存在本机配置里。 点一下追加到标题或内容;短语在客户端「设置 → 作业录入」里增删,存在本机配置里。
- **课间**:镜像回 ClassIsland 的课表按「每两节之间一段课间、最后一节之后不补」生成作息;
服务端合成的节次时间表(没有导入时间表时的兜底)也是同一口径。
- 接口:`GET /homeworks?date=YYYY-MM-DD`(按天)、`GET /homeworks/days?classId=&from=&to=`(哪些天有作业)、 - 接口:`GET /homeworks?date=YYYY-MM-DD`(按天)、`GET /homeworks/days?classId=&from=&to=`(哪些天有作业)、
`POST /homeworks`(附 `assignDate`;**班级账号也能调用**,用于教室机器录入)。 `POST /homeworks`(附 `assignDate`;**班级账号也能调用**,用于教室机器录入)。
@@ -1349,11 +1355,11 @@ pnpm db:migrate && pnpm db:seed
阶段 11(参考 WinIsland 重写灵动岛)新增文件: 阶段 11(参考 WinIsland 重写灵动岛)新增文件:
| 路径 | 说明 | | 路径 | 说明 |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------- | | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `docs/winisland-design-tokens.md` | 从 WinIsland 源码逐条提取的设计 token(几何/配色/排版/层级/布局/图标 + 动效与设置模型) | | `docs/winisland-design-tokens.md` | 从 WinIsland 源码逐条提取的设计 token(几何/配色/排版/层级/布局/图标 + 动效与设置模型) |
| `packages/desktop-client/src/island/squircle.ts` | 连续圆角(超椭圆角)路径生成,对齐 WinIsland `utils/shape.rs` 的 continuous rounded rect | | `packages/desktop-client/src/island/squircle.ts` | 连续圆角(超椭圆角)路径生成,对齐 WinIsland `utils/shape.rs` 的 continuous rounded rect |
| `packages/desktop-client/src/island/IslandApp.vue` | 灵动岛 UI 全量重写:squircle + 三种底 + Apple 系统色 + 字号系数驱动的排版 + 无回弹过渡 | | `packages/desktop-client/src/island/IslandApp.vue` | 灵动岛 UI 全量重写:squircle + 三种底 + Apple 系统色 + 字号系数驱动的排版 + 无回弹过渡 |
| `packages/desktop-client/src/main/island.ts` | 固定包围盒窗口、6 锚点、空闲细缝、亚克力材质、鼠标命中开关;岛尺寸增量与渲染层共用同一常量表 | | `packages/desktop-client/src/main/island.ts` | 固定包围盒窗口、6 锚点、空闲细缝、鼠标命中开关;岛尺寸增量与渲染层共用同一常量表(**刻意不设窗口背景材质**,见 AGENTS.md §7) |
阶段 10(学生端班级账号)新增文件: 阶段 10(学生端班级账号)新增文件:
+8 -3
View File
@@ -546,6 +546,8 @@ VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
2. **内阴影 / inset shadow**:代码中无任何实现 2. **内阴影 / inset shadow**:代码中无任何实现
3. **字距(letter-spacing)与行高倍数**:无;行距全靠显式 y 偏移 3. **字距(letter-spacing)与行高倍数**:无;行距全靠显式 y 偏移
4. **亚克力的模糊半径/透明度数值**:由 Windows `HostBackdropBrush` 系统决定,代码只给 `HOST_BACKDROP_INSET = 1.0` 4. **亚克力的模糊半径/透明度数值**:由 Windows `HostBackdropBrush` 系统决定,代码只给 `HOST_BACKDROP_INSET = 1.0`
→ Electron 侧**没有**等价物:`setBackgroundMaterial('acrylic')` 是整窗系统材质(跟随应用主题、覆盖窗口矩形),
不能像 `DWMWA_USE_HOSTBACKDROPBRUSH` 那样只画在圆角形状里;本项目因此改用无 backdrop 的降级色(见 §8)
5. **强调色用于岛内 UI**:岛内几乎没有强调色(只有资源占用的 CPU/RAM/警告/危险色); 5. **强调色用于岛内 UI**:岛内几乎没有强调色(只有资源占用的 CPU/RAM/警告/危险色);
统一强调色 `#0A84FF` 仅存在于**设置窗口**(`COLOR_ACCENT`) 统一强调色 `#0A84FF` 仅存在于**设置窗口**(`COLOR_ACCENT`)
6. **岛自身的亮/暗主题**:岛屿永远是深底白字;明暗主题只在设置窗口 6. **岛自身的亮/暗主题**:岛屿永远是深底白字;明暗主题只在设置窗口
@@ -561,7 +563,10 @@ VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
(`cubic-bezier` 或 JS 弹簧,参考 `stiffness 0.10 / damping 0.68`);`overflow: hidden` 做裁切 (`cubic-bezier` 或 JS 弹簧,参考 `stiffness 0.10 / damping 0.68`);`overflow: hidden` 做裁切
- compact:`120×27`(有歌词时宽度跟着文本走,上限 700;只有媒体无歌词时 155),`border-radius: 13.5px`(胶囊) - compact:`120×27`(有歌词时宽度跟着文本走,上限 700;只有媒体无歌词时 155),`border-radius: 13.5px`(胶囊)
- expanded:`360×200`,`border-radius: 48px`(想要 iOS 味请用 SVG 超椭圆路径) - expanded:`360×200`,`border-radius: 48px`(想要 iOS 味请用 SVG 超椭圆路径)
- 背景:`#000` 不透明;"glass" 用 `backdrop-filter` + `background: rgba(10,10,14,.588)`; - 背景:`#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)` 叠加 "dynamic" 用封面模糊层 + `rgba(20,20,24,.471)` 叠加
- 描边:`1px solid rgba(255,255,255,.118)`(纯色样式,展开时淡出)/ `.157`(glass、dynamic) - 描边:`1px solid rgba(255,255,255,.118)`(纯色样式,展开时淡出)/ `.157`(glass、dynamic)
- 阴影:仅展开 `0 2px σ rgba(0,0,0,.11)`(σ ≈ 3px) - 阴影:仅展开 `0 2px σ rgba(0,0,0,.11)`(σ ≈ 3px)
@@ -573,14 +578,14 @@ VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
## 附:班级小助手实际采纳了哪些(本次重写对照表) ## 附:班级小助手实际采纳了哪些(本次重写对照表)
| WinIsland 做法 | 出处 | 我们的落地 | 验证 | | WinIsland 做法 | 出处 | 我们的落地 | 验证 |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| **窗口一次建大包围盒,形变发生在窗口内** | `window/app/layout.rs` `required_window_size()` + `PADDING=80` | 窗口固定为「最大形态 + `ISLAND_SHADOW_PAD(10)`」,开合期间**一帧都不 setBounds** | 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种) | | **窗口一次建大包围盒,形变发生在窗口内** | `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/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) | | 连续圆角(超椭圆) | `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 断言 | | 内容交叉淡入 `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`) | 计算样式断言 | | 描边 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))` | 截图 | | 展开投影 `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` | 纯黑底 / `win.setBackgroundMaterial('acrylic')` | 计算样式 + `getBackgroundMaterial()` | | 纯黑底 / 亚克力(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 | | `set_cursor_hittest` 命中控制 | `frame.rs`(`set_cursor_hittest`) | 默认 `setIgnoreMouseEvents(true,{forward:true})`,渲染层命中测试后切换 | 岛内 true / 岛外 false |
| 空闲细缝 `hidden_width = 5` | `core/config.rs` | 6px 宽竖条(窗口固定,不再受 Windows 最小窗口高度限制) | 空闲 `6x22` → 来消息 `268x44` | | 空闲细缝 `hidden_width = 5` | `core/config.rs` | 6px 宽竖条(窗口固定,不再受 Windows 最小窗口高度限制) | 空闲 `6x22` → 来消息 `268x44` |
| 6 个停靠位 `DockPosition` | `core/config.rs` | 顶/底 × 左/中/右 6 锚点,岛(而非窗口)精确落在锚点 | 6 锚点坐标断言 | | 6 个停靠位 `DockPosition` | `core/config.rs` | 顶/底 × 左/中/右 6 锚点,岛(而非窗口)精确落在锚点 | 6 锚点坐标断言 |
+4
View File
@@ -46,6 +46,10 @@
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。 > 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
> 暂存期间不回执,服务端会保留这条待提醒。 > 暂存期间不回执,服务端会保留这条待提醒。
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
> 不依赖班级小助手那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
> **两个方向各自需要的开关**: > **两个方向各自需要的开关**:
> >
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开; > - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
@@ -462,24 +462,26 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
} }
/// <summary> /// <summary>
/// 优先用"当前正在生效"的课表对应的时间表;取不到再退回档案里的第一个。 /// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。
/// 有多个时间表时选错会把节次时间报错,所以先按当前课表找。 ///
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
/// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。
/// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。
/// </summary> /// </summary>
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout( private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
ClassIsland.Shared.Models.Profile.Profile profile) ClassIsland.Shared.Models.Profile.Profile profile)
{ {
if (profile.TimeLayouts.Count == 0) return null; if (profile.TimeLayouts.Count == 0) return null;
var current = profile.SelectedClassPlanGroupId != Guid.Empty var best = profile.TimeLayouts
? CurrentClassPlan(profile) .Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0)))
: null; .Where(item => item.ClassPoints > 0)
if (current is not null) .OrderByDescending(item => item.ClassPoints)
{ .ToList();
var layout = ScheduleMapper.ResolveTimeLayout(current, profile); if (best.Count > 0) return best[0].Value;
if (layout is not null) return layout;
}
// 退而求其次:档案里被激活的时间表,最后才是任意一个 // 兜底:档案里被激活的时间表,最后才是任意一个
return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated) return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated)
?? profile.TimeLayouts.Values.First(); ?? profile.TimeLayouts.Values.First();
} }
@@ -51,11 +51,6 @@ public static class ClassPlanWriter
} }
var layout = source.TimeLayouts[0]; var layout = source.TimeLayouts[0];
// 课间 / 分割线 / 行动点:按起始时间索引,供"每条课目后接上对应课间"使用
var breakPoints = layout.Layouts
.Where(item => item.TimeType != 0)
.GroupBy(item => item.StartTime)
.ToDictionary(group => group.Key, group => group.First());
// 1) 补齐科目(同名的复用,避免每次镜像堆出新的 Subject) // 1) 补齐科目(同名的复用,避免每次镜像堆出新的 Subject)
EnsureSubjects(profile, source); EnsureSubjects(profile, source);
@@ -86,7 +81,7 @@ public static class ClassPlanWriter
group.WeekCountDivTotal, groups.Count); group.WeekCountDivTotal, groups.Count);
var planId = FindPlanId(profile, planName); var planId = FindPlanId(profile, planName);
var (timeLayoutId, timeLayout) = BuildTimeLayout( var (timeLayoutId, timeLayout) = BuildTimeLayout(
profile, planName, group.Entries, breakPoints); profile, planName, group.Entries);
var plan = profile.ClassPlans.TryGetValue(planId, out var existing) var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
? existing ? existing
@@ -172,39 +167,46 @@ public static class ClassPlanWriter
} }
/// <summary> /// <summary>
/// 按分组内的条目重建时间表:每条课目前后按需插入服务端的课间点。 /// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
/// 返回写入的时间表 GUID。 ///
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
/// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表,
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
/// </summary> /// </summary>
private static (Guid Id, TimeLayout Layout) BuildTimeLayout( private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
Profile profile, Profile profile,
string planName, string planName,
IReadOnlyList<ClassPlanEntryMirrorDto> entries, IReadOnlyList<ClassPlanEntryMirrorDto> entries)
IReadOnlyDictionary<string, TimeLayoutItemMirrorDto> breakPoints)
{ {
var layoutName = planName + TimeLayoutNameSuffix; var layoutName = planName + TimeLayoutNameSuffix;
var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName); var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName);
var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid(); var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
var layout = new TimeLayout { Name = layoutName, IsActivated = true }; var layout = new TimeLayout { Name = layoutName, IsActivated = true };
foreach (var entry in entries) for (var index = 0; index < entries.Count; index++)
{ {
var entry = entries[index];
layout.Layouts.Add(new TimeLayoutItem layout.Layouts.Add(new TimeLayoutItem
{ {
TimeType = 0, // 上课 TimeType = 0, // 上课
StartTime = ParseTime(entry.StartTime), StartTime = ParseTime(entry.StartTime),
EndTime = ParseTime(entry.EndTime), EndTime = ParseTime(entry.EndTime),
}); });
// 该课目结束后紧接着的课间(服务端时间表里有同起始时刻的课间点才插入)
if (breakPoints.TryGetValue(entry.EndTime, out var brk)) // 课间:下一节开始晚于本节结束才有空隙;最后一节之后(放学)不插
{ 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 layout.Layouts.Add(new TimeLayoutItem
{ {
TimeType = brk.TimeType, TimeType = 1, // 课间
StartTime = ParseTime(brk.StartTime), StartTime = breakStart,
EndTime = ParseTime(brk.EndTime), EndTime = breakEnd,
}); });
} }
}
profile.TimeLayouts[id] = layout; profile.TimeLayouts[id] = layout;
return (id, layout); return (id, layout);
@@ -21,8 +21,9 @@ import { squirclePath } from './squircle.js';
* 对齐点(详见 docs/winisland-design-tokens.md): * 对齐点(详见 docs/winisland-design-tokens.md):
* - 造型:**连续圆角(超椭圆 squircle)**而不是 border-radius(WinIsland `utils/shape.rs`); * - 造型:**连续圆角(超椭圆 squircle)**而不是 border-radius(WinIsland `utils/shape.rs`);
* 展开卡半径取 `min(48 * 圆角系数, w/2, h/2)`,胶囊半径取 `h/2`(满圆角); * 展开卡半径取 `min(48 * 圆角系数, w/2, h/2)`,胶囊半径取 `h/2`(满圆角);
* - 底色:默认纯黑(WinIsland `default` 风格),可选亚克力毛玻璃(`glass` → 主进程 * - 底色:默认纯黑(WinIsland `default` 风格),可选半透明毛玻璃(`glass`;
* `setBackgroundMaterial('acrylic')`,等价于 WinIsland 的 HostBackdropBrush); * **纯 CSS 实现**,取 WinIsland 拿不到 host backdrop 时的降级色 `rgba(32,32,36,.804)`——
* Electron 的 `setBackgroundMaterial()` 不能用在逐像素透明窗口上,原因见 `main/island.ts`);
* - 描边:1px 白、alpha 30(纯黑)/ 40(玻璃、主题色),与 WinIsland `BORDER_*_ALPHA` 同值; * - 描边:1px 白、alpha 30(纯黑)/ 40(玻璃、主题色),与 WinIsland `BORDER_*_ALPHA` 同值;
* - 阴影:只在展开态,`0 2px 3px rgba(0,0,0,.11)`(WinIsland `draw_expanded_shadow`); * - 阴影:只在展开态,`0 2px 3px rgba(0,0,0,.11)`(WinIsland `draw_expanded_shadow`);
* - 排版:白字 + alpha 分级(.92/.72/.58),**所有文本 = 基础字号 × 排版系数**, * - 排版:白字 + alpha 分级(.92/.72/.58),**所有文本 = 基础字号 × 排版系数**,
@@ -714,7 +715,8 @@ body {
} }
.island-card[data-style='glass'] { .island-card[data-style='glass'] {
--wn-bg: rgba(10, 10, 14, 0.588); /* WinIsland「glass 无 host backdrop」降级色:比纯黑透一点,但保证任何桌面下都不是白底 */
--wn-bg: rgba(32, 32, 36, 0.804);
--wn-border: rgba(255, 255, 255, 0.157); --wn-border: rgba(255, 255, 255, 0.157);
--wn-surface: rgba(255, 255, 255, 0.14); --wn-surface: rgba(255, 255, 255, 0.14);
--wn-surface-hover: rgba(255, 255, 255, 0.22); --wn-surface-hover: rgba(255, 255, 255, 0.22);
+25 -30
View File
@@ -123,8 +123,6 @@ class IslandController {
private boundsFrame: number | null = null; private boundsFrame: number | null = null;
/** 外观设置(设置页可调,主进程持久化) */ /** 外观设置(设置页可调,主进程持久化) */
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE }; private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
/** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */
private backgroundMaterial: string = 'none';
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */ /** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */ /** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
private blurCollapseEnabled = true; private blurCollapseEnabled = true;
@@ -221,9 +219,8 @@ class IslandController {
win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
// 启动时即应用已保存的风格(glass 需要窗口创建后立刻设置材质) // 注意:这里**不能**调用 `win.setBackgroundMaterial()`(亚克力/mica),
this.backgroundMaterial = 'none'; // 见 `applyBackgroundMaterial` 移除处的说明。
this.applyBackgroundMaterial();
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击), // 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
// 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。 // 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。
win.setIgnoreMouseEvents(true, { forward: true }); win.setIgnoreMouseEvents(true, { forward: true });
@@ -980,6 +977,29 @@ class IslandController {
this.boundsToken += 1; this.boundsToken += 1;
} }
/*
* 为什么不使用窗口背景材质(Windows 亚克力 / mica)——踩过的坑,别再往回加:
*
* WinIsland 的 `glass` 用 `DWMWA_USE_HOSTBACKDROPBRUSH`(HostBackdropBrush)做真·桌面模糊,
* Electron 里看起来对应 `win.setBackgroundMaterial('acrylic')`,但两者**语义完全不同**:
*
* 1. `setBackgroundMaterial()` 把材质刷在**整个窗口矩形**上,而不是卡片形状上。
* 灵动岛是"固定大包围盒窗口"(卡片之外还有形变/阴影留白),于是卡片周围会露出一圈
* 材质面板 —— 用户看到的就是"白底"(浅色主题下亚克力是浅灰白色,实测 (238,247,252))。
* 2. 材质的明暗跟随**应用主题**:本客户端是浅色 Fluent 主题,亚克力必然是浅色,
* 无法像 WinIsland 那样自带深色 tint(`Color::from_argb(150, 10, 10, 14)`)。
* 3. 更糟的是:只要调用过一次(**包括 `'none'`**),窗口就再也不是逐像素透明的了 ——
* Electron 会让它回落到默认不透明底色(`backgroundColor` 默认 `#FFF`),
* 于是从 `glass` 切到 `black` / `tinted` 之后,卡片周围会永久留一圈**纯白**。
* 这正好对应"毛玻璃和主题色渐变会出现白底"的现象。
* 4. CSS `backdrop-filter: blur()` 也不能替代:透明窗口里 Chromium 采样不到窗口之后的桌面,
* 真机实测与不加模糊的像素完全一致(卡片区域离散度都是 12.7)。
*
* 结论:`glass` 风格改为**纯 CSS 半透明深色卡片**(取 WinIsland 拿不到 host backdrop 时的
* 官方降级色 `rgba(32,32,36,0.804)`,见 `docs/winisland-design-tokens.md`),
* 桌面透过率由卡片自身 alpha 决定,窗口始终保持 `transparent: true` 的逐像素透明。
* 回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」。
*/
/** /**
* 应用外观设置(设置页实时生效):尺寸联动、透明度、置顶、动画开关与位置。 * 应用外观设置(设置页实时生效):尺寸联动、透明度、置顶、动画开关与位置。
* 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。 * 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。
@@ -990,7 +1010,6 @@ class IslandController {
if (this.win && !this.win.isDestroyed()) { if (this.win && !this.win.isDestroyed()) {
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
this.applyBackgroundMaterial();
if (this.win.isVisible()) { if (this.win.isVisible()) {
// 显示中:立即应用透明度(隐藏时由 fadeIn 负责) // 显示中:立即应用透明度(隐藏时由 fadeIn 负责)
this.win.setOpacity(this.appearance.opacity); this.win.setOpacity(this.appearance.opacity);
@@ -1009,30 +1028,6 @@ class IslandController {
); );
} }
/**
* 应用窗口背景材质(对应 WinIsland 的 `DWMWA_USE_HOSTBACKDROPBRUSH` / HostBackdropBrush):
* - `glass` 风格:Windows 11 的 acrylic 亚克力(真正的桌面模糊)
* - 其他风格:`none`(纯色由渲染进程绘制,避免多余的模糊开销)
* 平台不支持时静默降级为半透明纯色。
*/
private applyBackgroundMaterial(): void {
if (!this.win || this.win.isDestroyed()) return;
const material = this.appearance.style === 'glass' ? 'acrylic' : 'none';
if (this.backgroundMaterial === material) return;
try {
this.win.setBackgroundMaterial(material);
this.backgroundMaterial = material;
} catch (error) {
logger.warn(`设置窗口背景材质失败(降级为纯色):${(error as Error).message}`);
this.backgroundMaterial = 'none';
}
}
/** 当前窗口背景材质(供冒烟验证与设置页展示) */
getBackgroundMaterial(): string {
return this.backgroundMaterial;
}
/** /**
* 鼠标命中开关(照搬 WinIsland 的 `set_cursor_hittest`): * 鼠标命中开关(照搬 WinIsland 的 `set_cursor_hittest`):
* 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。 * 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。
+487 -25
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { app, screen } from 'electron'; import { app, BrowserWindow, desktopCapturer, screen } from 'electron';
import type { BrowserWindow, NativeImage } from 'electron'; import type { NativeImage } from 'electron';
import type { IslandAppearance, IslandNotification } from '@classhelper/shared'; import type { IslandAppearance, IslandNotification } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js'; import { getConfig, saveConfig } from './config.js';
import { getDiagnostics } from './ipc.js'; import { getDiagnostics } from './ipc.js';
@@ -59,6 +59,8 @@ interface IslandShotStats {
opaque: number; opaque: number;
transparent: number; transparent: number;
uniqueColors: number; uniqueColors: number;
/** 平均亮度(0~255) */
luminance: number;
/** 偏红像素数(紧急形态的红色描边/角标/内部光晕) */ /** 偏红像素数(紧急形态的红色描边/角标/内部光晕) */
reddish: number; reddish: number;
/** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */ /** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */
@@ -79,6 +81,8 @@ function analyzeBitmap(image: NativeImage): {
uniqueColors: number; uniqueColors: number;
reddish: number; reddish: number;
edgeReddish: number; edgeReddish: number;
/** 整张图的平均亮度(0~255):用来抓「卡片底色发白」这类问题 */
luminance: number;
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */ /** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
outsideReddish?: number; outsideReddish?: number;
} { } {
@@ -89,6 +93,7 @@ function analyzeBitmap(image: NativeImage): {
let transparent = 0; let transparent = 0;
let reddish = 0; let reddish = 0;
let edgeReddish = 0; let edgeReddish = 0;
let luminanceSum = 0;
const colors = new Set<number>(); const colors = new Set<number>();
for (let index = 0; index < total; index += 1) { for (let index = 0; index < total; index += 1) {
@@ -103,6 +108,7 @@ function analyzeBitmap(image: NativeImage): {
continue; continue;
} }
opaque += 1; opaque += 1;
luminanceSum += 0.2126 * red + 0.7152 * green + 0.0722 * blue;
if (colors.size < 4096) colors.add((red << 16) | (green << 8) | blue); if (colors.size < 4096) colors.add((red << 16) | (green << 8) | blue);
// 红色占优:紧急态的描边、角标、内部光晕 // 红色占优:紧急态的描边、角标、内部光晕
@@ -115,7 +121,14 @@ function analyzeBitmap(image: NativeImage): {
} }
} }
return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish }; return {
opaque,
transparent,
uniqueColors: colors.size,
reddish,
edgeReddish,
luminance: opaque > 0 ? luminanceSum / opaque : 0,
};
} }
/** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */ /** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */
@@ -265,6 +278,247 @@ async function captureIsland(
} }
} }
interface IslandBackdropStats {
name: string;
/** 环(窗口内、卡片外)亮度中位数:这一圈本该"透出桌面" */
ring: number;
/** 基准(窗口矩形之外、同一时刻的桌面)亮度中位数:用来对消壁纸本身的明暗 */
reference: number;
/** 卡片内部亮度中位数(防御性检查:卡片本身不能变白) */
card: number;
/** 卡片**近场**外圈里明显比紧邻桌面亮的像素数(材料/不透明窗口底色的直接证据) */
veilPixels: number;
filePath: string;
}
/**
* 卡片外圈自检的**纯色底板**。
*
* 直接把岛摆到用户的桌面上取样是不可靠的:壁纸、浏览器窗口、正在干活的其他程序都会变,
* 截出来的"环"到底是桌面还是面板根本说不清(真机实测就撞上过浏览器盖住岛的情况)。
* 所以测试期间在岛下面垫一块**已知颜色**的底板窗口,环的期望值就变成常量:
* 透明 ⇒ 环 = 底板色;被系统材质/不透明窗口底色糊过 ⇒ 环与底板色对不上(几秒内就能断言)。
*/
const ISLAND_BACKDROP_COLOR = '#101722';
const ISLAND_BACKDROP_LUMINANCE = 0.2126 * 0x10 + 0.7152 * 0x17 + 0.0722 * 0x22;
let islandBackdropWindow: BrowserWindow | null = null;
/** 惰性创建纯色底板窗口(不抢焦点、不占任务栏,只在取样期间显示) */
async function ensureIslandBackdropWindow(): Promise<BrowserWindow> {
if (islandBackdropWindow && !islandBackdropWindow.isDestroyed()) return islandBackdropWindow;
const win = new BrowserWindow({
show: false,
frame: false,
transparent: false,
backgroundColor: ISLAND_BACKDROP_COLOR,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
skipTaskbar: true,
hasShadow: false,
focusable: false,
alwaysOnTop: true,
type: 'toolbar',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
const page = `<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0;background:${ISLAND_BACKDROP_COLOR}"></body></html>`;
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(page)}`);
win.setAlwaysOnTop(true, 'screen-saver');
islandBackdropWindow = win;
return win;
}
/** 取样结束后收起底板(不留在桌面上) */
function hideIslandBackdropWindow(): void {
if (islandBackdropWindow && !islandBackdropWindow.isDestroyed()) islandBackdropWindow.hide();
}
function medianOf(values: number[]): number {
if (values.length === 0) return -1;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)] ?? -1;
}
/**
* 用**整屏截图**检查"卡片之外的那一圈"到底是什么颜色。
*
* 为什么必须拍整屏:`webContents.capturePage()` 只拿得到渲染进程自己的位面,
* 窗口自己的底色 / 系统背景材质(亚克力、mica)都画在它**后面**,根本拍不到 ——
* 「毛玻璃 / 主题色渐变出现白底」正是从这个盲区漏过去的(详见 `main/island.ts` 的说明)。
*
* 这里用 desktopCapturer 抓真实桌面合成结果,再按"岛矩形 / 窗口矩形"分三块取样:
* - 卡片:岛体内部 → 必须仍是深色;
* - 环:窗口内、卡片外 → 本该**完全透明**(露出桌面),出现浅色面板即回归;
* - 基准:窗口矩形之外、同一时刻的桌面 → 用来对消壁纸自身的明暗差异。
*/
async function captureIslandBackdrop(name: string): Promise<IslandBackdropStats | null> {
const win = island.getWindow();
if (!win || win.isDestroyed() || !win.isVisible()) return null;
const geometry = await readIslandGeometryFrom(win);
if (!geometry) return null;
const rect = geometry.window;
const display = screen.getDisplayMatching(rect);
const scale = display.scaleFactor || 1;
const margin = 40;
// 垫底板 + 把岛抬回最上层(否则全屏浏览器之类的前台窗口会把岛盖住,量到的是别人的像素)
const backdropWin = await ensureIslandBackdropWindow();
backdropWin.setBounds({
x: rect.x - margin - 8,
y: rect.y - margin - 8,
width: rect.width + (margin + 8) * 2,
height: rect.height + (margin + 8) * 2,
});
backdropWin.showInactive();
backdropWin.moveTop();
win.moveTop();
await sleep(320);
try {
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: {
width: Math.round(display.size.width * scale),
height: Math.round(display.size.height * scale),
},
});
const source = sources.find((item) => String(item.display_id) === String(display.id)) ?? sources[0];
if (!source) return null;
const shot = source.thumbnail;
const shotSize = shot.getSize();
// 缩略图可能被系统按上限缩小:一律用"缩略图尺寸 / 屏幕逻辑尺寸"换算,别假设等于 scaleFactor
const shotScale = shotSize.width / Math.max(1, display.size.width);
const left = Math.max(0, Math.round((rect.x - display.bounds.x - margin) * shotScale));
const top = Math.max(0, Math.round((rect.y - display.bounds.y - margin) * shotScale));
const width = Math.min(shotSize.width - left, Math.round((rect.width + margin * 2) * shotScale));
const height = Math.min(shotSize.height - top, Math.round((rect.height + margin * 2) * shotScale));
if (width <= 0 || height <= 0) return null;
const region = shot.crop({ x: left, y: top, width, height });
const bitmap = region.toBitmap();
const size = region.getSize();
// 屏幕逻辑坐标 → 裁剪图像素坐标
const originX = display.bounds.x + left / shotScale;
const originY = display.bounds.y + top / shotScale;
const toPx = (value: number, origin: number): number => Math.round((value - origin) * shotScale);
const winRect = {
left: toPx(rect.x, originX) + 2,
top: toPx(rect.y, originY) + 2,
right: toPx(rect.x + rect.width, originX) - 2,
bottom: toPx(rect.y + rect.height, originY) - 2,
};
const inset = Math.max(3, Math.round(3 * shotScale));
const islandRect = {
left: toPx(geometry.island.x, originX),
top: toPx(geometry.island.y, originY),
right: toPx(geometry.island.x + geometry.island.width, originX),
bottom: toPx(geometry.island.y + geometry.island.height, originY),
};
// 卡片内部取样:向内缩,避开描边与抗锯齿
const cardRect = {
left: islandRect.left + inset,
top: islandRect.top + inset,
right: islandRect.right - inset,
bottom: islandRect.bottom - inset,
};
// 环的"内边界"= 岛体矩形(外扩 1px):岛自己的描边/抗锯齿不能算进"环",
// 否则会把卡片本身那圈 1px 白描边误当成"白底面板"(真机踩过)。
const ringInner = {
left: islandRect.left - 1,
top: islandRect.top - 1,
right: islandRect.right + 1,
bottom: islandRect.bottom + 1,
};
const inRect = (
x: number,
y: number,
r: { left: number; top: number; right: number; bottom: number },
): boolean => x >= r.left && x < r.right && y >= r.top && y < r.bottom;
// 基准取样带:贴着窗口左右两侧、**与窗口同一批行**的桌面像素。
// 与"窗口内那一圈"贴得最近、行也一致,才能把壁纸自身的明暗/gradient 抵消掉。
const band = Math.max(4, Math.round(16 * shotScale));
const refBand = {
left: Math.max(0, winRect.left - band),
right: Math.min(size.width, winRect.right + band),
top: winRect.top,
bottom: winRect.bottom,
};
// 卡片外圈的"近场":卡片四周 pad+4 px 内(材料/窗口底色最先暴露的地方)
const padNear = Math.max(
0,
Math.min(
islandRect.left - winRect.left,
islandRect.top - winRect.top,
winRect.right - islandRect.right,
winRect.bottom - islandRect.bottom,
),
);
const haloRect = {
left: islandRect.left - padNear - 6,
top: islandRect.top - padNear - 6,
right: islandRect.right + padNear + 6,
bottom: islandRect.bottom + padNear + 6,
};
const ring: number[] = [];
const card: number[] = [];
const reference: number[] = [];
const fallbackReference: number[] = [];
const halo: number[] = [];
for (let y = 0; y < size.height; y += 1) {
for (let x = 0; x < size.width; x += 1) {
const offset = (y * size.width + x) * 4;
const blue = bitmap[offset] ?? 0;
const green = bitmap[offset + 1] ?? 0;
const red = bitmap[offset + 2] ?? 0;
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
if (inRect(x, y, cardRect)) card.push(luminance);
else if (!inRect(x, y, ringInner) && inRect(x, y, winRect)) {
ring.push(luminance);
if (inRect(x, y, haloRect)) halo.push(luminance);
} else {
fallbackReference.push(luminance);
if (y >= refBand.top && y < refBand.bottom && (x < winRect.left || x >= winRect.right)) {
reference.push(luminance);
}
}
}
}
// 贴边取样带样本太少(窗口贴屏幕边)时退回"窗口外全部像素"
const refSample = reference.length >= 300 ? reference : fallbackReference;
// 环上"白底"像素:比紧邻的桌面亮很多(材料面板 / 不透明窗口底色都会这样)
const refMedian = medianOf(refSample);
const veilPixels = halo.filter((value) => value > refMedian + 30).length;
const filePath = process.env.ISLAND_SHOTS_DIR
? path.join(process.env.ISLAND_SHOTS_DIR, `${name}-backdrop.png`)
: '';
if (filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, region.toPNG());
}
const stats: IslandBackdropStats = {
name,
ring: Math.round(medianOf(ring)),
reference: Math.round(refMedian),
card: Math.round(medianOf(card)),
veilPixels,
filePath,
};
console.log(
`[SMOKE] 卡片外圈(整屏截图):${name} 环=${stats.ring} 基准=${stats.reference} ` +
`卡片=${stats.card} 环上白底像素=${stats.veilPixels} 窗口=${rect.width}x${rect.height} ` +
`岛=${Math.round(geometry.island.width)}x${Math.round(geometry.island.height)} 图=${filePath}`,
);
return stats;
} catch (error) {
console.warn('[SMOKE] 卡片外圈截图失败', error);
return null;
} finally {
hideIslandBackdropWindow();
}
}
/** 按岛的矩形裁剪截图(含比例换算:capturePage 返回物理像素) */ /** 按岛的矩形裁剪截图(含比例换算:capturePage 返回物理像素) */
function cropToIsland( function cropToIsland(
image: Electron.NativeImage, image: Electron.NativeImage,
@@ -1995,9 +2249,7 @@ async function runIslandChecks(
); );
// 10.7) 视觉风格:纯黑 / 毛玻璃(亚克力)/ 主题色渐变,切换立即生效 // 10.7) 视觉风格:纯黑 / 毛玻璃(亚克力)/ 主题色渐变,切换立即生效
const probeStyle = async ( const probeStyle = async (style: IslandAppearance['style']): Promise<{ fill: string; stroke: string }> => {
style: IslandAppearance['style'],
): Promise<{ fill: string; stroke: string; material: string }> => {
island.setAppearance({ ...appearanceBefore, style }); island.setAppearance({ ...appearanceBefore, style });
await sleep(260); await sleep(260);
const measured = await islandWindow?.webContents const measured = await islandWindow?.webContents
@@ -2013,7 +2265,6 @@ async function runIslandChecks(
return { return {
fill: String(measured?.fill ?? '-'), fill: String(measured?.fill ?? '-'),
stroke: String(measured?.stroke ?? '-'), stroke: String(measured?.stroke ?? '-'),
material: island.getBackgroundMaterial(),
}; };
}; };
@@ -2021,15 +2272,11 @@ async function runIslandChecks(
const glassStyle = await probeStyle('glass'); const glassStyle = await probeStyle('glass');
const tintedStyle = await probeStyle('tinted'); const tintedStyle = await probeStyle('tinted');
record( record(
'视觉风格切换立刻生效(纯黑 / 毛玻璃亚克力 / 主题色)', '视觉风格切换立刻生效(纯黑 / 毛玻璃半透明 / 主题色)',
blackStyle.fill === 'rgb(0, 0, 0)' && blackStyle.fill === 'rgb(0, 0, 0)' &&
blackStyle.material === 'none' && glassStyle.fill.startsWith('rgba(32, 32, 36') &&
glassStyle.fill.startsWith('rgba(10, 10, 14') &&
glassStyle.material === 'acrylic' &&
tintedStyle.fill === 'rgb(11, 11, 15)', tintedStyle.fill === 'rgb(11, 11, 15)',
`纯黑 fill=${blackStyle.fill} 材质=${blackStyle.material};` + `纯黑 fill=${blackStyle.fill};毛玻璃 fill=${glassStyle.fill};主题色 fill=${tintedStyle.fill}`,
`毛玻璃 fill=${glassStyle.fill} 材质=${glassStyle.material};` +
`主题色 fill=${tintedStyle.fill} 材质=${tintedStyle.material}`,
); );
// 10.7b) 固定大包围盒窗口的鼠标穿透(照搬 WinIsland set_cursor_hittest): // 10.7b) 固定大包围盒窗口的鼠标穿透(照搬 WinIsland set_cursor_hittest):
// 默认整块窗口穿透,指针进入岛体才接收鼠标 —— 否则会吞掉桌面点击。 // 默认整块窗口穿透,指针进入岛体才接收鼠标 —— 否则会吞掉桌面点击。
@@ -2171,6 +2418,47 @@ async function runIslandChecks(
); );
record('个性设置:主题色生效(CSS 变量)', accentVar === '#ff7043', `CSS --island-accent=${accentVar}`); record('个性设置:主题色生效(CSS 变量)', accentVar === '#ff7043', `CSS --island-accent=${accentVar}`);
// 11.2b) 三种视觉风格的**底色**:黑 / 毛玻璃 / 主题色渐变都必须是深色卡片
// 并且卡片**外面那一圈**必须完全透明(用户反馈:毛玻璃与主题色渐变会出现白底 ——
// 那圈留白曾被系统亚克力/不透明窗口底色垫成浅色面板,见 captureIslandBackdrop 说明)
const styleLuminance: string[] = [];
const styleRing: string[] = [];
let styleOk = true;
let ringOk = true;
for (const style of ['black', 'glass', 'tinted'] as const) {
island.setAppearance({ ...appearanceBefore, style, opacity: 1 });
island.pushNotification(makeNotification(`smoke-style-${style}`, 'NORMAL', '风格底色自检'), {
inClass: false,
});
await sleep(700);
const shot = await captureIsland(`island-style-${style}`, ISLAND_SIZES.expanded);
const luminance = shot?.luminance ?? -1;
// 深色卡片:均值亮度应明显偏低(>140 基本就是白底)
const ok = luminance >= 0 && luminance < 120;
if (!ok) styleOk = false;
styleLuminance.push(`${style}=${luminance >= 0 ? luminance.toFixed(0) : '-'}${ok ? '✓' : '✗'}`);
// 卡片外圈:必须仍是"透出桌面"的那层像素(环亮度≈窗口外的桌面亮度),不能出现面板
const backdrop = await captureIslandBackdrop(`island-style-${style}`);
// 基准必须真的落在纯色底板上(否则说明底板被遮挡/截图偏了 —— 先修测试,别放过)
const backdropVisible = backdrop ? Math.abs(backdrop.reference - ISLAND_BACKDROP_LUMINANCE) <= 8 : false;
const ringDelta = backdrop ? Math.abs(backdrop.ring - backdrop.reference) : -1;
// 环亮度必须与底板一致(系统材质/不透明窗口底色会把它整体抬亮或涂成白色)
const ringOneOk =
backdropVisible && ringDelta >= 0 && ringDelta <= 12 && (backdrop?.veilPixels ?? 1) <= 20;
if (!ringOneOk) ringOk = false;
styleRing.push(
`${style}=环${backdrop?.ring ?? '-'}/底板${backdrop?.reference ?? '-'}` +
`(差${ringDelta >= 0 ? ringDelta : '-'},卡片${backdrop?.card ?? '-'},` +
`近场白底${backdrop?.veilPixels ?? '-'})${ringOneOk ? '✓' : '✗'}` +
(backdropVisible ? '' : '(底板被遮挡)'),
);
island.handleAction({ action: 'dismiss' });
await sleep(200);
}
record('个性设置:三种风格都是深色底(无白底)', styleOk, `平均亮度 ${styleLuminance.join(' ')}`);
record('个性设置:卡片外圈透出桌面(三种风格都没有白底面板)', ringOk, styleRing.join(' '));
island.setAppearance(appearanceBefore);
// 11.3 位置:四个停靠点的窗口坐标必须落在对应锚点 // 11.3 位置:四个停靠点的窗口坐标必须落在对应锚点
const positionCases: { const positionCases: {
position: IslandAppearance['position']; position: IslandAppearance['position'];
@@ -2500,9 +2788,7 @@ async function runIslandRealtimeCheck(
* 因此这里在真实设置页里合成一次拖拽(mousedown → mousemove → mouseup,与用户操作同一路径), * 因此这里在真实设置页里合成一次拖拽(mousedown → mousemove → mouseup,与用户操作同一路径),
* 并同时断言「滑块自身的数值」与「主进程里的外观」都变了 —— 只断言前者会漏掉这个 bug。 * 并同时断言「滑块自身的数值」与「主进程里的外观」都变了 —— 只断言前者会漏掉这个 bug。
*/ */
async function runSettingsPageAppearanceCheck( async function runSettingsPageAppearanceCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> {
win: BrowserWindow,
): Promise<{ ok: boolean; detail: string }> {
const result = (await win.webContents const result = (await win.webContents
.executeJavaScript( .executeJavaScript(
`(async () => { `(async () => {
@@ -2553,9 +2839,7 @@ async function runSettingsPageAppearanceCheck(
ok: false as const, ok: false as const,
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`, detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
}))) as }))) as
| { ok: true; baseline: number; dragged: number; label: string } { ok: true; baseline: number; dragged: number; label: string } | { ok: false; detail: string } | null;
| { ok: false; detail: string }
| null;
if (result?.ok) { if (result?.ok) {
return { return {
@@ -2569,14 +2853,176 @@ async function runSettingsPageAppearanceCheck(
} }
/** /**
* 设置页「通知显示位置」自检(需求:提醒弹在 ClassHelper 还是 ClassIsland,由客户端自己选)。 * 设置页「左右边距」自检(用户反馈:调左右边距**没有实时预览、也不生效**)。
* *
* 走真实 UI:切到设置页 → 点单选项 → 断言「本地配置已写入」,再切回去。 * 为什么不能只调主进程:先前那条用例是直接 `island.setAppearance(...)`,绕过了
* 服务端同步(写回班级记录)由 verify:classisland 覆盖,这里只管客户端这一半。 * 「设置页 → preload → IPC → 主进程」这条真实链路 —— 用户遇到的失败很可能就在这一段。
* 因此这里在真实设置页里对「左右边距」滑块合成一次拖拽,并且:
* 1) 断言主进程 appearance.marginX 真的变了(IPC + 写值通了);
* 2) 断言岛的**真实几何**按新边距移动(窗口重排生效)。
*
* 岛的真实几何来自渲染进程上报(readGeometry),不是窗口 bounds —— 窗口是固定包围盒,
* 只有岛体坐标才反映锚点。
*/ */
async function runSettingsPageChannelCheck( async function runSettingsPageMarginCheck(
win: BrowserWindow, win: BrowserWindow,
readGeometry: () => Promise<{ island: Electron.Rectangle } | null>,
): Promise<{ ok: boolean; detail: string }> { ): Promise<{ ok: boolean; detail: string }> {
const appearanceBefore = island.getAppearance();
// 1) 默认(居中)停靠:左右边距**必须**被禁用并给出原因,否则用户会一直以为"调了不生效"
island.setAppearance({ ...appearanceBefore, position: 'top-center' });
await sleep(300);
const centerHint = (await win.webContents.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 必须"离开再回来":设置页只在挂载时读一次外观,同 hash 再赋值不会重新挂载,
// 页面里会留着过期的位置(这正是"改了设置没反应"的一个真实来源)
location.hash = '#/schedule';
await wait(250);
location.hash = '#/settings';
let item = null;
for (let i = 0; i < 40 && !item; i += 1) {
await wait(100);
item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
);
}
if (!item) return { ok: false, detail: '找不到「左右边距」表单项' };
const slider = item.querySelector('.el-slider');
const runway = item.querySelector('.el-slider__runway');
const disabled =
slider?.classList.contains('is-disabled') === true ||
runway?.classList.contains('is-disabled') === true ||
slider?.getAttribute('aria-disabled') === 'true' ||
runway?.getAttribute('aria-disabled') === 'true';
const text = (item.textContent ?? '').replace(/\s+/g, ' ').trim();
return { ok: true, disabled, hasHint: text.includes('居中') && text.includes('不生效') };
})()`,
)) as { ok: boolean; disabled?: boolean; hasHint?: boolean; detail?: string } | null;
if (!centerHint?.ok || centerHint.disabled !== true || centerHint.hasHint !== true) {
island.setAppearance(appearanceBefore);
return {
ok: false,
detail:
'居中停靠时「左右边距」的禁用/说明缺失:' +
(centerHint?.ok
? `disabled=${centerHint.disabled} hasHint=${centerHint.hasHint}`
: `${centerHint?.detail ?? '未执行'}`),
};
}
// 2) 改成左停靠后再验"拖滑块 → 岛真的移动"
island.setAppearance({ ...appearanceBefore, position: 'top-left', marginX: 8, marginY: 8 });
await sleep(500);
const before = (await readGeometry())?.island ?? null;
const drag = (await win.webContents
.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
location.hash = '#/schedule';
await wait(200);
location.hash = '#/settings';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
);
runway = item?.querySelector('.el-slider__runway') ?? null;
}
if (!runway) return { ok: false, detail: '设置页里找不到「左右边距」滑块' };
const marginItem = runway.closest('.el-form-item');
const marginSlider = marginItem?.querySelector('.el-slider');
const sliderDisabled = () =>
marginSlider?.classList.contains('is-disabled') === true ||
runway.classList.contains('is-disabled') === true;
// 等滑块可用:页面必须已经读到「左停靠」的最新外观,否则拖了也不会生效
let waited = 0;
while (sliderDisabled() && waited < 4000) {
await wait(150);
waited += 150;
}
const positionText = (() => {
const posItem = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
(node.querySelector('.el-form-item__label')?.textContent ?? '').trim() === '显示位置',
);
return (posItem?.querySelector('.el-select')?.textContent ?? '').trim();
})();
if (sliderDisabled()) {
return {
ok: false,
detail:
'左停靠下「左右边距」仍被禁用(页面没刷新到最新位置:显示位置=「' + positionText + '」)',
};
}
const rect = runway.getBoundingClientRect();
if (rect.width === 0) return { ok: false, detail: '滑块宽度为 0' };
const centerDisabled = false;
const x = rect.left + rect.width * 0.75;
const y = rect.top + rect.height / 2;
const fire = (target, type, extra) =>
target.dispatchEvent(
new MouseEvent(
type,
Object.assign({ bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }, extra),
),
);
fire(runway, 'mousedown', { buttons: 1 });
await wait(60);
fire(window, 'mousemove', { buttons: 1 });
await wait(60);
fire(window, 'mouseup', {});
await wait(600);
const label =
Array.from(document.querySelectorAll('.el-form-item__label'))
.map((node) => (node.textContent ?? '').trim())
.find((text) => text.startsWith('左右边距')) ?? '';
return { ok: true, label, centerDisabled };
})()`,
)
.catch((error: unknown) => ({ ok: false, detail: String(error) }))) as
| { ok: true; label: string; centerDisabled: boolean; positionText: string }
| { ok: false; detail: string }
| null;
await sleep(500);
const after = (await readGeometry())?.island ?? null;
const appearanceAfter = island.getAppearance();
island.setAppearance(appearanceBefore);
await sleep(250);
const expectedX = before ? before.x + (appearanceAfter.marginX - 8) : null;
const ipcOk = appearanceAfter.marginX > 8;
const movedOk = Boolean(before && after && expectedX !== null && Math.abs(after.x - expectedX) <= 2);
return {
ok: Boolean(drag?.ok) && ipcOk && movedOk && (drag?.ok ? drag.centerDisabled === false : false),
detail:
'居中停靠时禁用+说明=✓;改左停靠(页面显示「' +
(drag?.ok ? drag.positionText : '-') +
'」)后拖到 75%:标签=' +
(drag?.ok ? drag.label : '-') +
',主进程 marginX ' +
appearanceBefore.marginX +
' → ' +
appearanceAfter.marginX +
'(IPC=' +
ipcOk +
'),岛 x ' +
(before?.x ?? '-') +
' → ' +
(after?.x ?? '-') +
'(期望 ' +
expectedX +
',移动=' +
movedOk +
')' +
(drag?.ok ? '' : '|' + drag?.detail),
};
}
async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> {
const result = (await win.webContents const result = (await win.webContents
.executeJavaScript( .executeJavaScript(
`(async () => { `(async () => {
@@ -2616,7 +3062,14 @@ async function runSettingsPageChannelCheck(
ok: false as const, ok: false as const,
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`, detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
}))) as }))) as
| { ok: true; before: string; afterClient: string; afterBoth: string; clientChecked: boolean; labels: string[] } | {
ok: true;
before: string;
afterClient: string;
afterBoth: string;
clientChecked: boolean;
labels: string[];
}
| { ok: false; detail: string } | { ok: false; detail: string }
| null; | null;
@@ -2661,6 +3114,9 @@ async function runTrayAndShutdownChecks(mainWin: BrowserWindow, record: Recorder
destroyTray(); destroyTray();
island.destroy(); island.destroy();
// 卡片外圈自检用的纯色底板(若已创建)也要一并释放,不留隐藏窗口
if (islandBackdropWindow && !islandBackdropWindow.isDestroyed()) islandBackdropWindow.destroy();
islandBackdropWindow = null;
await sleep(200); await sleep(200);
record( record(
'退出前资源清理(托盘/灵动岛已释放)', '退出前资源清理(托盘/灵动岛已释放)',
@@ -2831,6 +3287,12 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
const settingsChannel = await runSettingsPageChannelCheck(win); const settingsChannel = await runSettingsPageChannelCheck(win);
record('设置页真实 UI:切换「通知显示位置」写回本地配置', settingsChannel.ok, settingsChannel.detail); record('设置页真实 UI:切换「通知显示位置」写回本地配置', settingsChannel.ok, settingsChannel.detail);
// 左右边距:必须走「设置页拖滑块」的真实链路(用户反馈:调了没实时预览、也不生效)
const settingsMargin = await runSettingsPageMarginCheck(win, () =>
island.getWindow() ? readIslandGeometryFrom(island.getWindow()!) : Promise.resolve(null),
);
record('设置页真实 UI:拖「左右边距」滑块立即生效', settingsMargin.ok, settingsMargin.detail);
// 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊; // 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊;
// 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读") // 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读")
const realtime = await runIslandRealtimeCheck(win); const realtime = await runIslandRealtimeCheck(win);
@@ -242,6 +242,14 @@ async function resetPhrases(): Promise<void> {
/* ------------------------------------------------------------ 灵动岛个性化 */ /* ------------------------------------------------------------ 灵动岛个性化 */
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE }); const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
/**
* 左右边距是否有效:只在停靠左 / 右时生效。
*
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
*/
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
const savingIsland = ref(false); const savingIsland = ref(false);
const positionOptions = ISLAND_POSITIONS.map((value) => ({ const positionOptions = ISLAND_POSITIONS.map((value) => ({
@@ -442,7 +450,7 @@ onMounted(async () => {
:value="item.value" :value="item.value"
/> />
</el-select> </el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(亚克力)/ 主题色渐变</span> <span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item> </el-form-item>
<el-form-item label="显示位置"> <el-form-item label="显示位置">
<el-select <el-select
@@ -464,9 +472,13 @@ onMounted(async () => {
:min="RANGES.marginX.min" :min="RANGES.marginX.min"
:max="RANGES.marginX.max" :max="RANGES.marginX.max"
:step="2" :step="2"
:disabled="!marginXApplies"
@input="(value: number) => applyIslandAppearance({ marginX: value })" @input="(value: number) => applyIslandAppearance({ marginX: value })"
/> />
<span class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span> <span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
<span v-else class="text-muted ml-8">
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
</span>
</el-form-item> </el-form-item>
<el-form-item :label="`上下边距 ${island.marginY}px`"> <el-form-item :label="`上下边距 ${island.marginY}px`">
<el-slider <el-slider
@@ -534,27 +546,39 @@ onMounted(async () => {
<el-descriptions :column="1" border size="small" class="mb-12"> <el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item> <el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland"> <el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} / {{ classIslandStatus?.classIslandVersion ?? '—' }} {{ classIslandStatus?.pluginVersion ?? '—' }} /
{{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<p class="text-muted"> <p class="text-muted">
<strong>通知模式</strong>:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland, <strong>通知模式</strong>
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。 由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p> </p>
<el-radio-group <el-radio-group
:model-value="appStore.notificationChannel" :model-value="appStore.notificationChannel"
:disabled="channelSaving" :disabled="channelSaving"
class="channel-group" class="channel-group"
@change="(value: string | number | boolean | undefined) => applyChannel(value as ClassIslandNotificationChannel)" @change="
(value: string | number | boolean | undefined) =>
applyChannel(value as ClassIslandNotificationChannel)
"
>
<el-radio
v-for="item in channelOptions"
:key="item.value"
:value="item.value"
:disabled="item.disabled"
> >
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
{{ item.label }} {{ item.label }}
</el-radio> </el-radio>
</el-radio-group> </el-radio-group>
<p class="text-muted channel-hint"> <p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }} {{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected"> <template v-if="!classIslandConnected">
<br />提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在 ClassIsland 上弹")。 <br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
ClassIsland 上弹")。
</template> </template>
</p> </p>
</el-card> </el-card>
@@ -562,8 +586,9 @@ onMounted(async () => {
<el-card shadow="never" class="mt-12"> <el-card shadow="never" class="mt-12">
<template #header><span>作业录入</span></template> <template #header><span>作业录入</span></template>
<p class="text-muted"> <p class="text-muted">
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
最多 {{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条 {{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。 {{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
</p> </p>
<div class="phrase-row"> <div class="phrase-row">
<el-tag <el-tag
@@ -662,7 +662,12 @@ function parseStoredTimeLayout(raw: string): { startTime: string; endTime: strin
} }
} }
/** 没有导入节次时间表时,用当天课表里出现过的起止时间合成一份(去重并按时间排序) */ /**
* 没有导入节次时间表时,用课表里出现过的起止时间合成一份(去重、按时间排序)。
*
* **每节课之间补一个课间**(timeType=1:上一节下课 → 下一节上课),最后一节之后不补(放学)。
* 班级小助手这边的节次时间表与 ClassIsland 档案保持同一口径,镜像过去才不会缺课间。
*/
function synthesizeTimeLayout( function synthesizeTimeLayout(
schedules: { startTime: string; endTime: string }[], schedules: { startTime: string; endTime: string }[],
): { startTime: string; endTime: string; timeType: number }[] { ): { startTime: string; endTime: string; timeType: number }[] {
@@ -670,9 +675,22 @@ function synthesizeTimeLayout(
for (const item of schedules) { for (const item of schedules) {
if (!seen.has(item.startTime)) seen.set(item.startTime, item.endTime); if (!seen.has(item.startTime)) seen.set(item.startTime, item.endTime);
} }
return [...seen.entries()]
const classPoints = [...seen.entries()]
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.map(([startTime, endTime]) => ({ startTime, endTime, timeType: 0 })); .map(([startTime, endTime]) => ({ startTime, endTime, timeType: 0 }));
const items: { startTime: string; endTime: string; timeType: number }[] = [];
for (let index = 0; index < classPoints.length; index += 1) {
const point = classPoints[index]!;
items.push(point);
const next = classPoints[index + 1];
if (!next) continue; // 最后一节之后是放学,不插课间
if (next.startTime > point.endTime) {
items.push({ startTime: point.endTime, endTime: next.startTime, timeType: 1 });
}
}
return items;
} }
/* ------------------------------------------------------------------ 供仪表盘/统计使用 */ /* ------------------------------------------------------------------ 供仪表盘/统计使用 */
+1 -1
View File
@@ -173,7 +173,7 @@ export const ISLAND_STYLES = ['black', 'glass', 'tinted'] as const;
export const ISLAND_STYLE_LABELS: Record<(typeof ISLAND_STYLES)[number], string> = { export const ISLAND_STYLE_LABELS: Record<(typeof ISLAND_STYLES)[number], string> = {
black: '纯黑(灵动岛默认)', black: '纯黑(灵动岛默认)',
glass: '毛玻璃(半透明模糊)', glass: '毛玻璃(半透明)',
tinted: '主题色渐变', tinted: '主题色渐变',
}; };