fix(admin): 时间轴分页与保留期文案按契约收紧(V2)

- 类型补上后端真在返回的 source/nextBefore/hasMore/retentionEnabled(此前类型里没有)
- 新增纯函数 pageHasOlder(hasMore 权威,老后端退满页启发式)/timelineCursorOf/retentionClaimable/
  timelineHints;取数体抽成可执行导出函数 ⇒ effect 本体首次被真跑(真 fetch + 真状态机 + 真 SSR)
- 🔴 修一处成对契约缺陷:后端 truncated = hasMore || !storeWired,而契约/设计要求 truncated ⇒
  「已过保留期」⇒ 旧前端会对主人说假话。现改为:只有「已翻到底 + 保留期启用 + 非 live-only」
  才说「已过保留期」,其余说「更早的轮次未显示」
- 3 个变异自检(去掉 canLoadMore 短路/恒可翻页/忽略 hasMore)
⚠️ 未验(如实):鉴权后的真机 HTTP 往返拿不到 owner 凭据;前端产物未部署(面板仍是旧文案)
This commit is contained in:
AskaEth committed 2026-10-06 20:25:06 +08:00
1 parent ed5005307a
commit 745365c7df
8 files changed
+1047 -99

No files matched your search

@@ -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`);