649 lines
20 KiB
TypeScript
649 lines
20 KiB
TypeScript
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
|
import { useEffect } from "react"
|
|
import { useNavigate, useSearch } from "@tanstack/react-router"
|
|
|
|
import { PageLayout } from "@/app/layout/page-layout"
|
|
import {
|
|
useSectorRadarDates,
|
|
useSectorRadarRankings,
|
|
} from "@/features/sector-radar/api/sector-radar.query"
|
|
import type {
|
|
RadarMetricQuality,
|
|
RadarMetricUnit,
|
|
RadarPublication,
|
|
RadarRankingRow,
|
|
RadarRankingsQuery,
|
|
SectorRadarRouteSearch,
|
|
} from "@/features/sector-radar/api/sector-radar.types"
|
|
import { Badge } from "@/shared/ui/badge"
|
|
import { Button } from "@/shared/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/shared/ui/card"
|
|
import { Input } from "@/shared/ui/input"
|
|
import { Pagination } from "@/shared/ui/pagination"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/shared/ui/select"
|
|
import { Skeleton } from "@/shared/ui/skeleton"
|
|
|
|
const PAGE_SIZE_OPTIONS = [10, 20, 50] as const
|
|
|
|
const sectorTypeOptions = [
|
|
{ label: "概念板块", value: "concept" },
|
|
{ label: "行业板块", value: "industry" },
|
|
] as const
|
|
|
|
const viewOptions = [
|
|
{ label: "主力净额", value: "amount" },
|
|
{ label: "单日资金率", value: "ratio" },
|
|
{ label: "3—10 日波段资金率", value: "swing" },
|
|
{ label: "排名变化", value: "rank_change" },
|
|
] as const
|
|
|
|
const metricOptions = [
|
|
{ label: "主力净额", value: "amount" },
|
|
{ label: "单日资金率", value: "ratio" },
|
|
{ label: "3—10 日波段资金率", 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),
|
|
}))
|
|
|
|
export function SectorRadarPage() {
|
|
const search = useSearch({ from: "/_workspace/sector-radar" })
|
|
const navigate = useNavigate({ from: "/sector-radar" })
|
|
const query: RadarRankingsQuery = {
|
|
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 total = rankings.data?.total ?? 0
|
|
const pageCount = Math.max(1, Math.ceil(total / search.pageSize))
|
|
|
|
useEffect(() => {
|
|
if (rankings.data?.status === "success" && search.page > pageCount) {
|
|
void navigate({
|
|
search: (previous) => ({ ...previous, page: pageCount }),
|
|
})
|
|
}
|
|
}, [navigate, pageCount, rankings.data?.status, search.page])
|
|
|
|
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
|
|
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
|
}
|
|
|
|
const loading = dates.isPending || rankings.isPending
|
|
const refreshing =
|
|
!loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching))
|
|
const fatalError =
|
|
(dates.isError && !dates.data) || (rankings.isError && !rankings.data)
|
|
const refreshFailed =
|
|
!fatalError && (Boolean(dates.isError) || Boolean(rankings.isError))
|
|
const noData =
|
|
dates.data?.status === "no_data" || rankings.data?.status === "no_data"
|
|
|
|
return (
|
|
<PageLayout
|
|
actions={
|
|
<RadarFilters
|
|
availableDates={dates.data?.available_dates ?? []}
|
|
onChange={updateSearch}
|
|
search={search}
|
|
/>
|
|
}
|
|
mode="bounded-workspace"
|
|
>
|
|
<div className="mx-auto flex min-h-0 w-full max-w-7xl flex-1 flex-col gap-3 md:overflow-hidden">
|
|
{loading ? <RadarLoading /> : null}
|
|
{!loading && fatalError ? (
|
|
<RadarError
|
|
onRetry={() => {
|
|
void dates.refetch()
|
|
void rankings.refetch()
|
|
}}
|
|
/>
|
|
) : null}
|
|
{!loading && !fatalError && noData ? <RadarNoData /> : null}
|
|
{!loading &&
|
|
!fatalError &&
|
|
!noData &&
|
|
dates.data &&
|
|
rankings.data?.status === "success" &&
|
|
rankings.data.publication ? (
|
|
<>
|
|
{refreshFailed ? (
|
|
<RadarRefreshFailureStatus />
|
|
) : refreshing ? (
|
|
<RadarRefreshStatus />
|
|
) : null}
|
|
<RadarStatusSummary
|
|
currentAttempt={dates.data.current_attempt}
|
|
lastGood={dates.data.last_good}
|
|
publication={rankings.data.publication}
|
|
disclaimer={rankings.data.definition.disclaimer}
|
|
metricVersion={rankings.data.definition.metric_version}
|
|
/>
|
|
<RadarTable
|
|
onPageChange={(page) => updateSearch({ page })}
|
|
onPageSizeChange={(pageSize) =>
|
|
updateSearch({ page: 1, pageSize })
|
|
}
|
|
page={Math.min(search.page, pageCount)}
|
|
pageSize={search.pageSize}
|
|
response={rankings.data}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</PageLayout>
|
|
)
|
|
}
|
|
|
|
interface RadarFiltersProps {
|
|
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
|
|
className="lg:col-span-2"
|
|
label="交易日"
|
|
options={dateOptions}
|
|
value={search.tradeDate ?? "latest"}
|
|
onValueChange={(value) =>
|
|
onChange({
|
|
page: 1,
|
|
tradeDate: value === "latest" ? undefined : value,
|
|
})
|
|
}
|
|
/>
|
|
<FilterSelect
|
|
className="lg:col-span-2"
|
|
label="板块类型"
|
|
options={sectorTypeOptions}
|
|
value={search.sectorType}
|
|
onValueChange={(value) =>
|
|
onChange({
|
|
page: 1,
|
|
sectorType: value as SectorRadarRouteSearch["sectorType"],
|
|
})
|
|
}
|
|
/>
|
|
<FilterSelect
|
|
className="lg:col-span-2"
|
|
label="指标视角"
|
|
options={viewOptions}
|
|
value={search.view}
|
|
onValueChange={(value) =>
|
|
onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] })
|
|
}
|
|
/>
|
|
{search.view === "rank_change" ? (
|
|
<>
|
|
<FilterSelect
|
|
className="lg:col-span-2"
|
|
label="变化指标"
|
|
options={metricOptions}
|
|
value={search.rankChangeMetric}
|
|
onValueChange={(value) =>
|
|
onChange({
|
|
page: 1,
|
|
rankChangeMetric:
|
|
value as SectorRadarRouteSearch["rankChangeMetric"],
|
|
})
|
|
}
|
|
/>
|
|
<FilterSelect
|
|
className="lg:col-span-2"
|
|
label="对比区间"
|
|
options={rankChangeDayOptions}
|
|
value={String(search.rankChangeDays)}
|
|
onValueChange={(value) =>
|
|
onChange({ page: 1, rankChangeDays: Number(value) })
|
|
}
|
|
/>
|
|
</>
|
|
) : 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
|
|
aria-label="搜索板块"
|
|
className="bg-background"
|
|
maxLength={100}
|
|
onChange={(event) =>
|
|
onChange({ page: 1, search: event.target.value })
|
|
}
|
|
placeholder="名称或代码"
|
|
type="search"
|
|
value={search.search}
|
|
/>
|
|
</label>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FilterSelect({
|
|
className,
|
|
label,
|
|
onValueChange,
|
|
options,
|
|
value,
|
|
}: {
|
|
className?: string
|
|
label: string
|
|
onValueChange: (value: string) => void
|
|
options: readonly { label: string; value: string }[]
|
|
value: string
|
|
}) {
|
|
return (
|
|
<label
|
|
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
|
|
>
|
|
{label}
|
|
<Select
|
|
items={options}
|
|
onValueChange={(next) => {
|
|
if (typeof next === "string") onValueChange(next)
|
|
}}
|
|
value={value}
|
|
>
|
|
<SelectTrigger aria-label={label} className="bg-background">
|
|
<SelectValue placeholder={label} />
|
|
</SelectTrigger>
|
|
<SelectContent align="start" alignItemWithTrigger={false}>
|
|
<SelectGroup>
|
|
{options.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function RadarStatusSummary({
|
|
currentAttempt,
|
|
disclaimer,
|
|
lastGood,
|
|
metricVersion,
|
|
publication,
|
|
}: {
|
|
currentAttempt: RadarPublication | null
|
|
disclaimer: string
|
|
lastGood: RadarPublication | null
|
|
metricVersion: string
|
|
publication: RadarPublication
|
|
}) {
|
|
const degradedAttempt =
|
|
currentAttempt &&
|
|
currentAttempt.status !== "success" &&
|
|
currentAttempt.publication_id !== lastGood?.publication_id
|
|
|
|
return (
|
|
<Card className="shrink-0 shadow-none">
|
|
<CardHeader className="gap-3 p-4 pb-3">
|
|
<div className="flex flex-wrap items-start gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<CardTitle className="text-base">收盘后板块资金排名</CardTitle>
|
|
<CardDescription className="mt-1">{disclaimer}</CardDescription>
|
|
</div>
|
|
<Badge variant="outline">知行独立实现</Badge>
|
|
<Badge variant="outline">{metricVersion}</Badge>
|
|
</div>
|
|
{degradedAttempt ? (
|
|
<div
|
|
role="status"
|
|
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
|
|
>
|
|
{attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
|
|
{currentAttempt.target_trade_date}
|
|
,当前仍展示最近有效发布{" "}
|
|
{lastGood?.target_trade_date ?? publication.target_trade_date}。
|
|
</div>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 p-4 pt-0 text-xs sm:grid-cols-2 lg:grid-cols-5">
|
|
<SummaryItem label="数据交易日" value={publication.target_trade_date} />
|
|
<SummaryItem
|
|
label="发布完成"
|
|
value={formatDateTime(publication.finished_at)}
|
|
/>
|
|
<SummaryItem
|
|
label="全局覆盖率"
|
|
value={formatCoverage(publication.coverage)}
|
|
/>
|
|
<SummaryItem label="事实来源" value={publication.source_version} />
|
|
<SummaryItem
|
|
label="板块范围版本"
|
|
value={publication.universe_version}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function SummaryItem({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<p className="text-muted-foreground">{label}</p>
|
|
<p className="mt-1 break-all font-medium text-foreground tabular-nums">
|
|
{value}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RadarTable({
|
|
onPageChange,
|
|
onPageSizeChange,
|
|
page,
|
|
pageSize,
|
|
response,
|
|
}: {
|
|
onPageChange: (page: number) => void
|
|
onPageSizeChange: (pageSize: number) => void
|
|
page: number
|
|
pageSize: number
|
|
response: Exclude<
|
|
ReturnType<typeof useSectorRadarRankings>["data"],
|
|
undefined
|
|
>
|
|
}) {
|
|
const rankChangeView = response.view === "rank_change"
|
|
return (
|
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
|
|
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
|
|
<table className="w-full min-w-[920px] border-collapse text-sm">
|
|
<caption className="sr-only">
|
|
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
|
|
</caption>
|
|
<thead className="sticky top-0 z-10 bg-muted/95 text-left text-xs text-muted-foreground backdrop-blur">
|
|
<tr>
|
|
<th className="px-3 py-2.5 font-medium" scope="col">
|
|
名次
|
|
</th>
|
|
<th className="px-3 py-2.5 font-medium" scope="col">
|
|
板块
|
|
</th>
|
|
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
|
{rankChangeView
|
|
? `${response.rank_change_days} 日排名变化`
|
|
: response.definition.label}
|
|
</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">
|
|
排名百分位
|
|
</th>
|
|
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
|
样本
|
|
</th>
|
|
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
|
资金覆盖率
|
|
</th>
|
|
<th className="px-3 py-2.5 font-medium" scope="col">
|
|
质量
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/60">
|
|
{response.rows.map((row) => (
|
|
<RadarTableRow
|
|
key={`${row.sector_type}-${row.sector_code}`}
|
|
rankChangeView={rankChangeView}
|
|
row={row}
|
|
/>
|
|
))}
|
|
{response.rows.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
className="px-3 py-12 text-center text-muted-foreground"
|
|
colSpan={rankChangeView ? 8 : 7}
|
|
>
|
|
没有符合当前筛选条件的板块。
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination
|
|
onPageChange={onPageChange}
|
|
onPageSizeChange={onPageSizeChange}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
total={response.total}
|
|
/>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function RadarTableRow({
|
|
rankChangeView,
|
|
row,
|
|
}: {
|
|
rankChangeView: boolean
|
|
row: RadarRankingRow
|
|
}) {
|
|
return (
|
|
<tr className="hover:bg-muted/40">
|
|
<td className="px-3 py-2.5 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">
|
|
{row.sector_code}
|
|
</p>
|
|
</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
|
|
? "—"
|
|
: `${formatNumber(row.rank_percentile, 2)}%`}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right tabular-nums">
|
|
{row.valid_sample_count}/{row.member_count}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right tabular-nums">
|
|
{formatCoverage(row.moneyflow_coverage)}
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<QualityBadge quality={row.quality} />
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
|
if (quality === "available") return <Badge variant="outline">可用</Badge>
|
|
if (quality === "available_limited_sample") {
|
|
return (
|
|
<Badge
|
|
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
|
|
variant="outline"
|
|
>
|
|
样本有限
|
|
</Badge>
|
|
)
|
|
}
|
|
return <Badge variant="destructive">不可用</Badge>
|
|
}
|
|
|
|
function RadarLoading() {
|
|
return (
|
|
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
|
<CardHeader className="gap-3">
|
|
<CardTitle className="text-base">正在加载板块资金雷达</CardTitle>
|
|
<CardDescription>正在读取最近有效发布和板块排名。</CardDescription>
|
|
<Skeleton className="h-5 w-52" />
|
|
<Skeleton className="h-4 w-80 max-w-full" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{Array.from({ length: 7 }, (_, index) => (
|
|
<Skeleton className="h-10 w-full" key={index} />
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function RadarRefreshStatus() {
|
|
return (
|
|
<div
|
|
role="status"
|
|
className="shrink-0 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-muted-foreground"
|
|
>
|
|
雷达数据正在刷新,当前继续展示上一次已读取的有效结果。
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RadarRefreshFailureStatus() {
|
|
return (
|
|
<div
|
|
role="status"
|
|
className="shrink-0 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
|
|
>
|
|
雷达数据刷新失败,当前继续展示上一次已读取的有效结果。请稍后重试。
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RadarError({ onRetry }: { onRetry: () => void }) {
|
|
return (
|
|
<Card className="shadow-none">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-lg">
|
|
<AlertTriangle
|
|
className="size-5 text-destructive"
|
|
aria-hidden="true"
|
|
/>
|
|
板块资金雷达暂时不可用
|
|
</CardTitle>
|
|
<CardDescription>
|
|
请求或响应校验失败,可能是服务、网络或接口契约暂时异常。
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button onClick={onRetry} variant="outline">
|
|
<RefreshCw aria-hidden="true" />
|
|
重试
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function RadarNoData() {
|
|
return (
|
|
<Card className="shadow-none">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-lg">
|
|
<Database className="size-5 text-primary" aria-hidden="true" />
|
|
暂无板块资金发布
|
|
</CardTitle>
|
|
<CardDescription>
|
|
完成第一次收盘后构建并通过完整性门槛后,这里会显示最近有效排名。
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</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)
|
|
}
|
|
|
|
function formatCoverage(value: number) {
|
|
return `${formatNumber(value * 100, 1)}%`
|
|
}
|
|
|
|
function formatNumber(value: number, maximumFractionDigits: number) {
|
|
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
|
}
|
|
|
|
function formatDateTime(value: string | null) {
|
|
if (!value) return "—"
|
|
return new Intl.DateTimeFormat("zh-CN", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
timeZone: "Asia/Shanghai",
|
|
}).format(new Date(value))
|
|
}
|
|
|
|
function attemptStatusLabel(status: RadarPublication["status"]) {
|
|
if (status === "running") return "新一期仍在构建"
|
|
if (status === "partial") return "新一期数据不完整"
|
|
if (status === "failed") return "新一期构建失败"
|
|
return "新一期已发布"
|
|
}
|