diff --git a/AGENTS.md b/AGENTS.md index 626c3e2..96f945e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,17 +255,33 @@ pnpm dist:classisland-plugin 服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处: 客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。 -16. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段 +16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线, + 此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 —— + 用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。 +17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课), + **最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算), + 服务端 `synthesizeTimeLayout` 同口径。 +18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段 客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);`urgent` 同样立刻弹。 插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件), 客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。 -17. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期 +19. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期 (`YYYY-MM-DD`,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。 UTC 切片(`toISOString().slice(0,10)`)在晚上的录入会算到第二天 —— 统一用 `@classhelper/shared` 的 `dayKeyLocal / isDayKey / shiftDayKey`。新增按天接口时同样用 `?date=YYYY-MM-DD` + zod 的 `isDayKey` 校验。 -18. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在 +20. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在 `homeworks.service.createHomework` 里判(staff 或本班班级账号)。班级账号的 `sub` 是**班级 id**, 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键。 +21. **灵动岛不要用窗口背景材质(`win.setBackgroundMaterial()`)**。它看起来像 WinIsland 的 + `DWMWA_USE_HOSTBACKDROPBRUSH`,其实是**整窗系统材质**:刷在整个窗口矩形上(岛是"固定大包围盒窗口", + 卡片之外还有留白),且明暗跟随应用主题(本客户端是浅色 Fluent 主题 ⇒ 材质必然是浅色), + 于是卡片周围会出现一圈浅色"白底面板"。更坑的是**调用过一次就再也回不去逐像素透明** + (含 `'none'`:Electron 会让窗口回落到默认不透明底色 `#FFF`),所以从 `glass` 切到 + `black`/`tinted` 后会永久留一圈**纯白**。CSS `backdrop-filter` 也替代不了(透明窗口里采样不到桌面)。 + `glass` 因此用 CSS 半透明深色卡(WinIsland 无 backdrop 时的降级色 `rgba(32,32,36,.804)`)。 + 回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它**整屏截图**取样 + (`webContents.capturePage()` 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因), + 并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。 ## 6. 代码风格 diff --git a/README.md b/README.md index 8ed9ede..e98b2f1 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,7 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 > 冒烟用 DOM 几何断言(`dx > 0 且 dy < 0`)守住。 > **视觉与排版参考 [WinIsland](https://github.com/WinIslandProject/WinIsland)(Rust/Direct2D 的 Windows 动态岛)重写**: -> 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/亚克力毛玻璃/主题色三种底、 +> 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/半透明毛玻璃/主题色三种底、 > Apple 系统色强调色、白字 alpha 分级、**所有文本 = 基础字号 × 排版系数**、 > 胶囊 `h/2` 满圆角 + 展开卡 `min(48, w/2, h/2)`(WinIsland `expanded_island_radius`)、 > 顶/底 × 左/中/右 **6 个停靠位**、可选"空闲细缝"(WinIsland `hidden_width`)。 @@ -537,6 +537,9 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 ### 个性化设置与系统托盘 - **设置 → 灵动岛 · 个性化**新增:左右 / 上下**边距**(贴边距离,任务栏在侧面时调大)、 + **跟随鼠标屏幕**(多显示器教室电脑:岛出现在鼠标所在那块屏)。 + > 注意:**左右边距只在"左 / 右停靠"时生效**;默认的"居中停靠"下水平锚点就是屏幕中线, + > 设置页会把该滑块置灰并说明(否则会以为"调了没反应")。 - **设置 → ClassIsland 联动**:联动状态(设备名 / 最后上报)+ 通知模式(两者都弹 / 灵动岛 / ClassIsland)。 - **设置 → 作业录入**:作业快捷短语的增删与恢复默认。 @@ -544,20 +547,20 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一 **设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效): -| 设置项 | 区间/取值 | 说明 | -| ------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+186/+202/+218px`),内容用 flex 承载且不溢出 | -| 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动(`+156/+172/+188px`) | -| 圆角 | 8–32px | 胶囊取 `min(圆角, h/2)`(WinIsland 胶囊是满圆角);展开卡取 `min(48×圆角/20, w/2, h/2)` | -| 不透明度 | 40%–100% | 窗口整体不透明度(所有显示路径都会应用,含"隐藏后再弹出胶囊") | -| **字号** | **11–20px** | **基础字号**:岛内所有文本 = 基础字号 × 排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),改一处整体等比缩放 | -| 动画速度 | 0.5×–2× | 形变/淡入时长倍率 | -| 主题色 | 取色器(默认 Apple 系统蓝 `#0a84ff`) | 用于高亮、按钮、进度条、主题色风格(CSS 变量下发到灵动岛渲染进程) | -| **视觉风格** | **纯黑 / 毛玻璃(亚克力)/ 主题色渐变** | 纯黑 = WinIsland `default`;毛玻璃调用 `win.setBackgroundMaterial('acrylic')`(真·桌面模糊,等价 HostBackdropBrush) | -| **显示位置** | **顶部/底部 × 左/中/右(6 个锚点)** | 按 `workArea` 计算;顶/底决定不动的是上边还是下边,左/中/右决定水平锚点 | -| 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) | -| 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` | -| **空闲细缝** | **开/关(默认关)** | 参考 WinIsland `hidden_width`:开启后没有消息时不再完全隐藏,而是留一条 6px 宽的竖条(窗口是固定包围盒,不受 Windows 最小窗口高度限制) | +| 设置项 | 区间/取值 | 说明 | +| ------------ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+186/+202/+218px`),内容用 flex 承载且不溢出 | +| 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动(`+156/+172/+188px`) | +| 圆角 | 8–32px | 胶囊取 `min(圆角, h/2)`(WinIsland 胶囊是满圆角);展开卡取 `min(48×圆角/20, w/2, h/2)` | +| 不透明度 | 40%–100% | 窗口整体不透明度(所有显示路径都会应用,含"隐藏后再弹出胶囊") | +| **字号** | **11–20px** | **基础字号**:岛内所有文本 = 基础字号 × 排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),改一处整体等比缩放 | +| 动画速度 | 0.5×–2× | 形变/淡入时长倍率 | +| 主题色 | 取色器(默认 Apple 系统蓝 `#0a84ff`) | 用于高亮、按钮、进度条、主题色风格(CSS 变量下发到灵动岛渲染进程) | +| **视觉风格** | **纯黑 / 毛玻璃(半透明)/ 主题色渐变** | 纯黑 = WinIsland `default`;毛玻璃 = **CSS 半透明深色卡**(WinIsland 无 host backdrop 时的降级色 `rgba(32,32,36,.804)`)。**不用** `setBackgroundMaterial()`:它是整窗系统材质,会跟随应用主题把卡片外那一圈垫成白底(详见 AGENTS.md §5 第 21 条) | +| **显示位置** | **顶部/底部 × 左/中/右(6 个锚点)** | 按 `workArea` 计算;顶/底决定不动的是上边还是下边,左/中/右决定水平锚点 | +| 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) | +| 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` | +| **空闲细缝** | **开/关(默认关)** | 参考 WinIsland `hidden_width`:开启后没有消息时不再完全隐藏,而是留一条 6px 宽的竖条(窗口是固定包围盒,不受 Windows 最小窗口高度限制) | - 越界值在**主进程**统一夹紧(`normalizeIslandAppearance`),用户填坏配置也不会让布局错乱。 - 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。 @@ -670,7 +673,8 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | **紧急叫人(URGENT + 叫人)** | ✅ 上课中也立即展开:`mode=expanded kind=call reason=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 风格:三种底即时切换** | ✅ 纯黑 `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` | | **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` | | **个性化:停靠位置(6 个锚点)** | ✅ 左上 `8,8` / 右上 `1216,8` / 底部居中 `586,800` / 底部左 / 底部右 / 顶部居中 `586,8` | @@ -704,6 +708,8 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 教室机器上只做"看作业 + 录作业"(服务端的完成数据与统计接口保持不变,Web 端照旧可用)。 - 录入时可以点**快捷短语**(默认 `P` / `大本` / `小本` / `卷子` / `背诵` / `默写` / `听写` / `预习` / `订正` / `读书`), 点一下追加到标题或内容;短语在客户端「设置 → 作业录入」里增删,存在本机配置里。 +- **课间**:镜像回 ClassIsland 的课表按「每两节之间一段课间、最后一节之后不补」生成作息; + 服务端合成的节次时间表(没有导入时间表时的兜底)也是同一口径。 - 接口:`GET /homeworks?date=YYYY-MM-DD`(按天)、`GET /homeworks/days?classId=&from=&to=`(哪些天有作业)、 `POST /homeworks`(附 `assignDate`;**班级账号也能调用**,用于教室机器录入)。 @@ -1348,12 +1354,12 @@ pnpm db:migrate && pnpm db:seed 阶段 11(参考 WinIsland 重写灵动岛)新增文件: -| 路径 | 说明 | -| -------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `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/IslandApp.vue` | 灵动岛 UI 全量重写:squircle + 三种底 + Apple 系统色 + 字号系数驱动的排版 + 无回弹过渡 | -| `packages/desktop-client/src/main/island.ts` | 固定包围盒窗口、6 锚点、空闲细缝、亚克力材质、鼠标命中开关;岛尺寸增量与渲染层共用同一常量表 | +| 路径 | 说明 | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `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/IslandApp.vue` | 灵动岛 UI 全量重写:squircle + 三种底 + Apple 系统色 + 字号系数驱动的排版 + 无回弹过渡 | +| `packages/desktop-client/src/main/island.ts` | 固定包围盒窗口、6 锚点、空闲细缝、鼠标命中开关;岛尺寸增量与渲染层共用同一常量表(**刻意不设窗口背景材质**,见 AGENTS.md §7) | 阶段 10(学生端班级账号)新增文件: diff --git a/docs/winisland-design-tokens.md b/docs/winisland-design-tokens.md index 1d210cb..08c663c 100644 --- a/docs/winisland-design-tokens.md +++ b/docs/winisland-design-tokens.md @@ -546,6 +546,8 @@ VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4 2. **内阴影 / inset shadow**:代码中无任何实现 3. **字距(letter-spacing)与行高倍数**:无;行距全靠显式 y 偏移 4. **亚克力的模糊半径/透明度数值**:由 Windows `HostBackdropBrush` 系统决定,代码只给 `HOST_BACKDROP_INSET = 1.0` + → Electron 侧**没有**等价物:`setBackgroundMaterial('acrylic')` 是整窗系统材质(跟随应用主题、覆盖窗口矩形), + 不能像 `DWMWA_USE_HOSTBACKDROPBRUSH` 那样只画在圆角形状里;本项目因此改用无 backdrop 的降级色(见 §8) 5. **强调色用于岛内 UI**:岛内几乎没有强调色(只有资源占用的 CPU/RAM/警告/危险色); 统一强调色 `#0A84FF` 仅存在于**设置窗口**(`COLOR_ACCENT`) 6. **岛自身的亮/暗主题**:岛屿永远是深底白字;明暗主题只在设置窗口 @@ -561,7 +563,10 @@ VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4 (`cubic-bezier` 或 JS 弹簧,参考 `stiffness 0.10 / damping 0.68`);`overflow: hidden` 做裁切 - compact:`120×27`(有歌词时宽度跟着文本走,上限 700;只有媒体无歌词时 155),`border-radius: 13.5px`(胶囊) - expanded:`360×200`,`border-radius: 48px`(想要 iOS 味请用 SVG 超椭圆路径) -- 背景:`#000` 不透明;"glass" 用 `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)` 叠加 - 描边:`1px solid rgba(255,255,255,.118)`(纯色样式,展开时淡出)/ `.157`(glass、dynamic) - 阴影:仅展开 `0 2px σ rgba(0,0,0,.11)`(σ ≈ 3px) @@ -572,17 +577,17 @@ VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4 ## 附:班级小助手实际采纳了哪些(本次重写对照表) -| WinIsland 做法 | 出处 | 我们的落地 | 验证 | -| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| **窗口一次建大包围盒,形变发生在窗口内** | `window/app/layout.rs` `required_window_size()` + `PADDING=80` | 窗口固定为「最大形态 + `ISLAND_SHADOW_PAD(10)`」,开合期间**一帧都不 setBounds** | 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种) | -| 尺寸/圆角由弹簧驱动 | `utils/physics.rs` + `app.rs` `IslandSprings`(w/h/r = `0.10/0.68`) | `src/island/spring.ts` 同参数同更新式(dt 以帧计、`damping^dt`、换向保留 35% 动量 + 20% 距离上限),并加**零过冲钳制** | 岛中心/边缘波动 0.00px、无过冲 | -| 连续圆角(超椭圆) | `utils/shape.rs` `continuous_rounded_rect_path` / `expanded_island_radius = min(48*scale, w/2, h/2)` | `src/island/squircle.ts` 超椭圆角路径;展开半径 `min(48×圆角/20, w/2, h/2)` | 形状 bbox 与卡片框一致(±2px) | -| 内容交叉淡入 `expanded=progress²`、`mini=1-1.5×progress` | `core/render.rs`(`MINI_FADE_RATE=1.5`) | 两层常驻、透明度按同一公式 | 截图与 DOM 断言 | -| 描边 1px 白 alpha 30(纯色)/ 40(glass) | `core/render.rs` `BORDER_*_ALPHA` | `--wn-border` 取同值(`.118` / `.157`) | 计算样式断言 | -| 展开投影 `rgba(0,0,0,.11)`、y+2、σ=3 | `draw_expanded_shadow` | `drop-shadow(0 2px 3px rgba(0,0,0,.11))` | 截图 | -| 纯黑底 / 亚克力(HostBackdropBrush) | `background.rs` + `win32.rs` `DWMWA_USE_HOSTBACKDROPBRUSH` | 纯黑底 / `win.setBackgroundMaterial('acrylic')` | 计算样式 + `getBackgroundMaterial()` | -| `set_cursor_hittest` 命中控制 | `frame.rs`(`set_cursor_hittest`) | 默认 `setIgnoreMouseEvents(true,{forward:true})`,渲染层命中测试后切换 | 岛内 true / 岛外 false | -| 空闲细缝 `hidden_width = 5` | `core/config.rs` | 6px 宽竖条(窗口固定,不再受 Windows 最小窗口高度限制) | 空闲 `6x22` → 来消息 `268x44` | -| 6 个停靠位 `DockPosition` | `core/config.rs` | 顶/底 × 左/中/右 6 锚点,岛(而非窗口)精确落在锚点 | 6 锚点坐标断言 | -| `font_size` = 基础字号 × 各文本系数 | `core/render/mini.rs`、`ui/expanded/music_view.rs` | 基础字号 11–20px × 系数表(标题 1.08/正文 0.95/次要 0.78/徽标与按钮 0.76/胶囊 0.92、0.74) | 13→19px 全部文本倍率 1.462 | -| 未采纳:音乐/歌词/小组件/音量浮窗 | `ui/expanded/**`、`ui/widget/**` | 业务不同(我们做通知/作业/叫人/紧急) | — | +| WinIsland 做法 | 出处 | 我们的落地 | 验证 | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| **窗口一次建大包围盒,形变发生在窗口内** | `window/app/layout.rs` `required_window_size()` + `PADDING=80` | 窗口固定为「最大形态 + `ISLAND_SHADOW_PAD(10)`」,开合期间**一帧都不 setBounds** | 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种) | +| 尺寸/圆角由弹簧驱动 | `utils/physics.rs` + `app.rs` `IslandSprings`(w/h/r = `0.10/0.68`) | `src/island/spring.ts` 同参数同更新式(dt 以帧计、`damping^dt`、换向保留 35% 动量 + 20% 距离上限),并加**零过冲钳制** | 岛中心/边缘波动 0.00px、无过冲 | +| 连续圆角(超椭圆) | `utils/shape.rs` `continuous_rounded_rect_path` / `expanded_island_radius = min(48*scale, w/2, h/2)` | `src/island/squircle.ts` 超椭圆角路径;展开半径 `min(48×圆角/20, w/2, h/2)` | 形状 bbox 与卡片框一致(±2px) | +| 内容交叉淡入 `expanded=progress²`、`mini=1-1.5×progress` | `core/render.rs`(`MINI_FADE_RATE=1.5`) | 两层常驻、透明度按同一公式 | 截图与 DOM 断言 | +| 描边 1px 白 alpha 30(纯色)/ 40(glass) | `core/render.rs` `BORDER_*_ALPHA` | `--wn-border` 取同值(`.118` / `.157`) | 计算样式断言 | +| 展开投影 `rgba(0,0,0,.11)`、y+2、σ=3 | `draw_expanded_shadow` | `drop-shadow(0 2px 3px rgba(0,0,0,.11))` | 截图 | +| 纯黑底 / 亚克力(HostBackdropBrush) | `background.rs` + `win32.rs` `DWMWA_USE_HOSTBACKDROPBRUSH` | 纯黑底 / **glass 用 CSS 半透明深色卡**(WinIsland 无 backdrop 降级色 `rgba(32,32,36,.804)`);**不调用** `setBackgroundMaterial()` | 计算样式 + 「卡片外圈透出桌面」整屏截图像素断言 | +| `set_cursor_hittest` 命中控制 | `frame.rs`(`set_cursor_hittest`) | 默认 `setIgnoreMouseEvents(true,{forward:true})`,渲染层命中测试后切换 | 岛内 true / 岛外 false | +| 空闲细缝 `hidden_width = 5` | `core/config.rs` | 6px 宽竖条(窗口固定,不再受 Windows 最小窗口高度限制) | 空闲 `6x22` → 来消息 `268x44` | +| 6 个停靠位 `DockPosition` | `core/config.rs` | 顶/底 × 左/中/右 6 锚点,岛(而非窗口)精确落在锚点 | 6 锚点坐标断言 | +| `font_size` = 基础字号 × 各文本系数 | `core/render/mini.rs`、`ui/expanded/music_view.rs` | 基础字号 11–20px × 系数表(标题 1.08/正文 0.95/次要 0.78/徽标与按钮 0.76/胶囊 0.92、0.74) | 13→19px 全部文本倍率 1.462 | +| 未采纳:音乐/歌词/小组件/音量浮窗 | `ui/expanded/**`、`ui/widget/**` | 业务不同(我们做通知/作业/叫人/紧急) | — | diff --git a/packages/classisland-plugin/README.md b/packages/classisland-plugin/README.md index d2379a2..37041c0 100644 --- a/packages/classisland-plugin/README.md +++ b/packages/classisland-plugin/README.md @@ -46,6 +46,10 @@ > 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。 > 暂存期间不回执,服务端会保留这条待提醒。 +> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段 +> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来, +> 不依赖班级小助手那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。 + > **两个方向各自需要的开关**: > > - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开; diff --git a/packages/classisland-plugin/src/Services/BridgeService.cs b/packages/classisland-plugin/src/Services/BridgeService.cs index a0fcfc2..8bf97e7 100644 --- a/packages/classisland-plugin/src/Services/BridgeService.cs +++ b/packages/classisland-plugin/src/Services/BridgeService.cs @@ -462,24 +462,26 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable } /// - /// 优先用"当前正在生效"的课表对应的时间表;取不到再退回档案里的第一个。 - /// 有多个时间表时选错会把节次时间报错,所以先按当前课表找。 + /// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。 + /// + /// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表, + /// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去, + /// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。 + /// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。 /// private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout( ClassIsland.Shared.Models.Profile.Profile profile) { if (profile.TimeLayouts.Count == 0) return null; - var current = profile.SelectedClassPlanGroupId != Guid.Empty - ? CurrentClassPlan(profile) - : null; - if (current is not null) - { - var layout = ScheduleMapper.ResolveTimeLayout(current, profile); - if (layout is not null) return layout; - } + var best = profile.TimeLayouts + .Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0))) + .Where(item => item.ClassPoints > 0) + .OrderByDescending(item => item.ClassPoints) + .ToList(); + if (best.Count > 0) return best[0].Value; - // 退而求其次:档案里被激活的时间表,最后才是任意一个 + // 兜底:档案里被激活的时间表,最后才是任意一个 return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated) ?? profile.TimeLayouts.Values.First(); } diff --git a/packages/classisland-plugin/src/Services/ClassPlanWriter.cs b/packages/classisland-plugin/src/Services/ClassPlanWriter.cs index 298f887..a09759e 100644 --- a/packages/classisland-plugin/src/Services/ClassPlanWriter.cs +++ b/packages/classisland-plugin/src/Services/ClassPlanWriter.cs @@ -51,11 +51,6 @@ public static class ClassPlanWriter } 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) EnsureSubjects(profile, source); @@ -86,7 +81,7 @@ public static class ClassPlanWriter group.WeekCountDivTotal, groups.Count); var planId = FindPlanId(profile, planName); var (timeLayoutId, timeLayout) = BuildTimeLayout( - profile, planName, group.Entries, breakPoints); + profile, planName, group.Entries); var plan = profile.ClassPlans.TryGetValue(planId, out var existing) ? existing @@ -172,38 +167,45 @@ public static class ClassPlanWriter } /// - /// 按分组内的条目重建时间表:每条课目前后按需插入服务端的课间点。 - /// 返回写入的时间表 GUID。 + /// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。 + /// + /// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来, + /// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表, + /// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。 /// private static (Guid Id, TimeLayout Layout) BuildTimeLayout( Profile profile, string planName, - IReadOnlyList entries, - IReadOnlyDictionary breakPoints) + IReadOnlyList entries) { var layoutName = planName + TimeLayoutNameSuffix; var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName); var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid(); var layout = new TimeLayout { Name = layoutName, IsActivated = true }; - foreach (var entry in entries) + for (var index = 0; index < entries.Count; index++) { + var entry = entries[index]; layout.Layouts.Add(new TimeLayoutItem { TimeType = 0, // 上课 StartTime = ParseTime(entry.StartTime), EndTime = ParseTime(entry.EndTime), }); - // 该课目结束后紧接着的课间(服务端时间表里有同起始时刻的课间点才插入) - if (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, - StartTime = ParseTime(brk.StartTime), - EndTime = ParseTime(brk.EndTime), - }); - } + TimeType = 1, // 课间 + StartTime = breakStart, + EndTime = breakEnd, + }); } profile.TimeLayouts[id] = layout; diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index e86bce8..3e9a1e5 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -21,8 +21,9 @@ import { squirclePath } from './squircle.js'; * 对齐点(详见 docs/winisland-design-tokens.md): * - 造型:**连续圆角(超椭圆 squircle)**而不是 border-radius(WinIsland `utils/shape.rs`); * 展开卡半径取 `min(48 * 圆角系数, w/2, h/2)`,胶囊半径取 `h/2`(满圆角); - * - 底色:默认纯黑(WinIsland `default` 风格),可选亚克力毛玻璃(`glass` → 主进程 - * `setBackgroundMaterial('acrylic')`,等价于 WinIsland 的 HostBackdropBrush); + * - 底色:默认纯黑(WinIsland `default` 风格),可选半透明毛玻璃(`glass`; + * **纯 CSS 实现**,取 WinIsland 拿不到 host backdrop 时的降级色 `rgba(32,32,36,.804)`—— + * Electron 的 `setBackgroundMaterial()` 不能用在逐像素透明窗口上,原因见 `main/island.ts`); * - 描边:1px 白、alpha 30(纯黑)/ 40(玻璃、主题色),与 WinIsland `BORDER_*_ALPHA` 同值; * - 阴影:只在展开态,`0 2px 3px rgba(0,0,0,.11)`(WinIsland `draw_expanded_shadow`); * - 排版:白字 + alpha 分级(.92/.72/.58),**所有文本 = 基础字号 × 排版系数**, @@ -714,7 +715,8 @@ body { } .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-surface: rgba(255, 255, 255, 0.14); --wn-surface-hover: rgba(255, 255, 255, 0.22); diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index bb6dc98..0b74a30 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -123,8 +123,6 @@ class IslandController { private boundsFrame: number | null = null; /** 外观设置(设置页可调,主进程持久化) */ private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE }; - /** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */ - private backgroundMaterial: string = 'none'; /** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */ /** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */ private blurCollapseEnabled = true; @@ -221,9 +219,8 @@ class IslandController { win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); - // 启动时即应用已保存的风格(glass 需要窗口创建后立刻设置材质) - this.backgroundMaterial = 'none'; - this.applyBackgroundMaterial(); + // 注意:这里**不能**调用 `win.setBackgroundMaterial()`(亚克力/mica), + // 见 `applyBackgroundMaterial` 移除处的说明。 // 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击), // 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。 win.setIgnoreMouseEvents(true, { forward: true }); @@ -980,6 +977,29 @@ class IslandController { 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`),开合过程不碰窗口。 @@ -990,7 +1010,6 @@ class IslandController { if (this.win && !this.win.isDestroyed()) { this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); - this.applyBackgroundMaterial(); if (this.win.isVisible()) { // 显示中:立即应用透明度(隐藏时由 fadeIn 负责) 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`): * 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。 diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 0221ab0..ec2f9ce 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { app, screen } from 'electron'; -import type { BrowserWindow, NativeImage } from 'electron'; +import { app, BrowserWindow, desktopCapturer, screen } from 'electron'; +import type { NativeImage } from 'electron'; import type { IslandAppearance, IslandNotification } from '@classhelper/shared'; import { getConfig, saveConfig } from './config.js'; import { getDiagnostics } from './ipc.js'; @@ -59,6 +59,8 @@ interface IslandShotStats { opaque: number; transparent: number; uniqueColors: number; + /** 平均亮度(0~255) */ + luminance: number; /** 偏红像素数(紧急形态的红色描边/角标/内部光晕) */ reddish: number; /** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */ @@ -79,6 +81,8 @@ function analyzeBitmap(image: NativeImage): { uniqueColors: number; reddish: number; edgeReddish: number; + /** 整张图的平均亮度(0~255):用来抓「卡片底色发白」这类问题 */ + luminance: number; /** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */ outsideReddish?: number; } { @@ -89,6 +93,7 @@ function analyzeBitmap(image: NativeImage): { let transparent = 0; let reddish = 0; let edgeReddish = 0; + let luminanceSum = 0; const colors = new Set(); for (let index = 0; index < total; index += 1) { @@ -103,6 +108,7 @@ function analyzeBitmap(image: NativeImage): { continue; } opaque += 1; + luminanceSum += 0.2126 * red + 0.7152 * green + 0.0722 * 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 { + 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 = ``; + 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 { + 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 返回物理像素) */ function cropToIsland( image: Electron.NativeImage, @@ -1995,9 +2249,7 @@ async function runIslandChecks( ); // 10.7) 视觉风格:纯黑 / 毛玻璃(亚克力)/ 主题色渐变,切换立即生效 - const probeStyle = async ( - style: IslandAppearance['style'], - ): Promise<{ fill: string; stroke: string; material: string }> => { + const probeStyle = async (style: IslandAppearance['style']): Promise<{ fill: string; stroke: string }> => { island.setAppearance({ ...appearanceBefore, style }); await sleep(260); const measured = await islandWindow?.webContents @@ -2013,7 +2265,6 @@ async function runIslandChecks( return { fill: String(measured?.fill ?? '-'), stroke: String(measured?.stroke ?? '-'), - material: island.getBackgroundMaterial(), }; }; @@ -2021,15 +2272,11 @@ async function runIslandChecks( const glassStyle = await probeStyle('glass'); const tintedStyle = await probeStyle('tinted'); record( - '视觉风格切换立刻生效(纯黑 / 毛玻璃亚克力 / 主题色)', + '视觉风格切换立刻生效(纯黑 / 毛玻璃半透明 / 主题色)', blackStyle.fill === 'rgb(0, 0, 0)' && - blackStyle.material === 'none' && - glassStyle.fill.startsWith('rgba(10, 10, 14') && - glassStyle.material === 'acrylic' && + glassStyle.fill.startsWith('rgba(32, 32, 36') && tintedStyle.fill === 'rgb(11, 11, 15)', - `纯黑 fill=${blackStyle.fill} 材质=${blackStyle.material};` + - `毛玻璃 fill=${glassStyle.fill} 材质=${glassStyle.material};` + - `主题色 fill=${tintedStyle.fill} 材质=${tintedStyle.material}`, + `纯黑 fill=${blackStyle.fill};毛玻璃 fill=${glassStyle.fill};主题色 fill=${tintedStyle.fill}`, ); // 10.7b) 固定大包围盒窗口的鼠标穿透(照搬 WinIsland set_cursor_hittest): // 默认整块窗口穿透,指针进入岛体才接收鼠标 —— 否则会吞掉桌面点击。 @@ -2171,6 +2418,47 @@ async function runIslandChecks( ); 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 位置:四个停靠点的窗口坐标必须落在对应锚点 const positionCases: { position: IslandAppearance['position']; @@ -2500,9 +2788,7 @@ async function runIslandRealtimeCheck( * 因此这里在真实设置页里合成一次拖拽(mousedown → mousemove → mouseup,与用户操作同一路径), * 并同时断言「滑块自身的数值」与「主进程里的外观」都变了 —— 只断言前者会漏掉这个 bug。 */ -async function runSettingsPageAppearanceCheck( - win: BrowserWindow, -): Promise<{ ok: boolean; detail: string }> { +async function runSettingsPageAppearanceCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> { const result = (await win.webContents .executeJavaScript( `(async () => { @@ -2553,9 +2839,7 @@ async function runSettingsPageAppearanceCheck( ok: false as const, detail: `执行失败:${error instanceof Error ? error.message : String(error)}`, }))) as - | { ok: true; baseline: number; dragged: number; label: string } - | { ok: false; detail: string } - | null; + { ok: true; baseline: number; dragged: number; label: string } | { ok: false; detail: string } | null; if (result?.ok) { return { @@ -2569,14 +2853,176 @@ async function runSettingsPageAppearanceCheck( } /** - * 设置页「通知显示位置」自检(需求:提醒弹在 ClassHelper 还是 ClassIsland,由客户端自己选)。 + * 设置页「左右边距」自检(用户反馈:调左右边距**没有实时预览、也不生效**)。 * - * 走真实 UI:切到设置页 → 点单选项 → 断言「本地配置已写入」,再切回去。 - * 服务端同步(写回班级记录)由 verify:classisland 覆盖,这里只管客户端这一半。 + * 为什么不能只调主进程:先前那条用例是直接 `island.setAppearance(...)`,绕过了 + * 「设置页 → preload → IPC → 主进程」这条真实链路 —— 用户遇到的失败很可能就在这一段。 + * 因此这里在真实设置页里对「左右边距」滑块合成一次拖拽,并且: + * 1) 断言主进程 appearance.marginX 真的变了(IPC + 写值通了); + * 2) 断言岛的**真实几何**按新边距移动(窗口重排生效)。 + * + * 岛的真实几何来自渲染进程上报(readGeometry),不是窗口 bounds —— 窗口是固定包围盒, + * 只有岛体坐标才反映锚点。 */ -async function runSettingsPageChannelCheck( +async function runSettingsPageMarginCheck( win: BrowserWindow, + readGeometry: () => Promise<{ island: Electron.Rectangle } | null>, ): 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 .executeJavaScript( `(async () => { @@ -2616,7 +3062,14 @@ async function runSettingsPageChannelCheck( ok: false as const, detail: `执行失败:${error instanceof Error ? error.message : String(error)}`, }))) 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 } | null; @@ -2661,6 +3114,9 @@ async function runTrayAndShutdownChecks(mainWin: BrowserWindow, record: Recorder destroyTray(); island.destroy(); + // 卡片外圈自检用的纯色底板(若已创建)也要一并释放,不留隐藏窗口 + if (islandBackdropWindow && !islandBackdropWindow.isDestroyed()) islandBackdropWindow.destroy(); + islandBackdropWindow = null; await sleep(200); record( '退出前资源清理(托盘/灵动岛已释放)', @@ -2831,6 +3287,12 @@ export async function runSmokeTest(win: BrowserWindow): Promise { const settingsChannel = await runSettingsPageChannelCheck(win); 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); diff --git a/packages/desktop-client/src/renderer/views/SettingsView.vue b/packages/desktop-client/src/renderer/views/SettingsView.vue index a3a94bb..7fec894 100644 --- a/packages/desktop-client/src/renderer/views/SettingsView.vue +++ b/packages/desktop-client/src/renderer/views/SettingsView.vue @@ -242,6 +242,14 @@ async function resetPhrases(): Promise { /* ------------------------------------------------------------ 灵动岛个性化 */ const island = ref({ ...DEFAULT_ISLAND_APPEARANCE }); + +/** + * 左右边距是否有效:只在停靠左 / 右时生效。 + * + * 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 —— + * 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。 + */ +const marginXApplies = computed(() => !island.value.position.endsWith('center')); const savingIsland = ref(false); const positionOptions = ISLAND_POSITIONS.map((value) => ({ @@ -442,7 +450,7 @@ onMounted(async () => { :value="item.value" /> - 纯黑 / 毛玻璃(亚克力)/ 主题色渐变 + 纯黑 / 毛玻璃(半透明)/ 主题色渐变 { :min="RANGES.marginX.min" :max="RANGES.marginX.max" :step="2" + :disabled="!marginXApplies" @input="(value: number) => applyIslandAppearance({ marginX: value })" /> - 停靠左/右时距屏幕边缘的距离 + 停靠左/右时距屏幕边缘的距离 + + 当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果 + { {{ classIslandStatusText }} - {{ classIslandStatus?.pluginVersion ?? '—' }} / {{ classIslandStatus?.classIslandVersion ?? '—' }} + {{ classIslandStatus?.pluginVersion ?? '—' }} / + {{ classIslandStatus?.classIslandVersion ?? '—' }}

- 通知模式:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland, + 通知模式 + :老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland, 由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。

- + {{ item.label }}

{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}

@@ -562,8 +586,9 @@ onMounted(async () => {

- 在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 - 最多 {{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条 {{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。 + 在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多 + {{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条 + {{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。

a[0].localeCompare(b[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; } /* ------------------------------------------------------------------ 供仪表盘/统计使用 */ diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index a317635..02eb667 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -173,7 +173,7 @@ export const ISLAND_STYLES = ['black', 'glass', 'tinted'] as const; export const ISLAND_STYLE_LABELS: Record<(typeof ISLAND_STYLES)[number], string> = { black: '纯黑(灵动岛默认)', - glass: '毛玻璃(半透明模糊)', + glass: '毛玻璃(半透明)', tinted: '主题色渐变', };