From be31235fc7244f549d5c4a54740880a5e2ac398d Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Tue, 1 Sep 2026 11:16:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(sector-radar):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8E=92=E5=90=8D=E5=88=97=E8=A1=A8=E6=BB=9A=E5=8A=A8=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/sector-radar.query.test.ts | 101 +++++++- .../sector-radar/api/sector-radar.query.ts | 41 +++- .../pages/sector-radar-page.test.tsx | 224 +++++++++++++----- .../sector-radar/pages/sector-radar-page.tsx | 212 ++++++++--------- 4 files changed, 396 insertions(+), 182 deletions(-) diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts index 3166f20..f872b26 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const useQuery = vi.hoisted(() => vi.fn()) +const useInfiniteQuery = vi.hoisted(() => vi.fn()) const api = vi.hoisted(() => ({ getSectorRadarDates: vi.fn(), getSectorRadarRankings: vi.fn(), })) -vi.mock("@tanstack/react-query", () => ({ useQuery })) +vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery })) vi.mock("./sector-radar.api", () => api) import { @@ -32,9 +33,10 @@ describe("sector radar query hooks", () => { beforeEach(() => { vi.clearAllMocks() useQuery.mockImplementation((options) => options) + useInfiniteQuery.mockImplementation((options) => options) }) - it("keeps every server-affecting filter in the ranking query key", () => { + it("keeps filters in one ranking key without splitting cached pages", () => { expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"]) expect(sectorRadarRankingsQueryKey(query)).toEqual([ "sectorRadar", @@ -46,9 +48,11 @@ describe("sector radar query hooks", () => { 3, "bottom", "银行", - 2, 50, ]) + expect( + sectorRadarRankingsQueryKey({ ...query, page: query.page + 1 }), + ).toEqual(sectorRadarRankingsQueryKey(query)) }) it("forwards React Query cancellation signals to both adapters", async () => { @@ -57,15 +61,98 @@ describe("sector radar query hooks", () => { const datesOptions = useQuery.mock.calls[0]?.[0] as { queryFn: (context: { signal: AbortSignal }) => unknown } - const rankingsOptions = useQuery.mock.calls[1]?.[0] as { - queryFn: (context: { signal: AbortSignal }) => unknown + const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as { + queryFn: (context: { pageParam: number; signal: AbortSignal }) => unknown } const signal = new AbortController().signal await datesOptions.queryFn({ signal }) - await rankingsOptions.queryFn({ signal }) + await rankingsOptions.queryFn({ pageParam: 3, signal }) expect(api.getSectorRadarDates).toHaveBeenCalledWith(signal) - expect(api.getSectorRadarRankings).toHaveBeenCalledWith(query, signal) + expect(api.getSectorRadarRankings).toHaveBeenCalledWith( + { ...query, page: 3 }, + signal, + ) + }) + + it("starts from page one and stops after loaded rows cover total", () => { + useSectorRadarRankings(query) + const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as { + getNextPageParam: ( + lastPage: { page: number; rows: unknown[]; total: number }, + pages: { page: number; rows: unknown[]; total: number }[], + lastPageParam: number, + ) => number | undefined + initialPageParam: number + } + const firstPage = { + page: 1, + rows: Array(50).fill(null), + status: "success", + total: 75, + } + const secondPage = { + page: 2, + rows: Array(25).fill(null), + status: "success", + total: 75, + } + + expect(rankingsOptions.initialPageParam).toBe(1) + expect(rankingsOptions.getNextPageParam(firstPage, [firstPage], 1)).toBe(2) + expect( + rankingsOptions.getNextPageParam(secondPage, [firstPage, secondPage], 2), + ).toBeUndefined() + }) + + it("stops when a page is empty or does not match its requested page param", () => { + useSectorRadarRankings(query) + const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as { + getNextPageParam: ( + lastPage: { + page: number + rows: unknown[] + status: "success" | "no_data" + total: number + }, + pages: { + page: number + rows: unknown[] + status: "success" | "no_data" + total: number + }[], + lastPageParam: number, + ) => number | undefined + } + const firstPage = { + page: 1, + rows: Array(50).fill(null), + status: "success" as const, + total: 75, + } + const emptyPage = { + page: 2, + rows: [], + status: "success" as const, + total: 75, + } + const mismatchedPage = { + page: 1, + rows: Array(25).fill(null), + status: "success" as const, + total: 75, + } + + expect( + rankingsOptions.getNextPageParam(emptyPage, [firstPage, emptyPage], 2), + ).toBeUndefined() + expect( + rankingsOptions.getNextPageParam( + mismatchedPage, + [firstPage, mismatchedPage], + 2, + ), + ).toBeUndefined() }) }) diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts index 37d4d05..50f8c6a 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts @@ -1,7 +1,14 @@ -import { useQuery } from "@tanstack/react-query" +import { + useInfiniteQuery, + useQuery, + type InfiniteData, +} from "@tanstack/react-query" import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api" -import type { RadarRankingsQuery } from "./sector-radar.types" +import type { + RadarRankingsQuery, + RadarRankingsResponse, +} from "./sector-radar.types" export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const @@ -16,7 +23,6 @@ export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) => query.rankChangeDays, query.side, query.search ?? "", - query.page, query.pageSize, ] as const @@ -28,8 +34,33 @@ export function useSectorRadarDates() { } export function useSectorRadarRankings(query: RadarRankingsQuery) { - return useQuery({ - queryFn: ({ signal }) => getSectorRadarRankings(query, signal), + return useInfiniteQuery< + RadarRankingsResponse, + Error, + InfiniteData, + ReturnType, + number + >({ + getNextPageParam: (lastPage, pages, lastPageParam) => { + const loadedRowCount = pages.reduce( + (count, page) => count + page.rows.length, + 0, + ) + if ( + lastPage.status !== "success" || + lastPage.rows.length === 0 || + lastPage.page !== lastPageParam || + loadedRowCount >= lastPage.total + ) { + return undefined + } + + const nextPageParam = lastPageParam + 1 + return Number.isSafeInteger(nextPageParam) ? nextPageParam : undefined + }, + initialPageParam: 1, + queryFn: ({ pageParam, signal }) => + getSectorRadarRankings({ ...query, page: pageParam }, signal), queryKey: sectorRadarRankingsQueryKey(query), }) } diff --git a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx index 79c7f23..df7c126 100644 --- a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx @@ -14,6 +14,7 @@ const useSectorRadarDates = vi.fn() const useSectorRadarRankings = vi.fn() const refetchDates = vi.fn() const refetchRankings = vi.fn() +const fetchNextPage = vi.fn() let routeSearch: SectorRadarRouteSearch @@ -114,6 +115,21 @@ const rankingsResponse: RadarRankingsResponse = { ], } +function rankingQueryResult(overrides: Record = {}) { + return { + data: { pageParams: [1], pages: [rankingsResponse] }, + fetchNextPage, + hasNextPage: false, + isError: false, + isFetchNextPageError: false, + isFetching: false, + isFetchingNextPage: false, + isPending: false, + refetch: refetchRankings, + ...overrides, + } +} + describe("SectorRadarPage", () => { beforeEach(() => { routeSearch = { @@ -134,36 +150,43 @@ describe("SectorRadarPage", () => { isPending: false, refetch: refetchDates, }) - useSectorRadarRankings.mockReturnValue({ - data: rankingsResponse, - isError: false, - isFetching: false, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue(rankingQueryResult()) navigate.mockReset() refetchDates.mockReset() refetchRankings.mockReset() + fetchNextPage.mockReset() + fetchNextPage.mockResolvedValue(undefined) }) - it("renders independent metric provenance, units, quality, and ranking rows", () => { + it("renders ranking rows without the description card or pagination", () => { render() - expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0) - expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument() expect(screen.getByText("12.5 亿元")).toBeInTheDocument() expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getByText("样本有限")).toBeInTheDocument() expect(screen.getByText("95%")).toBeInTheDocument() + expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument() + expect( + screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"), + ).not.toBeInTheDocument() + expect( + screen.queryByText("zhixing_amount_net_bn_v1"), + ).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "第 1 页" }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole("combobox", { name: "每页数量" }), + ).not.toBeInTheDocument() }) - it("stores search and pagination changes in router search state", () => { + it("resets filters to the first batch in router search state", () => { + routeSearch = { ...routeSearch, page: 3 } render() fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), { target: { value: "机器人" }, }) - fireEvent.click(screen.getByRole("button", { name: "第 1 页" })) const searchCall = navigate.mock.calls[0]?.[0].search as ( previous: SectorRadarRouteSearch, @@ -174,24 +197,112 @@ describe("SectorRadarPage", () => { }) }) + it("loads the next page once when repeated scroll events reach the bottom", () => { + fetchNextPage.mockReturnValue(new Promise(() => undefined)) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ hasNextPage: true }), + ) + render() + const scrollRegion = screen.getByRole("region", { + name: "板块资金排名滚动区域", + }) + Object.defineProperties(scrollRegion, { + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 1_000 }, + scrollTop: { configurable: true, value: 550 }, + }) + + fireEvent.scroll(scrollRegion) + fireEvent.scroll(scrollRegion) + + expect(fetchNextPage).toHaveBeenCalledOnce() + }) + + it("announces that the next page is loading without hiding loaded rows", () => { + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + hasNextPage: true, + isFetching: true, + isFetchingNextPage: true, + }), + ) + + render() + + expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块") + expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument() + }) + + it("merges rows from every loaded page", () => { + const firstPage = { ...rankingsResponse, total: 3 } + const nextPage = { + ...rankingsResponse, + page: 2, + total: 3, + rows: [ + { + ...rankingsResponse.rows[0], + sector_code: "BK0003.DC", + sector_name: "商业航天", + rank_position: 3, + }, + ], + } + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + data: { pageParams: [1, 2], pages: [firstPage, nextPage] }, + }), + ) + + render() + + expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.getByText("低空经济")).toBeInTheDocument() + expect(screen.getByText("商业航天")).toBeInTheDocument() + }) + + it("keeps loaded rows and offers retry when loading more fails", () => { + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + hasNextPage: true, + isError: true, + isFetchNextPageError: true, + }), + ) + + render() + + expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.getByRole("alert")).toHaveTextContent( + "加载更多板块失败,已加载结果仍保留", + ) + expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "重试加载更多" })) + expect(fetchNextPage).toHaveBeenCalledOnce() + }) + it("shows rank changes without inventing missing history", () => { routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 } - useSectorRadarRankings.mockReturnValue({ - data: { - ...rankingsResponse, - view: "rank_change", - rank_change_days: 5, - rows: rankingsResponse.rows.map((row, index) => ({ - ...row, - rank_change_days: 5, - rank_change: index === 0 ? 3 : null, - })), - }, - isError: false, - isFetching: false, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + data: { + pageParams: [1], + pages: [ + { + ...rankingsResponse, + view: "rank_change", + rank_change_days: 5, + rows: rankingsResponse.rows.map((row, index) => ({ + ...row, + rank_change_days: 5, + rank_change: index === 0 ? 3 : null, + })), + }, + ], + }, + }), + ) render() @@ -230,13 +341,9 @@ describe("SectorRadarPage", () => { }) it("keeps the last response visible while React Query refreshes it", () => { - useSectorRadarRankings.mockReturnValue({ - data: rankingsResponse, - isError: false, - isFetching: true, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ isFetching: true }), + ) render() @@ -247,13 +354,9 @@ describe("SectorRadarPage", () => { }) it("keeps stale data visible when a background refresh fails", () => { - useSectorRadarRankings.mockReturnValue({ - data: rankingsResponse, - isError: true, - isFetching: false, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ isError: true }), + ) render() @@ -272,8 +375,12 @@ describe("SectorRadarPage", () => { }) useSectorRadarRankings.mockReturnValue({ data: undefined, + fetchNextPage, + hasNextPage: false, isError: false, + isFetchNextPageError: false, isFetching: true, + isFetchingNextPage: false, isPending: true, refetch: refetchRankings, }) @@ -289,8 +396,12 @@ describe("SectorRadarPage", () => { }) useSectorRadarRankings.mockReturnValue({ data: undefined, + fetchNextPage, + hasNextPage: false, isError: true, + isFetchNextPageError: false, isFetching: false, + isFetchingNextPage: false, isPending: false, refetch: refetchRankings, }) @@ -311,19 +422,22 @@ describe("SectorRadarPage", () => { isPending: false, refetch: refetchDates, }) - useSectorRadarRankings.mockReturnValue({ - data: { - ...rankingsResponse, - status: "no_data", - publication: null, - rows: [], - total: 0, - }, - isError: false, - isFetching: false, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + data: { + pageParams: [1], + pages: [ + { + ...rankingsResponse, + status: "no_data", + publication: null, + rows: [], + total: 0, + }, + ], + }, + }), + ) rerender() expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument() }) diff --git a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx index 53710e1..38804b8 100644 --- a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx @@ -1,5 +1,5 @@ import { AlertTriangle, Database, RefreshCw } from "lucide-react" -import { useEffect } from "react" +import { useRef } from "react" import { useNavigate, useSearch } from "@tanstack/react-router" import { PageLayout } from "@/app/layout/page-layout" @@ -13,6 +13,7 @@ import type { RadarPublication, RadarRankingRow, RadarRankingsQuery, + RadarRankingsResponse, SectorRadarRouteSearch, } from "@/features/sector-radar/api/sector-radar.types" import { Badge } from "@/shared/ui/badge" @@ -25,7 +26,6 @@ import { CardTitle, } from "@/shared/ui/card" import { Input } from "@/shared/ui/input" -import { Pagination } from "@/shared/ui/pagination" import { Select, SelectContent, @@ -36,8 +36,6 @@ import { } from "@/shared/ui/select" import { Skeleton } from "@/shared/ui/skeleton" -const PAGE_SIZE_OPTIONS = [10, 20, 50] as const - const sectorTypeOptions = [ { label: "概念板块", value: "concept" }, { label: "行业板块", value: "industry" }, @@ -83,16 +81,9 @@ export function SectorRadarPage() { } const dates = useSectorRadarDates() const rankings = useSectorRadarRankings(query) - const total = rankings.data?.total ?? 0 - const pageCount = Math.max(1, Math.ceil(total / search.pageSize)) - - useEffect(() => { - if (rankings.data?.status === "success" && search.page > pageCount) { - void navigate({ - search: (previous) => ({ ...previous, page: pageCount }), - }) - } - }, [navigate, pageCount, rankings.data?.status, search.page]) + const rankingPages = rankings.data?.pages ?? [] + const firstRankingPage = rankingPages[0] + const rankingRows = rankingPages.flatMap((page) => page.rows) function updateSearch(next: Partial) { void navigate({ search: (previous) => ({ ...previous, ...next }) }) @@ -100,13 +91,17 @@ export function SectorRadarPage() { const loading = dates.isPending || rankings.isPending const refreshing = - !loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching)) + !loading && + (Boolean(dates.isFetching) || + (Boolean(rankings.isFetching) && !rankings.isFetchingNextPage)) const fatalError = (dates.isError && !dates.data) || (rankings.isError && !rankings.data) const refreshFailed = - !fatalError && (Boolean(dates.isError) || Boolean(rankings.isError)) + !fatalError && + (Boolean(dates.isError) || + (Boolean(rankings.isError) && !rankings.isFetchNextPageError)) const noData = - dates.data?.status === "no_data" || rankings.data?.status === "no_data" + dates.data?.status === "no_data" || firstRankingPage?.status === "no_data" return ( {refreshFailed ? ( ) : refreshing ? ( ) : null} - updateSearch({ page })} - onPageSizeChange={(pageSize) => - updateSearch({ page: 1, pageSize }) - } - page={Math.min(search.page, pageCount)} - pageSize={search.pageSize} - response={rankings.data} + hasNextPage={Boolean(rankings.hasNextPage)} + isFetchNextPageError={rankings.isFetchNextPageError} + isFetchingNextPage={rankings.isFetchingNextPage} + onLoadMore={() => rankings.fetchNextPage()} + response={firstRankingPage} + rows={rankingRows} /> ) : null} @@ -307,17 +299,13 @@ function FilterSelect({ ) } -function RadarStatusSummary({ +function RadarPublicationStatus({ currentAttempt, - disclaimer, lastGood, - metricVersion, publication, }: { currentAttempt: RadarPublication | null - disclaimer: string lastGood: RadarPublication | null - metricVersion: string publication: RadarPublication }) { const degradedAttempt = @@ -325,80 +313,71 @@ function RadarStatusSummary({ currentAttempt.status !== "success" && currentAttempt.publication_id !== lastGood?.publication_id - return ( - - -
-
- 收盘后板块资金排名 - {disclaimer} -
- 知行独立实现 - {metricVersion} -
- {degradedAttempt ? ( -
- {attemptStatusLabel(currentAttempt.status)}:目标交易日{" "} - {currentAttempt.target_trade_date} - ,当前仍展示最近有效发布{" "} - {lastGood?.target_trade_date ?? publication.target_trade_date}。 -
- ) : null} -
- - - - - - - -
- ) -} + if (!degradedAttempt) return null -function SummaryItem({ label, value }: { label: string; value: string }) { return ( -
-

{label}

-

- {value} -

+
+ {attemptStatusLabel(currentAttempt.status)}:目标交易日{" "} + {currentAttempt.target_trade_date},当前仍展示最近有效发布{" "} + {lastGood?.target_trade_date ?? publication.target_trade_date}。
) } function RadarTable({ - onPageChange, - onPageSizeChange, - page, - pageSize, + hasNextPage, + isFetchNextPageError, + isFetchingNextPage, + onLoadMore, response, + rows, }: { - onPageChange: (page: number) => void - onPageSizeChange: (pageSize: number) => void - page: number - pageSize: number - response: Exclude< - ReturnType["data"], - undefined - > + hasNextPage: boolean + isFetchNextPageError: boolean + isFetchingNextPage: boolean + onLoadMore: () => Promise + response: RadarRankingsResponse + rows: RadarRankingRow[] }) { const rankChangeView = response.view === "rank_change" + const loadMoreRequestPending = useRef(false) + + function requestLoadMore() { + if (loadMoreRequestPending.current || isFetchingNextPage) return + + loadMoreRequestPending.current = true + const releaseRequest = () => { + loadMoreRequestPending.current = false + } + void onLoadMore().then(releaseRequest, releaseRequest) + } + return (
-
+
{ + const container = event.currentTarget + const remaining = + container.scrollHeight - + container.scrollTop - + container.clientHeight + if ( + remaining <= 160 && + hasNextPage && + !isFetchingNextPage && + !isFetchNextPageError + ) { + requestLoadMore() + } + }} + role="region" + tabIndex={0} + > - {response.rows.map((row) => ( + {rows.map((row) => ( ))} - {response.rows.length === 0 ? ( + {rows.length === 0 ? (
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名 @@ -436,14 +415,14 @@ function RadarTable({
+ {isFetchingNextPage ? ( +
+ 正在加载更多板块… +
+ ) : null} + {isFetchNextPageError ? ( +
+ 加载更多板块失败,已加载结果仍保留。 + +
+ ) : null}
-
) } @@ -631,15 +622,6 @@ function formatNumber(value: number, maximumFractionDigits: number) { return value.toLocaleString("zh-CN", { maximumFractionDigits }) } -function formatDateTime(value: string | null) { - if (!value) return "—" - return new Intl.DateTimeFormat("zh-CN", { - dateStyle: "medium", - timeStyle: "short", - timeZone: "Asia/Shanghai", - }).format(new Date(value)) -} - function attemptStatusLabel(status: RadarPublication["status"]) { if (status === "running") return "新一期仍在构建" if (status === "partial") return "新一期数据不完整"