fix(webui)★★: 对话树改成画布式真树 + 修两个线上 bug;日历翻月卡死

移植自本地 main(含 3536689..e670f6f 的最终状态)。
基于 origin/main 而非 rebase 本地 —— 本地那条链上的 6 个提交
(0a9287b/d52be3f/74aacc3/646cb3f/6e198d2/010c8ec)是远端已推内容的等价改写,
重复移植只会制造冲突;远端另有一条线上的成果(main.cjs/preload.cjs/
index.css/main.tsx/TitleBar.tsx/run-all.mjs 与 4 份 docs)本地没有,
**不能覆盖**,故从 origin/main 起只取本次真正改动的 5 个文件。

## 对话树:换成画布式真树

用户连续三轮反馈「这不还是老版的对话树」,最后明确要求:
「直接用你之前写的那个**可拖动缩放的对话树界面**替换现在这个对话树界面」。

我前两轮改错了方向 —— 一直在**缩进列表**上打补丁(改排序 → 改竖线 →
整份重写),而缩进列表**表达不了分支**:实测最深的线索 205 封、59 层,
多个兄弟追问并排时缩进线断成一片;画布上每条边都是独立的一条线。

现在:可拖动平移 / 滚轮缩放(20%–250%,锚定光标)/ 点节点看正文 /
分层 Reingold–Tilford 布局(不用力导向:位置会变、点不准)。

## 两个线上 bug(用户实测报告,我第一轮没看出来)

① **页面没撑满**:打开后 zoom=140%、pan.x=-9393,内容包围盒 11340×7193,
   屏幕上只看得到左上角。而手动点「适应」是对的(0.1217)——
   同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。
   逐层实测揪出三个连环坑:`nodes` 是**根数组**(只 1 个根)、
   自动 fit 的 effect 没在数据到达后重跑、`fittedFor` 守卫置位太早把自己挡掉。
   另:`MailView` 的 `threadOf` 分支裸返回 `<ThreadView/>`,缺
   `flex-1 min-w-0 min-h-0` ⇒ 高度链断。

② **一缩放就崩**:`onWheel` 直接解引用 `e.currentTarget`,
   组件卸载后滚轮事件仍在飞 ⇒ `TypeError: Cannot read properties of null`。
   实测放大 20 次后面板消失。改成先取可空引用 + 空值守卫。

真机复测:200 节点 / 55 层,打开即 fit 到 12%,放大 20 次 + 缩小 25 次
全部 0 崩溃 0 错误。

## 日历:翻月卡死

用户实测「点击切换月份的按钮,直接从10月跳到八月卡死」。
CDP 复现:

    第1跳起:opacity="0",transform="translateX(-94.8px)",元素 x = -15
    右侧工具栏(导入/导出/新建)整块消失

根因:注释写着「用**声明式** key + 类」,实现却是两个 ref ——
**ref 改动不触发重渲染** ⇒ slideClass 只能用上一次的方向,且 class 名永不变
⇒ 动画只播一次不再重放。而 `animation-fill-mode: both` 把元素留在帧之间,
动画一旦没跑到终点就永久停在 `translateX(-12%) + opacity:0`。

修:ref → `useState<{dir,seq}>`;`seq` 进 key 让动画真重放;
加 260ms 自清理兜底(最坏只是没动画,不会卡死)。
复测:12 次连点,每次都是「class 已摘掉 OK」,侧栏/网格/工具栏全好。

## 判据

`thread-view-tree.test.mjs` 15 格 + `calendar-slide.test.mjs` 6 格,
每个 bug 都有对应的**可复现变异**(退回 ref / 去掉清理 / 去掉 seq …),
实测全部转红。

## 教训

两轮都只跑 `tsc` + 判据就宣布完成,然后请用户去看 ——
**「类型没错」不等于「页面能用」**。日历那一轮我更是盲巡检了十几轮
(还把「看起来被切」误判成 bug,最后自己量出 scrollHeight==clientHeight 才撤回)。
**先问清楚,比继续猜便宜得多。**
This commit is contained in:
2026-10-05 09:09:36 +08:00
parent 6e5ea42138
commit 1f517eca4b
5 changed files with 710 additions and 277 deletions

View File

@ -0,0 +1,62 @@
/*
Electron 日历页的判据(2026-10-04)。
★ 起因:用户实测报告「点击切换月份的按钮,直接从10月跳到八月卡死」。
我用 CDP 真机复现时看到的是:
animation-play-state: running,opacity: 0,
transform: translateX(-94.8px),元素 x = -15(跑出视口)
⇒ 日历整块滑出屏幕且**永不回来**。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { code } from './lib/read.mjs';
const src = p => code(`src/${p}`);
test('★ 翻页动画状态必须是 state,不能是 ref', () => {
const cal = src('components/CalendarView.tsx');
// ★ 核心:注释写着「用**声明式** key + 类」,实现却是命令式 ref。
// ref 改动不触发重渲染 ⇒ slideClass 只能用上一次的方向,
// 且 class 名永不变 ⇒ 动画只播一次就停在起始帧 ⇒ 观感「卡死」。
assert.match(cal, /const \[slide, setSlide\] = useState/,
'★ 翻页动画状态还在用 ref —— ref 改动不触发重渲染,动画会卡在起始帧');
assert.doesNotMatch(cal, /navigated\.current|slideDir\.current/,
'★ 还在用 navigated/slideDir ref(命令式)—— 与注释宣称的「声明式」矛盾');
});
test('★ 每次翻页都要重放一次动画(key 含 seq)', () => {
const cal = src('components/CalendarView.tsx');
assert.match(cal, /seq: \(s\?\.seq \?\? 0\) \+ 1/, 'seq 没有递增');
assert.match(cal, /key=\{`\$\{anchor\.getTime\(\)\}-\$\{scale\}-\$\{slide\?\.seq \?\? 0\}`\}/,
'★ key 不含 seq —— 同方向连续翻页时动画可能不重放');
});
test('★ 动画结束必须摘掉 class(fill-mode:both 的兜底)', () => {
const cal = src('components/CalendarView.tsx');
// ★ animation-fill-mode: both 会把元素留在帧之间。动画一旦没跑到终点
// (连点 / 切后台 / reduced-motion 切换),元素就永久停在
// translateX(-12%) + opacity:0 —— 实测元素 x=-15,整块日历跑出视口。
assert.match(cal, /setTimeout\(\(\) => setSlide\(null\)/,
'★ 动画结束后没有清掉 class —— 元素会永久停在动画帧上(用户实测「卡死」)');
});
test('翻页方向只有一个来源(按钮与手势共用 shift)', () => {
const cal = src('components/CalendarView.tsx');
assert.match(cal, /function shift\(dir: 1 \| -1\)/, '没有统一的 shift()');
const n = (cal.match(/shift\(/g) || []).length;
assert.ok(n >= 3, `shift 只有 ${n} 处引用,按钮/手势可能各写一套`);
});
test('翻页用函数式更新(连点不丢更新)', () => {
const cal = src('components/CalendarView.tsx');
assert.match(cal, /setAnchor\(a => addMonths\(a, dir\)\)/,
'★ setAnchor 没用函数式更新 —— 连点时会基于同一个旧 anchor 算,跳月会丢步');
assert.match(cal, /addDays\(a, dir \* 7\)/, '周视图没有按 7 天翻页');
});
test('addMonths 先归到 1 号(setMonth 会溢出)', () => {
const lib = src('lib/calendar.ts');
assert.match(lib, /new Date\(d\.getFullYear\(\), d\.getMonth\(\) \+ n, 1\)/,
'★ addMonths 没有先归到 1 号 —— 1月31日加一月会溢出到 3 月');
});