From 745365c7df3a95b8bd182f8743c3c7a6196a8674 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 20:25:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E6=97=B6=E9=97=B4=E8=BD=B4?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E4=B8=8E=E4=BF=9D=E7=95=99=E6=9C=9F=E6=96=87?= =?UTF-8?q?=E6=A1=88=E6=8C=89=E5=A5=91=E7=BA=A6=E6=94=B6=E7=B4=A7=EF=BC=88?= =?UTF-8?q?V2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 类型补上后端真在返回的 source/nextBefore/hasMore/retentionEnabled(此前类型里没有) - 新增纯函数 pageHasOlder(hasMore 权威,老后端退满页启发式)/timelineCursorOf/retentionClaimable/ timelineHints;取数体抽成可执行导出函数 ⇒ effect 本体首次被真跑(真 fetch + 真状态机 + 真 SSR) - 🔴 修一处成对契约缺陷:后端 truncated = hasMore || !storeWired,而契约/设计要求 truncated ⇒ 「已过保留期」⇒ 旧前端会对主人说假话。现改为:只有「已翻到底 + 保留期启用 + 非 live-only」 才说「已过保留期」,其余说「更早的轮次未显示」 - 3 个变异自检(去掉 canLoadMore 短路/恒可翻页/忽略 hasMore) ⚠️ 未验(如实):鉴权后的真机 HTTP 往返拿不到 owner 凭据;前端产物未部署(面板仍是旧文案) --- .../src/admin/modules/flow/TimelineView.tsx | 13 +- .../modules/flow/flowTimeline.render.test.ts | 39 ++ .../admin/modules/flow/flowTimeline.test.ts | 60 +++ .../src/admin/modules/flow/flowTimeline.ts | 128 +++++- .../flow/flowTimelineWiring.render.test.ts | 262 ++++++++++++ .../modules/flow/useFlowTimeline.test.ts | 379 ++++++++++++++++++ .../src/admin/modules/flow/useFlowTimeline.ts | 231 +++++++---- frontend/web/src/lib/api/adminSystem.ts | 34 +- 8 files changed, 1047 insertions(+), 99 deletions(-) create mode 100644 frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts create mode 100644 frontend/web/src/admin/modules/flow/useFlowTimeline.test.ts diff --git a/frontend/web/src/admin/modules/flow/TimelineView.tsx b/frontend/web/src/admin/modules/flow/TimelineView.tsx index 26bed4a..9ccdbd5 100644 --- a/frontend/web/src/admin/modules/flow/TimelineView.tsx +++ b/frontend/web/src/admin/modules/flow/TimelineView.tsx @@ -5,6 +5,8 @@ import { LaneTracks } from "./LaneTracks"; import type { FlowTimelineState } from "./useFlowTimeline"; import type { FlowTimelineOrder, RoundStats } from "./flowTimeline"; import { + LIVE_ONLY_TEXT, + MORE_HIDDEN_TEXT, RETENTION_TRUNCATED_TEXT, fmtRoundTime, orderRounds, @@ -17,6 +19,7 @@ import { roundStatusLabel, roundStatusTag, shortTurnId, + timelineHints, } from "./flowTimeline"; /** @@ -28,7 +31,10 @@ import { * - 每个回合一个**可折叠块**,标题 = 时间 + `round` + `turnId` 短标识 + 状态; * - 块内**复用 `LaneTracks`** 画该回合各路的**并列泳道**(真并行不止一张); * - 🔴 **块与块之间不画因果连线**(不臆造关系):只用时间与序号分隔,页首明写这一点; - * - 过期轮次不在返回值里 ⇒ 后端给 `truncated` 时页首**如实标注**「更早的轮次已过保留期」。 + * - 页首的三句话**各有判据**(`flowTimeline.ts#timelineHints`):`hasMore`(还有更早的可拉) + * ⇒ 「更早的轮次未显示」;**已翻到底**却仍不是全部且保留期启用 ⇒ 才说「更早的轮次已过 + * 保留期」;本页全来自内存 ⇒ 「只拿到最近一轮」。**不许**拿 `truncated` 直接当「已过保留 + * 期」(契约 v0.80 §4.11.1(b).3:那会对着一页仍可翻的历史说假话)。 * * 全部 **props 驱动**(不读 store、不取数):本项目没有 jsdom,渲染测试只能喂 props * (见 `flowSnapshot.render.test.ts` 头注释的同一理由)。改一个块的展开态由父层持有 @@ -90,6 +96,7 @@ function TimelineHeader({ state: FlowTimelineState; binding: TimelineBinding; }): React.ReactNode { + const hints = timelineHints(state); return (
@@ -124,7 +131,9 @@ function TimelineHeader({

{retentionText(state.retentionHours)} - {state.truncated ? ` —— ${RETENTION_TRUNCATED_TEXT}` : null} + {hints.expired ? ` —— ${RETENTION_TRUNCATED_TEXT}` : null} + {hints.hidden ? ` —— ${MORE_HIDDEN_TEXT}` : null} + {hints.liveOnly ? ` —— ${LIVE_ONLY_TEXT}` : null}  · 数据源:GET /api/v1/admin/flow/timeline(只读回看,不写库、不发布帧)

diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts b/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts index a9b8fc4..5a2e1ef 100644 --- a/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts +++ b/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts @@ -98,7 +98,9 @@ function tlState( rounds, total: rounds.length, retentionHours: 168, + retentionEnabled: null, truncated: false, + source: null, loading: false, canLoadMore: false, loadingMore: false, @@ -365,4 +367,41 @@ const bodyUnwired = renderToStaticMarkup( ); ok(has(bodyUnwired, "时间轴取数未接线"), "切到时间轴但没喂数据 ⇒ 如实说未接线(不假装空态)"); +// ── ⑬ 「未显示」与「已过保留期」是**两件事**(契约 v0.80 §4.11.1(b).3) ───────── +// 真机上 `truncated = hasMore || 落盘源未接线` ⇒ 只要还有更早的可拉它就是 true。拿它直接 +// 当「已过保留期」,面板就会对着一页**仍然可翻**的历史说假话 —— 这条是核出来的成对契约缺陷。 +const moreToLoad = view(tlState(NEWEST_FIRST, { truncated: true, canLoadMore: true })); +ok(has(moreToLoad, "更早的轮次未显示"), "还有更早的可拉 ⇒ 只能说「未显示」"); +ok( + !has(moreToLoad, "更早的轮次已过保留期"), + "还有更早的可拉 ⇒ **绝不**能说「已过保留期」(那是对着能翻的历史说假话)" +); +ok(has(moreToLoad, "加载更早的回合"), "同一份状态里翻页入口仍在(缺失不是静默的)"); + +const retentionOff = view( + tlState(NEWEST_FIRST, { truncated: true, retentionHours: 0, retentionEnabled: false }) +); +ok(has(retentionOff, "更早的轮次未显示"), "保留期未启用 ⇒ 只能说「未显示」"); +ok( + !has(retentionOff, "更早的轮次已过保留期"), + "保留期未启用 ⇒ 不得说「已过保留期」(那些回合不是被清扫掉的)" +); +ok(has(retentionOff, "保留期未启用"), "「保留期未启用」这件事本身照实说"); + +const expiredNow = view(tlState(NEWEST_FIRST, { truncated: true, canLoadMore: false })); +ok( + has(expiredNow, "更早的轮次已过保留期"), + "已翻到底仍不是全部 + 保留期启用 ⇒ 才可以说「已过保留期」(design/19 §7.1 的逐字文案)" +); +ok(!has(expiredNow, "更早的轮次未显示"), "同一份状态不得两种说法都给"); + +// ── ⑭ 三种真实状态的呈现(有数据 / 只有最近一轮 / 读取失败) ──────────────── +const liveOnly = view(tlState([C], { source: "live", truncated: true, canLoadMore: false })); +ok(has(liveOnly, "只拿到内存里的最近一轮"), "source=live(本页全部来自内存)⇒ 如实说只拿到最近一轮"); +same(blocks(liveOnly).length, 1, "「只有最近一轮」仍然画出那一块(不是空态)"); +ok(!has(liveOnly, "保留期内没有可回看的回合"), "「只有最近一轮」≠「没有回合」(两件事不许混)"); + +const failedWithRounds = view(tlState([B], { failed: true })); +same(blocks(failedWithRounds).length, 1, "首屏失败标记不吞掉已拿到的回合(回归:只有空态才说读不到)"); + console.log(`flow timeline render test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.test.ts b/frontend/web/src/admin/modules/flow/flowTimeline.test.ts index 15aff27..7598c7b 100644 --- a/frontend/web/src/admin/modules/flow/flowTimeline.test.ts +++ b/frontend/web/src/admin/modules/flow/flowTimeline.test.ts @@ -29,7 +29,9 @@ import { mergeTimelineRounds, normalizeTimelineRounds, orderRounds, + pageHasOlder, pageMayHaveOlder, + retentionClaimable, retentionText, roundExpanded, roundGraphs, @@ -40,9 +42,12 @@ import { roundStatusTag, shortTurnId, timelineCursor, + timelineCursorOf, + timelineHints, timelinePageOf, toggleRoundExpansion, } from "./flowTimeline"; +import type { TimelineHints } from "./flowTimeline"; let checks = 0; @@ -333,4 +338,59 @@ same( globalThis.fetch = originalFetch; globalThis.localStorage = originalLocalStorage; +// ── ⑬ 分页判据:`hasMore` 权威 / `nextBefore` 权威(契约 v0.80 §4.11.1(a)) ─── +ok(pageHasOlder(true, 2, 50), "后端说 hasMore ⇒ 可能还有(**哪怕这一页是短页**)"); +ok(!pageHasOlder(false, 50, 50), "后端说 hasMore=false ⇒ 到头了(**哪怕这一页是满页**,不要白打请求)"); +ok(pageHasOlder(null, 50, 50), "老后端没给 hasMore ⇒ 退回满页启发式"); +ok(!pageHasOlder(null, 3, 50), "老后端没给 hasMore + 短页 ⇒ 到头了"); +same( + timelineCursorOf({ nextBefore: r1.startedAt, rounds: [r1, r2] }), + r1.startedAt, + "游标优先用后端的 nextBefore" +); +same( + timelineCursorOf({ nextBefore: null, rounds: [r1, r2] }), + r1.startedAt, + "后端没给 nextBefore ⇒ 退回自己算的「最旧那一回合」(原样字符串)" +); +same(timelineCursorOf({ nextBefore: null, rounds: [] }), null, "都没有 ⇒ 没有游标(不瞎凑)"); + +// ── ⑭ 保留期能不能用来解释缺失(`retentionClaimable` 的三支) ──────────────── +ok(retentionClaimable({ retentionHours: 168, retentionEnabled: null }), "有小时数且 >0 ⇒ 可以解释"); +ok(!retentionClaimable({ retentionHours: 0, retentionEnabled: null }), "≤0 = 配置为不回收 ⇒ 不许说过期"); +ok( + !retentionClaimable({ retentionHours: 168, retentionEnabled: false }), + "后端显式说没启用 ⇒ **永远**不许说过期(哪怕小时数字段还带着 168)" +); +ok(retentionClaimable({ retentionHours: null, retentionEnabled: true }), "显式说启用了 ⇒ 可以解释"); +ok(!retentionClaimable({ retentionHours: null, retentionEnabled: null }), "两样都不知道 ⇒ 不说"); + +// ── ⑮ 页首三句话的判定(`timelineHints`):互不冒充 ───────────────────────── +const hintBase = { truncated: true, canLoadMore: false, retentionHours: 168, retentionEnabled: true, source: null }; +/** 三支一起比(对象比较要逐字段,`same` 只比标量)。 */ +function hintsOf(h: TimelineHints): string { + return `${h.hidden ? "hidden" : "-"}/${h.expired ? "expired" : "-"}/${h.liveOnly ? "liveOnly" : "-"}`; +} +same(hintsOf(timelineHints({ ...hintBase, truncated: false })), "-/-/-", "没说缺 ⇒ 三支都为假"); +same( + hintsOf(timelineHints({ ...hintBase, canLoadMore: true })), + "hidden/-/-", + "还有更早可拉 ⇒ 只能「未显示」(**绝不**说「已过保留期」)" +); +same( + hintsOf(timelineHints(hintBase)), + "-/expired/-", + "翻到底仍不是全部 + 保留期启用 ⇒ 才说「已过保留期」" +); +same( + hintsOf(timelineHints({ ...hintBase, retentionHours: 0, retentionEnabled: false })), + "hidden/-/-", + "保留期未启用 ⇒ 只能说「未显示」" +); +same( + hintsOf(timelineHints({ ...hintBase, source: "live" })), + "hidden/-/liveOnly", + "本页全来自内存(落盘源未接线)⇒ 「只拿到最近一轮」,且**不**说成过期" +); + console.log(`flow timeline logic test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.ts b/frontend/web/src/admin/modules/flow/flowTimeline.ts index 0f7ef60..b7afbd9 100644 --- a/frontend/web/src/admin/modules/flow/flowTimeline.ts +++ b/frontend/web/src/admin/modules/flow/flowTimeline.ts @@ -29,9 +29,22 @@ export const TIMELINE_PAGE_SIZE = 50; export type FlowTimelineOrder = "newest-first" | "oldest-first"; /** 「更早的轮次已过保留期」——**逐字**文案(`design/19 §7.1`:过期轮次不在返回值里时, - * 必须由面板如实标注,**不许静默少一块**)。 */ + * 必须由面板如实标注,**不许静默少一块**)。 + * + * ⚠️ 这句话**有判据**(契约 v0.80 §4.11.1(b).3):只有「分页已到底(`hasMore` 为假) + * 却仍不是全部,且保留期**启用**」才可以说;`truncated` 本身只等于「本次不是全部」, + * 拿它直接当「已过保留期」会对着一页**仍然可翻**的历史说假话(本切片核出的成对契约缺陷)。 */ export const RETENTION_TRUNCATED_TEXT = "更早的轮次已过保留期"; +/** 「更早的轮次**未显示**」:本次不是全部,但原因**不是**保留期 —— 要么还有更早的可拉 + * (`hasMore`,翻页入口就在旁边),要么后端配置为**不回收**(`retentionEnabled:false`)。 + * 与「已过保留期」是**两件事**,不许互相冒充。 */ +export const MORE_HIDDEN_TEXT = "更早的轮次未显示"; + +/** 「只拿到内存里的最近一轮」:本页**全部**来自内存(`source:"live"`)= 落盘源未接线, + * 或该会话还没有落盘回合。**不**等于「这台会话没有回合」(那是空态该说的)。 */ +export const LIVE_ONLY_TEXT = "落盘源未接线:只拿到内存里的最近一轮"; + /** 时间读数:不可解析的值一律算 0(不伪造时间,也不让比较器算出 NaN)。 * 与 `flowHistory.ts#timeOf` 同一口径 —— 两处都只服务排序。 */ function timeOf(iso: string | null | undefined): number { @@ -108,26 +121,54 @@ export function timelineCursor( return cursor; } -/** 后端这一页是否**可能还有更早的**:满页才可能还有(短页 = 到头了)。 - * 与 `Notifications.tsx` 的游标翻页同一口径(短页即止),避免在同一个游标上打转。 */ +/** 「短页即止」的启发式:满页才可能还有更早的,短页 = 到头了。 + * 只在后端**没给** `hasMore` 时兜底(见 `pageHasOlder`);与 `Notifications.tsx` 的 + * 游标翻页同一口径,避免在同一个游标上打转。 */ export function pageMayHaveOlder(receivedCount: number, pageSize: number): boolean { return pageSize > 0 && receivedCount >= pageSize; } +/** 后端这一页是否**可能还有更早的**:`hasMore` 是**权威判据**(契约 §4.11.1(a)); + * 老后端没给这个键(`null`)时才退回「满页才可能还有」的启发式(`pageMayHaveOlder`, + * 与 `Notifications.tsx` 的短页即止同一口径)。 + * + * ⚠️ 只靠启发式会**对着满页的最后一页白打一次请求**(并短暂宣称「还有更早的」)—— + * 因此后端给了 `hasMore` 就一律以它为准。 */ +export function pageHasOlder( + hasMore: boolean | null | undefined, + receivedCount: number, + pageSize: number +): boolean { + if (typeof hasMore === "boolean") return hasMore; + return pageMayHaveOlder(receivedCount, pageSize); +} + +/** 下一页的游标:**优先后端给的 `nextBefore`**(权威 = 本页最早一轮的开始时刻;它连 + * 「开始时刻不可解析 ⇒ 该行的落盘时刻」这种情形都在服务端处理过了);后端没给才退回 + * 自己算的 `timelineCursor`。两者都拿不到 ⇒ `null`(**不能翻页**,面板如实停住)。 */ +export function timelineCursorOf(state: { + nextBefore?: string | null; + rounds: readonly AdminFlowTimelineRound[]; +}): string | null { + return state.nextBefore ? state.nextBefore : timelineCursor(state.rounds); +} + /** **追加一页**的判定(纯函数,取数层与逻辑测试共用): * - `rounds` = 合并去重后的全量(`mergeTimelineRounds`,不重不漏); * - `added` = 这一页真正**新增**了几条; * - `canLoadMore` = 还能不能继续翻 —— 要求 ① **真有新回合**(否则游标没推进,再翻只会在 - * 同一页上打转)且 ② 后端这一页是**满页**(`pageMayHaveOlder`)。 */ + * 同一页上打转)且 ② 后端这一页**后面还有更早的**(`hasMore` 权威;老后端没给该键时 + * 退回满页启发式,见 `pageHasOlder`)。 */ export function appendTimelinePage( existing: readonly AdminFlowTimelineRound[], incoming: readonly AdminFlowTimelineRound[] | null | undefined, - pageSize: number + pageSize: number, + hasMore: boolean | null = null ): { rounds: AdminFlowTimelineRound[]; added: number; canLoadMore: boolean } { const page = incoming ?? []; const rounds = mergeTimelineRounds(existing, page); const added = rounds.length - existing.length; - return { rounds, added, canLoadMore: added > 0 && pageMayHaveOlder(page.length, pageSize) }; + return { rounds, added, canLoadMore: added > 0 && pageHasOlder(hasMore, page.length, pageSize) }; } /** 回合标识的**短标识**(块标题用):取最后一个 `#` 之后那一段(v0.79 起标识形如 @@ -259,6 +300,58 @@ export function classifyTimelineFailure(err: unknown): TimelineFailure { return isNotImplemented(err) ? "unsupported" : "failed"; } +/** 保留期是否**可以用来解释「更早的轮次不见了」**(`retentionText` 三支的同源判据): + * `retentionEnabled:false` ⇒ **永远不许**说过期;给了小时数就看它是否 `>0`;两者都缺 ⇒ + * 只有当 `retentionEnabled === true` 才认(否则**不知道**,就不说)。 */ +export function retentionClaimable(state: { + retentionHours: number | null | undefined; + retentionEnabled: boolean | null | undefined; +}): boolean { + if (state.retentionEnabled === false) return false; + if (typeof state.retentionHours === "number" && Number.isFinite(state.retentionHours)) { + return state.retentionHours > 0; + } + return state.retentionEnabled === true; +} + +/** 时间轴页首要如实交代的三件事(**只看后端给的字段**,不猜、不美化)。 + * + * - `liveOnly`:本页**全部来自内存**(`source:"live"`)⇒ 说「只拿到最近一轮」—— 缺的那些 + * 回合**不是**被保留期清扫掉的(落盘源未接线),因此**永不**并入「已过保留期」; + * - `expired`:**分页已到底**(`hasMore` 为假)却仍「不是全部」,且保留期**可用它来解释** + * ⇒ 才可以说「更早的轮次已过保留期」(`design/19 §7.1`/§7.4 的逐字文案); + * - `hidden`:仍不是全部,但**不能**用保留期解释(还有更早的可拉 / 落盘源未接线 / 后端 + * 配置为不回收)⇒ 说「更早的轮次未显示」,**绝不**冒充「已过保留期」 + * (契约 v0.80 §4.11.1(b).3)。 + * + * 三者互不冒充;`truncated` 为假时三支都为假(没有可交代的缺失)。 */ +export interface TimelineHints { + /** 「更早的轮次未显示」(不是保留期的锅)。 */ + hidden: boolean; + /** 「更早的轮次已过保留期」(保留期启用 + 已翻到底 + 不是「只有最近一轮」)。 */ + expired: boolean; + /** 「只拿到内存里的最近一轮」。 */ + liveOnly: boolean; +} + +/** `timelineHints` 的入参(与 `FlowTimelineState` 结构兼容,便于渲染层直接喂 state)。 */ +export interface TimelineHintsInput { + truncated: boolean; + canLoadMore: boolean; + retentionHours: number | null | undefined; + retentionEnabled: boolean | null | undefined; + source: TimelineSource | null | undefined; +} + +/** 页首那三句话的判定(纯函数,逻辑测试与渲染测试共用**唯一真源**)。 */ +export function timelineHints(state: TimelineHintsInput): TimelineHints { + const liveOnly = state.source === "live"; + if (!state.truncated) return { hidden: false, expired: false, liveOnly }; + if (liveOnly || state.canLoadMore) return { hidden: true, expired: false, liveOnly }; + const expired = retentionClaimable(state); + return { hidden: !expired, expired, liveOnly }; +} + /** 折叠默认值:**用户没点过**的块 ⇒ 只有**最新一回合**默认展开(打开页面即看到最近的 * 那条链路),其余折叠成索引;用户点过就**以用户的为准**(点开过的保持开、点收过的 * 保持收)。`newest` 由调用方按**数据**判定(`rounds[0]`),与展示顺序无关。 */ @@ -282,12 +375,23 @@ export function toggleRoundExpansion( return { ...expanded, [turnId]: !roundExpanded(turnId, expanded, newest) }; } +/** 后端给出的**数据源**(契约 §4.11.1(a) 的 `source`):本页至少一轮来自落盘 / 本页全部 + * 来自内存 / 没有任何回合。缺席(老后端)= `null`(不猜)。 */ +export type TimelineSource = NonNullable; + /** 时间轴响应 → 取数层状态用的**纯**解包(缺键一律按诚实缺省:没有回合就是空数组、 - * `truncated` 缺席当 false、保留期缺席当未知)。 */ + * `truncated` 缺席当 false、保留期/游标/`hasMore` 缺席当**未知**(`null`),不冒充 0)。 */ export interface TimelinePage { rounds: AdminFlowTimelineRound[]; retentionHours: number | null; + /** 保留期清扫**是否启用**;缺席 = 未知(由 `retentionHours` 推断,见 `retentionClaimable`)。 */ + retentionEnabled: boolean | null; truncated: boolean; + /** 后端说「还有更早的」;缺席 = 不知道(取数层退回满页启发式)。 */ + hasMore: boolean | null; + /** 后端给的下一页游标(本页最早一轮的开始时刻);缺席 / 空串 = 没有。 */ + nextBefore: string | null; + source: TimelineSource | null; } /** 解包一页响应(**只读后端给的字段**:不补字段、不编值)。 */ @@ -300,6 +404,16 @@ export function timelinePageOf( typeof resp?.retentionHours === "number" && Number.isFinite(resp.retentionHours) ? resp.retentionHours : null, + retentionEnabled: typeof resp?.retentionEnabled === "boolean" ? resp.retentionEnabled : null, truncated: Boolean(resp?.truncated), + hasMore: typeof resp?.hasMore === "boolean" ? resp.hasMore : null, + nextBefore: resp?.nextBefore ? resp.nextBefore : null, + source: readSource(resp?.source), }; } + +/** 读 `source`:只认契约登记的三个取值,别的一律当**未知**(`null`)——不把没见过的字符串 + * 说成三种里的任何一种。 */ +function readSource(raw: string | undefined): TimelineSource | null { + return raw === "persisted" || raw === "live" || raw === "none" ? raw : null; +} diff --git a/frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts b/frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts new file mode 100644 index 0000000..ad3087c --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts @@ -0,0 +1,262 @@ +// 「真取数 → 真状态 → 真渲染」的**完整回路**测试(切片 V2;契约 v0.80 §4.11.1)。 +// +// 为什么需要它:本项目没有 jsdom ⇒ 既跑不了 hook,也点不了界面。W2 的渲染测试是**替身级** +// (手写 state 当 props),取数测试此前根本不存在。本文件把两头接起来: +// +// 真 `fetch`(照契约的假后端:`before` 严格更早 / `hasMore` / `nextBefore` / `truncated`) +// → 真的取数状态机(`loadFirstPage` / `loadOlderPage`) +// → 真的状态映射(`flowTimelineViewState`,就是 hook 返回的那个) +// → 真的视图(`FlowTimelineView` 的 SSR 输出) +// ⇒ 对**三种真实状态**逐条断言界面说了什么、**没说**什么: +// ① 有数据(多轮 + 分页到底)② 只有最近一轮(落盘源未接线)③ 读取失败(500) +// ④ 附:老后端(404)—— 与 ③ 是两件事,必须分开说。 +// +// 跑法(渲染类必须 `--format=cjs`;异步用 IIFE,抛错 ⇒ unhandled rejection ⇒ 非零退出): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowTimelineWiring.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-flow-timeline-wiring.render.test.cjs \ +// && node /tmp/cyrene-flow-timeline-wiring.render.test.cjs + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { FlowGraph } from "../../../lib/types"; +import { MAIN_SESSION_ID } from "../../../store/sessionCache"; +import { FlowBody } from "../Flow"; +import { type TimelineBinding } from "./TimelineView"; +import { + emptyState, + flowTimelineViewState, + loadFirstPage, + loadOlderPage, + type Send, + type TimelineData, +} from "./useFlowTimeline"; +import { timelineCursorOf } from "./flowTimeline"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same(got: T, want: T, what: string): void { + checks += 1; + if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +const originalLocalStorage = globalThis.localStorage; +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +} as unknown as Storage; + +// ── 照契约的假后端(同 `useFlowTimeline.test.ts` 的语义,这里只留渲染回路需要的档位) ── + +const STAMPS = [ + "2026-10-06T02:00:00.000Z", + "2026-10-06T02:05:00.000Z", + "2026-10-06T02:09:00.000Z", +]; + +/** 3 个回合(第 2 轮两路);页大小 2 ⇒ 走一次翻页。 */ +function wireRounds(): unknown[] { + return STAMPS.map((startedAt, i) => { + const round = i + 1; + const turnId = `web-${round}#turn-${round}`; + const graph: FlowGraph = { + id: turnId, + sessionId: MAIN_SESSION_ID, + turnId, + round, + startedAt, + nodes: [ + { id: "input", label: `哨兵${round}输入`, kind: "input", status: "done" }, + { id: "out", label: `哨兵${round}回复`, kind: "output", status: "done" }, + ], + edges: [{ from: "input", to: "out" }], + }; + const lanes = [{ turnId, graph }]; + if (round === 2) lanes.push({ turnId: `${turnId}#leg2`, graph: { ...graph, id: `${turnId}#leg2` } }); + return { turnId, round, startedAt, endedAt: startedAt, lanes }; + }); +} + +type Mode = "persisted" | "liveOnly" | "failed" | "oldBackend"; + +function installBackend(mode: Mode): void { + globalThis.fetch = ((url: string) => { + if (mode === "failed") { + return Promise.resolve({ + ok: false, + status: 500, + headers: { get: () => "application/json" }, + json: () => Promise.resolve({ error: { code: "flow_unavailable", message: "boom" } }), + }); + } + if (mode === "oldBackend") { + return Promise.resolve({ + ok: false, + status: 404, + headers: { get: () => "text/plain" }, + json: () => Promise.reject(new Error("not json")), + }); + } + const all = wireRounds(); + const q = new URLSearchParams(String(url).split("?")[1] ?? ""); + const before = q.get("before") ?? ""; + if (mode === "liveOnly") { + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: () => + Promise.resolve({ + data: { + sessionId: MAIN_SESSION_ID, + source: "live", + rounds: [all[all.length - 1]], + nextBefore: "", + hasMore: false, + truncated: true, + retentionHours: 168, + retentionEnabled: true, + }, + }), + }); + } + const pool = before ? all.filter((r) => (r as { startedAt: string }).startedAt < before) : all; + const page = pool.slice(-2); + const hasMore = pool.length > page.length; + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: () => + Promise.resolve({ + data: { + sessionId: MAIN_SESSION_ID, + source: page.length > 0 ? "persisted" : "none", + rounds: page, + nextBefore: (page[0] as { startedAt: string } | undefined)?.startedAt ?? "", + hasMore, + truncated: hasMore, + retentionHours: 168, + retentionEnabled: true, + }, + }), + }); + }) as unknown as typeof globalThis.fetch; +} + +/** 状态机宿主(等价于 hook 的 setState)。 */ +interface Host { + get: () => TimelineData; + send: Send; +} + +function host(): Host { + let state = emptyState(MAIN_SESSION_ID); + const send: Send = (patch) => { + state = patch(state); + }; + return { get: () => state, send }; +} + +/** 把真取数状态按 **hook 自己的映射** 翻成视图状态,再经**真正的 Flow 页视图**(`FlowBody` + * 的 `view="timeline"`,即面板上「时间轴」页签选中时的形态)SSR 成 HTML。 */ +function render(h: Host): string { + const state = flowTimelineViewState(h.get(), () => {}, () => {}); + return renderToStaticMarkup( + createElement(FlowBody, { + graph: { + id: "web-88", + sessionId: MAIN_SESSION_ID, + nodes: [{ id: "input", label: "最近一轮哨兵", kind: "input", status: "done" }], + edges: [], + }, + sessionId: MAIN_SESSION_ID, + source: "live", + view: "timeline", + onSelectView: () => {}, + timeline: state, + timelineBinding: { + order: "newest-first", + onToggleOrder: () => {}, + expanded: {}, + onToggleRound: () => {}, + onReload: () => {}, + } satisfies TimelineBinding, + }) + ); +} + +void (async () => { + // ── ① 有数据:真 fetch 翻到底 ⇒ 三块 + 保留期口径,且**没有**任何「过期」假话 ── + installBackend("persisted"); + const filled = host(); + await loadFirstPage(MAIN_SESSION_ID, filled.send); + same(filled.get().rounds.length, 2, "第一页 2 个回合"); + await loadOlderPage(MAIN_SESSION_ID, timelineCursorOf(filled.get()) ?? "", filled.send); + same(filled.get().rounds.length, 3, "翻一次后 3 个回合(三页数据两页取完,不重不漏)"); + same(filled.get().canLoadMore, false, "翻到底 ⇒ 不再有更早的"); + const filledHtml = render(filled); + same( + (filledHtml.match(/data-flow-timeline-round="/g) ?? []).length, + 3, + "三个回合 ⇒ 真渲染出三个块(数量与取数结果一致)" + ); + ok(has(filledHtml, "共 3 个回合"), "页首的回合数来自真取数结果"); + ok(has(filledHtml, "保留期 168 小时"), "页首如实转达后端给的保留期"); + ok(!has(filledHtml, "更早的轮次已过保留期"), "翻到底 + 后端没给清扫信号 ⇒ **不**说「已过保留期」"); + ok(!has(filledHtml, "更早的轮次未显示"), "翻到底 ⇒ 也不挂「未显示」"); + ok(has(filledHtml, "没有更早的回合了"), "翻到底 ⇒ 如实说没有更早的(不是静默消失)"); + ok(has(filledHtml, "2 路 · "), "真并行的那一轮两路都在(块标题如实报路数)"); + ok(has(filledHtml, "视图"), "页面上有视图切换页签(切到时间轴这件事在界面上是有出口的)"); + ok(!has(filledHtml, "最近一轮哨兵"), "时间轴视图**不**画「最近一轮」的图(两套不混)"); + + // ── ② 只有最近一轮(落盘源未接线):说「只拿到最近一轮」,**不等于**空态 ── + installBackend("liveOnly"); + const live = host(); + await loadFirstPage(MAIN_SESSION_ID, live.send); + same(live.get().source, "live", "真取数状态标出来源是内存(source=live)"); + const liveHtml = render(live); + same( + (liveHtml.match(/data-flow-timeline-round="/g) ?? []).length, + 1, + "只拿到最近一轮 ⇒ 仍然画出那一块(不是空态)" + ); + ok(has(liveHtml, "只拿到内存里的最近一轮"), "如实说明「只有内存里这一轮」(落盘源未接线)"); + ok(!has(liveHtml, "保留期内没有可回看的回合"), "「只有最近一轮」**不是**「没有回合」"); + ok(!has(liveHtml, "更早的轮次已过保留期"), "落盘源未接线也**不**能说成过期"); + + // ── ③ 读取失败(500):如实说读不到 + 给重试,不落进空态文案 ────────── + installBackend("failed"); + const broke = host(); + await loadFirstPage(MAIN_SESSION_ID, broke.send); + same(broke.get().failed, true, "真取数状态是 failed"); + const failedHtml = render(broke); + ok(has(failedHtml, "暂时读不到"), "读取失败 ⇒ 如实说「暂时读不到」"); + ok(has(failedHtml, "重试"), "读取失败 ⇒ 给重试入口"); + ok(!has(failedHtml, "保留期内没有可回看的回合"), "读取失败 ≠「没有回合」(两件事不许说成一件)"); + ok(!has(failedHtml, "当前只支持最近一轮"), "读取失败 ≠ 老后端(再说一遍也不能混)"); + + // ── ④ 老后端(404):与「读取失败」分开说 ───────────────────────────── + installBackend("oldBackend"); + const old = host(); + await loadFirstPage(MAIN_SESSION_ID, old.send); + same(old.get().unsupported, true, "真取数状态是 unsupported"); + const oldHtml = render(old); + ok(has(oldHtml, "当前只支持最近一轮"), "老后端 ⇒ 明确说「当前只支持最近一轮」"); + ok(!has(oldHtml, "暂时读不到"), "老后端 ≠ 读取失败"); + + globalThis.localStorage = originalLocalStorage; + console.log(`flow timeline wiring test: ${checks} checks passed`); +})(); diff --git a/frontend/web/src/admin/modules/flow/useFlowTimeline.test.ts b/frontend/web/src/admin/modules/flow/useFlowTimeline.test.ts new file mode 100644 index 0000000..a81ab17 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/useFlowTimeline.test.ts @@ -0,0 +1,379 @@ +// 「链路时间轴取数(`useFlowTimeline`)」的**真跑**测试(切片 V2;契约 v0.80 §4.11.1)。 +// +// 为什么需要它:W2 片自认的最大缺口是「`useFlowTimeline` 的 effect 本体**没有任何可执行 +// 测试**」(本项目没有 jsdom、没有测试运行器 ⇒ hook 与 effect 跑不了)。本测试把取数体 +// 从 hook 里抽成两个**导出**的异步函数(`loadFirstPage` / `loadOlderPage`)与两条导出守卫 +// (`timelineNeedsFirstPage` / `timelineCanLoadMore`),于是可以在 node 里**真跑**: +// +// 真的 api 客户端(`adminFlowTimeline` → `request` → `fetch`) +// + 一个**照契约实现**的假后端(按 `before` 严格更早、`hasMore`、`nextBefore`、`truncated`) +// + 真的状态机函数与真的纯函数(归一/去重/游标) +// ⇒ 断言「分页**不重不漏**、到底即停、零新增即停、失败不吞已拿到的、老后端 ≠ 空态」。 +// +// 跑法(与 `flowTimeline.test.ts` 同一手法:esbuild + node,无依赖): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/useFlowTimeline.test.ts \ +// --bundle --platform=node --format=esm \ +// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ +// --outfile=/tmp/cyrene-use-flow-timeline.test.mjs \ +// && node /tmp/cyrene-use-flow-timeline.test.mjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。 + +import { TIMELINE_PAGE_SIZE, timelineCursorOf, timelineHints } from "./flowTimeline"; +import { + emptyState, + loadFirstPage, + loadOlderPage, + timelineCanLoadMore, + timelineNeedsFirstPage, +} from "./useFlowTimeline"; +import type { Send, TimelineData } from "./useFlowTimeline"; +import { MAIN_SESSION_ID } from "../../../store/sessionCache"; +import type { FlowGraph } from "../../../lib/types"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same(got: T, want: T, what: string): void { + checks += 1; + if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`); +} + +const originalFetch = globalThis.fetch; +const originalLocalStorage = globalThis.localStorage; + +/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它。 */ +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +} as unknown as Storage; + +// ── 假后端:**照契约**实现时间轴出口(契约 v0.80 §4.11.1) ───────────────────── +// +// 数据:5 个回合(时间正序),其中第 2 轮有**两路**(真并行),因此总路数 6。 +// 页大小独立于 `limit`(设 2 ⇒ 等价于设计 §7.3 判据 4 的「`limit=2` 连续翻页」)。 +// `hasMore` / `nextBefore` / `truncated` 一律按后端口径算,不自作主张。 + +interface WireLane { + turnId: string; + graph: FlowGraph; +} +interface WireRound { + turnId: string; + round: number; + startedAt: string; + endedAt: string; + lanes: WireLane[]; +} + +function wireGraph(id: string, turnId: string, round: number, startedAt: string): FlowGraph { + return { + id, + sessionId: MAIN_SESSION_ID, + turnId, + round, + startedAt, + endedAt: startedAt, + nodes: [ + { id: "input", label: `${turnId}输入`, kind: "input", status: "done" }, + { id: "out", label: `${turnId}回复`, kind: "output", status: "done" }, + ], + edges: [{ from: "input", to: "out" }], + }; +} + +/** 5 个回合:`web-1#turn-a` … `web-5#turn-e`(第 2 轮两路)。 */ +function dataset(): WireRound[] { + const marks = [ + ["turn-a", "2026-10-06T02:00:00.000Z"], + ["turn-b", "2026-10-06T02:05:00.000Z"], + ["turn-c", "2026-10-06T02:09:00.000Z"], + ["turn-d", "2026-10-06T03:00:00.000Z"], + ["turn-e", "2026-10-06T03:30:00.000Z"], + ]; + return marks.map(([turnId, startedAt], i) => { + const round = i + 1; + const lanes: WireLane[] = [ + { turnId, graph: wireGraph(`web-${round}#${turnId}`, turnId, round, startedAt) }, + ]; + if (round === 2) { + lanes.push({ + turnId: `${turnId}#leg2`, + graph: wireGraph(`web-2#${turnId}#leg2`, `${turnId}#leg2`, 2, startedAt), + }); + } + return { turnId: `web-${round}#${turnId}`, round, startedAt, endedAt: startedAt, lanes }; + }); +} + +/** 假后端的可调档位(默认 = 正常后端)。 */ +interface ServerMode { + /** 一页几条(独立于 `limit`,用来把「`limit=2` 翻页」这条判据跑出来)。 */ + pageSize: number; + /** `persisted` = 正常;`liveOnly` = 落盘源未接线(只回内存最近一轮);`stuck` = 永远说还有更早。 */ + kind: "persisted" | "liveOnly" | "stuck"; +} + +const calls: string[] = []; + +/** 真跑 `fetch`:把假后端接在 `globalThis.fetch` 上(URL 逐条记下,供形状断言)。 + * + * 语义**照契约**(§4.11.1):`before` = 开始时刻**严格早于**它;`hasMore` = 库里还有更早的; + * `nextBefore` = 本页最早一轮的开始时刻;`truncated` = `hasMore || 落盘源未接线`。 + * `stuck` 档故意**忽略游标**并永远说 `hasMore`(模拟坏后端),用来钉住「零新增即停」。 */ +function installBackend(mode: Partial = {}): ServerMode { + const cfg: ServerMode = { pageSize: 2, kind: "persisted", ...mode }; + globalThis.fetch = ((url: string) => { + calls.push(String(url)); + const q = new URLSearchParams(String(url).split("?")[1] ?? ""); + const before = q.get("before") ?? ""; + const all = dataset(); + let pool = all; + if (cfg.kind === "liveOnly") { + pool = []; + } else if (before && cfg.kind !== "stuck") { + pool = all.filter((r) => r.startedAt < before); + } + const page = pool.slice(-cfg.pageSize); + const hasMore = cfg.kind === "stuck" ? pool.length > 0 : pool.length > page.length; + const payload = + cfg.kind === "liveOnly" + ? { + sessionId: MAIN_SESSION_ID, + source: "live", + rounds: [all[all.length - 1]], + nextBefore: "", + hasMore: false, + truncated: true, + retentionHours: 168, + retentionEnabled: true, + } + : { + sessionId: MAIN_SESSION_ID, + source: page.length > 0 ? "persisted" : "none", + rounds: page, + nextBefore: page.length > 0 ? page[0].startedAt : "", + hasMore, + truncated: hasMore, + retentionHours: 168, + retentionEnabled: true, + }; + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: () => Promise.resolve({ data: payload }), + }); + }) as unknown as typeof globalThis.fetch; + return cfg; +} + +/** 从记下的 URL 里取某个查询参数(客户端发的是 `URLSearchParams` 编码过的,这里解回来)。 */ +function queryOf(url: string, key: string): string | null { + return new URLSearchParams(url.split("?")[1] ?? "").get(key); +} + +/** 一个只回错误的假后端(`body === null` = 正文不是 JSON,真后端 404 就是这种)。 */ +function installError(status: number, body: unknown): void { + globalThis.fetch = ((url: string) => { + calls.push(String(url)); + return Promise.resolve({ + ok: false, + status, + headers: { get: () => "text/plain" }, + json: () => (body === null ? Promise.reject(new Error("not json")) : Promise.resolve(body)), + }); + }) as unknown as typeof globalThis.fetch; +} + +/** 测试里的「状态机宿主」:把 `Send` 补丁真的落进一份状态(等价于 hook 的 setState)。 */ +interface Host { + get: () => TimelineData; + send: Send; +} + +function host(sessionId: string): Host { + let state = emptyState(sessionId); + const send: Send = (patch) => { + state = patch(state); + }; + return { get: () => state, send }; +} + +/** 按 hook 的取法翻到底(`loadMore` 的守卫 + `timelineCursorOf` 的游标),返回翻页次数。 */ +async function pageToEnd(h: Host, sid: string, cap = 10): Promise { + let pages = 0; + while (timelineCanLoadMore(h.get()) && pages < cap) { + pages += 1; + const cursor = timelineCursorOf(h.get()); + if (!cursor) break; + await loadOlderPage(sid, cursor, h.send); + } + return pages; +} + +// ── ① 首屏:一个请求、URL 形状正确、状态照后端字段落定 ────────────────────── +installBackend(); +calls.length = 0; +const h = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, h.send); +same(calls.length, 1, "首屏恰好一个请求"); +ok( + calls[0].endsWith(`/api/v1/admin/flow/timeline?sessionId=${MAIN_SESSION_ID}&limit=${TIMELINE_PAGE_SIZE}`), + `首屏请求形状(会话 + limit):${calls[0]}` +); +same(h.get().rounds.length, 2, "假后端一页 2 条 ⇒ 状态里 2 个回合"); +same(h.get().rounds[0].turnId, "web-5#turn-e", "状态里**最新在前**(渲染顺序与分页游标解耦)"); +same(h.get().canLoadMore, true, "后端说 hasMore ⇒ 还能继续翻"); +same(h.get().truncated, true, "后端说 truncated ⇒ 原样落进状态(取数层不替它解释)"); +same(h.get().retentionHours, 168, "保留期原样落进状态"); +same(h.get().retentionEnabled, true, "保留期启用标记原样落进状态"); +same(h.get().source, "persisted", "数据源原样落进状态"); +same(h.get().loading, false, "拿到首屏后不再是加载态"); +same(h.get().failed, false, "首屏成功 ⇒ 没有失败标记"); + +// ── ② 分页收敛(`limit=2` 口径):不重不漏 + 到底即停 ─────────────────────── +calls.length = 0; +const pages = await pageToEnd(h, MAIN_SESSION_ID); +same(pages, 2, "5 条按 2 条一页 ⇒ 首屏之后再翻 2 次(第 3 页只有 1 条,hasMore=false ⇒ 停)"); +same(calls.length, 2, "翻页只打了 2 个请求(没有白打)"); +same( + queryOf(calls[0], "before"), + dataset()[3].startedAt, + `第 2 页的游标 = 上一页最早一轮的 startedAt(**原样回带**,不换算):${calls[0]}` +); +same( + queryOf(calls[1], "before"), + dataset()[1].startedAt, + `第 3 页的游标 = 第 2 页最早一轮的开始时刻(继续向更早推进):${calls[1]}` +); +same(queryOf(calls[0], "limit"), String(TIMELINE_PAGE_SIZE), "翻页请求照旧带 limit"); +same(h.get().rounds.length, 5, "3 页合并后 5 个回合(一个不多一个不少)"); +same(h.get().canLoadMore, false, "后端 hasMore=false ⇒ 停住(不再白打请求)"); +const ids = h.get().rounds.map((r) => r.turnId); +same(new Set(ids).size, ids.length, "合并后**没有重复**回合"); +same( + ids.join(","), + dataset() + .map((r) => r.turnId) + .reverse() + .join(","), + "合并后**没有漏**回合,且仍是「最新在前」" +); +same( + h.get().rounds.reduce((n, r) => n + r.lanes.length, 0), + 6, + "每轮的**路数**照后端给(第 2 轮两路 ⇒ 合计 6 路,不把两路并成一轮)" +); +same(h.get().rounds[3].lanes.length, 2, "真并行的那一轮,两路都在(不丢任一路)"); +// 到底之后再翻一次:守卫必须挡住(零请求)。 +const beforeGuard = calls.length; +same(timelineCanLoadMore(h.get()), false, "到底之后守卫为假"); +same(calls.length, beforeGuard, "守卫为假 ⇒ 不再发请求"); + +// ── ③ 零新增即停:坏后端(永远说 hasMore)也不许在同一个游标上打转 ─────────── +installBackend({ kind: "stuck" }); +calls.length = 0; +const stuckHost = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, stuckHost.send); +same(stuckHost.get().canLoadMore, true, "坏后端说 hasMore ⇒ 首屏后仍以为自己能翻"); +const stuckPages = await pageToEnd(stuckHost, MAIN_SESSION_ID); +same(stuckPages, 1, "坏后端每次回**同一页**(零新增)⇒ 只翻一次就停(否则无限打转)"); +same(calls.length, 2, "坏后端下总请求数 = 1(首屏)+ 1(唯一一次翻页)"); +same(stuckHost.get().rounds.length, 2, "零新增的那一页不改变已拿到的回合"); + +// ── ④ 首屏该不该拉(`enabled` 门 + 已拉到数据不重复拉) ───────────────────── +same(timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), MAIN_SESSION_ID), true, "没有数据 ⇒ 要拉"); +same(timelineNeedsFirstPage(h.get(), MAIN_SESSION_ID), false, "已经拿到回合 ⇒ 切视图来回**不重复拉**"); +same(timelineNeedsFirstPage(emptyState(""), MAIN_SESSION_ID), true, "会话变了(状态还是空的)⇒ 要拉"); +same(timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), "other"), true, "换成别的会话 ⇒ 要拉"); +same( + timelineNeedsFirstPage(emptyState(MAIN_SESSION_ID), ""), + false, + "视图没切到时间轴(hook 把 target 置空)⇒ **绝不打后端**(按需取数)" +); +same( + timelineNeedsFirstPage({ ...h.get(), failed: true }, MAIN_SESSION_ID), + true, + "上次失败 ⇒ 允许再拉(重试入口靠它)" +); +same( + timelineNeedsFirstPage({ ...h.get(), unsupported: true }, MAIN_SESSION_ID), + true, + "上次判定老后端 ⇒ 允许再拉(后端升级后重试)" +); + +// ── ⑤ 老后端(404 非 JSON 正文)≠ 空态;500 = 这次没读到 ──────────────────── +installError(404, null); +calls.length = 0; +const old721 = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, old721.send); +same(old721.get().unsupported, true, "404(路由不存在)⇒ unsupported(不是空态)"); +same(old721.get().failed, false, "404 不当成「这次没读到」(两件事分开)"); +same(old721.get().rounds.length, 0, "老后端下没有回合"); +same(old721.get().loading, false, "失败后不卡在加载态"); +same(calls.length, 1, "失败只打一次请求(不重试风暴)"); + +installError(500, { error: { code: "flow_unavailable", message: "boom" } }); +const broke = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, broke.send); +same(broke.get().failed, true, "500 ⇒ failed(如实说读不到)"); +same(broke.get().unsupported, false, "500 不当成老后端"); +const hintsWhenFailed = timelineHints(broke.get()); +ok( + !hintsWhenFailed.expired && !hintsWhenFailed.hidden, + "读不到时**不**标注「未显示 / 已过保留期」(那是另一件事)" +); + +// ── ⑥ 翻页失败:已拿到的回合照旧显示 ──────────────────────────────────────── +installBackend(); +const flaky = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, flaky.send); +const heldBefore = flaky.get().rounds.length; +installError(500, { error: { code: "flow_unavailable", message: "boom" } }); +await loadOlderPage(MAIN_SESSION_ID, timelineCursorOf(flaky.get()) ?? "", flaky.send); +same(flaky.get().moreFailed, true, "翻页失败 ⇒ moreFailed(界面据此说「这次没读到」)"); +same(flaky.get().rounds.length, heldBefore, "翻页失败**不吞掉**已拿到的回合"); +same(flaky.get().loadingMore, false, "翻页失败后不卡在 loadingMore"); + +// ── ⑦ 只有最近一轮(落盘源未接线):`source:"live"` + 如实提示 ────────────── +installBackend({ kind: "liveOnly" }); +const liveOnly = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, liveOnly.send); +same(liveOnly.get().source, "live", "本页全部来自内存 ⇒ source=live"); +same(liveOnly.get().rounds.length, 1, "只拿到最近一轮(1 个回合,不是 0)"); +same(liveOnly.get().canLoadMore, false, "live 页没有更早的可拉"); +const liveHints = timelineHints(liveOnly.get()); +ok(liveHints.liveOnly, "「只有最近一轮」必须能被标出来"); +ok(!liveHints.expired, "落盘源未接线 ≠「已过保留期」(不能说成过期)"); + +// ── ⑧ 🔴 「未显示」与「已过保留期」不许混(契约 §4.11.1(b).3) ────────────── +// 真机上 truncated = hasMore || 落盘源未接线 ⇒ 一页**还有更早可拉**时 truncated 就是 true。 +installBackend(); +const paging = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, paging.send); +const pagingHints = timelineHints(paging.get()); +ok(pagingHints.hidden, "还有更早的可拉 + truncated ⇒ 只能说「更早的轮次未显示」"); +ok(!pagingHints.expired, "**绝不**对着一页仍可翻的历史说「已过保留期」(这就是核出的缺陷)"); +await pageToEnd(paging, MAIN_SESSION_ID); +const endedHints = timelineHints(paging.get()); +same(paging.get().truncated, false, "翻到底后 `truncated` 逐页覆盖成 false(不累积成「历史上曾经缺过」)"); +ok(!endedHints.expired, "翻到底后**不再**说「已过保留期」(后端压根没给过清扫信号)"); +ok(!endedHints.hidden, "翻到底后也不再挂「更早的轮次未显示」——手上已经是保留期内的全部"); +// ── ⑨ 网络失败:仍然只是「这次没读到」(不伪装成空态 / 老后端) ───────────── +globalThis.fetch = (() => Promise.reject(new Error("offline"))) as unknown as typeof globalThis.fetch; +const offline = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, offline.send); +same(offline.get().failed, true, "网络失败 ⇒ failed(不伪装成空态 / 老后端)"); + +globalThis.fetch = originalFetch; +globalThis.localStorage = originalLocalStorage; + +console.log(`use flow timeline test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/useFlowTimeline.ts b/frontend/web/src/admin/modules/flow/useFlowTimeline.ts index 116525b..ca39d1c 100644 --- a/frontend/web/src/admin/modules/flow/useFlowTimeline.ts +++ b/frontend/web/src/admin/modules/flow/useFlowTimeline.ts @@ -5,29 +5,38 @@ import { TIMELINE_PAGE_SIZE, appendTimelinePage, classifyTimelineFailure, - pageMayHaveOlder, - timelineCursor, + pageHasOlder, + timelineCursorOf, timelinePageOf, } from "./flowTimeline"; +import type { TimelineSource } from "./flowTimeline"; /** * 链路**时间轴**的取数 hook(切片 W2;`design/19 §7.1`;W1 的新出口 - * `GET /api/v1/admin/flow/timeline`)。 + * `GET /api/v1/admin/flow/timeline`;契约 v0.80 §4.11.1)。 * * 分工:`useFlowSnapshot`(F1)补**最近一轮**、`useFlowTurns`(F2)翻**单轮历史**, * 本 hook 一次给**多轮**(每轮连同它每一路),供时间轴视图把「所有消息按时间顺序」摊开。 * 它**不写 store**(不碰 `flowGraphs` / `flowLanes`)—— 时间轴是**只读回看**,写进图桶 * 会把正在跑的那一轮顶掉(裁决 16.4.2 的同一理由)。 * - * 四条刻意口径: + * 五条刻意口径: * 1. **按需取数**(`enabled`):只有真的切到时间轴视图才打这个端点(默认不打扰后端); - * 2. **分页只向更早走**:游标 = 手上**最旧**那一回合的 `startedAt`(`flowTimeline.ts` - * 的纯函数),与展示顺序无关;一页没带来任何**新**回合 ⇒ 立刻判定到头(否则会在同 - * 一个游标上打转到天荒地老); + * 2. **分页只向更早走,且认后端的判据**:游标优先用响应里的 `nextBefore`、能不能继续翻 + * 优先用 `hasMore`(契约 §4.11.1(a) 的权威字段)—— 自己算的「最旧那一回合的开始时间」 + * 与「满页启发式」只在老后端**没给**这两个键时兜底;一页没带来任何**新**回合 ⇒ 立刻 + * 判定到头(否则会在同一个游标上打转到天荒地老); * 3. **老后端如实降级**:404/501 ⇒ `unsupported`(面板文案「当前只支持最近一轮」), * **绝不**把「没有这个出口」说成「这段时间没有回合」; * 4. **失败不伪装**:首屏失败 ⇒ `failed` + 可重试;翻页失败只记在 `moreFailed`, - * 已拿到的回合**照旧显示**(不回退成空态)。 + * 已拿到的回合**照旧显示**(不回退成空态); + * 5. **保留期口径原样转达**:`retentionHours` / `retentionEnabled` / `truncated` / `source` + * 四个字段**逐字**落进状态,由 `flowTimeline.ts#timelineHints` 判定「未显示」与 + * 「已过保留期」—— 取数层**不替**后端解释(那正是「已过保留期」说假话的来路)。 + * + * 取数体的两个函数(`loadFirstPage` / `loadOlderPage`)**导出**是为了能在无 jsdom 的项目里 + * 被真跑:测试驱动**真的** `lib/api` 客户端(request → fetch)+ **真的**这几个状态机函数 + * (同 `useFlowSnapshot.ts` 的手法),而不是只断言纯函数。 */ export interface FlowTimelineState { /** 保留期内的回合(**最新在前**,与展示顺序无关;渲染时再用 `orderRounds` 摆)。 */ @@ -36,11 +45,16 @@ export interface FlowTimelineState { total: number; /** 保留期(小时);后端没给 ⇒ `null`(面板显示「未知」,不编造)。 */ retentionHours: number | null; - /** 更早的回合已过保留期(后端口径)—— 面板必须**如实标注**。 */ + /** 保留期清扫**是否启用**;缺失 ⇒ `null`(由小时数推断,见 `retentionClaimable`)。 */ + retentionEnabled: boolean | null; + /** 后端**最近这次响应**说「本次不是全部」(`hasMore` 或落盘源未接线)—— 面板据此如实 + * 标注「更早的轮次未显示」;**不是**「已过保留期」(判据见 `timelineHints`)。 */ truncated: boolean; + /** 本页数据源:`persisted` / `live`(只拿到内存里的最近一轮)/ `none`;缺失 ⇒ `null`。 */ + source: TimelineSource | null; /** 首屏加载中(已有数据后翻页不算)。 */ loading: boolean; - /** 还有更早的回合可拉(短页 / 游标不可用 / 已拉空 ⇒ false)。 */ + /** 还有更早的回合可拉(后端 `hasMore` 或老后端的短页启发式 ⇒ false 时停)。 */ canLoadMore: boolean; /** 正在拉更早的一页。 */ loadingMore: boolean; @@ -56,11 +70,16 @@ export interface FlowTimelineState { reload: () => void; } -interface TimelineData { +/** 取数层的内部状态(= 上面那个快照 + 归属会话与分页游标)。 */ +export interface TimelineData { sessionId: string; rounds: AdminFlowTimelineRound[]; retentionHours: number | null; + retentionEnabled: boolean | null; truncated: boolean; + source: TimelineSource | null; + /** 后端给的下一页游标(本页最早一轮的开始时刻);`null` = 没有 / 老后端没给。 */ + nextBefore: string | null; loading: boolean; loadingMore: boolean; unsupported: boolean; @@ -70,12 +89,15 @@ interface TimelineData { } /** 空态初值(会话还没确定 / 视图还没切过来)。 */ -function emptyState(sessionId: string): TimelineData { +export function emptyState(sessionId: string): TimelineData { return { sessionId, rounds: [], retentionHours: null, + retentionEnabled: null, truncated: false, + source: null, + nextBefore: null, loading: false, loadingMore: false, unsupported: false, @@ -85,6 +107,25 @@ function emptyState(sessionId: string): TimelineData { }; } +/** 首屏该不该拉(effect 的守卫;抽成纯函数以便测试直接钉住 —— 本项目没有 jsdom)。 + * + * 不拉的情形只有一种:**这个会话已经拿到过回合,且没失败、也不是老后端**(切视图来回 + * 不该每次都打后端;真要重拉走 `reload`)。其余(没数据 / 上次失败 / 老后端修复后) + * 一律要拉。 */ +export function timelineNeedsFirstPage(known: TimelineData, target: string): boolean { + if (!target) return false; + if (known.sessionId !== target) return true; + if (known.failed || known.unsupported) return true; + return known.rounds.length === 0; +} + +/** 能不能翻更早的一页(`loadMore` 的守卫):会话已定 + 后端说还有更早 + 不在加载中。 */ +export function timelineCanLoadMore(state: TimelineData): boolean { + return ( + Boolean(state.sessionId) && state.canLoadMore && !state.loadingMore && !state.loading + ); +} + export function useFlowTimeline(sessionId: string, enabled: boolean): FlowTimelineState { const target = enabled ? sessionId : ""; const [state, setState] = useState(() => emptyState("")); @@ -92,29 +133,29 @@ export function useFlowTimeline(sessionId: string, enabled: boolean): FlowTimeli 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); + if (!target || !timelineNeedsFirstPage(stateRef.current, target)) return; + let cancelled = false; + // 卸载 / 换会话后到达的响应一律丢弃(与既有 hook 的 cleanup 同一口径)。 + const send: Send = (patch) => setState((s) => (cancelled ? s : patch(s))); + void loadFirstPage(target, send); + return () => { + cancelled = true; + }; }, [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 (!timelineCanLoadMore(current)) return; + const cursor = timelineCursorOf(current); if (!cursor) { - // 最旧那一回合没有可用的开始时间 ⇒ 没法构造游标 ⇒ 停住(不瞎凑游标), - // 由界面如实说明「没有更早的」。 + // 最旧那一回合既没有 `nextBefore`、开始时间也不可解析 ⇒ 没法构造游标 ⇒ 停住 + //(不瞎凑游标),由界面如实说明「没有更早的」。 setState((s) => ({ ...s, canLoadMore: false })); return; } - fetchOlderPage(sid, cursor, setState); + void loadOlderPage(current.sessionId, cursor, setState); }, []); const reload = useCallback(() => { @@ -123,75 +164,99 @@ export function useFlowTimeline(sessionId: string, enabled: boolean): FlowTimeli setSeq((n) => n + 1); }, []); + return flowTimelineViewState(state, loadMore, reload); +} + +/** 内部取数状态 → **视图状态**(hook 的返回值就是这个映射)。 + * + * 抽出来是为了让「真取数 → 真状态 → 真渲染」这条回路能在无 jsdom 的项目里跑完: + * 测试把**真 fetch 跑出来的状态**经这里映射后当 props 喂给 `FlowTimelineView`,于是 + * 渲染断言针对的是「取数层真的会得到的状态」,而不是手写的替身。 */ +export function flowTimelineViewState( + data: TimelineData, + loadMore: () => void, + reload: () => void +): FlowTimelineState { 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, + rounds: data.rounds, + total: data.rounds.length, + retentionHours: data.retentionHours, + retentionEnabled: data.retentionEnabled, + truncated: data.truncated, + source: data.source, + loading: data.loading, + canLoadMore: data.canLoadMore, + loadingMore: data.loadingMore, + unsupported: data.unsupported, + failed: data.failed, + moreFailed: data.moreFailed, loadMore, reload, }; } /** 取数体的类型(把「落进状态」这一动作注入,使下面两个取数函数不必认识 React)。 */ -type Send = (patch: (prev: TimelineData) => TimelineData) => void; +export type Send = (patch: (prev: TimelineData) => TimelineData) => void; /** 首屏取数:先置加载态,再把「一页 + 还能不能翻」落进状态;失败按 `classifyTimelineFailure` - * 分「老后端」与「这次没读到」两支。返回取消函数(effect 的 cleanup)。 */ -function fetchFirstPage(target: string, send: Send): () => void { - let cancelled = false; + * 分「老后端」与「这次没读到」两支。 + * + * ⚠️ 它**不自带取消**:取消由调用方(effect 的 cleanup)用带守卫的 `send` 表达 —— 这样 + * 同一份逻辑既能在 hook 里跑,也能在测试里被 `await`(本项目没有 jsdom / 没有测试运行器)。 */ +export async function loadFirstPage(target: string, send: Send): Promise { 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; - }; + try { + const resp = await adminFlowTimeline(target, { limit: TIMELINE_PAGE_SIZE }); + const page = timelinePageOf(resp); + send(() => ({ + ...emptyState(target), + rounds: page.rounds, + retentionHours: page.retentionHours, + retentionEnabled: page.retentionEnabled, + truncated: page.truncated, + source: page.source, + nextBefore: page.nextBefore, + canLoadMore: pageHasOlder(page.hasMore, page.rounds.length, TIMELINE_PAGE_SIZE), + })); + } catch (err: unknown) { + const mark = classifyTimelineFailure(err); + send(() => ({ + ...emptyState(target), + unsupported: mark === "unsupported", + failed: mark === "failed", + })); + } } -/** 取更早的一页:合并去重、保留期取「已拿到的优先」、`truncated` 一旦为真就不再回落 - * (过一个保留期边界就够了);失败只影响翻页,**已拿到的回合照旧显示**。 */ -function fetchOlderPage(sid: string, cursor: string, send: Send): void { +/** 取更早的一页:合并去重、保留期取「已拿到的优先」;失败只影响翻页,**已拿到的回合照旧 + * 显示**。 + * + * ⚠️ `truncated` **逐页覆盖,不累积** —— 它描述的是**最近这次响应**「是不是全部」, + * 而不是「历史上曾经缺过」(翻到底之后它就该消失;累积会让面板在翻到底后仍说 + * 「更早的轮次已过保留期」)。 + * + * `cursor` 由 `timelineCursorOf` 给出(优先后端的 `nextBefore`)。 */ +export async function loadOlderPage(sid: string, cursor: string, send: Send): Promise { 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)); + try { + const resp = await adminFlowTimeline(sid, { limit: TIMELINE_PAGE_SIZE, before: cursor }); + const page = timelinePageOf(resp); + send((s) => { + if (s.sessionId !== sid) return s; + const next = appendTimelinePage(s.rounds, page.rounds, TIMELINE_PAGE_SIZE, page.hasMore); + return { + ...s, + rounds: next.rounds, + retentionHours: page.retentionHours ?? s.retentionHours, + retentionEnabled: page.retentionEnabled ?? s.retentionEnabled, + truncated: page.truncated, + source: page.source ?? s.source, + nextBefore: page.nextBefore ?? s.nextBefore, + loadingMore: false, + canLoadMore: next.canLoadMore, + }; }); + } catch { + send((s) => (s.sessionId === sid ? { ...s, loadingMore: false, moreFailed: true } : s)); + } } diff --git a/frontend/web/src/lib/api/adminSystem.ts b/frontend/web/src/lib/api/adminSystem.ts index 5d8015a..314b68e 100644 --- a/frontend/web/src/lib/api/adminSystem.ts +++ b/frontend/web/src/lib/api/adminSystem.ts @@ -116,16 +116,33 @@ export interface AdminFlowTimelineRound { lanes: AdminFlowLane[]; } -/** 链路**时间轴**端点的响应(`design/19 §7.1`;W1 新增的只读出口)。 */ +/** 链路**时间轴**端点的响应(契约 v0.80 §4.11.1;W1 的新出口)。 + * + * 🔴 **`truncated` 不等于「已过保留期」**(契约 §4.11.1(b).3 的原话):它的含义是 + * **「本次不是全部」** = `hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。 + * 「更早的回合已过保留期」这句话的判据只能是 `retentionEnabled` + `retentionHours` + + * `truncated` **三者一起读**,**不得**因为「返回的回合比想象中少」就断言被清扫了 + * —— 否则面板会对着一页**仍然可翻**的历史说「已过保留期」(本切片核出的成对契约缺陷)。 */ export interface AdminFlowTimelineResponse { /** 解析后的会话(回显)。取数只认调用时给的 `sessionId`,不依赖这一键。 */ sessionId?: string; + /** 本页**至少一轮**来自落盘 `persisted` / 本页**全部**来自内存 `live`(落盘源未接线, + * 或该会话还没有落盘轮)/ `none`(没有任何回合)。 */ + source?: "persisted" | "live" | "none"; /** 保留期内的回合。后端按**时间正序**(最旧在前)返回;面板渲染前自己归一排序。 */ rounds: AdminFlowTimelineRound[]; - /** `flow_graphs` 的保留期(小时)。`≤0` = 后端配置为不回收(见 `FLOW_GRAPH_RETENTION`)。 */ - retentionHours: number; - /** **更早的回合已过保留期**(`design/19 §7.1`)⇒ 面板必须**如实标注**,不许静默少块。 */ + /** 本页**最早一轮**的开始时刻;`hasMore` 为真时**原样回带**成下一次请求的 `before` + * 即可连续向更早翻页(游标精度到毫秒)。没有回合时是空串。 */ + nextBefore?: string; + /** 库里**还有更早的回合**没被本次返回(= 还能继续翻页)。**这是唯一权威的翻页判据**; + * 面板不要用「本页是否满页」去猜(满页的最后一页会被猜成「还有更早」,白打一次请求)。 */ + hasMore?: boolean; + /** 本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线。**不是**「已过保留期」。 */ truncated: boolean; + /** `flow_graphs` 的保留期(小时,**下取整**)。`≤0` = 后端配置为不回收。 */ + retentionHours: number; + /** 保留期清扫**是否启用**。`false` ⇒ 「更早的回合」**不是**被清扫掉的,别说成过期。 */ + retentionEnabled?: boolean; } /** GET /api/v1/admin/flow/timeline?sessionId=&limit=&before= — **多轮时间轴**(`design/19 §7`; @@ -133,9 +150,12 @@ export interface AdminFlowTimelineResponse { * **增量兄弟**:一次给**多轮**,每轮连同它**每一路**的图,供面板画「所有消息按时间顺序串 * 起来」的时间轴。 * - * 分页:`limit` 缺省 50(后端硬上限 200);`before` 是**开始时间游标** —— 传「当前手上 - * 最旧那一回合的 `startedAt`(原样回传后端给的值)」即继续取更早的。⚠️ 本函数**不解释** - * `before`(不做字符串/毫秒换算),只原样转交:面板的游标就是后端给的时间字符串。 + * 分页(契约 §4.11.1(a)):`limit` 缺省 50(后端硬上限 200);`before` 是**开始时间游标** + * —— 「开始时刻**严格早于**它」的回合才返回(省略 = 最新那一页)。游标取响应里的 + * `nextBefore`(= 本页最早一轮的开始时刻)**原样回带**即可连续翻页,**不重不漏**。 + * ⚠️ 本函数**不解释** `before`(不做字符串/毫秒换算),只原样转交:面板的游标就是后端 + * 给的时间字符串。⚠️ **也不要**用「本页是否满页」代替 `hasMore`:满页的最后一页会被误 + * 判成「还有更早的」,白打一次请求(`hasMore` 才是权威判据)。 * * 老后端(该出口未接线)⇒ `404`;调用方按「当前只支持最近一轮」诚实降级,别把 404 读成 * 「这段时间没有回合」。 */