260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useParams } from "next/navigation";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { fetchAPI, type SectorDetailResponse, type SectorMemberCandidate } from "@/lib/api";
|
||
import { formatNumber } from "@/lib/utils";
|
||
|
||
type MemberFilter = "all" | "actionable" | "watch" | "observe";
|
||
|
||
function stageLabel(stage?: string) {
|
||
const map: Record<string, string> = { early: "启动期", mid: "发展期", late: "后期", end: "尾声", intraday: "盘中" };
|
||
return map[stage || ""] || "未分层";
|
||
}
|
||
|
||
function signalLabel(signal?: string) {
|
||
const map: Record<string, string> = {
|
||
breakout: "突破",
|
||
breakout_confirm: "确认",
|
||
pullback: "回踩",
|
||
launch: "启动",
|
||
reversal: "反转",
|
||
flow_momentum: "资金",
|
||
none: "无信号",
|
||
};
|
||
return map[signal || ""] || signal || "无信号";
|
||
}
|
||
|
||
function actionTone(action: string) {
|
||
if (action === "可操作") return "border-red-500/15 bg-red-500/[0.06] text-red-300";
|
||
if (action === "重点关注") return "border-amber-500/15 bg-amber-500/[0.07] text-amber-300";
|
||
return "border-border-subtle bg-surface-2 text-text-muted";
|
||
}
|
||
|
||
export default function SectorDetailPage() {
|
||
const params = useParams<{ name: string }>();
|
||
const sectorName = decodeURIComponent(params.name || "");
|
||
const [data, setData] = useState<SectorDetailResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [filter, setFilter] = useState<MemberFilter>("all");
|
||
|
||
const loadData = useCallback(async () => {
|
||
if (!sectorName) return;
|
||
setLoading(true);
|
||
try {
|
||
const result = await fetchAPI<SectorDetailResponse>(`/api/sectors/${encodeURIComponent(sectorName)}/detail`);
|
||
setData(result);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [sectorName]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
const sector = data?.sector;
|
||
const displayPct = sector ? (sector.realtime_pct_change ?? sector.pct_change) : 0;
|
||
const displayAmount = sector ? (sector.realtime_amount ?? sector.capital_inflow) : 0;
|
||
const displayLimitUp = sector ? (sector.realtime_limit_up_count ?? sector.limit_up_count) : 0;
|
||
|
||
const filteredMembers = useMemo(() => {
|
||
const members = data?.members || [];
|
||
if (filter === "actionable") return members.filter((item) => item.action_plan === "可操作");
|
||
if (filter === "watch") return members.filter((item) => item.action_plan === "重点关注");
|
||
if (filter === "observe") return members.filter((item) => item.action_plan === "观察");
|
||
return members;
|
||
}, [data, filter]);
|
||
|
||
const reasonCounts = useMemo(() => {
|
||
const counts: Record<string, number> = {};
|
||
(data?.members || []).forEach((item) => {
|
||
(item.elimination_reason || "待确认").split(";").forEach((reason) => {
|
||
if (!reason) return;
|
||
counts[reason] = (counts[reason] || 0) + 1;
|
||
});
|
||
});
|
||
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||
}, [data]);
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||
<Link href="/sectors" className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors">
|
||
← 返回板块主线
|
||
</Link>
|
||
|
||
<header className="glass-card-static p-4 md:p-5">
|
||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||
<div>
|
||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Theme Desk</div>
|
||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">{sector?.sector_name || sectorName}</h1>
|
||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">
|
||
{sector?.catalyst_reasons?.[0] || "最近扫描沉淀的板块成分候选,按主题、资金、角色和入场时机排序。"}
|
||
</p>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Badge>{stageLabel(sector?.stage)}</Badge>
|
||
<Badge>扫描 {data?.scan_session || "-"}</Badge>
|
||
<Badge>{sector?.data_mode || "local"}</Badge>
|
||
{(sector?.theme_aliases || []).slice(0, 4).map((alias) => <Badge key={alias}>{alias}</Badge>)}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Metric label="涨跌幅" value={`${displayPct > 0 ? "+" : ""}${displayPct.toFixed(2)}%`} tone={displayPct >= 0 ? "up" : "down"} />
|
||
<Metric label="热度" value={sector?.heat_score?.toFixed(0) || "-"} />
|
||
<Metric label="资金" value={`${displayAmount >= 0 ? "+" : ""}${formatNumber(displayAmount)}`} tone={displayAmount >= 0 ? "up" : "down"} />
|
||
<Metric label="涨停" value={`${displayLimitUp}只`} />
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||
<Summary label="候选成分" value={data?.summary.member_count ?? 0} />
|
||
<Summary label="可操作" value={data?.summary.actionable_count ?? 0} tone="red" />
|
||
<Summary label="重点关注" value={data?.summary.watch_count ?? 0} tone="amber" />
|
||
<Summary label="观察" value={data?.summary.observe_count ?? 0} />
|
||
<Summary label="均分" value={data?.summary.avg_score ?? 0} tone="amber" />
|
||
</section>
|
||
|
||
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[280px_1fr]">
|
||
<aside className="space-y-4">
|
||
<div className="glass-card-static p-4">
|
||
<h2 className="text-sm font-semibold text-text-primary">快速筛选</h2>
|
||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||
{[
|
||
{ key: "all", label: "全部", count: data?.members.length || 0 },
|
||
{ key: "actionable", label: "可操作", count: data?.summary.actionable_count || 0 },
|
||
{ key: "watch", label: "关注", count: data?.summary.watch_count || 0 },
|
||
{ key: "observe", label: "观察", count: data?.summary.observe_count || 0 },
|
||
].map((item) => (
|
||
<button
|
||
key={item.key}
|
||
onClick={() => setFilter(item.key as MemberFilter)}
|
||
className={`rounded-xl border px-3 py-2 text-left transition-all ${
|
||
filter === item.key
|
||
? "border-amber-500/20 bg-amber-500/[0.07] text-amber-400"
|
||
: "border-border-subtle bg-surface-2 text-text-muted hover:text-text-secondary"
|
||
}`}
|
||
>
|
||
<div className="text-xs font-semibold">{item.label}</div>
|
||
<div className="mt-1 font-mono text-[11px] tabular-nums">{item.count}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="glass-card-static p-4">
|
||
<h2 className="text-sm font-semibold text-text-primary">过滤原因</h2>
|
||
<div className="mt-3 space-y-2">
|
||
{reasonCounts.length ? reasonCounts.map(([reason, count]) => (
|
||
<div key={reason} className="flex items-center justify-between gap-3 rounded-lg bg-surface-2 px-3 py-2">
|
||
<span className="truncate text-[11px] text-text-muted">{reason}</span>
|
||
<span className="font-mono text-xs tabular-nums text-text-secondary">{count}</span>
|
||
</div>
|
||
)) : <div className="text-xs text-text-muted">暂无记录</div>}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="glass-card-static overflow-hidden">
|
||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||
<h2 className="text-sm font-semibold text-text-primary">成分候选</h2>
|
||
<button onClick={loadData} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary">
|
||
刷新
|
||
</button>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="space-y-3 p-4">
|
||
{[1, 2, 3, 4].map((item) => <div key={item} className="h-24 animate-shimmer rounded-xl bg-surface-2" />)}
|
||
</div>
|
||
) : filteredMembers.length === 0 ? (
|
||
<div className="p-10 text-center text-sm text-text-muted">暂无成分候选记录</div>
|
||
) : (
|
||
<div className="divide-y divide-border-subtle">
|
||
{filteredMembers.map((member) => <MemberRow key={member.ts_code} member={member} />)}
|
||
</div>
|
||
)}
|
||
</main>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MemberRow({ member }: { member: SectorMemberCandidate }) {
|
||
return (
|
||
<article className="grid gap-4 px-4 py-4 xl:grid-cols-[180px_88px_1fr_300px] xl:items-center">
|
||
<div className="min-w-0">
|
||
<Link href={`/stock/${member.ts_code}`} className="truncate text-sm font-semibold text-text-primary hover:text-amber-300">
|
||
{member.name}
|
||
</Link>
|
||
<div className="mt-1 font-mono text-[10px] text-text-muted">{member.ts_code}</div>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
<span className={`rounded-md border px-2 py-0.5 text-[10px] ${actionTone(member.action_plan)}`}>{member.action_plan}</span>
|
||
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{member.stock_role || "候选"}</span>
|
||
</div>
|
||
</div>
|
||
<div className="font-mono text-2xl font-bold tabular-nums text-amber-400">{member.final_score.toFixed(1)}</div>
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap gap-2">
|
||
<SmallStat label="主力" value={`${formatNumber(member.main_net_inflow)}`} />
|
||
<SmallStat label="流入比" value={`${Number(member.inflow_ratio || 0).toFixed(1)}%`} />
|
||
<SmallStat label="换手" value={`${Number(member.turnover_rate || 0).toFixed(1)}%`} />
|
||
<SmallStat label="信号" value={signalLabel(member.entry_signal_type)} />
|
||
</div>
|
||
<div className="mt-2 text-xs leading-5 text-text-muted line-clamp-2">
|
||
{member.action_plan === "观察" ? member.elimination_reason || "等待资金和时机确认" : member.trigger_condition || "等待触发条件"}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-5 gap-1.5">
|
||
<Score label="催化" value={member.catalyst_score} />
|
||
<Score label="题材" value={member.theme_money_score} />
|
||
<Score label="资金" value={member.stock_money_score} />
|
||
<Score label="角色" value={member.emotion_role_score} />
|
||
<Score label="时机" value={member.timing_score} />
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function Badge({ children }: { children: React.ReactNode }) {
|
||
return <span className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">{children}</span>;
|
||
}
|
||
|
||
function Metric({ label, value, tone = "default" }: { label: string; value: string; tone?: "default" | "up" | "down" }) {
|
||
const color = tone === "up" ? "text-red-400" : tone === "down" ? "text-emerald-400" : "text-text-primary";
|
||
return (
|
||
<div className="rounded-xl bg-surface-1 px-3 py-3">
|
||
<div className="text-[10px] text-text-muted">{label}</div>
|
||
<div className={`mt-1 font-mono text-sm font-bold tabular-nums ${color}`}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Summary({ label, value, tone = "default" }: { label: string; value: string | number; tone?: "default" | "red" | "amber" }) {
|
||
const color = tone === "red" ? "text-red-400" : tone === "amber" ? "text-amber-400" : "text-text-primary";
|
||
return (
|
||
<div className="glass-card-static p-4">
|
||
<div className="text-[10px] text-text-muted">{label}</div>
|
||
<div className={`mt-2 font-mono text-xl font-bold tabular-nums ${color}`}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SmallStat({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<span className="rounded-lg bg-surface-2 px-2 py-1 text-[10px] text-text-secondary">
|
||
<span className="text-text-muted">{label}</span> {value}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function Score({ label, value }: { label: string; value: number }) {
|
||
return (
|
||
<div className="rounded-lg bg-surface-2 px-2 py-1.5 text-center">
|
||
<div className="text-[9px] text-text-muted/60">{label}</div>
|
||
<div className="mt-0.5 font-mono text-[11px] font-semibold tabular-nums text-text-secondary">{Math.round(value)}</div>
|
||
</div>
|
||
);
|
||
}
|