Files
zhixing-system/zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx
T
yuxuanhui 7f93d6b0f5 feat(sector_radar): enhance sector radar functionality with active moneyflow and detailed metrics
- 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.
2026-09-06 16:06:17 +08:00

332 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}