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

198 lines
7.6 KiB
TypeScript
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.
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));
});
}