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"
? [
| |