astock-agent/frontend/src/app/(auth)/sectors/page.tsx
2026-06-10 08:36:25 +08:00

685 lines
28 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 { 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>
);
}