import React, { useState, useEffect } from 'react' import { api } from '../services/api' import './Recommendations.css' import CollapsibleUserGuide from './CollapsibleUserGuide' import { TrendingDown, TrendingUp } from 'lucide-react' function Recommendations() { const [recommendations, setRecommendations] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [typeFilter, setTypeFilter] = useState('realtime') // 'realtime' 或 'bookmarked' const [statusFilter, setStatusFilter] = useState('') const [directionFilter, setDirectionFilter] = useState('') const [generating, setGenerating] = useState(false) const [showDetails, setShowDetails] = useState({}) const [bookmarking, setBookmarking] = useState({}) // 记录正在标记的推荐ID useEffect(() => { loadRecommendations() // 如果是查看实时推荐,每10秒静默更新价格(不触发loading状态) let interval = null if (typeFilter === 'realtime') { interval = setInterval(async () => { // 静默更新:只更新价格,不显示loading try { const result = await api.getRecommendations({ type: 'realtime' }) const newData = result.data || [] // 使用setState直接更新,不触发loading状态 setRecommendations(prevRecommendations => { // 如果新数据为空,保持原数据不变 if (newData.length === 0) { return prevRecommendations } // 实时推荐没有id,使用symbol作为key const newDataMap = new Map(newData.map(rec => [rec.symbol, rec])) const prevMap = new Map(prevRecommendations.map(rec => [rec.symbol || rec.id, rec])) // 合并数据:优先使用新数据(包含实时价格更新) const updated = prevRecommendations.map(prevRec => { const key = prevRec.symbol || prevRec.id const newRec = newDataMap.get(key) if (newRec) { return newRec } return prevRec }) // 添加新出现的推荐 const newItems = newData.filter(newRec => !prevMap.has(newRec.symbol)) // 合并并去重(按symbol) const merged = [...updated, ...newItems] const uniqueMap = new Map() merged.forEach(rec => { const key = rec.symbol || rec.id if (!uniqueMap.has(key)) { uniqueMap.set(key, rec) } }) return Array.from(uniqueMap.values()) }) } catch (err) { // 静默失败,不显示错误 console.debug('静默更新价格失败:', err) } }, 10000) // 每10秒刷新 } return () => { if (interval) { clearInterval(interval) } } }, [typeFilter, statusFilter, directionFilter]) const loadRecommendations = async () => { try { setLoading(true) setError(null) const params = { type: typeFilter } if (typeFilter === 'bookmarked') { // 已标记的推荐:支持状态和方向过滤 if (statusFilter) params.status = statusFilter if (directionFilter) params.direction = directionFilter params.limit = 50 } else { // 实时推荐:只支持方向过滤 if (directionFilter) params.direction = directionFilter params.limit = 50 params.min_signal_strength = 5 } const result = await api.getRecommendations(params) const data = result.data || [] setRecommendations(data) } catch (err) { setError(err.message) console.error('加载推荐失败:', err) } finally { setLoading(false) } } const handleBookmark = async (rec) => { try { setBookmarking(prev => ({ ...prev, [rec.symbol || rec.id]: true })) await api.bookmarkRecommendation(rec) // 标记成功后,可以显示提示或更新状态 alert('推荐已标记到数据库,可用于复盘') // 如果当前查看的是已标记推荐,重新加载 if (typeFilter === 'bookmarked') { loadRecommendations() } } catch (err) { alert('标记失败: ' + err.message) console.error('标记推荐失败:', err) } finally { setBookmarking(prev => { const newState = { ...prev } delete newState[rec.symbol || rec.id] return newState }) } } const handleGenerate = async () => { try { setGenerating(true) setError(null) await api.generateRecommendations(5, 20) await loadRecommendations() alert('推荐生成成功!') } catch (err) { setError(err.message) alert(`生成推荐失败: ${err.message}`) } finally { setGenerating(false) } } const handleMarkExecuted = async (id) => { if (!window.confirm('确认标记为已执行?')) return try { await api.markRecommendationExecuted(id) await loadRecommendations() alert('已标记为执行') } catch (err) { alert(`标记失败: ${err.message}`) } } const handleCancel = async (id) => { const notes = window.prompt('请输入取消原因(可选):') if (notes === null) return try { await api.cancelRecommendation(id, notes || null) await loadRecommendations() alert('推荐已取消') } catch (err) { alert(`取消失败: ${err.message}`) } } const toggleDetails = (id) => { setShowDetails(prev => ({ ...prev, [id]: !prev[id] })) } const formatTime = (timeStr) => { if (!timeStr) return '-' try { // 统一按“北京时间”显示,但要先把输入解析成正确的时间点(epoch) // 注意:部分来源会给出不带时区的字符串(如 "2026-01-19T05:54:04" 或 "2026-01-19 05:54:04") // 这类字符串在浏览器里会被当作“本地时间”解析,导致显示偏差。 const raw = String(timeStr).trim() let date = null // 1) 纯数字:兼容 seconds/ms if (/^\d+$/.test(raw)) { const n = Number(raw) const ms = raw.length >= 13 ? n : n * 1000 date = new Date(ms) } else { // 2) "YYYY-MM-DD HH:mm:ss" -> 当作 UTC(历史推荐里常见),再转北京时间显示 if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(raw)) { const isoUtc = raw.replace(' ', 'T') + 'Z' date = new Date(isoUtc) } else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(raw)) { // 3) ISO 但无时区 -> 当作 UTC date = new Date(raw + 'Z') } else { // 4) 其他格式(包含 Z / +08:00 / RFC1123 等)交给浏览器解析 date = new Date(raw) } } // 检查日期是否有效 if (!date || isNaN(date.getTime())) { return timeStr } // 使用本地时区格式化(显示北京时间,如果服务器返回的是UTC时间) return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'Asia/Shanghai' // 明确使用北京时间 }) } catch (e) { return timeStr } } const getEntryStatus = (rec) => { try { const entry = Number(rec?.suggested_limit_price ?? rec?.planned_entry_price) const cur = Number(rec?.current_price) if (!isFinite(entry) || !isFinite(cur) || !rec?.direction) return null if (rec.direction === 'BUY') { return cur <= entry ? { text: '已到价', cls: 'hit' } : { text: '未到价', cls: 'wait' } } return cur >= entry ? { text: '已到价', cls: 'hit' } : { text: '未到价', cls: 'wait' } } catch (e) { return null } } const formatTimeMs = (ms) => { if (!ms && ms !== 0) return '-' try { const date = new Date(Number(ms)) if (isNaN(date.getTime())) return '-' return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'Asia/Shanghai' }) } catch (e) { return '-' } } const fmtPrice = (v) => { const n = Number(v || 0) if (!isFinite(n)) return '-' // 低价币用更多小数位,避免“挂单价=止损价”只是显示精度导致的误解 if (Math.abs(n) >= 100) return n.toFixed(2) if (Math.abs(n) >= 1) return n.toFixed(4) if (Math.abs(n) >= 0.01) return n.toFixed(6) return n.toFixed(8) } const getStatusBadge = (status) => { const statusMap = { active: { text: '有效', class: 'status-active' }, executed: { text: '已执行', class: 'status-executed' }, expired: { text: '已过期', class: 'status-expired' }, cancelled: { text: '已取消', class: 'status-cancelled' } } const statusInfo = statusMap[status] || { text: status, class: 'status-unknown' } return {statusInfo.text} } const getSignalStrengthColor = (strength) => { if (strength >= 8) return 'signal-strong' if (strength >= 6) return 'signal-medium' return 'signal-weak' } return (

交易推荐

{typeFilter === 'bookmarked' && ( )}
{error && (
{error}
)} {loading ? (
加载中...
) : recommendations.length === 0 ? (

暂无推荐记录

) : (
{recommendations.map((rec) => (
{rec.symbol} {rec.direction === 'BUY' ? '做多' : '做空'} {getStatusBadge(rec.status)}
信号强度: {rec.signal_strength}/10 {formatTime(rec.recommendation_time)}(北京)
{/* 推荐分类和风险等级标签 */} {(rec.recommendation_category || rec.risk_level) && (
{rec.recommendation_category && ( {rec.recommendation_category} )} {rec.risk_level && ( 风险: {rec.risk_level} )}
)}
{fmtPrice(rec.current_price)} USDT {rec.current_price_source === 'mark_price' && ( 🟢 )}
{(rec.current_price_source || rec.current_price_time_ms) && (
{rec.current_price_source === 'mark_price' ? '标记价' : (rec.current_price_source || 'snapshot')} {rec.current_price_time_ms ? ` · ${formatTimeMs(rec.current_price_time_ms)}` : ''}
)} {rec.change_percent !== undefined && rec.change_percent !== null && (
= 0 ? 'positive' : 'negative'}> {rec.change_percent >= 0 ? '+' : ''}{parseFloat(rec.change_percent).toFixed(2)}%
)}
{/* 用户指南(人话版计划) */} {rec.user_guide && (
📋 操作计划
{rec.direction === 'BUY' ? ( ) : ( )} 入场 {fmtPrice(rec.suggested_limit_price ?? rec.planned_entry_price)} USDT {(() => { const st = getEntryStatus(rec) return st ? {st.text} : null })()} 止损 {fmtPrice(rec.suggested_stop_loss)} USDT 目标1 {fmtPrice(rec.suggested_take_profit_1)} USDT 目标2 {fmtPrice(rec.suggested_take_profit_2)} USDT
)}
{typeFilter === 'realtime' && ( )}
{/* 预期持仓时间(如果存在) */} {rec.expected_hold_time && (
⏱️ 预期持仓时间: {rec.expected_hold_time}
)} {/* 最大持仓天数提醒 */} {rec.max_hold_days && (
⚠️ 退出提醒: 若持仓超过{rec.max_hold_days}天仍未触及第一目标,建议平仓离场重新评估
)} {showDetails[rec.id || rec.symbol] && (

交易提示

{rec.trading_tutorial || '-'}

技术分析原因

{rec.recommendation_reason || '-'}

建议参数

限价单
{rec.suggested_limit_price && (
{fmtPrice(rec.suggested_limit_price)} USDT {rec.current_price && ( ({rec.direction === 'BUY' ? '低于' : '高于'}当前价 {Math.abs(((rec.suggested_limit_price - rec.current_price) / rec.current_price) * 100).toFixed(2)}%) )}
)}
{fmtPrice(rec.suggested_stop_loss)}
{fmtPrice(rec.suggested_take_profit_1)}
{fmtPrice(rec.suggested_take_profit_2)}
{(parseFloat(rec.suggested_position_percent || 0) * 100).toFixed(2)}%
{rec.suggested_leverage || 10}x

技术指标

{rec.rsi && (
{parseFloat(rec.rsi).toFixed(2)}
)} {rec.macd_histogram !== null && rec.macd_histogram !== undefined && (
{parseFloat(rec.macd_histogram).toFixed(6)}
)} {rec.ema20 && (
{parseFloat(rec.ema20).toFixed(4)}
)} {rec.ema50 && (
{parseFloat(rec.ema50).toFixed(4)}
)} {rec.ema20_4h && (
{parseFloat(rec.ema20_4h).toFixed(4)}
)} {rec.atr && (
{parseFloat(rec.atr).toFixed(4)}
)}

市场状态

{rec.market_regime === 'trending' ? '趋势' : rec.market_regime === 'ranging' ? '震荡' : '-'}
{rec.trend_4h === 'up' ? '向上' : rec.trend_4h === 'down' ? '向下' : rec.trend_4h === 'neutral' ? '中性' : '-'}
{rec.volume_24h && (
{(parseFloat(rec.volume_24h) / 1000000).toFixed(2)}M USDT
)}
{rec.bollinger_upper && (

布林带

{parseFloat(rec.bollinger_upper).toFixed(4)}
{parseFloat(rec.bollinger_middle).toFixed(4)}
{parseFloat(rec.bollinger_lower).toFixed(4)}
)}
)} {rec.status === 'active' && (
)}
))}
)}
) } export default Recommendations