feat(island): 参考 WinIsland 重写灵动岛(squircle + 三种底 + 字号系数排版 + 空闲细缝)
参考: 工作目录 WinIsland-master.zip(Rust/Direct2D 的 Windows 动态岛),
逐项提取的设计 token 归档在 docs/winisland-design-tokens.md。
视觉与造型:
- 连续圆角 squircle(新增 island/squircle.ts,超椭圆角,对齐 WinIsland utils/shape.rs),
胶囊半径 = min(圆角, h/2)(满圆角),展开卡 = min(48*圆角/20, w/2, h/2)(WinIsland expanded_island_radius)
- 三种底: 纯黑(WinIsland default)/ 毛玻璃(win.setBackgroundMaterial('acrylic'),等价 HostBackdropBrush)/
主题色渐变;描边 1px 白 alpha .118(纯黑)/.157(玻璃),展开投影 0 2px 3px rgba(0,0,0,.11)
- Apple 系统色: 强调蓝 #0a84ff、警告橙 #ff9f0a、危险红 #ff453a;白字 alpha 分级 .92/.72/.58
- 状态色只做"内层微染 + 描边 + 徽标",不外溢(保持外圈偏红 = 0)
排版(本次重点):
- 基础字号 11–20px,岛内**所有文本** = 基础字号 × 排版系数
(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),
与 WinIsland 的 font_size 模型一致;实测 13→19px 全部文本倍率 1.462(=19/13)
- 图标、按钮高度、间距也由字号推导,改字号不会溢出
形态与设置:
- 尺寸基准对齐 WinIsland 比例(胶囊 216x34 基准、展开 +156/+186 等),卡片 = 窗口 - 2*5px 且水平居中
- 6 个停靠位(顶/底 × 左/中/右,对齐 WinIsland DockPosition)
- 新增"空闲细缝"(WinIsland hidden_width):空闲留一条 6px 宽竖条,来消息自动展开
- 所有设置依旧"改即生效";顺带修掉 hide() 用 skipWindow 绕过 syncWindow 导致细缝不生效的问题
验证(verify:desktop 49 → 53 项,全绿):
- 字号即时生效: 13px→19px 五项文本倍率全部 1.462
- 风格即时生效: 纯黑 fill=rgb(0,0,0)/材质 none;毛玻璃 fill=rgba(10,10,14,.59)/材质 acrylic;主题色 fill=rgb(11,11,15)
- 空闲细缝: 16x36 → 来消息 268x44;关闭后完全隐藏
- 6 锚点、开合不震动(0.00px / 无过冲)、截图 6 张(比例偏差 0.000、外圈偏红 0)全部保持
- verify:e2e 133/133、verify:web 19/19 不受影响;截图归档到 docs/screenshots/island/
工程发现: Windows 对透明窗口有约 36px 最小高度(请求 32px 实际 36px),
空闲细缝高度据此设下限并写入注释与 README。
No files matched your search
@@ -20,7 +20,7 @@
|
|||||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 49/49) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 53/53) |
|
||||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||||
@@ -179,7 +179,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项,
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项,
|
||||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||||
(**班级账号登录** + 四类数据 + Socket.IO),实测 49/49 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 43/43)。
|
(**班级账号登录** + 四类数据 + Socket.IO),实测 53/53 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 47/47)。
|
||||||
|
|
||||||
## 默认账号与种子数据
|
## 默认账号与种子数据
|
||||||
|
|
||||||
@@ -314,8 +314,21 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
|
|
||||||
### 灵动岛(桌面通知浮窗)
|
### 灵动岛(桌面通知浮窗)
|
||||||
|
|
||||||
客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口,
|
客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口。它是"消息到达"的第一现场。
|
||||||
常驻桌面**中上方**(屏幕工作区顶部居中,`y = workArea.y + 8`)。它是"消息到达"的第一现场:
|
|
||||||
|
> **视觉与排版参考 [WinIsland](https://github.com/WinIslandProject/WinIsland)(Rust/Direct2D 的 Windows 动态岛)重写**:
|
||||||
|
> 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/亚克力毛玻璃/主题色三种底、
|
||||||
|
> Apple 系统色强调色、白字 alpha 分级、**所有文本 = 基础字号 × 排版系数**、
|
||||||
|
> 胶囊 `h/2` 满圆角 + 展开卡 `min(48, w/2, h/2)`(WinIsland `expanded_island_radius`)、
|
||||||
|
> 顶/底 × 左/中/右 **6 个停靠位**、可选"空闲细缝"(WinIsland `hidden_width`)。
|
||||||
|
> 逐项从 WinIsland 源码提取的设计 token 见 **[docs/winisland-design-tokens.md](docs/winisland-design-tokens.md)**。
|
||||||
|
>
|
||||||
|
> 与 WinIsland 的两点**有意差异**(都写进了实现注释):
|
||||||
|
>
|
||||||
|
> 1. WinIsland 用"固定大窗口 + 画布内形变",我们沿用"按状态缓动窗口尺寸"——
|
||||||
|
> 在 Electron 里前者需要常驻的点击穿透大窗口(容易吞掉桌面点击),后者已被 53 项断言覆盖(含 0.00px 不震动);
|
||||||
|
> 2. Windows 对透明窗口有约 **36px 的最小高度**(实测请求 32px 得到 36px),
|
||||||
|
> 因此"空闲细缝"的高度以 36px 为下限(WinIsland 因为窗口恒定所以不受此限)。
|
||||||
|
|
||||||
| 场景 | 灵动岛行为 |
|
| 场景 | 灵动岛行为 |
|
||||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -394,18 +407,20 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
|
|
||||||
**设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效):
|
**设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效):
|
||||||
|
|
||||||
| 设置项 | 区间/取值 | 说明 |
|
| 设置项 | 区间/取值 | 说明 |
|
||||||
| ---------- | --------------------------------- | ------------------------------------------------------------------------------ |
|
| ------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+264/+292/+308px`),内容用 flex + 溢出滚动承载 |
|
| 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+186/+202/+218px`),内容用 flex 承载且不溢出 |
|
||||||
| 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动 |
|
| 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动(`+156/+172/+188px`) |
|
||||||
| 圆角 | 8–32px | 卡片圆角 |
|
| 圆角 | 8–32px | 胶囊取 `min(圆角, h/2)`(WinIsland 胶囊是满圆角);展开卡取 `min(48×圆角/20, w/2, h/2)` |
|
||||||
| 不透明度 | 40%–100% | 窗口整体不透明度 |
|
| 不透明度 | 40%–100% | 窗口整体不透明度(所有显示路径都会应用,含"隐藏后再弹出胶囊") |
|
||||||
| 字号 | 11–18px | 卡片基础字号 |
|
| **字号** | **11–20px** | **基础字号**:岛内所有文本 = 基础字号 × 排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),改一处整体等比缩放 |
|
||||||
| 动画速度 | 0.5×–2× | 形变/淡入时长倍率 |
|
| 动画速度 | 0.5×–2× | 形变/淡入时长倍率 |
|
||||||
| 主题色 | 取色器 | 用于高亮、按钮、进度条(CSS 变量下发到灵动岛渲染进程) |
|
| 主题色 | 取色器(默认 Apple 系统蓝 `#0a84ff`) | 用于高亮、按钮、进度条、主题色风格(CSS 变量下发到灵动岛渲染进程) |
|
||||||
| 显示位置 | 顶部居中 / 左上 / 右上 / 底部居中 | 按住 `workArea` 计算,多屏跟随主屏 |
|
| **视觉风格** | **纯黑 / 毛玻璃(亚克力)/ 主题色渐变** | 纯黑 = WinIsland `default`;毛玻璃调用 `win.setBackgroundMaterial('acrylic')`(真·桌面模糊,等价 HostBackdropBrush) |
|
||||||
| 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) |
|
| **显示位置** | **顶部/底部 × 左/中/右(6 个锚点)** | 按 `workArea` 计算;顶/底决定不动的是上边还是下边,左/中/右决定水平锚点 |
|
||||||
| 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` |
|
| 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) |
|
||||||
|
| 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` |
|
||||||
|
| **空闲细缝** | **开/关(默认关)** | 参考 WinIsland `hidden_width`:开启后没有消息时不再完全隐藏,而是留一条 6px 宽的竖条(受 Windows 最小窗口高度 36px 限制) |
|
||||||
|
|
||||||
- 越界值在**主进程**统一夹紧(`normalizeIslandAppearance`),用户填坏配置也不会让布局错乱。
|
- 越界值在**主进程**统一夹紧(`normalizeIslandAppearance`),用户填坏配置也不会让布局错乱。
|
||||||
- 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。
|
- 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。
|
||||||
@@ -425,7 +440,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
3. **卡片宽度取偶数**:卡片在窗口内水平居中,偶宽保证卡片中心落在整数像素上。
|
3. **卡片宽度取偶数**:卡片在窗口内水平居中,偶宽保证卡片中心落在整数像素上。
|
||||||
|
|
||||||
冒烟实测(逐帧采样窗口矩形 + 卡片真实屏幕矩形):展开时**卡片中心波动 0.00px、上边缘波动 0.00px**,
|
冒烟实测(逐帧采样窗口矩形 + 卡片真实屏幕矩形):展开时**卡片中心波动 0.00px、上边缘波动 0.00px**,
|
||||||
窗口最大 404×316(等于目标,不再过冲到 411×329),卡片最大 396×308(不再放大到 400.6px 再回落);
|
窗口最大 424×230(等于目标),卡片最大 414×220(重写前是 411×329 / 400.6×311.6 的过冲);
|
||||||
收回时同样 0.00px / 0.00px、最小窗口 268,窗口宽度序列 `356→318→286→274→270→268…` 单调收缩无回跳;
|
收回时同样 0.00px / 0.00px、最小窗口 268,窗口宽度序列 `356→318→286→274→270→268…` 单调收缩无回跳;
|
||||||
连续 5 次快速开合后状态与尺寸稳定。
|
连续 5 次快速开合后状态与尺寸稳定。
|
||||||
|
|
||||||
@@ -505,18 +520,21 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") |
|
| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") |
|
||||||
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 |
|
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 |
|
||||||
| **个性化:尺寸联动不破坏布局** | ✅ 高 56 / 宽 300 / 圆角 26 / 字号 14 → 窗口 `300x56`,CSS 变量同步,展开态内容溢出 **0px** |
|
| **个性化:尺寸联动不破坏布局** | ✅ 高 56 / 宽 300 / 圆角 26 / 字号 14 → 窗口 `300x56`,CSS 变量同步,展开态内容溢出 **0px** |
|
||||||
| **开合不震动:展开** | ✅ 逐帧采样 55 帧:卡片中心波动 **0.00px**、上边缘波动 **0.00px**、最大窗口 `404x316`(=目标,无过冲)、单调扩张 |
|
| **开合不震动:展开** | ✅ 逐帧采样 57 帧:卡片中心波动 **0.00px**、上边缘波动 **0.00px**、最大窗口 `424x230`(=目标,无过冲)、单调扩张 |
|
||||||
| **开合不震动:收回** | ✅ 逐帧采样 57 帧:卡片中心/上边缘波动 **0.00px**、最小窗口 `268`、宽度序列单调收缩无回跳 |
|
| **开合不震动:收回** | ✅ 逐帧采样 56 帧:卡片中心/上边缘波动 **0.00px**、最小窗口 `268`、宽度序列单调收缩无回跳 |
|
||||||
|
| **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 空闲细缝** | ✅ 空闲窗口收成 `16x36`(含 Windows 36px 最小高度下限)→ 来消息自动回到 `268x44`;关闭细缝后空闲 `isVisible=false` |
|
||||||
| **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` |
|
| **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` |
|
||||||
| **个性化:停靠位置(4 个锚点)** | ✅ 左上 `8,8` / 右上 `1164,8` / 底部居中 `586,800` / 顶部居中 `586,8` |
|
| **个性化:停靠位置(6 个锚点)** | ✅ 左上 `8,8` / 右上 `1216,8` / 底部居中 `586,800` / 底部左 / 底部右 / 顶部居中 `586,8` |
|
||||||
| **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` |
|
| **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` |
|
||||||
| **个性化:动画开关 / 速度** | ✅ 关闭动画后 60ms 内即为 `404x316`(无过渡);speed=0.5 收回 435ms vs speed=2 收回 128ms |
|
| **个性化:动画开关 / 速度** | ✅ 关闭动画后 60ms 内即到位(无过渡);speed=2 收回明显快于 speed=0.5 |
|
||||||
| **个性化:持久化** | ✅ 写入客户端配置后重新读取完全一致(尺寸/圆角/透明度/主题色/字号/动画/速度/位置/置顶) |
|
| **个性化:持久化** | ✅ 写入客户端配置后重新读取完全一致(尺寸/圆角/透明度/主题色/字号/动画/速度/位置/置顶/风格/细缝) |
|
||||||
| **退出清理:托盘 / 灵动岛 / 窗口** | ✅ `isTrayReady()=false`、`island.isReady()=false`、窗口全部销毁 |
|
| **退出清理:托盘 / 灵动岛 / 窗口** | ✅ `isTrayReady()=false`、`island.isReady()=false`、窗口全部销毁 |
|
||||||
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
||||||
|
|
||||||
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
||||||
已实测 `packaged: true`、49/49 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
已实测 `packaged: true`、53/53 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
||||||
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
||||||
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
||||||
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
||||||
@@ -871,14 +889,14 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
|
|
||||||
当前实测:
|
当前实测:
|
||||||
|
|
||||||
| 验证 | 结果 |
|
| 验证 | 结果 |
|
||||||
| -------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
| --------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
||||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
||||||
| `pnpm verify:desktop`(客户端冒烟 + 开合不震动 + 个性化全参数 + 托盘与退出无残留) | **49/49** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + 开合不震动 + WinIsland 风格 + 个性化全参数 + 托盘与退出无残留) | **53/53** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|
||||||
## 常见问题(本机环境已知坑)
|
## 常见问题(本机环境已知坑)
|
||||||
|
|
||||||
@@ -990,6 +1008,15 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
| `packages/web-admin/src/components/TimeLayoutImportDialog.vue` | ClassIsland 时间配置导入弹窗(粘贴/选文件、解析预览、覆盖/合并、已存列表) |
|
| `packages/web-admin/src/components/TimeLayoutImportDialog.vue` | ClassIsland 时间配置导入弹窗(粘贴/选文件、解析预览、覆盖/合并、已存列表) |
|
||||||
| `packages/shared/src/types.ts` 的导入 DTO | `TableImportPreview` / `TableImportResult` / `TimeLayoutDto` 等前后端共用 |
|
| `packages/shared/src/types.ts` 的导入 DTO | `TableImportPreview` / `TableImportResult` / `TimeLayoutDto` 等前后端共用 |
|
||||||
|
|
||||||
|
阶段 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 锚点、空闲细缝、亚克力材质、Windows 最小高度下限;尺寸常量与渲染进程 EXTRA 一一对应 |
|
||||||
|
|
||||||
阶段 10(学生端班级账号)新增文件:
|
阶段 10(学生端班级账号)新增文件:
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1,563 @@
|
|||||||
|
# WinIsland 视觉设计系统(Design Token 规格)
|
||||||
|
|
||||||
|
来源:`D:\classhelper\.cache\winisland-ref\WinIsland-master`(只读 checkout)。
|
||||||
|
所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。
|
||||||
|
|
||||||
|
关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**,
|
||||||
|
下文数值默认 scale = 1.0。文字颜色在渲染入口被硬编码为 `Color::WHITE`(`src/core/render.rs`
|
||||||
|
`draw_expanded_layer` / `draw_compact_layer` 传 `text_color: Color::WHITE`, `text_color_sec: Color::WHITE`),
|
||||||
|
所有"次级/次要"文字靠 **alpha** 或 **灰度通道值** 表达,而不是换颜色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 几何(几何 token)
|
||||||
|
|
||||||
|
### 1.1 两个状态的尺寸
|
||||||
|
|
||||||
|
| 项 | compact / mini | expanded | 来源 |
|
||||||
|
| -------- | ------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- |
|
||||||
|
| 宽 | `base_width = 120.0` | `expanded_width = 360.0` | `src/core/config.rs` `AppConfig::default()` |
|
||||||
|
| 高 | `base_height = 27.0` | `expanded_height = 200.0` | 同上 |
|
||||||
|
| 圆角 r | `h / 2`(=13.5)→ 胶囊/体育场形 | `min(48*scale, w/2, h/2)` = **48.0** | `src/window/app/frame.rs` `default_target_r`;`src/utils/shape.rs` `expanded_island_radius()` |
|
||||||
|
| 全局缩放 | `compact_scale = 1.0` | `expanded_scale = 1.0` | `src/core/config.rs` |
|
||||||
|
|
||||||
|
圆角函数原文(`src/utils/shape.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) fn expanded_island_radius(w: f32, h: f32, scale: f32) -> f32 {
|
||||||
|
(48.0 * scale).min(w / 2.0).min(h / 2.0).max(0.0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
compact 圆角:`IslandSprings::new` 里 `r = (base_height * compact_scale) / 2.0`(`src/window/app.rs`);
|
||||||
|
展开时 `default_target_r = compact_content_h / 2.0`(`src/window/app/frame.rs` `update_spring_targets`)。
|
||||||
|
|
||||||
|
### 1.2 compact 宽度的动态规则(不是固定 120)
|
||||||
|
|
||||||
|
`src/window/app/layout.rs` `compute_lyric_target_width()`:
|
||||||
|
|
||||||
|
- 无媒体 / 不在播放态:宽 = `base_width` = **120**
|
||||||
|
- 有媒体但无歌词:宽 = `base_width + 35.0` = **155**
|
||||||
|
- 有歌词(不滚动):`w = 60.0 + 文本宽`,再 `clamp(base_width+35, MAX_LYRIC_WIDTH)`
|
||||||
|
- `min_w = base_width + 35.0`、`MAX_LYRIC_WIDTH = 700.0`(`src/core/config.rs`)
|
||||||
|
- 有歌词(滚动模式,`lyrics_scroll = true`):`natural_w = 60.0 + text_w`,超过 `lyrics_scroll_max_width`(默认
|
||||||
|
`300.0`,`default_lyrics_scroll_max_width`)则固定为该上限;内部可用文本宽 = `(fixed_w - 59.0) * compact_scale`
|
||||||
|
- 二级歌词存在时:高度 = `max(base_height*scale, mini_lyric_pair_height)`,
|
||||||
|
其中 `lyric_pair_height = 主字号*(1 + 0.85 + 0.18) + 8*scale`
|
||||||
|
(`SECONDARY_LYRIC_SCALE = 0.85`、`LYRIC_PAIR_GAP_SCALE = 0.18` @ `src/core/render/mini.rs`)
|
||||||
|
→ 默认字号 12 时 = 12×2.03 + 8 = **32.36 px 高**,r ≈ 16.18
|
||||||
|
|
||||||
|
### 1.3 挂件(compact widget)导致的最小宽度
|
||||||
|
|
||||||
|
`src/ui/widget/compact/mod.rs`:
|
||||||
|
|
||||||
|
- `CONTENT_EDGE_INSET = 9.0`、`CONTENT_GAP = 7.0`
|
||||||
|
- `widget_width()`:`Time = 48.0`、`ResourceUsage = 132.0`
|
||||||
|
- 三组对齐宽度:`aligned_layout_width = 9*2 + left + center + right + 7*(占用组数-1)`
|
||||||
|
- 单侧扩展宽:`extension_width = CONTENT_EDGE_INSET + strip_width + CONTENT_GAP`
|
||||||
|
(例:左 Time → 9+48+7 = **64**;ResourceUsage → 9+132+7 = **148**)
|
||||||
|
- 最终 compact 宽 = `max(布局宽, base_width)`
|
||||||
|
|
||||||
|
### 1.4 compact overlay(通知 / 音量)会替换 base 尺寸
|
||||||
|
|
||||||
|
| overlay | 宽 | 高 | 圆角 | 来源 |
|
||||||
|
| ------- | ------------------------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 通知 | `max(base_width, 330.0) * scale` = **330** | `max(base_height, 82.0) * scale` = **82** | `h/2` = **41** | `src/ui/compact/notification.rs` `target_size()`;r 取自 `src/window/app/frame.rs`(`size.height/2.0`) |
|
||||||
|
| 音量 | `(base_width + 72.0) * scale` = **192** | `(base_height + 10.0) * scale` = **37** | `h/2` = **18.5** | `src/ui/compact/volume.rs` `target_size()` |
|
||||||
|
|
||||||
|
### 1.5 隐藏态尺寸
|
||||||
|
|
||||||
|
- `hidden_width` 默认 `5.0`(`default_hidden_width` @ `src/core/config.rs`),
|
||||||
|
- `MIN_HIDDEN_WIDTH = 0.0`、`MAX_HIDDEN_WIDTH = 400.0`
|
||||||
|
- 隐藏时高度收缩到 `hidden_width * compact_scale`,并保留 `hidden_reveal_h = 1.0`(1px 可点击边);
|
||||||
|
位移距离 `hide_distance = concealed_height + TOP_OFFSET`(`src/window/app/layout.rs`)
|
||||||
|
|
||||||
|
### 1.6 锚点(两种状态之间什么不动)
|
||||||
|
|
||||||
|
定位全部来自 `src/window/app/layout.rs` `compute_island_layout()` / `compute_window_position()`:
|
||||||
|
|
||||||
|
- **顶部居中停靠(默认)**:`island_y = TOP_OFFSET = 10.0`(**顶边固定**),
|
||||||
|
`offset_x = (os_w - w) / 2.0`(**水平中心固定**)
|
||||||
|
- **底部停靠**:`island_y = os_h - TOP_OFFSET - h`(**底边固定**,向上生长)
|
||||||
|
- **左/右停靠**:`offset_x = TOP_OFFSET` 或 `os_w - TOP_OFFSET - w`(对应侧边固定)
|
||||||
|
- `TOP_OFFSET: i32 = 10`(`src/core/config.rs`)= 岛顶边距屏幕顶 10 px
|
||||||
|
- 状态切换由三个弹簧同时插值:`w/h/r` 参数为 `stiffness = 0.10, damping = 0.68`(`src/window/app/frame.rs`),
|
||||||
|
页面横移 `view` 用 `0.12 / 0.68`;隐藏用 `(0.12, 0.70)` 隐藏 / `(0.08, 0.78)` 显示
|
||||||
|
- **compact 内容不跟随变大的岛做垂直居中重排**:mini 内容画在 `stable_island_y`(= 顶边)加上 `base_h` 带内居中
|
||||||
|
(`src/core/render/mini.rs`:封面 `y = stable_offset_y + (base_h - size)/2`,歌词 `center_y = stable_offset_y + base_h/2`)。
|
||||||
|
效果=岛向下长大,胶囊内容始终贴在顶部那条带里。
|
||||||
|
- 交叉淡出:`expanded_alpha = expansion_progress² * (1-hide_progress)`,
|
||||||
|
`mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1-hide_progress)`
|
||||||
|
(`MINI_FADE_RATE = 1.5` @ `src/core/render.rs`)→ 即进度 0~0.667 之间完成交叉淡出
|
||||||
|
|
||||||
|
### 1.7 有没有"挖孔/刘海"造型?
|
||||||
|
|
||||||
|
**没有。** 代码里没有 `SetWindowRgn` / `CreateRoundRectRgn` / notch / cutout 相关调用(已全仓 grep 确认),
|
||||||
|
也没有"顶部贴边 + 上方两角变直角"的特例分支。造型是:
|
||||||
|
|
||||||
|
- 一个**悬浮胶囊**(compact,r = h/2),**距屏幕顶 10 px**;
|
||||||
|
- 展开后是**四角一致的连续超椭圆**(squircle),r 最大 48。
|
||||||
|
|
||||||
|
圆角实现不是普通圆弧,而是 Flutter `RoundedSuperellipse` 的移植(`src/utils/shape.rs`,
|
||||||
|
注释标明 "Rounded-superellipse geometry adapted from Flutter under the BSD-3-Clause license"):
|
||||||
|
|
||||||
|
- `superellipse_parameters()`:11 项查表 `PARAMETERS`(如 `(2.0, 1.13276676) … (6.43023796, 2.98020421)`),
|
||||||
|
ratio > 5 时线性外推 `exponent = 6.43023796 + 1.559599389*(ratio-5.0)`、`factor = 2.98020421 + 0.522807185*(ratio-5.0)`
|
||||||
|
- `Octant::new()`:13 项 `BEZIER_FACTORS`(`(0.7078, 8.3194) … (0.9705, 1.2880)`),用 conic + cubic 拼四分之一角
|
||||||
|
- 路径缓存 `PATH_CACHE_CAPACITY = 64`
|
||||||
|
- **Web 复刻提示**:CSS `border-radius` 是正圆角,观感会偏"软";要还原请用 SVG path 或
|
||||||
|
`corner-shape: squircle`(若可用)/ `clip-path` 近似。**未找到**等价的简单圆角公式——真实几何就是上面那张表。
|
||||||
|
|
||||||
|
### 1.8 窗口留白 / 裁切
|
||||||
|
|
||||||
|
- `PADDING: f32 = 80.0`(`src/core/config.rs`);窗口尺寸 = `(岛宽 + 80, 岛高 + 80)`
|
||||||
|
(`src/window/app/layout.rs` `required_window_size()`)→ **岛四周各 40 px 透明留白**,供阴影/动画使用
|
||||||
|
- 绘制时 `canvas.clip_path(island_path, ClipOp::Intersect)`,所有内容裁在岛形内;
|
||||||
|
1px 描边在 `canvas.restore()` **之后**绘制(`src/core/render.rs` `draw_island()`)
|
||||||
|
- 挂件网格额外内缩,避免内容进圆角(`src/ui/widget/expanded/mod.rs` `widget_grid_layout`):
|
||||||
|
```rust
|
||||||
|
corner_inset = radius * (1.0 - FRAC_1_SQRT_2); // 48 * 0.29289 = 14.06
|
||||||
|
inset = (corner_inset + 8.0*scale).max(24.0*scale); // = 24.0(scale=1)
|
||||||
|
gap = 7.0 * scale;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.9 expanded 内容网格
|
||||||
|
|
||||||
|
- `WIDGET_GRID_COLS = 6`、`WIDGET_GRID_ROWS = 3`、`WIDGET_GRID_SLOTS = 18`(`src/core/config.rs`)
|
||||||
|
- 槽位宽 = `(inner_w - 7*5) / 6`,高 = `(inner_h - 7*2) / 3`;
|
||||||
|
用 360×200、inset 24 计算:inner = 312×152 → **slot ≈ 46.17 × 46.0**(`src/ui/widget/expanded/mod.rs`)
|
||||||
|
- 组件占格 `WidgetKind::span()`:`Clock = (2,1)`、`ResourceUsage = (2,1)`、`Calendar = (2,2)`、`Settings = (1,1)`
|
||||||
|
(`src/core/config.rs`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 配色(颜色 token)
|
||||||
|
|
||||||
|
### 2.1 岛背景(`src/core/render/background.rs` `draw_background()`)
|
||||||
|
|
||||||
|
| 样式 | 颜色值 | 说明 |
|
||||||
|
| --------------------------- | -------------------------------------------------------------- | -------------------- |
|
||||||
|
| `default`(`SOLID_STYLE`) | `Color::BLACK` = **#000000**,完全不透明 | `bg_color` |
|
||||||
|
| `glass` + host backdrop | `Color::from_argb(150, 10, 10, 14)` = **rgba(10,10,14,0.588)** | `draw_host_glass()` |
|
||||||
|
| `glass` 无 backdrop(回退) | `Color::from_argb(205, 32, 32, 36)` = **rgba(32,32,36,0.804)** | `fallback_color` |
|
||||||
|
| `dynamic` 底色 | `Color::from_rgb(32, 32, 36)` = **#202024**,不透明 | `draw_effect_base()` |
|
||||||
|
| `dynamic` 叠加层 | `Color::from_argb(120, 20, 20, 24)` = **rgba(20,20,24,0.471)** | 盖在旋转模糊封面之上 |
|
||||||
|
| `dynamic` 无封面回退 | 同 glass 分支(150/10,10,14 或 205/32,32,36) | |
|
||||||
|
|
||||||
|
`dynamic` 封面动效参数(同一函数):
|
||||||
|
|
||||||
|
- 集显:`rotate_speed = 0.015 rad/s, drift_speed_x = 0.075, drift_speed_y = 0.06, drift_amp_x = 10.0, drift_amp_y = 7.5`
|
||||||
|
- 独显:`rotate_speed = 0.03, drift_speed_x = 0.15, drift_speed_y = 0.12, drift_amp_x = 20.0, drift_amp_y = 15.0`
|
||||||
|
- 旋转矩形边长 `side_len = diagonal * 1.3`
|
||||||
|
|
||||||
|
**亚克力/backdrop 真实实现**(`src/window/d3d.rs` + `src/utils/win32.rs`):
|
||||||
|
Windows 11 `DWMWA_USE_HOSTBACKDROPBRUSH`,失败时退回 `SetWindowCompositionAttribute`
|
||||||
|
(`AccentPolicy { state: 5, flags: 0, gradient_color: 0 }`,state 5 = host backdrop);
|
||||||
|
再用 `Compositor::CreateHostBackdropBrush()` + `RoundedRectangleGeometry` 裁圆角
|
||||||
|
(`HOST_BACKDROP_INSET = 1.0`,几何 = `w-2, h-2, radius-1`)。
|
||||||
|
**未找到**任何模糊半径数值——模糊由系统 HostBackdropBrush 决定。
|
||||||
|
|
||||||
|
### 2.2 描边(`src/core/render.rs` `draw_island_border()`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
BORDER_WIDTH = 1.0; BORDER_INSET = BORDER_WIDTH / 2.0; // 0.5
|
||||||
|
SOLID_STYLE = "default"; SOLID_BORDER_ALPHA = 30; EFFECT_BORDER_ALPHA = 40;
|
||||||
|
```
|
||||||
|
|
||||||
|
- 颜色 = **纯白**,alpha:
|
||||||
|
- `default` 样式:`rgba(255,255,255, 30/255=0.118)`,**并再乘 `(1 - expansion_progress)`**(展开后描边消失)
|
||||||
|
- `glass` / `dynamic`:`rgba(255,255,255, 40/255=0.157)`,不随展开衰减
|
||||||
|
- 描边矩形 = `(x+0.5, y+0.5, w-1, h-1)`,圆角 `r-0.5`
|
||||||
|
|
||||||
|
### 2.3 阴影(`src/core/render.rs` `draw_expanded_shadow()`,仅展开时)
|
||||||
|
|
||||||
|
- 颜色 `Color::from_argb((28.0 * opacity) as u8, 0,0,0)` = **rgba(0,0,0,0.11)**
|
||||||
|
- `opacity = expansion_progress² * (1 - hide_progress)`,`opacity <= 0.01` 直接跳过
|
||||||
|
- 偏移 `offset_y = 2.0 * scale`;模糊 `sigma = min(3.0*scale, margin/3.0)`(margin = 到画布边界的余量)
|
||||||
|
- 用 `ClipOp::Difference` 只画在岛形之外
|
||||||
|
- **未找到内阴影**(没有任何 inner shadow 实现)
|
||||||
|
|
||||||
|
### 2.4 分隔线
|
||||||
|
|
||||||
|
| 位置 | 颜色 | 尺寸 | 来源 |
|
||||||
|
| -------------------- | -------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------- |
|
||||||
|
| compact 挂件之间 | `rgba(255,255,255, 0.14*alpha)` | 宽 `max(scale,1)`,从 `top+25%h` 到 `bottom-25%h` | `src/ui/widget/compact/mod.rs` `draw_separator()` |
|
||||||
|
| compact 资源占用内部 | `rgba(255,255,255, 0.10*alpha)` | 宽 `max(0.75*scale, 0.75)`,半高 `DIVIDER_HALF_HEIGHT = 6.0*scale` | `src/ui/widget/compact/resource_usage.rs` |
|
||||||
|
| expanded 资源环分隔 | `rgba(白, 0.09*alpha)` | 宽 `scale.max(0.75)`,从 `y+0.2h` 到 `y+0.8h` | `src/ui/widget/expanded/resource_usage.rs` |
|
||||||
|
| 设置窗口分隔线 | `rgba(255,255,255,26)` 暗 / `rgba(20,28,40,18)` 亮 | — | `src/utils/color.rs` |
|
||||||
|
|
||||||
|
### 2.5 文字颜色与不透明度分层
|
||||||
|
|
||||||
|
主文字一律 **#FFFFFF**;次级用 alpha:
|
||||||
|
|
||||||
|
| 元素 | 有效颜色 | 来源 |
|
||||||
|
| ------------------ | ------------------------------------------------------------ | ---------------------------------------------------- |
|
||||||
|
| 通知 app 名 | `rgba(255,255,255, 0.65*alpha)` | `src/ui/compact/notification.rs` |
|
||||||
|
| 通知标题 | `rgba(255,255,255, alpha)` | 同上 |
|
||||||
|
| 通知详情 | `rgba(255,255,255, 0.72*alpha)` | 同上 |
|
||||||
|
| 音量标签 | `rgba(255,255,255, 0.9*alpha)` | `src/ui/compact/volume.rs` |
|
||||||
|
| expanded 歌手 | `rgba(白, alpha*0.6)` | `src/ui/expanded/music_view.rs` `draw_track_text()` |
|
||||||
|
| 迷你歌词主行 | 白 alpha | `src/core/render/mini.rs` |
|
||||||
|
| 迷你歌词次行 | `rgb(176,176,176)`(`SECONDARY_LYRIC_CHANNEL = 176`) | 同上 |
|
||||||
|
| 逐字歌词"未唱"部分 | `rgb(190,190,190)`(`PENDING_LYRIC_CHANNEL = 190`) | 同上 |
|
||||||
|
| 进度条时间 | 白,`PROGRESS_TIME_IDLE_ALPHA = 0.5` → hover 1.0(线性插值) | `src/ui/expanded/music_view.rs` |
|
||||||
|
| 挂件时钟 | 白 alpha(bold) | `src/ui/widget/expanded/time.rs` |
|
||||||
|
| 挂件日历 月 | `0.78*alpha` | `src/ui/widget/expanded/calendar.rs` |
|
||||||
|
| 挂件日历 日 | `1.0*alpha` | 同上 |
|
||||||
|
| 挂件日历 星期 | `0.62*alpha` | 同上 |
|
||||||
|
| 挂件资源 数值 | `1.0*alpha`(bold) | `src/ui/widget/expanded/resource_usage.rs` |
|
||||||
|
| 挂件资源 标签 | `0.58*alpha` | 同上 |
|
||||||
|
| 封面占位块 | `rgba(白, 0.15*alpha)` | `src/ui/expanded/music_view.rs` `draw_placeholder()` |
|
||||||
|
|
||||||
|
### 2.6 强调色 / 状态色
|
||||||
|
|
||||||
|
岛内(`src/ui/widget/resource_usage.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
CPU_COLOR = rgb(50, 190, 246) // #32BEF6
|
||||||
|
RAM_COLOR = rgb(175, 82, 222) // #AF52DE
|
||||||
|
WARNING_COLOR = rgb(255, 159, 10) // #FF9F0A
|
||||||
|
CRITICAL_COLOR = rgb(255, 69, 58) // #FF453A
|
||||||
|
```
|
||||||
|
|
||||||
|
阈值曲线 `usage_color(base, usage)`:
|
||||||
|
|
||||||
|
- `usage <= 0.75` → base
|
||||||
|
- `0.75 < usage <= 0.9` → base ↔ WARNING 线性混合,`t = (usage-0.75)/0.15`
|
||||||
|
- `0.9 < usage` → WARNING ↔ CRITICAL 线性混合,`t = (usage-0.9)/0.1`
|
||||||
|
采样间隔 `SAMPLE_INTERVAL = 1s`;无数值时显示 `—`。
|
||||||
|
|
||||||
|
进度条/音量条(`src/ui/expanded/music_view.rs`、`src/ui/compact/volume.rs`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
PROGRESS_TRACK_ALPHA = 0.15; // 轨道 rgba(白, 0.15*alpha)
|
||||||
|
PROGRESS_TIME_IDLE_ALPHA = 0.5; // 时间文字静止透明度
|
||||||
|
// 音量轨道 rgba(255,255,255, 0.28*alpha),填充纯白 alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
音量的"填充"就是**白色**(不是强调色),图标仍是白色。
|
||||||
|
|
||||||
|
可视化条颜色**来自专辑封面**(`src/ui/expanded/music_view/palette.rs`):
|
||||||
|
|
||||||
|
- 把封面画到 8×8 的 GPU surface(`PALETTE_SAMPLE_SIZE = 8`)读回求平均 RGB
|
||||||
|
- `primary = brighten(avg, 1.3)`、`secondary = brighten(avg, 1.5)`
|
||||||
|
- `brighten` 用 BT.601 亮度 `0.299R + 0.587G + 0.114B`;若 < 80 则整体加 `80 - brightness`(抬到最低亮度 80)
|
||||||
|
- 调色板 = `[primary, secondary, primary]`,缓存上限 50 条
|
||||||
|
- 无封面默认值(`default_media_palette`):`rgb(180,180,180)` + `rgb(100,100,100)`
|
||||||
|
- 采样完全失败的兜底:`rgb(200,200,200)`
|
||||||
|
|
||||||
|
### 2.7 不同状态是否不同配色?
|
||||||
|
|
||||||
|
- **同一套**:底 + 白字 + alpha 分层;通知 / 音量 / 音乐 / 挂件没有各自的独立配色,
|
||||||
|
视觉区分靠 **尺寸(330×82 / 192×37 / 120×27)** 与 **内容排版**,而不是换色。
|
||||||
|
- 差异只在两处:**可视化条用封面取色**(2.6),**资源占用用 CPU/RAM/警告/危险四色**(2.6)。
|
||||||
|
- 岛背景三样式(default/glass/dynamic)才是真正的配色分支(2.1)。
|
||||||
|
|
||||||
|
### 2.8 设置窗口色板(另一套,`src/utils/color.rs`,供参考)
|
||||||
|
|
||||||
|
暗色主题 `dark_settings_theme()`:
|
||||||
|
|
||||||
|
```
|
||||||
|
win_bg #1C1C1E sidebar_bg #242426 group_bg #2C2C2E card_highlight #48484A
|
||||||
|
accent #0A84FF text_pri #F5F5F7 text_sec #AEAEB2 disabled #636366
|
||||||
|
danger #E6372D toggle_on #30D158 toggle_off #636366
|
||||||
|
sidebar_hover rgba(255,255,255,20) separator rgba(255,255,255,26)
|
||||||
|
popup_bg #323234 popup_border rgba(255,255,255,40) popup_shadow rgba(0,0,0,60)
|
||||||
|
popup_separator rgba(255,255,255,30) control_bg #3A3A3C control_hover #48484A
|
||||||
|
control_disabled #303032 control_border rgba(255,255,255,36)
|
||||||
|
group_border rgba(255,255,255,24) shadow rgba(0,0,0,45) scrollbar rgba(255,255,255,60)
|
||||||
|
```
|
||||||
|
|
||||||
|
亮色主题 `light_settings_theme()`:
|
||||||
|
|
||||||
|
```
|
||||||
|
win_bg #F4F5F7 sidebar_bg #FAFAFC group_bg #FFFFFF card_highlight #EFF2F7
|
||||||
|
text_pri #1F2228 text_sec #5B626E disabled #9297A1 accent #0067C0
|
||||||
|
danger #C42B1C toggle_on #0078D4 toggle_off #B0B5BE
|
||||||
|
selection_bg #E0EEFA selection_text #005599
|
||||||
|
sidebar_hover rgba(20,32,48,14) separator rgba(24,32,44,18)
|
||||||
|
popup_bg #FFFFFF popup_border rgba(20,28,40,24) popup_shadow rgba(20,28,40,20)
|
||||||
|
control_bg #F7F8FA control_hover #EBEFF4 control_disabled #F1F2F5
|
||||||
|
control_border rgba(20,28,40,24) group_border rgba(20,28,40,14)
|
||||||
|
shadow rgba(20,28,40,20) scrollbar rgba(52,58,68,52)
|
||||||
|
```
|
||||||
|
|
||||||
|
设置窗口另有"岛预览"配色(与真实渲染略有差异,`src/utils/settings_ui/renderer/widget_preview.rs`):
|
||||||
|
glass 预览 `rgba(24,24,28,220)`;dynamic 预览为线性渐变 **`rgb(18,12,36)` → `rgb(8,24,48)`**(左上→右下);
|
||||||
|
default 预览 `rgb(10,10,10)`;预览描边 alpha `52`(glass) / `38`(其他);预览阴影 `rgba(0,0,0,72)`,偏移 +4。
|
||||||
|
→ 若前端需要一张**静态渐变**替代 `dynamic`,这两个色值就是现成可抄的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 排版(字体 token)
|
||||||
|
|
||||||
|
### 3.1 字体族(`src/utils/font.rs`)
|
||||||
|
|
||||||
|
1. 用户自定义字体:`config.custom_font_path` → `FontMgr::new_from_data()`(优先于全部)
|
||||||
|
2. ASCII 文本:`"Microsoft YaHei"` → `"Segoe UI"` → 系统默认(`legacy_make_typeface`)
|
||||||
|
3. 非 ASCII:逐字符匹配 `match_family_style_character("", style, &["zh-CN","ja-JP","en-US"], ch)`,
|
||||||
|
再依次尝试 `"Segoe UI Emoji"` → `"Microsoft YaHei"` → `"Segoe UI Symbol"` → `"Segoe UI"`
|
||||||
|
4. 合成粗体:`needs_synthetic_bold()` = 请求 `weight >= 600` 且字体本身 `weight < 600` → `font.set_embolden(true)`
|
||||||
|
5. 亚像素渲染始终开启 `font.set_subpixel(true)`
|
||||||
|
|
||||||
|
字重只有两档:`FontStyle::bold()`(700)与 `FontStyle::normal()`(400);
|
||||||
|
滚动文本判定粗体用 `style.weight() >= 700`(`src/utils/scroll.rs`)。
|
||||||
|
|
||||||
|
**字距 / 行高**:代码里**未找到** letter-spacing 或 line-height 参数;
|
||||||
|
行距全部通过显式 y 偏移实现(见下表)。文本绘制走 Skia `draw_str`,
|
||||||
|
大字文本(标题/歌手)`render_as_paths = true`(`src/utils/scroll.rs` 调用点)。
|
||||||
|
|
||||||
|
### 3.2 字号表(默认 `config.font_size = 0.0` 时用内置值;>0 时用户值参与计算)
|
||||||
|
|
||||||
|
| 用途 | 字号 | 字重 | 说明 / 来源 |
|
||||||
|
| -------------------- | -------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| mini 歌词主行 | `font_size*0.8*scale` 或 **12.0\*scale** | normal | `src/core/render/mini.rs` `lyric_font_size()` |
|
||||||
|
| mini 歌词次行 | 主字号 × `SECONDARY_LYRIC_SCALE = 0.85` | normal | 同上 |
|
||||||
|
| mini 歌词对高度 | `主*(1+0.85+0.18) + 8*scale` | — | `lyric_pair_height()` |
|
||||||
|
| mini 插件文字(主) | `font_size*0.7*scale` 或 **11.0\*scale** | **bold** | `PLUGIN_FONT_SCALE = 0.7`、`DEFAULT_PLUGIN_FONT_SIZE = 11.0` |
|
||||||
|
| mini 插件文字(次) | 主 × `0.8` | normal | `PLUGIN_SECONDARY_FONT_SCALE = 0.8`,行距 `font_size*1.3`(`PLUGIN_SECONDARY_LINE_SPACING = 1.3`) |
|
||||||
|
| expanded 曲名 | `font_size*scale` 或 **15.0\*scale** | **bold** | `src/ui/expanded/music_view.rs` |
|
||||||
|
| expanded 歌手 | 同上 **15.0\*scale** | normal | 基线 = 曲名基线 + `22.0*scale` |
|
||||||
|
| 进度时间 | `font_size*0.67*scale` 或 **10.0\*scale** | normal | `PROGRESS_TIME_FONT_SCALE = 0.67`、`DEFAULT_PROGRESS_TIME_FONT_SIZE = 10.0` |
|
||||||
|
| 通知 app 名 | **11.0\*scale** | normal | `src/ui/compact/notification.rs` |
|
||||||
|
| 通知标题 | **13.0\*scale** | **bold** | 同上 |
|
||||||
|
| 通知详情 | **11.0\*scale** | normal | 行间距 `DETAIL_LINE_GAP = 21.0*scale` |
|
||||||
|
| 音量标签 | **12.0\*scale** | normal | `src/ui/compact/volume.rs` |
|
||||||
|
| compact 时钟挂件 | `clamp(h*0.43, 10, 12)` | **bold** | `src/ui/widget/compact/time.rs` |
|
||||||
|
| compact 资源—标签 | `METRIC_LABEL_SIZE = 7.0*scale` | **bold** | `src/ui/widget/compact/resource_usage.rs` |
|
||||||
|
| compact 资源—数值 | `METRIC_VALUE_SIZE = 10.0*scale` | **bold** | 同上 |
|
||||||
|
| expanded 时钟挂件 | `min(h*0.60, w*0.31)`,最小 `13.0*scale` | **bold** | `src/ui/widget/expanded/time.rs` |
|
||||||
|
| expanded 日历—月 | `clamp(h*0.14, 9, 15)*scale` | **bold** | `src/ui/widget/expanded/calendar.rs` |
|
||||||
|
| expanded 日历—日 | `min(h*0.53, w*0.70)`,最小 `26.0*scale` | **bold** | 同上 |
|
||||||
|
| expanded 日历—星期 | `clamp(h*0.12, 8, 12)*scale` | normal | 同上 |
|
||||||
|
| expanded 资源—圆环值 | `clamp(d*0.27, 7, 10.5*scale)`,d = 圆环直径 | **bold** | `src/ui/widget/expanded/resource_usage.rs` |
|
||||||
|
| expanded 资源—标签 | `clamp(h*0.12, 6, 8*scale)` | **bold** | 同上 |
|
||||||
|
| 设置窗口—行标签/值 | **13.0** | normal | `src/utils/settings_ui/renderer/items.rs` |
|
||||||
|
| 设置窗口—页签标签 | **11.5** | normal(选中为 bold) | `src/window/settings/renderer.rs` |
|
||||||
|
| 设置窗口—弹窗项 | **12.0** | normal | 同上 |
|
||||||
|
| 设置窗口—Markdown | `BODY_SIZE = 12.0`,`LINE_HEIGHT = 18.0` | — | `src/window/settings/pages/plugins/markdown.rs` |
|
||||||
|
|
||||||
|
字体基线规则(值得照抄):
|
||||||
|
|
||||||
|
- 居中对齐用 **字形包围盒** 居中:`baseline_y = bounds.top + (bounds.h - glyph.h)/2 - glyph.top`
|
||||||
|
(`src/ui/widget/expanded/mod.rs` `draw_widget_text_centered()`)
|
||||||
|
- 时间文字基线 = 进度条中心 + `0.35*字号`(`PROGRESS_TIME_BASELINE_SCALE = 0.35`)
|
||||||
|
- 迷你歌词单行时 `primary_y = center_y + size/3`;双行时按堆叠高度对齐(见 5.1)
|
||||||
|
|
||||||
|
### 3.3 是否随窗口尺寸 / 用户设置缩放
|
||||||
|
|
||||||
|
- 随 `compact_scale` / `expanded_scale` 缩放(乘在所有尺寸上)
|
||||||
|
- 字号受 `config.font_size`(默认 0 = 用内置值)影响:只有 mini 歌词、mini 插件、expanded 曲名/歌手、进度时间用它
|
||||||
|
- 挂件字号是**几何驱动**的(按自身矩形宽高 clamp),与 `font_size` 无关
|
||||||
|
- 设置窗口有自己的 `settings_theme`(`system` / 暗 / 亮)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 层级与效果
|
||||||
|
|
||||||
|
### 4.1 圆角
|
||||||
|
|
||||||
|
见 1.7。总结:连续超椭圆(squircle),compact 满圆角(h/2),expanded 最大 48。
|
||||||
|
挂件卡片 `widget_corner_radius = min(12.0*scale, min(w,h)/2)`(`src/ui/widget/expanded/mod.rs`)。
|
||||||
|
迷你封面 `MINI_COVER_RADIUS = 5.0`,expanded 封面 `16.0*scale`,通知图标 `11.0*scale`。
|
||||||
|
|
||||||
|
### 4.2 阴影 / 发光
|
||||||
|
|
||||||
|
- **外阴影**:仅 expanded,`rgba(0,0,0,0.11)`,y+2,blur σ = `min(3*scale, margin/3)`(见 2.3)
|
||||||
|
- **内阴影**:**未找到**
|
||||||
|
- **发光**:只出现在资源占用圆环——`glow` 圆半径 `diameter*0.57`,颜色 `accent`,alpha `0.07*alpha`
|
||||||
|
(`src/ui/widget/expanded/resource_usage.rs`)
|
||||||
|
- 设置窗口:窗口阴影 `rgba(0,0,0,90)`、弹出菜单阴影 `theme.popup_shadow` + 偏移 +2(`src/window/settings/renderer.rs`);
|
||||||
|
拖拽幽灵阴影 `rgba(0,0,0,90)`,偏移 +4
|
||||||
|
|
||||||
|
### 4.3 模糊半径(全部数值)
|
||||||
|
|
||||||
|
| 场景 | 参数 | 来源 |
|
||||||
|
| ----------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||||
|
| 运动模糊(整体) | 横向 = ` | w速度 | *0.3 + | view速度 | *w*0.4`,纵向 = ` | h速度 | *0.3`;上限集显 `(4.0, 3.5)`、独显 `(12.0, 10.0)`;仅在 σ>0.1 时 `saveLayer` | `src/utils/blur.rs` `calculate_blur_sigmas()`、`src/core/render.rs` `MIN_BLUR_SIGMA = 0.1` |
|
||||||
|
| 封面翻转 | `σ = (1- | cos | )^0.6 * 8.0 * scale`,x 方向再 ×0.3 | `src/ui/expanded/music_view.rs` |
|
||||||
|
| 暂停按钮速度模糊 | ` | 弹簧速度 | * 16.0 * scale`,上限 `2.5*scale` | `PAUSE_CONTROL_BLUR_SCALE = 16.0`、`PAUSE_CONTROL_MAX_BLUR = 2.5` |
|
||||||
|
| 快进/快退按钮 | `(1 - t/0.3).max(0) * 6.0 * scale`,x×0.3 | 同文件 `draw_skip_button()` |
|
||||||
|
| 歌词切换(Blur 模式) | `σ = 6.0*scale`,x 压缩 `LYRIC_TRANSITION_BLUR_X_SCALE = 0.25`,位移 `LYRIC_TRANSITION_OFFSET = 10.0*scale` | `src/core/render/mini.rs` |
|
||||||
|
| dynamic 背景封面 | 先降采样到 **64×64**,再 `image_filters::blur((8.0, 8.0), TileMode::Clamp)` | `src/utils/backdrop.rs` |
|
||||||
|
| host backdrop(亚克力) | **未找到数值**(由系统 HostBackdropBrush 决定,仅 `HOST_BACKDROP_INSET = 1.0`) | `src/window/d3d.rs` |
|
||||||
|
|
||||||
|
### 4.4 不透明度分层与可见性阈值
|
||||||
|
|
||||||
|
```
|
||||||
|
MIN_VISIBLE_OPACITY = 0.01 // 低于此不绘制
|
||||||
|
MIN_BLUR_SIGMA = 0.1
|
||||||
|
MINI_FADE_RATE = 1.5 // mini 淡出速率
|
||||||
|
COLLAPSED_VISUALIZER_HEIGHT_SCALE = 0.45 // 收起时可视化高度 = 45%
|
||||||
|
```
|
||||||
|
|
||||||
|
- `expanded_alpha = expansion_progress² * (1 - hide_progress)`(平方 → 展开"迟入场")
|
||||||
|
- `mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1 - hide_progress)`
|
||||||
|
- 可视化高度比例 `visualizer_height_scale = 0.45 + 0.55 * expansion_progress`
|
||||||
|
- 歌词额外淡出 `LYRIC_EXPANSION_FADE_RATE = 2.5`(`src/core/render/mini.rs`)
|
||||||
|
- 展开页横向滑动:`page_shift = view_offset * current_w`;
|
||||||
|
音乐页 `translate(-page_shift)`,挂件页 `translate(current_w - page_shift)`
|
||||||
|
(`src/core/render/expanded.rs`);`view` 弹簧 `0.12/0.68`
|
||||||
|
|
||||||
|
### 4.5 卡片四周留白 / 裁切
|
||||||
|
|
||||||
|
见 1.8:窗口 = 岛 + 80(每侧 40 透明);内容 `clip_path(Intersect)`;
|
||||||
|
描边在裁切外画;挂件网格 inset 24。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 布局骨架
|
||||||
|
|
||||||
|
### 5.1 compact(mini)120×27
|
||||||
|
|
||||||
|
`src/core/render/mini.rs`:
|
||||||
|
|
||||||
|
```
|
||||||
|
MIN_MINI_CONTENT_WIDTH = 45.0 // 可用宽小于此不画内容
|
||||||
|
MINI_COVER_SIZE = 18.0 // 封面尺寸
|
||||||
|
MINI_COVER_LEFT_INSET = 10.0 // 封面左边距
|
||||||
|
MINI_COVER_RADIUS = 5.0 // 封面圆角
|
||||||
|
MINI_VISUALIZER_RIGHT_INSET = 17.0
|
||||||
|
MINI_VISUALIZER_WIDTH_SCALE = 0.55
|
||||||
|
MINI_VISUALIZER_SMOOTHING = (0.6, 0.08) // (上升, 下降)
|
||||||
|
LYRIC_LEFT_INSET = 30.0
|
||||||
|
LYRIC_RIGHT_INSET = 29.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- **左**:18×18 封面,x = 10,y 垂直居中(`(base_h - 18)/2 = 4.5`),圆角 5,中心裁剪
|
||||||
|
- **中**:歌词,可用区 x ∈ [30, w-29],垂直居中;主行居中(滚动模式改为左对齐并从 x=30 起滚动),
|
||||||
|
次行始终水平居中;主行单行时 `y = center_y + size/3`,双行时
|
||||||
|
`stack_h = size + size*0.85 + size*0.18`,主行 `y = center_y - stack_h/2 + size*0.8`,
|
||||||
|
次行 `y = center_y + stack_h/2 - secondary*0.2`
|
||||||
|
- **右**:可视化条,中心 x = `w - 17`,中心 y = `base_h/2`
|
||||||
|
- 无媒体时改由 compact 挂件条接管:Left / Center / Right 三组;
|
||||||
|
有 mini 内容时只画 Left 与(Center+Right)两组并贴两端,并在内容两侧画竖分隔线
|
||||||
|
(`src/ui/widget/compact/mod.rs` `draw()`)
|
||||||
|
- 挂件宽:时间 **48**、资源 **132**;边缘 inset **9**;组内间距 **7**
|
||||||
|
|
||||||
|
### 5.2 expanded 音乐页 360×200
|
||||||
|
|
||||||
|
`src/ui/expanded/music_view.rs` 常量全表:
|
||||||
|
|
||||||
|
```
|
||||||
|
CONTENT_PADDING = 24.0 // 四周内边距
|
||||||
|
COVER_SIZE = 64.0 // 封面 64×64,圆角 16.0*scale,位置 (24,24)
|
||||||
|
TRACK_TEXT_GAP = 18.0 // 封面右到文字左
|
||||||
|
TRACK_TEXT_RIGHT_INSET = 70.0 // 文字右侧预留(给可视化条)
|
||||||
|
TRACK_TITLE_BASELINE_OFFSET= 26.0 // 曲名基线 y = 24 + 26 = 50
|
||||||
|
PROGRESS_TOP_GAP = 24.0 // 进度条中心 y = 24 + 64 + 24 = 112
|
||||||
|
PROGRESS_TIME_WIDTH = 28.0 // 两端时间文字预留宽
|
||||||
|
PROGRESS_TIME_GAP = 4.0
|
||||||
|
PROGRESS_BAR_HEIGHT = 6.5 // 常态条高
|
||||||
|
PROGRESS_BAR_HOVER_GROWTH = 3.5 // hover 增至 10.0
|
||||||
|
PLAYBACK_CONTROLS_TOP_GAP = 42.0 // 控制行中心 y = 112 + bar_h/2 + 42 ≈ 157.25
|
||||||
|
SKIP_BUTTON_GAP = 75.0 // 上一首/下一首中心距播放键 75
|
||||||
|
PAGE_ARROW_RIGHT_INSET = 7.5 // 右侧翻页条 x = w - 7.5,y = h/2
|
||||||
|
PAGE_ARROW_FADE_RATE = 5.0
|
||||||
|
COLLAPSED_VISUALIZER_INSET = 17.0 // 视觉条右侧 inset 随展开 17 → 37
|
||||||
|
EXPANDED_VISUALIZER_INSET = 37.0
|
||||||
|
VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4
|
||||||
|
```
|
||||||
|
|
||||||
|
- **左**:64 封面;**中**:曲名(15 bold)/歌手(15, α0.6) 两行,x = 24+64+18 = **106**;**右**:可视化条
|
||||||
|
- 进度条:左右各内缩 24,两端 28 宽时间 + 4 间距;条圆角 = bar_h/2;
|
||||||
|
填充宽度 `max(bar_total_w*progress, bar_h)`;填充只圆左端(RRect 左上/左下半径 = bar_h/2,右侧 0)
|
||||||
|
- 控制行中心 x = 180;播放键在中心、上一首 105、下一首 255
|
||||||
|
- 点击热区(`src/ui/expanded/music_view/controls.rs`):播放 **40×40**,上/下一首 **36×36**,
|
||||||
|
进度条热区高 **16.0\*scale**,封面整块可双击(激活媒体 App)
|
||||||
|
- 动画参数:`PROGRESS_SMOOTHING = 0.15`、`PROGRESS_HOVER_SMOOTHING = 0.18`、
|
||||||
|
`PROGRESS_HOVER_SNAP_THRESHOLD = 0.005`、`PROGRESS_START_THRESHOLD = 0.02`、`PROGRESS_JUMP_THRESHOLD = 0.3`、
|
||||||
|
`PLAY_STATE_RESPONSE = 0.18`、`SKIP_ANIMATION_DURATION_SECS = 0.5`、封面翻转 0.6s、
|
||||||
|
`PAUSE_CONTROL_STIFFNESS = 0.18`、`PAUSE_CONTROL_DAMPING = 0.64`、`PAUSE_CONTROL_MIN_SCALE = 0.8`、
|
||||||
|
`PAUSE_CONTROL_MAX_SCALE = 1.03`、`PAUSE_CONTROL_PRESS_VELOCITY = -0.18`
|
||||||
|
- 播放/暂停过渡:`cover_scale = 0.85 + 0.15*pause_t`、`cover_brightness = 0.75 + 0.25*pause_t`
|
||||||
|
(即暂停时封面缩小到 85% 并压暗到 75%),图标用 `smoothstep` 交叉淡出
|
||||||
|
|
||||||
|
### 5.3 expanded 挂件页(同 360×200)
|
||||||
|
|
||||||
|
- 6×3 网格,inset 24,gap 7(见 1.9)
|
||||||
|
- 卡片:背景 `rgba(28,28,30, 0.05*alpha)`,描边 `1px rgba(255,255,255, 0.16*alpha)`,圆角 `min(12*scale, min(w,h)/2)`
|
||||||
|
- 左侧翻页条 x = **7.5**,y = `h/2`(3×14 圆角矩形,α0.4);右侧同(x = w-7.5)
|
||||||
|
- 内置挂件各视图排布:
|
||||||
|
- **时钟**:整卡居中,字号 `min(h*0.6, w*0.31)`(≥13),bold,`HH:MM` 格式
|
||||||
|
- **日历**:月 `y+0.08h..0.26h`(bold,α0.78)/日 `y+0.27h..0.75h`(bold,字号 `min(0.53h,0.70w)`,≥26)/
|
||||||
|
星期 `y+0.78h..0.92h`(normal,α0.62)
|
||||||
|
- **资源占用(2×1)**:中间竖分隔(α0.09,`y+0.2h..0.8h`);两个圆环各占一半:
|
||||||
|
环心 `(center_x, top+0.43h)`,直径 `min(0.58h, 0.62w)`(≥20),描边 `min(3*scale, d*0.12)` 圆头,
|
||||||
|
底环 α0.13,进度弧从 **-90°** 起扫 `usage*360°`(α0.92),值文字 bold 居中,标签在 `top+0.76h`(α0.58)
|
||||||
|
- **设置(1×1)**:整卡居中齿轮图标,`icon_scale = min(w,h)*0.38/24`,α0.72
|
||||||
|
|
||||||
|
### 5.4 compact 各业务视图差异
|
||||||
|
|
||||||
|
| 视图 | 尺寸 | 排布 |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 时间挂件 | 48×27 | 居中 `HH:MM`,字号 `clamp(0.43h, 10..12)` bold,基线 `center_y + size*0.36` |
|
||||||
|
| 资源挂件 | 132×27 | 两个 metric 各 `(w-8)/2` 宽;标签 7px bold 在左(accent α0.78),数值 10px bold 在右(白); |
|
||||||
|
| 文字基线 `center_y + scale`;下方 2px 进度轨(y = 基线+5),轨 α0.13,填充 accent α0.9;中间分隔 α0.1,半高 6 |
|
||||||
|
| 通知 | 330×82 | 图标 42×42 圆角 11 @ x+18 垂直居中;文本左 = 有图标 `x+72` / 无图标 `x+20`;右内缩 18; |
|
||||||
|
| app 名基线 `top+22`(11px α0.65);标题 13px bold,基线 `top+34`(无 app 名)/ `top+43`(有 app 名)/ 垂直居中 `top+(h+13)/2`(两者皆无);详情 11px α0.72 在标题下 21px |
|
||||||
|
| 音量 | 192×37 | 喇叭图标 20px,中心 `x = left+21`;标签 12px @ `left+37`(α0.9);轨道从 `label_x + label_w + 26` 到 `right-14`,高 4 圆角 2,轨 α0.28,填充白 α1.0 |
|
||||||
|
|
||||||
|
### 5.5 动画时长(通知 / 音量)
|
||||||
|
|
||||||
|
- 通知:`DISPLAY_DURATION = 5s`,`ENTER_DURATION = 220ms`(`ease_out_cubic = 1-(1-t)³`),
|
||||||
|
`FADE_DURATION = 280ms`;入场位移 `(1-enter)*7.0*scale`(从上滑下 7px)
|
||||||
|
- 音量:`DISPLAY_DURATION = 1600ms`,`FADE_DURATION = 240ms`,
|
||||||
|
`VOLUME_CHANGE_THRESHOLD = 0.002`(音量变化阈值,低于此不显示)
|
||||||
|
- 歌词切换进度用 `smoothstep`:`p*p*(3-2p)`(`src/core/render/mini.rs`)
|
||||||
|
- 长文本跑马灯(`src/utils/scroll.rs`):先停 **2.0s**,再以 **35.0\*scale px/s** 滚动,
|
||||||
|
循环间隔 `full_w + 50*scale`,裁剪区 `(x, y-size*1.2, max_w, size*1.5)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 图标
|
||||||
|
|
||||||
|
**体系**:全部是 Skia 内的 **SVG path 数据 + 填充(`PaintStyle::Fill`)**,没有线性描边图标体系,
|
||||||
|
也没有 icon font。颜色一律单色:由 `text_color`(= 白)+ alpha 决定;只有资源环用 accent 色。
|
||||||
|
**未找到**线宽(stroke-width)概念——唯一的描边图标是设置窗口的翻页箭头(`1.8px`, round cap/join)。
|
||||||
|
|
||||||
|
| 图标 | 路径坐标系 | 缩放 | 实际视觉尺寸 | 来源 |
|
||||||
|
| ---------------- | ------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| 播放 ▶ | 640 单位(128..544) | `0.083 * scale` | ≈ 34.5 × 37 px | `src/icons/controls.rs` `draw_play_button` |
|
||||||
|
| 暂停 ⏸ | 640 单位 | `0.083 * scale` | ≈ 34.5 × 37 px | `draw_pause_button` |
|
||||||
|
| 快退/快进三角 | 640 单位(中心 336,320) | `path_scale * global_scale`,静止 `0.050`(左)与 `0.055`(右);动画中 `0.050~0.055` | 约 21 × 22 px | `draw_control_triangle`,`src/ui/expanded/music_view.rs` `draw_skip_button`(中心 ±10.92*scale) |
|
||||||
|
| 音乐(封面占位) | 1024 单位 | `scale*1.8`,内部 `0.024` | 随卡片 | `src/icons/music.rs` |
|
||||||
|
| 设置齿轮 | 1024 单位 | `min(w,h)*0.38/24`,内部 `0.024` | 卡片 38% 短边 | `src/icons/settings.rs` |
|
||||||
|
| 音量(4 档) | 25.75 单位 | `size / 25.75`,偏移 `(-11.089286, -7.883929)` | 传 20.0*scale | `src/icons/volume.rs`;档位 `level<=0→静音, <=1/3→低, <=2/3→中, else→高` |
|
||||||
|
| 翻页指示条 | 不是 path | — | `3.0*scale × 14.0*scale` 圆角矩形,圆角 `2.0*scale`,α×0.4 | `src/icons/arrows.rs` |
|
||||||
|
|
||||||
|
其他图标使用点:
|
||||||
|
|
||||||
|
- 通知 App 图标:`AppDisplayInfo::GetLogo(Size{64,64})`,按 alpha>8 计算可见包围盒,等比缩放塞进 42×42,圆角 11 裁切
|
||||||
|
- 封面:64×64(expanded)与 18×18(mini),中心裁剪,`FilterMode::Linear + MipmapMode::Linear`
|
||||||
|
- 迷你封面圆角 5、expanded 封面圆角 16(连续超椭圆路径,不是普通 `clip_rrect`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 明确「未找到」的项
|
||||||
|
|
||||||
|
1. **刘海/挖孔造型**:不存在(无 `SetWindowRgn`、无顶部直角特例)
|
||||||
|
2. **内阴影 / inset shadow**:代码中无任何实现
|
||||||
|
3. **字距(letter-spacing)与行高倍数**:无;行距全靠显式 y 偏移
|
||||||
|
4. **亚克力的模糊半径/透明度数值**:由 Windows `HostBackdropBrush` 系统决定,代码只给 `HOST_BACKDROP_INSET = 1.0`
|
||||||
|
5. **强调色用于岛内 UI**:岛内几乎没有强调色(只有资源占用的 CPU/RAM/警告/危险色);
|
||||||
|
统一强调色 `#0A84FF` 仅存在于**设置窗口**(`COLOR_ACCENT`)
|
||||||
|
6. **岛自身的亮/暗主题**:岛屿永远是深底白字;明暗主题只在设置窗口
|
||||||
|
7. **固定渐变色背景**:真实 `dynamic` 背景是封面图,没有固定渐变;
|
||||||
|
仅设置页预览用 `rgb(18,12,36) → rgb(8,24,48)`(可当静态替代)
|
||||||
|
8. **CSS 等价的超椭圆圆角公式**:真实实现是 Flutter 超椭圆查表(`src/utils/shape.rs`),没有简单的 border-radius 参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 给 Web/Electron 复刻的映射速查
|
||||||
|
|
||||||
|
- 岛容器:定位在屏幕顶上 `top: 10px`、水平居中,尺寸与圆角用 spring 插值
|
||||||
|
(`cubic-bezier` 或 JS 弹簧,参考 `stiffness 0.10 / damping 0.68`);`overflow: hidden` 做裁切
|
||||||
|
- compact:`120×27`(有歌词时宽度跟着文本走,上限 700;只有媒体无歌词时 155),`border-radius: 13.5px`(胶囊)
|
||||||
|
- expanded:`360×200`,`border-radius: 48px`(想要 iOS 味请用 SVG 超椭圆路径)
|
||||||
|
- 背景:`#000` 不透明;"glass" 用 `backdrop-filter` + `background: rgba(10,10,14,.588)`;
|
||||||
|
"dynamic" 用封面模糊层 + `rgba(20,20,24,.471)` 叠加
|
||||||
|
- 描边:`1px solid rgba(255,255,255,.118)`(纯色样式,展开时淡出)/ `.157`(glass、dynamic)
|
||||||
|
- 阴影:仅展开 `0 2px σ rgba(0,0,0,.11)`(σ ≈ 3px)
|
||||||
|
- 文字:`-apple-system / "Microsoft YaHei" / "Segoe UI"`,白 + alpha 分层(.65 / .72 / .9 / .6 / .5 / .58 / .62)
|
||||||
|
- 图标:内联 SVG,`fill: currentColor`,播放/暂停按 640 坐标系、尺寸 ≈ 34.5×37
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 连续圆角(squircle / Apple 连续曲率圆角)路径生成。
|
||||||
|
*
|
||||||
|
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表
|
||||||
|
* 构造 `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡的半径取
|
||||||
|
* `expanded_island_radius = min(48 * scale, w/2, h/2)`。
|
||||||
|
*
|
||||||
|
* 这里用等价的超椭圆角公式生成 SVG path:
|
||||||
|
* 角点区域按 x = r * (1 - cos^p(t)), y = r * (1 - sin^p(t)) 采样,
|
||||||
|
* 其中 p = 2/n、n 取 4.2 时最接近 Apple 的"连续曲率"观感(普通圆角相当于 n = 2)。
|
||||||
|
* 直边部分保持完全笔直,因此与 WinIsland 的"直边 + 连续圆角"一致。
|
||||||
|
*
|
||||||
|
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),所以圆角半径恒定、不被拉伸。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 超椭圆指数:2 = 标准圆弧圆角,越大越"方"但曲率越连续(Apple 观感约 4~5) */
|
||||||
|
const SUPERELLIPSE_EXPONENT = 4.2;
|
||||||
|
|
||||||
|
/** 每个角的采样段数(越多越平滑;4 个角共 4x 段) */
|
||||||
|
const CORNER_SEGMENTS = 14;
|
||||||
|
|
||||||
|
function cornerPoints(radius: number): { x: number; y: number }[] {
|
||||||
|
const p = 2 / SUPERELLIPSE_EXPONENT;
|
||||||
|
const points: { x: number; y: number }[] = [];
|
||||||
|
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
|
||||||
|
const t = (index / CORNER_SEGMENTS) * (Math.PI / 2);
|
||||||
|
const cos = Math.max(0, Math.cos(t));
|
||||||
|
const sin = Math.max(0, Math.sin(t));
|
||||||
|
// t=0 → 角外侧(贴直边),t=π/2 → 角内侧
|
||||||
|
points.push({
|
||||||
|
x: radius * (1 - Math.pow(cos, p)),
|
||||||
|
y: radius * (1 - Math.pow(sin, p)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成连续圆角矩形路径。
|
||||||
|
*
|
||||||
|
* @param width 卡片宽度(px)
|
||||||
|
* @param height 卡片高度(px)
|
||||||
|
* @param radius 四角基准半径(px,会被裁剪到 min(w/2, h/2))
|
||||||
|
*/
|
||||||
|
export function squirclePath(width: number, height: number, radius: number): string {
|
||||||
|
const w = Math.max(1, Math.round(width * 100) / 100);
|
||||||
|
const h = Math.max(1, Math.round(height * 100) / 100);
|
||||||
|
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
|
||||||
|
|
||||||
|
if (r <= 0.5) return `M0,0 H${round(w)} V${round(h)} H0 Z`;
|
||||||
|
|
||||||
|
const corner = cornerPoints(r);
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// 从左上角的"上方直边起点"开始,顺时针绘制
|
||||||
|
parts.push(`M${round(r)},0`);
|
||||||
|
// 上边 → 右上角
|
||||||
|
parts.push(`H${round(w - r)}`);
|
||||||
|
for (const point of corner) parts.push(`L${round(w - r + point.x)},${round(point.y)}`);
|
||||||
|
// 右边 → 右下角
|
||||||
|
parts.push(`V${round(h - r)}`);
|
||||||
|
for (const point of corner) parts.push(`L${round(w - point.y)},${round(h - r + point.x)}`);
|
||||||
|
// 下边 → 左下角
|
||||||
|
parts.push(`H${round(r)}`);
|
||||||
|
for (const point of corner) parts.push(`L${round(r - point.x)},${round(h - point.y)}`);
|
||||||
|
// 左边 → 左上角
|
||||||
|
parts.push(`V${round(r)}`);
|
||||||
|
for (const point of corner) parts.push(`L${round(point.y)},${round(r - point.x)}`);
|
||||||
|
parts.push('Z');
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展开卡半径(与 WinIsland 的 expanded_island_radius 同构:min(48*scale, w/2, h/2)) */
|
||||||
|
export function expandedRadius(width: number, height: number, radius: number): number {
|
||||||
|
return Math.max(0, Math.min(Math.max(radius, 24), width / 2, height / 2));
|
||||||
|
}
|
||||||
@@ -28,7 +28,15 @@ function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandApp
|
|||||||
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
return Math.min(max, Math.max(min, Math.round(numeric * 100) / 100));
|
return Math.min(max, Math.max(min, Math.round(numeric * 100) / 100));
|
||||||
};
|
};
|
||||||
const positions: IslandAppearance['position'][] = ['top-center', 'top-left', 'top-right', 'bottom-center'];
|
const positions: IslandAppearance['position'][] = [
|
||||||
|
'top-center',
|
||||||
|
'top-left',
|
||||||
|
'top-right',
|
||||||
|
'bottom-center',
|
||||||
|
'bottom-left',
|
||||||
|
'bottom-right',
|
||||||
|
];
|
||||||
|
const styles: IslandAppearance['style'][] = ['black', 'glass', 'tinted'];
|
||||||
return {
|
return {
|
||||||
height: clamp(input?.height, 36, 72, DEFAULT_ISLAND_APPEARANCE.height),
|
height: clamp(input?.height, 36, 72, DEFAULT_ISLAND_APPEARANCE.height),
|
||||||
width: clamp(input?.width, 220, 420, DEFAULT_ISLAND_APPEARANCE.width),
|
width: clamp(input?.width, 220, 420, DEFAULT_ISLAND_APPEARANCE.width),
|
||||||
@@ -38,13 +46,17 @@ function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandApp
|
|||||||
typeof input?.accent === 'string' && /^#[0-9a-fA-F]{6}$/.test(input.accent)
|
typeof input?.accent === 'string' && /^#[0-9a-fA-F]{6}$/.test(input.accent)
|
||||||
? input.accent
|
? input.accent
|
||||||
: DEFAULT_ISLAND_APPEARANCE.accent,
|
: DEFAULT_ISLAND_APPEARANCE.accent,
|
||||||
fontSize: clamp(input?.fontSize, 11, 18, DEFAULT_ISLAND_APPEARANCE.fontSize),
|
fontSize: clamp(input?.fontSize, 11, 20, DEFAULT_ISLAND_APPEARANCE.fontSize),
|
||||||
animations: input?.animations !== false,
|
animations: input?.animations !== false,
|
||||||
speed: clamp(input?.speed, 0.5, 2, DEFAULT_ISLAND_APPEARANCE.speed),
|
speed: clamp(input?.speed, 0.5, 2, DEFAULT_ISLAND_APPEARANCE.speed),
|
||||||
position: positions.includes(input?.position as IslandAppearance['position'])
|
position: positions.includes(input?.position as IslandAppearance['position'])
|
||||||
? (input?.position as IslandAppearance['position'])
|
? (input?.position as IslandAppearance['position'])
|
||||||
: DEFAULT_ISLAND_APPEARANCE.position,
|
: DEFAULT_ISLAND_APPEARANCE.position,
|
||||||
alwaysOnTop: input?.alwaysOnTop !== false,
|
alwaysOnTop: input?.alwaysOnTop !== false,
|
||||||
|
style: styles.includes(input?.style as IslandAppearance['style'])
|
||||||
|
? (input?.style as IslandAppearance['style'])
|
||||||
|
: DEFAULT_ISLAND_APPEARANCE.style,
|
||||||
|
idleSliver: input?.idleSliver === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import path from 'node:path';
|
|||||||
import { BrowserWindow, ipcMain, screen } from 'electron';
|
import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||||
import {
|
import {
|
||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
|
ISLAND_POSITIONS,
|
||||||
|
ISLAND_SLIVER_WIDTH,
|
||||||
|
ISLAND_STYLES,
|
||||||
type IslandAppearance,
|
type IslandAppearance,
|
||||||
type IslandMode,
|
type IslandMode,
|
||||||
type IslandNotification,
|
type IslandNotification,
|
||||||
@@ -31,12 +34,18 @@ import { getConfig } from './config.js';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** 基础尺寸:真实尺寸 = 外观设置联动计算(见 sizes()) */
|
/** 基础尺寸:真实尺寸 = 外观设置联动计算(见 sizes()) */
|
||||||
|
/**
|
||||||
|
* 基准尺寸(对齐 WinIsland 的 `base_width/base_height` 与 `expanded_width/expanded_height` 比例):
|
||||||
|
* - 胶囊:小而饱满(WinIsland compact 为 120×27,我们是文字型胶囊,取 216×34,圆角 = h/2 满圆角)
|
||||||
|
* - 展开卡:展开增量与渲染进程 IslandApp.vue 的 EXTRA 常量一一对应
|
||||||
|
* (expanded +156×+218 / urgent +172×+238 / call +188×+254)
|
||||||
|
*/
|
||||||
const BASE = {
|
const BASE = {
|
||||||
pill: { width: 268, height: 44 },
|
pill: { width: 216, height: 34 },
|
||||||
expanded: { width: 404, height: 316 },
|
expanded: { width: 372, height: 220 },
|
||||||
urgent: { width: 424, height: 344 },
|
urgent: { width: 388, height: 236 },
|
||||||
/** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */
|
/** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */
|
||||||
call: { width: 440, height: 360 },
|
call: { width: 404, height: 252 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +77,12 @@ let SIZES: Record<IslandMode, { width: number; height: number }> = { ...DEFAULT_
|
|||||||
let URGENT_SIZE = { ...BASE.urgent };
|
let URGENT_SIZE = { ...BASE.urgent };
|
||||||
let CALL_SIZE = { ...BASE.call };
|
let CALL_SIZE = { ...BASE.call };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空闲"细缝"尺寸(参考 WinIsland 的 hidden_width:空闲态是一条很窄的圆角柱)。
|
||||||
|
* 高度取胶囊高度的一部分,宽度固定(`ISLAND_SLIVER_WIDTH`)。
|
||||||
|
*/
|
||||||
|
let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
|
||||||
|
|
||||||
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
|
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
|
||||||
const TIMEOUTS = {
|
const TIMEOUTS = {
|
||||||
pill: 15_000,
|
pill: 15_000,
|
||||||
@@ -112,6 +127,8 @@ 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 state: IslandState = {
|
private state: IslandState = {
|
||||||
mode: 'hidden',
|
mode: 'hidden',
|
||||||
@@ -175,6 +192,9 @@ 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 需要窗口创建后立刻设置材质)
|
||||||
|
this.backgroundMaterial = 'none';
|
||||||
|
this.applyBackgroundMaterial();
|
||||||
win.setIgnoreMouseEvents(false);
|
win.setIgnoreMouseEvents(false);
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
this.win = null;
|
this.win = null;
|
||||||
@@ -392,6 +412,12 @@ class IslandController {
|
|||||||
hide(): void {
|
hide(): void {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
if (!this.win || this.win.isDestroyed()) return;
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
|
// 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏,
|
||||||
|
// 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。
|
||||||
|
if (this.appearance.idleSliver) {
|
||||||
|
this.setState({ mode: 'hidden', reason: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.fadeOut();
|
this.fadeOut();
|
||||||
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
||||||
}
|
}
|
||||||
@@ -497,26 +523,18 @@ class IslandController {
|
|||||||
const margin = ISLAND_MARGIN;
|
const margin = ISLAND_MARGIN;
|
||||||
const center = Math.round(area.x + area.width / 2);
|
const center = Math.round(area.x + area.width / 2);
|
||||||
|
|
||||||
switch (this.appearance.position) {
|
// 6 个停靠位置(顶/底 × 左/中/右),与 WinIsland 的 DockPosition 一一对应
|
||||||
case 'top-left':
|
const position = this.appearance.position;
|
||||||
return { hMode: 'left', hValue: area.x + margin, vMode: 'top', vValue: area.y + margin };
|
const vMode: IslandAnchor['vMode'] = position.startsWith('bottom') ? 'bottom' : 'top';
|
||||||
case 'top-right':
|
const vValue = vMode === 'bottom' ? area.y + area.height - margin : area.y + margin;
|
||||||
return {
|
const hMode: IslandAnchor['hMode'] = position.endsWith('left')
|
||||||
hMode: 'right',
|
? 'left'
|
||||||
hValue: area.x + area.width - margin,
|
: position.endsWith('right')
|
||||||
vMode: 'top',
|
? 'right'
|
||||||
vValue: area.y + margin,
|
: 'center';
|
||||||
};
|
const hValue =
|
||||||
case 'bottom-center':
|
hMode === 'left' ? area.x + margin : hMode === 'right' ? area.x + area.width - margin : center;
|
||||||
return {
|
return { hMode, hValue, vMode, vValue };
|
||||||
hMode: 'center',
|
|
||||||
hValue: center,
|
|
||||||
vMode: 'bottom',
|
|
||||||
vValue: area.y + area.height - margin,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return { hMode: 'center', hValue: center, vMode: 'top', vValue: area.y + margin };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -587,6 +605,15 @@ class IslandController {
|
|||||||
this.applyFocusable();
|
this.applyFocusable();
|
||||||
|
|
||||||
if (this.state.mode === 'hidden') {
|
if (this.state.mode === 'hidden') {
|
||||||
|
// 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱;
|
||||||
|
// 关闭时保持原行为(完全淡出并隐藏)。
|
||||||
|
if (this.appearance.idleSliver) {
|
||||||
|
this.cancelFade();
|
||||||
|
this.win.setOpacity(this.appearance.opacity);
|
||||||
|
if (!this.win.isVisible()) this.win.showInactive();
|
||||||
|
this.animateBounds(SLIVER_SIZE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.fadeOut();
|
this.fadeOut();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -761,11 +788,13 @@ class IslandController {
|
|||||||
* 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。
|
* 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。
|
||||||
*/
|
*/
|
||||||
setAppearance(patch: Partial<IslandAppearance>): void {
|
setAppearance(patch: Partial<IslandAppearance>): void {
|
||||||
|
const before = this.appearance;
|
||||||
this.appearance = normalizeAppearance({ ...this.appearance, ...patch });
|
this.appearance = normalizeAppearance({ ...this.appearance, ...patch });
|
||||||
recomputeSizes(this.appearance);
|
recomputeSizes(this.appearance);
|
||||||
|
|
||||||
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);
|
||||||
@@ -774,13 +803,42 @@ class IslandController {
|
|||||||
this.emitAppearance();
|
this.emitAppearance();
|
||||||
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
|
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
|
||||||
if (this.state.mode !== 'hidden') this.syncWindow();
|
if (this.state.mode !== 'hidden') this.syncWindow();
|
||||||
|
// 位置变化:即使当前不可见也先把窗口挪到新锚点,避免下次出现时"从旧位置飞过来"
|
||||||
|
if (this.state.mode === 'hidden' && before.position !== this.appearance.position) {
|
||||||
|
this.syncWindow();
|
||||||
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
`灵动岛外观已更新:${this.appearance.width}x${this.appearance.height} 圆角=${this.appearance.radius} ` +
|
`灵动岛外观已更新:${this.appearance.width}x${this.appearance.height} 圆角=${this.appearance.radius} ` +
|
||||||
`透明度=${this.appearance.opacity} 位置=${this.appearance.position} 动画=${this.appearance.animations} ` +
|
`透明度=${this.appearance.opacity} 位置=${this.appearance.position} 风格=${this.appearance.style} ` +
|
||||||
`速度=${this.appearance.speed}`,
|
`字号=${this.appearance.fontSize} 动画=${this.appearance.animations} 速度=${this.appearance.speed} ` +
|
||||||
|
`空闲细缝=${this.appearance.idleSliver}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用窗口背景材质(对应 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;
|
||||||
|
}
|
||||||
|
|
||||||
getAppearance(): IslandAppearance {
|
getAppearance(): IslandAppearance {
|
||||||
return { ...this.appearance };
|
return { ...this.appearance };
|
||||||
}
|
}
|
||||||
@@ -807,6 +865,22 @@ export function recomputeSizes(appearance: IslandAppearance): void {
|
|||||||
};
|
};
|
||||||
URGENT_SIZE = grow(BASE.urgent);
|
URGENT_SIZE = grow(BASE.urgent);
|
||||||
CALL_SIZE = grow(BASE.call);
|
CALL_SIZE = grow(BASE.call);
|
||||||
|
// 空闲细缝:卡片宽固定 6px(WinIsland hidden_width 的思路),窗口 = 卡片 + 内边距。
|
||||||
|
// 注意:Windows 对(透明无边框)窗口有约 36px 的**最小高度**限制——
|
||||||
|
// 实测请求 32px 时实际得到 36px,因此这里直接以 36px 为下限,避免断言/预览与真实不一致。
|
||||||
|
const sliverCardHeight = Math.max(16, Math.min(28, appearance.height - 22));
|
||||||
|
SLIVER_SIZE = {
|
||||||
|
width: Math.min(ISLAND_SLIVER_WIDTH + CARD_PAD_X * 2, appearance.width),
|
||||||
|
height: Math.max(MIN_WINDOW_HEIGHT, sliverCardHeight + CARD_PAD_Y * 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空闲细缝的**窗口**尺寸(`getBounds()` 返回值;卡片尺寸 = 再减去内边距)。
|
||||||
|
* 供冒烟验证与设置页预览使用。
|
||||||
|
*/
|
||||||
|
export function getSliverSize(): { width: number; height: number } {
|
||||||
|
return { ...SLIVER_SIZE };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
||||||
@@ -819,13 +893,13 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
|||||||
radius: clamp(input.radius, 8, 32),
|
radius: clamp(input.radius, 8, 32),
|
||||||
opacity: clamp(input.opacity, 0.4, 1),
|
opacity: clamp(input.opacity, 0.4, 1),
|
||||||
accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent,
|
accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent,
|
||||||
fontSize: clamp(input.fontSize, 11, 18),
|
fontSize: clamp(input.fontSize, 11, 20),
|
||||||
animations: input.animations !== false,
|
animations: input.animations !== false,
|
||||||
speed: clamp(input.speed, 0.5, 2),
|
speed: clamp(input.speed, 0.5, 2),
|
||||||
position: ['top-center', 'top-left', 'top-right', 'bottom-center'].includes(input.position)
|
position: ISLAND_POSITIONS.includes(input.position) ? input.position : 'top-center',
|
||||||
? input.position
|
|
||||||
: 'top-center',
|
|
||||||
alwaysOnTop: input.alwaysOnTop !== false,
|
alwaysOnTop: input.alwaysOnTop !== false,
|
||||||
|
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
|
||||||
|
idleSliver: input.idleSliver === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,11 +909,19 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
|||||||
* 现在所有形变统一走单调的 easeOutCubic(见 animateBounds),不再有任何回弹。
|
* 现在所有形变统一走单调的 easeOutCubic(见 animateBounds),不再有任何回弹。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 卡片相对窗口的内边距(必须与渲染进程 CSS 的 4px / 3px 一致) */
|
/**
|
||||||
const CARD_PAD_X = 4;
|
* 卡片相对窗口的内边距(必须与渲染进程 IslandApp.vue 的 CARD_PAD_X/CARD_PAD_Y 一致)。
|
||||||
const CARD_PAD_Y = 3;
|
* 取 5px 是为了给展开态的投影(WinIsland: y+2、σ=3)留出不被窗口裁掉的余量。
|
||||||
|
*/
|
||||||
|
const CARD_PAD_X = 5;
|
||||||
|
const CARD_PAD_Y = 5;
|
||||||
/** 灵动岛与屏幕工作区边缘的留白 */
|
/** 灵动岛与屏幕工作区边缘的留白 */
|
||||||
const ISLAND_MARGIN = 8;
|
const ISLAND_MARGIN = 8;
|
||||||
|
/**
|
||||||
|
* Windows 对窗口最小高度的实际限制(实测:请求 32px 会得到 36px)。
|
||||||
|
* 空闲细缝等"极小形态"必须以此作为高度下限,否则断言与真实窗口会不一致。
|
||||||
|
*/
|
||||||
|
const MIN_WINDOW_HEIGHT = 36;
|
||||||
|
|
||||||
interface Bounds {
|
interface Bounds {
|
||||||
x: number;
|
x: number;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BrowserWindow, 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';
|
||||||
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
import { getSliverSize, island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||||
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
|
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
|
||||||
|
|
||||||
interface SmokeResult {
|
interface SmokeResult {
|
||||||
@@ -381,7 +381,15 @@ async function runIslandChecks(
|
|||||||
await sleep(700);
|
await sleep(700);
|
||||||
clearInterval(shrinkSampler);
|
clearInterval(shrinkSampler);
|
||||||
await sleep(120);
|
await sleep(120);
|
||||||
const allowedSizes = new Set(['396x308', '416x336', '432x352', '260x38']);
|
// 允许的卡片尺寸由当前外观与各形态窗口尺寸推导(卡片 = 窗口 - 2*5px 内边距)
|
||||||
|
const cardLabel = (size: { width: number; height: number }): string =>
|
||||||
|
`${size.width - 10}x${size.height - 10}`;
|
||||||
|
const allowedSizes = new Set([
|
||||||
|
cardLabel(ISLAND_SIZES.pill),
|
||||||
|
cardLabel(ISLAND_SIZES.expanded),
|
||||||
|
cardLabel(ISLAND_URGENT_SIZE),
|
||||||
|
cardLabel(ISLAND_CALL_SIZE),
|
||||||
|
]);
|
||||||
const stretched = [...new Set(shrinkSamples)].filter((size) => !allowedSizes.has(size));
|
const stretched = [...new Set(shrinkSamples)].filter((size) => !allowedSizes.has(size));
|
||||||
record(
|
record(
|
||||||
'收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)',
|
'收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)',
|
||||||
@@ -626,7 +634,7 @@ async function runIslandChecks(
|
|||||||
const cardTops = jitterSamples.map((item) => item.cardTop);
|
const cardTops = jitterSamples.map((item) => item.cardTop);
|
||||||
const topSpread = cardTops.length > 0 ? Math.max(...cardTops) - Math.min(...cardTops) : 99;
|
const topSpread = cardTops.length > 0 ? Math.max(...cardTops) - Math.min(...cardTops) : 99;
|
||||||
const cardWidths = [...new Set(jitterSamples.map((item) => item.cardWidth))];
|
const cardWidths = [...new Set(jitterSamples.map((item) => item.cardWidth))];
|
||||||
const expectedWidths = [ISLAND_SIZES.expanded.width - 8, ISLAND_SIZES.pill.width - 8];
|
const expectedWidths = [ISLAND_SIZES.expanded.width - 10, ISLAND_SIZES.pill.width - 10];
|
||||||
const widthOk = cardWidths.every((width) => expectedWidths.includes(width));
|
const widthOk = cardWidths.every((width) => expectedWidths.includes(width));
|
||||||
record(
|
record(
|
||||||
'收回过程不抖动(窗口宽度单调收缩 + 卡片布局尺寸不拉伸)',
|
'收回过程不抖动(窗口宽度单调收缩 + 卡片布局尺寸不拉伸)',
|
||||||
@@ -704,8 +712,8 @@ async function runIslandChecks(
|
|||||||
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
|
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
|
||||||
await sleep(350);
|
await sleep(350);
|
||||||
|
|
||||||
const expandedCard = { width: ISLAND_SIZES.expanded.width - 8, height: ISLAND_SIZES.expanded.height - 6 };
|
const expandedCard = { width: ISLAND_SIZES.expanded.width - 10, height: ISLAND_SIZES.expanded.height - 10 };
|
||||||
const pillCard = { width: ISLAND_SIZES.pill.width - 8, height: ISLAND_SIZES.pill.height - 6 };
|
const pillCard = { width: ISLAND_SIZES.pill.width - 10, height: ISLAND_SIZES.pill.height - 10 };
|
||||||
|
|
||||||
await runMotionProbe('expand');
|
await runMotionProbe('expand');
|
||||||
const expandSamples = [...motionSamples];
|
const expandSamples = [...motionSamples];
|
||||||
@@ -771,6 +779,168 @@ async function runIslandChecks(
|
|||||||
|
|
||||||
// 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效
|
// 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效
|
||||||
await sleep(250);
|
await sleep(250);
|
||||||
|
/** 排空灵动岛队列:连续收起直到没有活动通知与排队通知(否则"空闲态"断言会被下一条顶掉) */
|
||||||
|
const drainIsland = async (): Promise<void> => {
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
const current = island.getState();
|
||||||
|
if (!current.active && current.queued.length === 0) return;
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(120);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 灵动岛状态摘要(失败时用于定位是"没排空队列"还是"几何没生效") */
|
||||||
|
const islandStateSummary = (): string => {
|
||||||
|
const current = island.getState();
|
||||||
|
return `mode=${current.mode} active=${current.active?.id ?? 'null'} queued=${current.queued.length} visible=${
|
||||||
|
islandWindow?.isVisible() ?? '-'
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 等待灵动岛渲染出指定选择器(最多约 2s) */
|
||||||
|
const waitForIslandDom = async (selector: string, timeoutMs = 2000): Promise<boolean> => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const found = await islandWindow?.webContents
|
||||||
|
.executeJavaScript(`Boolean(document.querySelector(${JSON.stringify(selector)}))`)
|
||||||
|
.catch(() => false);
|
||||||
|
if (found) return true;
|
||||||
|
await sleep(60);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 10.6) 字号:所有文本都由基础字号推导,改设置必须"立刻看到"(WinIsland 的 font_size 模型)
|
||||||
|
const readFontSizes = async (): Promise<Record<string, number>> =>
|
||||||
|
(await islandWindow?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
const pick = (selector) => {
|
||||||
|
const node = document.querySelector(selector);
|
||||||
|
if (!node) return 0;
|
||||||
|
return Math.round(parseFloat(getComputedStyle(node).fontSize) * 100) / 100;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
'pill-title': pick('.pill-title'),
|
||||||
|
'pill-sub': pick('.pill-sub'),
|
||||||
|
title: pick('.title'),
|
||||||
|
content: pick('.content-text'),
|
||||||
|
ghost: pick('.ghost-btn'),
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => ({}))) ?? {};
|
||||||
|
|
||||||
|
const probeFontScale = async (fontSize: number): Promise<Record<string, number>> => {
|
||||||
|
island.setAppearance({ ...appearanceBefore, fontSize });
|
||||||
|
await drainIsland();
|
||||||
|
island.pushNotification(makeNotification('smoke-font', 'NORMAL', '字号实时生效校验'), {
|
||||||
|
inClass: false,
|
||||||
|
});
|
||||||
|
await waitForIslandDom('.island-card.pill .pill-title');
|
||||||
|
await sleep(120);
|
||||||
|
const pillSizes = await readFontSizes();
|
||||||
|
island.handleAction({ action: 'expand' });
|
||||||
|
await waitForIslandDom('.island-card.expanded .title');
|
||||||
|
await sleep(160);
|
||||||
|
const expandedSizes = await readFontSizes();
|
||||||
|
// 两次读取各有一半元素不存在(返回 0),合并时只采纳 >0 的有效值
|
||||||
|
const merged: Record<string, number> = { ...pillSizes };
|
||||||
|
for (const [key, value] of Object.entries(expandedSizes)) {
|
||||||
|
if (value > 0) merged[key] = value;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
await drainIsland();
|
||||||
|
const fontSmall = await probeFontScale(13);
|
||||||
|
const fontLarge = await probeFontScale(19);
|
||||||
|
const fontKeys = Object.keys(fontSmall).filter((key) => (fontSmall[key] ?? 0) > 0);
|
||||||
|
const expectedRatio = 19 / 13;
|
||||||
|
const fontRatios = fontKeys.map((key) => ({
|
||||||
|
key,
|
||||||
|
ratio: (fontLarge[key] ?? 0) / (fontSmall[key] ?? 1),
|
||||||
|
}));
|
||||||
|
const fontUniform = fontRatios.every((item) => Math.abs(item.ratio - expectedRatio) <= 0.03);
|
||||||
|
record(
|
||||||
|
'字号设置立刻生效(岛内所有文本按基础字号等比缩放)',
|
||||||
|
fontKeys.length >= 5 && fontUniform,
|
||||||
|
`13px→[${fontKeys.map((key) => `${key}=${fontSmall[key]}`).join(' ')}] ` +
|
||||||
|
`19px→[${fontKeys.map((key) => `${key}=${fontLarge[key]}`).join(' ')}] ` +
|
||||||
|
`期望倍率=${expectedRatio.toFixed(3)} 实测=[${fontRatios.map((item) => `${item.key}:${item.ratio.toFixed(3)}`).join(' ')}] ` +
|
||||||
|
`${islandStateSummary()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 10.7) 视觉风格:纯黑 / 毛玻璃(亚克力)/ 主题色渐变,切换立即生效
|
||||||
|
const probeStyle = async (
|
||||||
|
style: IslandAppearance['style'],
|
||||||
|
): Promise<{ fill: string; stroke: string; material: string }> => {
|
||||||
|
island.setAppearance({ ...appearanceBefore, style });
|
||||||
|
await sleep(260);
|
||||||
|
const measured = await islandWindow?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
const path = document.querySelector('.island-card .shape path');
|
||||||
|
if (!path) return null;
|
||||||
|
const style = getComputedStyle(path);
|
||||||
|
return { fill: style.fill, stroke: style.stroke };
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
return {
|
||||||
|
fill: String(measured?.fill ?? '-'),
|
||||||
|
stroke: String(measured?.stroke ?? '-'),
|
||||||
|
material: island.getBackgroundMaterial(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const blackStyle = await probeStyle('black');
|
||||||
|
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' &&
|
||||||
|
tintedStyle.fill === 'rgb(11, 11, 15)',
|
||||||
|
`纯黑 fill=${blackStyle.fill} 材质=${blackStyle.material};` +
|
||||||
|
`毛玻璃 fill=${glassStyle.fill} 材质=${glassStyle.material};` +
|
||||||
|
`主题色 fill=${tintedStyle.fill} 材质=${tintedStyle.material}`,
|
||||||
|
);
|
||||||
|
island.setAppearance({ ...appearanceBefore, style: 'black' });
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// 10.8) 空闲细缝(参考 WinIsland hidden_width):开启后空闲留一条细缝,来消息再展开
|
||||||
|
const sliver = getSliverSize();
|
||||||
|
island.setAppearance({ ...appearanceBefore, idleSliver: true });
|
||||||
|
await drainIsland();
|
||||||
|
await sleep(420);
|
||||||
|
const sliverBounds = islandWindow?.getBounds();
|
||||||
|
island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false });
|
||||||
|
await sleep(500);
|
||||||
|
const afterSliverNotification = islandWindow?.getBounds();
|
||||||
|
record(
|
||||||
|
'空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)',
|
||||||
|
sliverBounds?.width === sliver.width &&
|
||||||
|
sliverBounds?.height === sliver.height &&
|
||||||
|
afterSliverNotification?.width === island.getAppearance().width,
|
||||||
|
`细缝=${sliverBounds?.width ?? '-'}x${sliverBounds?.height ?? '-'}(期望 ${sliver.width}x${sliver.height})` +
|
||||||
|
` 来消息后=${afterSliverNotification?.width ?? '-'}x${afterSliverNotification?.height ?? '-'} ${islandStateSummary()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
island.setAppearance({ ...appearanceBefore, idleSliver: false });
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(450);
|
||||||
|
record(
|
||||||
|
'关闭空闲细缝后空闲再次完全隐藏(保持原行为)',
|
||||||
|
islandWindow?.isVisible() === false,
|
||||||
|
`isVisible=${islandWindow?.isVisible() ?? '-'}`,
|
||||||
|
);
|
||||||
|
island.pushNotification(makeNotification('smoke-after-sliver', 'NORMAL', '细缝关闭后恢复'), {
|
||||||
|
inClass: false,
|
||||||
|
});
|
||||||
|
await sleep(320);
|
||||||
// 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化
|
// 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化
|
||||||
// (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示)
|
// (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示)
|
||||||
const workArea = screen.getPrimaryDisplay().workArea;
|
const workArea = screen.getPrimaryDisplay().workArea;
|
||||||
@@ -830,7 +1000,7 @@ async function runIslandChecks(
|
|||||||
`${item.position}=${bounds ? `${bounds.x},${bounds.y}` : '-'}${matched ? '✓' : '✗'}`,
|
`${item.position}=${bounds ? `${bounds.x},${bounds.y}` : '-'}${matched ? '✓' : '✗'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
record('个性设置:停靠位置生效(左上/右上/底部居中/顶部居中)', positionOk, positionDetails.join(' '));
|
record('个性设置:停靠位置生效(6 个锚点)', positionOk, positionDetails.join(' '));
|
||||||
island.setAppearance(appearanceBefore);
|
island.setAppearance(appearanceBefore);
|
||||||
|
|
||||||
// 11.4 置顶开关 → 窗口 alwaysOnTop
|
// 11.4 置顶开关 → 窗口 alwaysOnTop
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import {
|
|||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||||
ISLAND_POSITION_LABELS,
|
ISLAND_POSITION_LABELS,
|
||||||
|
ISLAND_STYLE_LABELS,
|
||||||
|
ISLAND_STYLES,
|
||||||
ISLAND_POSITIONS,
|
ISLAND_POSITIONS,
|
||||||
formatDate,
|
formatDate,
|
||||||
type IslandAppearance,
|
type IslandAppearance,
|
||||||
type IslandPosition,
|
type IslandPosition,
|
||||||
|
type IslandStyle,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||||
@@ -120,6 +123,11 @@ const positionOptions = ISLAND_POSITIONS.map((value) => ({
|
|||||||
label: ISLAND_POSITION_LABELS[value],
|
label: ISLAND_POSITION_LABELS[value],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const styleOptions = ISLAND_STYLES.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: ISLAND_STYLE_LABELS[value],
|
||||||
|
}));
|
||||||
|
|
||||||
/** 读取当前生效的外观(主进程为单一事实来源) */
|
/** 读取当前生效的外观(主进程为单一事实来源) */
|
||||||
async function loadIslandAppearance(): Promise<void> {
|
async function loadIslandAppearance(): Promise<void> {
|
||||||
if (!window.desktop?.islandGetAppearance) return;
|
if (!window.desktop?.islandGetAppearance) return;
|
||||||
@@ -279,10 +287,25 @@ onMounted(async () => {
|
|||||||
<el-form-item label="主题色">
|
<el-form-item label="主题色">
|
||||||
<el-color-picker
|
<el-color-picker
|
||||||
:model-value="island.accent"
|
:model-value="island.accent"
|
||||||
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#6cc4ff' })"
|
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
|
||||||
/>
|
/>
|
||||||
<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-select
|
||||||
|
:model-value="island.style"
|
||||||
|
style="width: 180px"
|
||||||
|
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in styleOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<span class="text-muted ml-8">纯黑 / 毛玻璃(亚克力)/ 主题色渐变</span>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="显示位置">
|
<el-form-item label="显示位置">
|
||||||
<el-select
|
<el-select
|
||||||
:model-value="island.position"
|
:model-value="island.position"
|
||||||
@@ -314,6 +337,15 @@ onMounted(async () => {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="空闲细缝">
|
||||||
|
<el-switch
|
||||||
|
:model-value="island.idleSliver"
|
||||||
|
@change="
|
||||||
|
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
|
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
|
||||||
|
|||||||
@@ -72,12 +72,17 @@ export const DEFAULT_ISLAND_APPEARANCE = {
|
|||||||
width: 268,
|
width: 268,
|
||||||
radius: 20,
|
radius: 20,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
accent: '#6cc4ff',
|
/** 默认强调色采用 Apple 系统蓝(与 WinIsland 的系统色体系一致) */
|
||||||
|
accent: '#0a84ff',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
animations: true,
|
animations: true,
|
||||||
speed: 1,
|
speed: 1,
|
||||||
position: 'top-center',
|
position: 'top-center',
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
|
/** 纯黑底(WinIsland default 风格) */
|
||||||
|
style: 'black',
|
||||||
|
/** 空闲时完全隐藏(与原行为一致;打开后空闲会留一条细缝) */
|
||||||
|
idleSliver: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 个性化设置项的合法区间(前端滑块与后端校验共用,避免越界导致布局错乱) */
|
/** 个性化设置项的合法区间(前端滑块与后端校验共用,避免越界导致布局错乱) */
|
||||||
@@ -86,19 +91,59 @@ export const ISLAND_APPEARANCE_RANGES = {
|
|||||||
width: { min: 220, max: 420 },
|
width: { min: 220, max: 420 },
|
||||||
radius: { min: 8, max: 32 },
|
radius: { min: 8, max: 32 },
|
||||||
opacity: { min: 0.4, max: 1 },
|
opacity: { min: 0.4, max: 1 },
|
||||||
fontSize: { min: 11, max: 18 },
|
fontSize: { min: 11, max: 20 },
|
||||||
speed: { min: 0.5, max: 2 },
|
speed: { min: 0.5, max: 2 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 停靠位置可选值 */
|
/** 停靠位置可选值(与 WinIsland 的 DockPosition 一致:顶/底 × 左/中/右) */
|
||||||
export const ISLAND_POSITIONS = ['top-center', 'top-left', 'top-right', 'bottom-center'] as const;
|
export const ISLAND_POSITIONS = [
|
||||||
|
'top-center',
|
||||||
|
'top-left',
|
||||||
|
'top-right',
|
||||||
|
'bottom-center',
|
||||||
|
'bottom-left',
|
||||||
|
'bottom-right',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const ISLAND_POSITION_LABELS: Record<(typeof ISLAND_POSITIONS)[number], string> = {
|
export const ISLAND_POSITION_LABELS: Record<(typeof ISLAND_POSITIONS)[number], string> = {
|
||||||
'top-center': '顶部居中',
|
'top-center': '顶部居中',
|
||||||
'top-left': '左上角',
|
'top-left': '左上角',
|
||||||
'top-right': '右上角',
|
'top-right': '右上角',
|
||||||
'bottom-center': '底部居中',
|
'bottom-center': '底部居中',
|
||||||
|
'bottom-left': '左下角',
|
||||||
|
'bottom-right': '右下角',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 视觉风格可选值(参考 WinIsland 的 island_style) */
|
||||||
|
export const ISLAND_STYLES = ['black', 'glass', 'tinted'] as const;
|
||||||
|
|
||||||
|
export const ISLAND_STYLE_LABELS: Record<(typeof ISLAND_STYLES)[number], string> = {
|
||||||
|
black: '纯黑(灵动岛默认)',
|
||||||
|
glass: '毛玻璃(半透明模糊)',
|
||||||
|
tinted: '主题色渐变',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空闲细缝尺寸(参考 WinIsland hidden_width = 5:空闲态是一条很窄的圆角柱)。
|
||||||
|
* 宽度固定 6px,高度取胶囊高度的一部分,视觉上就是一条"小黑条"。
|
||||||
|
*/
|
||||||
|
export const ISLAND_SLIVER_WIDTH = 6;
|
||||||
|
|
||||||
|
/** 岛内文本的排版系数(相对基础字号,集中在这里方便统一调整) */
|
||||||
|
export const ISLAND_TYPE_SCALE = {
|
||||||
|
/** 展开卡标题 */
|
||||||
|
title: 1.08,
|
||||||
|
/** 正文 */
|
||||||
|
body: 0.95,
|
||||||
|
/** 次要说明 / 元信息 */
|
||||||
|
meta: 0.78,
|
||||||
|
/** 徽标 / 按钮 */
|
||||||
|
badge: 0.76,
|
||||||
|
/** 胶囊主标题 */
|
||||||
|
pillTitle: 0.92,
|
||||||
|
/** 胶囊副标题 */
|
||||||
|
pillSub: 0.74,
|
||||||
|
} as const;
|
||||||
/** 默认学期周次上限 */
|
/** 默认学期周次上限 */
|
||||||
export const DEFAULT_WEEK_COUNT = 20;
|
export const DEFAULT_WEEK_COUNT = 20;
|
||||||
|
|
||||||
|
|||||||
@@ -415,26 +415,45 @@ export interface IslandAppearance {
|
|||||||
height: number;
|
height: number;
|
||||||
/** 胶囊宽度(px,220~420) */
|
/** 胶囊宽度(px,220~420) */
|
||||||
width: number;
|
width: number;
|
||||||
/** 卡片圆角(px,8~32) */
|
/** 卡片圆角(px,8~32):采用连续圆角(squircle),此值为四角的基准半径 */
|
||||||
radius: number;
|
radius: number;
|
||||||
/** 整体不透明度(0.4~1) */
|
/** 整体不透明度(0.4~1) */
|
||||||
opacity: number;
|
opacity: number;
|
||||||
/** 主题色(强调色,用于高亮与按钮) */
|
/** 主题色(强调色,用于高亮与按钮) */
|
||||||
accent: string;
|
accent: string;
|
||||||
/** 基础字号(px,11~18) */
|
/**
|
||||||
|
* 基础字号(px,11~20)——参考 WinIsland 的 font_size:
|
||||||
|
* 岛内**所有文本**都由它乘以各自的排版系数得出,因此改字号会整体等比缩放。
|
||||||
|
*/
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
/** 是否启用动画(关闭后展开/收起为瞬时) */
|
/** 是否启用动画(关闭后展开/收起为瞬时) */
|
||||||
animations: boolean;
|
animations: boolean;
|
||||||
/** 动画速度倍率(0.5 慢 ~ 2 快) */
|
/** 动画速度倍率(0.5 慢 ~ 2 快) */
|
||||||
speed: number;
|
speed: number;
|
||||||
/** 显示位置 */
|
/** 显示位置(顶/底 × 左/中/右,与 WinIsland 的 DockPosition 对齐) */
|
||||||
position: IslandPosition;
|
position: IslandPosition;
|
||||||
/** 是否始终置顶 */
|
/** 是否始终置顶 */
|
||||||
alwaysOnTop: boolean;
|
alwaysOnTop: boolean;
|
||||||
|
/**
|
||||||
|
* 视觉风格(参考 WinIsland 的 island_style):
|
||||||
|
* - `black`:纯黑底(默认,WinIsland `default` 风格)
|
||||||
|
* - `glass`:半透明 + 背景模糊(WinIsland `glass` 风格)
|
||||||
|
* - `tinted`:强调色渐变(本产品特色,保持与原主题色一致)
|
||||||
|
*/
|
||||||
|
style: IslandStyle;
|
||||||
|
/**
|
||||||
|
* 空闲时是否保留一条"细缝"(参考 WinIsland 的 hidden_width:空闲态是一条很窄的圆角柱)。
|
||||||
|
* 关闭时(默认)空闲即完全隐藏,与原行为一致。
|
||||||
|
*/
|
||||||
|
idleSliver: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 灵动岛停靠位置 */
|
/** 灵动岛停靠位置(6 个锚点,与 WinIsland 一致) */
|
||||||
export type IslandPosition = 'top-center' | 'top-left' | 'top-right' | 'bottom-center';
|
export type IslandPosition =
|
||||||
|
'top-center' | 'top-left' | 'top-right' | 'bottom-center' | 'bottom-left' | 'bottom-right';
|
||||||
|
|
||||||
|
/** 灵动岛视觉风格 */
|
||||||
|
export type IslandStyle = 'black' | 'glass' | 'tinted';
|
||||||
|
|
||||||
/** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */
|
/** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */
|
||||||
export interface IslandState {
|
export interface IslandState {
|
||||||
|
|||||||