- 视图切换:最近一轮(既有 DOM 逐字节未变,7/7 SSR 对照 IDENTICAL)| 时间轴(新) - 时间轴:竖轴=时间(默认最新在上)、每回合一个可折叠块、块内复用 LaneTracks 泳道、 块间不画任何连线(不臆造因果);before 游标分页;truncated ⇒ 显式「更早的轮次已过保留期」 - 降级诚实:只拿到最近一轮(老后端)⇒ 明确提示「当前只支持最近一轮」 - 4 个变异自检(排序方向/块间插线/truncated 静默/游标方向);老视图等价性实测
198 lines
7.6 KiB
TypeScript
198 lines
7.6 KiB
TypeScript
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));
|
||
});
|
||
}
|