增加推荐站
This commit is contained in:
parent
e00cbeb1fe
commit
37d766865b
30
recommendations-viewer/.gitignore
vendored
Normal file
30
recommendations-viewer/.gitignore
vendored
Normal 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
|
||||||
79
recommendations-viewer/README.md
Normal file
79
recommendations-viewer/README.md
Normal 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服务不可用,会显示错误信息
|
||||||
12
recommendations-viewer/index.html
Normal file
12
recommendations-viewer/index.html
Normal 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>
|
||||||
18
recommendations-viewer/package.json
Normal file
18
recommendations-viewer/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
412
recommendations-viewer/src/components/RecommendationsViewer.css
Normal file
412
recommendations-viewer/src/components/RecommendationsViewer.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
349
recommendations-viewer/src/components/RecommendationsViewer.jsx
Normal file
349
recommendations-viewer/src/components/RecommendationsViewer.jsx
Normal 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();
|
||||||
|
|
||||||
|
// 每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);
|
||||||
|
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;
|
||||||
24
recommendations-viewer/src/index.css
Normal file
24
recommendations-viewer/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
10
recommendations-viewer/src/main.jsx
Normal file
10
recommendations-viewer/src/main.jsx
Normal 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>,
|
||||||
|
)
|
||||||
26
recommendations-viewer/src/services/api.js
Normal file
26
recommendations-viewer/src/services/api.js
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
15
recommendations-viewer/vite.config.js
Normal file
15
recommendations-viewer/vite.config.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user