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>
|
|||
|
|
)
|
|||
|
|
}
|