286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|