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(() => 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)); }); }