feat(sector-radar): align dual ranking radar interactions
This commit is contained in:
@@ -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<string, unknown> = {}) {
|
||||
return {
|
||||
data: { pageParams: [1], pages: [rankingsResponse] },
|
||||
@@ -161,10 +173,10 @@ describe("SectorRadarPage", () => {
|
||||
it("renders ranking rows without the description card or pagination", () => {
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试" }))
|
||||
expect(refetchDates).toHaveBeenCalledOnce()
|
||||
expect(refetchRankings).toHaveBeenCalledOnce()
|
||||
expect(refetchRankings).toHaveBeenCalledTimes(2)
|
||||
|
||||
useSectorRadarDates.mockReturnValue({
|
||||
data: {
|
||||
|
||||
@@ -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<RadarRankingsQuery, "side"> = {
|
||||
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<SectorRadarRouteSearch>) {
|
||||
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 (
|
||||
<PageLayout
|
||||
actions={
|
||||
@@ -120,35 +128,30 @@ export function SectorRadarPage() {
|
||||
<RadarError
|
||||
onRetry={() => {
|
||||
void dates.refetch()
|
||||
void rankings.refetch()
|
||||
void topRankings.refetch()
|
||||
void bottomRankings.refetch()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!loading && !fatalError && noData ? <RadarNoData /> : null}
|
||||
{!loading &&
|
||||
!fatalError &&
|
||||
!noData &&
|
||||
dates.data &&
|
||||
firstRankingPage?.status === "success" &&
|
||||
firstRankingPage.publication ? (
|
||||
{!loading && !fatalError && !noData && response && publication ? (
|
||||
<>
|
||||
{refreshFailed ? (
|
||||
<RadarRefreshFailureStatus />
|
||||
) : refreshing ? (
|
||||
<RadarRefreshStatus />
|
||||
) : null}
|
||||
{dates.data ? (
|
||||
<RadarPublicationStatus
|
||||
currentAttempt={dates.data.current_attempt}
|
||||
lastGood={dates.data.last_good}
|
||||
publication={firstRankingPage.publication}
|
||||
publication={publication}
|
||||
/>
|
||||
) : null}
|
||||
<RadarTable
|
||||
hasNextPage={Boolean(rankings.hasNextPage)}
|
||||
isFetchNextPageError={rankings.isFetchNextPageError}
|
||||
isFetchingNextPage={rankings.isFetchingNextPage}
|
||||
onLoadMore={() => 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<SectorRadarRouteSearch>) => void
|
||||
search: SectorRadarRouteSearch
|
||||
}
|
||||
|
||||
function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
}) {
|
||||
const dateOptions = [
|
||||
{ label: "最近有效交易日", value: "latest" },
|
||||
...availableDates.map((value) => ({ label: value, value })),
|
||||
]
|
||||
|
||||
return (
|
||||
<div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12">
|
||||
<FilterSelect
|
||||
@@ -183,26 +187,24 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
<TabList
|
||||
ariaLabel="板块类型"
|
||||
className="lg:col-span-2"
|
||||
label="板块类型"
|
||||
options={sectorTypeOptions}
|
||||
value={search.sectorType}
|
||||
onValueChange={(value) =>
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
page: 1,
|
||||
sectorType: value as SectorRadarRouteSearch["sectorType"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="指标视角"
|
||||
<TabList
|
||||
ariaLabel="指标视角"
|
||||
className="lg:col-span-4"
|
||||
options={viewOptions}
|
||||
value={search.view}
|
||||
onValueChange={(value) =>
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
@@ -230,15 +228,6 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="榜单范围"
|
||||
options={sideOptions}
|
||||
value={search.side}
|
||||
onValueChange={(value) =>
|
||||
onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] })
|
||||
}
|
||||
/>
|
||||
<label className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2">
|
||||
搜索板块
|
||||
<Input
|
||||
@@ -257,6 +246,46 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabList({
|
||||
ariaLabel,
|
||||
className,
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
className?: string
|
||||
onChange: (value: string) => void
|
||||
options: readonly { label: string; value: string }[]
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
|
||||
>
|
||||
<span>{ariaLabel}</span>
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
className="flex rounded-md border border-input bg-background p-0.5"
|
||||
role="tablist"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
aria-selected={value === option.value}
|
||||
className={`min-h-9 flex-1 rounded-sm px-2 text-xs transition-colors ${value === option.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
className,
|
||||
label,
|
||||
@@ -312,9 +341,7 @@ function RadarPublicationStatus({
|
||||
currentAttempt &&
|
||||
currentAttempt.status !== "success" &&
|
||||
currentAttempt.publication_id !== lastGood?.publication_id
|
||||
|
||||
if (!degradedAttempt) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
@@ -327,106 +354,145 @@ function RadarPublicationStatus({
|
||||
)
|
||||
}
|
||||
|
||||
type RankingQueryResult = ReturnType<typeof useSectorRadarRankings>
|
||||
function RadarTable({
|
||||
hasNextPage,
|
||||
isFetchNextPageError,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
top,
|
||||
bottom,
|
||||
response,
|
||||
rows,
|
||||
}: {
|
||||
hasNextPage: boolean
|
||||
isFetchNextPageError: boolean
|
||||
isFetchingNextPage: boolean
|
||||
onLoadMore: () => Promise<unknown>
|
||||
top: RankingQueryResult
|
||||
bottom: RankingQueryResult
|
||||
response: RadarRankingsResponse
|
||||
rows: RadarRankingRow[]
|
||||
}) {
|
||||
const topRows = top.data?.pages.flatMap((page) => page.rows) ?? []
|
||||
const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? []
|
||||
const rowCount = Math.max(topRows.length, bottomRows.length)
|
||||
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
|
||||
const loadMoreRequestPending = useRef({ top: false, bottom: false })
|
||||
function requestLoadMore(query: RankingQueryResult) {
|
||||
const side = query === top ? "top" : "bottom"
|
||||
if (
|
||||
loadMoreRequestPending.current[side] ||
|
||||
query.isFetchingNextPage ||
|
||||
!query.hasNextPage ||
|
||||
query.isFetchNextPageError
|
||||
)
|
||||
return
|
||||
loadMoreRequestPending.current[side] = true
|
||||
void query.fetchNextPage().finally(() => {
|
||||
loadMoreRequestPending.current[side] = 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
|
||||
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()
|
||||
const c = event.currentTarget
|
||||
if (c.scrollHeight - c.scrollTop - c.clientHeight <= 160) {
|
||||
requestLoadMore(top)
|
||||
requestLoadMore(bottom)
|
||||
}
|
||||
}}
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
>
|
||||
<table className="w-full min-w-[920px] border-collapse text-sm">
|
||||
<table className="w-full min-w-[1280px] border-collapse text-sm">
|
||||
<caption className="sr-only">
|
||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
|
||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
||||
</caption>
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 text-left text-xs text-muted-foreground backdrop-blur">
|
||||
<colgroup>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<col
|
||||
key={i}
|
||||
className={i === 5 || i === 6 ? "w-[120px]" : "w-[100px]"}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 text-xs text-muted-foreground backdrop-blur">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
名次
|
||||
<th
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
colSpan={5}
|
||||
scope="colgroup"
|
||||
>
|
||||
资金进攻榜 TOP 10%
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
板块
|
||||
<th
|
||||
className="border-x border-border px-3 py-2 text-center font-medium"
|
||||
colSpan={2}
|
||||
scope="colgroup"
|
||||
>
|
||||
{rankChangeView ? "排名变化" : response.definition.label}
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
{rankChangeView
|
||||
? `${response.rank_change_days} 日排名变化`
|
||||
: response.definition.label}
|
||||
<th
|
||||
className="px-3 py-2 text-right font-medium"
|
||||
colSpan={5}
|
||||
scope="colgroup"
|
||||
>
|
||||
BOTTOM 10% 资金撤离榜
|
||||
</th>
|
||||
{rankChangeView ? (
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
参考指标值
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
) : null}
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
资金覆盖率
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
<th className="px-3 py-2 text-left font-medium" scope="col">
|
||||
质量
|
||||
</th>
|
||||
<th
|
||||
className="border-l border-border px-3 py-2 text-right font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名上升" : "流入"}
|
||||
</th>
|
||||
<th
|
||||
className="border-r border-border px-3 py-2 text-left font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名下降" : "流出"}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
质量
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
资金覆盖率
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{rows.map((row) => (
|
||||
<RadarTableRow
|
||||
key={`${row.sector_type}-${row.sector_code}`}
|
||||
{Array.from({ length: rowCount }, (_, i) => (
|
||||
<RadarMirrorRow
|
||||
key={i}
|
||||
rankChangeView={rankChangeView}
|
||||
row={row}
|
||||
top={topRows[i]}
|
||||
bottom={bottomRows[i]}
|
||||
/>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
{rowCount === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-3 py-12 text-center text-muted-foreground"
|
||||
colSpan={rankChangeView ? 8 : 7}
|
||||
colSpan={12}
|
||||
>
|
||||
没有符合当前筛选条件的板块。
|
||||
</td>
|
||||
@@ -434,7 +500,7 @@ function RadarTable({
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
{isFetchingNextPage ? (
|
||||
{top.isFetchingNextPage || bottom.isFetchingNextPage ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
|
||||
@@ -443,15 +509,43 @@ function RadarTable({
|
||||
正在加载更多板块…
|
||||
</div>
|
||||
) : null}
|
||||
{isFetchNextPageError ? (
|
||||
{top.isFetchNextPageError || bottom.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"
|
||||
className="flex flex-wrap 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">
|
||||
重试加载更多
|
||||
<span>
|
||||
{top.isFetchNextPageError ? "强榜加载更多失败" : null}
|
||||
{top.isFetchNextPageError && bottom.isFetchNextPageError
|
||||
? ","
|
||||
: null}
|
||||
{bottom.isFetchNextPageError ? "弱榜加载更多失败" : null}
|
||||
,已加载结果仍保留。
|
||||
</span>
|
||||
{top.isFetchNextPageError ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
loadMoreRequestPending.current.top = false
|
||||
void top.fetchNextPage()
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
重试强榜
|
||||
</Button>
|
||||
) : null}
|
||||
{bottom.isFetchNextPageError ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
loadMoreRequestPending.current.bottom = false
|
||||
void bottom.fetchNextPage()
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
重试弱榜
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -459,34 +553,57 @@ function RadarTable({
|
||||
)
|
||||
}
|
||||
|
||||
function RadarTableRow({
|
||||
function RadarMirrorRow({
|
||||
top,
|
||||
bottom,
|
||||
rankChangeView,
|
||||
row,
|
||||
}: {
|
||||
top?: RadarRankingRow
|
||||
bottom?: RadarRankingRow
|
||||
rankChangeView: boolean
|
||||
row: RadarRankingRow
|
||||
}) {
|
||||
return (
|
||||
<tr className="hover:bg-muted/40">
|
||||
<td className="px-3 py-2.5 font-semibold tabular-nums">
|
||||
<RankCells row={top} align="left" />
|
||||
<MetricCell direction="in" rankChangeView={rankChangeView} row={top} />
|
||||
<MetricCell
|
||||
direction="out"
|
||||
rankChangeView={rankChangeView}
|
||||
row={bottom}
|
||||
/>
|
||||
<RankCells row={bottom} align="right" />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
function RankCells({
|
||||
row,
|
||||
align,
|
||||
}: {
|
||||
row?: RadarRankingRow
|
||||
align: "left" | "right"
|
||||
}) {
|
||||
if (!row)
|
||||
return (
|
||||
<>
|
||||
<td className="px-3 py-2.5 text-muted-foreground">—</td>
|
||||
<td />
|
||||
<td />
|
||||
<td />
|
||||
<td />
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} font-semibold tabular-nums`}
|
||||
>
|
||||
{row.rank_position ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-medium text-foreground">{row.sector_name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="mx-1 font-normal text-muted-foreground">/</span>
|
||||
<span className="font-medium text-foreground">{row.sector_name}</span>
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
{row.sector_code}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-medium tabular-nums">
|
||||
{rankChangeView
|
||||
? formatRankChange(row.rank_change)
|
||||
: formatMetricValue(row.metric_value, row.unit)}
|
||||
</td>
|
||||
{rankChangeView ? (
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-muted-foreground">
|
||||
{formatMetricValue(row.metric_value, row.unit)}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
{row.rank_percentile === null
|
||||
? "—"
|
||||
@@ -501,13 +618,64 @@ function RadarTableRow({
|
||||
<td className="px-3 py-2.5">
|
||||
<QualityBadge quality={row.quality} />
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)
|
||||
}
|
||||
function MetricCell({
|
||||
row,
|
||||
rankChangeView,
|
||||
direction,
|
||||
}: {
|
||||
row?: RadarRankingRow
|
||||
rankChangeView: boolean
|
||||
direction: "in" | "out"
|
||||
}) {
|
||||
const value = row
|
||||
? rankChangeView
|
||||
? formatRankChange(row.rank_change)
|
||||
: formatDirectionalMetric(row.metric_value, row.unit, direction)
|
||||
: "—"
|
||||
const positive =
|
||||
row?.rank_change !== null &&
|
||||
row?.rank_change !== undefined &&
|
||||
row.rank_change > 0
|
||||
const negative =
|
||||
row?.rank_change !== null &&
|
||||
row?.rank_change !== undefined &&
|
||||
row.rank_change < 0
|
||||
const borderClass = direction === "in" ? "border-l" : "border-r"
|
||||
const toneClass =
|
||||
positive || (direction === "in" && !rankChangeView)
|
||||
? "text-red-600 dark:text-red-400 bg-red-500/10"
|
||||
: negative || (direction === "out" && !rankChangeView)
|
||||
? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
||||
: "text-foreground bg-muted/30"
|
||||
return (
|
||||
<td
|
||||
className={`${borderClass} border-border px-3 py-2.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDirectionalMetric(
|
||||
value: number | null,
|
||||
unit: RadarMetricUnit,
|
||||
direction: "in" | "out",
|
||||
) {
|
||||
if (value === null) return "—"
|
||||
const absoluteValue = Math.abs(value)
|
||||
const formatted =
|
||||
unit === "CNY_100M"
|
||||
? `${formatNumber(absoluteValue, 2)} 亿元`
|
||||
: `${formatNumber(absoluteValue * 100, 2)}%`
|
||||
return `${direction === "in" ? "+" : "−"}${formatted}`
|
||||
}
|
||||
|
||||
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
||||
if (quality === "available") return <Badge variant="outline">可用</Badge>
|
||||
if (quality === "available_limited_sample") {
|
||||
if (quality === "available_limited_sample")
|
||||
return (
|
||||
<Badge
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
|
||||
@@ -516,10 +684,8 @@ function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
||||
样本有限
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return <Badge variant="destructive">不可用</Badge>
|
||||
}
|
||||
|
||||
function RadarLoading() {
|
||||
return (
|
||||
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
||||
@@ -537,7 +703,6 @@ function RadarLoading() {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarRefreshStatus() {
|
||||
return (
|
||||
<div
|
||||
@@ -548,7 +713,6 @@ function RadarRefreshStatus() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarRefreshFailureStatus() {
|
||||
return (
|
||||
<div
|
||||
@@ -559,7 +723,6 @@ function RadarRefreshFailureStatus() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
@@ -584,7 +747,6 @@ function RadarError({ onRetry }: { onRetry: () => void }) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarNoData() {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
@@ -600,28 +762,18 @@ function RadarNoData() {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatMetricValue(value: number | null, unit: RadarMetricUnit) {
|
||||
if (value === null) return "—"
|
||||
return unit === "CNY_100M"
|
||||
? `${formatNumber(value, 2)} 亿元`
|
||||
: `${formatNumber(value * 100, 2)}%`
|
||||
}
|
||||
|
||||
function formatRankChange(value: number | null) {
|
||||
if (value === null) return "暂无可比历史"
|
||||
if (value > 0) return `+${value}`
|
||||
return String(value)
|
||||
if (value > 0) return `+${value} 名`
|
||||
if (value < 0) return `${value} 名`
|
||||
return "0 名"
|
||||
}
|
||||
|
||||
function formatCoverage(value: number) {
|
||||
return `${formatNumber(value * 100, 1)}%`
|
||||
}
|
||||
|
||||
function formatNumber(value: number, maximumFractionDigits: number) {
|
||||
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
||||
}
|
||||
|
||||
function attemptStatusLabel(status: RadarPublication["status"]) {
|
||||
if (status === "running") return "新一期仍在构建"
|
||||
if (status === "partial") return "新一期数据不完整"
|
||||
|
||||
Reference in New Issue
Block a user