"use client"; import { useEffect, useState, useCallback, useMemo } from "react"; import { fetchAPI } from "@/lib/api"; import type { SectorData, LeadingStock, SectorRotationData } from "@/lib/api"; import { formatNumber } from "@/lib/utils"; import { useWebSocket } from "@/hooks/use-websocket"; import { ErrorBoundary } from "@/components/error-boundary"; function getStageInfo(stage: string) { switch (stage) { case "early": return { label: "启动期", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/15", barColor: "bg-emerald-500/60" }; case "mid": return { label: "发展期", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15", barColor: "bg-amber-500/60" }; case "late": return { label: "后期", color: "text-orange-400", bg: "bg-orange-500/10 border-orange-500/15", barColor: "bg-orange-500/60" }; case "end": return { label: "尾声", color: "text-red-400", bg: "bg-red-500/10 border-red-500/15", barColor: "bg-red-500/60" }; default: return { label: "—", color: "text-text-muted", bg: "bg-surface-2 border-border-default", barColor: "bg-surface-3" }; } } function getOpportunityHint(stage: string, mainForceRatio?: number): string { switch (stage) { case "early": return "关注启动强度"; case "mid": return (mainForceRatio ?? 0) > 30 ? "关注回流确认" : "观察资金动向"; case "late": return "注意高位分化"; case "end": return "以观望为主"; default: return ""; } } function normalizeValues(values: number[]): number[] { if (!values.length) return []; const min = Math.min(...values); const max = Math.max(...values); if (max === min) return values.map(() => 50); return values.map(v => (v - min) / (max - min) * 100); } /** 迷你柱状图:近5日涨跌幅 */ function MiniBarChart({ data }: { data: number[] }) { if (!data.length) return null; const maxAbs = Math.max(...data.map(Math.abs), 0.1); return (
{data.map((v, i) => { const h = Math.max(Math.abs(v) / maxAbs * 100, 8); return (
= 0 ? "bg-red-500/50" : "bg-emerald-500/50"}`} style={{ height: `${h}%` }} />
); })}
); } /** 领涨股标签 */ function LeadingStockTag({ stock }: { stock: LeadingStock }) { const isLimitUp = stock.pct_chg >= 9.8; return ( {stock.name} 0 ? "text-red-400/80" : "text-emerald-400/80"}`}> {stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg}% {stock.limit_times != null && stock.limit_times > 1 && ( {stock.limit_times}连板 )} ); } function HeatScoreDots({ scores: [pct, cap, lim, con] }: { scores: [number, number, number, number] }) { const dots = [ { score: pct, label: "涨幅" }, { score: cap, label: "资金" }, { score: lim, label: "涨停" }, { score: con, label: "连续" }, ]; return (
{dots.map((d) => ( = 60 ? "bg-amber-400" : "bg-text-muted/30"}`} title={`${d.label}因子: ${d.score.toFixed(0)}`} /> ))}
); } function SectorDetailCard({ sector, index, factorScores }: { sector: SectorData; index: number; factorScores?: [number, number, number, number]; }) { const displayPct = sector.realtime_pct_change ?? sector.pct_change; const isUp = displayPct > 0; const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count; const displayAmount = sector.realtime_amount ?? sector.capital_inflow; const displayTurnover = sector.realtime_turnover_rate ?? sector.turnover_avg ?? 0; const displayUpCount = sector.realtime_up_count; const displayDownCount = sector.realtime_down_count; const leaders = sector.is_realtime ? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks) : sector.leading_stocks; const stage = getStageInfo(sector.stage ?? ""); const isTop3 = index < 3; const cumulativePct = sector.pct_trend ? sector.pct_trend.reduce((sum, v) => sum + v, 0) : sector.pct_change; const hint = getOpportunityHint(sector.stage ?? "", sector.main_force_ratio); const mainForceRatio = sector.main_force_ratio ?? 0; return (
{/* Stage color bar at top */}
{/* Header row */}
{index + 1}
{sector.sector_name} {sector.is_realtime && ( 实时 )}
{stage.label !== "—" && ( <> {stage.label} · 连{sector.days_continuous}天 · 0 ? "text-red-400/60" : "text-emerald-400/60"}`}> 累计{cumulativePct > 0 ? "+" : ""}{cumulativePct.toFixed(1)}% )} {stage.label === "—" && sector.member_count && `${sector.member_count}只成分股`}
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
{/* Opportunity hint */} {hint && (
{hint}
)} {/* Metrics row - 4 columns */}
{sector.is_realtime ? "实时成交额" : "资金净流入"}
0 ? "text-red-400" : "text-emerald-400"}`}> {displayAmount > 0 ? "+" : ""}{formatNumber(displayAmount)}
{sector.is_realtime ? "上涨/下跌" : "涨停股"}
{sector.is_realtime && displayUpCount != null && displayDownCount != null ? (
{displayUpCount} / {displayDownCount}
) : (
{displayLimitUp}
)}
{sector.is_realtime ? "实时换手" : "主力占比"}
{sector.is_realtime ? (
{displayTurnover.toFixed(1)}%
) : (
30 ? "text-amber-400" : mainForceRatio < 0 ? "text-red-400" : "text-text-secondary" }`}> {mainForceRatio.toFixed(1)}%
)}
热度评分
= 70 ? "text-amber-400" : sector.heat_score >= 50 ? "text-text-secondary" : "text-text-muted" }`}> {sector.heat_score.toFixed(0)} /100 {factorScores && }
{/* 5日趋势图 */} {sector.pct_trend && sector.pct_trend.length > 1 && (
近5日走势
{sector.pct_trend.map((v, i) => ( = 0 ? "text-red-400/60" : "text-emerald-400/60"}`}> {v > 0 ? "+" : ""}{v.toFixed(1)} ))}
)} {/* 领涨股 */} {leaders && leaders.length > 0 && (
领涨股
{leaders.map((s) => ( ))}
)}
); } /** 今日关注 - Top 3 摘要 */ function FocusSummary({ sectors }: { sectors: SectorData[] }) { const top3 = sectors.slice(0, 3); if (!top3.length) return null; return (

今日关注

{top3.map((sector, i) => { const displayPct = sector.realtime_pct_change ?? sector.pct_change; const isUp = displayPct > 0; const stage = getStageInfo(sector.stage ?? ""); const hint = getOpportunityHint(sector.stage ?? "", sector.main_force_ratio); const mainForceRatio = sector.main_force_ratio ?? 0; return (
{i + 1} {sector.sector_name}
{stage.label} {displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
{hint}
0 ? "text-red-400/80" : "text-emerald-400/80"}`}> 资金{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)} {mainForceRatio !== 0 && ( 30 ? "text-amber-400/80" : "text-text-muted/60"}`}> 主力{mainForceRatio.toFixed(1)}% )} {(sector.realtime_limit_up_count ?? sector.limit_up_count) > 0 && ( 涨停{(sector.realtime_limit_up_count ?? sector.limit_up_count)}只 )}
); })}
); } export default function SectorsPage() { const [sectors, setSectors] = useState([]); const [showRotation, setShowRotation] = useState(false); const [rotationData, setRotationData] = useState(null); const [stageFilter, setStageFilter] = useState("all"); const loadData = useCallback(async () => { try { const data = await fetchAPI("/api/sectors/hot?limit=20"); setSectors(data); } catch { // ignore } }, []); useEffect(() => { loadData(); }, [loadData]); useWebSocket( useCallback(() => { loadData(); }, [loadData]) ); const hasRealtime = sectors.some((s) => s.is_realtime); const structureTradeDate = sectors[0]?.structure_trade_date || sectors[0]?.trade_date || ""; const dataMode = sectors[0]?.data_mode || "daily_snapshot"; const loadRotation = useCallback(async () => { try { const data = await fetchAPI("/api/sectors/rotation?days=5"); setRotationData(data); } catch { // ignore } }, []); useEffect(() => { if (showRotation && !rotationData) { loadRotation(); } }, [showRotation, rotationData, loadRotation]); // Compute factor scores for heat score visualization const factorScoresMap = useMemo(() => { if (!sectors.length) return new Map(); const pctScores = normalizeValues(sectors.map(s => s.realtime_pct_change ?? s.pct_change)); const capScores = normalizeValues(sectors.map(s => s.capital_inflow)); const limScores = normalizeValues(sectors.map(s => s.realtime_limit_up_count ?? s.limit_up_count)); const conScores = normalizeValues(sectors.map(s => s.days_continuous)); const map = new Map(); sectors.forEach((s, i) => { map.set(s.sector_code, [pctScores[i], capScores[i], limScores[i], conScores[i]]); }); return map; }, [sectors]); // Stage filter counts const stageCounts = useMemo(() => { const counts = { all: sectors.length, early: 0, mid: 0, late_end: 0 }; sectors.forEach(s => { if (s.stage === "early") counts.early++; else if (s.stage === "mid") counts.mid++; else if (s.stage === "late" || s.stage === "end") counts.late_end++; }); return counts; }, [sectors]); const filteredSectors = useMemo(() => { if (stageFilter === "all") return sectors; if (stageFilter === "late_end") return sectors.filter(s => s.stage === "late" || s.stage === "end"); return sectors.filter(s => s.stage === stageFilter); }, [sectors, stageFilter]); const sectorBuckets = useMemo(() => { const mainline = sectors.slice(0, 3); const secondary = sectors.slice(3, 8); const watchlist = sectors.filter((sector) => ["late", "end"].includes(sector.stage ?? "")).slice(0, 4); return { mainline, secondary, watchlist }; }, [sectors]); return (

板块主线

判断当前主线、板块阶段、资金持续性和领涨股强度 {hasRealtime && · 今日实时优先}

{hasRealtime && dataMode === "realtime_today" && (

当前使用东方财富今日板块榜,涨幅、成交额、上涨/下跌家数与领涨股均为今日实时/收盘快照。

)} {hasRealtime && dataMode === "realtime_overlay" && (

盘中模式下,涨幅、成交额、上涨/下跌家数与领涨股为实时覆盖;阶段、资金连续性等结构字段仍基于 {structureTradeDate || "最近交易日"} 的板块快照。

)}
{!sectors.length ? (
暂无板块数据
触发扫描后自动更新
) : (
板块列表
先按阶段筛,再看每个板块的资金、阶段和领涨股结构。
{[ { key: "all", label: "全部", count: stageCounts.all }, { key: "early", label: "启动期", count: stageCounts.early }, { key: "mid", label: "发展期", count: stageCounts.mid }, { key: "late_end", label: "后期/尾声", count: stageCounts.late_end }, ].map(tab => ( ))}
{!filteredSectors.length ? (
当前筛选下无板块数据
) : (
{filteredSectors.map((sector) => { const originalIndex = sectors.findIndex(s => s.sector_code === sector.sector_code); return ( ); })}
)}
)}
); } function MainlineCommandDeck({ mainline, secondary, watchlist, }: { mainline: SectorData[]; secondary: SectorData[]; watchlist: SectorData[]; }) { return (
Sector Command

主线不是排行榜,是今天的方向分层

主线决定是否值得参与,次主线决定是否做轮动,观察线只保留跟踪,不抢跑。

); } function SectorLane({ title, description, tone, sectors, }: { title: string; description: string; tone: "red" | "amber" | "slate"; sectors: SectorData[]; }) { const toneClass = tone === "red" ? "border-red-500/15 bg-red-500/[0.04] text-red-400" : tone === "amber" ? "border-amber-500/15 bg-amber-500/[0.04] text-amber-400" : "border-border-subtle bg-surface-1 text-text-secondary"; return (

{title}

{description}

{sectors.length}
{sectors.length ? (
{sectors.map((sector) => ( ))}
) : (
暂无数据
)}
); } function SectorLaneRow({ sector }: { sector: SectorData }) { const stage = getStageInfo(sector.stage ?? ""); const leaders = sector.is_realtime ? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks) : sector.leading_stocks; const leadText = leaders?.slice(0, 2).map((stock) => stock.name).join(" / ") || "暂无代表股"; const action = getSectorAction(sector); const displayPct = sector.realtime_pct_change ?? sector.pct_change; return (
{sector.sector_name} {stage.label}
代表股:{leadText}
= 0 ? "text-red-400" : "text-emerald-400"}`}> {displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
{action.label}
参与建议:{action.advice}
失效观察:{action.risk}
); } function getSectorAction(sector: SectorData) { const stage = sector.stage ?? ""; const mainForce = sector.main_force_ratio ?? 0; const pct = sector.realtime_pct_change ?? sector.pct_change; if (stage === "early") { return { label: "可进攻", advice: "盯龙头和首个分歧回流,优先轻仓试错。", risk: "若涨停家数不扩散或龙头走弱,立刻降级观察。", }; } if (stage === "mid" && mainForce > 20 && pct > 0) { return { label: "回流参与", advice: "等分歧后的承接确认,不追情绪顶点。", risk: "若主力占比回落或板块跟风掉队,停止追击。", }; } return { label: "只观察", advice: "只保留跟踪,不作为当前主仓位方向。", risk: "若主线切换或板块退潮,及时移出观察列表。", }; } function SectorRotationChart({ data }: { data: SectorRotationData }) { const [el, setEl] = useState(null); const { theme } = useNextTheme(); useEffect(() => { if (!el || !data.sectors.length) return; let chart: ReturnType | null = null; import("echarts").then((ec) => { if (!el) return; const isDark = theme !== "light"; chart = ec.init(el, isDark ? "dark" : undefined); const isLight = theme === "light"; const axisLabelColor = isLight ? "#6b7280" : "#94a3b8"; const dates = data.dates.map((d) => d.slice(4)); const sectorNames = data.sectors.map((s) => s.sector_name); const heatData: [number, number, number][] = []; let minVal = Infinity; let maxVal = -Infinity; data.sectors.forEach((sector, yi) => { dates.forEach((_, xi) => { const dayData = sector.daily_data.find((d) => data.dates[xi] && d.trade_date === data.dates[xi]); const val = dayData?.pct_change ?? 0; heatData.push([xi, yi, val]); if (val < minVal) minVal = val; if (val > maxVal) maxVal = val; }); }); chart.setOption({ backgroundColor: "transparent", tooltip: { formatter: (params: { data: number[] }) => { const [x, y, val] = params.data; return `${sectorNames[y]}
${dates[x]}: ${val > 0 ? "+" : ""}${val.toFixed(2)}%`; }, }, grid: { left: "15%", right: "5%", top: "5%", bottom: "12%" }, xAxis: { type: "category", data: dates, splitArea: { show: true }, axisLabel: { fontSize: 10, color: axisLabelColor }, }, yAxis: { type: "category", data: sectorNames, axisLabel: { fontSize: 10, color: axisLabelColor, width: 60, overflow: "truncate" }, }, visualMap: { min: minVal, max: maxVal, calculable: true, orient: "horizontal", left: "center", bottom: 0, inRange: { color: ["#22c55e", "#fbbf24", "#ef4444"], }, textStyle: { fontSize: 10, color: axisLabelColor }, }, series: [{ type: "heatmap", data: heatData, label: { show: true, fontSize: 9, formatter: (params: { data: number[] }) => { const val = params.data[2]; return val > 0 ? `+${val.toFixed(1)}` : val.toFixed(1); }, }, }], }); const handleResize = () => chart?.resize(); window.addEventListener("resize", handleResize); }); return () => { chart?.dispose(); }; }, [data, theme, el]); return (

近{data.dates.length}日板块轮动

); } function useNextTheme() { // eslint-disable-next-line @typescript-eslint/no-require-imports const { useTheme } = require("next-themes"); return useTheme(); }