From fe6d2b4fe5a70c0786134dccdc0d2a423f47435c Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Tue, 1 Sep 2026 12:14:02 +0800 Subject: [PATCH] feat(sector-radar): align dual ranking radar interactions --- .../pages/sector-radar-page.test.tsx | 135 ++++- .../sector-radar/pages/sector-radar-page.tsx | 516 ++++++++++++------ 2 files changed, 452 insertions(+), 199 deletions(-) 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 df7c126..41183ff 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 @@ -115,6 +115,18 @@ const rankingsResponse: RadarRankingsResponse = { ], } +const bottomRankingsResponse: RadarRankingsResponse = { + ...rankingsResponse, + side: "bottom", + rows: rankingsResponse.rows.map((row, index) => ({ + ...row, + sector_code: `BOTTOM-${index + 1}`, + sector_name: index === 0 ? "新能源" : "医药商业", + metric_value: index === 0 ? -8.4 : null, + rank_position: index + 1, + })), +} + function rankingQueryResult(overrides: Record = {}) { return { data: { pageParams: [1], pages: [rankingsResponse] }, @@ -161,10 +173,10 @@ describe("SectorRadarPage", () => { it("renders ranking rows without the description card or pagination", () => { render() - expect(screen.getByText("12.5 亿元")).toBeInTheDocument() - expect(screen.getByText("机器人")).toBeInTheDocument() - expect(screen.getByText("样本有限")).toBeInTheDocument() - expect(screen.getByText("95%")).toBeInTheDocument() + expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument() + expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() + expect(screen.getAllByText("样本有限")[0]).toBeInTheDocument() + expect(screen.getAllByText("95%")[0]).toBeInTheDocument() expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument() expect( screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"), @@ -197,6 +209,38 @@ describe("SectorRadarPage", () => { }) }) + it("renders accessible tabs, writes tab state to the URL, and queries both sides", () => { + render() + + expect( + screen.getByRole("tablist", { name: "板块类型" }), + ).toBeInTheDocument() + expect( + screen.getByRole("tablist", { name: "指标视角" }), + ).toBeInTheDocument() + expect(screen.getByRole("tab", { name: "单日净额" })).toHaveAttribute( + "aria-selected", + "true", + ) + expect(useSectorRadarRankings).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ side: "top", view: "amount" }), + ) + expect(useSectorRadarRankings).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ side: "bottom", view: "amount" }), + ) + + fireEvent.click(screen.getByRole("tab", { name: "行业" })) + const searchCall = navigate.mock.calls[0]?.[0].search as ( + previous: SectorRadarRouteSearch, + ) => SectorRadarRouteSearch + expect(searchCall({ ...routeSearch, page: 4 })).toMatchObject({ + page: 1, + sectorType: "industry", + }) + }) + it("loads the next page once when repeated scroll events reach the bottom", () => { fetchNextPage.mockReturnValue(new Promise(() => undefined)) useSectorRadarRankings.mockReturnValue( @@ -230,7 +274,7 @@ describe("SectorRadarPage", () => { render() expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块") - expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument() }) @@ -257,9 +301,9 @@ describe("SectorRadarPage", () => { render() - expect(screen.getByText("机器人")).toBeInTheDocument() - expect(screen.getByText("低空经济")).toBeInTheDocument() - expect(screen.getByText("商业航天")).toBeInTheDocument() + expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() + expect(screen.getAllByText("低空经济")[0]).toBeInTheDocument() + expect(screen.getAllByText("商业航天")[0]).toBeInTheDocument() }) it("keeps loaded rows and offers retry when loading more fails", () => { @@ -273,15 +317,44 @@ describe("SectorRadarPage", () => { render() - expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.getByRole("alert")).toHaveTextContent( - "加载更多板块失败,已加载结果仍保留", + "强榜加载更多失败,弱榜加载更多失败,已加载结果仍保留", ) expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument() - fireEvent.click(screen.getByRole("button", { name: "重试加载更多" })) + fireEvent.click(screen.getByRole("button", { name: "重试强榜" })) expect(fetchNextPage).toHaveBeenCalledOnce() }) + it("keeps the healthy side visible when only the other side fails to load more", () => { + const topFetchNextPage = vi.fn().mockResolvedValue(undefined) + const bottomFetchNextPage = vi.fn().mockResolvedValue(undefined) + useSectorRadarRankings.mockImplementation((query: { side: string }) => + rankingQueryResult({ + data: { + pageParams: [1], + pages: [ + query.side === "bottom" ? bottomRankingsResponse : rankingsResponse, + ], + }, + fetchNextPage: + query.side === "bottom" ? bottomFetchNextPage : topFetchNextPage, + isFetchNextPageError: query.side === "bottom", + hasNextPage: true, + }), + ) + + render() + + expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() + expect(screen.getByText("新能源")).toBeInTheDocument() + expect(screen.getByRole("alert")).toHaveTextContent("弱榜加载更多失败") + expect(screen.getByRole("button", { name: "重试弱榜" })).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "重试弱榜" })) + expect(bottomFetchNextPage).toHaveBeenCalledOnce() + expect(topFetchNextPage).not.toHaveBeenCalled() + }) + it("shows rank changes without inventing missing history", () => { routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 } useSectorRadarRankings.mockReturnValue( @@ -307,10 +380,10 @@ describe("SectorRadarPage", () => { render() expect( - screen.getByRole("columnheader", { name: "5 日排名变化" }), + screen.getByRole("columnheader", { name: "排名上升" }), ).toBeInTheDocument() - expect(screen.getByText("+3")).toBeInTheDocument() - expect(screen.getByText("暂无可比历史")).toBeInTheDocument() + expect(screen.getAllByText("+3 名")[0]).toBeInTheDocument() + expect(screen.getAllByText("暂无可比历史")[0]).toBeInTheDocument() }) it("warns when a partial attempt has not replaced last-good", () => { @@ -350,7 +423,7 @@ describe("SectorRadarPage", () => { expect(screen.getByRole("status")).toHaveTextContent( "当前继续展示上一次已读取的有效结果", ) - expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() }) it("keeps stale data visible when a background refresh fails", () => { @@ -361,10 +434,38 @@ describe("SectorRadarPage", () => { render() expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败") - expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument() }) + it("shows a fatal error when one side has no data and the other has no response", () => { + useSectorRadarRankings.mockImplementation((query: { side: string }) => + rankingQueryResult( + query.side === "top" + ? { + data: { + pageParams: [1], + pages: [ + { + ...rankingsResponse, + side: "top", + status: "no_data", + publication: null, + rows: [], + total: 0, + }, + ], + }, + } + : { data: undefined, isError: true }, + ), + ) + + render() + + expect(screen.getByText("板块资金雷达暂时不可用")).toBeInTheDocument() + }) + it("renders loading, error with retry, and no-data states", () => { useSectorRadarDates.mockReturnValue({ data: undefined, @@ -408,7 +509,7 @@ describe("SectorRadarPage", () => { rerender() fireEvent.click(screen.getByRole("button", { name: "重试" })) expect(refetchDates).toHaveBeenCalledOnce() - expect(refetchRankings).toHaveBeenCalledOnce() + expect(refetchRankings).toHaveBeenCalledTimes(2) useSectorRadarDates.mockReturnValue({ data: { 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 38804b8..32809e7 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 @@ -8,12 +8,14 @@ import { useSectorRadarRankings, } from "@/features/sector-radar/api/sector-radar.query" import type { + RadarMetricKind, RadarMetricQuality, RadarMetricUnit, RadarPublication, RadarRankingRow, RadarRankingsQuery, RadarRankingsResponse, + RadarView, SectorRadarRouteSearch, } from "@/features/sector-radar/api/sector-radar.types" import { Badge } from "@/shared/ui/badge" @@ -37,29 +39,20 @@ import { import { Skeleton } from "@/shared/ui/skeleton" const sectorTypeOptions = [ - { label: "概念板块", value: "concept" }, - { label: "行业板块", value: "industry" }, + { label: "概念", value: "concept" }, + { label: "行业", value: "industry" }, ] as const - const viewOptions = [ - { label: "主力净额", value: "amount" }, + { label: "波段资金率", value: "swing" }, { label: "单日资金率", value: "ratio" }, - { label: "3—10 日波段资金率", value: "swing" }, + { label: "单日净额", value: "amount" }, { label: "排名变化", value: "rank_change" }, ] as const - const metricOptions = [ { label: "主力净额", value: "amount" }, { label: "单日资金率", value: "ratio" }, - { label: "3—10 日波段资金率", value: "swing" }, + { label: "波段资金率", value: "swing" }, ] as const - -const sideOptions = [ - { label: "全部板块", value: "all" }, - { label: "强榜", value: "top" }, - { label: "弱榜", value: "bottom" }, -] as const - const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({ label: `${value} 日变化`, value: String(value), @@ -68,41 +61,56 @@ const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({ export function SectorRadarPage() { const search = useSearch({ from: "/_workspace/sector-radar" }) const navigate = useNavigate({ from: "/sector-radar" }) - const query: RadarRankingsQuery = { + const baseQuery: Omit = { tradeDate: search.tradeDate, sectorType: search.sectorType, view: search.view, rankChangeMetric: search.rankChangeMetric, rankChangeDays: search.rankChangeDays, - side: search.side, search: search.search || undefined, page: search.page, pageSize: search.pageSize, } const dates = useSectorRadarDates() - const rankings = useSectorRadarRankings(query) - const rankingPages = rankings.data?.pages ?? [] - const firstRankingPage = rankingPages[0] - const rankingRows = rankingPages.flatMap((page) => page.rows) - + const topRankings = useSectorRadarRankings({ ...baseQuery, side: "top" }) + const bottomRankings = useSectorRadarRankings({ + ...baseQuery, + side: "bottom", + }) function updateSearch(next: Partial) { void navigate({ search: (previous) => ({ ...previous, ...next }) }) } - - const loading = dates.isPending || rankings.isPending + const loading = + dates.isPending || topRankings.isPending || bottomRankings.isPending const refreshing = !loading && (Boolean(dates.isFetching) || - (Boolean(rankings.isFetching) && !rankings.isFetchingNextPage)) + (Boolean(topRankings.isFetching) && !topRankings.isFetchingNextPage) || + (Boolean(bottomRankings.isFetching) && + !bottomRankings.isFetchingNextPage)) + const topHasData = Boolean(topRankings.data) + const bottomHasData = Boolean(bottomRankings.data) + const topFirstPage = topRankings.data?.pages[0] + const bottomFirstPage = bottomRankings.data?.pages[0] + const topHasSuccess = topFirstPage?.status === "success" + const bottomHasSuccess = bottomFirstPage?.status === "success" const fatalError = - (dates.isError && !dates.data) || (rankings.isError && !rankings.data) + Boolean(dates.isError && !dates.data) || + (!topHasData && !bottomHasData) || + (!topHasSuccess && + !bottomHasSuccess && + (topRankings.isError || bottomRankings.isError)) const refreshFailed = !fatalError && (Boolean(dates.isError) || - (Boolean(rankings.isError) && !rankings.isFetchNextPageError)) + (Boolean(topRankings.isError) && !topRankings.isFetchNextPageError) || + (Boolean(bottomRankings.isError) && !bottomRankings.isFetchNextPageError)) const noData = - dates.data?.status === "no_data" || firstRankingPage?.status === "no_data" - + dates.data?.status === "no_data" || + (topFirstPage?.status === "no_data" && + bottomFirstPage?.status === "no_data") + const response = topFirstPage ?? bottomFirstPage + const publication = topFirstPage?.publication ?? bottomFirstPage?.publication return ( { void dates.refetch() - void rankings.refetch() + void topRankings.refetch() + void bottomRankings.refetch() }} /> ) : null} {!loading && !fatalError && noData ? : null} - {!loading && - !fatalError && - !noData && - dates.data && - firstRankingPage?.status === "success" && - firstRankingPage.publication ? ( + {!loading && !fatalError && !noData && response && publication ? ( <> {refreshFailed ? ( ) : refreshing ? ( ) : null} - + {dates.data ? ( + + ) : null} rankings.fetchNextPage()} - response={firstRankingPage} - rows={rankingRows} + bottom={bottomRankings} + response={response} + top={topRankings} /> ) : null} @@ -157,18 +160,19 @@ export function SectorRadarPage() { ) } -interface RadarFiltersProps { +function RadarFilters({ + availableDates, + onChange, + search, +}: { availableDates: string[] onChange: (next: Partial) => void search: SectorRadarRouteSearch -} - -function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) { +}) { const dateOptions = [ { label: "最近有效交易日", value: "latest" }, ...availableDates.map((value) => ({ label: value, value })), ] - return (
- + onChange={(value) => onChange({ page: 1, sectorType: value as SectorRadarRouteSearch["sectorType"], }) } /> - - onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] }) - } + onChange={(value) => onChange({ page: 1, view: value as RadarView })} /> {search.view === "rank_change" ? ( <> @@ -212,11 +214,7 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) { options={metricOptions} value={search.rankChangeMetric} onValueChange={(value) => - onChange({ - page: 1, - rankChangeMetric: - value as SectorRadarRouteSearch["rankChangeMetric"], - }) + onChange({ page: 1, rankChangeMetric: value as RadarMetricKind }) } /> ) : null} - - onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] }) - } - />