From 2f1c4f36c27b5e58d717179ac459032cb1237a2c Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Mon, 21 Sep 2026 22:28:35 +0800 Subject: [PATCH] Support swing radar metrics and sector detail dialogs --- .../pages/sector-radar-page.test.tsx | 186 ++++++++++++------ .../sector-radar/pages/sector-radar-page.tsx | 77 ++++---- 2 files changed, 169 insertions(+), 94 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 177c48c..aeed80e 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 @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react" +import { fireEvent, render, screen, within } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import type { @@ -13,6 +13,7 @@ const navigate = vi.hoisted(() => vi.fn()) const useSectorRadarDates = vi.fn() const useSectorRadarRankings = vi.fn() const useSectorRadarHistory = vi.fn() +const useSectorRadarDetail = vi.fn() const refetchDates = vi.fn() const refetchRankings = vi.fn() const fetchNextPage = vi.fn() @@ -25,6 +26,7 @@ vi.mock("@tanstack/react-router", () => ({ })) vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({ + useSectorRadarDetail: (...args: unknown[]) => useSectorRadarDetail(...args), useSectorRadarHistory: (...args: unknown[]) => useSectorRadarHistory(...args), useSectorRadarDates: () => useSectorRadarDates(), useSectorRadarRankings: (...args: unknown[]) => @@ -180,6 +182,7 @@ describe("SectorRadarPage", () => { isError: false, data: { status: "no_data" }, }) + useSectorRadarDetail.mockReturnValue({ isPending: true }) navigate.mockReset() refetchDates.mockReset() refetchRankings.mockReset() @@ -230,65 +233,130 @@ describe("SectorRadarPage", () => { ).not.toBeInTheDocument() }) - it("mirrors ratio columns and expands only the selected sector history, resetting on date change", () => { - routeSearch = { ...routeSearch, view: "ratio" } - const ratioPage = { - ...rankingsResponse, - view: "ratio", - rows: rankingsResponse.rows.map((row) => ({ - ...row, - unit: "ratio", - metric_value: -0.01, - })), - } - useSectorRadarRankings.mockReturnValue( - rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }), - ) - const { rerender } = render() - expect(screen.getAllByRole("columnheader", { name: "在榜" })).toHaveLength( - 2, - ) - expect(screen.getAllByText("-1%")).toHaveLength(4) - const trigger = screen.getAllByRole("button", { - name: "机器人展开在榜历史", - })[0]! - fireEvent.click(trigger) - expect(trigger).toHaveAttribute("aria-expanded", "true") - expect(useSectorRadarHistory).toHaveBeenLastCalledWith({ - sectorType: "concept", - sectorCode: "BK0001.DC", - tradeDate: "2026-08-28", - }) - expect(screen.getByText("暂无在榜历史数据。")).toBeInTheDocument() - fireEvent.click(trigger) - expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() - fireEvent.click(trigger) - useSectorRadarRankings.mockReturnValue( - rankingQueryResult({ - data: { - pages: [ - { - ...ratioPage, - publication: { - ...successPublication, - publication_id: "next-day", + it.each(["ratio", "swing"] as const)( + "mirrors %s columns and expands only the selected sector history, resetting on date change", + (view) => { + routeSearch = { ...routeSearch, view } + const ratioPage = { + ...rankingsResponse, + view, + rows: rankingsResponse.rows.map((row) => ({ + ...row, + unit: "ratio", + metric_value: -0.01, + })), + } + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }), + ) + const { rerender } = render() + expect( + screen.getAllByRole("columnheader", { name: "在榜" }), + ).toHaveLength(2) + if (view === "swing") { + expect( + within(screen.getAllByRole("row")[1]!) + .getAllByRole("columnheader") + .map((cell) => cell.textContent), + ).toEqual([ + "板块/排名", + "涨跌幅", + "净额", + "在榜", + "流入", + "流出", + "在榜", + "净额", + "涨跌幅", + "板块/排名", + ]) + expect(screen.getAllByText("-1.25%")).toHaveLength(2) + expect(screen.getAllByText("+12.5 亿元")).toHaveLength(2) + } + expect(screen.getAllByText("-1%")).toHaveLength(4) + const trigger = screen.getAllByRole("button", { + name: "机器人展开在榜历史", + })[0]! + fireEvent.click(trigger) + expect(trigger).toHaveAttribute("aria-expanded", "true") + expect(useSectorRadarHistory).toHaveBeenLastCalledWith({ + sectorType: "concept", + sectorCode: "BK0001.DC", + tradeDate: "2026-08-28", + }) + expect(screen.getByText("暂无在榜历史数据。")).toBeInTheDocument() + fireEvent.click(trigger) + expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() + fireEvent.click(trigger) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + data: { + pages: [ + { + ...ratioPage, + publication: { + ...successPublication, + publication_id: "next-day", + }, + rows: ratioPage.rows.map((row) => ({ + ...row, + trade_date: "2026-08-31", + })), }, - rows: ratioPage.rows.map((row) => ({ - ...row, - trade_date: "2026-08-31", - })), - }, - ], - pageParams: [1], - }, - }), - ) - rerender() - expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() - expect( - screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0], - ).toHaveAttribute("aria-expanded", "false") - }) + ], + pageParams: [1], + }, + }), + ) + rerender() + expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() + expect( + screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0], + ).toHaveAttribute("aria-expanded", "false") + }, + ) + + it.each(["swing", "ratio", "amount", "rank_change"] as const)( + "opens sector details from both sides of the %s list", + async (view) => { + routeSearch = { ...routeSearch, view } + useSectorRadarRankings.mockImplementation((query: { side: string }) => + rankingQueryResult({ + data: { + pageParams: [1], + pages: [ + { + ...(query.side === "top" + ? rankingsResponse + : bottomRankingsResponse), + view, + }, + ], + }, + }), + ) + render() + expect(screen.queryByText("BK0001.DC")).not.toBeInTheDocument() + for (const [name, code] of [ + ["机器人", "BK0001.DC"], + ["新能源", "BOTTOM-1"], + ]) { + const trigger = screen.getByRole("button", { name }) + expect(trigger.closest("td")).toHaveTextContent(`1${name}`) + fireEvent.click(trigger) + const dialog = await screen.findByRole("dialog") + expect( + within(dialog).getByRole("heading", { name }), + ).toBeInTheDocument() + expect(useSectorRadarDetail).toHaveBeenLastCalledWith({ + sectorType: "concept", + sectorCode: code, + tradeDate: "2026-08-28", + }) + fireEvent.click(within(dialog).getByRole("button", { name: "Close" })) + } + }, + ) it("resets filters to the first batch in router search state", () => { routeSearch = { ...routeSearch, page: 3 } 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 9b02f67..d2ae6e5 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 @@ -369,8 +369,9 @@ function RadarTable({ const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? [] const rowCount = Math.max(topRows.length, bottomRows.length) const rankChangeView = response.view === "rank_change" - const dailyView = response.view === "amount" || response.view === "ratio" - const columns = dailyView && response.view === "ratio" ? 10 : 8 + const metricView = !rankChangeView + const columns = + response.view === "ratio" || response.view === "swing" ? 10 : 8 const loadMoreRequestPending = useRef({ top: false, bottom: false }) function requestLoadMore(query: RankingQueryResult) { const side = query === top ? "top" : "bottom" @@ -437,30 +438,30 @@ function RadarTable({ BOTTOM 10% 资金撤离榜 - {dailyView ? ( + {metricView ? ( - {(response.view === "ratio" + {(response.view !== "amount" ? [ - "排名 / 板块", + "板块/排名", "涨跌幅", "净额", "在榜", - "流入率", - "流入率", + response.view === "swing" ? "流入" : "流入率", + response.view === "swing" ? "流出" : "流入率", "在榜", "净额", "涨跌幅", - "排名 / 板块", + "板块/排名", ] : [ - "排名 / 板块", + "板块/排名", "涨跌幅", "单日流入率", "净额", "净额", "单日流入率", "涨跌幅", - "排名 / 板块", + "板块/排名", ] ).map((label, index) => ( - 排名 / 板块 + 板块/排名 排名百分位 @@ -503,7 +504,7 @@ function RadarTable({ 排名百分位 - 排名 / 板块 + 板块/排名 @@ -596,18 +597,18 @@ function RadarMirrorRow({ rankChangeView: boolean }) { const [expanded, setExpanded] = useState<"top" | "bottom" | null>(null) - if (view === "ratio" || view === "amount") + if (view !== "rank_change") return ( <> - setExpanded(expanded === "top" ? null : "top")} /> - {expanded && (expanded === "top" ? top : bottom) ? ( - + ) } -/** Mirror daily fields without borrowing values from the opposite sector. */ -function DailyCells({ +/** Mirror metric fields without borrowing values from the opposite sector. */ +function MetricCells({ row, side, view, @@ -657,14 +661,14 @@ function DailyCells({ }: { row?: RadarRankingRow side: "top" | "bottom" - view: "ratio" | "amount" + view: RadarMetricKind expanded: boolean onToggle: () => void }) { if (!row) return ( — @@ -672,10 +676,7 @@ function DailyCells({ ) const fields = [ - - {row.rank_position ?? "—"} - - + , , {formatRadarValue( - view === "ratio" ? row.daily_net_amount_yuan : row.daily_ratio, - view === "ratio" ? "yuan" : "ratio", + view !== "amount" ? row.daily_net_amount_yuan : row.daily_ratio, + view !== "amount" ? "yuan" : "ratio", )} , - ...(view === "ratio" + ...(view !== "amount" ? [