feat(reports): 我的报告 enters on a static shell and joins the prefetch; 过往的报告 pages 10 at a time with 加载更多 (BUG-1123, BUG-1124)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
90b1b6fba9
commit
9a616107c2
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-30 — 「我的报告」进得更快,过往报告可以一直往前翻
|
||||
|
||||
- 点「我的报告」不再先等一轮服务器渲染;侧栏悬停时已开始读的列表会直接用上,不再重读一遍。之前读过的列表进页即显示,后台再刷新。
|
||||
- 「过往的报告」每次显示 10 份,底部「加载更多」再往前 10 份,直到没有更早的;之前固定只显示最新 20 份,更早的看不到。生成中自动刷新不会把已经展开的列表收回去。Skill 版本不 bump。
|
||||
|
||||
## 2026-09-30 — 星盘符号气泡不再闪;星盘表格恢复圆角
|
||||
|
||||
- 鼠标停在星盘里的行星、上升或星座编号上,说明气泡会稳定显示;之前鼠标每动一下气泡就消失再出现。
|
||||
|
||||
@@ -14963,3 +14963,30 @@
|
||||
- 相关记录:BUG-1100(气泡引入)
|
||||
- 修复版本:分支 `codex/notice-loading-polish-20260930`
|
||||
|
||||
## BUG-1123 | 点「我的报告」要等很久才进页
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;待部署,真机体感待产品复核)
|
||||
- 首次发现 / 最近更新:2026-09-30 / 2026-09-30
|
||||
- 影响面:`frontend/src/app/(app)/reports/page.tsx`、`frontend/src/components/personal-report/personal-report-center.tsx`、`frontend/src/lib/secondary-page-data.ts`
|
||||
- 现象:产品反馈「点击我的报告 跳转页面的时间很长」。
|
||||
- 触发条件:从任一页面点侧栏「我的报告」。
|
||||
- 根因:三段串行等待叠加,且用来掩盖等待的预取被丢弃。① 页面 `force-dynamic` 且无 `loading.tsx`,客户端跳转必须先等一轮服务端 RSC 渲染才切页(`/reports` 并不读 cookies,是纯客户端外壳;BUG-716 已把 `/chart` 改成静态外壳,`/reports` 当时没跟上,还被 `stale-client-recovery.test.ts` 钉住)。② 进页挂载时 `invalidateReportsPage()` 重置缓存,侧栏悬停 / 按下时已发出的 `GET /api/reports` 结果被作废,再发第二次同样的请求。③ 列表接口本身(登录校验 → 列表 → 失败段落)排在后面。
|
||||
- 修复:去掉 `force-dynamic`,`/reports` 成为静态外壳;进页不再作废缓存:有缓存直接显示并后台重读,无缓存则加入正在进行的预取请求(同一 inflight)。删除行时仍作废缓存,旧 GET 不能让已删行复活。
|
||||
- 验证:`report-list-paging-20260930.test.tsx`「entering 我的报告 joins the sidebar prefetch: one GET, not two」——预取后挂载只发 1 次 GET(修前 2 次);`report-row-delete.test.tsx` 删除后旧 GET 不复活仍通过;`stale-client-recovery.test.ts` 断言改为不再 force-dynamic(三栏注释)。未在真实 staging 计时,体感待产品复核。
|
||||
- 防复发:次级页(chart / ephemeris / people / reports)一律静态外壳 + 客户端取数;进页 effect 不得 reset 共享缓存,只能 join 或后台重读。
|
||||
- 相关记录:BUG-716、BUG-1104
|
||||
- 修复版本:分支 `codex/reports-list-paging-20260930`
|
||||
|
||||
## BUG-1124 | 「过往的报告」只显示最新 20 份,更早的无法访问
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;待部署)
|
||||
- 首次发现 / 最近更新:2026-09-30 / 2026-09-30
|
||||
- 影响面:`frontend/src/app/api/reports/route.ts`(GET)、`frontend/src/lib/report-cursor.ts`(新)、`personal-report-center.tsx`
|
||||
- 现象:产品反馈「过往报告列表应该加一个分页」;核查发现接口写死 `.limit(20)` 且无游标,第 21 份起在列表里永远看不到。
|
||||
- 根因:列表接口初版只按 `created_at desc` 取前 20 行,没有分页参数;前端一次性渲染全部返回行。
|
||||
- 修复:`GET /api/reports?subject=&limit=10&before=<created_at,id>` 键集分页,按 `(created_at desc, id desc)` 排序、取 `limit + 1` 判断是否还有更多,返回 `{ reports, nextCursor }`;非法游标 400。不带 `limit` 视为首页 10 份(原为 20)。前端 10 份一批 +「加载更多」(产品选定);3 秒轮询与刷新只重读首页并合并到已展开的列表,不丢已加载的更早页;删除本地移除;换人物重置。`.or(created_at.lt…,and(…))` 与双 `.order` 的写法和 `/api/sessions` 游标同一套,自托管兼容层已在该接口上线使用。
|
||||
- 验证:`report-list-paging-20260930.test.tsx` 7 条(游标编解码与 Date 行、接口源码合同、合并规则、客户端 URL 与 before、加载更多追加 / 加载中 / 到底隐藏、静态外壳与样式);前端全量 4,478 条,失败 24 条与基线逐条相同,无测试名消失。未跑 `test:db`(本轮不动表;游标查询写法与已上线的会话分页相同)。
|
||||
- 防复发:列表接口不得用写死的上限代替分页;新列表按 `session-cursor` / `report-cursor` 的键集模式。
|
||||
- 相关记录:BUG-1123
|
||||
- 修复版本:分支 `codex/reports-list-paging-20260930`
|
||||
|
||||
|
||||
@@ -585,6 +585,7 @@ The chart page, the ephemeris and the report list wait with one motion: the home
|
||||
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits alone in the 46px header. The body opens with the **generate card** (`.report-center-create`, 2026-09-29, product sketch): a centred `--color-canvas` sheet, `--space-6` below the header (BUG-1103: it used to sit flush under it), with a file icon, the title 「完整本命报告」 (what you get), one line 「星盘、力量、大运、年运、瑜伽共 6 章,中英两版;生成后可离开,完成时下方自动出现。」 and the filled 「生成报告」 button (what it does) — title and button no longer say the same thing — the page's only generate entry; the header button was deleted rather than kept as a second one. Below it, 「过往的报告」 heads the **row list** of reports; with none yet, a single quiet line 「还没有个人报告。」 replaces the old second big empty card. The supporting paragraph (「有填报到分钟的出生时间即可生成……」) and the 「共 N 份 · 已完成 N 份」 overview line above it were removed on 2026-09-28 (TASK-self-edit-avatar-menu-20260928 S3, product: 「这里的提示去掉」); the minute requirement still lives in the 生成 button's hover title.
|
||||
It used to be a standalone full-screen route with a `report-center-shell` root, a `report-center-topbar` holding one 「返回对话」 link, and a `report-center-hero` with a page-sized h1 — the sidebar vanished the moment you opened it, and the only way back was that link.
|
||||
- **Row list, not a card grid:** `.report-center-list` is one column of `.report-center-row`, hairline-separated by a 1px grid gap over a `--color-border` ground so each boundary is a single rule rather than two touching borders. A card grid costs one scan per card; past five or six reports the reader is looking for state, and a single column puts every status chip on the same x. D12.
|
||||
- **Paging (2026-09-30, BUG-1124):** the list shows the newest 10; under it an outline 「加载更多」 button (centred, at least 160px; full width below 767px) brings the next 10. While that page loads the button reads 「加载中」 and is disabled — no spinner. It disappears when nothing older exists. A failed page is a toast 「更早的报告没读出来,再试一次。」, the list stays. The 3s poll while a report is generating and the 刷新 button re-read the first page only and merge it over the pages already opened, so a refresh never collapses the list back to 10. Entering 我的报告 shows the cached first page at once and revalidates behind it (BUG-1123); a cold entry keeps the one shared waiting state.
|
||||
- **Row anatomy:** status chip, then the title at `--type-title-md`/500, then the meta line (creation time · depth · themes) at `--type-caption` with `tabular-nums`, then — for a ready report — its stored card summary, or for a failed one the failure reason. Actions sit right-aligned on the same row and wrap under the body at the global 767px cut.
|
||||
- **Status chip:** a caption pill with a 5px `currentColor` dot and a text label. Ready uses `--color-success`, generating `--color-warning`, failed `--color-danger`; each background mixes the same token at 12%. No spinner. Ready rows use `--report-paper`; all other rows use `--color-canvas`.
|
||||
- **Meta honesty:** the row says only what `GET /api/reports` returns. It carries no section or chart count, so the row does not claim one; inventing 「9 节 · 22 张盘」 on the client would be a made-up number (VOICE.md 第 2 条). If the endpoint ever projects per-section progress, it belongs on the generating row's note line.
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { Metadata } from "next";
|
||||
|
||||
import { PersonalReportCenter } from "@/components/personal-report/personal-report-center";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "个人报告中心 · Jyotisha",
|
||||
robots: { index: false, follow: false },
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
subjectIdFromQuery,
|
||||
} from "@/lib/subject-birth";
|
||||
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
|
||||
import { clampReportLimit, nextReportCursor, parseReportCursor, reportCursorFilter } from "@/lib/report-cursor";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
@@ -107,7 +108,16 @@ export async function GET(request: Request) {
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
const subjectId = subjectIdFromQuery(new URL(request.url).searchParams.get("subject"));
|
||||
const url = new URL(request.url);
|
||||
// Keyset pages of 10 (BUG-1124). No `limit` is the first page, so an old
|
||||
// client that never sends one gets the newest 10 (was a fixed 20).
|
||||
const limit = clampReportLimit(url.searchParams.get("limit"));
|
||||
const beforeRaw = url.searchParams.get("before");
|
||||
const cursor = parseReportCursor(beforeRaw);
|
||||
if (beforeRaw && !cursor) {
|
||||
return NextResponse.json({ error: "报告列表请求无效" }, { status: 400 });
|
||||
}
|
||||
const subjectId = subjectIdFromQuery(url.searchParams.get("subject"));
|
||||
if (subjectId !== "self" && !isChartSubjectId(subjectId)) {
|
||||
return NextResponse.json({ error: "星盘不存在", code: "subject_not_found" }, { status: 404 });
|
||||
}
|
||||
@@ -118,11 +128,16 @@ export async function GET(request: Request) {
|
||||
listQuery = subjectId === "self"
|
||||
? listQuery.is("chart_profile_id", null)
|
||||
: listQuery.eq("chart_profile_id", subjectId);
|
||||
const { data, error } = await listQuery
|
||||
listQuery = listQuery
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(20);
|
||||
.order("id", { ascending: false })
|
||||
.limit(limit + 1);
|
||||
if (cursor) listQuery = listQuery.or(reportCursorFilter(cursor));
|
||||
const { data: pageRows, error } = await listQuery;
|
||||
if (error) throw error;
|
||||
const reports = Array.isArray(data) ? data.map((row) => listReportView(row)).filter((report) => report.id) : [];
|
||||
const nextCursor = nextReportCursor(Array.isArray(pageRows) ? pageRows : [], limit);
|
||||
const data = Array.isArray(pageRows) ? pageRows.slice(0, limit) : [];
|
||||
const reports = data.map((row) => listReportView(row)).filter((report) => report.id);
|
||||
const failed = reports.filter((report) => report.status === "failed" && report.requestId);
|
||||
if (failed.length > 0) {
|
||||
const { data: sections, error: sectionError } = await supabase
|
||||
@@ -144,9 +159,9 @@ export async function GET(request: Request) {
|
||||
}
|
||||
for (const [index, report] of reports.entries()) {
|
||||
if (report.status !== "failed") continue;
|
||||
const source = Array.isArray(data) ? data.find((row) => (
|
||||
const source = data.find((row) => (
|
||||
row && typeof row === "object" && (row as { id?: unknown }).id === report.id
|
||||
)) : null;
|
||||
)) ?? null;
|
||||
reports[index] = listReportView(source, summarizePersonalReportFailure({
|
||||
themeCount: report.themes.length,
|
||||
sections: grouped.get(report.requestId) ?? [],
|
||||
@@ -154,7 +169,7 @@ export async function GET(request: Request) {
|
||||
}));
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ reports });
|
||||
return NextResponse.json({ reports, nextCursor });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -3870,6 +3870,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
/* Row list, not a card grid. A card grid costs one scan per card; past five or
|
||||
six reports the status is what the reader is looking for, and a single column
|
||||
of rows puts every status chip on the same x. D12. */
|
||||
/* 「加载更多」 under 过往的报告 (2026-09-30, BUG-1124): 10 at a time, an
|
||||
outline button centred under the list, full width on phones. */
|
||||
.report-center-more { display: flex; margin: var(--space-4) auto 0; min-width: 160px; }
|
||||
@media (max-width: 767px) {
|
||||
.report-center-more { width: 100%; }
|
||||
}
|
||||
.report-center-list {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppLink } from "@/components/app-link";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { readCurrentSubjectScope, useCurrentSubjectScope, useCurrentSubjectReady } from "@/lib/current-subject";
|
||||
@@ -13,7 +14,9 @@ import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
import { PERSONAL_REPORT_GENERATING_COPY } from "@/lib/personal-report-longform-copy";
|
||||
import { REPORT_DEPTH_LABELS, reportThemeLabel } from "@/lib/personal-report-progress";
|
||||
import { ReportRowDelete } from "./report-row-delete";
|
||||
import { appendReportPage, mergeFirstReportPage } from "@/lib/report-cursor";
|
||||
import {
|
||||
fetchMoreReports,
|
||||
invalidateReportsPage,
|
||||
peekReportsPage,
|
||||
refreshReportsPage,
|
||||
@@ -25,11 +28,19 @@ import {
|
||||
|
||||
const LIST_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
type CenterState =
|
||||
| { phase: "loading"; reports: readonly ReportListItem[] }
|
||||
| { phase: "ready"; reports: readonly ReportListItem[] }
|
||||
| { phase: "unauthorized"; reports: readonly ReportListItem[] }
|
||||
| { phase: "error"; reports: readonly ReportListItem[] };
|
||||
/**
|
||||
* `nextCursor` is where 「加载更多」 continues; `extended` says the user has
|
||||
* opened pages past the first, so a refreshed first page is merged over them
|
||||
* instead of replacing the list (BUG-1124).
|
||||
*/
|
||||
type CenterState = {
|
||||
phase: "loading" | "ready" | "unauthorized" | "error";
|
||||
reports: readonly ReportListItem[];
|
||||
nextCursor: string | null;
|
||||
extended: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_LIST = { reports: [], nextCursor: null, extended: false } as const;
|
||||
|
||||
const STATUS_COPY = {
|
||||
generating: { label: "生成中", description: PERSONAL_REPORT_GENERATING_COPY },
|
||||
@@ -38,10 +49,25 @@ const STATUS_COPY = {
|
||||
} as const;
|
||||
|
||||
function snapshotToState(snapshot: ReportsPageSnapshot | null): CenterState {
|
||||
if (snapshot?.kind === "ready") return { phase: "ready", reports: snapshot.reports };
|
||||
if (snapshot?.kind === "unauthorized") return { phase: "unauthorized", reports: [] };
|
||||
if (snapshot?.kind === "error") return { phase: "error", reports: snapshot.reports };
|
||||
return { phase: "loading", reports: [] };
|
||||
if (snapshot?.kind === "ready") return { phase: "ready", reports: snapshot.reports, nextCursor: snapshot.nextCursor, extended: false };
|
||||
if (snapshot?.kind === "unauthorized") return { phase: "unauthorized", ...EMPTY_LIST };
|
||||
if (snapshot?.kind === "error") return { phase: "error", ...EMPTY_LIST, reports: snapshot.reports };
|
||||
return { phase: "loading", ...EMPTY_LIST };
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh first page applied to what is on screen. Before 「加载更多」 it simply
|
||||
* replaces the list; after it, the older rows the user opened stay and the
|
||||
* cursor keeps pointing past them.
|
||||
*/
|
||||
export function applyFirstReportPage(
|
||||
current: Pick<CenterState, "reports" | "nextCursor" | "extended">,
|
||||
page: { reports: readonly ReportListItem[]; nextCursor: string | null },
|
||||
): Pick<CenterState, "reports" | "nextCursor" | "extended"> {
|
||||
if (!current.extended || page.nextCursor === null) {
|
||||
return { reports: page.reports, nextCursor: page.nextCursor, extended: false };
|
||||
}
|
||||
return { reports: mergeFirstReportPage(page.reports, current.reports), nextCursor: current.nextCursor, extended: true };
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
@@ -69,30 +95,32 @@ export function PersonalReportCenter() {
|
||||
const subjectReady = useCurrentSubjectReady();
|
||||
const [seenSubject, setSeenSubject] = useState(subjectId);
|
||||
const [state, setState] = useState<CenterState>(() => snapshotToState(peekReportsPage()));
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const deletedIds = useRef(new Set<string>());
|
||||
const cancelled = useRef(false);
|
||||
if (seenSubject !== subjectId) {
|
||||
setSeenSubject(subjectId);
|
||||
setState({ phase: "loading", reports: [] });
|
||||
setState({ phase: "loading", ...EMPTY_LIST });
|
||||
}
|
||||
|
||||
const applySnapshot = useCallback((snapshot: ReportsPageSnapshot) => {
|
||||
const kept = (reports: readonly ReportListItem[]) => reports.filter(report => !deletedIds.current.has(report.id));
|
||||
if (snapshot.kind === "unauthorized") {
|
||||
setState({ phase: "unauthorized", reports: [] });
|
||||
setState({ phase: "unauthorized", ...EMPTY_LIST });
|
||||
return;
|
||||
}
|
||||
if (snapshot.kind === "error") {
|
||||
setState({ phase: "error", reports: snapshot.reports.filter(report => !deletedIds.current.has(report.id)) });
|
||||
setState(current => ({ ...current, phase: "error", reports: current.reports.length > 0 ? current.reports : kept(snapshot.reports) }));
|
||||
return;
|
||||
}
|
||||
setState({ phase: "ready", reports: snapshot.reports.filter(report => !deletedIds.current.has(report.id)) });
|
||||
setState(current => ({ phase: "ready", ...applyFirstReportPage(current, { reports: kept(snapshot.reports), nextCursor: snapshot.nextCursor }) }));
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (showLoading = false) => {
|
||||
if (!subjectReady) return;
|
||||
const requestedSubject = subjectId;
|
||||
if (showLoading) {
|
||||
setState((current) => ({ phase: "loading", reports: current.reports }));
|
||||
setState((current) => ({ ...current, phase: "loading" }));
|
||||
}
|
||||
try {
|
||||
const snapshot = await refreshReportsPage();
|
||||
@@ -108,11 +136,14 @@ export function PersonalReportCenter() {
|
||||
}
|
||||
}, [applySnapshot, subjectId, subjectReady]);
|
||||
|
||||
// Entering the page shows the cached first page at once and revalidates in
|
||||
// the background. The sidebar's hover / pointerdown prefetch is the same
|
||||
// in-flight request `load` joins; invalidating here threw it away and
|
||||
// started a second GET (BUG-1123).
|
||||
useEffect(() => {
|
||||
if (!subjectReady) return;
|
||||
cancelled.current = false;
|
||||
invalidateReportsPage();
|
||||
const timer = window.setTimeout(() => void load(true), 0);
|
||||
const timer = window.setTimeout(() => void load(peekReportsPage() === null), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
cancelled.current = true;
|
||||
@@ -127,6 +158,29 @@ export function PersonalReportCenter() {
|
||||
onPoll: refresh,
|
||||
});
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const cursor = state.nextCursor;
|
||||
if (!cursor || loadingMore) return;
|
||||
const requestedSubject = subjectId;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await fetchMoreReports(cursor);
|
||||
if (cancelled.current || requestedSubject !== readCurrentSubjectScope()) return;
|
||||
if (page.kind === "unauthorized") {
|
||||
setState({ phase: "unauthorized", ...EMPTY_LIST });
|
||||
} else if (page.kind === "ready") {
|
||||
const fresh = page.reports.filter(report => !deletedIds.current.has(report.id));
|
||||
setState(current => ({ ...current, reports: appendReportPage(current.reports, fresh), nextCursor: page.nextCursor, extended: true }));
|
||||
} else {
|
||||
toast.error("更早的报告没读出来,再试一次。");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled.current) toast.error("更早的报告没读出来,再试一次。");
|
||||
} finally {
|
||||
if (!cancelled.current) setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, state.nextCursor, subjectId]);
|
||||
|
||||
const removeReport = useCallback((id: string) => {
|
||||
deletedIds.current.add(id);
|
||||
invalidateReportsPage();
|
||||
@@ -221,6 +275,17 @@ export function PersonalReportCenter() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 10 at a time (2026-09-30, BUG-1124). No spinner: the label says it. */}
|
||||
{state.nextCursor && state.reports.length > 0 ? (
|
||||
<Button
|
||||
className="report-center-more"
|
||||
variant="outline"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void loadMore()}
|
||||
>
|
||||
{loadingMore ? "加载中" : "加载更多"}
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</SecondaryPageShell>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Keyset paging for 「过往的报告」 (2026-09-30, BUG-1124): newest first by
|
||||
* (created_at desc, id desc), `limit + 1` rows to know whether more exist.
|
||||
* Modelled on session-cursor.ts; reports page on created_at, not updated_at.
|
||||
*/
|
||||
const reportIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** One batch under 「加载更多」 (product: 10 at a time). Also the first page. */
|
||||
export const REPORT_PAGE_SIZE = 10;
|
||||
export const REPORT_PAGE_LIMIT_MAX = 50;
|
||||
|
||||
export type ReportCursor = {
|
||||
readonly createdAt: string;
|
||||
readonly id: string;
|
||||
};
|
||||
|
||||
export function clampReportLimit(raw: string | null | undefined): number {
|
||||
if (raw == null || raw === "") return REPORT_PAGE_SIZE;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) return REPORT_PAGE_SIZE;
|
||||
return Math.min(REPORT_PAGE_LIMIT_MAX, Math.max(1, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
export function encodeReportCursor(createdAt: string, id: string): string {
|
||||
return `${createdAt},${id}`;
|
||||
}
|
||||
|
||||
export function parseReportCursor(raw: string | null | undefined): ReportCursor | null {
|
||||
if (!raw) return null;
|
||||
const comma = raw.lastIndexOf(",");
|
||||
if (comma <= 0 || comma === raw.length - 1) return null;
|
||||
const createdAt = raw.slice(0, comma);
|
||||
const id = raw.slice(comma + 1);
|
||||
if (!Number.isFinite(Date.parse(createdAt))) return null;
|
||||
if (!reportIdPattern.test(id)) return null;
|
||||
return { createdAt, id };
|
||||
}
|
||||
|
||||
/** PostgREST `or=` filter: strictly older than the cursor row. */
|
||||
export function reportCursorFilter(cursor: ReportCursor): string {
|
||||
const createdAt = JSON.stringify(cursor.createdAt);
|
||||
const id = JSON.stringify(cursor.id);
|
||||
return `created_at.lt.${createdAt},and(created_at.eq.${createdAt},id.lt.${id})`;
|
||||
}
|
||||
|
||||
function rawCreatedAt(value: unknown): string {
|
||||
if (typeof value === "string") return Number.isFinite(Date.parse(value)) ? value : "";
|
||||
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
|
||||
return "";
|
||||
}
|
||||
|
||||
/** The cursor after the `limit`-th row when a `limit + 1`-th row came back, else null. */
|
||||
export function nextReportCursor(
|
||||
rows: readonly unknown[],
|
||||
limit: number,
|
||||
): string | null {
|
||||
if (rows.length <= limit) return null;
|
||||
const last = rows[limit - 1];
|
||||
if (!last || typeof last !== "object") return null;
|
||||
const row = last as { id?: unknown; created_at?: unknown };
|
||||
const createdAt = rawCreatedAt(row.created_at);
|
||||
if (!createdAt || typeof row.id !== "string") return null;
|
||||
return encodeReportCursor(createdAt, row.id);
|
||||
}
|
||||
|
||||
type Keyed = { readonly id: string; readonly createdAt: string };
|
||||
|
||||
/** Newest first, as the endpoint orders them. */
|
||||
function olderThan(left: Keyed, right: Keyed): boolean {
|
||||
const l = Date.parse(left.createdAt);
|
||||
const r = Date.parse(right.createdAt);
|
||||
if (l !== r) return l < r;
|
||||
return left.id < right.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh first page merged over the rows already on screen: the first page
|
||||
* wins for every row it carries; rows the user loaded with 「加载更多」 that are
|
||||
* older than the first page's last row stay. Used by the 3s poll and 刷新, so
|
||||
* a refresh never throws away pages the user opened.
|
||||
*/
|
||||
export function mergeFirstReportPage<T extends Keyed>(
|
||||
firstPage: readonly T[],
|
||||
loaded: readonly T[],
|
||||
): T[] {
|
||||
const tail = firstPage[firstPage.length - 1];
|
||||
if (!tail) return [];
|
||||
const ids = new Set(firstPage.map((row) => row.id));
|
||||
return [...firstPage, ...loaded.filter((row) => !ids.has(row.id) && olderThan(row, tail))];
|
||||
}
|
||||
|
||||
/** One more page appended after what is on screen; duplicates are dropped. */
|
||||
export function appendReportPage<T extends Keyed>(loaded: readonly T[], page: readonly T[]): T[] {
|
||||
const ids = new Set(loaded.map((row) => row.id));
|
||||
return [...loaded, ...page.filter((row) => !ids.has(row.id))];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { ChartViewResponse } from "@/lib/chart-view-contract";
|
||||
import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure";
|
||||
import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
|
||||
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
|
||||
import { REPORT_PAGE_SIZE } from "@/lib/report-cursor";
|
||||
|
||||
export const REPORTS_WAITING_COPY = "报告列表还没拿到。";
|
||||
export const EPHEMERIS_WAITING_COPY = "这一天的星历还没拿到。";
|
||||
@@ -30,8 +31,9 @@ export type ReportListItem = Readonly<{
|
||||
cardSummary?: string | null;
|
||||
}>;
|
||||
|
||||
/** The cache holds the first page only; `nextCursor` asks for the next 10 (BUG-1124). */
|
||||
export type ReportsPageSnapshot =
|
||||
| { kind: "ready"; reports: readonly ReportListItem[] }
|
||||
| { kind: "ready"; reports: readonly ReportListItem[]; nextCursor: string | null }
|
||||
| { kind: "unauthorized" }
|
||||
| { kind: "error"; reports: readonly ReportListItem[] };
|
||||
|
||||
@@ -313,10 +315,45 @@ export function readReports(value: unknown): ReportListItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function readReportsNextCursor(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || !("nextCursor" in value)) return null;
|
||||
const cursor = (value as { nextCursor?: unknown }).nextCursor;
|
||||
return typeof cursor === "string" && cursor ? cursor : null;
|
||||
}
|
||||
|
||||
function reportsUrl(subjectId: string, before: string | null): string {
|
||||
const params = new URLSearchParams({ subject: subjectId, limit: String(REPORT_PAGE_SIZE) });
|
||||
if (before) params.set("before", before);
|
||||
return `/api/reports?${params.toString()}`;
|
||||
}
|
||||
|
||||
export type ReportsMorePage =
|
||||
| { kind: "ready"; reports: readonly ReportListItem[]; nextCursor: string | null }
|
||||
| { kind: "unauthorized" }
|
||||
| { kind: "error" };
|
||||
|
||||
/**
|
||||
* 「加载更多」: the page after `before`. Never cached — only the first page is,
|
||||
* and the list merges fresh first pages over what the user already opened.
|
||||
*/
|
||||
export async function fetchMoreReports(before: string): Promise<ReportsMorePage> {
|
||||
const subjectId = readCurrentSubjectId();
|
||||
const response = await fetch(reportsUrl(subjectId, before), {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
const json: unknown = await response.json().catch(() => null);
|
||||
if (readCurrentSubjectId() !== subjectId) throw new Error("reports_subject_changed");
|
||||
if (response.status === 401) return { kind: "unauthorized" };
|
||||
if (!response.ok) return { kind: "error" };
|
||||
return { kind: "ready", reports: readReports(json), nextCursor: readReportsNextCursor(json) };
|
||||
}
|
||||
|
||||
let reportsSubject = "";
|
||||
const reportsCache = createMemoryCache<ReportsPageSnapshot>(async () => {
|
||||
const subjectId = readCurrentSubjectId();
|
||||
const response = await fetch(`/api/reports?subject=${encodeURIComponent(subjectId)}`, {
|
||||
const response = await fetch(reportsUrl(subjectId, null), {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
@@ -332,7 +369,7 @@ const reportsCache = createMemoryCache<ReportsPageSnapshot>(async () => {
|
||||
return { kind: "error", reports: [] };
|
||||
}
|
||||
reportsSubject = subjectId;
|
||||
return { kind: "ready", reports: readReports(json) };
|
||||
return { kind: "ready", reports: readReports(json), nextCursor: readReportsNextCursor(json) };
|
||||
});
|
||||
|
||||
export function peekChartPage(): ChartPageSnapshot | null {
|
||||
|
||||
@@ -117,7 +117,7 @@ test("same self subject in another account cannot reuse cache or publish a late
|
||||
resetSecondaryPageDataForTests();
|
||||
resetSubjectCatalogForTests();
|
||||
bindCurrentSubjectAccount("fictional-A", []);
|
||||
writeReportsPage({ kind: "ready", reports: [] });
|
||||
writeReportsPage({ kind: "ready", reports: [], nextCursor: null });
|
||||
assert.ok(peekReportsPage());
|
||||
let finish!: (response: Response) => void;
|
||||
const original = globalThis.fetch;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// 我的报告: keyset pages of 10 with 「加载更多」 (BUG-1124) and entering the page
|
||||
// joins the sidebar's prefetch instead of discarding it (BUG-1123).
|
||||
// Fictional list metadata only (AGENTS §8).
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { AppRouterContext, type AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||
import { SidebarProvider } from "../src/components/ui/sidebar";
|
||||
import { applyFirstReportPage, PersonalReportCenter } from "../src/components/personal-report/personal-report-center";
|
||||
import {
|
||||
appendReportPage,
|
||||
clampReportLimit,
|
||||
encodeReportCursor,
|
||||
mergeFirstReportPage,
|
||||
nextReportCursor,
|
||||
parseReportCursor,
|
||||
REPORT_PAGE_SIZE,
|
||||
reportCursorFilter,
|
||||
} from "../src/lib/report-cursor";
|
||||
import {
|
||||
fetchMoreReports,
|
||||
invalidateReportsPage,
|
||||
peekReportsPage,
|
||||
prefetchSecondaryPage,
|
||||
refreshReportsPage,
|
||||
writeReportsPage,
|
||||
type ReportListItem,
|
||||
} from "../src/lib/secondary-page-data";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||||
const buttons = (h: Harness, text: string) => h.elements().filter(node => node.tagName === "BUTTON" && node.text === text);
|
||||
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
|
||||
|
||||
const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
||||
/** Report n was created n days before 2026-09-30; higher n is older. */
|
||||
function report(n: number, extra: Partial<ReportListItem> = {}): ReportListItem {
|
||||
const created = new Date(Date.UTC(2026, 8, 30) - n * 86_400_000).toISOString();
|
||||
return { id: uuid(n), reportType: "personal_full", presentationMode: "default", depth: "standard", themes: ["career"], status: "ready", failureCode: null, createdAt: created, completedAt: created, ...extra };
|
||||
}
|
||||
const route = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/reports/page.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const router = { bfcacheId: "test", push() {}, replace() {}, refresh() {}, prefetch() {}, back() {}, forward() {} } as AppRouterInstance;
|
||||
|
||||
test("cursor helpers: 10 by default, clamped, (created_at, id) keyset, Date rows from self-hosted postgres", () => {
|
||||
assert.equal(REPORT_PAGE_SIZE, 10);
|
||||
assert.equal(clampReportLimit(null), 10);
|
||||
assert.equal(clampReportLimit("abc"), 10);
|
||||
assert.equal(clampReportLimit("0"), 1);
|
||||
assert.equal(clampReportLimit("999"), 50);
|
||||
const cursor = encodeReportCursor("2026-09-01T00:00:00.123456+00:00", uuid(3));
|
||||
assert.deepEqual(parseReportCursor(cursor), { createdAt: "2026-09-01T00:00:00.123456+00:00", id: uuid(3) });
|
||||
assert.equal(parseReportCursor("not-a-date," + uuid(3)), null);
|
||||
assert.equal(parseReportCursor("2026-09-01T00:00:00Z,drop table"), null);
|
||||
assert.equal(
|
||||
reportCursorFilter({ createdAt: "2026-09-01T00:00:00Z", id: uuid(3) }),
|
||||
`created_at.lt."2026-09-01T00:00:00Z",and(created_at.eq."2026-09-01T00:00:00Z",id.lt."${uuid(3)}")`,
|
||||
);
|
||||
const rows = Array.from({ length: 11 }, (_, i) => ({ id: uuid(i), created_at: new Date(Date.UTC(2026, 8, 30 - i)) }));
|
||||
assert.equal(nextReportCursor(rows.slice(0, 10), 10), null, "exactly one page: no more");
|
||||
assert.equal(nextReportCursor(rows, 10), encodeReportCursor(rows[9]!.created_at.toISOString(), uuid(9)));
|
||||
});
|
||||
|
||||
test("GET /api/reports pages by (created_at desc, id desc), fetches limit + 1 and returns nextCursor", () => {
|
||||
assert.match(route, /clampReportLimit\(url\.searchParams\.get\("limit"\)\)/);
|
||||
assert.match(route, /parseReportCursor\(beforeRaw\)/);
|
||||
assert.match(route, /if \(beforeRaw && !cursor\) \{\s*return NextResponse\.json\(\{ error: "报告列表请求无效" \}, \{ status: 400 \}\);/);
|
||||
assert.match(route, /\.order\("created_at", \{ ascending: false \}\)\s*\.order\("id", \{ ascending: false \}\)\s*\.limit\(limit \+ 1\)/);
|
||||
assert.match(route, /if \(cursor\) listQuery = listQuery\.or\(reportCursorFilter\(cursor\)\)/);
|
||||
assert.match(route, /nextReportCursor\(Array\.isArray\(pageRows\) \? pageRows : \[\], limit\)/);
|
||||
assert.match(route, /pageRows\.slice\(0, limit\)/);
|
||||
assert.match(route, /return NextResponse\.json\(\{ reports, nextCursor \}\);/);
|
||||
assert.doesNotMatch(route, /\.limit\(20\)/, "the fixed 20 made older reports unreachable (BUG-1124)");
|
||||
});
|
||||
|
||||
test("a fresh first page replaces the list until 加载更多 was used, then merges over the opened pages", () => {
|
||||
const first = [1, 2, 3].map(n => report(n));
|
||||
const opened = [1, 2, 3, 4, 5, 6].map(n => report(n));
|
||||
assert.deepEqual(applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: false }, { reports: first, nextCursor: "c3" }), { reports: first, nextCursor: "c3", extended: false });
|
||||
// A new report on top pushes 3 below the first page; 3 stays, nothing is lost, the cursor still points past 6.
|
||||
const polled = [0, 1, 2].map(n => report(n, n === 0 ? { status: "generating" } : {}));
|
||||
const merged = applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: true }, { reports: polled, nextCursor: "c2" });
|
||||
assert.deepEqual(merged.reports.map(r => r.id), [0, 1, 2, 3, 4, 5, 6].map(uuid));
|
||||
assert.equal(merged.nextCursor, "c6");
|
||||
assert.equal(merged.extended, true);
|
||||
// The first page's copy of a row wins (status changed while polling).
|
||||
assert.equal(mergeFirstReportPage([report(1, { status: "failed" })], [report(1), report(2)])[0]!.status, "failed");
|
||||
// Everything now fits on the first page: the list is exactly that page.
|
||||
assert.deepEqual(applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: true }, { reports: first, nextCursor: null }), { reports: first, nextCursor: null, extended: false });
|
||||
assert.deepEqual(appendReportPage([report(1), report(2)], [report(2), report(3)]).map(r => r.id), [1, 2, 3].map(uuid));
|
||||
});
|
||||
|
||||
test("the client asks for 10 at a time and 加载更多 sends the cursor", async () => {
|
||||
const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts");
|
||||
bindCurrentSubjectAccount("fictional-report-owner", []);
|
||||
const fetchBefore = globalThis.fetch; const urls: string[] = [];
|
||||
globalThis.fetch = (async (url: RequestInfo | URL) => { urls.push(String(url)); return Response.json({ reports: [report(1)], nextCursor: "2026-09-29T00:00:00.000Z," + uuid(1) }); }) as typeof fetch;
|
||||
try {
|
||||
invalidateReportsPage();
|
||||
const first = await refreshReportsPage();
|
||||
assert.equal(first.kind, "ready");
|
||||
assert.equal(first.kind === "ready" ? first.nextCursor : "", "2026-09-29T00:00:00.000Z," + uuid(1));
|
||||
assert.deepEqual(first.kind === "ready" ? first.reports.map(r => r.id) : [], [uuid(1)]);
|
||||
const more = await fetchMoreReports("2026-09-29T00:00:00.000Z," + uuid(1));
|
||||
assert.equal(more.kind, "ready");
|
||||
assert.match(urls[0]!, /^\/api\/reports\?subject=self&limit=10$/);
|
||||
assert.equal(new URL(urls[1]!, "http://x").searchParams.get("before"), "2026-09-29T00:00:00.000Z," + uuid(1));
|
||||
assert.equal(new URL(urls[1]!, "http://x").searchParams.get("limit"), "10");
|
||||
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); }
|
||||
});
|
||||
|
||||
test("entering 我的报告 joins the sidebar prefetch: one GET, not two (BUG-1123)", async () => {
|
||||
const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts");
|
||||
bindCurrentSubjectAccount("fictional-report-owner", []);
|
||||
const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch;
|
||||
const response = deferred<Response>(); let gets = 0;
|
||||
globalThis.fetch = (async (url: RequestInfo | URL) => {
|
||||
if (String(url).startsWith("/api/reports")) { gets += 1; return response.promise; }
|
||||
return Response.json({}, { status: 401 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
invalidateReportsPage();
|
||||
prefetchSecondaryPage("/reports");
|
||||
assert.equal(gets, 1);
|
||||
await h.render(<AppRouterContext.Provider value={router}><SidebarProvider open><PersonalReportCenter /></SidebarProvider></AppRouterContext.Provider>);
|
||||
await h.idle();
|
||||
response.resolve(Response.json({ reports: [report(1)], nextCursor: null }));
|
||||
await h.idle();
|
||||
assert.equal(gets, 1, "the in-flight prefetch is reused");
|
||||
assert.equal(peekReportsPage()?.kind, "ready");
|
||||
assert.match(h.container.text, /标准深度/);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); await h.close(); }
|
||||
});
|
||||
|
||||
test("加载更多 appends the next 10, says 加载中 while waiting and disappears at the end", async () => {
|
||||
const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts");
|
||||
bindCurrentSubjectAccount("fictional-report-owner", []);
|
||||
const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch;
|
||||
const firstPage = Array.from({ length: 10 }, (_, i) => report(i + 1));
|
||||
const secondPage = [report(11), report(12)];
|
||||
const more = deferred<Response>(); const befores: (string | null)[] = [];
|
||||
writeReportsPage({ kind: "ready", reports: firstPage, nextCursor: "c10" });
|
||||
globalThis.fetch = (async (url: RequestInfo | URL) => {
|
||||
const u = new URL(String(url), "http://x");
|
||||
if (u.pathname === "/api/reports") {
|
||||
befores.push(u.searchParams.get("before"));
|
||||
if (u.searchParams.get("before")) return more.promise;
|
||||
return Response.json({ reports: firstPage, nextCursor: "c10" });
|
||||
}
|
||||
return Response.json({}, { status: 401 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await h.render(<AppRouterContext.Provider value={router}><SidebarProvider open><PersonalReportCenter /></SidebarProvider></AppRouterContext.Provider>);
|
||||
await h.idle();
|
||||
assert.equal(h.elements().filter(n => n.tagName === "ARTICLE").length, 10);
|
||||
const [button] = buttons(h, "加载更多");
|
||||
assert.ok(button, "加载更多 is under the list while more exist");
|
||||
await h.event(button);
|
||||
assert.equal(buttons(h, "加载中").length, 1);
|
||||
more.resolve(Response.json({ reports: secondPage, nextCursor: null }));
|
||||
await h.idle();
|
||||
assert.deepEqual(befores, [null, "c10"]);
|
||||
assert.equal(h.elements().filter(n => n.tagName === "ARTICLE").length, 12);
|
||||
assert.equal(buttons(h, "加载更多").length, 0, "hidden when there is nothing older");
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); await h.close(); }
|
||||
});
|
||||
|
||||
test("/reports is a static shell and the button has a rule", () => {
|
||||
assert.doesNotMatch(page, /force-dynamic/);
|
||||
assert.match(css, /\.report-center-more \{[^}]*margin: var\(--space-4\) auto 0/);
|
||||
assert.match(css, /@media \(max-width: 767px\) \{\s*\.report-center-more \{ width: 100%; \}/);
|
||||
});
|
||||
@@ -146,7 +146,10 @@ test("cancellation guards survive the rewrite and no poll runs outside the gener
|
||||
assert.match(centerSource, /const cancelled = useRef\(false\)/);
|
||||
// 原值:if (cancelled.current) return;新值:取消或账户/人物范围已变均 return。
|
||||
// 原因:D3 加强了旧请求防线;成功和异常两条分支都必须保留取消检查,不接受只校验人物。
|
||||
assert.equal((centerSource.match(/if \(cancelled\.current \|\| requestedSubject !== readCurrentSubjectScope\(\)\) return;/g) ?? []).length, 2);
|
||||
// 原值: 该守卫出现 2 次(load 的成功与异常分支)
|
||||
// 新值: 3 次——「加载更多」的结果回来时同样要先查取消与人物范围
|
||||
// 原因: BUG-1124 报告列表分页新增 loadMore 请求;load 的两处保留不变
|
||||
assert.equal((centerSource.match(/if \(cancelled\.current \|\| requestedSubject !== readCurrentSubjectScope\(\)\) return;/g) ?? []).length, 3);
|
||||
assert.match(centerSource, /cancelled\.current = true;/);
|
||||
assert.match(pageSource, /const generating = state\.phase === "generating";/);
|
||||
});
|
||||
|
||||
@@ -70,7 +70,10 @@ test("report cache invalidation rejects stale GET writes and stale finally keeps
|
||||
assert.equal(peekReportsPage(), null);
|
||||
assert.equal(refreshReportsPage(), fresh); assert.equal(calls, 2);
|
||||
second.resolve(Response.json({ reports: [] })); await fresh;
|
||||
assert.deepEqual(peekReportsPage(), { kind: "ready", reports: [] });
|
||||
// 原值: { kind: "ready", reports: [] }
|
||||
// 新值: { kind: "ready", reports: [], nextCursor: null }
|
||||
// 原因: 报告列表按 10 份分页(BUG-1124),快照带上「加载更多」的游标;无更多时为 null
|
||||
assert.deepEqual(peekReportsPage(), { kind: "ready", reports: [], nextCursor: null });
|
||||
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); }
|
||||
});
|
||||
|
||||
@@ -79,7 +82,7 @@ test("mounted report center removes deleted row and an older GET cannot resurrec
|
||||
bindCurrentSubjectAccount("fictional-report-owner", []);
|
||||
const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch;
|
||||
const stale = deferred<Response>(); let gets = 0;
|
||||
writeReportsPage({ kind: "ready", reports: [row] });
|
||||
writeReportsPage({ kind: "ready", reports: [row], nextCursor: null });
|
||||
globalThis.fetch = async (url, options) => {
|
||||
if (options?.method === "DELETE") return new Response(null, { status: 204 });
|
||||
if (String(url).startsWith("/api/reports")) { gets += 1; return stale.promise; }
|
||||
|
||||
@@ -79,7 +79,11 @@ test("the web build stamps the Git SHA as Next deploymentId and does not cache t
|
||||
const reportsPage = readFileSync(new URL("../src/app/(app)/reports/page.tsx", import.meta.url), "utf8");
|
||||
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
|
||||
assert.match(loginPage, /export const dynamic = "force-dynamic"/);
|
||||
assert.match(reportsPage, /export const dynamic = "force-dynamic"/);
|
||||
// 原值: assert.match(reportsPage, /export const dynamic = "force-dynamic"/)
|
||||
// 新值: 断言不再 force-dynamic
|
||||
// 原因: BUG-1123——/reports 是不读 cookies 的客户端外壳,force-dynamic 让每次点「我的报告」
|
||||
// 先等一轮服务端渲染;与 BUG-716 的 /chart 同样改为静态外壳,数据由客户端取。
|
||||
assert.doesNotMatch(reportsPage, /export const dynamic = "force-dynamic"/);
|
||||
assert.match(adminLayout, /export const dynamic = "force-dynamic"/);
|
||||
assert.match(layoutSource, /StaleClientRecovery/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user