"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 (
{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 ? (
) : null}
查看成分候选
{leaders.length ? (
代表股
{leaders.map((stock) => )}
) : null}
);
}
function MetricBox({ label, children }: { label: string; children: React.ReactNode }) {
return (
);
}
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 (
);
}
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) => (
))}
)}
怎么使用
先看今日主线,再看前排代表股。
只在回流、扩散和承接同时出现时提高优先级。
后期和尾声方向只做观察,不当作新主线追。
>
)}
);
}