astock-agent/frontend/src/app/(auth)/stock/[code]/page.tsx
2026-04-16 21:30:52 +08:00

559 lines
21 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 { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { fetchAPI } from "@/lib/api";
import type { RecommendationData, DayGroup } from "@/lib/api";
import { getScoreColor } from "@/lib/utils";
import KlineChart from "@/components/kline-chart";
import CapitalFlowChart from "@/components/capital-flow";
import { ErrorBoundary } from "@/components/error-boundary";
interface StockSignals {
ts_code: string;
name: string;
score: number;
trend_score: number;
signal_count: number;
ma_bullish: boolean;
volume_breakout: boolean;
macd_golden: boolean;
rsi_healthy: boolean;
pullback_support: boolean;
big_yang: boolean;
boll_support: boolean;
support_price: number | null;
resist_price: number | null;
stop_loss_price: number | null;
rally_pct_5d: number;
rally_pct_10d: number;
distance_from_high: number;
position_score: number;
}
interface RecScore {
supply_demand_score: number;
price_action_score: number;
technical_score: number;
position_score: number;
score: number;
}
interface QuoteData {
ts_code: string;
name: string;
price: number;
pct_chg: number;
volume: number;
amount: number;
turnover_rate: number;
pe: number | null;
pb: number | null;
circ_mv: number | null;
total_mv: number | null;
volume_ratio: number | null;
high: number | null;
low: number | null;
open: number | null;
pre_close: number | null;
limit_up: number | null;
limit_down: number | null;
amplitude: number | null;
}
interface FlowRecord {
trade_date: string;
main_net_inflow: number;
net_mf_amount: number;
elg_net: number;
lg_net: number;
md_net: number;
sm_net: number;
}
export default function StockDetailPage() {
const params = useParams();
const code = params.code as string;
const [quote, setQuote] = useState<QuoteData | null>(null);
const [signals, setSignals] = useState<StockSignals | null>(null);
const [recScore, setRecScore] = useState<RecScore | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [kline, setKline] = useState<any[]>([]);
const [capitalFlow, setCapitalFlow] = useState<FlowRecord[]>([]);
useEffect(() => {
if (!code) return;
Promise.all([
fetchAPI<QuoteData>(`/api/stocks/${code}/quote`).catch(() => null),
fetchAPI<StockSignals>(`/api/stocks/${code}/signals`).catch(() => null),
fetchAPI<unknown[]>(`/api/stocks/${code}/kline?days=120`).catch(() => []),
fetchAPI<FlowRecord[]>(`/api/stocks/${code}/capital_flow?days=10`).catch(() => []),
]).then(([q, s, k, c]) => {
setQuote(q);
setSignals(s);
setKline(k);
setCapitalFlow(c as FlowRecord[]);
});
// 尝试从推荐历史中获取该股票的评分
fetchAPI<DayGroup[]>(`/api/recommendations/history?days=14`).then((history) => {
for (const group of history) {
const rec = group.recommendations?.find((r) => r.ts_code === code);
if (rec && rec.supply_demand_score) {
setRecScore({
supply_demand_score: rec.supply_demand_score,
price_action_score: rec.price_action_score ?? 0,
technical_score: rec.technical_score,
position_score: rec.position_score ?? 50,
score: rec.score,
});
break;
}
}
}).catch(() => null);
}, [code]);
const latestFlow = capitalFlow.length > 0 ? capitalFlow[capitalFlow.length - 1] : null;
return (
<ErrorBoundary>
<div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-4">
{/* Back */}
<a
href="/"
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-primary transition-colors animate-fade-in-up"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</a>
{/* Quote header */}
<div className="animate-fade-in-up">
{quote && (
<div className="glass-card-static p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className="text-lg font-bold tracking-tight">{quote.name}</span>
<span className="text-sm text-text-muted font-mono tabular-nums">{quote.ts_code}</span>
</div>
<a
href={`/diagnose?code=${code}`}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-lg hover:from-amber-500/30 hover:to-amber-600/25 transition-all border border-amber-500/10"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
AI
</a>
</div>
<div className="flex items-baseline gap-3">
<span
className={`text-3xl font-bold font-mono tabular-nums tracking-tight ${
quote.pct_chg > 0
? "text-red-400"
: quote.pct_chg < 0
? "text-emerald-400"
: "text-text-primary"
}`}
>
{quote.price.toFixed(2)}
</span>
<span
className={`text-sm font-mono tabular-nums font-medium ${
quote.pct_chg > 0 ? "text-red-400" : "text-emerald-400"
}`}
>
{quote.pct_chg > 0 ? "+" : ""}
{quote.pct_chg.toFixed(2)}%
</span>
{quote.pre_close && (
<span className="text-sm text-text-muted font-mono tabular-nums">
{quote.pre_close.toFixed(2)}
</span>
)}
</div>
{/* OHLC row */}
<div className="grid grid-cols-4 gap-3 mt-4">
<MiniStat
label="开盘"
value={quote.open && quote.open > 0 ? quote.open.toFixed(2) : "-"}
color={
quote.open && quote.open > 0 && quote.pre_close
? quote.open >= quote.pre_close
? "text-red-400"
: "text-emerald-400"
: undefined
}
/>
<MiniStat
label="最高"
value={quote.high && quote.high > 0 ? quote.high.toFixed(2) : "-"}
color="text-red-400"
/>
<MiniStat
label="最低"
value={quote.low && quote.low > 0 ? quote.low.toFixed(2) : "-"}
color="text-emerald-400"
/>
<MiniStat
label="振幅"
value={quote.amplitude != null ? `${quote.amplitude.toFixed(2)}%` : "-"}
/>
</div>
{/* Valuation row */}
<div className="grid grid-cols-4 gap-3 mt-3">
<MiniStat label="换手率" value={`${quote.turnover_rate?.toFixed(2)}%`} />
<MiniStat label="市盈率" value={quote.pe?.toFixed(1) ?? "-"} />
<MiniStat label="市净率" value={quote.pb?.toFixed(2) ?? "-"} />
<MiniStat
label="量比"
value={quote.volume_ratio?.toFixed(2) ?? "-"}
highlight={quote.volume_ratio != null && quote.volume_ratio > 2}
/>
</div>
{/* Market cap row */}
<div className="grid grid-cols-4 gap-3 mt-3">
<MiniStat
label="总市值"
value={quote.total_mv ? `${formatBigNum(quote.total_mv)}亿` : "-"}
/>
<MiniStat
label="流通市值"
value={quote.circ_mv ? `${formatBigNum(quote.circ_mv)}亿` : "-"}
/>
<MiniStat
label="涨停"
value={quote.limit_up?.toFixed(2) ?? "-"}
color="text-red-400"
/>
<MiniStat
label="跌停"
value={quote.limit_down?.toFixed(2) ?? "-"}
color="text-emerald-400"
/>
</div>
</div>
)}
</div>
{/* Position Safety + Capital Flow Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up delay-75">
{/* Position Safety Card */}
{signals && (
<div className="glass-card-static p-5">
<div className="flex items-center gap-2 mb-4">
<span className="w-1 h-4 rounded-full" style={{ backgroundColor: getPositionColor(signals.position_score) }} />
<h2 className="text-sm font-bold tracking-tight">
</h2>
<span className={`text-lg font-bold font-mono tabular-nums ml-auto`} style={{ color: getPositionColor(signals.position_score) }}>
{Math.round(signals.position_score)}
<span className="text-[10px] text-text-muted ml-0.5"></span>
</span>
</div>
{/* 位置安全评分条 */}
<div className="mb-4">
<div className="h-2.5 bg-surface-3 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700 ease-out"
style={{ width: `${Math.min(signals.position_score, 100)}%`, backgroundColor: getPositionColor(signals.position_score) }}
/>
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-3 gap-3">
<PositionBar label="5日涨幅" value={signals.rally_pct_5d} />
<PositionBar label="10日涨幅" value={signals.rally_pct_10d} />
<PositionBar label="距高点" value={signals.distance_from_high} invert />
</div>
</div>
)}
{/* Capital Flow Breakdown */}
{latestFlow && (
<CapitalFlowBreakdown flow={latestFlow} />
)}
</div>
{/* Technical signals */}
{signals && (
<div className="glass-card-static p-5 animate-fade-in-up delay-75">
<div className="flex items-center justify-between mb-5">
<h2 className="text-sm font-bold tracking-tight">
</h2>
{recScore && (
<div className={`text-2xl font-bold font-mono tabular-nums ${getScoreColor(recScore.score)}`}>
{recScore.score}
<span className="text-xs text-text-muted ml-1"></span>
</div>
)}
</div>
{/* ── Module 1: 核心评分维度 ── */}
<div className="mb-5 pb-5 border-b border-border-subtle">
<div className="flex items-center gap-2 mb-3">
<span className="w-1 h-4 rounded-full bg-amber-500/70" />
<span className="text-xs font-semibold text-text-secondary"></span>
{recScore && (
<span className="text-[10px] text-text-muted/40 ml-1"> = ×50% + ×40% + ×10%</span>
)}
</div>
{recScore ? (
<div className="grid grid-cols-4 gap-3">
<DimensionScore label="供需关系" sublabel="50%权重" value={recScore.supply_demand_score} />
<DimensionScore label="价格形态" sublabel="40%权重" value={recScore.price_action_score} />
<DimensionScore label="趋势方向" sublabel="10%权重" value={recScore.technical_score} />
<DimensionScore label="位置安全" sublabel="防追高" value={recScore.position_score} />
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<DimensionScore label="趋势评分" sublabel="均线排列+结构+MA20方向" value={signals.trend_score} />
<DimensionScore label="信号计数" sublabel="触发即加分,仅供参考" value={(signals.signal_count / 7) * 100} displayValue={`${signals.signal_count}/7`} />
</div>
)}
</div>
{/* ── Module 2: 辅助信号 ── */}
<div className="mb-5 pb-5 border-b border-border-subtle">
<div className="flex items-center gap-2 mb-3">
<span className="w-1 h-4 rounded-full bg-cyan-500/70" />
<span className="text-xs font-semibold text-text-secondary"></span>
<span className="text-[10px] text-text-muted/30">·</span>
<span className={`text-xs font-mono tabular-nums ml-auto ${signals.signal_count >= 4 ? "text-amber-400" : signals.signal_count >= 2 ? "text-cyan-400" : "text-text-muted"}`}>
{signals.signal_count}/7
</span>
</div>
<div className="grid grid-cols-4 gap-2">
<SignalChip label="均线多头" active={signals.ma_bullish} points={15} />
<SignalChip label="放量突破" active={signals.volume_breakout} points={20} />
<SignalChip label="MACD金叉" active={signals.macd_golden} points={15} />
<SignalChip label="RSI健康" active={signals.rsi_healthy} points={10} />
<SignalChip label="缩量回踩" active={signals.pullback_support} points={15} />
<SignalChip label="放量长阳" active={signals.big_yang} points={15} />
<SignalChip label="布林支撑" active={signals.boll_support} points={10} />
</div>
</div>
{/* ── Module 3: 关键价位 ── */}
<div>
<div className="flex items-center gap-2 mb-3">
<span className="w-1 h-4 rounded-full bg-emerald-500/70" />
<span className="text-xs font-semibold text-text-secondary"></span>
</div>
<div className="grid grid-cols-3 gap-3">
<PriceLevel label="支撑位" value={signals.support_price} color="text-orange-400" />
<PriceLevel label="压力位" value={signals.resist_price} color="text-red-400" />
<PriceLevel label="止损位" value={signals.stop_loss_price} color="text-emerald-400" />
</div>
</div>
</div>
)}
{/* K-line + Capital flow chart */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-fade-in-up delay-150">
{kline.length > 0 && <KlineChart data={kline} />}
{capitalFlow.length > 0 && <CapitalFlowChart data={capitalFlow} />}
</div>
</div>
</ErrorBoundary>
);
}
/* ── Helper components ── */
function MiniStat({
label,
value,
color,
highlight,
}: {
label: string;
value: string;
color?: string;
highlight?: boolean;
}) {
return (
<div className={`rounded-xl px-3 py-2.5 border ${highlight ? "border-amber-500/20 bg-amber-500/[0.04]" : "border-border-subtle bg-surface-1"}`}>
<div className="text-[11px] text-text-muted leading-tight">{label}</div>
<div className={`text-sm font-bold font-mono tabular-nums mt-0.5 ${color ?? ""}`}>
{value}
</div>
</div>
);
}
function PositionBar({ label, value, invert = false }: { label: string; value: number; invert?: boolean }) {
const absVal = Math.abs(value);
const maxDisplay = 30;
const pct = Math.min(absVal / maxDisplay, 1) * 100;
const isPositive = value > 0;
const showWarning = invert ? value < -20 : value > 20;
const barColor = showWarning ? "bg-amber-400" : isPositive ? "bg-red-400" : "bg-emerald-400";
const textColor = showWarning ? "text-amber-400" : isPositive ? "text-red-400" : "text-emerald-400";
return (
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle">
<div className="text-[10px] text-text-muted leading-tight">{label}</div>
<div className={`text-sm font-bold font-mono tabular-nums ${textColor}`}>
{isPositive ? "+" : ""}
{value.toFixed(1)}%
</div>
<div className="h-1.5 rounded-full bg-surface-3 overflow-hidden mt-1.5">
<div
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function CapitalFlowBreakdown({ flow }: { flow: FlowRecord }) {
const maxDisplay = Math.max(
...[flow.elg_net, flow.lg_net, flow.md_net, flow.sm_net].map(Math.abs),
1
);
const isMainInflow = flow.main_net_inflow > 0;
return (
<div className="glass-card-static p-5">
<div className="flex items-center gap-2 mb-4">
<span className={`w-1 h-4 rounded-full ${isMainInflow ? "bg-red-500/70" : "bg-emerald-500/70"}`} />
<h2 className="text-sm font-bold tracking-tight">
</h2>
<span
className={`text-lg font-bold font-mono tabular-nums ml-auto ${isMainInflow ? "text-red-400" : "text-emerald-400"}`}
>
{isMainInflow ? "+" : ""}
{formatFlowAmount(flow.main_net_inflow)}
<span className="text-[10px] text-text-muted ml-0.5"></span>
</span>
</div>
<div className="space-y-2.5">
<FlowBar label="特大单" value={flow.elg_net} max={maxDisplay} />
<FlowBar label="大单" value={flow.lg_net} max={maxDisplay} />
<FlowBar label="中单" value={flow.md_net} max={maxDisplay} />
<FlowBar label="小单" value={flow.sm_net} max={maxDisplay} />
</div>
</div>
);
}
function FlowBar({ label, value, max }: { label: string; value: number; max: number }) {
const absVal = Math.abs(value);
const pct = (absVal / max) * 100;
const isInflow = value > 0;
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-muted">{label}</span>
<span
className={`font-mono tabular-nums ${isInflow ? "text-red-400" : "text-emerald-400"}`}
>
{isInflow ? "+" : ""}
{formatFlowAmount(value)}
</span>
</div>
<div className="h-1.5 rounded-full bg-surface-2 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
isInflow ? "bg-red-400" : "bg-emerald-400"
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function SignalChip({ label, active, points }: { label: string; active: boolean; points: number }) {
return (
<div
className={`flex flex-col items-center py-2.5 rounded-xl transition-all duration-200 ${
active
? "bg-red-500/[0.08] border border-red-500/15"
: "bg-surface-1 border border-transparent"
}`}
>
<span className={`text-[11px] font-medium ${active ? "text-red-400" : "text-text-muted/60"}`}>
{label}
</span>
<span className={`text-[11px] font-mono tabular-nums mt-0.5 ${active ? "text-amber-400 font-semibold" : "text-text-muted/30"}`}>
{active ? `+${points}` : "—"}
</span>
</div>
);
}
function DimensionScore({ label, sublabel, value, displayValue }: { label: string; sublabel: string; value: number; displayValue?: string }) {
const width = Math.min(value, 100);
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
const scoreColor = value >= 70 ? "text-amber-400" : value >= 50 ? "text-cyan-400" : "text-text-muted";
return (
<div>
<div className="flex items-baseline justify-between mb-1.5">
<div>
<span className={`text-xs font-semibold ${scoreColor}`}>{label}</span>
<span className="text-[10px] text-text-muted/40 ml-1">{sublabel}</span>
</div>
<span className={`text-sm font-bold font-mono tabular-nums ${scoreColor}`}>
{displayValue ?? value.toFixed(0)}
</span>
</div>
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
style={{ width: `${width}%` }}
/>
</div>
</div>
);
}
function PriceLevel({ label, value, color }: { label: string; value: number | null; color: string }) {
return (
<div className="bg-surface-1 rounded-xl px-3 py-2.5 border border-border-subtle">
<div className="text-[10px] text-text-muted leading-tight">{label}</div>
<div className={`text-sm font-bold font-mono tabular-nums ${color}`}>
{value?.toFixed(2) ?? "—"}
</div>
</div>
);
}
/* ── Helper functions ── */
function getPositionColor(score: number) {
if (score >= 70) return "#22c55e";
if (score >= 40) return "#eab308";
return "#ef4444";
}
function formatBigNum(val: number): string {
if (val >= 10000) return (val / 10000).toFixed(1) + "万";
return val.toFixed(0);
}
function formatFlowAmount(val: number): string {
const absVal = Math.abs(val);
if (absVal >= 10000) return (val / 10000).toFixed(1) + "亿";
return val.toFixed(0) + "万";
}