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:
1 parent
7536abfc95
commit
5c27586af9
12 files changed
+678
-141
No files matched your search
@@ -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. 代码风格
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
- **设置 → 作业录入**:作业快捷短语的增删与恢复默认。
|
- **设置 → 作业录入**:作业快捷短语的增删与恢复默认。
|
||||||
|
|
||||||
@@ -544,20 +547,20 @@ 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)` |
|
||||||
| 不透明度 | 40%–100% | 窗口整体不透明度(所有显示路径都会应用,含"隐藏后再弹出胶囊") |
|
| 不透明度 | 40%–100% | 窗口整体不透明度(所有显示路径都会应用,含"隐藏后再弹出胶囊") |
|
||||||
| **字号** | **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` |
|
||||||
| **空闲细缝** | **开/关(默认关)** | 参考 WinIsland `hidden_width`:开启后没有消息时不再完全隐藏,而是留一条 6px 宽的竖条(窗口是固定包围盒,不受 Windows 最小窗口高度限制) |
|
| **空闲细缝** | **开/关(默认关)** | 参考 WinIsland `hidden_width`:开启后没有消息时不再完全隐藏,而是留一条 6px 宽的竖条(窗口是固定包围盒,不受 Windows 最小窗口高度限制) |
|
||||||
|
|
||||||
- 越界值在**主进程**统一夹紧(`normalizeIslandAppearance`),用户填坏配置也不会让布局错乱。
|
- 越界值在**主进程**统一夹紧(`normalizeIslandAppearance`),用户填坏配置也不会让布局错乱。
|
||||||
- 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。
|
- 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。
|
||||||
@@ -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`;**班级账号也能调用**,用于教室机器录入)。
|
||||||
|
|
||||||
@@ -1348,12 +1354,12 @@ 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(学生端班级账号)新增文件:
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -572,17 +577,17 @@ 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 锚点坐标断言 |
|
||||||
| `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 |
|
| `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/**` | 业务不同(我们做通知/作业/叫人/紧急) | — |
|
| 未采纳:音乐/歌词/小组件/音量浮窗 | `ui/expanded/**`、`ui/widget/**` | 业务不同(我们做通知/作业/叫人/紧急) | — |
|
||||||
@@ -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,38 +167,45 @@ 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 = 1, // 课间
|
||||||
{
|
StartTime = breakStart,
|
||||||
TimeType = brk.TimeType,
|
EndTime = breakEnd,
|
||||||
StartTime = ParseTime(brk.StartTime),
|
});
|
||||||
EndTime = ParseTime(brk.EndTime),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
profile.TimeLayouts[id] = layout;
|
profile.TimeLayouts[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);
|
||||||
|
|||||||
@@ -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`):
|
||||||
* 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。
|
* 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 供仪表盘/统计使用 */
|
/* ------------------------------------------------------------------ 供仪表盘/统计使用 */
|
||||||
|
|||||||
@@ -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: '主题色渐变',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user