7f93d6b0f5
- Introduced ActiveMoneyflowSource to fetch optional active-order flow, enhancing the sector radar's data capabilities. - Updated StockFactRecord and DailyAggregateRecord to include pct_change and active_buy_net_amount_yuan for improved financial insights. - Modified the build process to incorporate active moneyflow data without invalidating main rankings on failure. - Enhanced the HTTP API to return detailed sector history and metrics, including pct_change and active buy metrics for members. - Updated tests to validate the new functionality and ensure data integrity across various scenarios.
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import { lazy, Suspense, useState } from "react"
|
||
import { Button } from "@/shared/ui/button"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/shared/ui/dialog"
|
||
import { useSectorRadarDetail } from "../api/sector-radar.query"
|
||
import type {
|
||
RadarDetailResponse,
|
||
RadarMemberMetric,
|
||
RadarMetricKind,
|
||
RadarRankingRow,
|
||
RadarSectorQuery,
|
||
} from "../api/sector-radar.types"
|
||
import {
|
||
formatRadarValue,
|
||
radarMembersCsv,
|
||
radarValueTone,
|
||
} from "./radar-format"
|
||
import { RadarHistoryGrid } from "./radar-history"
|
||
const RadarRankChart = lazy(() =>
|
||
import("./radar-rank-chart").then((module) => ({
|
||
default: module.RadarRankChart,
|
||
})),
|
||
)
|
||
|
||
const metricLabels = {
|
||
swing: "波段资金率",
|
||
ratio: "单日流入率",
|
||
amount: "单日净额",
|
||
} as const
|
||
const memberLabels = {
|
||
pct_change: "涨跌幅",
|
||
net_amount_yuan: "主力净额",
|
||
active_buy_net_amount_yuan: "主买净额",
|
||
} as const
|
||
|
||
/** Keep the trigger inside the dialog primitive so Escape and close restore its focus. */
|
||
export function RadarDetailDialog({ row }: { row: RadarRankingRow }) {
|
||
const [open, setOpen] = useState(false)
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogTrigger className="font-medium text-foreground underline-offset-4 hover:text-primary hover:underline">
|
||
{row.sector_name}
|
||
</DialogTrigger>
|
||
<DialogContent className="max-h-[90dvh] grid-cols-1 overflow-y-auto sm:max-w-4xl">
|
||
<DialogTitle>{row.sector_name}</DialogTitle>
|
||
<DialogDescription>
|
||
{row.sector_type === "concept" ? "概念" : "行业"} · {row.sector_code}{" "}
|
||
· {row.trade_date}
|
||
</DialogDescription>
|
||
{open ? (
|
||
<RadarDetail
|
||
initialMetric={row.metric_kind}
|
||
query={{
|
||
sectorType: row.sector_type,
|
||
sectorCode: row.sector_code,
|
||
tradeDate: row.trade_date,
|
||
}}
|
||
/>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function RadarDetail({
|
||
query,
|
||
initialMetric,
|
||
}: {
|
||
query: RadarSectorQuery
|
||
initialMetric: RadarMetricKind
|
||
}) {
|
||
const detail = useSectorRadarDetail(query)
|
||
if (detail.isPending) return <p role="status">正在加载板块详情…</p>
|
||
if (detail.isError)
|
||
return (
|
||
<div role="alert">
|
||
板块详情加载失败。
|
||
<Button onClick={() => void detail.refetch()} variant="outline">
|
||
重试详情
|
||
</Button>
|
||
</div>
|
||
)
|
||
if (!detail.data || detail.data.status === "no_data")
|
||
return <p>暂无板块详情数据。</p>
|
||
return <RadarDetailBody data={detail.data} initialMetric={initialMetric} />
|
||
}
|
||
|
||
function RadarDetailBody({
|
||
data,
|
||
initialMetric,
|
||
}: {
|
||
data: RadarDetailResponse
|
||
initialMetric: RadarMetricKind
|
||
}) {
|
||
const [metric, setMetric] = useState<RadarMetricKind>(initialMetric)
|
||
const [memberMetric, setMemberMetric] =
|
||
useState<RadarMemberMetric>("pct_change")
|
||
const [memberSide, setMemberSide] = useState<"top" | "bottom">("top")
|
||
const [exportStatus, setExportStatus] = useState("")
|
||
const leaders = data.leaders[memberMetric]
|
||
async function copyMembers() {
|
||
try {
|
||
await navigator.clipboard.writeText(
|
||
data.members
|
||
.map((member) => `${member.ts_code}\t${member.name}`)
|
||
.join("\n"),
|
||
)
|
||
setExportStatus(`已复制 ${data.members.length} 只成员。`)
|
||
} catch {
|
||
setExportStatus("复制失败,请使用导出成员。")
|
||
}
|
||
}
|
||
function exportMembers() {
|
||
const url = URL.createObjectURL(
|
||
new Blob(["\ufeff", radarMembersCsv(data.members)], {
|
||
type: "text/csv;charset=utf-8",
|
||
}),
|
||
)
|
||
const anchor = document.createElement("a")
|
||
anchor.href = url
|
||
anchor.download = `${data.sector_code}-${data.trade_date}-members.csv`
|
||
anchor.click()
|
||
URL.revokeObjectURL(url)
|
||
setExportStatus(`已导出 ${data.members.length} 只成员。`)
|
||
}
|
||
return (
|
||
<div className="min-w-0 space-y-5">
|
||
<div className="flex flex-wrap justify-end gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!data.members.length}
|
||
onClick={() => void copyMembers()}
|
||
>
|
||
复制成员
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!data.members.length}
|
||
onClick={exportMembers}
|
||
>
|
||
导出成员
|
||
</Button>
|
||
</div>
|
||
{exportStatus ? <p role="status">{exportStatus}</p> : null}
|
||
<div className="flex flex-wrap gap-x-6 gap-y-2 rounded-md bg-muted/50 p-3">
|
||
<p>实际交易日:{data.trade_date ?? "—"}</p>
|
||
<p>
|
||
当日涨跌幅:
|
||
<span className={radarValueTone(data.pct_change)}>
|
||
{formatRadarValue(data.pct_change, "percent")}
|
||
</span>
|
||
</p>
|
||
<p>
|
||
领涨股:
|
||
{data.leading_stock
|
||
? `${data.leading_stock.name ?? ""} ${data.leading_stock.ts_code}`
|
||
: "暂无数据"}
|
||
</p>
|
||
</div>
|
||
<section aria-label="三指标排名" className="space-y-3">
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{(["swing", "ratio", "amount"] as const).map((key) => (
|
||
<button
|
||
type="button"
|
||
key={key}
|
||
onClick={() => setMetric(key)}
|
||
aria-pressed={metric === key}
|
||
className={`min-w-0 rounded-md border p-2 text-left break-words sm:p-3 ${metric === key ? "border-primary bg-primary/5" : "border-border"}`}
|
||
>
|
||
<span className="block text-xs text-muted-foreground">
|
||
{metricLabels[key]}
|
||
</span>
|
||
<span className="mt-1 block font-semibold">
|
||
{data.summary[key].missing ||
|
||
data.summary[key].rank_position === null
|
||
? "暂无排名"
|
||
: `第 ${data.summary[key].rank_position} 名 / ${data.summary[key].pool_size}`}
|
||
</span>
|
||
<span className={radarValueTone(data.summary[key].metric_value)}>
|
||
{formatRadarValue(
|
||
data.summary[key].metric_value,
|
||
key === "amount" ? "CNY_100M" : "ratio",
|
||
)}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{data.history.points.some(
|
||
(point) =>
|
||
!point[metric].missing && point[metric].rank_position !== null,
|
||
) ? (
|
||
<Suspense fallback={<p role="status">正在加载排名曲线…</p>}>
|
||
<RadarRankChart
|
||
points={data.history.points}
|
||
metric={metric}
|
||
label={metricLabels[metric]}
|
||
/>
|
||
</Suspense>
|
||
) : (
|
||
<p className="rounded-md bg-muted/30 p-4 text-muted-foreground">
|
||
暂无{metricLabels[metric]}排名轨迹,当前历史记录均缺失。
|
||
</p>
|
||
)}
|
||
<RadarHistoryGrid history={data.history} metric={metric} />
|
||
</section>
|
||
<section aria-label="成分强弱" className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="mr-auto font-medium">成分强弱</h3>
|
||
{(Object.keys(memberLabels) as RadarMemberMetric[]).map((key) => (
|
||
<Button
|
||
key={key}
|
||
variant={key === memberMetric ? "default" : "outline"}
|
||
size="sm"
|
||
aria-pressed={key === memberMetric}
|
||
onClick={() => setMemberMetric(key)}
|
||
>
|
||
{memberLabels[key]}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<div aria-label="成分排名方向" className="flex justify-end gap-1">
|
||
{(["top", "bottom"] as const).map((side) => (
|
||
<Button
|
||
key={side}
|
||
size="sm"
|
||
variant={memberSide === side ? "default" : "outline"}
|
||
aria-pressed={memberSide === side}
|
||
onClick={() => setMemberSide(side)}
|
||
>
|
||
{side === "top" ? "前 5" : "后 5"}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<div>
|
||
{[memberSide].map((side) => (
|
||
<div key={side} className="rounded-md border border-border p-3">
|
||
<h4 className="mb-2 font-medium">
|
||
{side === "top" ? "前 5 名" : "后 5 名"}
|
||
</h4>
|
||
{leaders[side].length ? (
|
||
<ol className="space-y-2">
|
||
{leaders[side].map((member) => (
|
||
<li
|
||
key={member.ts_code}
|
||
className="flex justify-between gap-3"
|
||
>
|
||
<span>
|
||
{member.name}{" "}
|
||
<span className="text-xs text-muted-foreground">
|
||
{member.ts_code}
|
||
</span>
|
||
</span>
|
||
<span
|
||
className={`tabular-nums ${radarValueTone(member[memberMetric])}`}
|
||
>
|
||
{formatRadarValue(
|
||
member[memberMetric],
|
||
memberMetric === "pct_change" ? "percent" : "yuan",
|
||
)}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
) : (
|
||
<p className="text-muted-foreground">暂无有效数据</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
仅展示有该指标的成员,不足 5
|
||
只按实际数量显示。主买净额与主力净额为不同来源指标。
|
||
</p>
|
||
</section>
|
||
<section aria-label="相似板块" className="space-y-2">
|
||
<h3 className="font-medium">相似板块</h3>
|
||
<p className="text-xs text-muted-foreground">
|
||
本系统口径:同日可确认成员交集 / 并集(Jaccard 重合度)。
|
||
</p>
|
||
{data.similar_sectors.length ? (
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
{data.similar_sectors.map((sector) => (
|
||
<div
|
||
className="flex justify-between rounded-md border border-border p-3"
|
||
key={`${sector.sector_type}:${sector.sector_code}`}
|
||
>
|
||
<span>
|
||
{sector.sector_name}{" "}
|
||
<span className="text-xs text-muted-foreground">
|
||
{sector.sector_type === "concept" ? "概念" : "行业"}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
{(sector.overlap_ratio * 100).toFixed(1)}%{" "}
|
||
<span className="text-xs text-muted-foreground">
|
||
({sector.intersection_count}/{sector.union_count})
|
||
</span>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-muted-foreground">暂无相似板块数据</p>
|
||
)}
|
||
</section>
|
||
<section aria-label="板块成员" className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="mr-auto font-medium">
|
||
板块成员({data.members.length})
|
||
</h3>
|
||
</div>
|
||
<div className="max-h-44 overflow-auto rounded-md border border-border p-3">
|
||
<p className="leading-7 text-muted-foreground">
|
||
{data.members.length
|
||
? data.members
|
||
.map((member) => `${member.name} (${member.ts_code})`)
|
||
.join("、")
|
||
: "暂无成员数据"}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|