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:
AskaEth committed 2026-09-15 01:31:07 +08:00
1 parent 892b9190aa
commit 9c7081b21d
27 files changed
+5467 -2

No files matched your search

+205
View File
@@ -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,龙芯需要另找运行时;
- 表盘目前没有"刻度数字"(只有刻度线与指针)。