Develop #21
@@ -1,12 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
const useQuery = vi.hoisted(() => vi.fn())
|
const useQuery = vi.hoisted(() => vi.fn())
|
||||||
|
const useInfiniteQuery = vi.hoisted(() => vi.fn())
|
||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
getSectorRadarDates: vi.fn(),
|
getSectorRadarDates: vi.fn(),
|
||||||
getSectorRadarRankings: vi.fn(),
|
getSectorRadarRankings: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock("@tanstack/react-query", () => ({ useQuery }))
|
vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery }))
|
||||||
vi.mock("./sector-radar.api", () => api)
|
vi.mock("./sector-radar.api", () => api)
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -32,9 +33,10 @@ describe("sector radar query hooks", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
useQuery.mockImplementation((options) => options)
|
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(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
|
||||||
expect(sectorRadarRankingsQueryKey(query)).toEqual([
|
expect(sectorRadarRankingsQueryKey(query)).toEqual([
|
||||||
"sectorRadar",
|
"sectorRadar",
|
||||||
@@ -46,9 +48,11 @@ describe("sector radar query hooks", () => {
|
|||||||
3,
|
3,
|
||||||
"bottom",
|
"bottom",
|
||||||
"银行",
|
"银行",
|
||||||
2,
|
|
||||||
50,
|
50,
|
||||||
])
|
])
|
||||||
|
expect(
|
||||||
|
sectorRadarRankingsQueryKey({ ...query, page: query.page + 1 }),
|
||||||
|
).toEqual(sectorRadarRankingsQueryKey(query))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("forwards React Query cancellation signals to both adapters", async () => {
|
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 {
|
const datesOptions = useQuery.mock.calls[0]?.[0] as {
|
||||||
queryFn: (context: { signal: AbortSignal }) => unknown
|
queryFn: (context: { signal: AbortSignal }) => unknown
|
||||||
}
|
}
|
||||||
const rankingsOptions = useQuery.mock.calls[1]?.[0] as {
|
const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as {
|
||||||
queryFn: (context: { signal: AbortSignal }) => unknown
|
queryFn: (context: { pageParam: number; signal: AbortSignal }) => unknown
|
||||||
}
|
}
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
|
|
||||||
await datesOptions.queryFn({ signal })
|
await datesOptions.queryFn({ signal })
|
||||||
await rankingsOptions.queryFn({ signal })
|
await rankingsOptions.queryFn({ pageParam: 3, signal })
|
||||||
|
|
||||||
expect(api.getSectorRadarDates).toHaveBeenCalledWith(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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { 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
|
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
|
||||||
|
|
||||||
@@ -16,7 +23,6 @@ export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) =>
|
|||||||
query.rankChangeDays,
|
query.rankChangeDays,
|
||||||
query.side,
|
query.side,
|
||||||
query.search ?? "",
|
query.search ?? "",
|
||||||
query.page,
|
|
||||||
query.pageSize,
|
query.pageSize,
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -28,8 +34,33 @@ export function useSectorRadarDates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSectorRadarRankings(query: RadarRankingsQuery) {
|
export function useSectorRadarRankings(query: RadarRankingsQuery) {
|
||||||
return useQuery({
|
return useInfiniteQuery<
|
||||||
queryFn: ({ signal }) => getSectorRadarRankings(query, signal),
|
RadarRankingsResponse,
|
||||||
|
Error,
|
||||||
|
InfiniteData<RadarRankingsResponse>,
|
||||||
|
ReturnType<typeof sectorRadarRankingsQueryKey>,
|
||||||
|
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),
|
queryKey: sectorRadarRankingsQueryKey(query),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const useSectorRadarDates = vi.fn()
|
|||||||
const useSectorRadarRankings = vi.fn()
|
const useSectorRadarRankings = vi.fn()
|
||||||
const refetchDates = vi.fn()
|
const refetchDates = vi.fn()
|
||||||
const refetchRankings = vi.fn()
|
const refetchRankings = vi.fn()
|
||||||
|
const fetchNextPage = vi.fn()
|
||||||
|
|
||||||
let routeSearch: SectorRadarRouteSearch
|
let routeSearch: SectorRadarRouteSearch
|
||||||
|
|
||||||
@@ -114,6 +115,21 @@ const rankingsResponse: RadarRankingsResponse = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rankingQueryResult(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
data: { pageParams: [1], pages: [rankingsResponse] },
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage: false,
|
||||||
|
isError: false,
|
||||||
|
isFetchNextPageError: false,
|
||||||
|
isFetching: false,
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
isPending: false,
|
||||||
|
refetch: refetchRankings,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("SectorRadarPage", () => {
|
describe("SectorRadarPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
routeSearch = {
|
routeSearch = {
|
||||||
@@ -134,36 +150,43 @@ describe("SectorRadarPage", () => {
|
|||||||
isPending: false,
|
isPending: false,
|
||||||
refetch: refetchDates,
|
refetch: refetchDates,
|
||||||
})
|
})
|
||||||
useSectorRadarRankings.mockReturnValue({
|
useSectorRadarRankings.mockReturnValue(rankingQueryResult())
|
||||||
data: rankingsResponse,
|
|
||||||
isError: false,
|
|
||||||
isFetching: false,
|
|
||||||
isPending: false,
|
|
||||||
refetch: refetchRankings,
|
|
||||||
})
|
|
||||||
navigate.mockReset()
|
navigate.mockReset()
|
||||||
refetchDates.mockReset()
|
refetchDates.mockReset()
|
||||||
refetchRankings.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(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0)
|
|
||||||
expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
|
expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
|
||||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||||
expect(screen.getByText("样本有限")).toBeInTheDocument()
|
expect(screen.getByText("样本有限")).toBeInTheDocument()
|
||||||
expect(screen.getByText("95%")).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(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
|
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
|
||||||
target: { value: "机器人" },
|
target: { value: "机器人" },
|
||||||
})
|
})
|
||||||
fireEvent.click(screen.getByRole("button", { name: "第 1 页" }))
|
|
||||||
|
|
||||||
const searchCall = navigate.mock.calls[0]?.[0].search as (
|
const searchCall = navigate.mock.calls[0]?.[0].search as (
|
||||||
previous: SectorRadarRouteSearch,
|
previous: SectorRadarRouteSearch,
|
||||||
@@ -174,10 +197,99 @@ 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(<SectorRadarPage />)
|
||||||
|
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(<SectorRadarPage />)
|
||||||
|
|
||||||
|
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(<SectorRadarPage />)
|
||||||
|
|
||||||
|
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(<SectorRadarPage />)
|
||||||
|
|
||||||
|
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", () => {
|
it("shows rank changes without inventing missing history", () => {
|
||||||
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
|
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
|
||||||
useSectorRadarRankings.mockReturnValue({
|
useSectorRadarRankings.mockReturnValue(
|
||||||
|
rankingQueryResult({
|
||||||
data: {
|
data: {
|
||||||
|
pageParams: [1],
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
...rankingsResponse,
|
...rankingsResponse,
|
||||||
view: "rank_change",
|
view: "rank_change",
|
||||||
rank_change_days: 5,
|
rank_change_days: 5,
|
||||||
@@ -187,11 +299,10 @@ describe("SectorRadarPage", () => {
|
|||||||
rank_change: index === 0 ? 3 : null,
|
rank_change: index === 0 ? 3 : null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
isError: false,
|
],
|
||||||
isFetching: false,
|
},
|
||||||
isPending: false,
|
}),
|
||||||
refetch: refetchRankings,
|
)
|
||||||
})
|
|
||||||
|
|
||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
@@ -230,13 +341,9 @@ describe("SectorRadarPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("keeps the last response visible while React Query refreshes it", () => {
|
it("keeps the last response visible while React Query refreshes it", () => {
|
||||||
useSectorRadarRankings.mockReturnValue({
|
useSectorRadarRankings.mockReturnValue(
|
||||||
data: rankingsResponse,
|
rankingQueryResult({ isFetching: true }),
|
||||||
isError: false,
|
)
|
||||||
isFetching: true,
|
|
||||||
isPending: false,
|
|
||||||
refetch: refetchRankings,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
@@ -247,13 +354,9 @@ describe("SectorRadarPage", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("keeps stale data visible when a background refresh fails", () => {
|
it("keeps stale data visible when a background refresh fails", () => {
|
||||||
useSectorRadarRankings.mockReturnValue({
|
useSectorRadarRankings.mockReturnValue(
|
||||||
data: rankingsResponse,
|
rankingQueryResult({ isError: true }),
|
||||||
isError: true,
|
)
|
||||||
isFetching: false,
|
|
||||||
isPending: false,
|
|
||||||
refetch: refetchRankings,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
@@ -272,8 +375,12 @@ describe("SectorRadarPage", () => {
|
|||||||
})
|
})
|
||||||
useSectorRadarRankings.mockReturnValue({
|
useSectorRadarRankings.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
|
isFetchNextPageError: false,
|
||||||
isFetching: true,
|
isFetching: true,
|
||||||
|
isFetchingNextPage: false,
|
||||||
isPending: true,
|
isPending: true,
|
||||||
refetch: refetchRankings,
|
refetch: refetchRankings,
|
||||||
})
|
})
|
||||||
@@ -289,8 +396,12 @@ describe("SectorRadarPage", () => {
|
|||||||
})
|
})
|
||||||
useSectorRadarRankings.mockReturnValue({
|
useSectorRadarRankings.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage: false,
|
||||||
isError: true,
|
isError: true,
|
||||||
|
isFetchNextPageError: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
|
isFetchingNextPage: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
refetch: refetchRankings,
|
refetch: refetchRankings,
|
||||||
})
|
})
|
||||||
@@ -311,19 +422,22 @@ describe("SectorRadarPage", () => {
|
|||||||
isPending: false,
|
isPending: false,
|
||||||
refetch: refetchDates,
|
refetch: refetchDates,
|
||||||
})
|
})
|
||||||
useSectorRadarRankings.mockReturnValue({
|
useSectorRadarRankings.mockReturnValue(
|
||||||
|
rankingQueryResult({
|
||||||
data: {
|
data: {
|
||||||
|
pageParams: [1],
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
...rankingsResponse,
|
...rankingsResponse,
|
||||||
status: "no_data",
|
status: "no_data",
|
||||||
publication: null,
|
publication: null,
|
||||||
rows: [],
|
rows: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
},
|
},
|
||||||
isError: false,
|
],
|
||||||
isFetching: false,
|
},
|
||||||
isPending: false,
|
}),
|
||||||
refetch: refetchRankings,
|
)
|
||||||
})
|
|
||||||
rerender(<SectorRadarPage />)
|
rerender(<SectorRadarPage />)
|
||||||
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
|
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
||||||
import { useEffect } from "react"
|
import { useRef } from "react"
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { PageLayout } from "@/app/layout/page-layout"
|
import { PageLayout } from "@/app/layout/page-layout"
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
RadarPublication,
|
RadarPublication,
|
||||||
RadarRankingRow,
|
RadarRankingRow,
|
||||||
RadarRankingsQuery,
|
RadarRankingsQuery,
|
||||||
|
RadarRankingsResponse,
|
||||||
SectorRadarRouteSearch,
|
SectorRadarRouteSearch,
|
||||||
} from "@/features/sector-radar/api/sector-radar.types"
|
} from "@/features/sector-radar/api/sector-radar.types"
|
||||||
import { Badge } from "@/shared/ui/badge"
|
import { Badge } from "@/shared/ui/badge"
|
||||||
@@ -25,7 +26,6 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/ui/card"
|
} from "@/shared/ui/card"
|
||||||
import { Input } from "@/shared/ui/input"
|
import { Input } from "@/shared/ui/input"
|
||||||
import { Pagination } from "@/shared/ui/pagination"
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -36,8 +36,6 @@ import {
|
|||||||
} from "@/shared/ui/select"
|
} from "@/shared/ui/select"
|
||||||
import { Skeleton } from "@/shared/ui/skeleton"
|
import { Skeleton } from "@/shared/ui/skeleton"
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 20, 50] as const
|
|
||||||
|
|
||||||
const sectorTypeOptions = [
|
const sectorTypeOptions = [
|
||||||
{ label: "概念板块", value: "concept" },
|
{ label: "概念板块", value: "concept" },
|
||||||
{ label: "行业板块", value: "industry" },
|
{ label: "行业板块", value: "industry" },
|
||||||
@@ -83,16 +81,9 @@ export function SectorRadarPage() {
|
|||||||
}
|
}
|
||||||
const dates = useSectorRadarDates()
|
const dates = useSectorRadarDates()
|
||||||
const rankings = useSectorRadarRankings(query)
|
const rankings = useSectorRadarRankings(query)
|
||||||
const total = rankings.data?.total ?? 0
|
const rankingPages = rankings.data?.pages ?? []
|
||||||
const pageCount = Math.max(1, Math.ceil(total / search.pageSize))
|
const firstRankingPage = rankingPages[0]
|
||||||
|
const rankingRows = rankingPages.flatMap((page) => page.rows)
|
||||||
useEffect(() => {
|
|
||||||
if (rankings.data?.status === "success" && search.page > pageCount) {
|
|
||||||
void navigate({
|
|
||||||
search: (previous) => ({ ...previous, page: pageCount }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [navigate, pageCount, rankings.data?.status, search.page])
|
|
||||||
|
|
||||||
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
|
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
|
||||||
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
||||||
@@ -100,13 +91,17 @@ export function SectorRadarPage() {
|
|||||||
|
|
||||||
const loading = dates.isPending || rankings.isPending
|
const loading = dates.isPending || rankings.isPending
|
||||||
const refreshing =
|
const refreshing =
|
||||||
!loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching))
|
!loading &&
|
||||||
|
(Boolean(dates.isFetching) ||
|
||||||
|
(Boolean(rankings.isFetching) && !rankings.isFetchingNextPage))
|
||||||
const fatalError =
|
const fatalError =
|
||||||
(dates.isError && !dates.data) || (rankings.isError && !rankings.data)
|
(dates.isError && !dates.data) || (rankings.isError && !rankings.data)
|
||||||
const refreshFailed =
|
const refreshFailed =
|
||||||
!fatalError && (Boolean(dates.isError) || Boolean(rankings.isError))
|
!fatalError &&
|
||||||
|
(Boolean(dates.isError) ||
|
||||||
|
(Boolean(rankings.isError) && !rankings.isFetchNextPageError))
|
||||||
const noData =
|
const noData =
|
||||||
dates.data?.status === "no_data" || rankings.data?.status === "no_data"
|
dates.data?.status === "no_data" || firstRankingPage?.status === "no_data"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
@@ -134,29 +129,26 @@ export function SectorRadarPage() {
|
|||||||
!fatalError &&
|
!fatalError &&
|
||||||
!noData &&
|
!noData &&
|
||||||
dates.data &&
|
dates.data &&
|
||||||
rankings.data?.status === "success" &&
|
firstRankingPage?.status === "success" &&
|
||||||
rankings.data.publication ? (
|
firstRankingPage.publication ? (
|
||||||
<>
|
<>
|
||||||
{refreshFailed ? (
|
{refreshFailed ? (
|
||||||
<RadarRefreshFailureStatus />
|
<RadarRefreshFailureStatus />
|
||||||
) : refreshing ? (
|
) : refreshing ? (
|
||||||
<RadarRefreshStatus />
|
<RadarRefreshStatus />
|
||||||
) : null}
|
) : null}
|
||||||
<RadarStatusSummary
|
<RadarPublicationStatus
|
||||||
currentAttempt={dates.data.current_attempt}
|
currentAttempt={dates.data.current_attempt}
|
||||||
lastGood={dates.data.last_good}
|
lastGood={dates.data.last_good}
|
||||||
publication={rankings.data.publication}
|
publication={firstRankingPage.publication}
|
||||||
disclaimer={rankings.data.definition.disclaimer}
|
|
||||||
metricVersion={rankings.data.definition.metric_version}
|
|
||||||
/>
|
/>
|
||||||
<RadarTable
|
<RadarTable
|
||||||
onPageChange={(page) => updateSearch({ page })}
|
hasNextPage={Boolean(rankings.hasNextPage)}
|
||||||
onPageSizeChange={(pageSize) =>
|
isFetchNextPageError={rankings.isFetchNextPageError}
|
||||||
updateSearch({ page: 1, pageSize })
|
isFetchingNextPage={rankings.isFetchingNextPage}
|
||||||
}
|
onLoadMore={() => rankings.fetchNextPage()}
|
||||||
page={Math.min(search.page, pageCount)}
|
response={firstRankingPage}
|
||||||
pageSize={search.pageSize}
|
rows={rankingRows}
|
||||||
response={rankings.data}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -307,17 +299,13 @@ function FilterSelect({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarStatusSummary({
|
function RadarPublicationStatus({
|
||||||
currentAttempt,
|
currentAttempt,
|
||||||
disclaimer,
|
|
||||||
lastGood,
|
lastGood,
|
||||||
metricVersion,
|
|
||||||
publication,
|
publication,
|
||||||
}: {
|
}: {
|
||||||
currentAttempt: RadarPublication | null
|
currentAttempt: RadarPublication | null
|
||||||
disclaimer: string
|
|
||||||
lastGood: RadarPublication | null
|
lastGood: RadarPublication | null
|
||||||
metricVersion: string
|
|
||||||
publication: RadarPublication
|
publication: RadarPublication
|
||||||
}) {
|
}) {
|
||||||
const degradedAttempt =
|
const degradedAttempt =
|
||||||
@@ -325,80 +313,71 @@ function RadarStatusSummary({
|
|||||||
currentAttempt.status !== "success" &&
|
currentAttempt.status !== "success" &&
|
||||||
currentAttempt.publication_id !== lastGood?.publication_id
|
currentAttempt.publication_id !== lastGood?.publication_id
|
||||||
|
|
||||||
|
if (!degradedAttempt) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="shrink-0 shadow-none">
|
|
||||||
<CardHeader className="gap-3 p-4 pb-3">
|
|
||||||
<div className="flex flex-wrap items-start gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<CardTitle className="text-base">收盘后板块资金排名</CardTitle>
|
|
||||||
<CardDescription className="mt-1">{disclaimer}</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline">知行独立实现</Badge>
|
|
||||||
<Badge variant="outline">{metricVersion}</Badge>
|
|
||||||
</div>
|
|
||||||
{degradedAttempt ? (
|
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
|
className="shrink-0 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
|
||||||
>
|
>
|
||||||
{attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
|
{attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
|
||||||
{currentAttempt.target_trade_date}
|
{currentAttempt.target_trade_date},当前仍展示最近有效发布{" "}
|
||||||
,当前仍展示最近有效发布{" "}
|
|
||||||
{lastGood?.target_trade_date ?? publication.target_trade_date}。
|
{lastGood?.target_trade_date ?? publication.target_trade_date}。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-3 p-4 pt-0 text-xs sm:grid-cols-2 lg:grid-cols-5">
|
|
||||||
<SummaryItem label="数据交易日" value={publication.target_trade_date} />
|
|
||||||
<SummaryItem
|
|
||||||
label="发布完成"
|
|
||||||
value={formatDateTime(publication.finished_at)}
|
|
||||||
/>
|
|
||||||
<SummaryItem
|
|
||||||
label="全局覆盖率"
|
|
||||||
value={formatCoverage(publication.coverage)}
|
|
||||||
/>
|
|
||||||
<SummaryItem label="事实来源" value={publication.source_version} />
|
|
||||||
<SummaryItem
|
|
||||||
label="板块范围版本"
|
|
||||||
value={publication.universe_version}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryItem({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">{label}</p>
|
|
||||||
<p className="mt-1 break-all font-medium text-foreground tabular-nums">
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarTable({
|
function RadarTable({
|
||||||
onPageChange,
|
hasNextPage,
|
||||||
onPageSizeChange,
|
isFetchNextPageError,
|
||||||
page,
|
isFetchingNextPage,
|
||||||
pageSize,
|
onLoadMore,
|
||||||
response,
|
response,
|
||||||
|
rows,
|
||||||
}: {
|
}: {
|
||||||
onPageChange: (page: number) => void
|
hasNextPage: boolean
|
||||||
onPageSizeChange: (pageSize: number) => void
|
isFetchNextPageError: boolean
|
||||||
page: number
|
isFetchingNextPage: boolean
|
||||||
pageSize: number
|
onLoadMore: () => Promise<unknown>
|
||||||
response: Exclude<
|
response: RadarRankingsResponse
|
||||||
ReturnType<typeof useSectorRadarRankings>["data"],
|
rows: RadarRankingRow[]
|
||||||
undefined
|
|
||||||
>
|
|
||||||
}) {
|
}) {
|
||||||
const rankChangeView = response.view === "rank_change"
|
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 (
|
return (
|
||||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
|
<div
|
||||||
|
aria-label="板块资金排名滚动区域"
|
||||||
|
className="min-h-0 min-w-0 flex-1 overflow-auto"
|
||||||
|
onScroll={(event) => {
|
||||||
|
const container = event.currentTarget
|
||||||
|
const remaining =
|
||||||
|
container.scrollHeight -
|
||||||
|
container.scrollTop -
|
||||||
|
container.clientHeight
|
||||||
|
if (
|
||||||
|
remaining <= 160 &&
|
||||||
|
hasNextPage &&
|
||||||
|
!isFetchingNextPage &&
|
||||||
|
!isFetchNextPageError
|
||||||
|
) {
|
||||||
|
requestLoadMore()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="region"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
<table className="w-full min-w-[920px] border-collapse text-sm">
|
<table className="w-full min-w-[920px] border-collapse text-sm">
|
||||||
<caption className="sr-only">
|
<caption className="sr-only">
|
||||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
|
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
|
||||||
@@ -436,14 +415,14 @@ function RadarTable({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border/60">
|
<tbody className="divide-y divide-border/60">
|
||||||
{response.rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<RadarTableRow
|
<RadarTableRow
|
||||||
key={`${row.sector_type}-${row.sector_code}`}
|
key={`${row.sector_type}-${row.sector_code}`}
|
||||||
rankChangeView={rankChangeView}
|
rankChangeView={rankChangeView}
|
||||||
row={row}
|
row={row}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{response.rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
className="px-3 py-12 text-center text-muted-foreground"
|
className="px-3 py-12 text-center text-muted-foreground"
|
||||||
@@ -455,15 +434,27 @@ function RadarTable({
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
正在加载更多板块…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isFetchNextPageError ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span>加载更多板块失败,已加载结果仍保留。</span>
|
||||||
|
<Button onClick={requestLoadMore} size="sm" variant="outline">
|
||||||
|
重试加载更多
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
onPageSizeChange={onPageSizeChange}
|
|
||||||
page={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
||||||
total={response.total}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -631,15 +622,6 @@ function formatNumber(value: number, maximumFractionDigits: number) {
|
|||||||
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
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"]) {
|
function attemptStatusLabel(status: RadarPublication["status"]) {
|
||||||
if (status === "running") return "新一期仍在构建"
|
if (status === "running") return "新一期仍在构建"
|
||||||
if (status === "partial") return "新一期数据不完整"
|
if (status === "partial") return "新一期数据不完整"
|
||||||
|
|||||||
Reference in New Issue
Block a user