astock-agent/frontend/src/app/(auth)/sectors/[name]/page.tsx
2026-06-02 11:25:08 +08:00

260 lines
12 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 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>
);
}