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:
Jesse_Chen
2026-09-30 13:45:44 +08:00
co-authored by Claude Opus 5.5
parent 90b1b6fba9
commit 9a616107c2
14 changed files with 470 additions and 33 deletions
@@ -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>