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> = {}) {
|
function rankingQueryResult(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
data: { pageParams: [1], pages: [rankingsResponse] },
|
data: { pageParams: [1], pages: [rankingsResponse] },
|
||||||
@@ -161,10 +173,10 @@ describe("SectorRadarPage", () => {
|
|||||||
it("renders ranking rows without the description card or pagination", () => {
|
it("renders ranking rows without the description card or pagination", () => {
|
||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
|
expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
|
||||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||||
expect(screen.getByText("样本有限")).toBeInTheDocument()
|
expect(screen.getAllByText("样本有限")[0]).toBeInTheDocument()
|
||||||
expect(screen.getByText("95%")).toBeInTheDocument()
|
expect(screen.getAllByText("95%")[0]).toBeInTheDocument()
|
||||||
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
|
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"),
|
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", () => {
|
it("loads the next page once when repeated scroll events reach the bottom", () => {
|
||||||
fetchNextPage.mockReturnValue(new Promise(() => undefined))
|
fetchNextPage.mockReturnValue(new Promise(() => undefined))
|
||||||
useSectorRadarRankings.mockReturnValue(
|
useSectorRadarRankings.mockReturnValue(
|
||||||
@@ -230,7 +274,7 @@ describe("SectorRadarPage", () => {
|
|||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块")
|
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块")
|
||||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||||
expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument()
|
expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -257,9 +301,9 @@ describe("SectorRadarPage", () => {
|
|||||||
|
|
||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||||
expect(screen.getByText("低空经济")).toBeInTheDocument()
|
expect(screen.getAllByText("低空经济")[0]).toBeInTheDocument()
|
||||||
expect(screen.getByText("商业航天")).toBeInTheDocument()
|
expect(screen.getAllByText("商业航天")[0]).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("keeps loaded rows and offers retry when loading more fails", () => {
|
it("keeps loaded rows and offers retry when loading more fails", () => {
|
||||||
@@ -273,15 +317,44 @@ describe("SectorRadarPage", () => {
|
|||||||
|
|
||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||||
"加载更多板块失败,已加载结果仍保留",
|
"强榜加载更多失败,弱榜加载更多失败,已加载结果仍保留",
|
||||||
)
|
)
|
||||||
expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument()
|
expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole("button", { name: "重试加载更多" }))
|
fireEvent.click(screen.getByRole("button", { name: "重试强榜" }))
|
||||||
expect(fetchNextPage).toHaveBeenCalledOnce()
|
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", () => {
|
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(
|
||||||
@@ -307,10 +380,10 @@ describe("SectorRadarPage", () => {
|
|||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("columnheader", { name: "5 日排名变化" }),
|
screen.getByRole("columnheader", { name: "排名上升" }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(screen.getByText("+3")).toBeInTheDocument()
|
expect(screen.getAllByText("+3 名")[0]).toBeInTheDocument()
|
||||||
expect(screen.getByText("暂无可比历史")).toBeInTheDocument()
|
expect(screen.getAllByText("暂无可比历史")[0]).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("warns when a partial attempt has not replaced last-good", () => {
|
it("warns when a partial attempt has not replaced last-good", () => {
|
||||||
@@ -350,7 +423,7 @@ describe("SectorRadarPage", () => {
|
|||||||
expect(screen.getByRole("status")).toHaveTextContent(
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
"当前继续展示上一次已读取的有效结果",
|
"当前继续展示上一次已读取的有效结果",
|
||||||
)
|
)
|
||||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("keeps stale data visible when a background refresh fails", () => {
|
it("keeps stale data visible when a background refresh fails", () => {
|
||||||
@@ -361,10 +434,38 @@ describe("SectorRadarPage", () => {
|
|||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败")
|
expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败")
|
||||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||||
expect(screen.queryByText("板块资金雷达暂时不可用")).not.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", () => {
|
it("renders loading, error with retry, and no-data states", () => {
|
||||||
useSectorRadarDates.mockReturnValue({
|
useSectorRadarDates.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
@@ -408,7 +509,7 @@ describe("SectorRadarPage", () => {
|
|||||||
rerender(<SectorRadarPage />)
|
rerender(<SectorRadarPage />)
|
||||||
fireEvent.click(screen.getByRole("button", { name: "重试" }))
|
fireEvent.click(screen.getByRole("button", { name: "重试" }))
|
||||||
expect(refetchDates).toHaveBeenCalledOnce()
|
expect(refetchDates).toHaveBeenCalledOnce()
|
||||||
expect(refetchRankings).toHaveBeenCalledOnce()
|
expect(refetchRankings).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
useSectorRadarDates.mockReturnValue({
|
useSectorRadarDates.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
useSectorRadarRankings,
|
useSectorRadarRankings,
|
||||||
} from "@/features/sector-radar/api/sector-radar.query"
|
} from "@/features/sector-radar/api/sector-radar.query"
|
||||||
import type {
|
import type {
|
||||||
|
RadarMetricKind,
|
||||||
RadarMetricQuality,
|
RadarMetricQuality,
|
||||||
RadarMetricUnit,
|
RadarMetricUnit,
|
||||||
RadarPublication,
|
RadarPublication,
|
||||||
RadarRankingRow,
|
RadarRankingRow,
|
||||||
RadarRankingsQuery,
|
RadarRankingsQuery,
|
||||||
RadarRankingsResponse,
|
RadarRankingsResponse,
|
||||||
|
RadarView,
|
||||||
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"
|
||||||
@@ -37,29 +39,20 @@ import {
|
|||||||
import { Skeleton } from "@/shared/ui/skeleton"
|
import { Skeleton } from "@/shared/ui/skeleton"
|
||||||
|
|
||||||
const sectorTypeOptions = [
|
const sectorTypeOptions = [
|
||||||
{ label: "概念板块", value: "concept" },
|
{ label: "概念", value: "concept" },
|
||||||
{ label: "行业板块", value: "industry" },
|
{ label: "行业", value: "industry" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const viewOptions = [
|
const viewOptions = [
|
||||||
{ label: "主力净额", value: "amount" },
|
{ label: "波段资金率", value: "swing" },
|
||||||
{ label: "单日资金率", value: "ratio" },
|
{ label: "单日资金率", value: "ratio" },
|
||||||
{ label: "3—10 日波段资金率", value: "swing" },
|
{ label: "单日净额", value: "amount" },
|
||||||
{ label: "排名变化", value: "rank_change" },
|
{ label: "排名变化", value: "rank_change" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const metricOptions = [
|
const metricOptions = [
|
||||||
{ label: "主力净额", value: "amount" },
|
{ label: "主力净额", value: "amount" },
|
||||||
{ label: "单日资金率", value: "ratio" },
|
{ label: "单日资金率", value: "ratio" },
|
||||||
{ label: "3—10 日波段资金率", value: "swing" },
|
{ label: "波段资金率", value: "swing" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const sideOptions = [
|
|
||||||
{ label: "全部板块", value: "all" },
|
|
||||||
{ label: "强榜", value: "top" },
|
|
||||||
{ label: "弱榜", value: "bottom" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
|
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
|
||||||
label: `${value} 日变化`,
|
label: `${value} 日变化`,
|
||||||
value: String(value),
|
value: String(value),
|
||||||
@@ -68,41 +61,56 @@ const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
|
|||||||
export function SectorRadarPage() {
|
export function SectorRadarPage() {
|
||||||
const search = useSearch({ from: "/_workspace/sector-radar" })
|
const search = useSearch({ from: "/_workspace/sector-radar" })
|
||||||
const navigate = useNavigate({ from: "/sector-radar" })
|
const navigate = useNavigate({ from: "/sector-radar" })
|
||||||
const query: RadarRankingsQuery = {
|
const baseQuery: Omit<RadarRankingsQuery, "side"> = {
|
||||||
tradeDate: search.tradeDate,
|
tradeDate: search.tradeDate,
|
||||||
sectorType: search.sectorType,
|
sectorType: search.sectorType,
|
||||||
view: search.view,
|
view: search.view,
|
||||||
rankChangeMetric: search.rankChangeMetric,
|
rankChangeMetric: search.rankChangeMetric,
|
||||||
rankChangeDays: search.rankChangeDays,
|
rankChangeDays: search.rankChangeDays,
|
||||||
side: search.side,
|
|
||||||
search: search.search || undefined,
|
search: search.search || undefined,
|
||||||
page: search.page,
|
page: search.page,
|
||||||
pageSize: search.pageSize,
|
pageSize: search.pageSize,
|
||||||
}
|
}
|
||||||
const dates = useSectorRadarDates()
|
const dates = useSectorRadarDates()
|
||||||
const rankings = useSectorRadarRankings(query)
|
const topRankings = useSectorRadarRankings({ ...baseQuery, side: "top" })
|
||||||
const rankingPages = rankings.data?.pages ?? []
|
const bottomRankings = useSectorRadarRankings({
|
||||||
const firstRankingPage = rankingPages[0]
|
...baseQuery,
|
||||||
const rankingRows = rankingPages.flatMap((page) => page.rows)
|
side: "bottom",
|
||||||
|
})
|
||||||
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
|
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
|
||||||
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
||||||
}
|
}
|
||||||
|
const loading =
|
||||||
const loading = dates.isPending || rankings.isPending
|
dates.isPending || topRankings.isPending || bottomRankings.isPending
|
||||||
const refreshing =
|
const refreshing =
|
||||||
!loading &&
|
!loading &&
|
||||||
(Boolean(dates.isFetching) ||
|
(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 =
|
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 =
|
const refreshFailed =
|
||||||
!fatalError &&
|
!fatalError &&
|
||||||
(Boolean(dates.isError) ||
|
(Boolean(dates.isError) ||
|
||||||
(Boolean(rankings.isError) && !rankings.isFetchNextPageError))
|
(Boolean(topRankings.isError) && !topRankings.isFetchNextPageError) ||
|
||||||
|
(Boolean(bottomRankings.isError) && !bottomRankings.isFetchNextPageError))
|
||||||
const noData =
|
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 (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
actions={
|
actions={
|
||||||
@@ -120,35 +128,30 @@ export function SectorRadarPage() {
|
|||||||
<RadarError
|
<RadarError
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
void dates.refetch()
|
void dates.refetch()
|
||||||
void rankings.refetch()
|
void topRankings.refetch()
|
||||||
|
void bottomRankings.refetch()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && !fatalError && noData ? <RadarNoData /> : null}
|
{!loading && !fatalError && noData ? <RadarNoData /> : null}
|
||||||
{!loading &&
|
{!loading && !fatalError && !noData && response && publication ? (
|
||||||
!fatalError &&
|
|
||||||
!noData &&
|
|
||||||
dates.data &&
|
|
||||||
firstRankingPage?.status === "success" &&
|
|
||||||
firstRankingPage.publication ? (
|
|
||||||
<>
|
<>
|
||||||
{refreshFailed ? (
|
{refreshFailed ? (
|
||||||
<RadarRefreshFailureStatus />
|
<RadarRefreshFailureStatus />
|
||||||
) : refreshing ? (
|
) : refreshing ? (
|
||||||
<RadarRefreshStatus />
|
<RadarRefreshStatus />
|
||||||
) : null}
|
) : null}
|
||||||
<RadarPublicationStatus
|
{dates.data ? (
|
||||||
currentAttempt={dates.data.current_attempt}
|
<RadarPublicationStatus
|
||||||
lastGood={dates.data.last_good}
|
currentAttempt={dates.data.current_attempt}
|
||||||
publication={firstRankingPage.publication}
|
lastGood={dates.data.last_good}
|
||||||
/>
|
publication={publication}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<RadarTable
|
<RadarTable
|
||||||
hasNextPage={Boolean(rankings.hasNextPage)}
|
bottom={bottomRankings}
|
||||||
isFetchNextPageError={rankings.isFetchNextPageError}
|
response={response}
|
||||||
isFetchingNextPage={rankings.isFetchingNextPage}
|
top={topRankings}
|
||||||
onLoadMore={() => rankings.fetchNextPage()}
|
|
||||||
response={firstRankingPage}
|
|
||||||
rows={rankingRows}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -157,18 +160,19 @@ export function SectorRadarPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RadarFiltersProps {
|
function RadarFilters({
|
||||||
|
availableDates,
|
||||||
|
onChange,
|
||||||
|
search,
|
||||||
|
}: {
|
||||||
availableDates: string[]
|
availableDates: string[]
|
||||||
onChange: (next: Partial<SectorRadarRouteSearch>) => void
|
onChange: (next: Partial<SectorRadarRouteSearch>) => void
|
||||||
search: SectorRadarRouteSearch
|
search: SectorRadarRouteSearch
|
||||||
}
|
}) {
|
||||||
|
|
||||||
function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
|
||||||
const dateOptions = [
|
const dateOptions = [
|
||||||
{ label: "最近有效交易日", value: "latest" },
|
{ label: "最近有效交易日", value: "latest" },
|
||||||
...availableDates.map((value) => ({ label: value, value })),
|
...availableDates.map((value) => ({ label: value, value })),
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12">
|
<div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12">
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
@@ -183,26 +187,24 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FilterSelect
|
<TabList
|
||||||
|
ariaLabel="板块类型"
|
||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
label="板块类型"
|
|
||||||
options={sectorTypeOptions}
|
options={sectorTypeOptions}
|
||||||
value={search.sectorType}
|
value={search.sectorType}
|
||||||
onValueChange={(value) =>
|
onChange={(value) =>
|
||||||
onChange({
|
onChange({
|
||||||
page: 1,
|
page: 1,
|
||||||
sectorType: value as SectorRadarRouteSearch["sectorType"],
|
sectorType: value as SectorRadarRouteSearch["sectorType"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FilterSelect
|
<TabList
|
||||||
className="lg:col-span-2"
|
ariaLabel="指标视角"
|
||||||
label="指标视角"
|
className="lg:col-span-4"
|
||||||
options={viewOptions}
|
options={viewOptions}
|
||||||
value={search.view}
|
value={search.view}
|
||||||
onValueChange={(value) =>
|
onChange={(value) => onChange({ page: 1, view: value as RadarView })}
|
||||||
onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{search.view === "rank_change" ? (
|
{search.view === "rank_change" ? (
|
||||||
<>
|
<>
|
||||||
@@ -212,11 +214,7 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
|||||||
options={metricOptions}
|
options={metricOptions}
|
||||||
value={search.rankChangeMetric}
|
value={search.rankChangeMetric}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onChange({
|
onChange({ page: 1, rankChangeMetric: value as RadarMetricKind })
|
||||||
page: 1,
|
|
||||||
rankChangeMetric:
|
|
||||||
value as SectorRadarRouteSearch["rankChangeMetric"],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
@@ -230,15 +228,6 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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">
|
<label className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2">
|
||||||
搜索板块
|
搜索板块
|
||||||
<Input
|
<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({
|
function FilterSelect({
|
||||||
className,
|
className,
|
||||||
label,
|
label,
|
||||||
@@ -312,9 +341,7 @@ function RadarPublicationStatus({
|
|||||||
currentAttempt &&
|
currentAttempt &&
|
||||||
currentAttempt.status !== "success" &&
|
currentAttempt.status !== "success" &&
|
||||||
currentAttempt.publication_id !== lastGood?.publication_id
|
currentAttempt.publication_id !== lastGood?.publication_id
|
||||||
|
|
||||||
if (!degradedAttempt) return null
|
if (!degradedAttempt) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
@@ -327,106 +354,145 @@ function RadarPublicationStatus({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RankingQueryResult = ReturnType<typeof useSectorRadarRankings>
|
||||||
function RadarTable({
|
function RadarTable({
|
||||||
hasNextPage,
|
top,
|
||||||
isFetchNextPageError,
|
bottom,
|
||||||
isFetchingNextPage,
|
|
||||||
onLoadMore,
|
|
||||||
response,
|
response,
|
||||||
rows,
|
|
||||||
}: {
|
}: {
|
||||||
hasNextPage: boolean
|
top: RankingQueryResult
|
||||||
isFetchNextPageError: boolean
|
bottom: RankingQueryResult
|
||||||
isFetchingNextPage: boolean
|
|
||||||
onLoadMore: () => Promise<unknown>
|
|
||||||
response: RadarRankingsResponse
|
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 rankChangeView = response.view === "rank_change"
|
||||||
const loadMoreRequestPending = useRef(false)
|
const loadMoreRequestPending = useRef({ top: false, bottom: false })
|
||||||
|
function requestLoadMore(query: RankingQueryResult) {
|
||||||
function requestLoadMore() {
|
const side = query === top ? "top" : "bottom"
|
||||||
if (loadMoreRequestPending.current || isFetchingNextPage) return
|
if (
|
||||||
|
loadMoreRequestPending.current[side] ||
|
||||||
loadMoreRequestPending.current = true
|
query.isFetchingNextPage ||
|
||||||
const releaseRequest = () => {
|
!query.hasNextPage ||
|
||||||
loadMoreRequestPending.current = false
|
query.isFetchNextPageError
|
||||||
}
|
)
|
||||||
void onLoadMore().then(releaseRequest, releaseRequest)
|
return
|
||||||
|
loadMoreRequestPending.current[side] = true
|
||||||
|
void query.fetchNextPage().finally(() => {
|
||||||
|
loadMoreRequestPending.current[side] = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
<div
|
||||||
aria-label="板块资金排名滚动区域"
|
aria-label="板块资金排名滚动区域"
|
||||||
className="min-h-0 min-w-0 flex-1 overflow-auto"
|
className="min-h-0 min-w-0 flex-1 overflow-auto"
|
||||||
onScroll={(event) => {
|
onScroll={(event) => {
|
||||||
const container = event.currentTarget
|
const c = event.currentTarget
|
||||||
const remaining =
|
if (c.scrollHeight - c.scrollTop - c.clientHeight <= 160) {
|
||||||
container.scrollHeight -
|
requestLoadMore(top)
|
||||||
container.scrollTop -
|
requestLoadMore(bottom)
|
||||||
container.clientHeight
|
|
||||||
if (
|
|
||||||
remaining <= 160 &&
|
|
||||||
hasNextPage &&
|
|
||||||
!isFetchingNextPage &&
|
|
||||||
!isFetchNextPageError
|
|
||||||
) {
|
|
||||||
requestLoadMore()
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="region"
|
role="region"
|
||||||
tabIndex={0}
|
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">
|
<caption className="sr-only">
|
||||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
|
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
||||||
</caption>
|
</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>
|
<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>
|
||||||
<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>
|
||||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
<th
|
||||||
{rankChangeView
|
className="px-3 py-2 text-right font-medium"
|
||||||
? `${response.rank_change_days} 日排名变化`
|
colSpan={5}
|
||||||
: response.definition.label}
|
scope="colgroup"
|
||||||
|
>
|
||||||
|
BOTTOM 10% 资金撤离榜
|
||||||
</th>
|
</th>
|
||||||
{rankChangeView ? (
|
</tr>
|
||||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
<tr>
|
||||||
参考指标值
|
<th className="px-3 py-2 text-left font-medium" scope="col">
|
||||||
</th>
|
排名 / 板块
|
||||||
) : null}
|
</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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border/60">
|
<tbody className="divide-y divide-border/60">
|
||||||
{rows.map((row) => (
|
{Array.from({ length: rowCount }, (_, i) => (
|
||||||
<RadarTableRow
|
<RadarMirrorRow
|
||||||
key={`${row.sector_type}-${row.sector_code}`}
|
key={i}
|
||||||
rankChangeView={rankChangeView}
|
rankChangeView={rankChangeView}
|
||||||
row={row}
|
top={topRows[i]}
|
||||||
|
bottom={bottomRows[i]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{rows.length === 0 ? (
|
{rowCount === 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"
|
||||||
colSpan={rankChangeView ? 8 : 7}
|
colSpan={12}
|
||||||
>
|
>
|
||||||
没有符合当前筛选条件的板块。
|
没有符合当前筛选条件的板块。
|
||||||
</td>
|
</td>
|
||||||
@@ -434,7 +500,7 @@ function RadarTable({
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{isFetchingNextPage ? (
|
{top.isFetchingNextPage || bottom.isFetchingNextPage ? (
|
||||||
<div
|
<div
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
|
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
|
||||||
@@ -443,15 +509,43 @@ function RadarTable({
|
|||||||
正在加载更多板块…
|
正在加载更多板块…
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isFetchNextPageError ? (
|
{top.isFetchNextPageError || bottom.isFetchNextPageError ? (
|
||||||
<div
|
<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"
|
role="alert"
|
||||||
>
|
>
|
||||||
<span>加载更多板块失败,已加载结果仍保留。</span>
|
<span>
|
||||||
<Button onClick={requestLoadMore} size="sm" variant="outline">
|
{top.isFetchNextPageError ? "强榜加载更多失败" : null}
|
||||||
重试加载更多
|
{top.isFetchNextPageError && bottom.isFetchNextPageError
|
||||||
</Button>
|
? ","
|
||||||
|
: 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -459,34 +553,57 @@ function RadarTable({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarTableRow({
|
function RadarMirrorRow({
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
rankChangeView,
|
rankChangeView,
|
||||||
row,
|
|
||||||
}: {
|
}: {
|
||||||
|
top?: RadarRankingRow
|
||||||
|
bottom?: RadarRankingRow
|
||||||
rankChangeView: boolean
|
rankChangeView: boolean
|
||||||
row: RadarRankingRow
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<tr className="hover:bg-muted/40">
|
<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 ?? "—"}
|
{row.rank_position ?? "—"}
|
||||||
</td>
|
<span className="mx-1 font-normal text-muted-foreground">/</span>
|
||||||
<td className="px-3 py-2.5">
|
<span className="font-medium text-foreground">{row.sector_name}</span>
|
||||||
<p className="font-medium text-foreground">{row.sector_name}</p>
|
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
||||||
{row.sector_code}
|
{row.sector_code}
|
||||||
</p>
|
</span>
|
||||||
</td>
|
</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">
|
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||||
{row.rank_percentile === null
|
{row.rank_percentile === null
|
||||||
? "—"
|
? "—"
|
||||||
@@ -501,13 +618,64 @@ function RadarTableRow({
|
|||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<QualityBadge quality={row.quality} />
|
<QualityBadge quality={row.quality} />
|
||||||
</td>
|
</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 }) {
|
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
||||||
if (quality === "available") return <Badge variant="outline">可用</Badge>
|
if (quality === "available") return <Badge variant="outline">可用</Badge>
|
||||||
if (quality === "available_limited_sample") {
|
if (quality === "available_limited_sample")
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
|
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
|
||||||
@@ -516,10 +684,8 @@ function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
|||||||
样本有限
|
样本有限
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
return <Badge variant="destructive">不可用</Badge>
|
return <Badge variant="destructive">不可用</Badge>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarLoading() {
|
function RadarLoading() {
|
||||||
return (
|
return (
|
||||||
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
||||||
@@ -537,7 +703,6 @@ function RadarLoading() {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarRefreshStatus() {
|
function RadarRefreshStatus() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -548,7 +713,6 @@ function RadarRefreshStatus() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarRefreshFailureStatus() {
|
function RadarRefreshFailureStatus() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -559,7 +723,6 @@ function RadarRefreshFailureStatus() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarError({ onRetry }: { onRetry: () => void }) {
|
function RadarError({ onRetry }: { onRetry: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
@@ -584,7 +747,6 @@ function RadarError({ onRetry }: { onRetry: () => void }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarNoData() {
|
function RadarNoData() {
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
@@ -600,28 +762,18 @@ function RadarNoData() {
|
|||||||
</Card>
|
</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) {
|
function formatRankChange(value: number | null) {
|
||||||
if (value === null) return "暂无可比历史"
|
if (value === null) return "暂无可比历史"
|
||||||
if (value > 0) return `+${value}`
|
if (value > 0) return `+${value} 名`
|
||||||
return String(value)
|
if (value < 0) return `${value} 名`
|
||||||
|
return "0 名"
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCoverage(value: number) {
|
function formatCoverage(value: number) {
|
||||||
return `${formatNumber(value * 100, 1)}%`
|
return `${formatNumber(value * 100, 1)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value: number, maximumFractionDigits: number) {
|
function formatNumber(value: number, maximumFractionDigits: number) {
|
||||||
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
||||||
}
|
}
|
||||||
|
|
||||||
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