Files
zhixing-system/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx
T

649 lines
20 KiB
TypeScript
Raw Normal View History

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 "新一期已发布"
}