352 lines
13 KiB
JavaScript
352 lines
13 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { api } from '../services/api';
|
||
import './RecommendationsViewer.css';
|
||
|
||
function RecommendationsViewer() {
|
||
const [recommendations, setRecommendations] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [directionFilter, setDirectionFilter] = useState('');
|
||
const [showDetails, setShowDetails] = useState({});
|
||
|
||
useEffect(() => {
|
||
loadRecommendations();
|
||
|
||
// 每10秒静默更新价格(不触发loading状态)
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
const result = await api.getRecommendations({
|
||
type: 'realtime',
|
||
direction: directionFilter,
|
||
limit: 50,
|
||
min_signal_strength: 5
|
||
});
|
||
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 () => {
|
||
clearInterval(interval);
|
||
};
|
||
}, [directionFilter]);
|
||
|
||
const loadRecommendations = async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
const params = {
|
||
type: 'realtime',
|
||
limit: 50,
|
||
min_signal_strength: 5
|
||
};
|
||
|
||
if (directionFilter) {
|
||
params.direction = directionFilter;
|
||
}
|
||
|
||
const result = await api.getRecommendations(params);
|
||
const data = result.data || [];
|
||
|
||
setRecommendations(data);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
console.error('加载推荐失败:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const toggleDetails = (key) => {
|
||
setShowDetails(prev => ({
|
||
...prev,
|
||
[key]: !prev[key]
|
||
}));
|
||
};
|
||
|
||
const formatTime = (timeStr) => {
|
||
if (!timeStr) return '-';
|
||
try {
|
||
const date = new Date(timeStr);
|
||
if (isNaN(date.getTime())) return timeStr;
|
||
// 使用北京时间格式化
|
||
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 getSignalStrengthColor = (strength) => {
|
||
if (strength >= 8) return 'signal-strong';
|
||
if (strength >= 6) return 'signal-medium';
|
||
return 'signal-weak';
|
||
};
|
||
|
||
return (
|
||
<div className="recommendations-viewer">
|
||
<div className="viewer-header">
|
||
<h1>交易推荐</h1>
|
||
<div className="header-info">
|
||
<span className="update-info">每10秒自动更新</span>
|
||
<button
|
||
className="btn-refresh"
|
||
onClick={loadRecommendations}
|
||
disabled={loading}
|
||
>
|
||
{loading ? '刷新中...' : '手动刷新'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filters">
|
||
<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>
|
||
</div>
|
||
) : (
|
||
<div className="recommendations-list">
|
||
{recommendations.map((rec) => {
|
||
const key = rec.id || rec.symbol;
|
||
return (
|
||
<div key={key} 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>
|
||
</div>
|
||
<div className="card-meta">
|
||
<span className={`signal-strength ${getSignalStrengthColor(rec.signal_strength)}`}>
|
||
信号强度: {rec.signal_strength}/10
|
||
</span>
|
||
{rec.recommendation_time && (
|
||
<span className="time">{formatTime(rec.recommendation_time)}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card-content">
|
||
<div className="price-info">
|
||
<div className="price-item">
|
||
<label>当前价格:</label>
|
||
<span>
|
||
{parseFloat(rec.current_price || 0).toFixed(4)} USDT
|
||
{rec.price_updated && (
|
||
<span className="price-updated-badge" title="价格已通过WebSocket实时更新">🟢</span>
|
||
)}
|
||
</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>
|
||
|
||
<div className="recommendation-reason">
|
||
<strong>推荐原因:</strong>
|
||
<p>{rec.recommendation_reason || '-'}</p>
|
||
</div>
|
||
|
||
<div className="suggested-params">
|
||
<div className="param-item order-type">
|
||
<label>订单类型:</label>
|
||
<span className={`order-type-badge ${(rec.order_type || 'LIMIT').toLowerCase()}`}>
|
||
{rec.order_type === 'LIMIT' ? '限价单' : '市价单'}
|
||
</span>
|
||
</div>
|
||
{rec.order_type === 'LIMIT' && rec.suggested_limit_price && (
|
||
<div className="param-item limit-price">
|
||
<label>建议挂单价:</label>
|
||
<span className="limit-price-value">
|
||
{parseFloat(rec.suggested_limit_price || 0).toFixed(4)} 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>{parseFloat(rec.suggested_stop_loss || 0).toFixed(4)}</span>
|
||
</div>
|
||
<div className="param-item">
|
||
<label>第一目标:</label>
|
||
<span>{parseFloat(rec.suggested_take_profit_1 || 0).toFixed(4)}</span>
|
||
</div>
|
||
<div className="param-item">
|
||
<label>第二目标:</label>
|
||
<span>{parseFloat(rec.suggested_take_profit_2 || 0).toFixed(4)}</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>
|
||
|
||
<button
|
||
className="btn-toggle-details"
|
||
onClick={() => toggleDetails(key)}
|
||
>
|
||
{showDetails[key] ? '隐藏' : '显示'}详细信息
|
||
</button>
|
||
|
||
{showDetails[key] && (
|
||
<div className="details-panel">
|
||
<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.atr && (
|
||
<div className="indicator-item">
|
||
<label>ATR:</label>
|
||
<span>{parseFloat(rec.atr).toFixed(4)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{rec.market_regime && (
|
||
<div className="details-section">
|
||
<h4>市场状态</h4>
|
||
<p>{rec.market_regime === 'trending' ? '趋势市场' : '震荡市场'}</p>
|
||
</div>
|
||
)}
|
||
|
||
{rec.trend_4h && (
|
||
<div className="details-section">
|
||
<h4>4H趋势</h4>
|
||
<p>
|
||
{rec.trend_4h === 'up' ? '上涨' :
|
||
rec.trend_4h === 'down' ? '下跌' : '中性'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{rec.volume_24h && (
|
||
<div className="details-section">
|
||
<h4>24小时成交量</h4>
|
||
<p>{parseFloat(rec.volume_24h).toFixed(2)}</p>
|
||
</div>
|
||
)}
|
||
|
||
{rec.volatility && (
|
||
<div className="details-section">
|
||
<h4>波动率</h4>
|
||
<p>{parseFloat(rec.volatility).toFixed(4)}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default RecommendationsViewer;
|