feat(admin): Flow 页时间轴视图(W2)

- 视图切换:最近一轮(既有 DOM 逐字节未变,7/7 SSR 对照 IDENTICAL)| 时间轴(新)
- 时间轴:竖轴=时间(默认最新在上)、每回合一个可折叠块、块内复用 LaneTracks 泳道、
  块间不画任何连线(不臆造因果);before 游标分页;truncated ⇒ 显式「更早的轮次已过保留期」
- 降级诚实:只拿到最近一轮(老后端)⇒ 明确提示「当前只支持最近一轮」
- 4 个变异自检(排序方向/块间插线/truncated 静默/游标方向);老视图等价性实测
This commit is contained in:
AskaEth committed 2026-10-06 15:59:11 +08:00
1 parent 1efe3a6a79
commit 5ee5f9126f
10 files changed
+1831 -127

No files matched your search

@@ -0,0 +1,197 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { adminFlowTimeline } from "../../../lib/api";
import type { AdminFlowTimelineRound } from "../../../lib/api";
import {
TIMELINE_PAGE_SIZE,
appendTimelinePage,
classifyTimelineFailure,
pageMayHaveOlder,
timelineCursor,
timelinePageOf,
} from "./flowTimeline";
/**
* 链路**时间轴**的取数 hook(切片 W2;`design/19 §7.1`;W1 的新出口
* `GET /api/v1/admin/flow/timeline`)。
*
* 分工:`useFlowSnapshot`(F1)补**最近一轮**、`useFlowTurns`(F2)翻**单轮历史**,
* 本 hook 一次给**多轮**(每轮连同它每一路),供时间轴视图把「所有消息按时间顺序」摊开。
* 它**不写 store**(不碰 `flowGraphs` / `flowLanes`)—— 时间轴是**只读回看**,写进图桶
* 会把正在跑的那一轮顶掉(裁决 16.4.2 的同一理由)。
*
* 四条刻意口径:
* 1. **按需取数**(`enabled`):只有真的切到时间轴视图才打这个端点(默认不打扰后端);
* 2. **分页只向更早走**:游标 = 手上**最旧**那一回合的 `startedAt`(`flowTimeline.ts`
* 的纯函数),与展示顺序无关;一页没带来任何**新**回合 ⇒ 立刻判定到头(否则会在同
* 一个游标上打转到天荒地老);
* 3. **老后端如实降级**:404/501 ⇒ `unsupported`(面板文案「当前只支持最近一轮」),
* **绝不**把「没有这个出口」说成「这段时间没有回合」;
* 4. **失败不伪装**:首屏失败 ⇒ `failed` + 可重试;翻页失败只记在 `moreFailed`,
* 已拿到的回合**照旧显示**(不回退成空态)。
*/
export interface FlowTimelineState {
/** 保留期内的回合(**最新在前**,与展示顺序无关;渲染时再用 `orderRounds` 摆)。 */
rounds: AdminFlowTimelineRound[];
/** 全量回合数(= `rounds.length`;单独给一个名字,避免调用方各自取长度)。 */
total: number;
/** 保留期(小时);后端没给 ⇒ `null`(面板显示「未知」,不编造)。 */
retentionHours: number | null;
/** 更早的回合已过保留期(后端口径)—— 面板必须**如实标注**。 */
truncated: boolean;
/** 首屏加载中(已有数据后翻页不算)。 */
loading: boolean;
/** 还有更早的回合可拉(短页 / 游标不可用 / 已拉空 ⇒ false)。 */
canLoadMore: boolean;
/** 正在拉更早的一页。 */
loadingMore: boolean;
/** 后端**没有**这个出口(老后端)⇒ 「当前只支持最近一轮」。 */
unsupported: boolean;
/** 首屏读取失败(网络 / 500 / 401)。 */
failed: boolean;
/** 翻页失败(已拿到的回合照旧显示)。 */
moreFailed: boolean;
/** 拉更早的一页。 */
loadMore: () => void;
/** 重试首屏(失败 / 老后端修复后都走它)。 */
reload: () => void;
}
interface TimelineData {
sessionId: string;
rounds: AdminFlowTimelineRound[];
retentionHours: number | null;
truncated: boolean;
loading: boolean;
loadingMore: boolean;
unsupported: boolean;
failed: boolean;
moreFailed: boolean;
canLoadMore: boolean;
}
/** 空态初值(会话还没确定 / 视图还没切过来)。 */
function emptyState(sessionId: string): TimelineData {
return {
sessionId,
rounds: [],
retentionHours: null,
truncated: false,
loading: false,
loadingMore: false,
unsupported: false,
failed: false,
moreFailed: false,
canLoadMore: false,
};
}
export function useFlowTimeline(sessionId: string, enabled: boolean): FlowTimelineState {
const target = enabled ? sessionId : "";
const [state, setState] = useState<TimelineData>(() => emptyState(""));
const [seq, setSeq] = useState(0);
const stateRef = useRef(state);
stateRef.current = state;
// 首屏(会话变了 / 切到时间轴 / 手动重试)。已拉到数据的会话**不重复拉** —— 切视图来回
// 不该每次都打后端;真要重拉有 `reload`。
useEffect(() => {
if (!target) return;
const known = stateRef.current;
if (known.sessionId === target && !known.failed && !known.unsupported && known.rounds.length > 0) {
return;
}
return fetchFirstPage(target, setState);
}, [target, seq]);
const loadMore = useCallback(() => {
const current = stateRef.current;
if (!current.sessionId || !current.canLoadMore || current.loadingMore || current.loading) return;
const sid = current.sessionId;
const cursor = timelineCursor(current.rounds);
if (!cursor) {
// 最旧那一回合没有可用的开始时间 ⇒ 没法构造游标 ⇒ 停住(不瞎凑游标),
// 由界面如实说明「没有更早的」。
setState((s) => ({ ...s, canLoadMore: false }));
return;
}
fetchOlderPage(sid, cursor, setState);
}, []);
const reload = useCallback(() => {
const sid = stateRef.current.sessionId;
setState(sid ? { ...emptyState(sid), loading: true } : emptyState(""));
setSeq((n) => n + 1);
}, []);
return {
rounds: state.rounds,
total: state.rounds.length,
retentionHours: state.retentionHours,
truncated: state.truncated,
loading: state.loading,
canLoadMore: state.canLoadMore,
loadingMore: state.loadingMore,
unsupported: state.unsupported,
failed: state.failed,
moreFailed: state.moreFailed,
loadMore,
reload,
};
}
/** 取数体的类型(把「落进状态」这一动作注入,使下面两个取数函数不必认识 React)。 */
type Send = (patch: (prev: TimelineData) => TimelineData) => void;
/** 首屏取数:先置加载态,再把「一页 + 还能不能翻」落进状态;失败按 `classifyTimelineFailure`
* 分「老后端」与「这次没读到」两支。返回取消函数(effect 的 cleanup)。 */
function fetchFirstPage(target: string, send: Send): () => void {
let cancelled = false;
send(() => ({ ...emptyState(target), loading: true }));
adminFlowTimeline(target, { limit: TIMELINE_PAGE_SIZE })
.then((resp) => {
if (cancelled) return;
const page = timelinePageOf(resp);
send(() => ({
...emptyState(target),
...page,
canLoadMore: pageMayHaveOlder(page.rounds.length, TIMELINE_PAGE_SIZE),
}));
})
.catch((err: unknown) => {
if (cancelled) return;
const mark = classifyTimelineFailure(err);
send(() => ({
...emptyState(target),
unsupported: mark === "unsupported",
failed: mark === "failed",
}));
});
return () => {
cancelled = true;
};
}
/** 取更早的一页:合并去重、保留期取「已拿到的优先」、`truncated` 一旦为真就不再回落
* (过一个保留期边界就够了);失败只影响翻页,**已拿到的回合照旧显示**。 */
function fetchOlderPage(sid: string, cursor: string, send: Send): void {
send((s) => (s.sessionId === sid ? { ...s, loadingMore: true, moreFailed: false } : s));
adminFlowTimeline(sid, { limit: TIMELINE_PAGE_SIZE, before: cursor })
.then((resp) => {
const page = timelinePageOf(resp);
send((s) => {
if (s.sessionId !== sid) return s;
const next = appendTimelinePage(s.rounds, page.rounds, TIMELINE_PAGE_SIZE);
return {
...s,
rounds: next.rounds,
retentionHours: page.retentionHours ?? s.retentionHours,
truncated: s.truncated || page.truncated,
loadingMore: false,
canLoadMore: next.canLoadMore,
};
});
})
.catch(() => {
send((s) => (s.sessionId === sid ? { ...s, loadingMore: false, moreFailed: true } : s));
});
}