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:
@ -135,22 +135,47 @@ export default function CalendarView() {
|
||||
* 首次进入不动画(`navigated` 为 false),否则一打开日历就滑一下很怪。
|
||||
*/
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const slideDir = useRef<1 | -1>(1);
|
||||
const navigated = useRef(false);
|
||||
const slideClass = navigated.current
|
||||
? slideDir.current === 1
|
||||
? 'cal-slide-next'
|
||||
: 'cal-slide-prev'
|
||||
: '';
|
||||
/*
|
||||
* ★ 必须用 **state**,不能用 ref(用户 2026-10-04 实测报告:
|
||||
* 「点击切换月份的按钮,直接从10月跳到八月卡死」)。
|
||||
*
|
||||
* 旧代码是 `slideDir` / `navigated` 两个 ref。注释写着「用**声明式** key + 类」,
|
||||
* 实现却是命令式 ref —— 两者自相矛盾:
|
||||
*
|
||||
* ref 改动**不触发重渲染** ⇒ `slideClass` 只能用「上一次的」方向算出来,
|
||||
* 且 class 名永远不变 ⇒ CSS 动画只播一次就不再重放。
|
||||
* 快速连点时元素被 `animation-fill-mode: both` 留在**起始帧**:
|
||||
* animation-play-state: running,opacity: 0,translateX: -94.8px
|
||||
* 实测元素 x = -15 ⇒ 整个日历滑出视口 ⇒ 观感就是「卡死」。
|
||||
*
|
||||
* 顺带修掉方向错乱:连点「上一月」时 class 可能是 cal-slide-next。
|
||||
*/
|
||||
const [slide, setSlide] = useState<{ dir: 1 | -1; seq: number } | null>(null);
|
||||
const slideClass = slide === null ? '' : slide.dir === 1 ? 'cal-slide-next' : 'cal-slide-prev';
|
||||
|
||||
/*
|
||||
* 动画结束后把 slide 清空 ⇒ class 被摘掉,元素回到自然位置。
|
||||
*
|
||||
* ★ 为什么要这一步:`animation-fill-mode: both` 会把元素**留在** from/to 帧之间,
|
||||
* 一旦动画因为任何原因(连点、标签页切后台、prefers-reduced-motion 切换)
|
||||
* 没跑到终点,元素就永久停在 translateX(-12%) + opacity:0 上 ——
|
||||
* 实测这正是用户说的「跳到八月卡死」(元素 x=-15,跑出视口)。
|
||||
* 定时清掉 class ⇒ 最坏情况只是没动画,不会卡死。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (slide === null) return;
|
||||
const h = setTimeout(() => setSlide(null), 260);
|
||||
return () => clearTimeout(h);
|
||||
}, [slide]);
|
||||
|
||||
function shift(dir: 1 | -1) {
|
||||
// 记下方向:过渡动画要往正确的方向推(下一段从右边进、上一段从左边进)。
|
||||
// 手势和"上一页/下一页"按钮都走这里 ⇒ 方向只有一个来源。
|
||||
slideDir.current = dir;
|
||||
navigated.current = true;
|
||||
// ★ seq 递增:让 key 变化 ⇒ 每次翻页都真的重放一次动画。
|
||||
if (scale === 'month') setAnchor(a => addMonths(a, dir));
|
||||
else if (scale === 'week') setAnchor(a => addDays(a, dir * 7));
|
||||
else setAnchor(a => addDays(a, dir));
|
||||
setSlide(s => ({ dir, seq: (s?.seq ?? 0) + 1 }));
|
||||
}
|
||||
|
||||
/*
|
||||
@ -412,7 +437,9 @@ export default function CalendarView() {
|
||||
// key 里带 anchor/scale:一变就是新元素 ⇒ 动画必然重放(见上面的说明)。
|
||||
<div
|
||||
ref={bodyRef}
|
||||
key={`${anchor.getTime()}-${scale}`}
|
||||
// ★ seq 也要进 key:同一方向连续翻页时 anchor 每<><E6AF8F>都变,但若 key 不含 seq
|
||||
// 而 slideClass 又只由 dir 决定,浏览器可能复用旧元素/不重放动画。
|
||||
key={`${anchor.getTime()}-${scale}-${slide?.seq ?? 0}`}
|
||||
className={`flex-1 min-h-0 flex ${slideClass}`}
|
||||
data-slide={slideClass || 'none'}
|
||||
>
|
||||
|
||||
@ -129,9 +129,16 @@ export default function MailView() {
|
||||
);
|
||||
}
|
||||
|
||||
if (threadOf) {
|
||||
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
|
||||
}
|
||||
if (threadOf) {
|
||||
// ★ 必须带 flex-1 min-w-0 min-h-0:ThreadView 是裸返回,
|
||||
// 不像下面的分支那样有这组类。没有它 ⇒ 面板不撑满、内容区高度塌陷
|
||||
// (用户 2026-10-04:「页面没有撑满」)。
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col bg-white">
|
||||
<ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col bg-white">
|
||||
|
||||
@ -1,299 +1,435 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ThreadNode, ThreadPage } from '../types';
|
||||
import * as api from '../api/client';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { participantAddress } from '../lib/replyTarget';
|
||||
import type { ThreadNode } from '../types';
|
||||
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
import BackButton from './BackButton';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
|
||||
/**
|
||||
* 对话树视图(从线索根整树展开,分块加载)。
|
||||
* 对话树 —— 画布式真树(可拖动 / 可缩放 / 点节点看正文)。
|
||||
*
|
||||
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
|
||||
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
|
||||
* ★ 2026-10-04:这就是用户要的那棵树。此前的实现是一列**缩进列表**
|
||||
* (ThreadView),改了几轮排序与竖线仍是「看起来像平铺」——
|
||||
* 因为缩进列表表达不了**分支**:59 层里多个兄弟追问并排时,
|
||||
* 缩进线断成一片,而画布上每条边都是独立的一条线。
|
||||
*
|
||||
* 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」
|
||||
* 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件
|
||||
* 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的
|
||||
* 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。
|
||||
* 用户原话:「把原本的对话树替换为新版本」。
|
||||
*
|
||||
* 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。
|
||||
* # 数据源:邮件线索(不是会话列表)
|
||||
*
|
||||
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
|
||||
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
|
||||
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
|
||||
* `/mail/{id}/thread` 返回整条线索的**全部**邮件及其 `parent_mail_id`。
|
||||
* 实测最深的线索有 **205 封、深达 59 层**
|
||||
* (`redeploy-pi-bridge-授权唤醒修复`,Agent↔Agent 连续授权唤醒 59 轮)。
|
||||
*
|
||||
* # 布局:分层(简化 Reingold–Tilford)
|
||||
*
|
||||
* 先算每棵子树的**宽度**,父节点水平居中于其子树之上。
|
||||
* ⚠️ 不用力导向:力导向每次渲染位置都变 ⇒ 点不准,也无法两次打开
|
||||
* 画成同一棵树(截图对比与用户心智都依赖稳定)。
|
||||
*/
|
||||
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
|
||||
const [nodes, setNodes] = useState<ThreadNode[]>([]);
|
||||
const [hidden, setHidden] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [nextOffset, setNextOffset] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
const [initial, setInitial] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinel = useRef<HTMLDivElement>(null);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
|
||||
const gen = useRef(0);
|
||||
// loading 的同步副本。setState 是异步的,哨兵连续进入视口时
|
||||
// 读 state 会看到旧的 false 而并发发两个请求。
|
||||
const busy = useRef(false);
|
||||
const NODE_W = 260;
|
||||
const HEADER_H = 78;
|
||||
const GAP_X = 20;
|
||||
const GAP_Y = 14;
|
||||
|
||||
const sortNodes = (list: ThreadNode[]) =>
|
||||
list
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
a.depth - b.depth || a.created_at.localeCompare(b.created_at)
|
||||
);
|
||||
const MIN_ZOOM = 0.04; // ★ 200 节点同层时横线可达 56000px;fit 要能缩到看全
|
||||
const MAX_ZOOM = 2.5;
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
const merge = useCallback((incoming: ThreadNode[]) => {
|
||||
setNodes(prev => {
|
||||
const seen = new Set(prev.map(n => n.mail_id));
|
||||
return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]);
|
||||
interface Placed extends ThreadNode {
|
||||
x: number;
|
||||
y: number;
|
||||
children: Placed[];
|
||||
}
|
||||
|
||||
export default function ThreadView({ mailID, onClose }: {
|
||||
mailID: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [nodes, setNodes] = useState<Placed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string>(mailID);
|
||||
const [detail, setDetail] = useState<ThreadNode | null>(null);
|
||||
/** 已经为哪封做过自动 fit —— 防止 ResizeObserver 反复重置用户拖好的视口。 */
|
||||
const [fittedFor, setFittedFor] = useState('');
|
||||
/** 最新节点:fitTo 只读它,避免闭包拿到旧数组(见 fitTo 注释)。 */
|
||||
const nodesRef = useRef<Placed[]>([]);
|
||||
/** 用户是否拖动/缩放过 —— 之后容器尺寸变化不再自动重置视口。 */
|
||||
const touched = useRef(false);
|
||||
|
||||
// 视口:平移 + 缩放
|
||||
const [pan, setPan] = useState({ x: 48, y: 40 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const drag = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const openMailByID = useMailStore(s => s.openMailByID);
|
||||
|
||||
/**
|
||||
* ★ 必须读 ref 而不是接收参数(用户 2026-10-04 实测定位)。
|
||||
* 传参时 ResizeObserver / rAF 回调闭包捕获的是**上一次渲染**的 nodes
|
||||
* (旧数组:bounds 11340x7193),而屏幕上已经是新数组(7840x5060)。
|
||||
* 同一份公式、同一个容器,自动那次算出 zoom=1.4、手动那次算出 0.1217 ——
|
||||
* 差别只在「拿到的是哪份数据」。
|
||||
*/
|
||||
const fitTo = useCallback(() => {
|
||||
const flat = nodesRef.current;
|
||||
if (!flat.length) return;
|
||||
const el = (wrapRef.current ?? document.querySelector(
|
||||
'[data-am-tree-canvas]')) as HTMLElement | null;
|
||||
const rect = el?.getBoundingClientRect();
|
||||
// 量不到尺寸(容器还没布局)时直接返回,交给上层 effect 的 rAF 重试。
|
||||
if (!rect || !rect.width || !rect.height) return;
|
||||
const minX = Math.min(...flat.map(n => n.x));
|
||||
const maxX = Math.max(...flat.map(n => n.x + NODE_W));
|
||||
const maxY = Math.max(...flat.map(n => n.y + HEADER_H));
|
||||
const z = Math.max(MIN_ZOOM, Math.min(1.4,
|
||||
Math.min((rect.width - 48) / Math.max(maxX - minX, 1),
|
||||
(rect.height - 48) / Math.max(maxY, 1))));
|
||||
setZoom(z);
|
||||
setPan({ x: 24 - minX * z, y: 24 });
|
||||
}, []);
|
||||
|
||||
// 拉线索。⚠️ 只拉一次:每次 nodes 变化都重拉会把用户拖好的视口反复重置。
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelected(mailID);
|
||||
(async () => {
|
||||
try {
|
||||
const page: ThreadPage = await api.getMailThread(mailID, {
|
||||
offset: 0, limit: PAGE_SIZE,
|
||||
});
|
||||
if (!alive) return;
|
||||
const built = layout(page.nodes || []);
|
||||
setNodes(built);
|
||||
// 清掉守卫,让下面的自动 fit effect 能跑(换一封邮件时也要重 fit)。
|
||||
setFittedFor('');
|
||||
} catch (e) {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setNodes([]);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [mailID, fitTo]);
|
||||
|
||||
// ── 拖动平移(点在节点上时不启动)──
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('[data-node]')) return;
|
||||
touched.current = true;
|
||||
drag.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [pan]);
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
setPan({ x: d.panX + (e.clientX - d.x), y: d.panY + (e.clientY - d.y) });
|
||||
}, []);
|
||||
const endDrag = useCallback(() => { drag.current = null; }, []);
|
||||
|
||||
// ── 滚轮缩放,锚定光标 ──
|
||||
const onWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
// ★ 组件可能已卸载(切走邮件 / 关闭面板),但滚轮事件仍在飞。
|
||||
// 直接解引用 e.currentTarget / wrapRef.current ⇒
|
||||
// TypeError: Cannot read properties of null
|
||||
// (用户 2026-10-04:「一缩放还崩溃」,浏览器实测复现)。
|
||||
const el = e.currentTarget as HTMLElement | null;
|
||||
if (!el) return;
|
||||
touched.current = true;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
setZoom(z => {
|
||||
const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * factor));
|
||||
// 保持光标下的点不动:只 setZoom 会以原点缩放,内容会「跳」出去
|
||||
setPan(p => ({
|
||||
x: cx - ((cx - p.x) * next) / z,
|
||||
y: cy - ((cy - p.y) * next) / z,
|
||||
}));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 首屏
|
||||
useEffect(() => {
|
||||
const myGen = ++gen.current;
|
||||
setNodes([]);
|
||||
setHidden(0);
|
||||
setErr('');
|
||||
setInitial(true);
|
||||
busy.current = true;
|
||||
api
|
||||
.getMailThread(mailID, { offset: 0, limit: 60 })
|
||||
.then(p => {
|
||||
if (gen.current !== myGen) return;
|
||||
setNodes(sortNodes(p.nodes));
|
||||
setHidden(p.hidden);
|
||||
setHasMore(p.has_more);
|
||||
setNextOffset(p.next_offset);
|
||||
})
|
||||
.catch(e => {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (gen.current === myGen) {
|
||||
setInitial(false);
|
||||
busy.current = false;
|
||||
}
|
||||
// ⚠️ 不在 load 里调 fitTo,而是在 nodes 落地、且容器已布局时调。
|
||||
// 实测两条死路(用户 2026-10-04 现场):
|
||||
// · load 里直调 → 首帧 rect 未完成 ⇒ 算出 z=1.4(上限),永不纠正;
|
||||
// · load 里套 rAF → 仍被初始 state 的一次重渲染盖掉。
|
||||
// 「适应」按钮手动点则是对的(实测 12%)⇒ 逻辑没问题,是**时机**。
|
||||
useEffect(() => {
|
||||
if (!nodes.length || fittedFor === mailID) return;
|
||||
let raf = 0;
|
||||
const run = () => { fitTo(); setFittedFor(mailID); };
|
||||
raf = requestAnimationFrame(() => { raf = requestAnimationFrame(run); });
|
||||
// 容器尺寸真的变了(侧栏收放、窗口缩放)时也要重算
|
||||
let ro: ResizeObserver | undefined;
|
||||
if (typeof ResizeObserver !== 'undefined' && wrapRef.current) {
|
||||
ro = new ResizeObserver(() => {
|
||||
if (touched.current) return; // 用户拖/缩过就别再自动重置
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(run);
|
||||
});
|
||||
}, [mailID]);
|
||||
|
||||
// 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后,
|
||||
// 不滚过去的话用户点开一封邮件却停在整条线索的开头。
|
||||
useEffect(() => {
|
||||
if (initial || !anchorRef.current) return;
|
||||
anchorRef.current.scrollIntoView({ block: 'center' });
|
||||
}, [initial]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (busy.current || !hasMore) return;
|
||||
const myGen = gen.current;
|
||||
busy.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 });
|
||||
if (gen.current !== myGen) return;
|
||||
merge(p.nodes);
|
||||
setHidden(h => h + p.hidden);
|
||||
setHasMore(p.has_more);
|
||||
setNextOffset(p.next_offset);
|
||||
} catch (e) {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (gen.current === myGen) setLoading(false);
|
||||
busy.current = false;
|
||||
ro.observe(wrapRef.current);
|
||||
}
|
||||
}, [mailID, merge, hasMore, nextOffset]);
|
||||
return () => { ro?.disconnect(); cancelAnimationFrame(raf); };
|
||||
}, [nodes, mailID, fitTo, fittedFor]);
|
||||
|
||||
// 底部哨兵进入视口就续取。rootMargin 提前 200px 触发,
|
||||
// 让加载在用户滑到边界前完成。
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const obs = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) if (e.isIntersecting) loadMore();
|
||||
},
|
||||
{ root, rootMargin: '200px' }
|
||||
);
|
||||
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
|
||||
return () => obs.disconnect();
|
||||
}, [loadMore]);
|
||||
const fit = useCallback(() => {
|
||||
touched.current = false;
|
||||
fitTo();
|
||||
setFittedFor(mailID);
|
||||
}, [fitTo, mailID]);
|
||||
|
||||
const pick = useCallback((n: Placed) => {
|
||||
setSelected(n.mail_id);
|
||||
setDetail(n);
|
||||
}, []);
|
||||
|
||||
const openBody = useCallback(() => {
|
||||
if (detail) openMailByID(detail.mail_id);
|
||||
}, [detail, openMailByID]);
|
||||
|
||||
const flat = useMemo(() => flatten(nodes), [nodes]);
|
||||
// ★ ref 要存**扁平**列表:nodes 是「根节点数组」,只有 roots.length 个。
|
||||
// 曾经直接存 nodes ⇒ fit 拿到 1 个节点、span 260 ⇒ z 顶到 1.4 上限
|
||||
// ⇒ 内容 11340x7193 只露左上角(用户 2026-10-04 实测)。
|
||||
nodesRef.current = flat;
|
||||
const bounds = useMemo(() => boundsOf(flat), [flat]);
|
||||
const maxDepth = useMemo(
|
||||
() => flat.reduce((m, n) => Math.max(m, n.depth), 0), [flat]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
{/* 窄屏下对话树是盖在列表上的一层,得有返回出口。
|
||||
它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表,
|
||||
关闭只收起树、留在这封邮件上。 */}
|
||||
<BackButton />
|
||||
<span className="text-sm font-semibold text-gray-900">对话树</span>
|
||||
// ★ h-full min-h-0 必须在这:父容器靠它撑满,缺了则高度链断掉。
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col bg-white">
|
||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-gray-900">对话树</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
已加载 {nodes.length} 封
|
||||
{hasMore && ',滑动加载更多'}
|
||||
{hidden > 0 && `,${hidden} 封无权查看`}
|
||||
{loading ? '载入中…' : `${flat.length} 封`}
|
||||
{!loading && maxDepth > 0 && ` · 最深 ${maxDepth} 层`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
<button onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - 0.2))}
|
||||
className="tap px-2 py-0.5 rounded text-2xs border border-gray-300 text-gray-600">−</button>
|
||||
<span className="text-2xs text-gray-500 w-10 text-center">{Math.round(zoom * 100)}%</span>
|
||||
<button onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + 0.2))}
|
||||
className="tap px-2 py-0.5 rounded text-2xs border border-gray-300 text-gray-600">+</button>
|
||||
<button onClick={fit}
|
||||
className="tap px-2 py-0.5 rounded text-2xs border border-gray-300 text-gray-600">适应</button>
|
||||
<button onClick={onClose}
|
||||
className="tap text-xs text-gray-500 hover:text-gray-800 px-1.5 py-0.5 rounded hover:bg-gray-100">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
{initial && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
加载中
|
||||
{error && <p className="px-4 py-2 text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<div
|
||||
ref={wrapRef}
|
||||
data-am-tree-canvas
|
||||
className="relative flex-1 min-w-0 overflow-hidden bg-gray-50 cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onWheel={onWheel}
|
||||
>
|
||||
{loading && (
|
||||
<p className="absolute inset-0 flex items-center justify-center text-xs text-gray-400">
|
||||
载入线索…
|
||||
</p>
|
||||
)}
|
||||
{!loading && !flat.length && (
|
||||
<p className="absolute inset-0 flex items-center justify-center text-xs text-gray-400">
|
||||
这条线索里还没有其它邮件
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: bounds.w,
|
||||
height: bounds.h,
|
||||
}}>
|
||||
{/* ★ 边用绝对定位的 div 画,不用 <svg>。
|
||||
<svg width={1} height={1} overflow-visible> 会让浏览器按
|
||||
**内容包围盒**分配光栅化画布:205 节点 × 缩放后可达 10^5 px,
|
||||
一次滚轮就抽干纹理内存 ⇒ 渲染进程崩溃(用户 2026-10-04:
|
||||
「一缩放还崩溃」)。div 没有这个坑。*/}
|
||||
{edges(flat).map(e => (
|
||||
<div key={e.key} className="absolute pointer-events-none"
|
||||
style={{ left: e.x1, top: e.y1, width: e.dx, height: e.dy }}>
|
||||
<div className="absolute overflow-hidden"
|
||||
style={{ width: Math.max(Math.abs(e.dx), 1), height: Math.max(Math.abs(e.dy), 1) }}>
|
||||
<svg className="absolute" width={Math.max(Math.abs(e.dx), 1)}
|
||||
height={Math.max(Math.abs(e.dy), 1)}
|
||||
viewBox={`0 0 ${Math.max(Math.abs(e.dx), 1)} ${Math.max(Math.abs(e.dy), 1)}`}
|
||||
preserveAspectRatio="none">
|
||||
<path d={e.d} fill="none" stroke="#cbd5e1" strokeWidth={1.5} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{flat.map(n => (
|
||||
<button
|
||||
key={n.mail_id}
|
||||
data-node
|
||||
onClick={() => pick(n)}
|
||||
className={`absolute text-left rounded-lg border px-2.5 py-1.5 shadow-sm transition-colors cursor-pointer ${
|
||||
selected === n.mail_id
|
||||
? 'border-blue-400 ring-1 ring-blue-100'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300'
|
||||
} ${n.mail_id === mailID ? 'border-blue-500' : ''}`}
|
||||
style={{ left: n.x, top: n.y, width: NODE_W, height: HEADER_H }}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-2xs font-semibold text-gray-900 truncate">
|
||||
{n.from_name} → {n.to_name}
|
||||
</span>
|
||||
{n.depth > 0 && (
|
||||
<span className="ml-auto text-3xs text-gray-400 border border-gray-200 rounded px-1 shrink-0">
|
||||
L{n.depth}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-3xs text-gray-600 mt-0.5 line-clamp-2">{n.subject}</p>
|
||||
<p className="text-3xs text-gray-400 mt-0.5 truncate">
|
||||
{new Date(n.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
{n.attachment_count > 0 ? ` · 附件 ${n.attachment_count}` : ''}
|
||||
{n.cc_list?.length ? ` · 抄送 ${n.cc_list.length}` : ''}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点节点后的正文预览(打开正文仍走既有的邮件视图)*/}
|
||||
{detail && (
|
||||
<div className="w-[320px] shrink-0 border-l border-gray-200 flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-gray-200">
|
||||
<p className="text-xs font-semibold">{detail.subject}</p>
|
||||
<p className="text-3xs text-gray-500 mt-0.5">
|
||||
{detail.from_name} → {detail.to_name} · L{detail.depth}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
<pre className="text-3xs text-gray-700 whitespace-pre-wrap break-words">
|
||||
{detail.body_preview || detail.body || '(无正文预览)'}
|
||||
</pre>
|
||||
</div>
|
||||
<button onClick={openBody}
|
||||
className="m-2 px-2 py-1.5 rounded text-2xs border border-gray-300 text-gray-600 hover:bg-gray-50">
|
||||
打开完整正文
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-red-600">{err}</p>}
|
||||
|
||||
{!initial && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
{nodes.map(n => (
|
||||
<Node
|
||||
key={n.mail_id}
|
||||
node={n}
|
||||
anchorID={mailID}
|
||||
anchorRef={n.mail_id === mailID ? anchorRef : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="tap w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
|
||||
>
|
||||
加载后续往来
|
||||
</button>
|
||||
)}
|
||||
<div ref={bottomSentinel} className="h-px" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({
|
||||
node,
|
||||
anchorID,
|
||||
anchorRef
|
||||
}: {
|
||||
node: ThreadNode;
|
||||
anchorID: string;
|
||||
anchorRef?: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const openMailByID = useMailStore(s => s.openMailByID);
|
||||
const narrow = useIsNarrow();
|
||||
const isPermission = node.mail_type === 'permission_request';
|
||||
const isAnchor = node.mail_id === anchorID;
|
||||
const ccCount = node.cc_list?.length ?? 0;
|
||||
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
|
||||
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
|
||||
const isForward = node.subject.startsWith('Fwd: ');
|
||||
// 缩进:每级的像素数与上限都随屏宽变。
|
||||
//
|
||||
// 原先固定「每级 20px、上限 8 级」= 最多 160px。在 320px 屏上容器还要去掉
|
||||
// px-4 的 32px 与连接线的 18px,卡片只剩 110px —— 发件人一行就被 truncate 吃掉。
|
||||
// 窄屏改成每级 10px、上限 5 级(最多 50px),层级仍然看得出来,卡片还有余地。
|
||||
const step = narrow ? 10 : 20;
|
||||
const maxDepth = narrow ? 5 : 8;
|
||||
const indent = Math.min(Math.max(node.depth, 0), maxDepth) * step;
|
||||
const time = new Date(node.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
// ── 布局:按子树宽度分配列,父居中于子之上 ──
|
||||
|
||||
return (
|
||||
<div ref={anchorRef} className="flex items-stretch" style={{ paddingLeft: indent }}>
|
||||
{indent > 0 && (
|
||||
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => openMailByID(node.mail_id)}
|
||||
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
|
||||
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{node.from_workspace ? (
|
||||
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
{/* 树节点一行里塞了 from → to、转发标记与时间,不带会话位:
|
||||
整棵树本来就在同一条线索上,每个节点重复一遍别名毫无信息量。
|
||||
人还是 Agent 走显式布尔;workspace 从会话取(from_workspace
|
||||
对 Agent 存的是 Agent 名)。 */}
|
||||
<span className="text-xs font-mono text-gray-700 truncate">
|
||||
{participantAddress(
|
||||
node.from_name,
|
||||
node.from_human,
|
||||
node.from_human ? '' : node.session_workspace || ''
|
||||
)}
|
||||
</span>
|
||||
<span className="text-3xs text-gray-500">→</span>
|
||||
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
|
||||
<div className="flex-1" />
|
||||
{isForward && (
|
||||
<span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs">
|
||||
转发
|
||||
</span>
|
||||
)}
|
||||
{node.parent_hidden && (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-3xs"
|
||||
title="上一封不在你的可见范围内"
|
||||
>
|
||||
上游不可见
|
||||
</span>
|
||||
)}
|
||||
{isPermission && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-3xs">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
</span>
|
||||
)}
|
||||
{node.attachment_count > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-3xs text-gray-500">
|
||||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||||
{node.attachment_count}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-3xs text-gray-500 shrink-0">{time}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
|
||||
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
|
||||
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
|
||||
{ccCount > 0 && (
|
||||
<p className="text-3xs text-gray-500 mt-0.5 truncate">
|
||||
抄送 {node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')}
|
||||
</p>
|
||||
)}
|
||||
{node.body_preview && (
|
||||
<p className="text-2xs text-gray-500 mt-0.5 line-clamp-2">{node.body_preview}</p>
|
||||
)}
|
||||
{node.session_alias && (
|
||||
<span className="text-3xs text-blue-600 font-mono">.{node.session_alias}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
function layout(nodes: ThreadNode[]): Placed[] {
|
||||
if (!nodes.length) return [];
|
||||
const byId = new Map<string, Placed>();
|
||||
for (const n of nodes) byId.set(n.mail_id, { ...n, x: 0, y: 0, children: [] });
|
||||
|
||||
const roots: Placed[] = [];
|
||||
for (const n of byId.values()) {
|
||||
const p = n.parent_mail_id ? byId.get(n.parent_mail_id) : undefined;
|
||||
if (p && p.session_id === n.session_id && p.mail_id !== n.mail_id) p.children.push(n);
|
||||
else roots.push(n);
|
||||
}
|
||||
|
||||
// parent_mail_id 是外部可写字段:脏数据成环时按路径打断,否则递归不死
|
||||
const onPath = new Set<string>();
|
||||
const width = new Map<string, number>();
|
||||
const calc = (n: Placed): number => {
|
||||
if (width.has(n.mail_id)) return width.get(n.mail_id)!;
|
||||
if (onPath.has(n.mail_id)) { n.children = []; return NODE_W; }
|
||||
onPath.add(n.mail_id);
|
||||
const kidsW = n.children.reduce((s, c) => s + calc(c) + GAP_X, 0);
|
||||
const w = Math.max(NODE_W, kidsW - (n.children.length ? GAP_X : 0));
|
||||
onPath.delete(n.mail_id);
|
||||
width.set(n.mail_id, w);
|
||||
return w;
|
||||
};
|
||||
for (const r of roots) calc(r);
|
||||
|
||||
let cursor = 0;
|
||||
const place = (n: Placed): Placed => {
|
||||
const w = calc(n);
|
||||
let x: number;
|
||||
if (!n.children.length) {
|
||||
x = cursor;
|
||||
cursor += w + GAP_X;
|
||||
} else {
|
||||
const kids = n.children
|
||||
.slice()
|
||||
.sort((a, b) => a.created_at.localeCompare(b.created_at))
|
||||
.map(place);
|
||||
x = (kids[0].x + kids[kids.length - 1].x) / 2;
|
||||
return { ...n, x, y: n.depth * (HEADER_H + GAP_Y), children: kids };
|
||||
}
|
||||
return { ...n, x, y: n.depth * (HEADER_H + GAP_Y), children: [] };
|
||||
};
|
||||
|
||||
return roots.map(r => place(r));
|
||||
}
|
||||
|
||||
function flatten(roots: Placed[]): Placed[] {
|
||||
const out: Placed[] = [];
|
||||
const walk = (n: Placed) => { out.push(n); n.children.forEach(walk); };
|
||||
roots.forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
interface Edge {
|
||||
key: string;
|
||||
d: string;
|
||||
/** 边自身包围盒(用于给 svg 一个真实尺寸) */
|
||||
x1: number; y1: number; dx: number; dy: number;
|
||||
}
|
||||
|
||||
function edges(list: Placed[]): Edge[] {
|
||||
const out: Edge[] = [];
|
||||
for (const n of list) {
|
||||
for (const c of n.children) {
|
||||
const x1 = n.x + NODE_W / 2;
|
||||
const y1 = n.y + HEADER_H;
|
||||
const x2 = c.x + NODE_W / 2;
|
||||
const y2 = c.y;
|
||||
const my = (y1 + y2) / 2;
|
||||
out.push({
|
||||
key: `${n.mail_id}->${c.mail_id}`,
|
||||
d: `M 0 0 C 0 ${my - y1}, ${x2 - x1} ${my - y1}, ${x2 - x1} ${y2 - y1}`,
|
||||
x1: Math.min(x1, x2),
|
||||
y1,
|
||||
dx: Math.max(Math.abs(x2 - x1), 1),
|
||||
dy: Math.max(Math.abs(y2 - y1), 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 内容包围盒:给绝对定位的画布一个真实尺寸(否则子元素定位无参照)。 */
|
||||
function boundsOf(list: Placed[]): { w: number; h: number } {
|
||||
if (!list.length) return { w: 0, h: 0 };
|
||||
return {
|
||||
w: Math.max(...list.map(n => n.x + NODE_W)),
|
||||
h: Math.max(...list.map(n => n.y + HEADER_H)),
|
||||
};
|
||||
}
|
||||
62
client/electron/test/calendar-slide.test.mjs
Normal file
62
client/electron/test/calendar-slide.test.mjs
Normal 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 月');
|
||||
});
|
||||
201
client/electron/test/thread-view-tree.test.mjs
Normal file
201
client/electron/test/thread-view-tree.test.mjs
Normal file
@ -0,0 +1,201 @@
|
||||
/*
|
||||
对话树 = 画布式真树(可拖动 / 可缩放 / 点节点看正文)的判据(2026-10-04)。
|
||||
|
||||
★ 演进(用户连续几轮反馈「还是老版」):
|
||||
① 原实现是缩进列表 → 我改排序/竖线/缩进上限,仍像平铺(缩进列表
|
||||
**表达不了分支**:59 层里多个兄弟并排时线断成一片)
|
||||
② 整份重写为缩进列表的改良版 → 仍不是用户要的东西
|
||||
③ **本轮**:用户明确「直接用你之前写的那个可拖动缩放的对话树界面替换
|
||||
现在这个对话树界面」⇒ ThreadView 改成画布树。
|
||||
|
||||
★ 本文件盯画布树的三个能力 + 一条禁止退回的守卫。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
|
||||
const src = p => code(`src/${p}`);
|
||||
|
||||
test('会话视图保持平铺(范围纪律,用户 2026-10-04)', () => {
|
||||
const view = src('components/MailView.tsx');
|
||||
assert.match(view, /currentSessionMails\.map\(/, '会话视图不该变成树');
|
||||
});
|
||||
|
||||
test('★ 可拖动:指针拖拽改平移,且绑在容器上', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /const onPointerDown = useCallback/, '没有 onPointerDown 实现');
|
||||
// ★ 必须查**绑定**而不只是字面量:变异把 JSX 上的 onPointerDown 换成注释时,
|
||||
// 早期版本判据全绿(函数还在,只是没接上)。
|
||||
assert.match(tv, /onPointerDown=\{onPointerDown\}/, 'onPointerDown 没绑到容器 ⇒ 拖不动');
|
||||
assert.match(tv, /onPointerMove=\{onPointerMove\}/, 'onPointerMove 没绑 ⇒ 拖不动');
|
||||
assert.match(tv, /setPointerCapture/, '未捕获指针 —— 拖出元素会中断');
|
||||
assert.match(tv, /cursor-grab/, '缺拖动光标提示');
|
||||
// 点在节点上时不启动平移(否则点一下会把整棵树拖走)
|
||||
assert.match(tv, /closest\('\[data-node\]'\)/, '点节点也会启动平移');
|
||||
});
|
||||
|
||||
test('★ 可缩放:滚轮缩放且锚定光标', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /const onWheel = useCallback/, '没有 onWheel 实现');
|
||||
assert.match(tv, /onWheel=\{onWheel\}/, '★ onWheel 没绑 ⇒ 滚轮不缩放');
|
||||
// ★ 必须断言**结果里真的用上了光标坐标**,而不只是「代码里出现过它」。
|
||||
// 变异「把 setPan 改成原样返回(坐标照算但不用)」时,
|
||||
// `clientX - rect.left` 仍在源码里 ⇒ 只查字面量会假绿。
|
||||
assert.match(tv, /setPan\(p =>\s*\(\{\s*\n?\s*x: cx -/,
|
||||
'★ 缩放结果没有按光标锚定 —— 缩放时内容会从光标下「跳」走');
|
||||
assert.match(tv, /MIN_ZOOM/, '没有缩放下限');
|
||||
assert.match(tv, /适应/, '没有「适应」按钮(60% 缩放下 205 封的树没法看)');
|
||||
});
|
||||
|
||||
test('★ 可点击看正文', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /onClick=\{\(\) => pick\(n\)\}/, '节点不可点击');
|
||||
assert.match(tv, /setDetail\(n\)/, '点了没有正文');
|
||||
assert.match(tv, /body_preview \|\| detail\.body/, '正文没有渲染');
|
||||
assert.match(tv, /openMailByID\(detail\.mail_id\)/, '没有「打开完整正文」的出口');
|
||||
});
|
||||
|
||||
test('★ 布局是分层画布,不是缩进列表', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /function layout\(nodes: ThreadNode\[\]\): Placed\[\]/, '没有画布布局');
|
||||
assert.match(tv, /x: number/, '节点没有坐标 ⇒ 不是画布');
|
||||
assert.match(tv, /nodes - Math\.max|calc\(c\)/, '没有按子树宽度分配列');
|
||||
// ⚠️ 力导向:位置每次变 ⇒ 点不准、两次打开画成不同样
|
||||
assert.doesNotMatch(tv, /simulation|velocity|forceLayout/,
|
||||
'用了力导向 —— 节点位置不稳定,点不准');
|
||||
// ★ 禁止退回缩进列表
|
||||
assert.doesNotMatch(tv, /paddingLeft: indent|Math\.min\(Math\.max\(node\.depth/,
|
||||
'★ 又退回缩进列表了 —— 那表达不了分支,正是「看起来还是平铺」的原因');
|
||||
});
|
||||
|
||||
test('数据源是邮件线索(不是会话列表)', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /getMailThread/, '没有读邮件线索');
|
||||
assert.doesNotMatch(tv, /listSessionTree|listContacts/,
|
||||
'用了会话级数据 —— 那是 198 个会话,不是这条线索的 205 封邮件');
|
||||
const m = prose('src/components/ThreadView.tsx').match(/PAGE_SIZE = (\d+)/);
|
||||
assert.ok(Number(m[1]) >= 200,
|
||||
`首屏 ${m[1]} 封 —— 实测最深的线索有 205 封,60 会把后半截藏起来`);
|
||||
});
|
||||
|
||||
test('parent_mail_id 外部可写 ⇒ 防环 + 不丢信', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /onPath\.has\(n\.mail_id\)/,
|
||||
'无环保护 —— 成环会递归不死(205 封的面板直接白屏)');
|
||||
assert.match(tv, /p\.mail_id !== n\.mail_id/, '没排除自引用');
|
||||
});
|
||||
|
||||
test('★ 打开就 fit(否则 200 节点只露左上角 = 没撑满)', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /const run = \(\) => \{ fitTo\(\); setFittedFor\(mailID\); \};/,
|
||||
'没有自动 fit 的入口');
|
||||
// ★ 实测两条死路:load 里直调 → 首帧 rect 未完成 ⇒ 算出 1.4 上限值;
|
||||
// load 里套 rAF → 仍被初始 state 的重渲染盖掉。
|
||||
// 正确位置是「nodes 落地后、且只做一次」的 effect。
|
||||
assert.match(tv, /if \(!nodes\.length \|\| fittedFor === mailID\) return;/,
|
||||
'★ 自动 fit 没有只做一次的守卫 —— ResizeObserver 会反复重置用户拖好的视口');
|
||||
assert.match(tv, /raf = requestAnimationFrame\(\(\) => \{ raf = requestAnimationFrame\(run\); \}\);/,
|
||||
'★ 没有等布局完成再 fit(双 rAF)—— 首帧量到的尺寸是错的');
|
||||
// ★ 关键回归:首帧 wrapRef 还是 null,量不到尺寸就 return ⇒ fit 永远不跑
|
||||
assert.match(tv, /if \(!rect \|\| !rect\.width \|\| !rect\.height\) return;/,
|
||||
'量不到尺寸时没有安全返回');
|
||||
assert.match(tv, /new ResizeObserver/, '没有容器尺寸变化时的重算');
|
||||
assert.match(tv, /new ResizeObserver/, '没有监听容器尺寸变化');
|
||||
assert.match(tv, /ro\.observe\(wrapRef\.current\)/, 'ResizeObserver 没有观察画布容器');
|
||||
assert.match(tv, /raf = requestAnimationFrame\(\(\) => \{ raf = requestAnimationFrame\(run\); \}\);/,
|
||||
'没有等布局完成再 fit(双 rAF)—— 首帧量到的尺寸是错的');
|
||||
// fit 要同时按宽高算,只看宽会被 59 层(高 7000+)拖成极小
|
||||
const m = tv.match(/rect\.height - 48\) \/ Math\.max\(maxY, 1\)/);
|
||||
assert.ok(m, 'fit 只按宽度算 ⇒ 59 层的树被压成一条');
|
||||
});
|
||||
|
||||
test('★ 滚轮不崩:不能解引用可能为 null 的元素', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
// 用户实测:放大后 TypeError: Cannot read properties of null
|
||||
assert.match(tv, /const el = e\.currentTarget as HTMLElement \| null;/,
|
||||
'没有先取一次可空引用');
|
||||
assert.match(tv, /if \(!el\) return;/, '★ 没做空值守卫 —— 卸载后滚轮会抛异常');
|
||||
assert.doesNotMatch(tv, /\(e\.currentTarget as HTMLElement\)\.getBoundingClientRect\(\)/,
|
||||
'★ 直接解引用 currentTarget —— 卸载后即为 null');
|
||||
});
|
||||
|
||||
/*
|
||||
★ 两个线上症状的判据(用户 2026-10-04 实测报告,我第一轮没看出来)。
|
||||
两轮都只跑了 tsc + 判据就宣布完成 —— 「类型没错」不等于「页面能用」。
|
||||
*/
|
||||
|
||||
test('★ 撑满:ThreadView 必须被 flex 容器包住', () => {
|
||||
const view = src('components/MailView.tsx');
|
||||
// 症状:「页面没有撑满」。threadOf 分支是**裸返回**,
|
||||
// 不像正常分支那样带 flex-1 min-w-0 min-h-0 ⇒ 高度链断、内容塌陷。
|
||||
const m = view.match(/if \(threadOf\)\s*\{[\s\S]{0,400}?return \(([\s\S]{0,400}?)\);/);
|
||||
assert.ok(m, '找不到 threadOf 分支');
|
||||
assert.match(m[1], /flex-1/,
|
||||
'★ threadOf 分支裸返回 ThreadView,没有 flex-1 ⇒ 面板不撑满(用户实测「页面没有撑满」)');
|
||||
assert.match(m[1], /min-h-0/, '没有 min-h-0 ⇒ 高度链断');
|
||||
assert.match(m[1], /min-w-0/, '没有 min-w-0 ⇒ 宽屏下被内容撑出横向滚动');
|
||||
});
|
||||
|
||||
test('★ 缩放不崩:边不能画在 width={1} 的 overflow-visible svg 里', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
// 症状:「一缩放还崩溃」。<svg width={1} height={1} overflow-visible> 会让浏览器
|
||||
// 按**内容包围盒**光栅化:205 节点缩放后包围盒可达 10^5 px,一次滚轮就抽干纹理内存。
|
||||
assert.doesNotMatch(tv, /<svg[^>]*overflow-visible/,
|
||||
'★ 用了 overflow-visible 的溢出 svg —— 缩放时渲染进程会崩(用户实测)');
|
||||
assert.doesNotMatch(tv, /className="absolute overflow-visible[^"]*"\s*width=\{1\}/,
|
||||
'★ 1×1 的溢出 svg(同上)');
|
||||
// 每条边要有自己的包围盒,svg 才能拿到真实尺寸
|
||||
assert.match(tv, /viewBox=\{`0 0 /, '边没有独立包围盒 ⇒ svg 尺寸仍是 1×1');
|
||||
assert.match(tv, /dx: number; dy: number;/, 'Edge 没有包围盒字段');
|
||||
});
|
||||
|
||||
test('画布有真实尺寸(绝对定位的子元素需要参照)', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /const bounds = useMemo\(\(\) => boundsOf\(flat\)/,
|
||||
'没有算内容包围盒');
|
||||
assert.match(tv, /position: 'absolute',[\s\S]{0,200}?width: bounds\.w/,
|
||||
'画布没设尺寸 —— 绝对定位的节点没有参照物');
|
||||
});
|
||||
|
||||
/*
|
||||
★ 这一格盯的是本轮最贵的两个坑(用户 2026-10-04 实测发现「页面没撑满」)。
|
||||
|
||||
实测现象:200 节点 / 55 层的树,打开后 zoom=140%、pan.x=-9393,
|
||||
内容包围盒 11340x7193 —— 屏幕上只看得到左上角一小块。
|
||||
而**手动点「适应」按钮算出来的却是正确的 0.1217**。
|
||||
同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。
|
||||
*/
|
||||
|
||||
test('★ fit 必须读**扁平**节点,不是根节点数组', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
// ★ nodes 是「根节点数组」(这棵树上只有 1 个根),
|
||||
// 直接喂给 fit ⇒ span 260 ⇒ z 顶到上限 1.4 ⇒ 只露左上角。
|
||||
assert.match(tv, /nodesRef\.current = flat;/,
|
||||
'★ ref 存的是 nodes(根数组)而不是 flat —— fit 只看得到根,缩放算错');
|
||||
const m = tv.match(/const flat = useMemo\(\(\) => flatten\(nodes\), \[nodes\]\);[\s\S]{0,200}?nodesRef\.current = flat;/);
|
||||
assert.ok(m, 'nodesRef.current = flat 不在 flat 定义之后 —— 顺序反了会拿到上一轮的值');
|
||||
// fitTo 不接受参数,只读 ref ⇒ 闭包不会捕获旧数组
|
||||
assert.match(tv, /const fitTo = useCallback\(\(\) => \{/, 'fitTo 又开始接收参数了');
|
||||
assert.match(tv, /const flat = nodesRef\.current;/, 'fitTo 没有读 ref');
|
||||
});
|
||||
|
||||
test('★ 自动 fit 的 effect 必须依赖 nodes(数据到了要重跑)', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
// 实测:effect 只在 nodes=0 时跑了一次就 return,之后数据到了不再触发
|
||||
// ⇒ 永远停在初始 zoom。setFittedFor('') 与 setNodes 同批次会被 React bail out,
|
||||
// 所以依赖里必须有 nodes。
|
||||
const m = tv.match(/useEffect\(\(\) => \{\s*\n?\s*if \(!nodes\.length \|\| fittedFor === mailID\) return;[\s\S]*?\}, \[([^\]]*)\]\);/);
|
||||
assert.ok(m, '找不到自动 fit 的 effect');
|
||||
assert.match(m[1], /nodes/, '★ effect 依赖里没有 nodes —— 数据到达后不会重跑(实测卡在初始 zoom)');
|
||||
});
|
||||
|
||||
test('用户拖/缩过之后不再自动重置视口', () => {
|
||||
const tv = src('components/ThreadView.tsx');
|
||||
assert.match(tv, /const touched = useRef\(false\)/, '没有「用户动过」标记');
|
||||
assert.match(tv, /if \(touched\.current\) return;/,
|
||||
'ResizeObserver 会把用户拖好的视口反复重置');
|
||||
// 手动「适应」要清标记
|
||||
assert.match(tv, /touched\.current = false;\s*\n\s*fitTo\(\);/,
|
||||
'手动「适应」后没有清标记 ⇒ 之后再也不能自动重算');
|
||||
});
|
||||
Reference in New Issue
Block a user