"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { fetchAPI, postAPI } from "@/lib/api"; import type { IndexOverview, LatestResult, MarketTemperatureData, OpsStatusResponse, RecommendationData, SectorData, StrategyBoard, } from "@/lib/api"; import { ThemeToggle } from "@/components/theme-toggle"; import { useAuth } from "@/hooks/use-auth"; import { useWebSocket } from "@/hooks/use-websocket"; interface ScanStatus { is_trading: boolean; scan_mode: string; description: string; } const SCAN_TIMEOUT_MS = 120_000; export default function DashboardPage() { const { user } = useAuth(); const [data, setData] = useState(null); const [marketTemperature, setMarketTemperature] = useState(null); const [sectors, setSectors] = useState([]); const [scanStatus, setScanStatus] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [refreshResult, setRefreshResult] = useState(null); const [indices, setIndices] = useState([]); const [strategyBoard, setStrategyBoard] = useState(null); const [opsStatus, setOpsStatus] = useState(null); const [opsRunning, setOpsRunning] = useState(null); const scanTimeoutRef = useRef | null>(null); const loadData = useCallback(async () => { try { const [latestResult, sectorResult, statusResult, overviewResult] = await Promise.allSettled([ fetchAPI("/api/recommendations/latest"), fetchAPI("/api/sectors/hot?limit=8"), fetchAPI("/api/recommendations/status"), fetchAPI("/api/market/overview"), ]); const latest = latestResult.status === "fulfilled" ? latestResult.value : null; const sectorData = sectorResult.status === "fulfilled" ? sectorResult.value : []; const status = statusResult.status === "fulfilled" ? statusResult.value : null; const overview = overviewResult.status === "fulfilled" ? overviewResult.value : []; if (latest) { setData(latest); setMarketTemperature(latest.market_temperature ?? null); } setSectors(sectorData); if (status) setScanStatus(status); setIndices(overview); } catch (error) { console.error("加载数据失败:", error); } finally { setLoading(false); } }, []); const loadSecondaryData = useCallback(async () => { try { const board = await fetchAPI("/api/market/strategy-board").catch(() => null); const ops = user?.role === "admin" ? await fetchAPI("/api/market/ops-status").catch(() => null) : null; setStrategyBoard(board); setOpsStatus(ops); } catch (error) { console.error("加载次要数据失败:", error); } }, [user?.role]); const clearScanTimeout = useCallback(() => { if (scanTimeoutRef.current) { clearTimeout(scanTimeoutRef.current); scanTimeoutRef.current = null; } }, []); useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadSecondaryData(); }, [loadSecondaryData]); useWebSocket( useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => { clearScanTimeout(); if (msg.type === "scan_update") { const modeLabel = msg.scan_mode === "realtime_today" || msg.scan_mode === "intraday" ? "今日盘中" : "收盘复盘"; setRefreshResult(`${modeLabel}更新完成,保留 ${msg.count ?? 0} 只股票`); setRefreshing(false); loadData(); loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); } else if (msg.type === "scan_error") { setRefreshResult("更新失败,请重试"); setRefreshing(false); setTimeout(() => setRefreshResult(null), 5000); } else { loadData(); loadSecondaryData(); } }, [clearScanTimeout, loadData, loadSecondaryData]), ["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"] ); const handleRefresh = async () => { setRefreshing(true); setRefreshResult(null); try { const res = await postAPI<{ status: string; message?: string; is_trading: boolean; }>("/api/recommendations/refresh?scan_session=manual"); if (res.status === "already_running") { setRefreshResult(res.message || "更新正在执行中,请稍候"); } else if (res.status === "scanning") { setRefreshResult("更新已启动,完成后自动刷新..."); } scanTimeoutRef.current = setTimeout(() => { setRefreshResult("更新超时,已自动刷新数据"); setRefreshing(false); loadData(); loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); }, SCAN_TIMEOUT_MS); } catch (error) { console.error("触发更新失败:", error); setRefreshResult("触发更新失败,请重试"); setRefreshing(false); setTimeout(() => setRefreshResult(null), 5000); } }; const handleAdminAction = async (action: "update_tracking") => { setOpsRunning(action); setRefreshResult(null); try { const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/update-tracking"); setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${clampPercent(result.win_rate ?? 0).toFixed(1)}%`); await loadData(); await loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); } catch (error) { console.error("管理员操作失败:", error); setRefreshResult("管理员操作失败,请重试"); setTimeout(() => setRefreshResult(null), 5000); } finally { setOpsRunning(null); } }; useEffect(() => { return () => clearScanTimeout(); }, [clearScanTimeout]); const recommendations = data?.recommendations ?? []; const actionable = recommendations.filter((rec) => rec.action_plan === "可操作"); const watch = recommendations.filter((rec) => rec.action_plan === "重点关注"); const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? "")); const marketSummary = useMemo( () => buildMarketSummary(data?.strategy_profile ?? null, marketTemperature, strategyBoard, scanStatus, actionable.length, watch.length), [actionable.length, data?.strategy_profile, marketTemperature, scanStatus, strategyBoard, watch.length] ); const todayActions = useMemo( () => buildActionGuides(data?.strategy_profile ?? null, strategyBoard, marketTemperature, actionable, watch, observe, sectors), [actionable, data?.strategy_profile, marketTemperature, observe, sectors, strategyBoard, watch] ); const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations; if (loading) { return (
); } return (

今日作战

{scanStatus?.is_trading ? ( 交易中 ) : ( 已收盘 )}
{user?.role === "admin" ? ( ) : null}
{refreshResult ? (
{refreshResult}
) : null}
); } function DecisionHero({ board, summary, actions, marketTemperature, sectors, focusQueue, actionableCount, watchCount, observeCount, }: { board: StrategyBoard | null; summary: ReturnType; actions: ReturnType; marketTemperature: MarketTemperatureData | null; sectors: SectorData[]; focusQueue: RecommendationData[]; actionableCount: number; watchCount: number; observeCount: number; }) { const leadingSectors = sectors.slice(0, 3); return (
今日作战台

{summary.headline}

{summary.detail}

市场温度
{Math.round(marketTemperature?.temperature ?? 0)}
{leadingSectors.length ? (
{leadingSectors.map((sector) => ( ))}
) : null}
焦点标的
{focusQueue.length ? ( focusQueue.map((rec) => ) ) : (
暂无焦点标的。
)}
); } function MarketSnapshot({ marketTemperature, indices, sectors, summary, }: { marketTemperature: MarketTemperatureData | null; indices: IndexOverview[]; sectors: SectorData[]; summary: ReturnType; }) { const leadingSectors = sectors.slice(0, 4); const majorIndices = indices.slice(0, 3); return (

盘面依据

温度 {Math.round(marketTemperature?.temperature ?? 0)}
{majorIndices.length ? (
{majorIndices.map((item) => (
{item.name}
{item.close.toFixed(2)} = 0 ? "text-red-400" : "text-emerald-400"}`}> {item.pct_chg >= 0 ? "+" : ""}{item.pct_chg.toFixed(2)}%
))}
) : null} {leadingSectors.length ? (
今日盯住的板块
主线优先
{leadingSectors.map((sector) => { const pct = sector.realtime_pct_change ?? sector.pct_change; return (
{sector.sector_name}
{sector.limit_up_count > 0 ? `${sector.limit_up_count} 涨停` : "无涨停"} · {sector.stage || "mid"}
= 0 ? "text-red-400" : "text-emerald-400"}`}> {pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
); })}
) : null}
结论:{summary.headline}
); } function AdminPanel({ isAdmin, opsStatus, strategyProfile, refreshing, opsRunning, onRefresh, onAction, }: { isAdmin?: boolean; opsStatus: OpsStatusResponse | null; strategyProfile: LatestResult["strategy_profile"]; refreshing: boolean; opsRunning: string | null; onRefresh: () => void; onAction: (action: "update_tracking") => void; }) { if (!isAdmin || !opsStatus) return null; return (

管理员

{opsStatus.data_freshness.message}

{opsStatus.scan_running ? "扫描中" : "空闲"}
{strategyProfile?.feedback_applied ? (
反馈回写已生效
{(strategyProfile.feedback_notes?.length ? strategyProfile.feedback_notes : strategyProfile.notes || []).slice(0, 3).map((note, index) => (
{note}
))}
) : null}
); } function HeroMetric({ label, value, tone }: { label: string; value: number; tone: string }) { return (
{label}
{value}
); } function ActionBucket({ title, items, tone }: { title: string; items: string[]; tone: "do" | "wait" | "avoid" }) { const toneClass = tone === "do" ? "text-emerald-400 bg-emerald-500/[0.06] border-emerald-500/10" : tone === "wait" ? "text-amber-400 bg-amber-500/[0.06] border-amber-500/10" : "text-text-muted bg-surface-2/70 border-border-subtle"; return (
{title}
{items.map((item, index) => (
{item}
))}
); } function CompactBadge({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } function SectorChip({ sector }: { sector: SectorData }) { const pct = sector.realtime_pct_change ?? sector.pct_change; return ( {sector.sector_name} = 0 ? "text-red-400" : "text-emerald-400"}`}> {pct >= 0 ? "+" : ""}{pct.toFixed(2)}% ); } function FocusStockCard({ rec }: { rec: RecommendationData }) { const stripeClass = rec.action_plan === "可操作" ? "bg-red-400" : rec.action_plan === "重点关注" ? "bg-amber-400" : "bg-text-muted"; const labelClass = rec.action_plan === "可操作" ? "text-red-400" : rec.action_plan === "重点关注" ? "text-amber-400" : "text-text-muted"; return (
{rec.name} {rec.action_plan ?? "观察"}
{rec.suggested_position_pct != null ? ( {rec.suggested_position_pct}% ) : null}
{rec.ts_code} {rec.sector ? ( <> / {rec.sector} ) : null}
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
{(rec.invalidation_condition || rec.risk_note) ? (
失效: {rec.invalidation_condition ?? rec.risk_note}
) : null}
); } function EvidenceStat({ label, value, tone }: { label: string; value: number; tone: string }) { return (
{label}
{value}
); } function FreshnessPill({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function buildMarketSummary( strategyProfile: LatestResult["strategy_profile"], marketTemperature: MarketTemperatureData | null, board: StrategyBoard | null, scanStatus: ScanStatus | null, actionableCount: number, watchCount: number ) { const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0; const allowTrading = strategyProfile?.allow_trading ?? actionableCount > 0; const headline = !allowTrading ? "防守观察" : board?.market_regime ?? (temp >= 70 ? "主线进攻" : temp >= 50 ? "确认机会" : temp >= 30 ? "轻仓试错" : "偏弱观察"); const detail = strategyProfile?.decision_note ?? board?.summary ?? (scanStatus?.is_trading ? "当前结论:看节奏、仓位、主线强弱。" : "最近结论:复盘主线,准备下一交易日。"); const canDo = [ !allowTrading ? "观察等待。" : actionableCount > 0 ? `只看 ${actionableCount} 只可操作标的。` : "无可操作标的,保留观察。", watchCount > 0 ? `关注 ${watchCount} 只确认信号。` : "盯最强板块前排。", board?.position_suggestion ?? (strategyProfile?.max_position_pct ? `仓位上限 ${strategyProfile.max_position_pct}%。` : temp >= 50 ? "围绕主线试错。" : "控制仓位。"), ]; const cannotDo = [ board?.avoid_rules?.[0] ?? "不要追后排、跟风和没有板块支撑的个股。", board?.avoid_rules?.[1] ?? (temp < 50 ? "不把个别异动当回暖。" : "不把盘中脉冲当主线。"), !allowTrading || temp < 40 ? "不逆势加仓。" : "不随意切换题材。", ]; return { headline, detail, canDo, cannotDo, modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新", positionLabel: board?.position_suggestion || (strategyProfile?.max_position_pct ? `${strategyProfile.max_position_pct}% 以内` : "等待更新"), }; } function clampPercent(value: number) { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, value)); } function buildActionGuides( strategyProfile: LatestResult["strategy_profile"], board: StrategyBoard | null, marketTemperature: MarketTemperatureData | null, actionable: RecommendationData[], watch: RecommendationData[], observe: RecommendationData[], sectors: SectorData[] ) { const topSectors = (board?.watch_sectors?.slice(0, 2) ?? []).map((sector) => sector.sector_name); const backupSectors = sectors.slice(0, 2).map((sector) => sector.sector_name); const focusSectors = topSectors.length ? topSectors : backupSectors; const temp = marketTemperature?.temperature ?? board?.metrics.temperature ?? 0; const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0; const priority = [ !allowTrading ? "等待清晰主线和承接。" : actionable[0] ? `盯 ${actionable[0].name}${actionable[0].trigger_condition ? `:${actionable[0].trigger_condition}` : " 的确认信号"}。` : focusSectors[0] ? `看 ${focusSectors[0]} 前排强度。` : "等待更清晰信号。", focusSectors[1] ? `主线池:${focusSectors.slice(0, 2).join("、")}。` : "只围绕最强主线。", !allowTrading ? "保守观察。" : temp >= 50 ? "做回流确认,不做无量冲高。" : "只看最强确认。", ]; const watchItems = [ watch[0] ? `${watch[0].name}:量能、回流、承接。` : focusSectors[0] ? `${focusSectors[0]}:确认前不追。` : "观察新主线聚焦。", watch[1] ? `${watch[1].name}:观察队列。` : "新热点先看板块扩散。", observe.length > 0 ? `观察池 ${observe.length} 只。` : "暂无弱候选。", ]; const avoid = [ board?.avoid_rules?.[0] ?? "回避没有主线归属、没有触发条件的个股。", board?.avoid_rules?.[1] ?? "回避后排补涨和尾盘情绪化拉升。", temp < 40 ? "回避逆势加仓和高频换股。" : "回避脱离计划的追涨杀跌。", ]; return { priority, watch: watchItems, avoid }; }