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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user