Files
ClockLab/DEVELOP.md
T
AskaEth 9c7081b21d feat: ClockLab —— 可调速桌面时钟(数字/表盘,支持倍率、暂停、倒转、快进)
一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、
可拖动、可拖边缘改大小,由控制窗口开与关)。

功能
- 两个窗口各自的「始终置顶」开关
- 显示样式:纯数字 / 圆形表盘,切换立即生效
- 实时设定时间(含「填入当前显示时间」)
- 倍率:支持正数、负数(倒转)、0(暂停)、小数
- 暂停 / 继续(继续时回到暂停前的倍率,按钮文案会显示是几倍)
- 快进五档:+5 秒 / +1 分钟 / +5 分钟 / +10 分钟 / +30 分钟

实现要点
- 走时是「基准时间 + 基准时刻 + 倍率」的锚点式模型(不累加、零漂移);
  倍率、暂停、倒转、快进因此是同一套机制的四个用法,互不打架
- 跳转动画做在「呈现时间」这一层:数字滚动与表盘指针扫动共用同一个中间值,
  所以永远同步,也不会跨 12 点倒着转一圈
- 无边框窗口的拖动(CSS app-region + 控件 no-drag)与拖边缘改大小
  (自己算几何 + IPC setBounds,因为 frame:false 下系统边缘命中不可靠)

测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩)
2026-09-15 01:31:07 +08:00

206 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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,龙芯需要另找运行时;
- 表盘目前没有"刻度数字"(只有刻度线与指针)。