"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchAPI, postAPI } from "@/lib/api"; import type { DayGroup, LatestResult, OpsStatusResponse, PerformanceStats, RecommendationData, TrackedRecommendation, } from "@/lib/api"; import StockCard from "@/components/stock-card"; import { useAuth } from "@/hooks/use-auth"; function formatDate(dateStr: string): string { const d = new Date(dateStr); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); if (dateStr === today.toISOString().slice(0, 10)) return "今日"; if (dateStr === yesterday.toISOString().slice(0, 10)) return "昨日"; const weekDays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; return `${d.getMonth() + 1}月${d.getDate()}日 ${weekDays[d.getDay()]}`; } function formatScanTime(value?: string): string { if (!value) return "暂无扫描"; const normalized = value.includes("T") ? value : value.replace(" ", "T"); const d = new Date(normalized); if (Number.isNaN(d.getTime())) return value; const today = new Date(); const isToday = d.toDateString() === today.toDateString(); const time = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); if (isToday) return `今日 ${time}`; return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } type RecommendationWithDate = RecommendationData & { groupDate: string }; type FocusTab = "actionable" | "watch" | "observe" | "tracking" | "closed"; const SIGNAL_FILTERS = [ { key: "all", label: "全部" }, { key: "breakout", label: "突破" }, { key: "pullback", label: "回踩" }, { key: "launch", label: "启动" }, { key: "buy", label: "买入" }, ]; export default function RecommendationsPage() { const { user } = useAuth(); const [dayGroups, setDayGroups] = useState([]); const [latest, setLatest] = useState(null); const [expandedDays, setExpandedDays] = useState>(new Set()); const [historyFilter, setHistoryFilter] = useState("all"); const [focusTab, setFocusTab] = useState("actionable"); const [opsStatus, setOpsStatus] = useState(null); const [performance, setPerformance] = useState(null); const [trackingUpdating, setTrackingUpdating] = useState(false); const loadData = useCallback(async () => { try { const [history, latestResult, ops, perf] = await Promise.all([ fetchAPI("/api/recommendations/history?days=14"), fetchAPI("/api/recommendations/latest").catch(() => null), user?.role === "admin" ? fetchAPI("/api/market/ops-status").catch(() => null) : Promise.resolve(null), fetchAPI("/api/recommendations/performance").catch(() => null), ]); setDayGroups(history); setLatest(latestResult); setOpsStatus(ops); setPerformance(perf); setExpandedDays((prev) => { if (prev.size || history.length === 0) return prev; return new Set([history[0].date]); }); } catch (error) { console.error("加载推荐失败:", error); } }, [user?.role]); useEffect(() => { loadData(); }, [loadData]); const updateTracking = async () => { setTrackingUpdating(true); try { await postAPI("/api/recommendations/update-tracking"); await loadData(); } finally { setTrackingUpdating(false); } }; const toggleDay = (date: string) => { setExpandedDays((prev) => { const next = new Set(prev); if (next.has(date)) next.delete(date); else next.add(date); return next; }); }; const applyHistoryFilter = useCallback((recs: RecommendationData[]) => { if (historyFilter === "all") return recs; if (historyFilter === "buy") return recs.filter((r) => r.signal === "BUY"); return recs.filter((r) => r.entry_signal_type === historyFilter); }, [historyFilter]); const allRecommendations: RecommendationWithDate[] = useMemo( () => dayGroups.flatMap((group) => group.recommendations.map((rec) => ({ ...rec, groupDate: group.date }))), [dayGroups] ); const latestDate = dayGroups[0]?.date ?? ""; const latestRecommendations = useMemo(() => { if (latest?.recommendations?.length) return latest.recommendations.map((rec) => ({ ...rec, groupDate: latestDate || "latest" })); return latestDate ? allRecommendations.filter((rec) => rec.groupDate === latestDate) : []; }, [allRecommendations, latest, latestDate]); const actionable = latestRecommendations.filter((rec) => rec.action_plan === "可操作" || rec.lifecycle_status === "actionable"); const watch = latestRecommendations.filter((rec) => rec.action_plan === "重点关注"); const observe = latestRecommendations.filter((rec) => (rec.action_plan ?? "观察") === "观察"); const tracking = allRecommendations.filter((rec) => rec.lifecycle_status === "tracking"); const closed = allRecommendations.filter((rec) => ["closed_win", "closed_loss", "expired", "invalidated"].includes(rec.lifecycle_status ?? "")); const focusTabs: Array<{ key: FocusTab; label: string; count: number; description: string }> = [ { key: "actionable", label: "可操作", count: actionable.length, description: "执行名单" }, { key: "watch", label: "重点关注", count: watch.length, description: "等待确认" }, { key: "observe", label: "观察池", count: observe.length, description: "不急处理" }, { key: "tracking", label: "跟踪中", count: tracking.length, description: "兑现进度" }, { key: "closed", label: "已结束", count: closed.length, description: "复盘样本" }, ]; const focusItems = useMemo(() => { if (focusTab === "actionable") return actionable; if (focusTab === "watch") return watch; if (focusTab === "observe") return observe.slice(0, 12); if (focusTab === "tracking") return tracking.slice(0, 8); return closed.slice(0, 8); }, [actionable, closed, focusTab, observe, tracking, watch]); const themeFocus = useMemo(() => { const source = latestRecommendations.filter((rec) => ["可操作", "重点关注"].includes(rec.action_plan ?? "")); const pool = source.length ? source : latestRecommendations; const counts = new Map(); pool.forEach((rec) => { const key = rec.sector || "未归类"; counts.set(key, (counts.get(key) ?? 0) + 1); }); return Array.from(counts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3); }, [latestRecommendations]); const todayCount = applyHistoryFilter(dayGroups[0]?.recommendations ?? []).length; const totalCount = dayGroups.reduce((sum, group) => sum + applyHistoryFilter(group.recommendations).length, 0); const latestScanLabel = latest?.latest_scan?.created_at || dayGroups[0]?.scanned_at; const focusSummary = buildFocusSummary({ strategyProfile: latest?.strategy_profile ?? null, actionable, watch, observe, tracking, closed, }); return (

今日决策池

今日结论

{focusSummary.headline}

{focusSummary.detail}

{latest?.strategy_profile ? (
) : null} {themeFocus.length ? (
{themeFocus.map(([theme, count]) => ( {theme} {count}只 ))}
) : null}
{user?.role === "admin" && opsStatus ? (
{opsStatus.data_freshness.message}
) : null}

{focusTabs.find((tab) => tab.key === focusTab)?.label ?? "焦点标的"}

{focusTabs.find((tab) => tab.key === focusTab)?.description ?? "按当前结论处理"}
{focusTabs.map((tab) => ( ))}
{focusItems.length ? (
{focusItems.map((rec) => ( ))}
) : (
当前分组暂无标的。
)}

历史记录

{SIGNAL_FILTERS.map((item) => ( ))}
{dayGroups.length === 0 ? (
暂无推荐记录。
) : (
{dayGroups.map((group, index) => { const filtered = applyHistoryFilter(group.recommendations); const isExpanded = expandedDays.has(group.date); const isToday = index === 0; const reasons = group.elimination_reasons ? Object.entries(group.elimination_reasons).slice(0, 3) : []; return (
{isExpanded ? (
{filtered.length ? (
{filtered.map((rec) => ( ))}
) : (
{historyFilter === "all" ? "本日扫描无入选标的" : "本日扫描存在,但当前筛选条件下无标的"}
{group.scan_summary || "系统已完成扫描,但没有标的进入历史推荐池。"}
{reasons.length ? (
{reasons.map(([reason, count]) => ( {reason} {count} ))}
) : null}
)}
) : null}
); })}
)}
); } function buildFocusSummary({ strategyProfile, actionable, watch, observe, tracking, closed, }: { strategyProfile: LatestResult["strategy_profile"]; actionable: RecommendationData[]; watch: RecommendationData[]; observe: RecommendationData[]; tracking: RecommendationData[]; closed: RecommendationData[]; }) { const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0; const headline = !allowTrading ? "防守观察" : actionable.length > 0 ? `处理 ${actionable.length} 只可操作标的` : watch.length > 0 ? `关注 ${watch.length} 只确认信号` : "暂无优势机会"; const detail = strategyProfile?.decision_note ?? (!allowTrading ? "防守观察,等待确认。" : actionable.length > 0 ? "执行名单已收敛。" : watch.length > 0 ? "等待确认,不扩池。" : "观察为主。"); const now = [ !allowTrading ? "等待主线扩散和回流。" : actionable[0] ? `看 ${actionable[0].name}${actionable[0].trigger_condition ? `:${actionable[0].trigger_condition}` : " 的确认信号"}。` : watch[0] ? `盯住 ${watch[0].name} 是否从观察转成可操作。` : "只留主线候选。", watch.length > 0 ? `${watch.length} 只重点关注,等待确认。` : allowTrading ? "不从观察池强挑。" : "确认前不提级。", tracking.length > 0 ? `${tracking.length} 只跟踪兑现。` : "关注今日新结论。", ]; const later = [ observe.length > 0 ? `${observe.length} 只观察池标的。` : "不堆弱标的。", closed.length > 0 ? `${closed.length} 只已结束样本。` : "暂无结束样本。", "不追无触发标的。", ]; return { headline, detail, now, later }; } function PerformanceReviewCard({ performance, canUpdate, updating, onUpdate, }: { performance: PerformanceStats | null; canUpdate: boolean; updating: boolean; onUpdate: () => void; }) { const details = performance?.details ?? []; const topRoutes = (performance?.route_breakdown ?? []).slice(0, 4); const conclusion = buildPerformanceConclusion(performance); return (
推荐复盘

{conclusion.headline}

{conclusion.detail}

{canUpdate ? ( ) : null}
= 0 ? "text-red-400" : "text-emerald-400"} suffix="%" signed />
有效路线
{topRoutes.length ? topRoutes.map((route) => (
{formatRouteLabel(route.route)}
{route.count} 个样本
{route.win_rate}%
= 0 ? "text-red-400" : "text-emerald-400"}> {route.avg_return > 0 ? "+" : ""}{route.avg_return}%
)) : (
暂无路线样本。
)}
最近复盘样本
按推荐时间倒序
{details.length ? ( {details.slice(0, 8).map((item) => ( ))}
标的 推荐日 现价/入场 涨跌 过程 结论
) : (
暂无跟踪记录。先执行一次跟踪更新后再看复盘。
)}
); } function ReviewRow({ item }: { item: TrackedRecommendation }) { const pct = item.pct_from_entry ?? 0; const pctTone = pct >= 0 ? "text-red-400" : "text-emerald-400"; const cellClass = "bg-surface-2/30 px-3 py-3 transition-colors group-hover:bg-surface-2/55"; return (
{item.name}
{item.ts_code}
{item.created_at || "-"} {formatNumber(item.current_price)} / {formatNumber(item.entry_price)} {pct > 0 ? "+" : ""}{pct}% {formatSigned(item.max_return_pct)} / {formatSigned(item.max_drawdown_pct)} {item.review_note || formatCloseReason(item.close_reason)} ); } function buildPerformanceConclusion(performance: PerformanceStats | null) { if (!performance || performance.tracked === 0) { return { headline: "等待形成有效复盘样本", detail: "当前还没有可统计的推荐跟踪记录。更新跟踪后,会按推荐后的价格表现判断是否兑现预期。", }; } const avg = performance.avg_return ?? 0; const direction = avg > 0 ? "整体兑现为正" : avg < 0 ? "整体仍在回撤" : "整体接近持平"; return { headline: `${performance.tracked} 个样本,胜率 ${performance.win_rate}%`, detail: `${direction},平均收益 ${avg > 0 ? "+" : ""}${avg.toFixed(2)}%,平均最大浮盈 ${performance.avg_max_return.toFixed(2)}%,平均最大回撤 ${performance.avg_max_drawdown.toFixed(2)}%。`, }; } function formatRouteLabel(route: string) { const labels: Record = { hot_theme_core: "主线核心", theme_leader: "板块龙头", top_theme_member: "强主题成员", sector_recall: "板块召回", }; return labels[route] ?? route; } function formatCloseReason(reason?: string) { const labels: Record = { hit_target: "命中目标", hit_stop_loss: "触发止损", review_expired_profit: "到期盈利", review_expired_loss: "到期亏损", review_expired_flat: "到期震荡", }; return labels[reason ?? ""] ?? "跟踪中"; } function formatNumber(value: number | null | undefined) { return value == null ? "-" : Number(value).toFixed(2); } function formatSigned(value: number | null | undefined) { if (value == null) return "-"; return `${value > 0 ? "+" : ""}${Number(value).toFixed(2)}%`; } function KeyList({ title, items }: { title: string; items: string[] }) { return (
{title}
{items.map((item, index) => (
{item}
))}
); } function SummaryMetric({ label, value, tone, suffix, signed, }: { label: string; value: number; tone: string; suffix?: string; signed?: boolean; }) { return (
{label}
{signed && value > 0 ? "+" : ""} {value} {suffix ?? ""}
); } function SummaryFact({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function FreshnessCell({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function SummaryChip({ label, value }: { label: string; value: string }) { return ( {label} {value} ); }