astock-agent/frontend/src/app/(auth)/sectors/page.tsx
2026-04-23 17:59:43 +08:00

783 lines
32 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.

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