feat: ClockLab —— 可调速桌面时钟(数字/表盘,支持倍率、暂停、倒转、快进)
一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、 可拖动、可拖边缘改大小,由控制窗口开与关)。 功能 - 两个窗口各自的「始终置顶」开关 - 显示样式:纯数字 / 圆形表盘,切换立即生效 - 实时设定时间(含「填入当前显示时间」) - 倍率:支持正数、负数(倒转)、0(暂停)、小数 - 暂停 / 继续(继续时回到暂停前的倍率,按钮文案会显示是几倍) - 快进五档:+5 秒 / +1 分钟 / +5 分钟 / +10 分钟 / +30 分钟 实现要点 - 走时是「基准时间 + 基准时刻 + 倍率」的锚点式模型(不累加、零漂移); 倍率、暂停、倒转、快进因此是同一套机制的四个用法,互不打架 - 跳转动画做在「呈现时间」这一层:数字滚动与表盘指针扫动共用同一个中间值, 所以永远同步,也不会跨 12 点倒着转一圈 - 无边框窗口的拖动(CSS app-region + 控件 no-drag)与拖边缘改大小 (自己算几何 + IPC setBounds,因为 frame:false 下系统边缘命中不可靠) 测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩)
This commit is contained in:
1 parent
892b9190aa
commit
9c7081b21d
27 files changed
+5467
-2
No files matched your search
+10
@@ -0,0 +1,10 @@
|
||||
# 依赖(几百 MB,且由 pnpm 从镜像还原)
|
||||
node_modules/
|
||||
|
||||
# 构建产物(Windows zip / Linux tar.gz 各上百 MB,随 Release 或交付包分发,不进仓库)
|
||||
dist/
|
||||
|
||||
# 日志与系统垃圾
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,7 @@
|
||||
# 依赖与二进制一律走国内镜像 / 加速站点(这台机器在离线内网环境外,直连官方源很慢甚至失败)。
|
||||
# 注意:这几个键对 pnpm 同样生效(pnpm 读 .npmrc),所以本机与构建机都不用再手配环境变量。
|
||||
registry=https://registry.npmmirror.com
|
||||
|
||||
# electron 的运行时(postinstall 下载的那几十 MB)与 electron-builder 的打包工具二进制
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
# ClockLab · 开发文档
|
||||
|
||||
面向**要改这个项目**的人。使用者请看 [README.md](./README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 一、项目结构
|
||||
|
||||
```
|
||||
main.js 主进程:建两个窗口、持有走时模型、注册全部 IPC、夹窗口尺寸
|
||||
preload.js contextBridge 暴露 window.clockApi(contextIsolation:true,无 nodeIntegration)
|
||||
renderer/
|
||||
control.html/.css/.js 控制窗口:三块分区(时钟窗口 / 显示样式 / 时间与流速)
|
||||
clock.html/.css/.js 时钟窗口:拖动区 + 8 条改大小热区 + 数字/表盘两种视图
|
||||
resize.js 拖边缘改大小的纯几何计算(单独拆出来是为了能在 node 里跑用例)
|
||||
presented-time.js 呈现时间层:跳转动画(纯逻辑,浏览器/node 双向可加载)
|
||||
src/
|
||||
clock-model.js 走时模型:基准时间 + 基准时刻 + 倍率(纯函数,可单测)
|
||||
selftest.js GUI 自检(electron . --selftest),跑完出截图,只在开发机上用
|
||||
test/ 6 个文件、152 条用例(见第五节)
|
||||
build/icon.ico, icon.png 程序图标
|
||||
.npmrc、pnpm-workspace.yaml 镜像与 pnpm 11 的依赖构建放行(见第六节,**别删**)
|
||||
```
|
||||
|
||||
**分层原则**:凡是能用纯函数表达的(走时、改大小几何、呈现时间)都**单独成模块**,页面里只做接线。这样 152 条用例里有 103 条不需要浏览器环境就能跑。
|
||||
|
||||
---
|
||||
|
||||
## 二、三个关键实现
|
||||
|
||||
### 2.1 走时模型:锚点式,不累加
|
||||
|
||||
`src/clock-model.js` 只有三个动作,**每一个都是"重新锚定基准"**,没有一处累加:
|
||||
|
||||
```js
|
||||
displayMs(clock, now) = clock.baseMs + (now - clock.baseRealMs) * clock.rate
|
||||
|
||||
setRate(clock, now, rate) // 倍率、暂停(0)、倒转(负)、小数都走这里
|
||||
clock.baseMs = displayMs(clock, now) // 先把当前显示时间固化 → 切倍率不跳变
|
||||
clock.baseRealMs = now
|
||||
clock.rate = rate
|
||||
if (rate !== 0) clock.lastRate = rate // 暂停时记住上次倍率,供「继续」用
|
||||
|
||||
shift(clock, now, deltaMs) // 快进/快退:与倍率完全解耦
|
||||
setTimeOfDay(clock, now, h, m, s) // 设定时间
|
||||
```
|
||||
|
||||
为什么这么写:如果用 `setInterval` 每次 `+1 秒`,倍率不是整数时会累积漂移,而且暂停/倒转都要另开分支。锚点式写法下,**倍率、暂停、倒转、快进是同一套机制的四个用法**,互不打架("先倒转、再暂停、再 +5 分钟"不会丢掉倒转的偏移)。
|
||||
|
||||
两个窗口各自用 `Date.now()`(同一台机器)跑同一个公式,画面 60fps 重算。
|
||||
|
||||
### 2.2 呈现时间层:跳转动画
|
||||
|
||||
`renderer/presented-time.js`。**数字和指针都从"呈现时间"推导**,而不是各做一套动画。
|
||||
|
||||
```js
|
||||
function jumpBetween(previous, next) { // 同一真实时刻,两个参数算出的目标值之差
|
||||
const at = next.baseRealMs;
|
||||
return targetAt(next, at) - targetAt(previous, at);
|
||||
}
|
||||
const animate = layer.snapshot !== null && snapshot.rate !== 0
|
||||
&& Math.abs(jump) > JUMP_THRESHOLD_MS; // 1500ms
|
||||
```
|
||||
|
||||
三个细节,**别在重构时弄丢**:
|
||||
|
||||
1. **改倍率不会被误判成跳变** —— 比较的是"同一真实时刻的两个目标值",而模型在切倍率时先把当前显示时间固化进基准,两个公式在这一刻必然相等;
|
||||
2. **数字和指针永远同步** —— 它们读同一个呈现时间,不会出现"数字到了、指针还在半路";
|
||||
3. **不会跨 12 点倒着转一圈** —— 动画的是**时间**,指针角度照样按正常公式从时间算(359°→1° 自然往前扫)。这是这类动画最经典的坑,用"动画时间而不是动画角度"直接绕开。
|
||||
|
||||
阈值 1500ms、缓动 0.35s `ease-out`。**动画没结束又来新跳变时,从当前呈现值重新出发**(不是跳回旧值重来)。
|
||||
|
||||
### 2.3 无边框窗口:拖动与改大小都是自己实现的
|
||||
|
||||
`frame: false` 之后,Windows 上**靠系统做边缘命中不可靠**,所以:
|
||||
|
||||
- **拖动移动**:CSS `-webkit-app-region: drag`。整块 `.stage` 是拖动区,**按钮和 8 条热区单独声明 `no-drag`** —— 否则按钮会被拖动区盖住、点不动。
|
||||
- **拖边缘改大小**:渲染进程热区接 `pointerdown/move`,用「按下瞬间的窗口 bounds + 鼠标位移」算新 bounds,走 IPC 让主进程 `setBounds`。几何计算在 `renderer/resize.js`(纯函数,17 条用例)。
|
||||
- `pointerdown` 时用 `sendSync` 取 bounds —— **必须在这一刻拿到精确基准**;
|
||||
- 顶到最小尺寸后继续拖,**被拖的那条边停住**,而不是把整个窗口推走(`resize.js` 里有专门处理与用例);
|
||||
- 主进程 `applyBounds` 再夹一次最小值并挡掉 `NaN`。
|
||||
|
||||
最小尺寸 **260×150**(`main.js` 的 `MIN_SIZE`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、环境与命令
|
||||
|
||||
```bash
|
||||
pnpm install # 依赖
|
||||
npm start # 起应用
|
||||
npm test # 152 条用例(纯逻辑 + DOM 桩)
|
||||
npm run selftest # GUI 自检:electron . --selftest,跑完出截图
|
||||
```
|
||||
|
||||
⚠️ **本机(Linux 无显示环境)跑 `npm start` 要 Xvfb**,而且 GPU 会刷 MESA 报错 —— 那是正常的。
|
||||
|
||||
---
|
||||
|
||||
## 四、改代码的几条约定
|
||||
|
||||
- **纯逻辑尽量往模块里放**(`src/clock-model.js`、`renderer/resize.js`、`renderer/presented-time.js`),页面只接线 —— 这样测试不用浏览器。
|
||||
- **改行为就同时改用例**,但**改断言之前先想清楚**:现有 152 条里有不少是钉边界的(例如"拖西边超过最小宽度时右边界不动"),它们是有意为之。
|
||||
- **数字输入相关的坑**:HTML 里 `type="number"` 的 `v-model`/取值在不同框架下会给 `number` 而不是字符串,本项目一律显式 `String(x)` 再用;**另一个坑是补零** —— `String(0)` 是 `"0"`,而时钟输入框按 `HH:MM:SS` 解析,所以必须 `pad2`(这个 bug 只在**凌晨 9 点前**才露出来,白天怎么测都是绿的)。
|
||||
- 注释写**为什么**,不写"这里做了什么"。
|
||||
|
||||
---
|
||||
|
||||
## 五、测试(152 条)
|
||||
|
||||
| 文件 | 条数 | 覆盖 |
|
||||
| --- | --- | --- |
|
||||
| `test/clock-model.test.js` | 26 | 走时:零漂移、倍率切换不跳变、暂停/继续、倒转、快进 |
|
||||
| `test/resize.test.js` | 17 | 改大小几何:四边四角、最小尺寸夹紧 |
|
||||
| `test/presented-time.test.js` | 30 | 跳变判定、阈值、重定向、暂停不缓动、换倍率不算跳变 |
|
||||
| `test/renderer-clock.dom-test.js` | 24 | 时钟窗口 DOM 桩:渲染、样式切换、热区接线 |
|
||||
| `test/renderer-clock-anim.dom-test.js` | 26 | 跳转动画全链路:参数下发 → 呈现时间 → 数字 + 指针 |
|
||||
| `test/renderer-control.dom-test.js` | 29 | 控制窗口:各按钮发出的调用、输入夹紧、置顶开关 |
|
||||
|
||||
---
|
||||
|
||||
## 六、构建与打包
|
||||
|
||||
### 6.1 Windows(在 Windows 构建机上)
|
||||
|
||||
**推荐在真 Windows 上打**,不要在这台 Linux 上跨平台打。踩过的坑见 6.3,能跑的流程是这样:
|
||||
|
||||
```bat
|
||||
:: 1) 依赖:必须 --ignore-scripts
|
||||
:: electron 的 postinstall 会去下载运行时,网络不通时会把整个 install 拖垮
|
||||
pnpm install --ignore-scripts
|
||||
|
||||
:: 2) 运行时:把 electron-v38.2.2-win32-x64.zip 手工解压到位
|
||||
:: ⚠️ node_modules/electron 是 pnpm 的**符号链接**,tar 拒绝"穿过符号链接"解压
|
||||
:: → 先解到临时目录,再 robocopy 进去
|
||||
mkdir D:\tmp\electron-dist
|
||||
cd /d D:\tmp\electron-dist && tar -xf D:\tmp\electron-win-x64.zip
|
||||
robocopy D:\tmp\electron-dist <项目>\node_modules\electron\dist /E
|
||||
|
||||
:: 3) 出包:直接调 electron-builder 的 CLI(`pnpm run` 会先做依赖检查、可能 EPERM)
|
||||
:: 用 --config.electronDist 明确告诉它运行时在哪,别让它走下载分支
|
||||
node node_modules\electron-builder\out\cli\cli.js --win --x64 ^
|
||||
--config.electronDist=D:\tmp\electron-dist
|
||||
|
||||
:: 4) 压缩:electron-builder 的 zip target 会去下 7zip 工具(见 6.3),自己压更稳
|
||||
cd dist && tar -a -c -f ClockLab-1.0.0-win-x64.zip win-unpacked
|
||||
```
|
||||
|
||||
产物:`dist\win-unpacked\`(目录版)+ 自己压的 zip。
|
||||
|
||||
### 6.2 Linux ARM64(在本机打)
|
||||
|
||||
**手工拼**,不要用 electron-builder:
|
||||
|
||||
```bash
|
||||
# 1) 复制 Electron 运行时(本机是 arm64,node_modules/electron/dist 就是对的版本)
|
||||
cp -a node_modules/electron/dist/. dist/ClockLab-1.0.0-linux-arm64/
|
||||
mv dist/ClockLab-1.0.0-linux-arm64/electron dist/ClockLab-1.0.0-linux-arm64/clocklab
|
||||
|
||||
# 2) 应用代码进 resources/app/
|
||||
mkdir -p dist/ClockLab-1.0.0-linux-arm64/resources/app
|
||||
cp main.js preload.js package.json dist/ClockLab-1.0.0-linux-arm64/resources/app/
|
||||
cp -a src renderer dist/ClockLab-1.0.0-linux-arm64/resources/app/
|
||||
|
||||
# 3) 打 tar.gz
|
||||
tar -C dist -czf dist/ClockLab-1.0.0-linux-arm64.tar.gz ClockLab-1.0.0-linux-arm64
|
||||
```
|
||||
|
||||
**验证**:解压后 `./clocklab` 直接能跑(本机就是 arm64,Xvfb 下起得来即说明链接与资源都对)。
|
||||
|
||||
### 6.3 踩过的坑(**看这一节能省几个小时**)
|
||||
|
||||
**① electron-builder 在"需要下载"这条分支上必崩**
|
||||
|
||||
```
|
||||
⨯ Cannot read properties of undefined (reading 'ReadWrite')
|
||||
at resolveCacheMode (app-builder-lib/src/util/electronGet.ts:131)
|
||||
```
|
||||
根因:`ElectronDownloadCacheMode` 在运行环境里是 `undefined`,代码却直接读它的 `.ReadWrite`。**26.15.3 与 26.16.1 都有**,Windows 与 Linux 都复现。触发点是"它认为需要下载 Electron 运行时/7zip 工具" —— 所以:
|
||||
- 用 `--config.electronDist=<运行时目录>` 让它**不需要下载**;
|
||||
- 打 zip 时它仍要 7zip 工具,那条路也会崩 → **压缩自己来**。
|
||||
|
||||
**② 手工放运行时还不够,还得让它"知道"运行时在哪**
|
||||
- electron 包装里的 `path.txt` **不是** electron-builder 的判据(它走 `@electron/get` 的缓存机制);
|
||||
- 直接给它 `electronDist` 才是正解。
|
||||
|
||||
**③ 网络:这台环境从官方源下 Electron 很难**
|
||||
- 直连 GitHub:`%TEMP%` 里会堆一排 `electron-download-*` 重试目录直到失败;
|
||||
- npmmirror 镜像:`ELECTRON_MIRROR` 可用,但它在拼 `SHASUMS256.txt` 时可能 404;
|
||||
- gh 加速站(`gh-proxy.com`):实测 403;
|
||||
- **最稳**:**在能上网的机器上把 zip 下好**(`https://npmmirror.com/mirrors/electron/38.2.2/electron-v38.2.2-<平台>.zip`),再传过去手工放。
|
||||
|
||||
**④ pnpm 11 的 `strictDepBuilds`**
|
||||
只要有依赖的构建脚本被跳过,`pnpm install` 会**直接以退出码 1 失败**(`ERR_PNPM_IGNORED_BUILDS`),而 electron 的 postinstall 正是下载运行时那一步。放行写在 `pnpm-workspace.yaml` 的 `allowBuilds`(`package.json` 的 `pnpm` 字段与 `.npmrc` 的 `onlyBuiltDependencies` 在 pnpm 11 已不生效),**这个文件别删**。
|
||||
|
||||
**⑤ `pnpm run` 会先做依赖状态检查**
|
||||
`pnpm run dist:win` 可能触发一次 `install`,在 `node_modules` 被动过的目录上会 `ERR_PNPM_EPERM`(rename 失败)。**直接调 CLI**(`node node_modules/electron-builder/out/cli/cli.js …`)可绕过。
|
||||
|
||||
---
|
||||
|
||||
## 七、已知可以再做的
|
||||
|
||||
- **Windows 的 portable 单文件 exe** 还没做成(卡在 6.3 ① 的 7zip 工具那条路)—— 绿色版 zip 功能等价,只是多一步解压;
|
||||
- **飞腾平台(LoongArch 不是 ARM64)不支持** —— 本项目产出的是 ARM64,龙芯需要另找运行时;
|
||||
- 表盘目前没有"刻度数字"(只有刻度线与指针)。
|
||||
@@ -1,3 +1,109 @@
|
||||
# ClockLab
|
||||
# ClockLab · 可调速桌面时钟
|
||||
|
||||
一个简单的本地时钟计时器
|
||||
一个用于**演练、演示、排练**的小工具:一个**可以任意调速的时钟**(走快、走慢、暂停、**倒着走**、一键快进),配上随时可开的**满屏时钟窗口**。
|
||||
|
||||
典型用法:现场排练时把时钟投到第二块屏上,需要"跳过 30 分钟"或"把时间倒回去重来一遍"时,不用真的等 —— 在控制窗口点一下就行。
|
||||
|
||||
---
|
||||
|
||||
## 一、两个窗口
|
||||
|
||||
程序启动后只出来**一个**窗口:
|
||||
|
||||
- **控制窗口** —— 普通窗口,所有操作都在这里;
|
||||
- **时钟窗口** —— **默认不出现**,由控制窗口的「打开时钟窗口」按钮创建/关闭。
|
||||
|
||||
**时钟窗口的特点**:无标题栏、无工具栏、无边框 —— 投屏时不会露出窗口装饰。它**可以拖动移动**(按住空白处拖),**可以拖四条边和四个角改变大小**(最小 260×150)。
|
||||
|
||||
---
|
||||
|
||||
## 二、怎么操作
|
||||
|
||||
### 时钟窗口那一块
|
||||
|
||||
| 功能 | 说明 |
|
||||
| --- | --- |
|
||||
| **打开 / 关闭时钟窗口** | 按钮文案会跟着状态变;关掉随时能再开 |
|
||||
| **时钟窗口始终置顶** | 让它压在所有窗口之上(现场投屏基本都要开) |
|
||||
| **控制窗口始终置顶** | 单独一个开关,两个窗口互不影响 |
|
||||
|
||||
> 提醒:时钟窗口用的是**比普通置顶更硬**的级别,**可能会盖住任务栏**。如果你希望它"温柔"一点,见《DEVELOP.md》里的说明。
|
||||
|
||||
### 显示样式
|
||||
|
||||
- **纯数字** —— 大字号电子钟,`HH:MM:SS` 加日期;
|
||||
- **圆形表盘** —— 带时针 / 分针 / 秒针的表盘。
|
||||
|
||||
切换**立即生效**,不用重开窗口。
|
||||
|
||||
### 时间与流速
|
||||
|
||||
| 功能 | 说明 |
|
||||
| --- | --- |
|
||||
| **设定时间** | 填时 / 分 / 秒,点应用,时钟立刻跳到那个时间 |
|
||||
| **填入当前显示时间** | 把输入框一键填成时钟**现在显示**的时间(改之前先看一下当前值) |
|
||||
| **倍率** | 支持 **正数、负数、0、小数**:`1` 正常、`2` 两倍速、`-1` **倒着走**、`0` 暂停、`0.5` 半速 |
|
||||
| **暂停 / 继续** | 暂停后按钮会变成「**继续(回到 ×N)**」,N 是暂停前的倍率,点一下按原速度接着走 |
|
||||
| **快进** | `+5 秒` / `+1 分钟` / `+5 分钟` / `+10 分钟` / `+30 分钟`,点了立刻往前跳(与当前倍率不冲突,倒着走时点快进也是往前跳) |
|
||||
|
||||
几个组合起来的效果:
|
||||
|
||||
- 想现场"重新来一遍":倍率填 `-1`,时钟就倒着走;到点了点「暂停」,再点 `+30 分钟` 往前调。
|
||||
- 想演示"时间飞逝":倍率填 `10`,秒针会飞快转。
|
||||
- 只想让时钟停在那儿:点「暂停」。
|
||||
|
||||
### 跳转时的动画
|
||||
|
||||
**快进、设定时间、继续**这些**一次性跳变**会播放动画:数字滚到新值、表盘指针扫过去(约 0.35 秒)。
|
||||
|
||||
**正常走时、倒转、高倍率不加动画** —— 那时数字本来就在飞快变化,再加动画只会糊成一团。跳变幅度小于 1.5 秒也直接跳(免得正常走时每秒都抖一下)。
|
||||
|
||||
---
|
||||
|
||||
## 三、怎么运行
|
||||
|
||||
### Windows
|
||||
|
||||
解压 `ClockLab-1.0.0-win-x64.zip`,双击里面的 **`ClockLab.exe`**。绿色版,不写注册表、不需要安装。
|
||||
|
||||
### Linux(ARM64)
|
||||
|
||||
```bash
|
||||
tar -xzf ClockLab-1.0.0-linux-arm64.tar.gz # 解压(约 290 MB)
|
||||
cd ClockLab-1.0.0-linux-arm64
|
||||
./clocklab
|
||||
```
|
||||
|
||||
> ⚠️ **解压前建议先校验**:`sha256sum ClockLab-1.0.0-linux-arm64.tar.gz` 对着交付时给的哈希看一眼。传输中断过的话,`tar` 会报 `异常的 EOF`,那就是没传完(不是包坏了)。
|
||||
|
||||
---
|
||||
|
||||
## 四、常见问题
|
||||
|
||||
**界面黑屏 / 花屏 / 一闪就退(国产平台常见)**
|
||||
|
||||
多半是显卡驱动下的硬件加速不兼容,用这两个参数之一启动:
|
||||
|
||||
```bash
|
||||
./clocklab --disable-gpu # 关掉硬件加速(最常用)
|
||||
./clocklab --in-process-gpu # 或者让 GPU 跑在主进程里
|
||||
```
|
||||
|
||||
**窗口拖不动 / 改不了大小**
|
||||
|
||||
先确认拖的是**时钟窗口的空白处**(按钮和边缘热区不在拖动区内);改大小要把鼠标移到**四条边或四个角**上(光标会变成双向箭头)。
|
||||
|
||||
**文字太小 / 太大**
|
||||
|
||||
直接拖时钟窗口的边缘改变尺寸,数字和表盘会跟着缩放。
|
||||
|
||||
---
|
||||
|
||||
## 五、这个项目在哪
|
||||
|
||||
源码与构建说明见同目录的 **[DEVELOP.md](./DEVELOP.md)**。已经打好的包在 `dist/`:
|
||||
|
||||
```
|
||||
dist/ClockLab-1.0.0-win-x64.zip Windows x64 绿色版
|
||||
dist/ClockLab-1.0.0-linux-arm64.tar.gz Linux ARM64(飞腾 D2000 等)
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,243 @@
|
||||
'use strict';
|
||||
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const path = require('node:path');
|
||||
const clockModel = require('./src/clock-model');
|
||||
|
||||
const RENDERER_DIR = path.join(__dirname, 'renderer');
|
||||
const PRELOAD = path.join(__dirname, 'preload.js');
|
||||
const SELF_TEST = process.argv.includes('--selftest');
|
||||
|
||||
const MIN_SIZE = { width: 260, height: 150 };
|
||||
const DEFAULT_CLOCK_SIZE = { width: 520, height: 280 };
|
||||
|
||||
const state = {
|
||||
clock: clockModel.create(Date.now()),
|
||||
style: 'digital',
|
||||
alwaysOnTop: { clock: true, control: false }
|
||||
};
|
||||
|
||||
let controlWindow = null;
|
||||
let clockWindow = null;
|
||||
|
||||
function isAlive(win) {
|
||||
return Boolean(win) && !win.isDestroyed();
|
||||
}
|
||||
|
||||
function publicState() {
|
||||
return {
|
||||
style: state.style,
|
||||
baseMs: state.clock.baseMs,
|
||||
baseRealMs: state.clock.baseRealMs,
|
||||
rate: state.clock.rate,
|
||||
lastRate: state.clock.lastRate,
|
||||
paused: state.clock.rate === 0,
|
||||
clockWindowOpen: isAlive(clockWindow),
|
||||
alwaysOnTop: { ...state.alwaysOnTop },
|
||||
serverNow: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
const snapshot = publicState();
|
||||
for (const win of [controlWindow, clockWindow]) {
|
||||
if (isAlive(win)) win.webContents.send('state', snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
const WEB_PREFERENCES = {
|
||||
preload: PRELOAD,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
};
|
||||
|
||||
function createControlWindow() {
|
||||
controlWindow = new BrowserWindow({
|
||||
width: 920,
|
||||
height: 720,
|
||||
minWidth: 780,
|
||||
minHeight: 560,
|
||||
title: 'ClockLab 控制台',
|
||||
backgroundColor: '#12151c',
|
||||
autoHideMenuBar: true,
|
||||
show: false,
|
||||
webPreferences: WEB_PREFERENCES
|
||||
});
|
||||
|
||||
controlWindow.setAlwaysOnTop(state.alwaysOnTop.control);
|
||||
controlWindow.loadFile(path.join(RENDERER_DIR, 'control.html'));
|
||||
controlWindow.once('ready-to-show', () => controlWindow.show());
|
||||
controlWindow.on('closed', () => {
|
||||
controlWindow = null;
|
||||
// 控制窗口是唯一常驻入口,它被关掉就等于用户要退出整个程序(时钟窗口一起走)
|
||||
if (!SELF_TEST) app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
function createClockWindow() {
|
||||
if (isAlive(clockWindow)) return clockWindow;
|
||||
|
||||
clockWindow = new BrowserWindow({
|
||||
width: DEFAULT_CLOCK_SIZE.width,
|
||||
height: DEFAULT_CLOCK_SIZE.height,
|
||||
minWidth: MIN_SIZE.width,
|
||||
minHeight: MIN_SIZE.height,
|
||||
frame: false,
|
||||
resizable: true,
|
||||
movable: true,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
backgroundColor: '#05070c',
|
||||
title: 'ClockLab 时钟',
|
||||
show: false,
|
||||
webPreferences: WEB_PREFERENCES
|
||||
});
|
||||
|
||||
// screen-saver 级别:现场投影时希望它压在其他全屏窗口之上,普通 alwaysOnTop 在 Windows 上不够硬
|
||||
clockWindow.setAlwaysOnTop(state.alwaysOnTop.clock, 'screen-saver');
|
||||
clockWindow.loadFile(path.join(RENDERER_DIR, 'clock.html'));
|
||||
clockWindow.once('ready-to-show', () => {
|
||||
clockWindow.show();
|
||||
broadcast();
|
||||
});
|
||||
clockWindow.on('closed', () => {
|
||||
clockWindow = null;
|
||||
broadcast();
|
||||
});
|
||||
|
||||
return clockWindow;
|
||||
}
|
||||
|
||||
function closeClockWindow() {
|
||||
if (isAlive(clockWindow)) clockWindow.close();
|
||||
}
|
||||
|
||||
// 自定义拖边缘改大小最终落到这里:夹住最小尺寸,避免渲染进程算出 NaN 时把窗口搞坏
|
||||
function applyBounds(next) {
|
||||
if (!isAlive(clockWindow)) return null;
|
||||
const current = clockWindow.getBounds();
|
||||
const bounds = {
|
||||
x: Number.isFinite(next.x) ? Math.round(next.x) : current.x,
|
||||
y: Number.isFinite(next.y) ? Math.round(next.y) : current.y,
|
||||
width: Math.max(MIN_SIZE.width, Number.isFinite(next.width) ? Math.round(next.width) : current.width),
|
||||
height: Math.max(MIN_SIZE.height, Number.isFinite(next.height) ? Math.round(next.height) : current.height)
|
||||
};
|
||||
clockWindow.setBounds(bounds);
|
||||
return clockWindow.getBounds();
|
||||
}
|
||||
|
||||
// 所有会改动时钟/窗口的动作都集中在这里:IPC 和自检脚本共用同一条代码路径,
|
||||
// 自检才不会测到一份「和线上不一样」的逻辑。
|
||||
const actions = {
|
||||
setRate(rate) {
|
||||
const value = Number(rate);
|
||||
if (Number.isFinite(value)) {
|
||||
clockModel.setRate(state.clock, Date.now(), value);
|
||||
broadcast();
|
||||
}
|
||||
return publicState();
|
||||
},
|
||||
|
||||
togglePause() {
|
||||
const now = Date.now();
|
||||
const target = state.clock.rate === 0 ? state.clock.lastRate || 1 : 0;
|
||||
clockModel.setRate(state.clock, now, target);
|
||||
broadcast();
|
||||
return publicState();
|
||||
},
|
||||
|
||||
fastForward(deltaMs) {
|
||||
const delta = Number(deltaMs);
|
||||
if (Number.isFinite(delta)) {
|
||||
clockModel.shift(state.clock, Date.now(), delta);
|
||||
broadcast();
|
||||
}
|
||||
return publicState();
|
||||
},
|
||||
|
||||
setTime(h, m, s) {
|
||||
const parts = [Number(h), Number(m), Number(s)];
|
||||
if (parts.every(Number.isFinite)) {
|
||||
clockModel.setTimeOfDay(state.clock, Date.now(), parts[0], parts[1], parts[2]);
|
||||
broadcast();
|
||||
}
|
||||
return publicState();
|
||||
},
|
||||
|
||||
setStyle(style) {
|
||||
if (style === 'digital' || style === 'analog') {
|
||||
state.style = style;
|
||||
broadcast();
|
||||
}
|
||||
return publicState();
|
||||
},
|
||||
|
||||
setAlwaysOnTop(target, value) {
|
||||
const key = target === 'control' ? 'control' : 'clock';
|
||||
state.alwaysOnTop[key] = Boolean(value);
|
||||
const win = key === 'clock' ? clockWindow : controlWindow;
|
||||
// 时钟窗口用 screen-saver 级别,普通置顶在 Windows 上会被某些全屏窗口压住
|
||||
if (isAlive(win)) win.setAlwaysOnTop(state.alwaysOnTop[key], key === 'clock' ? 'screen-saver' : 'normal');
|
||||
broadcast();
|
||||
return publicState();
|
||||
},
|
||||
|
||||
toggleClockWindow() {
|
||||
if (isAlive(clockWindow)) closeClockWindow();
|
||||
else createClockWindow();
|
||||
broadcast();
|
||||
return publicState();
|
||||
},
|
||||
|
||||
closeClockWindow() {
|
||||
closeClockWindow();
|
||||
return publicState();
|
||||
}
|
||||
};
|
||||
|
||||
function registerIpc() {
|
||||
ipcMain.handle('state:get', () => publicState());
|
||||
ipcMain.handle('rate:set', (_event, rate) => actions.setRate(rate));
|
||||
ipcMain.handle('pause:toggle', () => actions.togglePause());
|
||||
ipcMain.handle('fast-forward', (_event, deltaMs) => actions.fastForward(deltaMs));
|
||||
ipcMain.handle('time:set', (_event, payload) => actions.setTime(payload?.h, payload?.m, payload?.s));
|
||||
ipcMain.handle('style:set', (_event, style) => actions.setStyle(style));
|
||||
ipcMain.handle('always-on-top:set', (_event, payload) => actions.setAlwaysOnTop(payload?.target, payload?.value));
|
||||
ipcMain.handle('clock-window:toggle', () => actions.toggleClockWindow());
|
||||
ipcMain.handle('clock-window:close', () => actions.closeClockWindow());
|
||||
ipcMain.handle('control-window:close', () => {
|
||||
if (isAlive(controlWindow)) controlWindow.close();
|
||||
return publicState();
|
||||
});
|
||||
|
||||
ipcMain.on('bounds:get-sync', (event) => {
|
||||
event.returnValue = isAlive(clockWindow) ? clockWindow.getBounds() : null;
|
||||
});
|
||||
|
||||
ipcMain.on('bounds:set', (_event, bounds) => {
|
||||
if (bounds && typeof bounds === 'object') applyBounds(bounds);
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc();
|
||||
createControlWindow();
|
||||
|
||||
if (SELF_TEST) {
|
||||
require('./src/selftest')({
|
||||
app,
|
||||
state,
|
||||
clockModel,
|
||||
actions,
|
||||
publicState,
|
||||
applyBounds,
|
||||
createClockWindow,
|
||||
getClockWindow: () => clockWindow,
|
||||
getControlWindow: () => controlWindow
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (!SELF_TEST) app.quit();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "clocklab",
|
||||
"version": "1.0.0",
|
||||
"description": "ClockLab - 可调速桌面时钟(数字/表盘,支持倍率、暂停、倒转、快进)",
|
||||
"author": "AskaEth",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"selftest": "electron . --selftest",
|
||||
"test": "node test/clock-model.test.js && node test/resize.test.js && node test/presented-time.test.js && node test/renderer-clock.dom-test.js && node test/renderer-clock-anim.dom-test.js && node test/renderer-control.dom-test.js",
|
||||
"dist:win": "electron-builder --win --x64",
|
||||
"dist:linux": "electron-builder --linux --arm64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "38.2.2",
|
||||
"electron-builder": "26.15.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.askasoft.clocklab",
|
||||
"productName": "ClockLab",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"src/**/*",
|
||||
"renderer/**/*",
|
||||
"!**/*.md"
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"artifactName": "ClockLab-${version}-${arch}.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "ClockLab-${version}-portable-x64.exe"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "build/icon.png",
|
||||
"category": "Utility",
|
||||
"artifactName": "ClockLab-${version}-${arch}.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "tar.gz",
|
||||
"arch": [
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2413
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
# pnpm 11 的 strictDepBuilds 默认为 true:只要有依赖的构建脚本被跳过,pnpm install 就直接以退出码 1 结束。
|
||||
# electron 的 postinstall 正是「下载运行时」那一步,必须放行,否则构建机上装依赖会中途失败。
|
||||
# (pnpm 11 起设置从 package.json 的 pnpm 字段和 .npmrc 搬到这里,且旧键 onlyBuiltDependencies 已不再生效。)
|
||||
allowBuilds:
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 只把需要的动作暴露出去,渲染进程拿不到 ipcRenderer 本体,也不开 nodeIntegration
|
||||
contextBridge.exposeInMainWorld('clockApi', {
|
||||
getState: () => ipcRenderer.invoke('state:get'),
|
||||
onState: (cb) => ipcRenderer.on('state', (_event, snapshot) => cb(snapshot)),
|
||||
setRate: (rate) => ipcRenderer.invoke('rate:set', rate),
|
||||
togglePause: () => ipcRenderer.invoke('pause:toggle'),
|
||||
fastForward: (deltaMs) => ipcRenderer.invoke('fast-forward', deltaMs),
|
||||
setTime: (h, m, s) => ipcRenderer.invoke('time:set', { h, m, s }),
|
||||
setStyle: (style) => ipcRenderer.invoke('style:set', style),
|
||||
setAlwaysOnTop: (target, value) => ipcRenderer.invoke('always-on-top:set', { target, value }),
|
||||
toggleClockWindow: () => ipcRenderer.invoke('clock-window:toggle'),
|
||||
closeClockWindow: () => ipcRenderer.invoke('clock-window:close'),
|
||||
closeControlWindow: () => ipcRenderer.invoke('control-window:close'),
|
||||
// 调整大小要在 mousedown 那一刻拿到精确的窗口位置,异步往返会让第一条 mousemove 用错基准
|
||||
getBoundsSync: () => ipcRenderer.sendSync('bounds:get-sync'),
|
||||
setBounds: (bounds) => ipcRenderer.send('bounds:set', bounds)
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
:root {
|
||||
--bg: #05070c;
|
||||
--fg: #f2f6ff;
|
||||
--accent: #4fd1c5;
|
||||
--muted: #7d8aa5;
|
||||
--handle: 10px;
|
||||
--corner: 18px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
/* 拖动区:无边框窗口靠它实现「按住任意空白处移动窗口」 */
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(120% 140% at 50% 0%, #131a29 0%, var(--bg) 70%);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.digital-time {
|
||||
font-family: Consolas, 'Cascadia Mono', 'DejaVu Sans Mono', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--fg);
|
||||
text-shadow: 0 0 28px rgba(79, 209, 197, 0.35);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.digital-date {
|
||||
margin-top: 0.7em;
|
||||
/* 用 vmin 跟着窗口缩放,投到大屏时日期才不会小得看不见 */
|
||||
font-size: clamp(13px, 5.5vmin, 64px);
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.analog-view {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dial {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.dial-face {
|
||||
fill: #0b1020;
|
||||
stroke: #26314a;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.tick {
|
||||
stroke: #46577a;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.tick-major {
|
||||
stroke: #8fa4cc;
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.numeral {
|
||||
fill: #8fa4cc;
|
||||
font-family: Consolas, 'DejaVu Sans Mono', monospace;
|
||||
font-size: 18px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
|
||||
.hand {
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.hand-hour {
|
||||
stroke: #e6ecff;
|
||||
stroke-width: 7;
|
||||
}
|
||||
|
||||
.hand-minute {
|
||||
stroke: #c3cfe8;
|
||||
stroke-width: 4.5;
|
||||
}
|
||||
|
||||
.hand-second {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.hand-cap {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.06em;
|
||||
color: #0b1020;
|
||||
background: var(--accent);
|
||||
opacity: 0.92;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.badge[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 1;
|
||||
font-size: 18px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #9fb0cc;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
/* 关键:按钮必须退出拖动区,否则在无边框窗口里点不动 */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.stage:hover .close-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #fff;
|
||||
background: #e0555a;
|
||||
}
|
||||
|
||||
/* 自定义改大小的边缘热区:放在 stage 之外,天然不属于拖动区,鼠标事件能正常到达 */
|
||||
.rz {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.rz-n,
|
||||
.rz-s {
|
||||
left: var(--corner);
|
||||
right: var(--corner);
|
||||
height: var(--handle);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.rz-w,
|
||||
.rz-e {
|
||||
top: var(--corner);
|
||||
bottom: var(--corner);
|
||||
width: var(--handle);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.rz-n { top: 0; }
|
||||
.rz-s { bottom: 0; }
|
||||
.rz-w { left: 0; }
|
||||
.rz-e { right: 0; }
|
||||
|
||||
.rz-nw,
|
||||
.rz-ne,
|
||||
.rz-sw,
|
||||
.rz-se {
|
||||
width: var(--corner);
|
||||
height: var(--corner);
|
||||
}
|
||||
|
||||
.rz-nw { top: 0; left: 0; cursor: nwse-resize; }
|
||||
.rz-se { bottom: 0; right: 0; cursor: nwse-resize; }
|
||||
.rz-ne { top: 0; right: 0; cursor: nesw-resize; }
|
||||
.rz-sw { bottom: 0; left: 0; cursor: nesw-resize; }
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'" />
|
||||
<title>ClockLab 时钟</title>
|
||||
<link rel="stylesheet" href="clock.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- stage 整体是拖动区;里面的按钮/角标再单独声明 no-drag,避免被拖动区吞掉点击 -->
|
||||
<main id="stage" class="stage">
|
||||
<div id="digitalView" class="view digital-view">
|
||||
<div id="digitalTime" class="digital-time">00:00:00</div>
|
||||
<div id="digitalDate" class="digital-date"></div>
|
||||
</div>
|
||||
|
||||
<div id="analogView" class="view analog-view" hidden>
|
||||
<svg id="dial" class="dial" viewBox="-100 -100 200 200" aria-hidden="true">
|
||||
<circle class="dial-face" cx="0" cy="0" r="94"></circle>
|
||||
<g id="ticks"></g>
|
||||
<g id="numerals"></g>
|
||||
<line id="hourHand" class="hand hand-hour" x1="0" y1="10" x2="0" y2="-50"></line>
|
||||
<line id="minuteHand" class="hand hand-minute" x1="0" y1="14" x2="0" y2="-76"></line>
|
||||
<line id="secondHand" class="hand hand-second" x1="0" y1="20" x2="0" y2="-86"></line>
|
||||
<circle class="hand-cap" cx="0" cy="0" r="6"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div id="badge" class="badge" hidden></div>
|
||||
<button id="closeBtn" class="close-btn" title="关闭时钟窗口">×</button>
|
||||
</main>
|
||||
|
||||
<div class="rz rz-n" data-dir="n"></div>
|
||||
<div class="rz rz-s" data-dir="s"></div>
|
||||
<div class="rz rz-w" data-dir="w"></div>
|
||||
<div class="rz rz-e" data-dir="e"></div>
|
||||
<div class="rz rz-nw" data-dir="nw"></div>
|
||||
<div class="rz rz-ne" data-dir="ne"></div>
|
||||
<div class="rz rz-sw" data-dir="sw"></div>
|
||||
<div class="rz rz-se" data-dir="se"></div>
|
||||
|
||||
<script src="resize.js"></script>
|
||||
<script src="presented-time.js"></script>
|
||||
<script src="clock.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,190 @@
|
||||
'use strict';
|
||||
|
||||
const api = window.clockApi;
|
||||
|
||||
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
|
||||
const stage = document.getElementById('stage');
|
||||
const digitalView = document.getElementById('digitalView');
|
||||
const digitalTime = document.getElementById('digitalTime');
|
||||
const digitalDate = document.getElementById('digitalDate');
|
||||
const analogView = document.getElementById('analogView');
|
||||
const badge = document.getElementById('badge');
|
||||
const closeBtn = document.getElementById('closeBtn');
|
||||
const hands = {
|
||||
hour: document.getElementById('hourHand'),
|
||||
minute: document.getElementById('minuteHand'),
|
||||
second: document.getElementById('secondHand')
|
||||
};
|
||||
|
||||
// 主进程下发的走时参数:目标时间 = baseMs + (真实时间 - baseRealMs) × rate
|
||||
let snapshot = { baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, style: 'digital' };
|
||||
|
||||
// 呈现时间层:跳变时由它把「目标时间」缓动过来(数字滚、指针扫都是它的副产品)
|
||||
const presented = presentedTime.create();
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function buildDial() {
|
||||
const ticks = document.getElementById('ticks');
|
||||
const numerals = document.getElementById('numerals');
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
const major = i % 5 === 0;
|
||||
const angle = (i * 6 * Math.PI) / 180;
|
||||
const outer = 90;
|
||||
const inner = major ? 78 : 85;
|
||||
const line = document.createElementNS(ns, 'line');
|
||||
line.setAttribute('x1', String(Math.sin(angle) * inner));
|
||||
line.setAttribute('y1', String(-Math.cos(angle) * inner));
|
||||
line.setAttribute('x2', String(Math.sin(angle) * outer));
|
||||
line.setAttribute('y2', String(-Math.cos(angle) * outer));
|
||||
line.setAttribute('class', major ? 'tick tick-major' : 'tick');
|
||||
ticks.appendChild(line);
|
||||
}
|
||||
for (let h = 1; h <= 12; h += 1) {
|
||||
const angle = (h * 30 * Math.PI) / 180;
|
||||
const text = document.createElementNS(ns, 'text');
|
||||
text.setAttribute('x', String(Math.sin(angle) * 62));
|
||||
text.setAttribute('y', String(-Math.cos(angle) * 62));
|
||||
text.setAttribute('class', 'numeral');
|
||||
text.textContent = String(h);
|
||||
numerals.appendChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
// 数字时钟用等宽 tabular 字体,字符宽度与字号成正比,所以量一次比例就能按窗口尺寸精确铺满
|
||||
let fontRatio = null;
|
||||
|
||||
function measureFontRatio() {
|
||||
const previous = digitalTime.style.fontSize;
|
||||
digitalTime.style.fontSize = '100px';
|
||||
const rect = digitalTime.getBoundingClientRect();
|
||||
fontRatio = { width: rect.width / 100, height: rect.height / 100 };
|
||||
digitalTime.style.fontSize = previous;
|
||||
}
|
||||
|
||||
function layoutDigital() {
|
||||
if (snapshot.style !== 'digital' || !fontRatio || !fontRatio.width || !fontRatio.height) return;
|
||||
const box = digitalView.getBoundingClientRect();
|
||||
const size = Math.min((box.width * 0.9) / fontRatio.width, (box.height * 0.6) / fontRatio.height);
|
||||
digitalTime.style.fontSize = `${Math.max(12, Math.floor(size))}px`;
|
||||
}
|
||||
|
||||
function renderDigital(ms) {
|
||||
const date = new Date(ms);
|
||||
digitalTime.textContent = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
||||
digitalDate.textContent = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${WEEKDAYS[date.getDay()]}`;
|
||||
}
|
||||
|
||||
function renderAnalog(ms) {
|
||||
const date = new Date(ms);
|
||||
// 秒针按毫秒连续走,这样在高倍率/负倍率下看起来是平滑扫过而不是一格一格跳
|
||||
const seconds = date.getSeconds() + date.getMilliseconds() / 1000;
|
||||
const minutes = date.getMinutes() + seconds / 60;
|
||||
const hours = (date.getHours() % 12) + minutes / 60;
|
||||
hands.hour.setAttribute('transform', `rotate(${hours * 30} 0 0)`);
|
||||
hands.minute.setAttribute('transform', `rotate(${minutes * 6} 0 0)`);
|
||||
hands.second.setAttribute('transform', `rotate(${seconds * 6} 0 0)`);
|
||||
}
|
||||
|
||||
function updateBadge() {
|
||||
const rate = snapshot.rate;
|
||||
if (rate === 1) {
|
||||
badge.hidden = true;
|
||||
return;
|
||||
}
|
||||
badge.hidden = false;
|
||||
if (rate === 0) badge.textContent = '暂停';
|
||||
else if (rate < 0) badge.textContent = `倒转 ×${rate}`;
|
||||
else badge.textContent = `×${rate}`;
|
||||
}
|
||||
|
||||
function applyStyle() {
|
||||
const analog = snapshot.style === 'analog';
|
||||
digitalView.hidden = analog;
|
||||
analogView.hidden = !analog;
|
||||
if (!analog) layoutDigital();
|
||||
}
|
||||
|
||||
function frame() {
|
||||
// 数字与表盘都只从「呈现时间」推导:平时它等于目标时间,跳变后的 0.35 秒里它是缓动中的中间值
|
||||
const ms = presentedTime.valueAt(presented, Date.now());
|
||||
if (snapshot.style === 'analog') renderAnalog(ms);
|
||||
else renderDigital(ms);
|
||||
// 自检脚本靠这个读数核对「主进程模型 → IPC → 画面」是否一致
|
||||
document.body.dataset.displayMs = String(Math.round(ms));
|
||||
document.body.dataset.style = snapshot.style;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
// —— 自定义拖边缘改大小 ——
|
||||
// 无边框窗口在 Windows 上边缘命中不稳定,所以完全自己做:8 条热区接 pointer 事件,
|
||||
// 用按下瞬间的窗口 bounds 加鼠标位移算出新 bounds,交给主进程 setBounds。
|
||||
let resize = null;
|
||||
|
||||
function applyResize(screenX, screenY) {
|
||||
const next = computeBounds(
|
||||
resize.bounds,
|
||||
resize.dir,
|
||||
screenX - resize.startX,
|
||||
screenY - resize.startY
|
||||
);
|
||||
api.setBounds(next);
|
||||
}
|
||||
|
||||
for (const handle of document.querySelectorAll('.rz')) {
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
const bounds = api.getBoundsSync();
|
||||
if (!bounds) return;
|
||||
resize = { dir: handle.dataset.dir, startX: event.screenX, startY: event.screenY, bounds };
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
handle.addEventListener('pointermove', (event) => {
|
||||
if (!resize) return;
|
||||
applyResize(event.screenX, event.screenY);
|
||||
});
|
||||
|
||||
const stop = (event) => {
|
||||
if (!resize) return;
|
||||
resize = null;
|
||||
try {
|
||||
handle.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
/* 指针已经失效时 release 会抛,忽略即可 */
|
||||
}
|
||||
};
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', stop);
|
||||
}
|
||||
|
||||
closeBtn.addEventListener('click', () => api.closeClockWindow());
|
||||
|
||||
// 万一鼠标松开的事件丢了(拖拽中窗口失焦等),不能让下一次移动继续改大小
|
||||
window.addEventListener('blur', () => {
|
||||
resize = null;
|
||||
});
|
||||
|
||||
// 主进程每次下发参数都走这里:先让呈现时间层判断这次要不要缓动,再更新画面
|
||||
function applyState(next) {
|
||||
presentedTime.accept(presented, Date.now(), next);
|
||||
snapshot = next;
|
||||
applyStyle();
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
api.onState(applyState);
|
||||
|
||||
new ResizeObserver(() => layoutDigital()).observe(stage);
|
||||
|
||||
buildDial();
|
||||
measureFontRatio();
|
||||
api.getState().then((initial) => {
|
||||
applyState(initial);
|
||||
frame();
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
:root {
|
||||
--bg: #12151c;
|
||||
--panel: #1a1f2b;
|
||||
--panel-2: #222838;
|
||||
--line: #2c3446;
|
||||
--fg: #e8edf8;
|
||||
--muted: #8b98b4;
|
||||
--accent: #4fd1c5;
|
||||
--accent-dark: #2aa79c;
|
||||
--warn: #e0a355;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 18px 20px 26px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #1d2432, #171c27);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.readout {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.readout-time {
|
||||
font-family: Consolas, 'Cascadia Mono', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.readout-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px 18px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.wide-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.row.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stack-tight {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field + .field {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: var(--panel-2);
|
||||
color: var(--fg);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, transform 0.06s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2a3244;
|
||||
border-color: #3b465e;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-dark);
|
||||
border-color: var(--accent-dark);
|
||||
color: #06231f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary.is-on {
|
||||
background: #3a2a33;
|
||||
border-color: #6b3a44;
|
||||
color: #ffb3bd;
|
||||
}
|
||||
|
||||
.btn-primary.is-on:hover {
|
||||
background: #4a333e;
|
||||
}
|
||||
|
||||
.btn-warn {
|
||||
border-color: #6b5326;
|
||||
color: #f0c37a;
|
||||
}
|
||||
|
||||
/* 暂停中要一眼看出来,否则用户会以为时钟坏了 */
|
||||
.btn-warn.is-on {
|
||||
background: #3b2f1a;
|
||||
border-color: var(--warn);
|
||||
color: #ffd79a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn-chip {
|
||||
padding: 6px 11px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.btn.seg {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.btn.seg.is-active {
|
||||
background: var(--accent-dark);
|
||||
border-color: var(--accent);
|
||||
color: #06231f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: #0f131b;
|
||||
color: var(--fg);
|
||||
font-family: Consolas, 'Cascadia Mono', monospace;
|
||||
font-size: 15px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.num {
|
||||
width: 62px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wide-num {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.time-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.colon {
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent-dark);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.presets {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'" />
|
||||
<title>ClockLab 控制台</title>
|
||||
<link rel="stylesheet" href="control.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="dot"></span>
|
||||
<div>
|
||||
<h1>ClockLab 时钟控制台</h1>
|
||||
<p class="sub">显示时间 = 基准时间 + 真实经过时间 × 倍率</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="readout">
|
||||
<div id="readoutTime" class="readout-time">--:--:--</div>
|
||||
<div id="readoutMeta" class="readout-meta">连接中…</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section class="card">
|
||||
<h2>时钟窗口</h2>
|
||||
<div class="row">
|
||||
<button id="toggleClockWindow" class="btn btn-primary wide">打开时钟窗口</button>
|
||||
</div>
|
||||
<div class="row stack-tight">
|
||||
<label class="check">
|
||||
<input id="aotClock" type="checkbox" checked />
|
||||
<span>时钟窗口始终置顶(默认开)</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input id="aotControl" type="checkbox" />
|
||||
<span>控制窗口始终置顶(默认关)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">关掉控制窗口即退出整个程序,时钟窗口一并关闭。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>显示样式</h2>
|
||||
<div class="segmented" id="styleGroup">
|
||||
<button class="btn seg" data-style="digital">纯数字</button>
|
||||
<button class="btn seg" data-style="analog">圆形时钟</button>
|
||||
</div>
|
||||
<p class="hint">切换立即生效,不需要重开时钟窗口。</p>
|
||||
</section>
|
||||
|
||||
<section class="card wide-card">
|
||||
<h2>时间与流速</h2>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">设定时间(24 小时制)</div>
|
||||
<div class="row">
|
||||
<div class="time-inputs">
|
||||
<input id="hh" class="input num" type="number" min="0" max="23" value="12" />
|
||||
<span class="colon">:</span>
|
||||
<input id="mm" class="input num" type="number" min="0" max="59" value="0" />
|
||||
<span class="colon">:</span>
|
||||
<input id="ss" class="input num" type="number" min="0" max="59" value="0" />
|
||||
</div>
|
||||
<button id="applyTime" class="btn">应用</button>
|
||||
<button id="fillNow" class="btn btn-ghost">填入当前显示时间</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">流速倍率(可正、可负、可 0、可小数)</div>
|
||||
<div class="row">
|
||||
<input id="rate" class="input num wide-num" type="number" step="0.1" value="1" />
|
||||
<button id="applyRate" class="btn">应用</button>
|
||||
<button id="togglePause" class="btn btn-warn">暂停</button>
|
||||
</div>
|
||||
<div class="row wrap presets">
|
||||
<button class="btn btn-chip" data-rate="-1">-1 倒转</button>
|
||||
<button class="btn btn-chip" data-rate="-2">-2</button>
|
||||
<button class="btn btn-chip" data-rate="0">0 暂停</button>
|
||||
<button class="btn btn-chip" data-rate="0.5">0.5</button>
|
||||
<button class="btn btn-chip" data-rate="1">1 正常</button>
|
||||
<button class="btn btn-chip" data-rate="2">2</button>
|
||||
<button class="btn btn-chip" data-rate="10">10</button>
|
||||
<button class="btn btn-chip" data-rate="60">60</button>
|
||||
</div>
|
||||
<p class="hint">「暂停 / 继续」按钮等价于倍率 0 / 回到暂停前的那个倍率。倍率为负时时钟会一直往回走,不是卡住。</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">快进 / 快退(按当前显示时间整体平移,可与任意倍率叠加)</div>
|
||||
<div class="row wrap">
|
||||
<button class="btn btn-chip" data-delta="5000">+5 秒</button>
|
||||
<button class="btn btn-chip" data-delta="60000">+1 分钟</button>
|
||||
<button class="btn btn-chip" data-delta="300000">+5 分钟</button>
|
||||
<button class="btn btn-chip" data-delta="600000">+10 分钟</button>
|
||||
<button class="btn btn-chip" data-delta="1800000">+30 分钟</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="control.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
'use strict';
|
||||
|
||||
const api = window.clockApi;
|
||||
|
||||
const els = {
|
||||
readoutTime: document.getElementById('readoutTime'),
|
||||
readoutMeta: document.getElementById('readoutMeta'),
|
||||
toggleClockWindow: document.getElementById('toggleClockWindow'),
|
||||
aotClock: document.getElementById('aotClock'),
|
||||
aotControl: document.getElementById('aotControl'),
|
||||
styleGroup: document.getElementById('styleGroup'),
|
||||
hh: document.getElementById('hh'),
|
||||
mm: document.getElementById('mm'),
|
||||
ss: document.getElementById('ss'),
|
||||
applyTime: document.getElementById('applyTime'),
|
||||
fillNow: document.getElementById('fillNow'),
|
||||
rate: document.getElementById('rate'),
|
||||
applyRate: document.getElementById('applyRate'),
|
||||
togglePause: document.getElementById('togglePause')
|
||||
};
|
||||
|
||||
let snapshot = { baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, style: 'digital', paused: false, clockWindowOpen: false };
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function displayMs(now) {
|
||||
return snapshot.baseMs + (now - snapshot.baseRealMs) * snapshot.rate;
|
||||
}
|
||||
|
||||
function clampInt(value, min, max, fallback) {
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
function renderReadout() {
|
||||
const ms = displayMs(Date.now());
|
||||
const date = new Date(ms);
|
||||
els.readoutTime.textContent = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
||||
|
||||
const rateText = snapshot.rate === 0 ? '暂停' : `×${snapshot.rate}`;
|
||||
const runText = snapshot.rate === 0 ? '已暂停' : snapshot.rate < 0 ? '倒转中' : '运行中';
|
||||
els.readoutMeta.textContent = `${rateText} · ${runText} · 时钟窗口${snapshot.clockWindowOpen ? '已打开' : '已关闭'}`;
|
||||
requestAnimationFrame(renderReadout);
|
||||
}
|
||||
|
||||
function syncControls() {
|
||||
els.toggleClockWindow.textContent = snapshot.clockWindowOpen ? '关闭时钟窗口' : '打开时钟窗口';
|
||||
els.toggleClockWindow.classList.toggle('is-on', snapshot.clockWindowOpen);
|
||||
|
||||
els.aotClock.checked = Boolean(snapshot.alwaysOnTop.clock);
|
||||
els.aotControl.checked = Boolean(snapshot.alwaysOnTop.control);
|
||||
|
||||
for (const button of els.styleGroup.querySelectorAll('.seg')) {
|
||||
button.classList.toggle('is-active', button.dataset.style === snapshot.style);
|
||||
}
|
||||
|
||||
// 用户正在输入时不要抢走他的光标和内容
|
||||
if (document.activeElement !== els.rate) els.rate.value = String(snapshot.rate);
|
||||
|
||||
if (snapshot.rate === 0) {
|
||||
els.togglePause.textContent = `继续(回到 ×${snapshot.lastRate})`;
|
||||
els.togglePause.classList.add('is-on');
|
||||
} else {
|
||||
els.togglePause.textContent = '暂停';
|
||||
els.togglePause.classList.remove('is-on');
|
||||
}
|
||||
}
|
||||
|
||||
function accept(next) {
|
||||
if (next) {
|
||||
snapshot = next;
|
||||
syncControls();
|
||||
}
|
||||
}
|
||||
|
||||
els.toggleClockWindow.addEventListener('click', async () => accept(await api.toggleClockWindow()));
|
||||
|
||||
els.aotClock.addEventListener('change', async () => accept(await api.setAlwaysOnTop('clock', els.aotClock.checked)));
|
||||
els.aotControl.addEventListener('change', async () => accept(await api.setAlwaysOnTop('control', els.aotControl.checked)));
|
||||
|
||||
for (const button of els.styleGroup.querySelectorAll('.seg')) {
|
||||
button.addEventListener('click', async () => accept(await api.setStyle(button.dataset.style)));
|
||||
}
|
||||
|
||||
els.applyTime.addEventListener('click', async () => {
|
||||
const h = clampInt(els.hh.value, 0, 23, 0);
|
||||
const m = clampInt(els.mm.value, 0, 59, 0);
|
||||
const s = clampInt(els.ss.value, 0, 59, 0);
|
||||
els.hh.value = String(h);
|
||||
els.mm.value = String(m);
|
||||
els.ss.value = String(s);
|
||||
accept(await api.setTime(h, m, s));
|
||||
});
|
||||
|
||||
els.fillNow.addEventListener('click', () => {
|
||||
// 三格都要补零:`String(0)` 是 "0",而输入框按 HH:MM:SS 解析,
|
||||
// 上午 9 点前会填出 "0:39:27" 这种非法值,点应用时解析失败。
|
||||
const date = new Date(displayMs(Date.now()));
|
||||
els.hh.value = pad2(date.getHours());
|
||||
els.mm.value = pad2(date.getMinutes());
|
||||
els.ss.value = pad2(date.getSeconds());
|
||||
});
|
||||
|
||||
function applyRateInput() {
|
||||
const value = Number(els.rate.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
els.rate.value = String(snapshot.rate);
|
||||
return;
|
||||
}
|
||||
api.setRate(value).then(accept);
|
||||
}
|
||||
|
||||
els.applyRate.addEventListener('click', applyRateInput);
|
||||
|
||||
els.rate.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') applyRateInput();
|
||||
});
|
||||
|
||||
els.togglePause.addEventListener('click', async () => accept(await api.togglePause()));
|
||||
|
||||
document.body.addEventListener('click', (event) => {
|
||||
const rate = event.target.dataset?.rate;
|
||||
if (rate !== undefined) {
|
||||
api.setRate(Number(rate)).then(accept);
|
||||
return;
|
||||
}
|
||||
const delta = event.target.dataset?.delta;
|
||||
if (delta !== undefined) {
|
||||
api.fastForward(Number(delta)).then(accept);
|
||||
}
|
||||
});
|
||||
|
||||
api.onState((next) => {
|
||||
snapshot = next;
|
||||
syncControls();
|
||||
});
|
||||
|
||||
api.getState().then((initial) => {
|
||||
snapshot = initial;
|
||||
syncControls();
|
||||
renderReadout();
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
// 呈现时间层:主进程给的是「目标时间」,画面用的是「呈现时间」。
|
||||
// 平时两者相等;只有在发生一次性跳变(快进、设定时间)时,才用一小段时间把呈现时间缓动到新目标,
|
||||
// 于是数字会自己「滚」过去、指针会「扫」过去 —— 数字和表盘都只从这一个值推导,不必各写一套动画。
|
||||
//
|
||||
// 之所以动画「时间」而不是动画「字符 / 角度」:跨 12 点、跨 0 点时角度公式照旧,
|
||||
// 不会出现 359°→1° 倒着转一圈;老式里程表那种数字观感也只是同一个值的副产品。
|
||||
//
|
||||
// 拆成独立文件是为了能在 node 里直接跑用例(同 renderer/resize.js 的做法)。
|
||||
|
||||
const JUMP_THRESHOLD_MS = 1500; // 一步小于这个幅度就直接跳:正常走时每秒都在变,不该每秒动一次
|
||||
const EASE_MS = 350; // 缓动时长,够看清「滚」的过程,又不至于拖到下一次操作
|
||||
|
||||
function targetAt(snapshot, now) {
|
||||
return snapshot.baseMs + (now - snapshot.baseRealMs) * snapshot.rate;
|
||||
}
|
||||
|
||||
// 两次参数之间的目标断点:在同一个真实时刻上,分别用新旧参数算时间再相减。
|
||||
// 取新参数的基准时刻来比,是因为改倍率时模型先把当前显示时间固化进了基准,
|
||||
// 两个公式在这一刻必然相等 —— 「改变速度」不会被误判成「跳变」。
|
||||
function jumpBetween(previous, next) {
|
||||
if (!previous) return 0;
|
||||
const at = next.baseRealMs;
|
||||
return targetAt(next, at) - targetAt(previous, at);
|
||||
}
|
||||
|
||||
function create() {
|
||||
return { snapshot: null, offsetMs: 0, startMs: 0 };
|
||||
}
|
||||
|
||||
// 尚未消化的偏移:缓动起点处的差值按三次缓出衰减到 0,越小说明越接近目标
|
||||
function pendingMs(layer, now) {
|
||||
const t = (now - layer.startMs) / EASE_MS;
|
||||
if (t >= 1) return 0; // 到点就彻底归零,不给浮点留残余(否则数字永远差几百毫秒)
|
||||
if (t <= 0) return layer.offsetMs;
|
||||
const left = 1 - t;
|
||||
return layer.offsetMs * left * left * left;
|
||||
}
|
||||
|
||||
// 收到新的走时参数时调用:决定这一次是「直接跳」还是「缓动过去」
|
||||
function accept(layer, now, snapshot) {
|
||||
// 只在「目标时间确实断了一截」时才缓动,所以:
|
||||
// · 幅度太小(正常走时、+1 秒)直跳 —— 否则每秒都在滚,反而看不清时间;
|
||||
// · rate 0(暂停)直跳 —— 暂停画面必须停在暂停那一刻的准确时间上,
|
||||
// 让缓动挂在那里的话,暂停期间显示的就是一个走不完的中间值(看着像时间不对)。
|
||||
const jump = layer.snapshot ? jumpBetween(layer.snapshot, snapshot) : 0;
|
||||
const animate = layer.snapshot !== null && snapshot.rate !== 0 && Math.abs(jump) > JUMP_THRESHOLD_MS;
|
||||
|
||||
if (animate) {
|
||||
// 从「当前呈现值」重新出发:缓动没结束又来一次跳变时,用旧目标减去尚未消化的偏移反推回去,
|
||||
// 不能从跳变前的旧值重来,否则画面会先弹回去再滚一次。
|
||||
const presentedNow = targetAt(layer.snapshot, now) - pendingMs(layer, now);
|
||||
layer.offsetMs = targetAt(snapshot, now) - presentedNow;
|
||||
} else {
|
||||
layer.offsetMs = 0;
|
||||
}
|
||||
|
||||
layer.snapshot = snapshot;
|
||||
layer.startMs = now;
|
||||
}
|
||||
|
||||
// 每帧调用:这一刻该显示的时间
|
||||
function valueAt(layer, now) {
|
||||
return targetAt(layer.snapshot, now) - pendingMs(layer, now);
|
||||
}
|
||||
|
||||
// 渲染进程里这几个函数要能被 clock.js 直接用,所以除了 CommonJS 导出,还挂成一个命名空间
|
||||
// (同 resize.js 的双向可用做法:浏览器读全局,node 测试读 require)
|
||||
const presentedTime = { create, accept, valueAt, targetAt, jumpBetween, JUMP_THRESHOLD_MS, EASE_MS };
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = presentedTime;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
// 拖边缘改大小的纯计算部分单独放一个文件:
|
||||
// 这段几何运算是最容易出错、但在无边框窗口里只能靠真鼠标才能触发的地方,
|
||||
// 拆出来之后可以在 node 里直接跑用例,不必等到 Windows 上肉眼试。
|
||||
|
||||
const MIN_SIZE = { width: 260, height: 150 };
|
||||
|
||||
function computeBounds(start, dir, dx, dy, min) {
|
||||
const limit = min || MIN_SIZE;
|
||||
let x = start.x;
|
||||
let y = start.y;
|
||||
let width = start.width;
|
||||
let height = start.height;
|
||||
|
||||
if (dir.includes('e')) width += dx;
|
||||
if (dir.includes('s')) height += dy;
|
||||
if (dir.includes('w')) {
|
||||
width -= dx;
|
||||
x += dx;
|
||||
}
|
||||
if (dir.includes('n')) {
|
||||
height -= dy;
|
||||
y += dy;
|
||||
}
|
||||
|
||||
// 顶到最小尺寸后继续拖,被拖的那条边要停住,而不是把整个窗口推走
|
||||
if (width < limit.width) {
|
||||
if (dir.includes('w')) x = start.x + start.width - limit.width;
|
||||
width = limit.width;
|
||||
}
|
||||
if (height < limit.height) {
|
||||
if (dir.includes('n')) y = start.y + start.height - limit.height;
|
||||
height = limit.height;
|
||||
}
|
||||
|
||||
return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { computeBounds, MIN_SIZE };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
// 走时模型:显示时间 = 基准时间 + (真实时间 - 基准时刻) × 倍率。
|
||||
// 之所以不用「setInterval 每次 +1 秒」,是因为定时器实际间隔会被系统调度拖后,误差会一直累积;
|
||||
// 这里把「走多快」和「从哪一刻开始走」彻底分开,任何倍率变化/快进/暂停都只是重新锚定一次基准,
|
||||
// 因此倍率、暂停(0)、倒转(负)、小数、快进这几种操作可以共用同一套代码,且不会漂移。
|
||||
|
||||
function create(now) {
|
||||
return { baseMs: now, baseRealMs: now, rate: 1, lastRate: 1 };
|
||||
}
|
||||
|
||||
function displayMs(clock, now) {
|
||||
return clock.baseMs + (now - clock.baseRealMs) * clock.rate;
|
||||
}
|
||||
|
||||
// 改倍率前先把当前显示时间固化进基准,否则改倍率的瞬间时间会跳一下
|
||||
function setRate(clock, now, rate) {
|
||||
clock.baseMs = displayMs(clock, now);
|
||||
clock.baseRealMs = now;
|
||||
clock.rate = rate;
|
||||
if (rate !== 0) clock.lastRate = rate;
|
||||
}
|
||||
|
||||
function shift(clock, now, deltaMs) {
|
||||
clock.baseMs = displayMs(clock, now) + deltaMs;
|
||||
clock.baseRealMs = now;
|
||||
}
|
||||
|
||||
function setTimeOfDay(clock, now, h, m, s) {
|
||||
const d = new Date(now);
|
||||
d.setHours(h, m, s, 0);
|
||||
clock.baseMs = d.getTime();
|
||||
clock.baseRealMs = now;
|
||||
}
|
||||
|
||||
module.exports = { create, displayMs, setRate, shift, setTimeOfDay };
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
'use strict';
|
||||
|
||||
// 自检:electron . --selftest
|
||||
// 只在开发机上跑:证明「走时模型 → IPC → 画面 → 窗口」整条链路真的通,并留下截图。
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const OUT_DIR = path.join(__dirname, '..', 'dist', 'selftest');
|
||||
const results = [];
|
||||
|
||||
function check(name, ok, detail) {
|
||||
results.push({ name, ok: Boolean(ok), detail });
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` [${detail}]` : ''}`);
|
||||
}
|
||||
|
||||
function skip(name, detail) {
|
||||
console.log(`SKIP ${name}${detail ? ` [${detail}]` : ''}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForLoad(win) {
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
if (!win || win.isDestroyed()) return false;
|
||||
if (!win.webContents.isLoading()) return true;
|
||||
await sleep(50);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = async function runSelftest(ctx) {
|
||||
const { app, clockModel, actions, applyBounds, createClockWindow, getClockWindow, getControlWindow } = ctx;
|
||||
let exitCode = 1;
|
||||
|
||||
const readShown = () => getClockWindow().webContents.executeJavaScript('Number(document.body.dataset.displayMs)');
|
||||
const readStyle = () => getClockWindow().webContents.executeJavaScript('document.body.dataset.style');
|
||||
const expectedNow = (now) => {
|
||||
const c = ctx.state.clock;
|
||||
return c.baseMs + (now - c.baseRealMs) * c.rate;
|
||||
};
|
||||
|
||||
try {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// —— 1. 纯模型:倍率 / 倒转 / 暂停 / 小数 / 快进 / 设定时间 ——
|
||||
const t0 = 1_700_000_000_000;
|
||||
const model = clockModel.create(t0);
|
||||
check('rate=1:1 秒真实时间 = 1 秒显示时间', clockModel.displayMs(model, t0 + 1000) === t0 + 1000);
|
||||
|
||||
clockModel.setRate(model, t0, 2);
|
||||
check('倍率 2:1 秒真实时间走 2 秒', clockModel.displayMs(model, t0 + 1000) === t0 + 2000);
|
||||
|
||||
clockModel.setRate(model, t0, -1);
|
||||
check('倍率 -1:时间往回走', clockModel.displayMs(model, t0 + 1000) === t0 - 1000);
|
||||
|
||||
clockModel.setRate(model, t0, 0);
|
||||
check('倍率 0:时间冻结', clockModel.displayMs(model, t0 + 5000) === t0);
|
||||
|
||||
clockModel.setRate(model, t0, 0.5);
|
||||
check('倍率 0.5:半速', clockModel.displayMs(model, t0 + 1000) === t0 + 500);
|
||||
|
||||
clockModel.setRate(model, t0, -1);
|
||||
clockModel.shift(model, t0, 300_000);
|
||||
check('负倍率下快进 +5 分钟:先固化显示时间再平移', clockModel.displayMs(model, t0) === t0 + 300_000);
|
||||
|
||||
clockModel.setTimeOfDay(model, t0, 8, 30, 15);
|
||||
const set = new Date(clockModel.displayMs(model, t0));
|
||||
check('设定时间 08:30:15 生效', set.getHours() === 8 && set.getMinutes() === 30 && set.getSeconds() === 15,
|
||||
`${set.getHours()}:${set.getMinutes()}:${set.getSeconds()}`);
|
||||
|
||||
const pauseModel = clockModel.create(t0);
|
||||
clockModel.setRate(pauseModel, t0, 4);
|
||||
clockModel.setRate(pauseModel, t0, 0);
|
||||
check('暂停后仍记得上次倍率 4(供恢复)', pauseModel.rate === 0 && pauseModel.lastRate === 4);
|
||||
|
||||
// —— 2. 端到端:主进程模型 → IPC → 时钟窗口画面 ——
|
||||
actions.setStyle('digital');
|
||||
actions.setRate(1);
|
||||
const clockWin = createClockWindow();
|
||||
await waitForLoad(clockWin);
|
||||
clockWin.focus();
|
||||
await sleep(400);
|
||||
|
||||
const first = await readShown();
|
||||
check('时钟窗口渲染进程已接上走时参数', Number.isFinite(first), `displayMs=${first}`);
|
||||
|
||||
actions.setRate(60);
|
||||
const wall0 = Date.now();
|
||||
const expect0 = expectedNow(wall0);
|
||||
await sleep(600);
|
||||
const shown = await readShown();
|
||||
const expect1 = expect0 + (Date.now() - wall0) * 60;
|
||||
const drift = Math.abs(shown - expect1);
|
||||
check('倍率 60:画面与模型一致(容差 3 秒显示时间)', drift < 3000,
|
||||
`画面=${shown} 期望=${Math.round(expect1)} 偏差=${Math.round(drift)}ms`);
|
||||
|
||||
actions.setRate(-1);
|
||||
await sleep(150);
|
||||
const back1 = await readShown();
|
||||
await sleep(500);
|
||||
const back2 = await readShown();
|
||||
check('倒转时画面数字持续变小(不是卡住)', back2 < back1 - 300, `${back1} → ${back2}`);
|
||||
|
||||
actions.setRate(0);
|
||||
await sleep(150);
|
||||
const frozen1 = await readShown();
|
||||
await sleep(500);
|
||||
const frozen2 = await readShown();
|
||||
check('暂停时画面完全静止', frozen1 === frozen2, `${frozen1} → ${frozen2}`);
|
||||
|
||||
actions.togglePause();
|
||||
await sleep(150);
|
||||
const resumed1 = await readShown();
|
||||
await sleep(400);
|
||||
const resumed2 = await readShown();
|
||||
check('恢复按钮找回上次倍率 -1(继续倒转)', resumed2 < resumed1, `${resumed1} → ${resumed2}`);
|
||||
|
||||
actions.setRate(1);
|
||||
await sleep(150);
|
||||
const beforeFast = await readShown();
|
||||
actions.fastForward(300_000);
|
||||
await sleep(120); // 缓动全长 350ms,此刻本该在半路上
|
||||
const duringFast = await readShown();
|
||||
const rolled = duringFast - beforeFast;
|
||||
if (rolled >= 300_000 - 20_000) {
|
||||
skip('快进 +5 分钟:中途读数(本轮读得太慢,动画已落定,判不出是滚过去还是瞬间跳)',
|
||||
`已走 ${Math.round(rolled)}ms`);
|
||||
} else {
|
||||
check('快进 +5 分钟:数字滚过去而不是瞬间跳(120ms 时还在半路)',
|
||||
rolled > 0 && rolled < 300_000, `已走 ${Math.round(rolled)}ms / 300000ms`);
|
||||
}
|
||||
await sleep(500); // 等缓动落定
|
||||
const afterFast = await readShown();
|
||||
check('快进 +5 分钟:缓动结束后精确落在 +300 秒',
|
||||
Math.abs(afterFast - beforeFast - 300_000) < 3000, `跳变=${afterFast - beforeFast}ms`);
|
||||
|
||||
actions.setTime(6, 0, 0);
|
||||
await sleep(500); // 同样等缓动落定后再读
|
||||
const setShown = new Date(await readShown());
|
||||
check('设定时间后时钟滚到 06:00:00(缓动落定后的值)',
|
||||
setShown.getHours() === 6 && setShown.getMinutes() === 0 && setShown.getSeconds() === 0,
|
||||
`${setShown.getHours()}:${setShown.getMinutes()}:${setShown.getSeconds()}`);
|
||||
|
||||
// —— 3. 自定义拖边缘改大小:合成鼠标事件真的拖热区 ——
|
||||
actions.setRate(1);
|
||||
const beforeBounds = clockWin.getBounds();
|
||||
const grabX = beforeBounds.width - 4;
|
||||
const grabY = Math.floor(beforeBounds.height / 2);
|
||||
clockWin.webContents.sendInputEvent({ type: 'mouseDown', x: grabX, y: grabY, button: 'left', clickCount: 1 });
|
||||
for (let step = 1; step <= 6; step += 1) {
|
||||
clockWin.webContents.sendInputEvent({ type: 'mouseMove', x: grabX + step * 20, y: grabY, button: 'left', buttons: 1 });
|
||||
await sleep(40);
|
||||
}
|
||||
clockWin.webContents.sendInputEvent({ type: 'mouseUp', x: grabX + 120, y: grabY, button: 'left', clickCount: 1 });
|
||||
await sleep(250);
|
||||
const afterEast = clockWin.getBounds();
|
||||
check('拖东边热区把窗口改宽(热区 → IPC → setBounds 全链路)',
|
||||
afterEast.width > beforeBounds.width + 40, `${beforeBounds.width} → ${afterEast.width}`);
|
||||
|
||||
const southY = afterEast.height - 4;
|
||||
const midX = Math.floor(afterEast.width / 2);
|
||||
clockWin.webContents.sendInputEvent({ type: 'mouseDown', x: midX, y: southY, button: 'left', clickCount: 1 });
|
||||
for (let step = 1; step <= 4; step += 1) {
|
||||
clockWin.webContents.sendInputEvent({ type: 'mouseMove', x: midX, y: southY + step * 15, button: 'left', buttons: 1 });
|
||||
await sleep(40);
|
||||
}
|
||||
clockWin.webContents.sendInputEvent({ type: 'mouseUp', x: midX, y: southY + 60, button: 'left', clickCount: 1 });
|
||||
await sleep(250);
|
||||
const afterSouth = clockWin.getBounds();
|
||||
check('拖南边热区把窗口改高', afterSouth.height > afterEast.height + 20, `${afterEast.height} → ${afterSouth.height}`);
|
||||
|
||||
applyBounds({ x: afterSouth.x, y: afterSouth.y, width: 50, height: 40 });
|
||||
await sleep(100);
|
||||
const clamped = clockWin.getBounds();
|
||||
check('尺寸被夹在最小值 260x150', clamped.width === 260 && clamped.height === 150, `${clamped.width}x${clamped.height}`);
|
||||
applyBounds({ x: afterSouth.x, y: afterSouth.y, width: 640, height: 360 });
|
||||
|
||||
// —— 4. 拖动区 / no-drag 的布局前提,以及按钮没有被拖动区吃掉 ——
|
||||
const regions = await clockWin.webContents.executeJavaScript(`(() => {
|
||||
const read = (el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return s.webkitAppRegion || s.appRegion || '';
|
||||
};
|
||||
return {
|
||||
stage: read(document.getElementById('stage')),
|
||||
close: read(document.getElementById('closeBtn')),
|
||||
handle: read(document.querySelector('.rz-e'))
|
||||
};
|
||||
})()`);
|
||||
if (regions.stage && regions.close) {
|
||||
check('拖动区与按钮的 app-region 布局正确(stage=drag, 按钮/热区=no-drag)',
|
||||
regions.stage === 'drag' && regions.close === 'no-drag' && regions.handle === 'no-drag',
|
||||
JSON.stringify(regions));
|
||||
} else {
|
||||
skip('app-region 计算样式读取(当前 Chromium 不返回该属性)', JSON.stringify(regions));
|
||||
}
|
||||
|
||||
const beforeClose = clockWin.getBounds();
|
||||
const closeX = beforeClose.width - 21;
|
||||
clockWin.webContents.sendInputEvent({ type: 'mouseDown', x: closeX, y: 20, button: 'left', clickCount: 1 });
|
||||
clockWin.webContents.sendInputEvent({ type: 'mouseUp', x: closeX, y: 20, button: 'left', clickCount: 1 });
|
||||
await sleep(500);
|
||||
check('点击时钟窗口右上角 × 能关掉窗口(按钮没被拖动区覆盖)', getClockWindow() === null);
|
||||
|
||||
// —— 5. 置顶开关真的落到了 BrowserWindow 上 ——
|
||||
const win = createClockWindow();
|
||||
await waitForLoad(win);
|
||||
await sleep(300);
|
||||
actions.setAlwaysOnTop('clock', false);
|
||||
check('取消时钟窗口置顶后 isAlwaysOnTop()=false', win.isAlwaysOnTop() === false);
|
||||
actions.setAlwaysOnTop('clock', true);
|
||||
check('重新置顶后 isAlwaysOnTop()=true', win.isAlwaysOnTop() === true);
|
||||
actions.setAlwaysOnTop('control', true);
|
||||
check('控制窗口置顶开关生效', getControlWindow().isAlwaysOnTop() === true);
|
||||
actions.setAlwaysOnTop('control', false);
|
||||
check('控制窗口取消置顶生效', getControlWindow().isAlwaysOnTop() === false);
|
||||
|
||||
// —— 6. 时钟窗口的开关按钮 ——
|
||||
actions.toggleClockWindow();
|
||||
await sleep(500);
|
||||
check('toggle 关闭时钟窗口后窗口对象被回收', getClockWindow() === null);
|
||||
actions.toggleClockWindow();
|
||||
const reopened = createClockWindow();
|
||||
await waitForLoad(reopened);
|
||||
await sleep(500);
|
||||
check('toggle 重新打开时钟窗口,且沿用当前样式',
|
||||
getClockWindow() !== null && (await readStyle()) === 'digital', `style=${await readStyle()}`);
|
||||
|
||||
// —— 7. 截图留证 ——
|
||||
actions.setRate(30);
|
||||
await sleep(400);
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'clock-digital.png'), (await getClockWindow().webContents.capturePage()).toPNG());
|
||||
|
||||
actions.setStyle('analog');
|
||||
await sleep(500);
|
||||
check('样式切换后时钟窗口进入表盘模式', (await readStyle()) === 'analog');
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'clock-analog.png'), (await getClockWindow().webContents.capturePage()).toPNG());
|
||||
|
||||
actions.setRate(1);
|
||||
await sleep(250);
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'control.png'), (await getControlWindow().webContents.capturePage()).toPNG());
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(`\n自检结果:${results.length - failed.length}/${results.length} 通过`);
|
||||
if (failed.length) console.log(`未通过:${failed.map((f) => f.name).join(';')}`);
|
||||
console.log(`截图目录:${OUT_DIR}`);
|
||||
exitCode = failed.length === 0 ? 0 : 1;
|
||||
} catch (error) {
|
||||
console.error('自检异常:', error);
|
||||
exitCode = 2;
|
||||
} finally {
|
||||
app.exit(exitCode);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
|
||||
// 纯逻辑自测(不需要 Electron、不需要显示器):
|
||||
// node test/clock-model.test.js
|
||||
// 走时模型是所有功能的地基,这里把它单独钉死,GUI 的问题就不会和算法问题混在一起。
|
||||
|
||||
const clockModel = require('../src/clock-model');
|
||||
|
||||
let passed = 0;
|
||||
const failures = [];
|
||||
|
||||
function check(name, ok, detail) {
|
||||
if (ok) {
|
||||
passed += 1;
|
||||
console.log(`PASS ${name}${detail ? ` [${detail}]` : ''}`);
|
||||
} else {
|
||||
failures.push(name);
|
||||
console.log(`FAIL ${name}${detail ? ` [${detail}]` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
const T0 = 1_700_000_000_000; // 固定基准时刻,测试不依赖真实时钟
|
||||
const SECOND = 1000;
|
||||
|
||||
console.log('--- 正常走时 ---');
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
check('初始倍率为 1', c.rate === 1);
|
||||
check('走 1 秒真实时间 = 1 秒显示时间', clockModel.displayMs(c, T0 + SECOND) === T0 + SECOND);
|
||||
check('走 1 小时真实时间 = 1 小时显示时间', clockModel.displayMs(c, T0 + 3600 * SECOND) === T0 + 3600 * SECOND);
|
||||
}
|
||||
|
||||
{
|
||||
// 分 3600 次推进,验证「基准 + 经过时间 × 倍率」不会像逐帧累加那样漂移
|
||||
const c = clockModel.create(T0);
|
||||
let now = T0;
|
||||
for (let i = 0; i < 3600; i += 1) {
|
||||
now += SECOND;
|
||||
clockModel.displayMs(c, now);
|
||||
}
|
||||
check('一小时逐秒推进后零漂移', clockModel.displayMs(c, now) === T0 + 3600 * SECOND);
|
||||
}
|
||||
|
||||
console.log('--- 倍率 ---');
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, 2);
|
||||
check('倍率 2:1 秒真实时间走 2 秒', clockModel.displayMs(c, T0 + SECOND) === T0 + 2 * SECOND);
|
||||
}
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, 0.5);
|
||||
check('倍率 0.5:半速', clockModel.displayMs(c, T0 + SECOND) === T0 + 500);
|
||||
}
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, -1);
|
||||
check('倍率 -1:走 1 秒真实时间倒退 1 秒', clockModel.displayMs(c, T0 + SECOND) === T0 - SECOND);
|
||||
check('倍率 -1:再走 5 秒仍在继续倒退(不是卡住)',
|
||||
clockModel.displayMs(c, T0 + 6 * SECOND) === T0 - 6 * SECOND);
|
||||
}
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, -2.5);
|
||||
check('倍率 -2.5:小数 + 负数同时成立', clockModel.displayMs(c, T0 + 4 * SECOND) === T0 - 10 * SECOND);
|
||||
}
|
||||
|
||||
console.log('--- 切换倍率不跳变 ---');
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
const before = clockModel.displayMs(c, T0 + 3000);
|
||||
clockModel.setRate(c, T0 + 3000, 7);
|
||||
const after = clockModel.displayMs(c, T0 + 3000);
|
||||
check('切倍率的瞬间显示时间连续(同一时刻前后相等)', before === after, `${before} → ${after}`);
|
||||
check('切换后按新倍率走', clockModel.displayMs(c, T0 + 4000) === before + 7 * SECOND);
|
||||
}
|
||||
|
||||
console.log('--- 暂停 / 继续 ---');
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, 4);
|
||||
clockModel.setRate(c, T0 + SECOND, 0);
|
||||
const frozenAt = clockModel.displayMs(c, T0 + SECOND);
|
||||
check('倍率 0:显示时间冻结', clockModel.displayMs(c, T0 + 60 * SECOND) === frozenAt);
|
||||
check('暂停时记住了暂停前的倍率 4', c.lastRate === 4);
|
||||
|
||||
clockModel.setRate(c, T0 + 60 * SECOND, c.lastRate);
|
||||
check('继续:从暂停处按原倍率接着走',
|
||||
clockModel.displayMs(c, T0 + 61 * SECOND) === frozenAt + 4 * SECOND);
|
||||
}
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, 0);
|
||||
check('倍率一直是 0 时 lastRate 保持默认 1', c.lastRate === 1);
|
||||
}
|
||||
|
||||
console.log('--- 快进 / 快退 ---');
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
const before = clockModel.displayMs(c, T0 + 2000);
|
||||
clockModel.shift(c, T0 + 2000, 5 * SECOND);
|
||||
check('+5 秒:显示时间立刻加 5 秒', clockModel.displayMs(c, T0 + 2000) === before + 5 * SECOND);
|
||||
check('快进后基准确实变了', c.baseMs === before + 5 * SECOND && c.baseRealMs === T0 + 2000);
|
||||
check('快进后继续按倍率走', clockModel.displayMs(c, T0 + 3000) === before + 6 * SECOND);
|
||||
}
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, -1);
|
||||
const before = clockModel.displayMs(c, T0 + 10 * SECOND);
|
||||
clockModel.shift(c, T0 + 10 * SECOND, 30 * 60 * SECOND);
|
||||
check('负倍率下 +30 分钟:先固化显示时间再平移(不与倒转打架)',
|
||||
clockModel.displayMs(c, T0 + 10 * SECOND) === before + 30 * 60 * SECOND);
|
||||
check('平移后负倍率继续生效',
|
||||
clockModel.displayMs(c, T0 + 11 * SECOND) === before + 30 * 60 * SECOND - SECOND);
|
||||
}
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, 0);
|
||||
const before = clockModel.displayMs(c, T0 + 1000);
|
||||
clockModel.shift(c, T0 + 1000, 10 * 60 * SECOND);
|
||||
check('暂停状态下快进仍然生效,且之后继续冻结',
|
||||
clockModel.displayMs(c, T0 + 60 * SECOND) === before + 10 * 60 * SECOND);
|
||||
}
|
||||
{
|
||||
// 五个快进按钮的档位
|
||||
const deltas = [5 * SECOND, 60 * SECOND, 5 * 60 * SECOND, 10 * 60 * SECOND, 30 * 60 * SECOND];
|
||||
const c = clockModel.create(T0);
|
||||
const before = clockModel.displayMs(c, T0);
|
||||
for (const d of deltas) clockModel.shift(c, T0, d);
|
||||
const total = deltas.reduce((a, b) => a + b, 0);
|
||||
check('五档快进累加正确(+5s/+1m/+5m/+10m/+30m)',
|
||||
clockModel.displayMs(c, T0) === before + total, `总计 ${total / 1000} 秒`);
|
||||
}
|
||||
|
||||
console.log('--- 设定时间 ---');
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setRate(c, T0, 60);
|
||||
clockModel.setTimeOfDay(c, T0 + 5000, 8, 30, 15);
|
||||
const d = new Date(clockModel.displayMs(c, T0 + 5000));
|
||||
check('设定为 08:30:15 且毫秒归零',
|
||||
d.getHours() === 8 && d.getMinutes() === 30 && d.getSeconds() === 15 && d.getMilliseconds() === 0,
|
||||
`${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}.${d.getMilliseconds()}`);
|
||||
check('设定后高倍率继续生效', clockModel.displayMs(c, T0 + 6000) === d.getTime() + 60 * SECOND);
|
||||
}
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setTimeOfDay(c, T0, 0, 0, 0);
|
||||
const d = new Date(clockModel.displayMs(c, T0));
|
||||
check('设定为 00:00:00(边界)', d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0);
|
||||
}
|
||||
{
|
||||
const c = clockModel.create(T0);
|
||||
clockModel.setTimeOfDay(c, T0, 23, 59, 59);
|
||||
const d = new Date(clockModel.displayMs(c, T0 + 2000));
|
||||
check('23:59:59 后跨零点进位', d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 1,
|
||||
`${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (failures.length === 0) {
|
||||
console.log(`全部通过:${passed}/${passed}`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use strict';
|
||||
|
||||
// 呈现时间层的纯用例(不需要 Electron、不需要显示器):
|
||||
// node test/presented-time.test.js
|
||||
// 「什么时候该动画、动画到哪、被打断怎么办」是这次跳转动画最容易写错的地方,
|
||||
// 所以把它从渲染代码里拆出来,先在 node 里钉死,Windows 上就只剩「观感」要试。
|
||||
|
||||
const presented = require('../renderer/presented-time');
|
||||
|
||||
const { JUMP_THRESHOLD_MS, EASE_MS } = presented;
|
||||
|
||||
let passed = 0;
|
||||
const failures = [];
|
||||
|
||||
function check(name, ok, detail) {
|
||||
if (ok) {
|
||||
passed += 1;
|
||||
console.log(`PASS ${name}${detail ? ` [${detail}]` : ''}`);
|
||||
} else {
|
||||
failures.push(name);
|
||||
console.log(`FAIL ${name}${detail ? ` [${detail}]` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
const T0 = 1_700_000_000_000; // 固定基准时刻,测试不依赖真实时钟
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
|
||||
// 走时参数就是主进程下发的那个快照,这里照原样造
|
||||
const snap = (baseMs, baseRealMs, rate) => ({ baseMs, baseRealMs, rate });
|
||||
|
||||
console.log('--- 平时不动画 ---');
|
||||
{
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, snap(T0, T0, 1));
|
||||
check('首个参数直接对齐(没有"上一个值"可以缓动)', presented.valueAt(layer, T0) === T0);
|
||||
check('走 1 秒后呈现时间 = 目标时间,没有动画', presented.valueAt(layer, T0 + SECOND) === T0 + SECOND);
|
||||
check('走 1 小时后依然相等(不会被动画层拖后腿)',
|
||||
presented.valueAt(layer, T0 + 3600 * SECOND) === T0 + 3600 * SECOND);
|
||||
}
|
||||
{
|
||||
// 首个参数哪怕离当前显示时间很远(例如时钟窗口后打开),也不该滚一次
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, snap(T0 + 10 * MINUTE, T0, 1));
|
||||
check('首个参数:直接对齐,不做动画', presented.valueAt(layer, T0) === T0 + 10 * MINUTE);
|
||||
}
|
||||
|
||||
console.log(`--- 大跳变(> ${JUMP_THRESHOLD_MS}ms)缓动 ${EASE_MS}ms ---`);
|
||||
{
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, snap(T0, T0, 1));
|
||||
const target = T0 + 30 * MINUTE;
|
||||
presented.accept(layer, T0, snap(target, T0, 1)); // 快进 +30 分钟
|
||||
|
||||
check('跳变当帧显示的还是跳变前的时间(数字从这里开始滚)',
|
||||
presented.valueAt(layer, T0) === T0, String(presented.valueAt(layer, T0)));
|
||||
|
||||
const half = presented.valueAt(layer, T0 + EASE_MS / 2);
|
||||
check('半程时停在两者之间', half > T0 && half < target, `${Math.round(half - T0)}ms`);
|
||||
check('缓出:半程已经走完一大半路程(不是匀速)', half - T0 > (target - T0) / 2,
|
||||
`${Math.round(((half - T0) / (target - T0)) * 100)}%`);
|
||||
|
||||
let previous = -Infinity;
|
||||
let monotonic = true;
|
||||
for (let t = 0; t <= EASE_MS; t += 25) {
|
||||
const value = presented.valueAt(layer, T0 + t);
|
||||
if (value < previous) monotonic = false;
|
||||
previous = value;
|
||||
}
|
||||
check('整段缓动单调向前(不会中途倒退)', monotonic);
|
||||
|
||||
// 结束时要比的是「那一刻的目标时间」—— 目标本身还在按 ×1 继续走
|
||||
const settled = target + EASE_MS;
|
||||
check(`满 ${EASE_MS}ms 精确落在目标上(不留残余,否则数字永远差几百毫秒)`,
|
||||
presented.valueAt(layer, T0 + EASE_MS) === settled, String(presented.valueAt(layer, T0 + EASE_MS) - settled));
|
||||
check('之后一直等于目标时间', presented.valueAt(layer, T0 + 5000) === target + 5000);
|
||||
}
|
||||
{
|
||||
// 往回跳(设定到更早的时间)同样缓动,只是方向相反
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, snap(T0, T0, 1));
|
||||
const target = T0 - 10 * MINUTE;
|
||||
presented.accept(layer, T0, snap(target, T0, 1));
|
||||
check('负向跳变当帧仍在原处', presented.valueAt(layer, T0) === T0);
|
||||
|
||||
let previous = Infinity;
|
||||
let monotonic = true;
|
||||
for (let t = 0; t <= EASE_MS; t += 25) {
|
||||
const value = presented.valueAt(layer, T0 + t);
|
||||
if (value > previous) monotonic = false;
|
||||
previous = value;
|
||||
}
|
||||
check('负向跳变整段单调往回', monotonic);
|
||||
check('负向跳变落在目标上', presented.valueAt(layer, T0 + EASE_MS) === target + EASE_MS);
|
||||
}
|
||||
|
||||
console.log('--- 小跳变直接跳 ---');
|
||||
{
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, snap(T0, T0, 1));
|
||||
presented.accept(layer, T0, snap(T0 + JUMP_THRESHOLD_MS, T0, 1));
|
||||
check(`正好 ${JUMP_THRESHOLD_MS}ms:直接跳,不缓动`,
|
||||
presented.valueAt(layer, T0) === T0 + JUMP_THRESHOLD_MS);
|
||||
|
||||
const other = presented.create();
|
||||
presented.accept(other, T0, snap(T0, T0, 1));
|
||||
presented.accept(other, T0, snap(T0 + JUMP_THRESHOLD_MS + 1, T0, 1));
|
||||
check(`超过 ${JUMP_THRESHOLD_MS}ms:开始缓动(当帧还在原处)`, presented.valueAt(other, T0) === T0);
|
||||
}
|
||||
|
||||
console.log('--- 改倍率不是跳变 ---');
|
||||
{
|
||||
const before = snap(T0, T0, 1);
|
||||
const visible = presented.targetAt(before, T0 + 5000); // 改倍率那一刻的显示时间
|
||||
const after = snap(visible, T0 + 5000, 60); // 模型先固化显示时间再换倍率
|
||||
|
||||
check('模型锚点式换倍率:同一时刻新旧参数算出的时间相等',
|
||||
presented.jumpBetween(before, after) === 0, String(presented.jumpBetween(before, after)));
|
||||
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, before);
|
||||
presented.accept(layer, T0 + 5000, after);
|
||||
check('换倍率后画面不缓动,当帧就是固化下来的那个值',
|
||||
presented.valueAt(layer, T0 + 5000) === visible);
|
||||
check('换倍率后立刻按新倍率走(不会被动画拦住)',
|
||||
presented.valueAt(layer, T0 + 5016) === visible + 16 * 60, String(presented.valueAt(layer, T0 + 5016) - visible));
|
||||
}
|
||||
{
|
||||
const before = snap(T0, T0, 1);
|
||||
const after = snap(T0 + 5 * SECOND, T0, 1); // 一次真实的 +5 秒快进
|
||||
check('快进 +5 秒:参数比较出来的跳变幅度就是 5 秒',
|
||||
presented.jumpBetween(before, after) === 5 * SECOND, String(presented.jumpBetween(before, after)));
|
||||
check('没有上一个参数时不判定跳变', presented.jumpBetween(null, after) === 0);
|
||||
}
|
||||
|
||||
console.log('--- 缓动中又跳一次:从当前呈现值重新出发 ---');
|
||||
{
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, snap(T0, T0, 1));
|
||||
presented.accept(layer, T0, snap(T0 + 30 * MINUTE, T0, 1));
|
||||
const mid = presented.valueAt(layer, T0 + 200);
|
||||
check('中途确实停在两者之间', mid > T0 && mid < T0 + 30 * MINUTE, `${Math.round(mid - T0)}ms`);
|
||||
|
||||
// 还没滚完又点了一次 +1 分钟:模型的目标变成 31 分钟
|
||||
presented.accept(layer, T0 + 200, snap(T0 + 31 * MINUTE, T0 + 200, 1));
|
||||
check('重定向当帧接着当前呈现值,不跳回起点',
|
||||
presented.valueAt(layer, T0 + 200) === mid, `${Math.round(presented.valueAt(layer, T0 + 200) - mid)}ms`);
|
||||
check('重定向后不会倒退', presented.valueAt(layer, T0 + 201) >= mid);
|
||||
check('重定向后仍然精确落在新目标上',
|
||||
presented.valueAt(layer, T0 + 200 + EASE_MS) === T0 + 31 * MINUTE + EASE_MS);
|
||||
}
|
||||
|
||||
console.log('--- 暂停不缓动 ---');
|
||||
{
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, snap(T0, T0, 1));
|
||||
presented.accept(layer, T0, snap(T0 + 30 * MINUTE, T0, 1));
|
||||
const mid = presented.valueAt(layer, T0 + 100);
|
||||
check('跳变后 100ms 正在缓动', mid > T0 && mid < T0 + 30 * MINUTE, `${Math.round(mid - T0)}ms`);
|
||||
|
||||
// 暂停:模型把显示时间固化在暂停那一刻(也就是刚跳到的目标)
|
||||
presented.accept(layer, T0 + 100, snap(T0 + 30 * MINUTE, T0 + 100, 0));
|
||||
check('暂停当帧就落到暂停时刻的准确时间(不会挂着走不完的中间值)',
|
||||
presented.valueAt(layer, T0 + 100) === T0 + 30 * MINUTE);
|
||||
check('暂停后画面纹丝不动',
|
||||
presented.valueAt(layer, T0 + 9000) === presented.valueAt(layer, T0 + 100));
|
||||
}
|
||||
{
|
||||
const layer = presented.create();
|
||||
presented.accept(layer, T0, snap(T0, T0, 1));
|
||||
presented.accept(layer, T0, snap(T0, T0, 0));
|
||||
check('暂停状态下快进也是直接落到新值(暂停画面必须是精确的暂停时间)',
|
||||
presented.valueAt(layer, T0) === T0);
|
||||
presented.accept(layer, T0 + 5000, snap(T0 + 5 * MINUTE, T0 + 5000, 0));
|
||||
check('暂停后快进:当帧就是新值,不滚',
|
||||
presented.valueAt(layer, T0 + 5000) === T0 + 5 * MINUTE);
|
||||
check('暂停后快进:之后继续定格在该值',
|
||||
presented.valueAt(layer, T0 + 60 * SECOND) === T0 + 5 * MINUTE);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (failures.length === 0) {
|
||||
console.log(`全部通过:${passed}/${passed}`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// 用最小 DOM 桩把「跳转动画」这条链路真的跑一遍:主进程下发参数 → 呈现时间层 → 数字 / 表盘
|
||||
// node test/renderer-clock-anim.dom-test.js
|
||||
// 桩终究不是真浏览器:它能证明「缓动从哪里出发、落在哪里、被打断时连不连续」,
|
||||
// 证明不了「滚起来顺不顺眼」——那部分只能在 Windows 上肉眼确认。
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
|
||||
|
||||
let passed = 0; const failures = [];
|
||||
const check = (n, ok, d) => { if (ok) { passed++; console.log(`PASS ${n}${d ? ` [${d}]` : ''}`); } else { failures.push(n); console.log(`FAIL ${n}${d ? ` [${d}]` : ''}`); } };
|
||||
|
||||
function makeEl(id) {
|
||||
return {
|
||||
id, textContent: '', hidden: false, style: {}, dataset: {}, children: [], attrs: {}, handlers: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
|
||||
appendChild(c) { this.children.push(c); },
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
getAttribute(k) { return this.attrs[k]; },
|
||||
getBoundingClientRect: () => ({ width: 520, height: 280, top: 0, left: 0 }),
|
||||
addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); },
|
||||
setPointerCapture() {}, releasePointerCapture() {}
|
||||
};
|
||||
}
|
||||
|
||||
const els = new Map();
|
||||
const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
||||
const body = makeEl('body');
|
||||
|
||||
const RealDate = Date;
|
||||
const T0 = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime(); // 03:00:00.000
|
||||
const MIN = 60 * 1000;
|
||||
let fakeNow = T0;
|
||||
|
||||
class FakeDate extends RealDate {
|
||||
constructor(...a) { if (a.length === 0) super(fakeNow); else super(...a); }
|
||||
static now() { return fakeNow; }
|
||||
}
|
||||
|
||||
// 主进程下发的参数就是这个形状;这里照原样造
|
||||
const snap = (baseMs, baseRealMs, rate, style = 'digital') => ({
|
||||
baseMs, baseRealMs, rate, lastRate: rate, style, paused: rate === 0,
|
||||
clockWindowOpen: true, alwaysOnTop: { clock: true, control: false }
|
||||
});
|
||||
|
||||
let rafCb = null;
|
||||
let stateCb = null;
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
Date: FakeDate,
|
||||
requestAnimationFrame: (cb) => { rafCb = cb; return 1; },
|
||||
ResizeObserver: class { observe() {} },
|
||||
getComputedStyle: () => ({}),
|
||||
addEventListener: () => {},
|
||||
document: {
|
||||
body,
|
||||
getElementById: byId,
|
||||
querySelectorAll: () => [],
|
||||
createElementNS: () => makeEl('svg-node')
|
||||
},
|
||||
window: {}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.clockApi = {
|
||||
getState: () => Promise.resolve(snap(T0, T0, 1)),
|
||||
onState: (cb) => { stateCb = cb; },
|
||||
setRate: () => Promise.resolve({}), fastForward: () => Promise.resolve({}),
|
||||
setTime: () => Promise.resolve({}), setStyle: () => Promise.resolve({}),
|
||||
togglePause: () => Promise.resolve({}), setAlwaysOnTop: () => Promise.resolve({}),
|
||||
toggleClockWindow: () => Promise.resolve({}), closeClockWindow: () => Promise.resolve({}),
|
||||
closeControlWindow: () => Promise.resolve({}), getBoundsSync: () => null, setBounds: () => {}
|
||||
};
|
||||
|
||||
const ctx = vm.createContext(sandbox);
|
||||
try {
|
||||
for (const file of ['renderer/resize.js', 'renderer/presented-time.js', 'renderer/clock.js']) {
|
||||
vm.runInContext(read(file), ctx, { filename: file });
|
||||
}
|
||||
check('渲染进程脚本加载无异常', true);
|
||||
} catch (e) {
|
||||
check('渲染进程脚本加载无异常', false, e.message);
|
||||
console.log('\n致命:渲染进程脚本无法加载'); process.exit(1);
|
||||
}
|
||||
|
||||
// —— 读数小工具 ——
|
||||
const frame = () => rafCb(); // 渲染一帧(真实时间就是 fakeNow)
|
||||
const advance = (ms) => { fakeNow += ms; rafCb(); };
|
||||
const shown = () => Number(body.dataset.displayMs);
|
||||
const text = () => byId('digitalTime').textContent;
|
||||
const rot = (el) => Number(/rotate\(([-\d.eE]+)/.exec(el.attrs.transform)[1]);
|
||||
const targetOf = (s, now) => s.baseMs + (now - s.baseRealMs) * s.rate;
|
||||
|
||||
// 与 clock.js 同一套角度公式:秒针按毫秒连续走
|
||||
function anglesFor(ms) {
|
||||
const d = new RealDate(ms);
|
||||
const seconds = d.getSeconds() + d.getMilliseconds() / 1000;
|
||||
const minutes = d.getMinutes() + seconds / 60;
|
||||
const hours = (d.getHours() % 12) + minutes / 60;
|
||||
return { hour: hours * 30, minute: minutes * 6, second: seconds * 6 };
|
||||
}
|
||||
|
||||
// 三根指针是否都严格由「当前呈现时间」推出来(差 0.05° 以内 ≈ 0.1ms 的误差)
|
||||
function handsMatchPresented() {
|
||||
const expect = anglesFor(shown());
|
||||
return Math.abs(rot(byId('hourHand')) - expect.hour) < 0.05 &&
|
||||
Math.abs(rot(byId('minuteHand')) - expect.minute) < 0.05 &&
|
||||
Math.abs(rot(byId('secondHand')) - expect.second) < 0.05;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('--- 平时不动画 ---');
|
||||
frame();
|
||||
check('初始显示 03:00:00', text() === '03:00:00', text());
|
||||
advance(1000);
|
||||
check('走 1 秒就是 1 秒(动画层不拖后腿)', text() === '03:00:01', text());
|
||||
|
||||
console.log('--- 快进 +30 分钟:数字滚过去 ---');
|
||||
const before = shown();
|
||||
stateCb(snap(before + 30 * MIN, fakeNow, 1)); // 主进程 shift:以此刻的显示时间为准整体平移
|
||||
frame();
|
||||
check('跳变当帧数字还在旧值上(从这里开始滚)',
|
||||
shown() === before && text() === '03:00:01', `${text()} / ${shown() - before}`);
|
||||
advance(175);
|
||||
const mid = shown();
|
||||
check('半程时数字落在两者之间', mid > before && mid < before + 30 * MIN, `${Math.round(mid - before)}ms`);
|
||||
check('半程时数字确实换过(不是干等)', text() !== '03:00:01' && text() !== '03:30:01', text());
|
||||
advance(175);
|
||||
check('350ms 后落在 +30 分钟那一刻', shown() === before + 30 * MIN + 350,
|
||||
`${shown() - (before + 30 * MIN + 350)}ms`);
|
||||
check('落地后显示 03:30:01', text() === '03:30:01', text());
|
||||
|
||||
console.log('--- 小跳变直接跳 ---');
|
||||
const smallBase = shown();
|
||||
stateCb(snap(smallBase + 1000, fakeNow, 1));
|
||||
frame();
|
||||
check('+1 秒:当帧就直接到新值,不缓动', shown() === smallBase + 1000, `${shown() - smallBase}ms`);
|
||||
|
||||
console.log('--- 改倍率不是跳变 ---');
|
||||
const rateBase = shown();
|
||||
stateCb(snap(rateBase, fakeNow, 60));
|
||||
frame();
|
||||
check('切 ×60:当帧显示时间不变(没有动画)', shown() === rateBase, `${shown() - rateBase}ms`);
|
||||
advance(100);
|
||||
check('切 ×60:之后立刻按新倍率走', shown() === rateBase + 6000, `${shown() - rateBase}ms`);
|
||||
|
||||
console.log('--- 表盘:指针从同一个呈现时间扫过去 ---');
|
||||
const T11 = new RealDate(2026, 8, 14, 11, 0, 0, 0).getTime();
|
||||
const digitalBefore = shown();
|
||||
stateCb(snap(T11, fakeNow, 1, 'analog'));
|
||||
frame();
|
||||
check('切表盘 + 跳变当帧:指针仍指在旧时间上', Math.abs(rot(byId('hourHand')) - anglesFor(digitalBefore).hour) < 0.05,
|
||||
`${rot(byId('hourHand'))}° vs 目标 ${anglesFor(T11).hour}°`);
|
||||
check('当帧还没到 11 点', Math.abs(rot(byId('hourHand')) - 330) > 1, `${rot(byId('hourHand'))}°`);
|
||||
advance(175);
|
||||
check('半程时三根指针都由当前呈现时间推出', handsMatchPresented(),
|
||||
`时针 ${rot(byId('hourHand')).toFixed(2)}°`);
|
||||
advance(175);
|
||||
check('落地后时针指向 11 点整(330°)', Math.abs(rot(byId('hourHand')) - 330) < 0.05, `${rot(byId('hourHand'))}°`);
|
||||
check('落地后三根指针依然与呈现时间一致', handsMatchPresented());
|
||||
|
||||
console.log('--- 跨 12 点:往前扫,不倒着转一圈 ---');
|
||||
// 11:00 → 次日 01:00(跨过 12 点),缓动过程中时间必须单调向前
|
||||
const T01 = new RealDate(2026, 8, 15, 1, 0, 0, 0).getTime();
|
||||
stateCb(snap(T01, fakeNow, 1, 'analog'));
|
||||
const samples = [];
|
||||
let handsAligned = true;
|
||||
for (let i = 0; i <= 7; i += 1) {
|
||||
if (i > 0) fakeNow += 50;
|
||||
rafCb();
|
||||
samples.push(shown());
|
||||
if (!handsMatchPresented()) handsAligned = false;
|
||||
}
|
||||
check('缓动全程三根指针都由当前呈现时间推出(含跨 12 点那几帧)', handsAligned);
|
||||
let monotonic = true;
|
||||
for (let i = 1; i < samples.length; i++) if (samples[i] <= samples[i - 1]) monotonic = false;
|
||||
check('缓动全程呈现时间单调向前(角度按公式跟随,所以不会倒着转一圈)', monotonic,
|
||||
`${samples[0]} → ${samples[samples.length - 1]}`);
|
||||
advance(350);
|
||||
check('跨 12 点后落在次日 01:00', Math.abs(rot(byId('hourHand')) % 360 - 30) < 1, `${rot(byId('hourHand'))}°`);
|
||||
|
||||
console.log('--- 缓动中又跳一次:从当前呈现值重新出发 ---');
|
||||
stateCb(snap(shown() + 30 * MIN, fakeNow, 1, 'digital'));
|
||||
advance(100);
|
||||
const midFlight = shown();
|
||||
const retargetTo = shown() + MIN; // 主进程在模型目标上再 +1 分钟
|
||||
stateCb(snap(retargetTo, fakeNow, 1, 'digital'));
|
||||
frame();
|
||||
check('重定向当帧不弹回跳变前的旧值', shown() === midFlight, `${shown() - midFlight}ms`);
|
||||
advance(350);
|
||||
check('重定向后落在新目标上', shown() === targetOf(snap(retargetTo, fakeNow - 350, 1), fakeNow),
|
||||
`${shown()} vs ${targetOf(snap(retargetTo, fakeNow - 350, 1), fakeNow)}`);
|
||||
|
||||
console.log('--- 暂停不缓动 ---');
|
||||
const pausedAt = shown();
|
||||
stateCb(snap(pausedAt, fakeNow, 0));
|
||||
frame();
|
||||
check('暂停当帧就是暂停时刻的准确时间', shown() === pausedAt, `${shown() - pausedAt}ms`);
|
||||
advance(5000);
|
||||
check('暂停后画面纹丝不动', shown() === pausedAt, `${shown() - pausedAt}ms`);
|
||||
|
||||
// 缓动没结束时按暂停:不能挂着走不完的中间值
|
||||
stateCb(snap(pausedAt + 30 * MIN, fakeNow, 1));
|
||||
advance(100);
|
||||
const inFlight = shown();
|
||||
check('恢复后先正常滚起来', inFlight > pausedAt && inFlight < pausedAt + 30 * MIN, `${Math.round(inFlight - pausedAt)}ms`);
|
||||
stateCb(snap(pausedAt + 30 * MIN, fakeNow, 0));
|
||||
frame();
|
||||
check('缓动中按暂停:立刻落到暂停时刻的准确值', shown() === pausedAt + 30 * MIN, `${shown() - (pausedAt + 30 * MIN)}ms`);
|
||||
advance(5000);
|
||||
check('暂停后继续保持该值', shown() === pausedAt + 30 * MIN);
|
||||
|
||||
console.log('');
|
||||
if (!failures.length) { console.log(`跳转动画链路:全部通过 ${passed}/${passed}`); process.exit(0); }
|
||||
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1);
|
||||
}, 30);
|
||||
@@ -0,0 +1,185 @@
|
||||
// 用最小 DOM 桩把 renderer/clock.js + resize.js 真的跑一遍(不需要 Electron、不需要显示器)
|
||||
// 注意:桩终究不是真浏览器,它能证明「接线和算法对」,证明不了「鼠标手感」。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
|
||||
|
||||
let passed = 0; const failures = [];
|
||||
const check = (n, ok, d) => { if (ok) { passed++; console.log(`PASS ${n}${d ? ` [${d}]` : ''}`); } else { failures.push(n); console.log(`FAIL ${n}${d ? ` [${d}]` : ''}`); } };
|
||||
|
||||
function makeEl(id) {
|
||||
const el = {
|
||||
id, textContent: '', hidden: false, style: {}, dataset: {}, children: [], attrs: {}, handlers: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
|
||||
appendChild(c) { this.children.push(c); },
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
getAttribute(k) { return this.attrs[k]; },
|
||||
getBoundingClientRect: () => ({ width: 520, height: 280, top: 0, left: 0 }),
|
||||
addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); },
|
||||
setPointerCapture() {}, releasePointerCapture() {},
|
||||
fire(type, ev) { (this.handlers[type] || []).forEach((fn) => fn(ev)); }
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const els = new Map();
|
||||
const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
||||
const handles = ['n','s','w','e','nw','ne','sw','se'].map((dir) => { const el = makeEl(`rz-${dir}`); el.dataset.dir = dir; return el; });
|
||||
|
||||
const body = makeEl('body');
|
||||
const calls = { setBounds: [], closed: 0, rates: [] };
|
||||
let rafCb = null;
|
||||
let stateCb = null;
|
||||
|
||||
const RealDate = Date;
|
||||
let fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime();
|
||||
class FakeDate extends RealDate {
|
||||
constructor(...a) { if (a.length === 0) super(fakeNow); else super(...a); }
|
||||
static now() { return fakeNow; }
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
Date: FakeDate,
|
||||
requestAnimationFrame: (cb) => { rafCb = cb; return 1; },
|
||||
ResizeObserver: class { observe() {} },
|
||||
getComputedStyle: () => ({ webkitAppRegion: 'drag' }),
|
||||
addEventListener: () => {},
|
||||
document: {
|
||||
body,
|
||||
getElementById: byId,
|
||||
querySelectorAll: (sel) => (sel === '.rz' ? handles : []),
|
||||
createElementNS: () => makeEl('svg-node')
|
||||
},
|
||||
window: {}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.clockApi = {
|
||||
getState: () => Promise.resolve({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' }),
|
||||
onState: (cb) => { stateCb = cb; },
|
||||
setRate: (r) => { calls.rates.push(r); return Promise.resolve({}); },
|
||||
setBounds: (b) => calls.setBounds.push(b),
|
||||
getBoundsSync: () => ({ x: 400, y: 300, width: 520, height: 280 }),
|
||||
closeClockWindow: () => { calls.closed++; return Promise.resolve({}); },
|
||||
togglePause: () => Promise.resolve({}), fastForward: () => Promise.resolve({}),
|
||||
setTime: () => Promise.resolve({}), setStyle: () => Promise.resolve({}),
|
||||
setAlwaysOnTop: () => Promise.resolve({}), toggleClockWindow: () => Promise.resolve({}),
|
||||
closeControlWindow: () => Promise.resolve({})
|
||||
};
|
||||
|
||||
console.log('--- 加载 ---');
|
||||
let ctx;
|
||||
try {
|
||||
ctx = vm.createContext(sandbox);
|
||||
vm.runInContext(read('renderer/resize.js'), ctx, { filename: 'resize.js' });
|
||||
vm.runInContext(read('renderer/presented-time.js'), ctx, { filename: 'presented-time.js' });
|
||||
vm.runInContext(read('renderer/clock.js'), ctx, { filename: 'clock.js' });
|
||||
check('resize.js + clock.js 在同一全局作用域下加载无异常(无重复声明冲突)', true);
|
||||
} catch (e) {
|
||||
check('resize.js + clock.js 在同一全局作用域下加载无异常', false, e.message);
|
||||
console.log('\n致命:渲染进程脚本无法加载'); process.exit(1);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('--- 走时显示 ---');
|
||||
check('数字视图渲染 HH:MM:SS', byId('digitalTime').textContent === '03:00:00', byId('digitalTime').textContent);
|
||||
const expectDate = new RealDate(fakeNow);
|
||||
const wk = ['周日','周一','周二','周三','周四','周五','周六'][expectDate.getDay()];
|
||||
check('日期行正确', byId('digitalDate').textContent === `2026-09-14 ${wk}`, byId('digitalDate').textContent);
|
||||
|
||||
fakeNow += 3661 * 1000; // 前进 1 小时 1 分 1 秒
|
||||
rafCb();
|
||||
check('走 1 小时后数字变成 04:01:01', byId('digitalTime').textContent === '04:01:01', byId('digitalTime').textContent);
|
||||
check('画面读数与模型公式一致', Number(body.dataset.displayMs) === fakeNow, body.dataset.displayMs);
|
||||
|
||||
console.log('--- 倍率 / 倒转 / 暂停(通过 IPC 下发状态) ---');
|
||||
fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime();
|
||||
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 60, lastRate: 60, style: 'digital' });
|
||||
fakeNow += 1000;
|
||||
rafCb();
|
||||
check('倍率 60:1 秒真实时间后画面走了 60 秒', byId('digitalTime').textContent === '03:01:00', byId('digitalTime').textContent);
|
||||
check('倍率角标显示 ×60', byId('badge').hidden === false && byId('badge').textContent === '×60', byId('badge').textContent);
|
||||
|
||||
// 每次下发状态都会把「当前显示时间」重新锚成基准,所以期望值要用同一公式算
|
||||
const revBase = fakeNow;
|
||||
stateCb({ baseMs: revBase, baseRealMs: revBase, rate: -1, lastRate: -1, style: 'digital' });
|
||||
const revText0 = byId('digitalTime').textContent;
|
||||
fakeNow += 5000;
|
||||
rafCb();
|
||||
const expectRev = new RealDate(revBase - 5000);
|
||||
const expectRevText = [expectRev.getHours(), expectRev.getMinutes(), expectRev.getSeconds()]
|
||||
.map((n) => String(n).padStart(2, '0')).join(':');
|
||||
check('倒转:画面时间往回走(5 秒真实时间 = 倒退 5 秒)',
|
||||
byId('digitalTime').textContent === expectRevText && byId('digitalTime').textContent < revText0,
|
||||
`${revText0} → ${byId('digitalTime').textContent}(期望 ${expectRevText})`);
|
||||
check('倍率角标显示「倒转 ×-1」', byId('badge').textContent === '倒转 ×-1', byId('badge').textContent);
|
||||
|
||||
const pauseBase = fakeNow;
|
||||
stateCb({ baseMs: pauseBase, baseRealMs: pauseBase, rate: 0, lastRate: -1, style: 'digital' });
|
||||
rafCb();
|
||||
const frozen = byId('digitalTime').textContent;
|
||||
fakeNow += 9000; rafCb();
|
||||
check('暂停:9 秒真实时间过去,画面完全不动', byId('digitalTime').textContent === frozen, `${frozen} → ${byId('digitalTime').textContent}`);
|
||||
check('暂停角标显示「暂停」', byId('badge').textContent === '暂停', byId('badge').textContent);
|
||||
|
||||
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' });
|
||||
rafCb();
|
||||
check('倍率回到 1 时角标隐藏', byId('badge').hidden === true);
|
||||
|
||||
console.log('--- 样式切换 ---');
|
||||
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'analog' });
|
||||
check('切到表盘:analog 显示、digital 隐藏',
|
||||
byId('analogView').hidden === false && byId('digitalView').hidden === true);
|
||||
fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime();
|
||||
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'analog' });
|
||||
fakeNow += 0; rafCb();
|
||||
check('03:00:00 时时针指向 90°(3 点方向)', byId('hourHand').attrs.transform === 'rotate(90 0 0)', byId('hourHand').attrs.transform);
|
||||
check('分针指向 0°', byId('minuteHand').attrs.transform === 'rotate(0 0 0)', byId('minuteHand').attrs.transform);
|
||||
check('秒针指向 0°', byId('secondHand').attrs.transform === 'rotate(0 0 0)', byId('secondHand').attrs.transform);
|
||||
fakeNow += 15000; rafCb(); // 15 秒
|
||||
check('15 秒后秒针指向 90°', byId('secondHand').attrs.transform === 'rotate(90 0 0)', byId('secondHand').attrs.transform);
|
||||
check('秒针按毫秒连续扫(15.5 秒 ≈ 93°)', (() => { fakeNow += 500; rafCb(); return byId('secondHand').attrs.transform === 'rotate(93 0 0)'; })(), byId('secondHand').attrs.transform);
|
||||
|
||||
stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' });
|
||||
check('切回数字:digital 显示、analog 隐藏',
|
||||
byId('digitalView').hidden === false && byId('analogView').hidden === true);
|
||||
|
||||
console.log('--- 拖边缘改大小(模拟真实鼠标事件序列) ---');
|
||||
const east = handles.find((h) => h.dataset.dir === 'e');
|
||||
east.fire('pointerdown', { button: 0, screenX: 916, screenY: 440, pointerId: 1, preventDefault() {} });
|
||||
east.fire('pointermove', { screenX: 996, screenY: 440, pointerId: 1 });
|
||||
east.fire('pointermove', { screenX: 1036, screenY: 440, pointerId: 1 });
|
||||
east.fire('pointerup', { pointerId: 1 });
|
||||
const b1 = calls.setBounds.at(-1);
|
||||
check('拖东边:宽度 520 → 640,左上角不动',
|
||||
b1.width === 640 && b1.x === 400 && b1.y === 300 && b1.height === 280, JSON.stringify(b1));
|
||||
|
||||
const south = handles.find((h) => h.dataset.dir === 's');
|
||||
south.fire('pointerdown', { button: 0, screenX: 660, screenY: 576, pointerId: 1, preventDefault() {} });
|
||||
south.fire('pointermove', { screenX: 660, screenY: 636, pointerId: 1 });
|
||||
south.fire('pointerup', { pointerId: 1 });
|
||||
const b2 = calls.setBounds.at(-1);
|
||||
check('拖南边:高度 280 → 340', b2.height === 340 && b2.y === 300, JSON.stringify(b2));
|
||||
|
||||
const west = handles.find((h) => h.dataset.dir === 'w');
|
||||
west.fire('pointerdown', { button: 0, screenX: 400, screenY: 440, pointerId: 1, preventDefault() {} });
|
||||
west.fire('pointermove', { screenX: 900, screenY: 440, pointerId: 1 });
|
||||
west.fire('pointerup', { pointerId: 1 });
|
||||
const b3 = calls.setBounds.at(-1);
|
||||
check('拖西边超过最小宽度:停在 260,右边界不动(窗口不会被推走)',
|
||||
b3.width === 260 && b3.x + b3.width === 920, JSON.stringify(b3));
|
||||
|
||||
const before = calls.setBounds.length;
|
||||
south.fire('pointermove', { screenX: 660, screenY: 700, pointerId: 1 });
|
||||
check('抬起鼠标后再移动不会继续改大小', calls.setBounds.length === before);
|
||||
|
||||
console.log('--- 关闭按钮 ---');
|
||||
byId('closeBtn').fire('click', {});
|
||||
check('点 × 调用了 closeClockWindow', calls.closed === 1, String(calls.closed));
|
||||
|
||||
console.log('');
|
||||
if (!failures.length) { console.log(`渲染进程逻辑:全部通过 ${passed}/${passed}`); process.exit(0); }
|
||||
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1);
|
||||
}, 30);
|
||||
@@ -0,0 +1,132 @@
|
||||
// 用最小 DOM 桩把 renderer/control.js 跑一遍,重点验证「暂停/继续」按钮的状态文案
|
||||
// 注意:桩不是真浏览器,只能证明接线与状态同步正确。
|
||||
const fs = require('fs'); const vm = require('vm'); const path = require('path');
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
|
||||
let passed = 0; const failures = [];
|
||||
const check = (n, ok, d) => { if (ok) { passed++; console.log(`PASS ${n}${d ? ` [${d}]` : ''}`); } else { failures.push(n); console.log(`FAIL ${n}${d ? ` [${d}]` : ''}`); } };
|
||||
|
||||
function makeEl(id) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
id, textContent: '', value: '', checked: false, hidden: false, style: {}, dataset: {}, handlers: {}, children: [],
|
||||
classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), contains: (c) => classes.has(c) },
|
||||
_classes: classes,
|
||||
appendChild(c) { this.children.push(c); }, setAttribute() {}, getAttribute() {}, getBoundingClientRect: () => ({ width: 800, height: 600 }),
|
||||
addEventListener(t, fn) { (this.handlers[t] ||= []).push(fn); },
|
||||
fire(t, ev = {}) { (this.handlers[t] || []).forEach((fn) => fn({ target: this, ...ev })); }
|
||||
};
|
||||
}
|
||||
const els = new Map();
|
||||
const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
|
||||
const segButtons = ['digital', 'analog'].map((s) => { const el = makeEl(`seg-${s}`); el.dataset.style = s; return el; });
|
||||
const styleGroup = makeEl('styleGroup');
|
||||
styleGroup.querySelectorAll = () => segButtons;
|
||||
|
||||
const rateChips = ['-1','-2','0','0.5','1','2','10','60'].map((r) => { const el = makeEl(`chip-${r}`); el.dataset.rate = r; return el; });
|
||||
const fastChips = ['5000','60000','300000','600000','1800000'].map((d) => { const el = makeEl(`fast-${d}`); el.dataset.delta = d; return el; });
|
||||
const allChips = [...rateChips, ...fastChips];
|
||||
|
||||
const body = makeEl('body');
|
||||
const calls = { setRate: [], fastForward: [], setTime: [], setStyle: [], aot: [], toggleClock: 0, togglePause: 0 };
|
||||
let stateCb = null;
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
requestAnimationFrame: () => 1,
|
||||
document: {
|
||||
body, activeElement: null,
|
||||
getElementById: (id) => (id === 'styleGroup' ? styleGroup : byId(id)),
|
||||
querySelectorAll: () => [], createElementNS: () => makeEl('x')
|
||||
},
|
||||
window: {}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.clockApi = {
|
||||
getState: () => Promise.resolve({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, lastRate: 1, style: 'digital', clockWindowOpen: false, alwaysOnTop: { clock: true, control: false } }),
|
||||
onState: (cb) => { stateCb = cb; },
|
||||
setRate: (r) => { calls.setRate.push(r); return Promise.resolve({}); },
|
||||
fastForward: (d) => { calls.fastForward.push(d); return Promise.resolve({}); },
|
||||
setTime: (h, m, s) => { calls.setTime.push([h, m, s]); return Promise.resolve({}); },
|
||||
setStyle: (s) => { calls.setStyle.push(s); return Promise.resolve({}); },
|
||||
setAlwaysOnTop: (t, v) => { calls.aot.push([t, v]); return Promise.resolve({}); },
|
||||
toggleClockWindow: () => { calls.toggleClock++; return Promise.resolve({}); },
|
||||
togglePause: () => { calls.togglePause++; return Promise.resolve({}); },
|
||||
closeClockWindow: () => Promise.resolve({}), closeControlWindow: () => Promise.resolve({}),
|
||||
getBoundsSync: () => null, setBounds: () => {}
|
||||
};
|
||||
|
||||
const ctx = vm.createContext(sandbox);
|
||||
try { vm.runInContext(read('renderer/control.js'), ctx, { filename: 'control.js' }); check('control.js 加载无异常', true); }
|
||||
catch (e) { check('control.js 加载无异常', false, e.message); process.exit(1); }
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('--- 初始状态 ---');
|
||||
check('时钟窗口按钮初始为「打开时钟窗口」', byId('toggleClockWindow').textContent === '打开时钟窗口', byId('toggleClockWindow').textContent);
|
||||
check('暂停按钮初始文案为「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent);
|
||||
check('时钟窗口置顶默认勾选', byId('aotClock').checked === true);
|
||||
check('控制窗口置顶默认不勾选', byId('aotControl').checked === false);
|
||||
|
||||
console.log('--- 暂停 / 继续按钮 ---');
|
||||
stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 0, lastRate: 2, style: 'digital', clockWindowOpen: true, alwaysOnTop: { clock: true, control: false } });
|
||||
check('暂停后按钮变成「继续(回到 ×2)」', byId('togglePause').textContent === '继续(回到 ×2)', byId('togglePause').textContent);
|
||||
check('暂停时按钮有高亮状态 is-on', byId('togglePause')._classes.has('is-on'));
|
||||
check('暂停时倍率输入框显示 0', byId('rate').value === '0', byId('rate').value);
|
||||
check('时钟窗口按钮反映已打开', byId('toggleClockWindow').textContent === '关闭时钟窗口', byId('toggleClockWindow').textContent);
|
||||
|
||||
stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 2, lastRate: 2, style: 'digital', clockWindowOpen: true, alwaysOnTop: { clock: true, control: false } });
|
||||
check('继续后按钮文案回到「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent);
|
||||
check('继续后高亮消失', !byId('togglePause')._classes.has('is-on'));
|
||||
check('继续后倍率输入框显示 2', byId('rate').value === '2', byId('rate').value);
|
||||
|
||||
stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: -1, lastRate: -1, style: 'analog', clockWindowOpen: true, alwaysOnTop: { clock: false, control: true } });
|
||||
check('负倍率时按钮仍是「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent);
|
||||
check('样式按钮高亮跟随状态(analog)', segButtons[1]._classes.has('is-active') && !segButtons[0]._classes.has('is-active'));
|
||||
check('置顶勾选框跟随状态', byId('aotClock').checked === false && byId('aotControl').checked === true);
|
||||
|
||||
console.log('--- 点击行为 ---');
|
||||
byId('togglePause').fire('click');
|
||||
check('点暂停按钮调用 togglePause', calls.togglePause === 1);
|
||||
byId('toggleClockWindow').fire('click');
|
||||
check('点时钟窗口按钮调用 toggleClockWindow', calls.toggleClock === 1);
|
||||
|
||||
segButtons[0].fire('click');
|
||||
check('点「纯数字」调用 setStyle(digital)', calls.setStyle.at(-1) === 'digital', String(calls.setStyle.at(-1)));
|
||||
|
||||
body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '-1') });
|
||||
check('点「-1 倒转」调用 setRate(-1)', calls.setRate.at(-1) === -1, String(calls.setRate.at(-1)));
|
||||
body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '0.5') });
|
||||
check('点「0.5」调用 setRate(0.5)', calls.setRate.at(-1) === 0.5, String(calls.setRate.at(-1)));
|
||||
body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '0') });
|
||||
check('点「0 暂停」调用 setRate(0)', calls.setRate.at(-1) === 0, String(calls.setRate.at(-1)));
|
||||
|
||||
for (const d of ['5000','60000','300000','600000','1800000']) body.fire('click', { target: fastChips.find((c) => c.dataset.delta === d) });
|
||||
check('五个快进按钮分别调用 fastForward(5000/60000/300000/600000/1800000)',
|
||||
JSON.stringify(calls.fastForward) === JSON.stringify([5000, 60000, 300000, 600000, 1800000]), JSON.stringify(calls.fastForward));
|
||||
|
||||
byId('rate').value = '-3.5';
|
||||
byId('applyRate').fire('click');
|
||||
check('手动输入倍率 -3.5 生效', calls.setRate.at(-1) === -3.5, String(calls.setRate.at(-1)));
|
||||
byId('rate').value = 'abc';
|
||||
byId('applyRate').fire('click');
|
||||
check('倍率输入非法时回退到当前值,不发送 NaN', calls.setRate.at(-1) === -3.5, String(calls.setRate.at(-1)));
|
||||
|
||||
byId('hh').value = '25'; byId('mm').value = '70'; byId('ss').value = '-3';
|
||||
byId('applyTime').fire('click');
|
||||
check('时间输入越界被夹到 23:59:00', JSON.stringify(calls.setTime.at(-1)) === JSON.stringify([23, 59, 0]), JSON.stringify(calls.setTime.at(-1)));
|
||||
check('夹紧后的值回填到输入框', byId('hh').value === '23' && byId('mm').value === '59' && byId('ss').value === '0');
|
||||
|
||||
byId('fillNow').fire('click');
|
||||
check('「填入当前显示时间」把输入框填成合法时间',
|
||||
/^\d{2}$/.test(byId('hh').value) && /^\d{2}$/.test(byId('mm').value) && /^\d{2}$/.test(byId('ss').value),
|
||||
`${byId('hh').value}:${byId('mm').value}:${byId('ss').value}`);
|
||||
|
||||
byId('aotClock').checked = false; byId('aotClock').fire('change');
|
||||
check('取消时钟置顶调用 setAlwaysOnTop(clock,false)', JSON.stringify(calls.aot.at(-1)) === JSON.stringify(['clock', false]), JSON.stringify(calls.aot.at(-1)));
|
||||
byId('aotControl').checked = true; byId('aotControl').fire('change');
|
||||
check('打开控制窗口置顶调用 setAlwaysOnTop(control,true)', JSON.stringify(calls.aot.at(-1)) === JSON.stringify(['control', true]), JSON.stringify(calls.aot.at(-1)));
|
||||
|
||||
console.log('');
|
||||
if (!failures.length) { console.log(`控制窗口逻辑:全部通过 ${passed}/${passed}`); process.exit(0); }
|
||||
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1);
|
||||
}, 30);
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
// 拖边缘改大小的几何用例(纯计算,不需要 Electron):
|
||||
// node test/resize.test.js
|
||||
// 无边框窗口里这段代码只有真鼠标拖拽才能触发,先用纯函数把它钉死,Windows 上就只剩「手感」要试。
|
||||
|
||||
const { computeBounds, MIN_SIZE } = require('../renderer/resize');
|
||||
|
||||
let passed = 0;
|
||||
const failures = [];
|
||||
|
||||
function check(name, ok, detail) {
|
||||
if (ok) {
|
||||
passed += 1;
|
||||
console.log(`PASS ${name}${detail ? ` [${detail}]` : ''}`);
|
||||
} else {
|
||||
failures.push(name);
|
||||
console.log(`FAIL ${name}${detail ? ` [${detail}]` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
const start = { x: 400, y: 300, width: 520, height: 280 };
|
||||
const same = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
|
||||
console.log('--- 单边 ---');
|
||||
check('东边 +120:只有宽度变,左上角不动',
|
||||
same(computeBounds(start, 'e', 120, 0), { x: 400, y: 300, width: 640, height: 280 }));
|
||||
check('南边 +60:只有高度变',
|
||||
same(computeBounds(start, 's', 0, 60), { x: 400, y: 300, width: 520, height: 340 }));
|
||||
check('西边 -80:宽度增加 80,左边界左移 80',
|
||||
same(computeBounds(start, 'w', -80, 0), { x: 320, y: 300, width: 600, height: 280 }));
|
||||
check('北边 -50:高度增加 50,上边界上移 50',
|
||||
same(computeBounds(start, 'n', 0, -50), { x: 400, y: 250, width: 520, height: 330 }));
|
||||
check('东边往回拖 -100:窗口变窄,左上角不动',
|
||||
same(computeBounds(start, 'e', -100, 0), { x: 400, y: 300, width: 420, height: 280 }));
|
||||
|
||||
console.log('--- 四角 ---');
|
||||
check('右下角 +40/+30:宽高同时增加',
|
||||
same(computeBounds(start, 'se', 40, 30), { x: 400, y: 300, width: 560, height: 310 }));
|
||||
check('左上角 -40/-30:宽高增加且整体左上移',
|
||||
same(computeBounds(start, 'nw', -40, -30), { x: 360, y: 270, width: 560, height: 310 }));
|
||||
check('右上角 +40/-30:宽度增加、上边界上移',
|
||||
same(computeBounds(start, 'ne', 40, -30), { x: 400, y: 270, width: 560, height: 310 }));
|
||||
check('左下角 -40/+30:左边界左移、高度增加',
|
||||
same(computeBounds(start, 'sw', -40, 30), { x: 360, y: 300, width: 560, height: 310 }));
|
||||
|
||||
console.log('--- 最小尺寸 ---');
|
||||
check('东边往死里拖:宽度停在 260', computeBounds(start, 'e', -9999, 0).width === MIN_SIZE.width);
|
||||
check('南边往死里拖:高度停在 150', computeBounds(start, 's', 0, -9999).height === MIN_SIZE.height);
|
||||
{
|
||||
const r = computeBounds(start, 'w', 9999, 0);
|
||||
check('西边往死里拖:宽度停在 260,且右边界不动(窗口不会被推走)',
|
||||
r.width === MIN_SIZE.width && r.x + r.width === start.x + start.width, JSON.stringify(r));
|
||||
}
|
||||
{
|
||||
const r = computeBounds(start, 'n', 0, 9999);
|
||||
check('北边往死里拖:高度停在 150,且下边界不动',
|
||||
r.height === MIN_SIZE.height && r.y + r.height === start.y + start.height, JSON.stringify(r));
|
||||
}
|
||||
{
|
||||
const r = computeBounds(start, 'nw', 9999, 9999);
|
||||
check('左上角往死里拖:宽高都停在最小值,右下角不动',
|
||||
r.width === MIN_SIZE.width && r.height === MIN_SIZE.height &&
|
||||
r.x + r.width === start.x + start.width && r.y + r.height === start.y + start.height, JSON.stringify(r));
|
||||
}
|
||||
|
||||
console.log('--- 边界 ---');
|
||||
check('位移为 0 时原样返回', same(computeBounds(start, 'e', 0, 0), start));
|
||||
check('结果都是整数(避免 setBounds 收到小数后抖动)',
|
||||
Number.isInteger(computeBounds(start, 'e', 12.4, 0).width));
|
||||
check('负坐标(窗口在主屏左侧)也能算',
|
||||
same(computeBounds({ x: -900, y: -200, width: 520, height: 280 }, 'w', -100, 0),
|
||||
{ x: -1000, y: -200, width: 620, height: 280 }));
|
||||
|
||||
console.log('');
|
||||
if (failures.length === 0) {
|
||||
console.log(`全部通过:${passed}/${passed}`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in new issue
Block a user