685 lines
28 KiB
TypeScript
685 lines
28 KiB
TypeScript
"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 (
|
||
<a
|
||
href={`/stock/${stock.ts_code}`}
|
||
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[11px] transition-colors hover:border-amber-500/20"
|
||
>
|
||
<span className="text-text-secondary font-medium">{stock.name}</span>
|
||
<span className={`font-mono tabular-nums ${stock.pct_chg >= 0 ? "text-red-400/80" : "text-emerald-400/80"}`}>
|
||
{stock.pct_chg > 0 ? "+" : ""}{stock.pct_chg.toFixed(2)}%
|
||
</span>
|
||
</a>
|
||
);
|
||
}
|
||
|
||
function DecisionList({
|
||
title,
|
||
items,
|
||
tone,
|
||
}: {
|
||
title: string;
|
||
items: string[];
|
||
tone: "positive" | "risk";
|
||
}) {
|
||
const dotClass = tone === "positive" ? "bg-emerald-400" : "bg-amber-400";
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||
<div className="text-[11px] font-semibold text-text-secondary">{title}</div>
|
||
<div className="mt-2 space-y-2">
|
||
{items.map((item, index) => (
|
||
<div key={`${title}-${index}`} className="flex items-start gap-2 text-sm text-text-secondary">
|
||
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${dotClass}`} />
|
||
<span className="leading-6">{item}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h3 className="text-sm font-bold tracking-tight text-text-primary">{title}</h3>
|
||
<p className="mt-1 text-[11px] text-text-muted">{description}</p>
|
||
</div>
|
||
<span className={`rounded-lg border px-2 py-1 text-xs font-mono tabular-nums ${toneClass}`}>
|
||
{sectors.length}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-2.5">
|
||
{sectors.length ? sectors.map((sector, index) => <LaneRow key={sector.sector_code} sector={sector} rank={index + 1} />) : (
|
||
<div className="text-xs text-text-muted">暂无数据</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Link href={`/sectors/${encodeURIComponent(sector.sector_name)}`} className="block rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-3 transition-colors hover:border-amber-500/20 hover:bg-surface-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex gap-3">
|
||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-surface-1 font-mono text-xs text-text-muted">
|
||
{rank}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-sm font-semibold text-text-primary">{sector.sector_name}</span>
|
||
{sector.board_type === "theme" ? (
|
||
<span className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
|
||
主题
|
||
</span>
|
||
) : null}
|
||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
|
||
{stage.label}
|
||
</span>
|
||
{catalyst ? (
|
||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${catalyst.className}`}>
|
||
{catalyst.label}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-text-muted">
|
||
{getThemeAliasLine(sector)}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-text-muted">
|
||
代表股:{leaders.length ? leaders.map((item) => item.name).join(" / ") : "暂无"}
|
||
</div>
|
||
</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="mt-1 text-[10px] text-text-muted">{action.label}</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px]">
|
||
<div className="rounded-lg bg-surface-1 px-2 py-1.5 text-text-secondary">
|
||
跟踪方式:{action.description}
|
||
</div>
|
||
<div className="rounded-lg bg-surface-1 px-2 py-1.5 text-text-secondary">
|
||
失效信号:{action.risk}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-3 animate-fade-in-up transition-colors hover:border-amber-500/20 hover:bg-surface-2/70" style={{ animationDelay: `${index * 30}ms` }}>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<Link href={`/sectors/${encodeURIComponent(sector.sector_name)}`} className="text-sm font-semibold text-text-primary hover:text-amber-300">
|
||
{sector.sector_name}
|
||
</Link>
|
||
{sector.board_type === "theme" ? (
|
||
<span className="rounded-md border border-sky-500/15 bg-sky-500/10 px-1.5 py-0.5 text-[10px] text-sky-300">
|
||
主题
|
||
</span>
|
||
) : null}
|
||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${stage.bg} ${stage.color}`}>
|
||
{stage.label}
|
||
</span>
|
||
{catalyst ? (
|
||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] ${catalyst.className}`}>
|
||
{catalyst.label}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-text-muted">
|
||
{action.label} · {action.description}
|
||
</div>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<div className={`text-base font-bold font-mono tabular-nums ${displayPct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||
{displayPct > 0 ? "+" : ""}{displayPct.toFixed(2)}%
|
||
</div>
|
||
<div className="mt-1 text-[10px] text-text-muted">热度 {sector.heat_score.toFixed(0)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||
<MetricBox label="资金">
|
||
<span className={`font-mono tabular-nums ${displayAmount >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||
{displayAmount >= 0 ? "+" : ""}{formatNumber(displayAmount)}
|
||
</span>
|
||
</MetricBox>
|
||
<MetricBox label="涨停">
|
||
<span className="font-mono tabular-nums text-text-secondary">{displayLimitUp}只</span>
|
||
</MetricBox>
|
||
<MetricBox label="主力">
|
||
<span className="font-mono tabular-nums text-text-secondary">{(sector.main_force_ratio ?? 0).toFixed(1)}%</span>
|
||
</MetricBox>
|
||
</div>
|
||
|
||
{catalystReason ? (
|
||
<div className="mt-3 rounded-xl border border-amber-500/15 bg-amber-500/[0.06] px-3 py-2">
|
||
<div className="text-[10px] uppercase tracking-wider text-amber-300/80 font-semibold">新闻/政策催化</div>
|
||
<div className="mt-1 text-[12px] leading-6 text-text-secondary">
|
||
{catalystReason}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-3 rounded-xl bg-surface-2/70 px-3 py-2">
|
||
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold">失效信号</div>
|
||
<div className="mt-1 text-[12px] leading-5 text-text-secondary">
|
||
{action.risk}
|
||
</div>
|
||
</div>
|
||
|
||
<Link href={`/sectors/${encodeURIComponent(sector.sector_name)}`} className="mt-3 inline-flex rounded-lg border border-amber-500/15 bg-amber-500/[0.05] px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-500/[0.08]">
|
||
查看成分候选
|
||
</Link>
|
||
|
||
{leaders.length ? (
|
||
<div className="mt-3">
|
||
<div className="text-[10px] uppercase tracking-wider text-text-muted font-semibold mb-2">代表股</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{leaders.map((stock) => <LeadingStockPill key={stock.ts_code} stock={stock} />)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetricBox({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="rounded-lg bg-surface-1 px-2.5 py-2">
|
||
<div className="text-[10px] text-text-muted/50 mb-0.5">{label}</div>
|
||
<div className="text-xs font-semibold">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<section className="glass-card-static overflow-hidden animate-fade-in-up">
|
||
<div className="flex flex-col gap-3 border-b border-border-subtle px-4 py-4 md:flex-row md:items-end md:justify-between md:px-5">
|
||
<div>
|
||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Industry Chain</div>
|
||
<h3 className="mt-1 text-base font-bold text-text-primary">产业链扩散路径</h3>
|
||
<p className="mt-1 text-xs leading-5 text-text-muted">从主题到环节,再到核心股和相关股。</p>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2 text-right">
|
||
<MatrixMetric label="主题" value={themes.length} />
|
||
<MatrixMetric label="环节" value={nodeCount} />
|
||
<MatrixMetric label="标的" value={mappedStockCount} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="divide-y divide-border-subtle">
|
||
{themes.map((theme) => (
|
||
<ThemeChainRow key={`${theme.theme}-${theme.raw_sector}`} theme={theme} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ThemeChainRow({ theme }: { theme: ResearchReport["theme_views"][number] }) {
|
||
const chainItems = normalizedChainItems(theme);
|
||
|
||
return (
|
||
<article className="grid gap-4 px-4 py-4 md:px-5 xl:grid-cols-[230px_minmax(0,1fr)]">
|
||
<div className="min-w-0">
|
||
<div className="flex items-start justify-between gap-3 xl:block">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-semibold text-text-primary">{theme.theme}</div>
|
||
<div className="mt-1 text-[11px] text-text-muted">
|
||
{theme.lifecycle_status || theme.stage} · {theme.raw_sector || theme.theme}
|
||
</div>
|
||
</div>
|
||
<div className="font-mono text-base font-semibold tabular-nums text-amber-400 xl:mt-3">
|
||
{Math.round(theme.heat_score)}
|
||
</div>
|
||
</div>
|
||
<p className="mt-3 line-clamp-3 text-xs leading-5 text-text-secondary">{theme.logic}</p>
|
||
</div>
|
||
|
||
<div className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
|
||
{chainItems.slice(0, 9).map((item) => (
|
||
<ChainNodeBlock key={`${theme.theme}-${item.chain_node}`} item={item} />
|
||
))}
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded-xl border border-border-subtle bg-surface-1/60 px-3 py-3">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="truncate text-xs font-semibold text-text-primary">{item.chain_node}</div>
|
||
{item.node_role ? (
|
||
<span className="shrink-0 rounded-md border border-amber-500/15 bg-amber-500/[0.06] px-1.5 py-0.5 text-[10px] text-amber-300">
|
||
{item.node_role}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{hasStocks ? (
|
||
<div className="mt-3 space-y-2">
|
||
{leaders.length ? <StockStrip label="核心" stocks={leaders} tone="core" /> : null}
|
||
{related.length ? <StockStrip label="相关" stocks={related} tone="related" /> : null}
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 rounded-lg bg-surface-2/45 px-2 py-2 text-[11px] text-text-muted">
|
||
待维护标的
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StockStrip({
|
||
label,
|
||
stocks,
|
||
tone,
|
||
}: {
|
||
label: string;
|
||
stocks: ThemeChainItem["leader_stocks"];
|
||
tone: "core" | "related";
|
||
}) {
|
||
return (
|
||
<div>
|
||
<div className={`mb-1 text-[10px] font-semibold ${tone === "core" ? "text-red-300" : "text-text-muted"}`}>{label}</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{stocks.map((stock, index) => <StockTag key={`${stockLabel(stock)}-${index}`} stock={stock} tone={tone} />)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Link href={`/stock/${code}`} className={`rounded-lg border px-2 py-1 text-[10px] transition-colors hover:border-amber-500/20 hover:text-amber-300 ${className}`}>
|
||
{label}
|
||
</Link>
|
||
);
|
||
}
|
||
return <span className={`rounded-lg border px-2 py-1 text-[10px] ${className}`}>{label}</span>;
|
||
}
|
||
|
||
function MatrixMetric({ label, value }: { label: string; value: number }) {
|
||
return (
|
||
<div className="rounded-lg bg-surface-2/60 px-3 py-2">
|
||
<div className="text-[10px] text-text-muted">{label}</div>
|
||
<div className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<SectorData[]>([]);
|
||
const [research, setResearch] = useState<ResearchReport | null>(null);
|
||
const [stageFilter, setStageFilter] = useState<string>("all");
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const [data, researchData] = await Promise.all([
|
||
fetchAPI<SectorData[]>("/api/sectors/hot?limit=20"),
|
||
fetchAPI<ResearchReport>("/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 (
|
||
<ErrorBoundary>
|
||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||
<div className="animate-fade-in-up">
|
||
<h1 className="text-lg font-bold tracking-tight">主题图谱</h1>
|
||
<p className="mt-1 text-xs text-text-muted">从板块强度升级到主题、产业链环节和前排公司。</p>
|
||
</div>
|
||
|
||
{!sectors.length ? (
|
||
<div className="glass-card-static p-12 text-center animate-fade-in-up">
|
||
<div className="text-sm text-text-muted">暂无主线结论</div>
|
||
<div className="mt-1 text-xs text-text-muted/50">等下一次复盘后再看。</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4">
|
||
<div>
|
||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">今日主线结论</div>
|
||
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{summary.title}</h2>
|
||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{summary.detail}</p>
|
||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<DecisionList title="动作" items={summary.canDo} tone="positive" />
|
||
<DecisionList title="边界" items={summary.avoid} tone="risk" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2 self-start">
|
||
<MetricBox label="主线数"><span className="font-mono tabular-nums text-text-primary">{mainline.length}</span></MetricBox>
|
||
<MetricBox label="次主线"><span className="font-mono tabular-nums text-text-primary">{secondary.length}</span></MetricBox>
|
||
<MetricBox label="观察线"><span className="font-mono tabular-nums text-text-primary">{watchline.length}</span></MetricBox>
|
||
<MetricBox label="强催化"><span className="font-mono tabular-nums text-amber-400">{sectors.filter((sector) => (sector.catalyst_score ?? 0) >= 70).length}</span></MetricBox>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{research?.theme_views?.length ? <IndustryChainMatrix themes={research.theme_views.slice(0, 6)} /> : null}
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-[1.15fr_0.95fr_0.8fr] gap-4 animate-fade-in-up">
|
||
<LaneCard
|
||
title={hasPositiveLeader ? "今日主线" : "相对抗跌"}
|
||
description={hasPositiveLeader ? "优先方向" : "暂不进攻"}
|
||
sectors={mainline}
|
||
tone="red"
|
||
/>
|
||
<LaneCard
|
||
title={hasPositiveLeader ? "次主线" : "弱轮动观察"}
|
||
description={hasPositiveLeader ? "轮动跟踪" : "转强观察"}
|
||
sectors={secondary}
|
||
tone="amber"
|
||
/>
|
||
<LaneCard
|
||
title="观察线"
|
||
description="变化记录"
|
||
sectors={watchline}
|
||
tone="slate"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-4 animate-fade-in-up">
|
||
<div className="glass-card-static p-4 md:p-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-sm font-semibold text-text-primary">方向清单</h2>
|
||
</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={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-all ${
|
||
stageFilter === tab.key
|
||
? "border-amber-500/20 bg-amber-500/[0.06] text-amber-400"
|
||
: "border-transparent bg-surface-2 text-text-muted hover:text-text-secondary"
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
<span className="ml-1 text-[10px] text-text-muted/50">{tab.count}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{!filteredSectors.length ? (
|
||
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">
|
||
当前筛选下无板块数据。
|
||
</div>
|
||
) : (
|
||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{filteredSectors.map((sector, index) => (
|
||
<SectorCard key={sector.sector_code} sector={sector} index={index} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="glass-card-static p-4 self-start">
|
||
<h2 className="text-sm font-semibold text-text-primary">怎么使用</h2>
|
||
<div className="mt-3 space-y-2 text-sm leading-6 text-text-secondary">
|
||
<div>先看今日主线,再看前排代表股。</div>
|
||
<div>只在回流、扩散和承接同时出现时提高优先级。</div>
|
||
<div>后期和尾声方向只做观察,不当作新主线追。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|