"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchAPI } from "@/lib/api"; import type { LeadingStock, ResearchReport, SectorData, ThemeChainItem } from "@/lib/api"; import { formatNumber } from "@/lib/utils"; import { ErrorBoundary } from "@/components/error-boundary"; import { useWebSocket } from "@/hooks/use-websocket"; import Link from "next/link"; function getThemeAliasLine(sector: SectorData) { const aliases = (sector.theme_aliases ?? []).filter((alias) => alias && alias !== sector.sector_name).slice(0, 4); if (!aliases.length) return "主题归类待补充"; return `包含:${aliases.join(" / ")}`; } function getStageInfo(stage: string) { switch (stage) { case "early": return { label: "启动期", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/15" }; case "mid": return { label: "发展期", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/15" }; case "late": return { label: "后期", color: "text-orange-400", bg: "bg-orange-500/10 border-orange-500/15" }; case "end": return { label: "尾声", color: "text-red-400", bg: "bg-red-500/10 border-red-500/15" }; default: return { label: "未分层", color: "text-text-muted", bg: "bg-surface-2 border-border-subtle" }; } } function getActionPlan(sector: SectorData) { const stage = sector.stage ?? ""; const pct = sector.realtime_pct_change ?? sector.pct_change; const mainForce = sector.main_force_ratio ?? 0; if (pct <= 0) { return { label: "抗跌观察", description: "等待止跌转强。", risk: "继续走弱或前排补跌。", }; } if (stage === "early") { return { label: "优先盯", description: "龙头和分歧回流。", risk: "无扩散、无回流、无承接。", }; } if (stage === "mid" && pct > 0 && mainForce > 20) { return { label: "跟回流", description: "等待确认。", risk: "内部掉队。", }; } return { label: "只观察", description: "保留跟踪。", risk: "退潮加速。", }; } function getLeaders(sector: SectorData): LeadingStock[] { return sector.is_realtime ? (sector.leading_stocks_realtime?.length ? sector.leading_stocks_realtime : sector.leading_stocks) ?? [] : sector.leading_stocks ?? []; } function getCatalystLabel(sector: SectorData) { const score = sector.catalyst_score ?? 0; if (score >= 70) return { label: `强催化 ${score.toFixed(0)}`, className: "border-red-500/15 bg-red-500/10 text-red-300" }; if (score >= 45) return { label: `有催化 ${score.toFixed(0)}`, className: "border-amber-500/15 bg-amber-500/10 text-amber-300" }; if ((sector.catalyst_count ?? 0) > 0) return { label: `催化 ${score.toFixed(0)}`, className: "border-border-subtle bg-surface-2 text-text-secondary" }; return null; } function getHeadline(sectors: SectorData[]) { const primary = sectors[0]; const secondary = sectors[1]; const watch = sectors.find((sector) => ["late", "end"].includes(sector.stage ?? "")); if (!primary) { return { title: "暂无主线数据", detail: "等待新的主线结论后再判断。", canDo: ["等待主线结论更新。"], avoid: ["不做情绪追涨。"], }; } const primaryPlan = getActionPlan(primary); const secondaryName = secondary ? secondary.sector_name : "暂无"; const watchName = watch ? watch.sector_name : "暂无"; const primaryPct = primary.realtime_pct_change ?? primary.pct_change; const hasPositiveLeader = primaryPct > 0; return { title: hasPositiveLeader ? `优先盯 ${primary.sector_name}` : `${primary.sector_name} 相对抗跌`, detail: hasPositiveLeader ? `次主线 ${secondaryName},观察线 ${watchName}。` : `${primary.sector_name}、${secondaryName} 暂按抗跌观察。`, canDo: hasPositiveLeader ? [ `${primary.sector_name}:${primaryPlan.description}`, secondary ? `${secondary.sector_name}:次主线观察。` : "暂无次主线。", "看前排、广度、回流。", ] : [ `看 ${primary.sector_name} 是否止跌转强。`, secondary ? `${secondary.sector_name}:备选抗跌。` : "暂无次主线。", "确认翻红、扩散、承接。", ], avoid: hasPositiveLeader ? [ "不把后排补涨当主线。", "不只看涨幅。", watch ? `${watch.sector_name}:观察线。` : "后期板块只观察。", ] : [ "不把负涨幅当主线。", "不因抗跌重仓进攻。", "不只盯排名第一。", ], }; } function LeadingStockPill({ stock }: { stock: LeadingStock }) { return ( {stock.name} = 0 ? "text-red-400/80" : "text-emerald-400/80"}`}> {stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg.toFixed(2)}% ); } function DecisionList({ title, items, tone, }: { title: string; items: string[]; tone: "positive" | "risk"; }) { const dotClass = tone === "positive" ? "bg-emerald-400" : "bg-amber-400"; return (
{title}
{items.map((item, index) => (
{item}
))}
); } function LaneCard({ title, description, sectors, tone, }: { title: string; description: string; sectors: SectorData[]; tone: "red" | "amber" | "slate"; }) { 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-2 text-text-secondary"; return (

{title}

{description}

{sectors.length}
{sectors.length ? sectors.map((sector, index) => ) : (
暂无数据
)}
); } function LaneRow({ sector, rank }: { sector: SectorData; rank: number }) { const displayPct = sector.realtime_pct_change ?? sector.pct_change; const stage = getStageInfo(sector.stage ?? ""); const action = getActionPlan(sector); const leaders = getLeaders(sector).slice(0, 2); const catalyst = getCatalystLabel(sector); return (
{rank}
{sector.sector_name} {sector.board_type === "theme" ? ( 主题 ) : null} {stage.label} {catalyst ? ( {catalyst.label} ) : null}
{getThemeAliasLine(sector)}
代表股:{leaders.length ? leaders.map((item) => item.name).join(" / ") : "暂无"}
= 0 ? "text-red-400" : "text-emerald-400"}`}> {displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
{action.label}
跟踪方式:{action.description}
失效信号:{action.risk}
); } function SectorCard({ sector, index }: { sector: SectorData; index: number }) { const displayPct = sector.realtime_pct_change ?? sector.pct_change; const displayAmount = sector.realtime_amount ?? sector.capital_inflow; const displayLimitUp = sector.realtime_limit_up_count ?? sector.limit_up_count; const stage = getStageInfo(sector.stage ?? ""); const leaders = getLeaders(sector).slice(0, 3); const action = getActionPlan(sector); const catalyst = getCatalystLabel(sector); const catalystReason = sector.catalyst_reasons?.[0]; return (
{sector.sector_name} {sector.board_type === "theme" ? ( 主题 ) : null} {stage.label} {catalyst ? ( {catalyst.label} ) : null}
{action.label} · {action.description}
= 0 ? "text-red-400" : "text-emerald-400"}`}> {displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
热度 {sector.heat_score.toFixed(0)}
= 0 ? "text-red-400" : "text-emerald-400"}`}> {displayAmount >= 0 ? "+" : ""}{formatNumber(displayAmount)} {displayLimitUp}只 {(sector.main_force_ratio ?? 0).toFixed(1)}%
{catalystReason ? (
新闻/政策催化
{catalystReason}
) : null}
失效信号
{action.risk}
查看成分候选 {leaders.length ? (
代表股
{leaders.map((stock) => )}
) : null}
); } function MetricBox({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } function IndustryChainMatrix({ themes }: { themes: ResearchReport["theme_views"] }) { const nodeCount = themes.reduce((sum, theme) => sum + normalizedChainItems(theme).length, 0); const mappedStockCount = themes.reduce( (sum, theme) => sum + normalizedChainItems(theme).reduce((nodeSum, node) => nodeSum + node.leader_stocks.length + node.related_stocks.length, 0), 0 ); return (
Industry Chain

产业链扩散路径

从主题到环节,再到核心股和相关股。

{themes.map((theme) => ( ))}
); } function ThemeChainRow({ theme }: { theme: ResearchReport["theme_views"][number] }) { const chainItems = normalizedChainItems(theme); return (
{theme.theme}
{theme.lifecycle_status || theme.stage} · {theme.raw_sector || theme.theme}
{Math.round(theme.heat_score)}

{theme.logic}

{chainItems.slice(0, 9).map((item) => ( ))}
); } function ChainNodeBlock({ item }: { item: ThemeChainItem }) { const leaders = item.leader_stocks.slice(0, 4); const related = item.related_stocks.slice(0, 4); const hasStocks = leaders.length || related.length; return (
{item.chain_node}
{item.node_role ? ( {item.node_role} ) : null}
{hasStocks ? (
{leaders.length ? : null} {related.length ? : null}
) : (
待维护标的
)}
); } function StockStrip({ label, stocks, tone, }: { label: string; stocks: ThemeChainItem["leader_stocks"]; tone: "core" | "related"; }) { return (
{label}
{stocks.map((stock, index) => )}
); } function StockTag({ stock, tone }: { stock: string | { name?: string; ts_code?: string }; tone: "core" | "related" }) { const label = stockLabel(stock); const code = typeof stock === "string" ? "" : stock.ts_code || ""; const className = tone === "core" ? "border-red-500/15 bg-red-500/[0.06] text-red-200" : "border-border-subtle bg-surface-2/70 text-text-secondary"; if (code) { return ( {label} ); } return {label}; } function MatrixMetric({ label, value }: { label: string; value: number }) { return (
{label}
{value}
); } function normalizedChainItems(theme: ResearchReport["theme_views"][number]): ThemeChainItem[] { const explicitItems = (theme.chain_items ?? []).filter((item) => item.chain_node); if (explicitItems.length) return explicitItems; return theme.chain_nodes.map((node) => ({ chain_node: node, leader_stocks: [], related_stocks: [], node_role: "", })); } function stockLabel(stock: string | { name?: string; ts_code?: string }) { if (typeof stock === "string") return stock; return stock.name || stock.ts_code || "-"; } export default function SectorsPage() { const [sectors, setSectors] = useState([]); const [research, setResearch] = useState(null); const [stageFilter, setStageFilter] = useState("all"); const loadData = useCallback(async () => { try { const [data, researchData] = await Promise.all([ fetchAPI("/api/sectors/hot?limit=20"), fetchAPI("/api/research/today").catch(() => null), ]); setSectors(data); setResearch(researchData); } catch { // ignore } }, []); useEffect(() => { loadData(); }, [loadData]); useWebSocket( useCallback(() => { loadData(); }, [loadData]) ); const summary = getHeadline(sectors); const topPct = sectors[0] ? (sectors[0].realtime_pct_change ?? sectors[0].pct_change) : 0; const hasPositiveLeader = topPct > 0; const stageCounts = useMemo(() => { const counts = { all: sectors.length, early: 0, mid: 0, late_end: 0 }; sectors.forEach((sector) => { if (sector.stage === "early") counts.early++; else if (sector.stage === "mid") counts.mid++; else if (sector.stage === "late" || sector.stage === "end") counts.late_end++; }); return counts; }, [sectors]); const filteredSectors = useMemo(() => { if (stageFilter === "all") return sectors; if (stageFilter === "late_end") return sectors.filter((sector) => sector.stage === "late" || sector.stage === "end"); return sectors.filter((sector) => sector.stage === stageFilter); }, [sectors, stageFilter]); const mainline = sectors.slice(0, 3); const secondary = sectors.slice(3, 6); const watchline = sectors.filter((sector) => ["late", "end"].includes(sector.stage ?? "")).slice(0, 4); return (

主题图谱

从板块强度升级到主题、产业链环节和前排公司。

{!sectors.length ? (
暂无主线结论
等下一次复盘后再看。
) : ( <>
今日主线结论

{summary.title}

{summary.detail}

{mainline.length} {secondary.length} {watchline.length} {sectors.filter((sector) => (sector.catalyst_score ?? 0) >= 70).length}
{research?.theme_views?.length ? : null}

方向清单

{[ { 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, index) => ( ))}
)}

怎么使用

先看今日主线,再看前排代表股。
只在回流、扩散和承接同时出现时提高优先级。
后期和尾声方向只做观察,不当作新主线追。
)}
); }