Develop #21
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<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", () => {
|
||||
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(<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("机器人")).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(<SectorRadarPage />)
|
||||
|
||||
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,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", () => {
|
||||
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
|
||||
useSectorRadarRankings.mockReturnValue({
|
||||
useSectorRadarRankings.mockReturnValue(
|
||||
rankingQueryResult({
|
||||
data: {
|
||||
pageParams: [1],
|
||||
pages: [
|
||||
{
|
||||
...rankingsResponse,
|
||||
view: "rank_change",
|
||||
rank_change_days: 5,
|
||||
@@ -187,11 +299,10 @@ describe("SectorRadarPage", () => {
|
||||
rank_change: index === 0 ? 3 : null,
|
||||
})),
|
||||
},
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isPending: false,
|
||||
refetch: refetchRankings,
|
||||
})
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
@@ -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(<SectorRadarPage />)
|
||||
|
||||
@@ -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(<SectorRadarPage />)
|
||||
|
||||
@@ -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({
|
||||
useSectorRadarRankings.mockReturnValue(
|
||||
rankingQueryResult({
|
||||
data: {
|
||||
pageParams: [1],
|
||||
pages: [
|
||||
{
|
||||
...rankingsResponse,
|
||||
status: "no_data",
|
||||
publication: null,
|
||||
rows: [],
|
||||
total: 0,
|
||||
},
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isPending: false,
|
||||
refetch: refetchRankings,
|
||||
})
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
rerender(<SectorRadarPage />)
|
||||
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -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<SectorRadarRouteSearch>) {
|
||||
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 (
|
||||
<PageLayout
|
||||
@@ -134,29 +129,26 @@ export function SectorRadarPage() {
|
||||
!fatalError &&
|
||||
!noData &&
|
||||
dates.data &&
|
||||
rankings.data?.status === "success" &&
|
||||
rankings.data.publication ? (
|
||||
firstRankingPage?.status === "success" &&
|
||||
firstRankingPage.publication ? (
|
||||
<>
|
||||
{refreshFailed ? (
|
||||
<RadarRefreshFailureStatus />
|
||||
) : refreshing ? (
|
||||
<RadarRefreshStatus />
|
||||
) : null}
|
||||
<RadarStatusSummary
|
||||
<RadarPublicationStatus
|
||||
currentAttempt={dates.data.current_attempt}
|
||||
lastGood={dates.data.last_good}
|
||||
publication={rankings.data.publication}
|
||||
disclaimer={rankings.data.definition.disclaimer}
|
||||
metricVersion={rankings.data.definition.metric_version}
|
||||
publication={firstRankingPage.publication}
|
||||
/>
|
||||
<RadarTable
|
||||
onPageChange={(page) => 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
|
||||
|
||||
if (!degradedAttempt) return null
|
||||
|
||||
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
|
||||
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)}:目标交易日{" "}
|
||||
{currentAttempt.target_trade_date}
|
||||
,当前仍展示最近有效发布{" "}
|
||||
{currentAttempt.target_trade_date},当前仍展示最近有效发布{" "}
|
||||
{lastGood?.target_trade_date ?? publication.target_trade_date}。
|
||||
</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({
|
||||
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<typeof useSectorRadarRankings>["data"],
|
||||
undefined
|
||||
>
|
||||
hasNextPage: boolean
|
||||
isFetchNextPageError: boolean
|
||||
isFetchingNextPage: boolean
|
||||
onLoadMore: () => Promise<unknown>
|
||||
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 (
|
||||
<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">
|
||||
<caption className="sr-only">
|
||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
|
||||
@@ -436,14 +415,14 @@ function RadarTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{response.rows.map((row) => (
|
||||
{rows.map((row) => (
|
||||
<RadarTableRow
|
||||
key={`${row.sector_type}-${row.sector_code}`}
|
||||
rankChangeView={rankChangeView}
|
||||
row={row}
|
||||
/>
|
||||
))}
|
||||
{response.rows.length === 0 ? (
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-3 py-12 text-center text-muted-foreground"
|
||||
@@ -455,15 +434,27 @@ function RadarTable({
|
||||
) : null}
|
||||
</tbody>
|
||||
</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>
|
||||
<Pagination
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
total={response.total}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 "新一期数据不完整"
|
||||
|
||||
Reference in New Issue
Block a user