增加推荐站

This commit is contained in:
薇薇安 2026-01-15 20:21:10 +08:00
parent e00cbeb1fe
commit 37d766865b
10 changed files with 975 additions and 0 deletions

30
recommendations-viewer/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.production.local
.env.development.local

View File

@ -0,0 +1,79 @@
# 交易推荐查看器
一个独立的React前端应用用于查看实时交易推荐。
## 功能特点
- ✅ 实时查看交易推荐(基于当前行情数据)
- ✅ 每10秒自动刷新价格
- ✅ 支持按方向过滤(做多/做空)
- ✅ 显示详细的推荐信息(价格、止损、止盈、仓位、杠杆等)
- ✅ 显示技术指标详情
- ✅ 响应式设计,支持移动端
## 安装和运行
### 1. 安装依赖
```bash
cd recommendations-viewer
npm install
```
### 2. 配置API地址可选
如果需要修改API地址可以
- 在项目根目录创建 `.env` 文件:
```
VITE_API_URL=http://your-api-url.com
```
- 或者修改 `vite.config.js` 中的代理配置
### 3. 启动开发服务器
```bash
npm run dev
```
应用将在 `http://localhost:3001` 启动
### 4. 构建生产版本
```bash
npm run build
```
构建后的文件在 `dist` 目录中
## 项目结构
```
recommendations-viewer/
├── src/
│ ├── components/
│ │ ├── RecommendationsViewer.jsx # 主组件
│ │ └── RecommendationsViewer.css # 样式文件
│ ├── services/
│ │ └── api.js # API服务
│ ├── main.jsx # 应用入口
│ └── index.css # 全局样式
├── index.html # HTML模板
├── package.json # 项目配置
├── vite.config.js # Vite配置
└── README.md # 说明文档
```
## API接口
应用使用以下API接口
- `GET /api/recommendations?type=realtime&direction=BUY&limit=50&min_signal_strength=5`
- 获取实时推荐列表
## 注意事项
- 这是一个只读应用,不包含任何操作功能(如标记、执行、取消等)
- 推荐数据每10秒自动刷新确保价格信息实时更新
- 如果API服务不可用会显示错误信息

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
<title>交易推荐查看</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,18 @@
{
"name": "recommendations-viewer",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
}
}

View File

@ -0,0 +1,412 @@
.recommendations-viewer {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
background-color: #f5f5f5;
}
.viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.viewer-header h1 {
margin: 0;
color: #333;
font-size: 24px;
}
.header-info {
display: flex;
align-items: center;
gap: 15px;
}
.update-info {
font-size: 12px;
color: #666;
}
.btn-refresh {
padding: 8px 16px;
background-color: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-refresh:hover:not(:disabled) {
background-color: #0b7dda;
}
.btn-refresh:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
padding: 15px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background-color: white;
cursor: pointer;
}
.error-message {
padding: 12px;
background-color: #ffebee;
color: #c62828;
border-radius: 4px;
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
background-color: white;
border-radius: 8px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
background-color: white;
border-radius: 8px;
}
.empty-state p {
margin-bottom: 20px;
font-size: 16px;
}
.recommendations-list {
display: grid;
gap: 20px;
}
.recommendation-card {
background-color: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s;
}
.recommendation-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.card-title {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.symbol {
font-size: 18px;
font-weight: bold;
color: #333;
}
.direction {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.direction.buy {
background-color: #4CAF50;
color: white;
}
.direction.sell {
background-color: #f44336;
color: white;
}
.card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 5px;
}
.signal-strength {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.signal-strong {
background-color: #4CAF50;
color: white;
}
.signal-medium {
background-color: #FF9800;
color: white;
}
.signal-weak {
background-color: #9E9E9E;
color: white;
}
.time {
font-size: 12px;
color: #666;
}
.card-content {
display: flex;
flex-direction: column;
gap: 15px;
}
.price-info {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.price-item {
display: flex;
align-items: center;
gap: 8px;
}
.price-item label {
font-weight: bold;
color: #666;
}
.price-item .positive {
color: #4CAF50;
}
.price-item .negative {
color: #f44336;
}
.price-updated-badge {
margin-left: 5px;
font-size: 10px;
}
.recommendation-reason {
background-color: #f5f5f5;
padding: 12px;
border-radius: 4px;
}
.recommendation-reason strong {
display: block;
margin-bottom: 8px;
color: #333;
}
.recommendation-reason p {
margin: 0;
color: #666;
line-height: 1.5;
}
.suggested-params {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 4px;
}
.param-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.param-item label {
font-size: 12px;
color: #666;
font-weight: bold;
}
.param-item span {
font-size: 14px;
color: #333;
font-weight: 500;
}
.param-item.order-type {
grid-column: span 2;
}
.order-type-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.order-type-badge.limit {
background-color: #2196F3;
color: white;
}
.order-type-badge.market {
background-color: #FF9800;
color: white;
}
.param-item.limit-price {
grid-column: span 2;
}
.limit-price-value {
display: flex;
flex-direction: column;
gap: 4px;
}
.price-diff {
font-size: 11px;
color: #666;
font-weight: normal;
font-style: italic;
}
.btn-toggle-details {
padding: 8px 16px;
background-color: #e0e0e0;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
align-self: flex-start;
}
.btn-toggle-details:hover {
background-color: #d0d0d0;
}
.details-panel {
margin-top: 15px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.details-section {
margin-bottom: 20px;
}
.details-section:last-child {
margin-bottom: 0;
}
.details-section h4 {
margin: 0 0 10px 0;
color: #333;
font-size: 14px;
}
.indicators-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.indicator-item {
display: flex;
justify-content: space-between;
padding: 8px;
background-color: white;
border-radius: 4px;
}
.indicator-item label {
font-size: 12px;
color: #666;
}
.indicator-item span {
font-size: 12px;
color: #333;
font-weight: 500;
}
/* 响应式设计 */
@media (max-width: 768px) {
.recommendations-viewer {
padding: 10px;
}
.viewer-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.header-info {
width: 100%;
justify-content: space-between;
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
.card-meta {
align-items: flex-start;
margin-top: 10px;
}
.suggested-params {
grid-template-columns: 1fr;
}
.param-item.order-type,
.param-item.limit-price {
grid-column: span 1;
}
}

View File

@ -0,0 +1,349 @@
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();
// 10loading
const interval = setInterval(async () => {
try {
const result = await api.getRecommendations({
type: 'realtime',
direction: directionFilter,
limit: 50,
min_signal_strength: 5
});
const newData = result.data || [];
// 使setStateloading
setRecommendations(prevRecommendations => {
if (newData.length === 0) {
return prevRecommendations;
}
// id使symbolkey
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);
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;

View File

@ -0,0 +1,24 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #333;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
#root {
min-height: 100vh;
}

View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import RecommendationsViewer from './components/RecommendationsViewer'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RecommendationsViewer />
</React.StrictMode>,
)

View File

@ -0,0 +1,26 @@
// API服务 - 只包含推荐查询功能
const API_BASE_URL = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? '' : 'http://localhost:8000');
const buildUrl = (path) => {
const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return baseUrl ? `${baseUrl}${cleanPath}` : cleanPath;
};
export const api = {
// 获取实时推荐
getRecommendations: async (params = {}) => {
// 默认使用实时推荐
if (!params.type) {
params.type = 'realtime';
}
const query = new URLSearchParams(params).toString();
const url = query ? `${buildUrl('/api/recommendations')}?${query}` : buildUrl('/api/recommendations');
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取推荐失败' }));
throw new Error(error.detail || '获取推荐失败');
}
return response.json();
}
};

View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://asapi.deepx1.com',
changeOrigin: true
}
}
}
})