astock-agent/frontend/src/components/stock-card.tsx
2026-04-22 22:19:29 +08:00

286 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 { getLevelBadge } from "@/lib/utils";
import type { RecommendationData } from "@/lib/api";
export default function StockCard({ rec }: { rec: RecommendationData }) {
const badge = getLevelBadge(rec.level);
const aiConviction = rec.llm_score != null ? Math.round(rec.llm_score) : null;
const recallLabels: Record<string, string> = {
sector_recall: "主线召回",
trend_scan: "趋势召回",
intraday_active: "盘中异动",
hot_sector_core: "板块核心",
sector_leader: "前排线索",
moneyflow_support: "资金支撑",
volume_active: "量能活跃",
};
const prefilterLabel: Record<string, string> = {
priority: "AI优先深看",
watch: "AI保留观察",
ignore: "AI建议忽略",
"": "待AI预筛",
};
// 入场信号标签
const signalTypeMap: Record<string, { label: string; style: string }> = {
breakout: { label: "突破型", style: "bg-red-500/15 text-red-400 border-red-500/20" },
pullback: { label: "回踩型", style: "bg-blue-500/15 text-blue-400 border-blue-500/20" },
launch: { label: "启动型", style: "bg-orange-500/15 text-orange-400 border-orange-500/20" },
};
// 向后兼容:旧数据使用 strategy 字段
const signalInfo = signalTypeMap[rec.entry_signal_type || ""];
const legacyStrategy = rec.strategy === "potential"
? { label: "潜在启动", style: "bg-cyan-500/15 text-cyan-400 border-cyan-500/20" }
: rec.strategy === "momentum"
? { label: "强中选强", style: "bg-amber-500/15 text-amber-400 border-amber-500/20" }
: null;
const tag = signalInfo || legacyStrategy;
const actionPlanStyle: Record<string, string> = {
"可操作": "bg-red-500/15 text-red-400 border-red-500/20",
"重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20",
"观察": "bg-surface-3 text-text-muted border-border-default",
};
const lifecycleLabel: Record<string, string> = {
candidate: "观察池",
actionable: "可操作",
tracking: "跟踪中",
closed_win: "盈利结束",
closed_loss: "亏损结束",
expired: "到期复盘",
invalidated: "已失效",
};
const actionPlanCopy: Record<string, string> = {
"可操作": "触发条件成立时才执行",
"重点关注": "等待确认,不提前交易",
"观察": "只记录,不主动出手",
};
const evidence = [
rec.prefilter_reason,
rec.focus_points?.[0],
rec.reasons?.[0],
rec.entry_timing,
rec.data_freshness,
].filter(Boolean).slice(0, 3) as string[];
return (
<div className="glass-card p-4 group">
{/* Clickable top section — navigates to stock detail */}
<a href={`/stock/${rec.ts_code}`} className="block">
{/* Header: Name + Action state */}
<div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-semibold text-sm tracking-tight">{rec.name}</span>
{rec.signal === "BUY" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-md font-medium bg-red-500/15 text-red-400 border border-red-500/20">
</span>
)}
{tag && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${tag.style}`}>
{tag.label}
</span>
)}
{rec.action_plan && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${actionPlanStyle[rec.action_plan] ?? actionPlanStyle["观察"]}`}>
{rec.action_plan}
</span>
)}
</div>
<div className="text-[11px] text-text-muted mt-1 font-mono tabular-nums">
{rec.ts_code} · {rec.sector}
</div>
</div>
<div className="text-right shrink-0 ml-3">
<div className="text-[10px] text-text-muted uppercase tracking-wider"></div>
<div className="text-xs text-text-secondary mt-0.5">{rec.action_plan ?? "观察"}</div>
{aiConviction != null ? (
<div className="text-[10px] font-mono tabular-nums text-cyan-400/80 mt-0.5">
AI {aiConviction}/10
</div>
) : null}
</div>
</div>
{(rec.action_plan || rec.trigger_condition || rec.invalidation_condition || rec.suggested_position_pct) && (
<div className="mb-3 rounded-xl bg-surface-1/70 border border-border-subtle p-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">AI </div>
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${rec.action_plan ? actionPlanStyle[rec.action_plan] ?? actionPlanStyle["观察"] : actionPlanStyle["观察"]}`}>
{rec.action_plan ? actionPlanCopy[rec.action_plan] ?? rec.action_plan : "等待结论"}
</span>
</div>
{rec.trigger_condition && (
<div className="text-[11px] text-text-secondary leading-relaxed line-clamp-2">
{rec.trigger_condition}
</div>
)}
{rec.invalidation_condition && (
<div className="text-[11px] text-text-muted leading-relaxed line-clamp-2 mt-1">
{rec.invalidation_condition}
</div>
)}
<div className="flex flex-wrap items-center gap-2 mt-2 text-[10px] text-text-muted">
{rec.suggested_position_pct != null && (
<span className="rounded-md bg-surface-2 px-2 py-1">
{rec.suggested_position_pct}%
</span>
)}
{rec.review_after_days ? (
<span className="rounded-md bg-surface-2 px-2 py-1">
{rec.review_after_days}
</span>
) : null}
{aiConviction != null ? (
<span className="rounded-md bg-cyan-500/[0.06] border border-cyan-500/10 px-2 py-1 text-cyan-400/80">
AI置信 {aiConviction}/10
</span>
) : rec.score ? (
<span className="rounded-md bg-surface-2 px-2 py-1 text-text-secondary">
{rec.score.toFixed(0)}
</span>
) : null}
<span className={`rounded-md px-2 py-1 ${badge.bg} ${badge.text}`}>
{rec.level}
</span>
</div>
</div>
)}
{evidence.length > 0 && (
<div className="mb-3 rounded-xl bg-surface-1/60 border border-border-subtle px-3 py-2.5">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold mb-2">AI </div>
<div className="space-y-1.5">
{evidence.map((item, index) => (
<div key={`${rec.ts_code}-evidence-${index}`} className="text-[11px] text-text-secondary flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[6px] shrink-0" />
<span className="leading-relaxed line-clamp-2">{item}</span>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2 mt-2.5 text-[10px] text-text-muted">
{(rec.recall_tags ?? []).slice(0, 3).map((tag) => (
<span key={`${rec.ts_code}-${tag}`} className="rounded-md bg-surface-2 px-2 py-1">
{recallLabels[tag] ?? tag}
</span>
))}
<span className="rounded-md bg-cyan-500/[0.06] border border-cyan-500/10 px-2 py-1 text-cyan-400/80">
{prefilterLabel[rec.prefilter_decision ?? ""] ?? "AI预筛"}
</span>
</div>
</div>
)}
{(rec.focus_points?.length ?? 0) > 0 && (
<div className="mb-3 rounded-xl bg-surface-1/60 border border-border-subtle px-3 py-2.5">
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold mb-2"></div>
<div className="space-y-1.5">
{(rec.focus_points ?? []).slice(0, 3).map((item, index) => (
<div key={`${rec.ts_code}-focus-${index}`} className="text-[11px] text-text-secondary flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-cyan-400/70 mt-[6px] shrink-0" />
<span className="leading-relaxed line-clamp-2">{item}</span>
</div>
))}
</div>
</div>
)}
{/* Price reference */}
{rec.entry_price && (
<div className="grid grid-cols-3 gap-2 mb-2 bg-surface-1/60 rounded-lg px-3 py-2 border border-border-subtle">
<div>
<span className="text-text-muted/60 block text-[10px]"></span>
<span className="text-red-400 font-mono tabular-nums text-xs">{rec.entry_price}</span>
</div>
<div>
<span className="text-text-muted/60 block text-[10px]"></span>
<span className="text-amber-400 font-mono tabular-nums text-xs">{rec.target_price}</span>
</div>
<div>
<span className="text-text-muted/60 block text-[10px]"></span>
<span className="text-emerald-400 font-mono tabular-nums text-xs">{rec.stop_loss}</span>
</div>
</div>
)}
{rec.tracking && (
<div className="mb-3 bg-surface-1/60 rounded-lg px-3 py-2 border border-border-subtle">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-text-muted">
· {lifecycleLabel[rec.lifecycle_status || ""] ?? rec.lifecycle_status ?? "跟踪"}
</span>
<span className="text-[10px] text-text-muted font-mono tabular-nums">
{rec.tracking.days_since_recommendation ?? 0} · {rec.tracking.track_date}
</span>
</div>
<div className="grid grid-cols-3 gap-2">
<TrackingMetric
label="当前"
value={rec.tracking.pct_from_entry}
/>
<TrackingMetric
label="最大浮盈"
value={rec.tracking.max_return_pct}
/>
<TrackingMetric
label="最大回撤"
value={rec.tracking.max_drawdown_pct}
/>
</div>
{rec.tracking.review_note && (
<div className="text-[11px] text-text-muted leading-relaxed mt-2 line-clamp-2">
{rec.tracking.review_note}
</div>
)}
</div>
)}
{/* Reasons */}
<div className="space-y-1.5">
{rec.reasons.slice(0, 3).map((r, i) => (
<div key={i} className="text-xs text-text-secondary flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[6px] shrink-0" />
<span className="leading-relaxed line-clamp-2">{r}</span>
</div>
))}
</div>
</a>
<div className="mt-3 border-t border-border-subtle pt-2 flex items-center justify-between gap-3 text-[11px]">
<div className="text-text-muted">
{aiConviction != null && (
<span className="ml-2 font-mono tabular-nums text-cyan-400/80">
AI {aiConviction}/10
</span>
)}
</div>
<a href={`/stock/${rec.ts_code}`} className="shrink-0 text-cyan-400/80 hover:text-cyan-400 transition-colors">
</a>
</div>
{/* Risk note */}
{rec.risk_note && (
<div className="mt-2 text-[11px] text-amber-500/50 bg-amber-500/[0.04] rounded-lg px-3 py-1.5">
{rec.risk_note}
</div>
)}
</div>
);
}
function TrackingMetric({ label, value }: { label: string; value: number | null }) {
const num = value ?? 0;
const color = num > 0 ? "text-red-400" : num < 0 ? "text-emerald-400" : "text-text-secondary";
return (
<div>
<div className="text-[10px] text-text-muted/60 mb-0.5">{label}</div>
<div className={`text-xs font-mono tabular-nums font-semibold ${color}`}>
{num > 0 ? "+" : ""}{num.toFixed(2)}%
</div>
</div>
);
}