"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 (
{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();
}