690 lines
27 KiB
JavaScript
690 lines
27 KiB
JavaScript
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 <span className={`status-badge ${statusInfo.class}`}>{statusInfo.text}</span>
|
||
}
|
||
|
||
const getSignalStrengthColor = (strength) => {
|
||
if (strength >= 8) return 'signal-strong'
|
||
if (strength >= 6) return 'signal-medium'
|
||
return 'signal-weak'
|
||
}
|
||
|
||
return (
|
||
<div className="recommendations-container">
|
||
<div className="recommendations-header">
|
||
<h2>交易推荐</h2>
|
||
<div className="header-actions">
|
||
<button
|
||
className="btn-generate"
|
||
onClick={handleGenerate}
|
||
disabled={generating}
|
||
>
|
||
{generating ? '生成中...' : '生成推荐'}
|
||
</button>
|
||
<button
|
||
className="btn-refresh"
|
||
onClick={loadRecommendations}
|
||
disabled={loading}
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filters">
|
||
<select
|
||
value={typeFilter}
|
||
onChange={(e) => {
|
||
setTypeFilter(e.target.value)
|
||
setStatusFilter('') // 切换类型时重置状态过滤
|
||
}}
|
||
className="filter-select"
|
||
>
|
||
<option value="realtime">实时推荐</option>
|
||
<option value="bookmarked">已标记推荐</option>
|
||
</select>
|
||
|
||
{typeFilter === 'bookmarked' && (
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="filter-select"
|
||
>
|
||
<option value="">全部状态</option>
|
||
<option value="active">有效</option>
|
||
<option value="executed">已执行</option>
|
||
<option value="expired">已过期</option>
|
||
<option value="cancelled">已取消</option>
|
||
</select>
|
||
)}
|
||
|
||
<select
|
||
value={directionFilter}
|
||
onChange={(e) => setDirectionFilter(e.target.value)}
|
||
className="filter-select"
|
||
>
|
||
<option value="">全部方向</option>
|
||
<option value="BUY">做多</option>
|
||
<option value="SELL">做空</option>
|
||
</select>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="error-message">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="loading">加载中...</div>
|
||
) : recommendations.length === 0 ? (
|
||
<div className="empty-state">
|
||
<p>暂无推荐记录</p>
|
||
<button className="btn-generate" onClick={handleGenerate}>
|
||
生成推荐
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="recommendations-list">
|
||
{recommendations.map((rec) => (
|
||
<div key={rec.id || rec.symbol} className="recommendation-card">
|
||
<div className="card-header">
|
||
<div className="card-title">
|
||
<span className="symbol">{rec.symbol}</span>
|
||
<span className={`direction ${rec.direction.toLowerCase()}`}>
|
||
{rec.direction === 'BUY' ? '做多' : '做空'}
|
||
</span>
|
||
{getStatusBadge(rec.status)}
|
||
</div>
|
||
<div className="card-meta">
|
||
<span className={`signal-strength ${getSignalStrengthColor(rec.signal_strength)}`}>
|
||
信号强度: {rec.signal_strength}/10
|
||
</span>
|
||
<span className="time" title="北京时间">
|
||
{formatTime(rec.recommendation_time)}(北京)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card-content">
|
||
{/* 推荐分类和风险等级标签 */}
|
||
{(rec.recommendation_category || rec.risk_level) && (
|
||
<div className="recommendation-tags">
|
||
{rec.recommendation_category && (
|
||
<span className={`category-tag category-${rec.recommendation_category?.replace(/[(\(\))]/g, '').replace(/\s+/g, '-').toLowerCase() || 'default'}`}>
|
||
{rec.recommendation_category}
|
||
</span>
|
||
)}
|
||
{rec.risk_level && (
|
||
<span className={`risk-tag risk-${rec.risk_level?.replace(/\s+/g, '-').toLowerCase() || 'medium'}`}>
|
||
风险: {rec.risk_level}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="price-info">
|
||
<div className="price-item">
|
||
<label>当前价格:</label>
|
||
<span>
|
||
{fmtPrice(rec.current_price)} USDT
|
||
{rec.current_price_source === 'mark_price' && (
|
||
<span
|
||
className="price-updated-badge"
|
||
title={`标记价(Binance premiumIndex) 更新时间: ${formatTimeMs(rec.current_price_time_ms)}`}
|
||
>
|
||
🟢
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{(rec.current_price_source || rec.current_price_time_ms) && (
|
||
<div className="price-item price-meta">
|
||
<label>价格时间:</label>
|
||
<span>
|
||
{rec.current_price_source === 'mark_price' ? '标记价' : (rec.current_price_source || 'snapshot')}
|
||
{rec.current_price_time_ms ? ` · ${formatTimeMs(rec.current_price_time_ms)}` : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{rec.change_percent !== undefined && rec.change_percent !== null && (
|
||
<div className="price-item">
|
||
<label>24h涨跌:</label>
|
||
<span className={rec.change_percent >= 0 ? 'positive' : 'negative'}>
|
||
{rec.change_percent >= 0 ? '+' : ''}{parseFloat(rec.change_percent).toFixed(2)}%
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 用户指南(人话版计划) */}
|
||
{rec.user_guide && (
|
||
<div className={`user-guide ${rec.direction === 'BUY' ? 'buy-bg' : 'sell-bg'}`}>
|
||
<div className="user-guide-head">
|
||
<strong>📋 操作计划</strong>
|
||
<div className="user-guide-prices">
|
||
<span className={`ug-direction-badge ${rec.direction === 'BUY' ? 'buy' : 'sell'}`} title={rec.direction === 'BUY' ? '做多' : '做空'}>
|
||
{rec.direction === 'BUY' ? (
|
||
<TrendingUp className="ug-direction-icon" aria-label="做多" size={18} />
|
||
) : (
|
||
<TrendingDown className="ug-direction-icon" aria-label="做空" size={18} />
|
||
)}
|
||
</span>
|
||
<span className="ug-pill entry text-white text-xl">
|
||
入场 {fmtPrice(rec.suggested_limit_price ?? rec.planned_entry_price)} USDT
|
||
{(() => {
|
||
const st = getEntryStatus(rec)
|
||
return st ? <span className={`ug-entry-status ${st.cls}`}>{st.text}</span> : null
|
||
})()}
|
||
</span>
|
||
<span className="ug-pill stop">
|
||
止损 {fmtPrice(rec.suggested_stop_loss)} USDT
|
||
</span>
|
||
<span className="ug-pill tp1">
|
||
目标1 {fmtPrice(rec.suggested_take_profit_1)} USDT
|
||
</span>
|
||
<span className="ug-pill tp2">
|
||
目标2 {fmtPrice(rec.suggested_take_profit_2)} USDT
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<CollapsibleUserGuide text={rec.user_guide} maxHeight={160} />
|
||
</div>
|
||
)}
|
||
|
||
<div className="card-actions">
|
||
{typeFilter === 'realtime' && (
|
||
<button
|
||
className="btn-bookmark"
|
||
onClick={() => handleBookmark(rec)}
|
||
disabled={bookmarking[rec.symbol || rec.id]}
|
||
title="标记到数据库用于复盘"
|
||
>
|
||
{bookmarking[rec.symbol || rec.id] ? '标记中...' : '📌 标记'}
|
||
</button>
|
||
)}
|
||
<button
|
||
className="btn-toggle-details"
|
||
onClick={() => toggleDetails(rec.id || rec.symbol)}
|
||
>
|
||
{showDetails[rec.id || rec.symbol] ? '隐藏' : '显示'}详细信息
|
||
</button>
|
||
</div>
|
||
|
||
{/* 预期持仓时间(如果存在) */}
|
||
{rec.expected_hold_time && (
|
||
<div className="expected-hold-time">
|
||
<strong>⏱️ 预期持仓时间:</strong>
|
||
<span>{rec.expected_hold_time}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 最大持仓天数提醒 */}
|
||
{rec.max_hold_days && (
|
||
<div className="max-hold-warning">
|
||
<strong>⚠️ 退出提醒:</strong>
|
||
<span>若持仓超过{rec.max_hold_days}天仍未触及第一目标,建议平仓离场重新评估</span>
|
||
</div>
|
||
)}
|
||
|
||
{showDetails[rec.id || rec.symbol] && (
|
||
<div className="details-panel">
|
||
<div className="details-section">
|
||
<h4>交易提示</h4>
|
||
<div className="market-info">
|
||
<div className="info-item">
|
||
<label>提示:</label>
|
||
<span>{rec.trading_tutorial || '-'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="details-section">
|
||
<h4>技术分析原因</h4>
|
||
<div className="market-info">
|
||
<div className="info-item">
|
||
<label>原因:</label>
|
||
<span>{rec.recommendation_reason || '-'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="details-section">
|
||
<h4>建议参数</h4>
|
||
<div className="suggested-params">
|
||
<div className="param-item order-type">
|
||
<label>订单类型:</label>
|
||
<span className="order-type-badge limit">限价单</span>
|
||
</div>
|
||
{rec.suggested_limit_price && (
|
||
<div className="param-item limit-price highlight">
|
||
<label>建议挂单价:</label>
|
||
<span className="limit-price-value highlight-price">
|
||
{fmtPrice(rec.suggested_limit_price)} USDT
|
||
{rec.current_price && (
|
||
<span className="price-diff">
|
||
({rec.direction === 'BUY' ? '低于' : '高于'}当前价
|
||
{Math.abs(((rec.suggested_limit_price - rec.current_price) / rec.current_price) * 100).toFixed(2)}%)
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="param-item">
|
||
<label>建议止损:</label>
|
||
<span>{fmtPrice(rec.suggested_stop_loss)}</span>
|
||
</div>
|
||
<div className="param-item">
|
||
<label>第一目标:</label>
|
||
<span>{fmtPrice(rec.suggested_take_profit_1)}</span>
|
||
</div>
|
||
<div className="param-item">
|
||
<label>第二目标:</label>
|
||
<span>{fmtPrice(rec.suggested_take_profit_2)}</span>
|
||
</div>
|
||
<div className="param-item">
|
||
<label>建议仓位:</label>
|
||
<span>{(parseFloat(rec.suggested_position_percent || 0) * 100).toFixed(2)}%</span>
|
||
</div>
|
||
<div className="param-item">
|
||
<label>建议杠杆:</label>
|
||
<span>{rec.suggested_leverage || 10}x</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="details-section">
|
||
<h4>技术指标</h4>
|
||
<div className="indicators-grid">
|
||
{rec.rsi && (
|
||
<div className="indicator-item">
|
||
<label>RSI:</label>
|
||
<span>{parseFloat(rec.rsi).toFixed(2)}</span>
|
||
</div>
|
||
)}
|
||
{rec.macd_histogram !== null && rec.macd_histogram !== undefined && (
|
||
<div className="indicator-item">
|
||
<label>MACD:</label>
|
||
<span>{parseFloat(rec.macd_histogram).toFixed(6)}</span>
|
||
</div>
|
||
)}
|
||
{rec.ema20 && (
|
||
<div className="indicator-item">
|
||
<label>EMA20:</label>
|
||
<span>{parseFloat(rec.ema20).toFixed(4)}</span>
|
||
</div>
|
||
)}
|
||
{rec.ema50 && (
|
||
<div className="indicator-item">
|
||
<label>EMA50:</label>
|
||
<span>{parseFloat(rec.ema50).toFixed(4)}</span>
|
||
</div>
|
||
)}
|
||
{rec.ema20_4h && (
|
||
<div className="indicator-item">
|
||
<label>EMA20(4H):</label>
|
||
<span>{parseFloat(rec.ema20_4h).toFixed(4)}</span>
|
||
</div>
|
||
)}
|
||
{rec.atr && (
|
||
<div className="indicator-item">
|
||
<label>ATR:</label>
|
||
<span>{parseFloat(rec.atr).toFixed(4)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="details-section">
|
||
<h4>市场状态</h4>
|
||
<div className="market-info">
|
||
<div className="info-item">
|
||
<label>市场状态:</label>
|
||
<span>{rec.market_regime === 'trending' ? '趋势' : rec.market_regime === 'ranging' ? '震荡' : '-'}</span>
|
||
</div>
|
||
<div className="info-item">
|
||
<label>4H趋势:</label>
|
||
<span>
|
||
{rec.trend_4h === 'up' ? '向上' : rec.trend_4h === 'down' ? '向下' : rec.trend_4h === 'neutral' ? '中性' : '-'}
|
||
</span>
|
||
</div>
|
||
{rec.volume_24h && (
|
||
<div className="info-item">
|
||
<label>24h成交量:</label>
|
||
<span>{(parseFloat(rec.volume_24h) / 1000000).toFixed(2)}M USDT</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{rec.bollinger_upper && (
|
||
<div className="details-section">
|
||
<h4>布林带</h4>
|
||
<div className="bollinger-info">
|
||
<div className="bollinger-item">
|
||
<label>上轨:</label>
|
||
<span>{parseFloat(rec.bollinger_upper).toFixed(4)}</span>
|
||
</div>
|
||
<div className="bollinger-item">
|
||
<label>中轨:</label>
|
||
<span>{parseFloat(rec.bollinger_middle).toFixed(4)}</span>
|
||
</div>
|
||
<div className="bollinger-item">
|
||
<label>下轨:</label>
|
||
<span>{parseFloat(rec.bollinger_lower).toFixed(4)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{rec.status === 'active' && (
|
||
<div className="card-actions">
|
||
<button
|
||
className="btn-execute"
|
||
onClick={() => handleMarkExecuted(rec.id)}
|
||
>
|
||
标记已执行
|
||
</button>
|
||
<button
|
||
className="btn-cancel"
|
||
onClick={() => handleCancel(rec.id)}
|
||
>
|
||
取消推荐
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Recommendations
|