783 lines
32 KiB
TypeScript
783 lines
32 KiB
TypeScript
"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 (
|
||
<div className="flex items-end gap-1 h-8">
|
||
{data.map((v, i) => {
|
||
const h = Math.max(Math.abs(v) / maxAbs * 100, 8);
|
||
return (
|
||
<div key={i} className="flex-1 flex flex-col justify-end items-center" style={{ height: "100%" }}>
|
||
<div
|
||
className={`w-full rounded-sm transition-all duration-300 ${v >= 0 ? "bg-red-500/50" : "bg-emerald-500/50"}`}
|
||
style={{ height: `${h}%` }}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 领涨股标签 */
|
||
function LeadingStockTag({ stock }: { stock: LeadingStock }) {
|
||
const isLimitUp = stock.pct_chg >= 9.8;
|
||
return (
|
||
<a
|
||
href={`/stock/${stock.ts_code}`}
|
||
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-lg bg-surface-2 border border-border-default hover:bg-surface-4 transition-colors"
|
||
>
|
||
<span className="text-text-secondary font-medium">{stock.name}</span>
|
||
<span className={`font-mono tabular-nums ${isLimitUp ? "text-red-400 font-bold" : stock.pct_chg > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
|
||
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg}%
|
||
</span>
|
||
{stock.limit_times != null && stock.limit_times > 1 && (
|
||
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/15 text-red-400 font-bold">
|
||
{stock.limit_times}连板
|
||
</span>
|
||
)}
|
||
</a>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex items-center gap-1">
|
||
{dots.map((d) => (
|
||
<span
|
||
key={d.label}
|
||
className={`w-1.5 h-1.5 rounded-full ${d.score >= 60 ? "bg-amber-400" : "bg-text-muted/30"}`}
|
||
title={`${d.label}因子: ${d.score.toFixed(0)}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className="glass-card animate-fade-in-up overflow-hidden"
|
||
style={{ animationDelay: `${index * 60}ms` }}
|
||
>
|
||
{/* Stage color bar at top */}
|
||
<div className={`h-1 ${stage.barColor}`} />
|
||
|
||
<div className="p-5">
|
||
{/* Header row */}
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div className="flex items-center gap-2.5">
|
||
<span className={`w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${
|
||
index === 0
|
||
? "bg-gradient-to-br from-amber-500/30 to-amber-600/20 text-amber-400 border border-amber-500/20"
|
||
: index === 1
|
||
? "bg-gradient-to-br from-slate-400/20 to-slate-500/15 text-slate-300 border border-slate-400/15"
|
||
: index === 2
|
||
? "bg-gradient-to-br from-amber-700/20 to-amber-800/15 text-amber-400 border border-amber-600/15"
|
||
: "bg-surface-2 text-text-muted border border-border-subtle"
|
||
}`}>
|
||
{index + 1}
|
||
</span>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className={`text-sm font-semibold ${isTop3 ? "text-text-primary" : "text-text-secondary"}`}>
|
||
{sector.sector_name}
|
||
</span>
|
||
{sector.is_realtime && (
|
||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400/80 border border-emerald-500/15">
|
||
实时
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-[11px] text-text-muted/60 mt-0.5">
|
||
{stage.label !== "—" && (
|
||
<>
|
||
<span className={stage.color}>{stage.label}</span>
|
||
<span className="text-text-muted/30 mx-1">·</span>
|
||
<span>连{sector.days_continuous}天</span>
|
||
<span className="text-text-muted/30 mx-1">·</span>
|
||
<span className={`font-mono tabular-nums ${cumulativePct > 0 ? "text-red-400/60" : "text-emerald-400/60"}`}>
|
||
累计{cumulativePct > 0 ? "+" : ""}{cumulativePct.toFixed(1)}%
|
||
</span>
|
||
</>
|
||
)}
|
||
{stage.label === "—" && sector.member_count && `${sector.member_count}只成分股`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className={`text-base font-bold font-mono tabular-nums ${isUp ? "text-red-400" : "text-emerald-400"}`}>
|
||
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Opportunity hint */}
|
||
{hint && (
|
||
<div className={`text-[11px] mb-3 px-2.5 py-1.5 rounded-lg ${stage.bg} ${stage.color}`}>
|
||
{hint}
|
||
</div>
|
||
)}
|
||
|
||
{/* Metrics row - 4 columns */}
|
||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
||
<div className="text-[10px] text-text-muted/50 mb-0.5">{sector.is_realtime ? "实时成交额" : "资金净流入"}</div>
|
||
<div className={`text-xs font-mono tabular-nums font-semibold ${displayAmount > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||
{displayAmount > 0 ? "+" : ""}{formatNumber(displayAmount)}
|
||
</div>
|
||
</div>
|
||
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
||
<div className="text-[10px] text-text-muted/50 mb-0.5">{sector.is_realtime ? "上涨/下跌" : "涨停股"}</div>
|
||
{sector.is_realtime && displayUpCount != null && displayDownCount != null ? (
|
||
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
|
||
{displayUpCount}<span className="text-text-muted/40 text-[10px]"> / </span>{displayDownCount}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
|
||
{displayLimitUp}<span className="text-text-muted/40 text-[10px]"> 只</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
||
<div className="text-[10px] text-text-muted/50 mb-0.5">{sector.is_realtime ? "实时换手" : "主力占比"}</div>
|
||
{sector.is_realtime ? (
|
||
<div className="text-xs font-mono tabular-nums font-semibold text-text-secondary">
|
||
{displayTurnover.toFixed(1)}<span className="text-text-muted/40 text-[10px]">%</span>
|
||
</div>
|
||
) : (
|
||
<div className={`text-xs font-mono tabular-nums font-semibold ${
|
||
mainForceRatio > 30 ? "text-amber-400" : mainForceRatio < 0 ? "text-red-400" : "text-text-secondary"
|
||
}`}>
|
||
{mainForceRatio.toFixed(1)}<span className="text-text-muted/40 text-[10px]">%</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="bg-surface-1 rounded-lg px-2.5 py-2">
|
||
<div className="text-[10px] text-text-muted/50 mb-0.5">热度评分</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className={`text-xs font-mono tabular-nums font-semibold ${
|
||
sector.heat_score >= 70 ? "text-amber-400" : sector.heat_score >= 50 ? "text-text-secondary" : "text-text-muted"
|
||
}`}>
|
||
{sector.heat_score.toFixed(0)}
|
||
<span className="text-text-muted/40 text-[10px]">/100</span>
|
||
</span>
|
||
{factorScores && <HeatScoreDots scores={factorScores} />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 5日趋势图 */}
|
||
{sector.pct_trend && sector.pct_trend.length > 1 && (
|
||
<div className="mb-3">
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<span className="text-[10px] text-text-muted/50">近5日走势</span>
|
||
<div className="flex gap-2">
|
||
{sector.pct_trend.map((v, i) => (
|
||
<span key={i} className={`text-[9px] font-mono tabular-nums ${v >= 0 ? "text-red-400/60" : "text-emerald-400/60"}`}>
|
||
{v > 0 ? "+" : ""}{v.toFixed(1)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<MiniBarChart data={sector.pct_trend} />
|
||
</div>
|
||
)}
|
||
|
||
{/* 领涨股 */}
|
||
{leaders && leaders.length > 0 && (
|
||
<div>
|
||
<span className="text-[10px] text-text-muted/50 mb-1.5 block">领涨股</span>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{leaders.map((s) => (
|
||
<LeadingStockTag key={s.ts_code} stock={s} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 今日关注 - Top 3 摘要 */
|
||
function FocusSummary({ sectors }: { sectors: SectorData[] }) {
|
||
const top3 = sectors.slice(0, 3);
|
||
if (!top3.length) return null;
|
||
|
||
return (
|
||
<div className="glass-card-static p-4 animate-fade-in-up mb-4">
|
||
<h2 className="text-xs font-semibold text-amber-400 uppercase tracking-wider mb-3">
|
||
今日关注
|
||
</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-1 gap-3">
|
||
{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 (
|
||
<div key={sector.sector_code} className={`rounded-xl p-3 border ${stage.bg}`}>
|
||
<div className="flex flex-wrap items-start justify-between gap-2 mb-1.5">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<span className={`w-5 h-5 rounded-md flex items-center justify-center text-[10px] font-bold ${
|
||
i === 0 ? "bg-amber-500/20 text-amber-400" : i === 1 ? "bg-slate-400/15 text-slate-300" : "bg-amber-700/15 text-amber-400/80"
|
||
}`}>
|
||
{i + 1}
|
||
</span>
|
||
<span className="min-w-0 break-words text-sm font-semibold text-text-primary">
|
||
{sector.sector_name}
|
||
</span>
|
||
</div>
|
||
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-1.5">
|
||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md border font-medium ${stage.bg} ${stage.color}`}>
|
||
{stage.label}
|
||
</span>
|
||
<span className={`text-sm font-bold font-mono tabular-nums ${isUp ? "text-red-400" : "text-emerald-400"}`}>
|
||
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className={`text-[11px] ${stage.color} mb-2`}>
|
||
{hint}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[11px]">
|
||
<span className={`font-mono tabular-nums ${sector.capital_inflow > 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
|
||
资金{sector.capital_inflow > 0 ? "+" : ""}{formatNumber(sector.capital_inflow)}
|
||
</span>
|
||
{mainForceRatio !== 0 && (
|
||
<span className={`font-mono tabular-nums ${mainForceRatio > 30 ? "text-amber-400/80" : "text-text-muted/60"}`}>
|
||
主力{mainForceRatio.toFixed(1)}%
|
||
</span>
|
||
)}
|
||
{(sector.realtime_limit_up_count ?? sector.limit_up_count) > 0 && (
|
||
<span className="font-mono tabular-nums text-red-400/80">
|
||
涨停{(sector.realtime_limit_up_count ?? sector.limit_up_count)}只
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function SectorsPage() {
|
||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||
const [showRotation, setShowRotation] = useState(false);
|
||
const [rotationData, setRotationData] = useState<SectorRotationData | null>(null);
|
||
const [stageFilter, setStageFilter] = useState<string>("all");
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const data = await fetchAPI<SectorData[]>("/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<SectorRotationData>("/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<string, [number, number, number, number]>();
|
||
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<string, [number, number, number, number]>();
|
||
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 (
|
||
<ErrorBoundary>
|
||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||
<div className="animate-fade-in-up mb-5">
|
||
<h1 className="text-lg font-bold tracking-tight">板块主线</h1>
|
||
<p className="text-xs text-text-muted mt-0.5">
|
||
判断当前主线、板块阶段、资金持续性和领涨股强度
|
||
{hasRealtime && <span className="text-emerald-400/60 ml-1">· 今日实时优先</span>}
|
||
</p>
|
||
{hasRealtime && dataMode === "realtime_today" && (
|
||
<p className="text-[11px] text-text-muted/70 mt-2">
|
||
当前使用东方财富今日板块榜,涨幅、成交额、上涨/下跌家数与领涨股均为今日实时/收盘快照。
|
||
</p>
|
||
)}
|
||
{hasRealtime && dataMode === "realtime_overlay" && (
|
||
<p className="text-[11px] text-text-muted/70 mt-2">
|
||
盘中模式下,涨幅、成交额、上涨/下跌家数与领涨股为实时覆盖;阶段、资金连续性等结构字段仍基于
|
||
<span className="text-text-secondary"> {structureTradeDate || "最近交易日"} </span>
|
||
的板块快照。
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{!sectors.length ? (
|
||
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||
<div className="text-text-muted text-sm mb-1">暂无板块数据</div>
|
||
<div className="text-text-muted/50 text-xs">触发扫描后自动更新</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.35fr)_340px] gap-5">
|
||
<section className="space-y-4">
|
||
<MainlineCommandDeck
|
||
mainline={sectorBuckets.mainline}
|
||
secondary={sectorBuckets.secondary}
|
||
watchlist={sectorBuckets.watchlist}
|
||
/>
|
||
|
||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||
<div>
|
||
<div className="text-[10px] uppercase tracking-[0.22em] text-text-muted font-semibold">板块列表</div>
|
||
<div className="text-sm text-text-secondary mt-1">先按阶段筛,再看每个板块的资金、阶段和领涨股结构。</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{[
|
||
{ 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 => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setStageFilter(tab.key)}
|
||
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-all ${
|
||
stageFilter === tab.key
|
||
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/15"
|
||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
<span className="ml-1 text-[10px] text-text-muted/50">{tab.count}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{!filteredSectors.length ? (
|
||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">
|
||
当前筛选下无板块数据
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[1200px] overflow-y-auto pr-1">
|
||
{filteredSectors.map((sector) => {
|
||
const originalIndex = sectors.findIndex(s => s.sector_code === sector.sector_code);
|
||
return (
|
||
<SectorDetailCard
|
||
key={sector.sector_code}
|
||
sector={sector}
|
||
index={originalIndex}
|
||
factorScores={factorScoresMap.get(sector.sector_code)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<aside className="space-y-4">
|
||
<FocusSummary sectors={sectors} />
|
||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">轮动观察</div>
|
||
<div className="text-sm text-text-secondary mt-1">轮动图收纳在侧栏,避免首屏被大图挤占。</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowRotation(!showRotation)}
|
||
className={`text-xs px-3 py-2 rounded-xl font-medium transition-all ${
|
||
showRotation
|
||
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
|
||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||
}`}
|
||
>
|
||
{showRotation ? "收起" : "展开"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{showRotation ? (
|
||
rotationData && rotationData.sectors.length > 0 ? (
|
||
<SectorRotationChart data={rotationData} />
|
||
) : (
|
||
<div className="glass-card-static p-8 text-center">
|
||
<div className="w-6 h-6 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-2" />
|
||
<div className="text-xs text-text-muted">加载轮动数据...</div>
|
||
</div>
|
||
)
|
||
) : null}
|
||
</aside>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|
||
|
||
function MainlineCommandDeck({
|
||
mainline,
|
||
secondary,
|
||
watchlist,
|
||
}: {
|
||
mainline: SectorData[];
|
||
secondary: SectorData[];
|
||
watchlist: SectorData[];
|
||
}) {
|
||
return (
|
||
<div className="glass-card-static p-5 animate-fade-in-up mb-4 overflow-hidden relative">
|
||
<div className="absolute right-[-120px] top-[-120px] w-72 h-72 rounded-full bg-amber-500/[0.045] blur-3xl pointer-events-none" />
|
||
<div className="relative">
|
||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold mb-2">Sector Command</div>
|
||
<h2 className="text-xl font-bold tracking-tight">主线不是排行榜,是今天的方向分层</h2>
|
||
<p className="text-sm text-text-secondary leading-relaxed mt-2">
|
||
主线决定是否值得参与,次主线决定是否做轮动,观察线只保留跟踪,不抢跑。
|
||
</p>
|
||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3 mt-4">
|
||
<SectorLane title="今日主线" description="可以围绕龙头和回流机会制定计划" tone="red" sectors={mainline} />
|
||
<SectorLane title="次主线" description="可跟踪轮动,不宜当主仓位方向" tone="amber" sectors={secondary} />
|
||
<SectorLane title="观察线" description="只看变化,不在当前阶段主动追击" tone="slate" sectors={watchlist} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded-2xl bg-surface-1/70 border border-border-subtle p-4">
|
||
<div className="flex items-center justify-between gap-3 mb-2">
|
||
<div>
|
||
<h3 className="text-sm font-bold tracking-tight">{title}</h3>
|
||
<p className="text-[11px] text-text-muted mt-0.5">{description}</p>
|
||
</div>
|
||
<span className={`text-xs font-mono tabular-nums rounded-lg border px-2 py-1 ${toneClass}`}>
|
||
{sectors.length}
|
||
</span>
|
||
</div>
|
||
{sectors.length ? (
|
||
<div className="space-y-2.5 mt-3">
|
||
{sectors.map((sector) => (
|
||
<SectorLaneRow key={sector.sector_code} sector={sector} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-text-muted mt-3">暂无数据</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded-xl bg-surface-2/70 border border-border-subtle px-3 py-2.5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-semibold truncate">{sector.sector_name}</span>
|
||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md border ${stage.bg} ${stage.color}`}>
|
||
{stage.label}
|
||
</span>
|
||
</div>
|
||
<div className="text-[11px] text-text-muted mt-0.5 line-clamp-1">
|
||
代表股:{leadText}
|
||
</div>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<div className={`text-sm font-bold font-mono tabular-nums ${displayPct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
|
||
</div>
|
||
<div className="text-[10px] text-text-muted">{action.label}</div>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 mt-2 text-[11px]">
|
||
<div className="rounded-lg bg-surface-1 px-2 py-1.5 text-text-secondary">
|
||
参与建议:{action.advice}
|
||
</div>
|
||
<div className="rounded-lg bg-surface-1 px-2 py-1.5 text-text-secondary">
|
||
失效观察:{action.risk}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement | null>(null);
|
||
const { theme } = useNextTheme();
|
||
|
||
useEffect(() => {
|
||
if (!el || !data.sectors.length) return;
|
||
|
||
let chart: ReturnType<typeof import("echarts")["init"]> | 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]}<br/>${dates[x]}: <b>${val > 0 ? "+" : ""}${val.toFixed(2)}%</b>`;
|
||
},
|
||
},
|
||
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 (
|
||
<div className="glass-card-static p-4">
|
||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||
近{data.dates.length}日板块轮动
|
||
</h2>
|
||
<div ref={setEl} className="w-full" style={{ height: Math.max(data.sectors.length * 28 + 60, 200) }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function useNextTheme() {
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const { useTheme } = require("next-themes");
|
||
return useTheme();
|
||
}
|