feat(sector-radar): align dual ranking radar interactions

This commit is contained in:
yuxuanhui
2026-09-01 12:14:02 +08:00
parent 21ec0b2353
commit fe6d2b4fe5
2 changed files with 452 additions and 199 deletions
@@ -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}
<RadarPublicationStatus
currentAttempt={dates.data.current_attempt}
lastGood={dates.data.last_good}
publication={firstRankingPage.publication}
/>
{dates.data ? (
<RadarPublicationStatus
currentAttempt={dates.data.current_attempt}
lastGood={dates.data.last_good}
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
}
void onLoadMore().then(releaseRequest, releaseRequest)
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
})
}
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">
参考指标值
</th>
) : null}
<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>
<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">
重试加载更多
</Button>
<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 "新一期数据不完整"