Compare commits

..

13 Commits

Author SHA1 Message Date
WrBug f1eae8a40b chore: 移除 Telegram PR 合并通知工作流 2026-01-31 00:59:45 +08:00
WrBug ca655f351d docs: 添加 v2.0.3 发布说明 2026-01-31 00:57:45 +08:00
WrBug 40081c2464 feat: 为所有数值显示添加千分位分隔符
- 重构 formatNumber 和 formatUSDC 函数,默认添加千分位分隔符
- 更新 Statistics.tsx、CopyTradingStatistics.tsx 等统计页面
- 更新 PositionList.tsx 持仓列表的数值显示
- 更新 AccountList.tsx 账户列表的余额显示
- 所有数值(金额、数量、价格等)现在默认显示千分位
- 示例:1234567.89 显示为 1,234,567.89

影响范围:
- 工具函数:utils/index.ts
- 统计页面:Statistics.tsx, CopyTradingStatistics.tsx
- 业务页面:PositionList.tsx, AccountList.tsx

Changes:
- Refactored formatNumber and formatUSDC to include thousand separators by default
- Updated statistics pages to display numbers with commas
- Updated position list and account list for better readability
- Example: 1234567.89 now displays as 1,234,567.89
2026-01-31 00:52:42 +08:00
WrBug e8fd1b503b 移除跟单最大仓位数量(maxPositionCount)配置
- 数据库:创建迁移文件 V26 删除 max_position_count 字段
- 后端:移除实体类、DTO、服务中的 maxPositionCount 相关代码
- 后端:移除 FilterResult 中的 FAILED_MAX_POSITION_COUNT 状态
- 后端:移除 CopyTradingFilterService 中的最大仓位数量检查逻辑
- 前端:移除类型定义、表单字段和国际化翻译
- 前端:移除过滤订单列表中的 MAX_POSITION_COUNT 类型
- 前端:修复编译错误(Spin 导入、Table fixed 类型、size 类型)
2026-01-31 00:24:25 +08:00
WrBug 390b3ee876 feat: 在创建跟单配置时显示Leader资产信息
- 选择Leader后自动获取并显示资产信息(总资产、可用余额、仓位资产)
- 在AddModal和EditModal中都实现该功能
- 使用Card和Statistic组件美观展示
- 添加中英文多语言支持
- 使用formatUSDC格式化显示金额
2026-01-30 23:58:18 +08:00
WrBug 80976609c7 fix: 修复TypeScript类型错误
- 移除未使用的PositionDto导入
- 修复pnl参数重复声明
2026-01-30 23:29:42 +08:00
WrBug 17eea0183f refactor: 列表只显示可用余额
- 桌面和移动端都只显示可用余额
- 移除总余额显示
2026-01-30 23:13:26 +08:00
WrBug 6980781f89 refactor: 移除仓位资产列
- 列表只显示总余额和跟单数
- 移除仓位资产列
- 移动端卡片移除仓位资产Tag
2026-01-30 22:42:44 +08:00
WrBug 3350039f05 feat: Leader列表显示仓位资产
- 列表新增仓位资产列
- 移动端卡片新增仓位资产Tag
- 文案'仓位余额'改为'仓位资产'
- 仓位资产使用紫色Tag显示
2026-01-30 22:24:59 +08:00
WrBug 0bdc0c74d1 feat: Leader列表优化
- 后端过滤价值为0的仓位
- 持仓列表显示市场名称而非ID
- 列表移除分类和创建时间列
- 文案'跟单关系数'改为'跟单数'
- 持仓DTO添加title字段
2026-01-30 22:03:50 +08:00
WrBug fa1a915e9c docs: 添加 v2.0.2 release notes 2026-01-29 04:00:11 +08:00
WrBug e5992b5145 feat(workflow): 优化构建流程和通知机制
- 添加 workflow_dispatch 支持手动触发构建
- Release 事件默认只打包产物(在线升级),不构建 Docker
- 手动触发可选择构建类型:在线升级或 Docker 升级
- 优化 Telegram 通知文案,区分构建类型和升级路径
- 在线升级任务不发送开始通知,只发送完成通知
- 手动触发时产物上传为 Artifact,Release 事件上传到 Release Assets
2026-01-29 03:59:07 +08:00
WrBug 42472f6b03 fix: 修复买入订单金额精度问题
- 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
- makerAmount (USDC) 限制为最多 2 位小数
- takerAmount (shares) 限制为最多 4 位小数
- 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
2026-01-29 03:45:17 +08:00
33 changed files with 2005 additions and 1030 deletions
+119 -21
View File
@@ -4,6 +4,24 @@ on:
release:
types:
- published # 当通过 GitHub Releases 页面创建 release 时触发
workflow_dispatch:
inputs:
build_type:
description: '构建类型'
required: true
type: choice
options:
- package-only # 只打包产物
- package-and-docker # 打包产物 + Docker 镜像
default: 'package-and-docker'
version:
description: '版本号(例如: v1.0.0'
required: false
type: string
tag_name:
description: 'Git Tag 名称(留空则使用 version'
required: false
type: string
jobs:
build-and-push:
@@ -16,25 +34,61 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
- name: Determine build type
id: build_config
run: |
# 确定构建类型
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:默认只打包产物(不构建 Docker)
BUILD_TYPE="package-only"
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
else
# workflow_dispatch 事件:使用用户输入
BUILD_TYPE="${{ github.event.inputs.build_type }}"
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
fi
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
- name: Extract version and check if pre-release
id: extract_version
run: |
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0
TAG_NAME="${{ github.event.release.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
# 从不同事件源提取版本号
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:从 release tag 中提取
TAG_NAME="${{ github.event.release.tag_name }}"
IS_PRERELEASE="${{ github.event.release.prerelease }}"
else
# workflow_dispatch 事件:从输入参数中提取
TAG_NAME="${{ github.event.inputs.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME="${{ github.event.inputs.version }}"
fi
# 如果仍然为空,尝试从 git ref 中提取
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
# 不是 tag,尝试从分支名或 commit SHA 获取
TAG_NAME=${GITHUB_REF#refs/heads/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
fi
fi
fi
IS_PRERELEASE="false"
fi
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
exit 1
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
echo " 当前版本号: $TAG_NAME"
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
fi
VERSION=${TAG_NAME#v} # 移除 v 前缀
IS_PRERELEASE="${{ github.event.release.prerelease }}"
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
@@ -47,7 +101,7 @@ jobs:
fi
- name: Send Telegram notification (build started)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -59,12 +113,15 @@ jobs:
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_URL="${{ github.event.release.html_url }}"
# 构建消息内容(仅包含关键信息)
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
@@ -141,12 +198,18 @@ jobs:
echo "✅ 前端文件已复制"
# 创建版本信息文件
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
else
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
fi
cat > update-package/version.json <<EOF
{
"version": "${{ steps.extract_version.outputs.VERSION }}",
"tag": "${{ steps.extract_version.outputs.TAG }}",
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"releaseNotes": $(echo '${{ github.event.release.body }}' | jq -Rs .)
"releaseNotes": ${RELEASE_NOTES}
}
EOF
echo "✅ 版本信息已创建"
@@ -169,6 +232,7 @@ jobs:
echo "$CHECKSUM $FILE" > checksums.txt
- name: Upload Update Package to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -178,7 +242,8 @@ jobs:
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
asset_content_type: application/gzip
- name: Upload Checksums
- name: Upload Checksums to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -188,19 +253,32 @@ jobs:
asset_name: checksums.txt
asset_content_type: text/plain
- name: Upload Update Package as Artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
path: |
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
checksums.txt
retention-days: 30
- name: Set up Docker Buildx
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/setup-buildx-action@v3
with:
# 启用多架构构建支持
platforms: linux/amd64,linux/arm64
- name: Log in to Docker Hub
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Prepare Docker build context
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
run: |
echo "📦 准备 Docker 构建上下文..."
# 确保构建产物存在且可访问
@@ -217,6 +295,7 @@ jobs:
ls -lh backend/build/libs/*.jar
- name: Build and push Docker image
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/build-push-action@v5
with:
context: .
@@ -234,6 +313,12 @@ jobs:
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Skip Docker build notice
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
run: |
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only"
echo "✅ 仅打包产物已完成"
- name: Send Telegram notification
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
@@ -250,13 +335,26 @@ jobs:
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_NAME="${{ github.event.release.name }}"
RELEASE_URL="${{ github.event.release.html_url }}"
REPO_NAME="${{ github.repository }}"
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-103
View File
@@ -1,103 +0,0 @@
name: Telegram Notification on PR Merge
on:
pull_request:
types:
- closed # 当 PR 被关闭(合并或关闭)时触发
jobs:
notify:
runs-on: ubuntu-latest
# 只在 PR 被合并到 main 分支时执行
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get PR details
id: pr_details
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
# 获取 PR 详细信息
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
# 获取 PR 变更的文件列表
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
# 提取 PR 描述(body),保留换行,限制长度
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
if [ ${#PR_BODY} -gt 500 ]; then
PR_BODY="${PR_BODY:0:500}..."
fi
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
echo "$PR_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查 PR 是否被合并(而不是仅关闭)
PR_MERGED="${{ github.event.pull_request.merged }}"
if [ "$PR_MERGED" != "true" ]; then
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
exit 0
fi
# 检查必要的环境变量
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取 PR 基本信息
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_TITLE="${{ github.event.pull_request.title }}"
PR_URL="${{ github.event.pull_request.html_url }}"
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
# 获取 PR 详细信息
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
# 转义 PR 标题中的 HTML 特殊字符
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g')
# 构建消息内容(仅包含关键信息)
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 1
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 1
fi
+144
View File
@@ -0,0 +1,144 @@
# PolyHermes v2.0.2 Release Notes
## 📋 版本信息
- **版本号**: v2.0.2
- **发布日期**: 2026-01-29
- **基础版本**: v2.0.1
## 🎯 改动摘要
本次版本主要修复了买入订单金额精度问题,并优化了构建流程和通知机制。
---
## 🐛 Bug 修复
### 1. 修复买入订单金额精度问题
**问题描述**
- 市场买入订单创建时出现 `invalid amounts` 错误
- Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
- 之前的实现不符合 API 要求,导致订单创建失败
**修复内容**
- ✅ 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
- ✅ makerAmount (USDC) 限制为最多 2 位小数(之前为 4 位)
- ✅ takerAmount (shares) 限制为最多 4 位小数(之前为 2 位)
- ✅ 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
**影响范围**
- `OrderSigningService.kt` - 订单签名服务
**提交**: 42472f6
---
## 🔧 优化改进
### 优化构建流程和通知机制
**改进内容**
- ✅ 添加 workflow_dispatch 支持手动触发构建
- ✅ Release 事件默认只打包产物(在线升级),不构建 Docker
- ✅ 手动触发可选择构建类型:在线升级或 Docker 升级
- ✅ 优化 Telegram 通知文案,区分构建类型和升级路径
- ✅ 在线升级任务不发送开始通知,只发送完成通知
- ✅ 手动触发时产物上传为 ArtifactRelease 事件上传到 Release Assets
**影响范围**
- `.github/workflows/docker-build.yml` - GitHub Actions 工作流
**提交**: e5992b5
---
## ⚠️ 潜在问题和注意事项
### 1. 买入订单金额精度调整可能带来的影响
**问题说明**
本次修复调整了买入订单的金额精度限制,可能会带来以下影响:
#### 1.1 makerAmount 精度降低(4 位 → 2 位小数)
**影响**
- **订单金额可能被向下舍入**:由于 makerAmount (USDC) 从 4 位小数降低到 2 位小数,订单金额可能会被向下舍入
- **实际支付金额可能略低于预期**:例如,如果计算出的金额是 `0.5985 USDC`,现在会被舍入为 `0.59 USDC`,实际支付金额可能比预期少 `0.0085 USDC`
- **可能影响固定金额模式的跟单**:在固定金额模式下,如果金额被舍入,实际买入的数量可能会略少于预期
**建议**
- 在固定金额模式下,建议设置稍大一点的金额,以补偿可能的舍入损失
- 监控订单创建情况,确认金额是否符合预期
- 如果发现金额差异较大,可以适当调整跟单配置
#### 1.2 takerAmount 精度提高(2 位 → 4 位小数)
**影响**
- **订单数量精度提高**takerAmount (shares) 从 2 位小数提高到 4 位小数,可以更精确地指定买入数量
- **可能增加订单复杂度**:更高的精度可能导致一些边缘情况,需要确保数量计算正确
**建议**
- 验证订单数量是否符合预期
- 确认跟单数量计算逻辑是否正确
#### 1.3 订单创建成功率变化
**影响**
- **修复前**:订单创建可能因为精度问题失败
- **修复后**:订单创建成功率应该提高,但金额可能略有变化
**建议**
- 升级后监控订单创建成功率
- 对比升级前后的订单金额,确认差异是否在可接受范围内
### 2. 构建流程变更
**影响**
- Release 事件默认只打包产物,不构建 Docker
- 如果需要 Docker 镜像,需要手动触发构建并选择 Docker 升级类型
**建议**
- 了解新的构建流程,根据需要选择合适的构建方式
- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
---
## 📊 文件变更统计
- **修改文件数**: 2
- **新增行数**: 127
- **删除行数**: 27
---
## 🔄 升级建议
1. **测试买入订单创建**
- 升级后先测试少量买入订单,确认金额和数量是否符合预期
- 特别关注固定金额模式的跟单,确认金额是否被正确舍入
2. **监控订单创建成功率**
- 升级后监控订单创建成功率,确认是否解决了 `invalid amounts` 错误
- 对比升级前后的订单金额,确认差异是否在可接受范围内
3. **验证金额计算**
- 验证 makerAmount 是否被正确限制为 2 位小数
- 验证 takerAmount 是否被正确限制为 4 位小数
4. **了解构建流程变更**
- 了解新的构建流程,根据需要选择合适的构建方式
- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
---
## 📝 完整提交列表
- 42472f6 - fix: 修复买入订单金额精度问题
- e5992b5 - feat(workflow): 优化构建流程和通知机制
---
## 🙏 致谢
感谢所有贡献者和用户的支持与反馈!
+241
View File
@@ -0,0 +1,241 @@
# PolyHermes v2.0.3 Release Notes
## 📋 版本信息
- **版本号**: v2.0.3
- **发布日期**: 2026-01-31
- **基础版本**: v2.0.2
## 🎯 改动摘要
本次版本主要优化了用户界面显示,包括数值格式化、Leader列表优化、移除不必要的配置项,提升了用户体验。
---
## ✨ 新功能
### 1. 为所有数值显示添加千分位分隔符
**功能描述**
- ✅ 重构 `formatNumber``formatUSDC` 函数,默认添加千分位分隔符
- ✅ 所有数值(金额、数量、价格等)现在默认显示千分位
- ✅ 自动去除尾随零,提升可读性
- ✅ 示例:`1234567.89` 显示为 `1,234,567.89`
**影响范围**
- `frontend/src/utils/index.ts` - 工具函数
- `frontend/src/pages/Statistics.tsx` - 统计页面
- `frontend/src/pages/CopyTradingStatistics.tsx` - 跟单统计页面
- `frontend/src/pages/PositionList.tsx` - 持仓列表
- `frontend/src/pages/AccountList.tsx` - 账户列表
**提交**: 40081c2
---
### 2. 在创建跟单配置时显示Leader资产信息
**功能描述**
- ✅ 选择Leader后自动获取并显示资产信息
- ✅ 显示总资产、可用余额、仓位资产
- ✅ 使用Card和Statistic组件美观展示
- ✅ 支持中英文多语言
- ✅ 使用formatUSDC格式化显示金额
**影响范围**
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加跟单配置弹窗
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑跟单配置弹窗
- `frontend/src/locales/**/common.json` - 多语言文件
**提交**: 390b3ee
---
### 3. Leader列表显示仓位资产
**功能描述**
- ✅ Leader列表新增仓位资产显示
- ✅ 显示Leader的总资产、可用余额、仓位资产
- ✅ 优化资产信息展示方式
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt` - Leader DTO
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
**提交**: 3350039
---
## 🔧 优化改进
### 1. Leader列表优化
**改进内容**
- ✅ 后端过滤价值为0的仓位
- ✅ 持仓列表显示市场名称而非ID
- ✅ 列表移除分类和创建时间列
- ✅ 文案'跟单关系数'改为'跟单数'
- ✅ 持仓DTO添加title字段
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt` - 区块链服务
**提交**: 0bdc0c7
---
### 2. 列表只显示可用余额
**改进内容**
- ✅ 账户列表和Leader列表只显示可用余额
- ✅ 简化界面,减少信息冗余
**影响范围**
- `frontend/src/pages/AccountList.tsx` - 账户列表
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt` - 账户服务
**提交**: 17eea01
---
### 3. 移除仓位资产列
**改进内容**
- ✅ 移除不必要的仓位资产列显示
- ✅ 简化界面布局
**影响范围**
- `frontend/src/pages/PositionList.tsx` - 持仓列表
**提交**: 6980781
---
## 🗑️ 移除功能
### 移除跟单最大仓位数量(maxPositionCount)配置
**移除原因**
- 该配置项使用频率低,且增加了系统复杂度
- 简化跟单配置,提升用户体验
**移除内容**
- ✅ 数据库:创建迁移文件 V26 删除 `max_position_count` 字段
- ✅ 后端:移除实体类、DTO、服务中的 `maxPositionCount` 相关代码
- ✅ 后端:移除 `FilterResult` 中的 `FAILED_MAX_POSITION_COUNT` 状态
- ✅ 后端:移除 `CopyTradingFilterService` 中的最大仓位数量检查逻辑
- ✅ 前端:移除类型定义、表单字段和国际化翻译
- ✅ 前端:移除过滤订单列表中的 `MAX_POSITION_COUNT` 类型
**影响范围**
- `backend/src/main/resources/db/migration/V26__remove_max_position_count.sql` - 数据库迁移
- `backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt` - 实体类
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt` - DTO
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt` - 过滤服务
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加表单
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑表单
- `frontend/src/types/index.ts` - 类型定义
**提交**: e8fd1b5
---
## 🐛 Bug 修复
### 修复TypeScript类型错误
**修复内容**
- ✅ 修复编译时的TypeScript类型错误
- ✅ 修复Spin导入问题
- ✅ 修复Table fixed类型问题
- ✅ 修复size类型问题
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
**提交**: 8097660
---
## ⚠️ 潜在问题和注意事项
### 1. 数值格式化变更
**影响**
- 所有数值现在默认显示千分位分隔符
- 如果之前有代码依赖特定的数值格式,可能需要调整
**建议**
- 检查是否有代码依赖特定的数值格式
- 确认数值显示是否符合预期
### 2. 移除maxPositionCount配置
**影响**
- 如果之前使用了最大仓位数量限制功能,升级后将不再可用
- 需要手动调整跟单策略
**建议**
- 升级前检查是否有跟单配置使用了最大仓位数量限制
- 如有需要,可以手动调整跟单策略
### 3. Leader列表显示变更
**影响**
- Leader列表现在只显示价值大于0的仓位
- 列表布局和显示内容有所调整
**建议**
- 升级后检查Leader列表显示是否符合预期
- 确认仓位信息是否正确显示
---
## 📊 文件变更统计
- **修改文件数**: 28
- **新增行数**: 1490
- **删除行数**: 897
---
## 🔄 升级建议
1. **检查数值显示**
- 升级后检查所有数值显示是否符合预期
- 确认千分位分隔符显示正确
2. **检查跟单配置**
- 如果有使用最大仓位数量限制的配置,需要手动调整
- 确认跟单功能正常工作
3. **检查Leader列表**
- 升级后检查Leader列表显示是否正确
- 确认仓位信息是否完整
4. **数据库迁移**
- 升级时会自动执行数据库迁移 V26
- 迁移会删除 `max_position_count` 字段
- 建议在升级前备份数据库
---
## 📝 完整提交列表
- 40081c2 - feat: 为所有数值显示添加千分位分隔符
- e8fd1b5 - 移除跟单最大仓位数量(maxPositionCount)配置
- 390b3ee - feat: 在创建跟单配置时显示Leader资产信息
- 8097660 - fix: 修复TypeScript类型错误
- 17eea01 - refactor: 列表只显示可用余额
- 6980781 - refactor: 移除仓位资产列
- 3350039 - feat: Leader列表显示仓位资产
- 0bdc0c7 - feat: Leader列表优化
---
## 🙏 致谢
感谢所有贡献者和用户的支持与反馈!
@@ -140,7 +140,7 @@ class LeaderController(
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderDetail(request.leaderId)
result.fold(
onSuccess = { leader ->
@@ -159,6 +159,36 @@ class LeaderController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询被跟单者余额
*/
@PostMapping("/balance")
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
return try {
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderBalance(request.leaderId)
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(balance))
},
onFailure = { e ->
logger.error("查询 Leader 余额失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("查询 Leader 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
/**
@@ -93,6 +93,16 @@ data class AccountListResponse(
val total: Long
)
/**
* 钱包余额响应(通用类,用于 Account 和 Leader
*/
data class WalletBalanceResponse(
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
/**
* 账户余额响应
*/
@@ -108,6 +118,7 @@ data class AccountBalanceResponse(
*/
data class PositionDto(
val marketId: String,
val title: String?, // 市场名称
val side: String, // YES 或 NO
val quantity: String,
val avgPrice: String,
@@ -36,7 +36,6 @@ data class CopyTradingCreateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -75,7 +74,6 @@ data class CopyTradingUpdateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -151,7 +149,6 @@ data class CopyTradingDto(
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -36,6 +36,13 @@ data class LeaderListRequest(
val category: String? = null // sports 或 crypto
)
/**
* Leader 余额请求
*/
data class LeaderBalanceRequest(
val leaderId: Long // LeaderID(必需)
)
/**
* Leader 信息响应
*/
@@ -61,3 +68,16 @@ data class LeaderListResponse(
val total: Long
)
/**
* Leader 余额响应
*/
data class LeaderBalanceResponse(
val leaderId: Long,
val leaderAddress: String,
val leaderName: String?,
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
@@ -83,9 +83,6 @@ data class CopyTrading(
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
@Column(name = "max_position_count")
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
@@ -278,7 +278,7 @@ class AccountService(
if (accountId == null) {
return Result.failure(IllegalArgumentException("账户ID不能为空"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
@@ -288,68 +288,19 @@ class AccountService(
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
}
// 查询 USDC 余额和持仓信息
// 使用通用方法查询余额
val balanceResult = runBlocking {
try {
// 查询持仓信息(用于返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull()?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 使用 /value 接口获取仓位总价值(而不是累加)
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 查询可用余额(通过 RPC 查询 USDC 余额)
// 必须使用代理地址查询
val availableBalanceResult = blockchainService.getUsdcBalance(
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
AccountBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
} catch (e: Exception) {
logger.error("查询余额失败: ${e.message}", e)
throw e
}
blockchainService.getWalletBalance(account.proxyAddress)
}
Result.success(balanceResult)
balanceResult.map { walletBalance: WalletBalanceResponse ->
AccountBalanceResponse(
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
@@ -8,9 +8,12 @@ import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PositionResponse
import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.PositionDto
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.RpcNodeService
@@ -255,7 +258,6 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("代理地址不能为空"))
}
// 使用 RPC 查询 USDC 余额(使用代理地址)
val balance = queryUsdcBalanceViaRpc(proxyAddress)
Result.success(balance)
@@ -264,6 +266,82 @@ class BlockchainService(
Result.failure(e)
}
}
/**
* 查询钱包余额(通用方法)
* 用于 Account 和 Leader 的余额查询
* @param walletAddress 钱包地址(代理地址,Polymarket 使用代理地址存储资产)
* @return WalletBalanceResponse 包含可用余额、仓位余额、总余额和持仓列表
*/
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
return try {
if (walletAddress.isBlank()) {
logger.error("钱包地址为空,无法查询余额")
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
}
// 1. 查询持仓信息(用于返回持仓列表)
val positionsResult = getPositions(walletAddress)
val positions = if (positionsResult.isSuccess) {
// 过滤掉价值为0的仓位
positionsResult.getOrNull()?.filter { pos ->
val currentValue = pos.currentValue ?: 0.0
currentValue > 0
}?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
title = pos.title,
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 2. 使用 /value 接口获取仓位总价值
val positionBalanceResult = getTotalValue(walletAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
val availableBalanceResult = getUsdcBalance(
walletAddress = walletAddress,
proxyAddress = walletAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 4. 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
Result.success(
WalletBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
)
} catch (e: Exception) {
logger.error("查询钱包余额失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 RPC 查询 USDC 余额
@@ -315,7 +315,7 @@ class CopyTradingFilterService(
outcomeIndex: Int?
): FilterResult {
// 如果未配置仓位限制,直接通过
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
if (copyTrading.maxPositionValue == null) {
return FilterResult.passed()
}
@@ -369,39 +369,6 @@ class CopyTradingFilterService(
}
}
// 检查最大仓位数量(如果配置了)
if (copyTrading.maxPositionCount != null) {
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
val extCount = positions.currentPositions
.filter { it.accountId == copyTrading.accountId }
.map { it.marketId }
.distinct()
.size
val currentPositionCount = maxOf(dbCount, extCount)
// 检查:如果当前没有该市场该方向的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
// 判断当前市场该方向是否已有活跃仓位(数据库)
val hasDbPosition = if (outcomeIndex != null) {
copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id, marketId, outcomeIndex
).isNotEmpty()
} else {
false
}
val hasExtPosition = marketPositions.isNotEmpty()
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
return FilterResult.maxPositionCountFailed(
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
)
}
}
return FilterResult.passed()
} catch (e: Exception) {
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
@@ -100,7 +100,6 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
@@ -132,7 +131,6 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
@@ -164,7 +162,6 @@ class CopyTradingService(
minPrice = config.minPrice,
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
maxPositionCount = config.maxPositionCount,
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
configName = configName,
@@ -282,16 +279,6 @@ class CopyTradingService(
} else {
copyTrading.maxPositionValue
},
// 处理 maxPositionCount-1 表示要清空(设置为 null),null 表示不更新
maxPositionCount = if (request.maxPositionCount != null) {
if (request.maxPositionCount == -1) {
null
} else {
request.maxPositionCount
}
} else {
copyTrading.maxPositionCount
},
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
keywords = if (request.keywords != null) {
convertKeywordsToJson(request.keywords)
@@ -520,7 +507,6 @@ class CopyTradingService(
minPrice = copyTrading.minPrice?.toPlainString(),
maxPrice = copyTrading.maxPrice?.toPlainString(),
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
maxPositionCount = copyTrading.maxPositionCount,
keywordFilterMode = copyTrading.keywordFilterMode,
keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName,
@@ -585,7 +571,6 @@ class CopyTradingService(
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?,
val keywordFilterMode: String,
val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
@@ -20,8 +20,6 @@ enum class FilterStatus {
FAILED_ORDER_DEPTH,
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER,
/** 失败:市场截止时间超出限制 */
@@ -87,12 +85,6 @@ data class FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
reason = reason
)
/** 超过最大仓位数量 */
fun maxPositionCountFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
reason = reason
)
/** 关键字过滤失败 */
fun keywordFilterFailed(reason: String) = FilterResult(
@@ -5,10 +5,12 @@ import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.util.CategoryValidator
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlinx.coroutines.runBlocking
/**
* Leader 管理服务
@@ -17,7 +19,8 @@ import org.springframework.transaction.annotation.Transactional
class LeaderService(
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository
private val copyTradingRepository: CopyTradingRepository,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
@@ -176,7 +179,7 @@ class LeaderService(
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
Result.success(toDto(leader, copyTradingCount))
} catch (e: Exception) {
@@ -184,6 +187,40 @@ class LeaderService(
Result.failure(e)
}
}
/**
* 查询 Leader 余额
* 使用代理地址查询 USDC 余额和持仓信息
*/
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// Leader 的 leaderAddress 就是代理地址
val walletAddress = leader.leaderAddress
// 使用通用方法查询余额
val balanceResult = runBlocking {
blockchainService.getWalletBalance(walletAddress)
}
balanceResult.map { walletBalance: WalletBalanceResponse ->
LeaderBalanceResponse(
leaderId = leader.id!!,
leaderAddress = leader.leaderAddress,
leaderName = leader.leaderName,
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询 Leader 余额失败", e)
Result.failure(e)
}
}
/**
* 转换为 DTO
@@ -94,14 +94,16 @@ class OrderSigningService {
if (side.uppercase() == "BUY") {
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
// 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
// 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
// takerAmount (shares) 使用 4 位小数
val rawTakerAmt = roundDown(sizeDecimal, 4)
var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
// 如果 makerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
if (decimalPlaces(rawMakerAmt) > roundConfig.amount) {
rawMakerAmt = roundUp(rawMakerAmt, roundConfig.amount + 4)
if (decimalPlaces(rawMakerAmt) > roundConfig.amount) {
rawMakerAmt = roundDown(rawMakerAmt, roundConfig.amount)
// makerAmount (USDC) 使用 2 位小数
if (decimalPlaces(rawMakerAmt) > 2) {
rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
if (decimalPlaces(rawMakerAmt) > 2) {
rawMakerAmt = roundDown(rawMakerAmt, 2)
}
}
@@ -1430,7 +1430,6 @@ open class CopyOrderTrackingService(
FilterStatus.FAILED_SPREAD -> "SPREAD"
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
}
@@ -0,0 +1,8 @@
-- ============================================
-- V26: 移除最大仓位数量配置字段
-- 从 copy_trading 表中删除 max_position_count 字段
-- ============================================
ALTER TABLE copy_trading
DROP COLUMN max_position_count;
+39 -11
View File
@@ -4,6 +4,7 @@
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"viewDetail": "View Details",
"delete": "Delete",
"add": "Add",
"search": "Search",
@@ -481,6 +482,37 @@
"fetchFailed": "Failed to get Leader details",
"invalidId": "Invalid Leader ID"
},
"leaderDetail": {
"title": "Leader Details",
"loading": "Loading...",
"invalidId": "Invalid Leader ID",
"fetchFailed": "Failed to get Leader details",
"fetchBalanceFailed": "Failed to get balance",
"noBalanceData": "No balance data",
"basicInfo": "Basic Information",
"leaderName": "Leader Name",
"leaderAddress": "Wallet Address",
"category": "Category",
"copyTradingCount": "Copy Trading Count",
"remark": "Remark",
"createdAt": "Created At",
"updatedAt": "Updated At",
"website": "Website",
"openWebsite": "Open Website",
"balanceInfo": "Balance Information",
"refresh": "Refresh",
"availableBalance": "Available Balance",
"positionBalance": "Position Asset",
"totalBalance": "Total Balance",
"positions": "Positions",
"noPositions": "No positions",
"marketId": "Market ID",
"side": "Side",
"quantity": "Quantity",
"avgPrice": "Avg Price",
"currentValue": "Current Value",
"pnl": "PnL"
},
"userList": {
"title": "User Management",
"username": "Username",
@@ -686,9 +718,6 @@
"maxPositionValue": "Max Position Value (USDC)",
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"supportSell": "Support Sell",
"supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.",
"pushFilteredOrders": "Push Filtered Orders",
@@ -760,9 +789,6 @@
"maxPositionValue": "Max Position Value (USDC)",
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"keywordFilter": "Keyword Filter",
"keywordFilterMode": "Filter Mode",
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
@@ -808,7 +834,13 @@
"noAccounts": "No accounts",
"importAccount": "Import Account",
"noLeaders": "No Leaders",
"addLeader": "Add Leader"
"addLeader": "Add Leader",
"leaderAssetInfo": "Leader Asset Info",
"loadingAssetInfo": "Loading asset info...",
"totalAsset": "Total Assets",
"availableBalance": "Available Balance",
"positionAsset": "Position Assets",
"fetchAssetInfoFailed": "Failed to fetch asset info"
},
"copyTradingEdit": {
"title": "Edit Copy Trading Config",
@@ -867,9 +899,6 @@
"maxPositionValue": "Max Position Value (USDC)",
"maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)",
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"keywordFilter": "Keyword Filter",
"keywordFilterMode": "Filter Mode",
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
@@ -934,7 +963,6 @@
"orderbookEmpty": "Orderbook Empty",
"priceRange": "Price Range Mismatch",
"maxPositionValue": "Exceeds Max Position Value",
"maxPositionCount": "Exceeds Max Position Count",
"marketEndDate": "Market End Date Exceeds Limit",
"unknown": "Unknown Reason"
},
+41 -13
View File
@@ -6,6 +6,7 @@
"confirm": "确定",
"delete": "删除",
"edit": "编辑",
"viewDetail": "查看详情",
"add": "添加",
"refresh": "刷新",
"search": "搜索",
@@ -216,7 +217,7 @@
"walletAddress": "钱包地址",
"category": "分类",
"all": "全部",
"copyTradingCount": "跟单关系数",
"copyTradingCount": "跟单数",
"createdAt": "创建时间",
"action": "操作",
"add": "添加",
@@ -424,7 +425,7 @@
"website": "网站",
"openWebsite": "打开网页",
"all": "全部",
"copyTradingCount": "跟单关系数",
"copyTradingCount": "跟单数",
"copyTradingRelations": "{{count}} 个跟单关系",
"createdAt": "创建时间",
"noData": "暂无 Leader 数据",
@@ -481,6 +482,37 @@
"fetchFailed": "获取 Leader 详情失败",
"invalidId": "Leader ID 无效"
},
"leaderDetail": {
"title": "Leader 详情",
"loading": "加载中...",
"invalidId": "Leader ID 无效",
"fetchFailed": "获取 Leader 详情失败",
"fetchBalanceFailed": "获取余额失败",
"noBalanceData": "暂无余额数据",
"basicInfo": "基本信息",
"leaderName": "Leader 名称",
"leaderAddress": "钱包地址",
"category": "分类",
"copyTradingCount": "跟单数",
"remark": "备注",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"website": "网站",
"openWebsite": "打开网页",
"balanceInfo": "余额信息",
"refresh": "刷新",
"availableBalance": "可用余额",
"positionBalance": "仓位资产",
"totalBalance": "总余额",
"positions": "持仓列表",
"noPositions": "暂无持仓",
"marketId": "市场ID",
"side": "方向",
"quantity": "数量",
"avgPrice": "均价",
"currentValue": "当前价值",
"pnl": "盈亏"
},
"userList": {
"title": "用户管理",
"username": "用户名",
@@ -686,9 +718,6 @@
"maxPositionValue": "最大仓位金额 (USDC)",
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
"pushFilteredOrders": "推送已过滤订单",
@@ -774,9 +803,6 @@
"maxPositionValue": "最大仓位金额 (USDC)",
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
@@ -808,7 +834,13 @@
"noAccounts": "暂无账户",
"importAccount": "导入账户",
"noLeaders": "暂无 Leader",
"addLeader": "添加 Leader"
"addLeader": "添加 Leader",
"leaderAssetInfo": "Leader 资产信息",
"loadingAssetInfo": "加载资产信息中...",
"totalAsset": "总资产",
"availableBalance": "可用余额",
"positionAsset": "仓位资产",
"fetchAssetInfoFailed": "获取资产信息失败"
},
"copyTradingEdit": {
"title": "编辑跟单配置",
@@ -881,9 +913,6 @@
"maxPositionValue": "最大仓位金额 (USDC)",
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
@@ -934,7 +963,6 @@
"orderbookEmpty": "订单簿为空",
"priceRange": "价格区间不符",
"maxPositionValue": "超过最大仓位金额",
"maxPositionCount": "超过最大仓位数量",
"marketEndDate": "市场截止时间超出限制",
"unknown": "未知原因"
},
+43 -15
View File
@@ -4,6 +4,7 @@
"save": "保存",
"cancel": "取消",
"edit": "編輯",
"viewDetail": "查看詳情",
"delete": "刪除",
"add": "添加",
"search": "搜索",
@@ -216,7 +217,7 @@
"walletAddress": "錢包地址",
"category": "分類",
"all": "全部",
"copyTradingCount": "跟單關係數",
"copyTradingCount": "跟單數",
"createdAt": "創建時間",
"action": "操作",
"add": "添加",
@@ -424,7 +425,7 @@
"website": "網站",
"openWebsite": "打開網頁",
"all": "全部",
"copyTradingCount": "跟單關係數",
"copyTradingCount": "跟單數",
"copyTradingRelations": "{{count}} 個跟單關係",
"createdAt": "創建時間",
"noData": "暫無 Leader 數據",
@@ -481,6 +482,37 @@
"fetchFailed": "獲取 Leader 詳情失敗",
"invalidId": "Leader ID 無效"
},
"leaderDetail": {
"title": "Leader 詳情",
"loading": "加載中...",
"invalidId": "Leader ID 無效",
"fetchFailed": "獲取 Leader 詳情失敗",
"fetchBalanceFailed": "獲取餘額失敗",
"noBalanceData": "暫無餘額數據",
"basicInfo": "基本信息",
"leaderName": "Leader 名稱",
"leaderAddress": "錢包地址",
"category": "分類",
"copyTradingCount": "跟單數",
"remark": "備註",
"createdAt": "創建時間",
"updatedAt": "更新時間",
"website": "網站",
"openWebsite": "打開網頁",
"balanceInfo": "餘額信息",
"refresh": "刷新",
"availableBalance": "可用餘額",
"positionBalance": "倉位資產",
"totalBalance": "總餘額",
"positions": "持倉列表",
"noPositions": "暫無持倉",
"marketId": "市場ID",
"side": "方向",
"quantity": "數量",
"avgPrice": "均價",
"currentValue": "當前價值",
"pnl": "盈虧"
},
"userList": {
"title": "用戶管理",
"username": "用戶名",
@@ -686,9 +718,6 @@
"maxPositionValue": "最大倉位金額 (USDC)",
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"supportSell": "跟單賣出",
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
"pushFilteredOrders": "推送已過濾訂單",
@@ -760,9 +789,6 @@
"maxPositionValue": "最大倉位金額 (USDC)",
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
@@ -805,10 +831,16 @@
"fetchLeaderFailed": "獲取 Leader 列表失敗",
"fetchTemplateFailed": "獲取模板列表失敗",
"templateName": "模板名稱",
"noAccounts": "暫無戶",
"importAccount": "導入戶",
"noAccounts": "暫無戶",
"importAccount": "導入戶",
"noLeaders": "暫無 Leader",
"addLeader": "添加 Leader"
"addLeader": "添加 Leader",
"leaderAssetInfo": "Leader 資產資訊",
"loadingAssetInfo": "加載資產資訊中...",
"totalAsset": "總資產",
"availableBalance": "可用餘額",
"positionAsset": "倉位資產",
"fetchAssetInfoFailed": "獲取資產資訊失敗"
},
"copyTradingEdit": {
"title": "編輯跟單配置",
@@ -867,9 +899,6 @@
"maxPositionValue": "最大倉位金額 (USDC)",
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
@@ -934,7 +963,6 @@
"orderbookEmpty": "訂單簿為空",
"priceRange": "價格區間不符",
"maxPositionValue": "超過最大倉位金額",
"maxPositionCount": "超過最大倉位數量",
"marketEndDate": "市場截止時間超出限制",
"unknown": "未知原因"
},
+113 -113
View File
@@ -26,18 +26,18 @@ const AccountList: React.FC = () => {
const [editLoading, setEditLoading] = useState(false)
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
const [accountImportForm] = Form.useForm()
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
const handleAccountImportSuccess = async () => {
message.success(t('accountImport.importSuccess'))
setAccountImportModalVisible(false)
accountImportForm.resetFields()
fetchAccounts()
}
// 加载所有账户的余额
useEffect(() => {
const loadBalances = async () => {
@@ -46,8 +46,8 @@ const AccountList: React.FC = () => {
setBalanceLoading(prev => ({ ...prev, [account.id]: true }))
try {
const balanceData = await fetchAccountBalance(account.id)
setBalanceMap(prev => ({
...prev,
setBalanceMap(prev => ({
...prev,
[account.id]: {
total: balanceData.totalBalance || '0',
available: balanceData.availableBalance || '0',
@@ -56,8 +56,8 @@ const AccountList: React.FC = () => {
}))
} catch (error) {
console.error(`获取账户 ${account.id} 余额失败:`, error)
setBalanceMap(prev => ({
...prev,
setBalanceMap(prev => ({
...prev,
[account.id]: { total: '-', available: '-', position: '-' }
}))
} finally {
@@ -66,12 +66,12 @@ const AccountList: React.FC = () => {
}
}
}
if (accounts.length > 0) {
loadBalances()
}
}, [accounts])
const handleDelete = async (account: Account) => {
try {
await deleteAccount(account.id)
@@ -80,13 +80,13 @@ const AccountList: React.FC = () => {
message.error(error.message || t('accountList.deleteFailed'))
}
}
const handleCopy = (text: string) => {
if (!text) {
message.warning(t('accountList.copyFailed') || '复制失败:地址为空')
return
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
message.success({
@@ -103,7 +103,7 @@ const AccountList: React.FC = () => {
fallbackCopyTextToClipboard(text)
}
}
const fallbackCopyTextToClipboard = (text: string) => {
const textArea = document.createElement('textarea')
textArea.value = text
@@ -113,7 +113,7 @@ const AccountList: React.FC = () => {
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
@@ -131,19 +131,19 @@ const AccountList: React.FC = () => {
document.body.removeChild(textArea)
}
}
const handleShowDetail = async (account: Account) => {
try {
setDetailModalVisible(true)
setDetailAccount(account)
setDetailBalance(null)
setDetailBalanceLoading(false)
// 加载详情和余额
try {
const accountDetail = await fetchAccountDetail(account.id)
setDetailAccount(accountDetail)
// 加载余额
setDetailBalanceLoading(true)
try {
@@ -173,10 +173,10 @@ const AccountList: React.FC = () => {
setDetailAccount(null)
}
}
const handleRefreshDetailBalance = async () => {
if (!detailAccount) return
setDetailBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(detailAccount.id)
@@ -193,16 +193,16 @@ const AccountList: React.FC = () => {
setDetailBalanceLoading(false)
}
}
const handleShowEdit = async (account: Account) => {
try {
setEditModalVisible(true)
setEditAccount(account)
// 加载账户详情并设置表单初始值
const accountDetail = await fetchAccountDetail(account.id)
setEditAccount(accountDetail)
editForm.setFieldsValue({
accountName: accountDetail.accountName || '',
apiKey: '', // 不显示实际值,留空表示不修改
@@ -216,10 +216,10 @@ const AccountList: React.FC = () => {
setEditAccount(null)
}
}
const handleEditSubmit = async (values: any) => {
if (!editAccount) return
setEditLoading(true)
try {
// 构建更新请求,只支持编辑账户名称
@@ -227,17 +227,17 @@ const AccountList: React.FC = () => {
accountId: editAccount.id,
accountName: values.accountName || undefined
}
await updateAccount(updateData)
message.success(t('accountList.updateSuccess'))
setEditModalVisible(false)
setEditAccount(null)
editForm.resetFields()
// 刷新账户列表
await fetchAccounts()
// 如果详情 Modal 打开着,也刷新详情
if (detailModalVisible && detailAccount && detailAccount.id === editAccount.id) {
const accountDetail = await fetchAccountDetail(editAccount.id)
@@ -249,7 +249,7 @@ const AccountList: React.FC = () => {
setEditLoading(false)
}
}
const columns = [
{
title: t('accountList.accountName'),
@@ -339,7 +339,7 @@ const AccountList: React.FC = () => {
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
@@ -356,7 +356,7 @@ const AccountList: React.FC = () => {
)
}
]
const mobileColumns = [
{
title: t('accountList.accountName'),
@@ -364,16 +364,16 @@ const AccountList: React.FC = () => {
render: (_: any, record: Account) => {
return (
<div style={{ padding: '8px 0' }}>
<div style={{
fontWeight: 'bold',
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
fontSize: '16px'
}}>
{record.accountName || `${t('accountList.accountName')} ${record.id}`}
</div>
<div style={{
fontSize: '11px',
color: '#666',
<div style={{
fontSize: '11px',
color: '#666',
marginBottom: '8px',
wordBreak: 'break-all',
fontFamily: 'monospace',
@@ -406,7 +406,7 @@ const AccountList: React.FC = () => {
/>
</div>
</div>
<div style={{
<div style={{
fontSize: '14px',
fontWeight: '500',
color: '#1890ff'
@@ -420,7 +420,7 @@ const AccountList: React.FC = () => {
)}
</div>
{balanceMap[record.id] && balanceMap[record.id].available !== '-' && (
<div style={{
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px'
@@ -459,7 +459,7 @@ const AccountList: React.FC = () => {
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
@@ -468,9 +468,9 @@ const AccountList: React.FC = () => {
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Button
size="small"
block
<Button
size="small"
block
danger
style={{ minHeight: '32px' }}
>
@@ -481,15 +481,15 @@ const AccountList: React.FC = () => {
)
}
]
return (
<div style={{
<div style={{
padding: isMobile ? '0' : undefined,
margin: isMobile ? '0 -8px' : undefined
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: isMobile ? '12px' : '16px',
flexWrap: 'wrap',
@@ -510,8 +510,8 @@ const AccountList: React.FC = () => {
{t('accountList.importAccount')}
</Button>
</div>
<Card style={{
<Card style={{
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}>
@@ -544,7 +544,7 @@ const AccountList: React.FC = () => {
/>
)}
</Card>
{/* 账户详情 Modal */}
<Modal
title={detailAccount ? (detailAccount.accountName || `${t('accountList.accountName')} ${detailAccount.id}`) : t('accountList.accountDetail')}
@@ -555,19 +555,19 @@ const AccountList: React.FC = () => {
setDetailBalance(null)
}}
footer={[
<Button
key="refresh"
icon={<ReloadOutlined />}
onClick={handleRefreshDetailBalance}
<Button
key="refresh"
icon={<ReloadOutlined />}
onClick={handleRefreshDetailBalance}
loading={detailBalanceLoading}
disabled={!detailAccount}
>
{t('accountList.refreshBalance')}
</Button>,
<Button
key="edit"
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
icon={<EditOutlined />}
onClick={() => {
if (detailAccount) {
setDetailModalVisible(false)
@@ -578,8 +578,8 @@ const AccountList: React.FC = () => {
>
{t('accountList.edit')}
</Button>,
<Button
key="close"
<Button
key="close"
onClick={() => {
setDetailModalVisible(false)
setDetailAccount(null)
@@ -610,8 +610,8 @@ const AccountList: React.FC = () => {
</Descriptions.Item>
<Descriptions.Item label={t('accountList.walletAddress')} span={isMobile ? 1 : 2}>
<Space>
<span style={{
fontFamily: 'monospace',
<span style={{
fontFamily: 'monospace',
fontSize: isMobile ? '11px' : '13px',
wordBreak: 'break-all',
lineHeight: '1.4',
@@ -633,8 +633,8 @@ const AccountList: React.FC = () => {
</Descriptions.Item>
<Descriptions.Item label={t('accountList.proxyAddress')} span={isMobile ? 1 : 2}>
<Space>
<span style={{
fontFamily: 'monospace',
<span style={{
fontFamily: 'monospace',
fontSize: isMobile ? '11px' : '13px',
wordBreak: 'break-all',
lineHeight: '1.4',
@@ -688,9 +688,9 @@ const AccountList: React.FC = () => {
)}
</Descriptions.Item>
</Descriptions>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
@@ -720,51 +720,51 @@ const AccountList: React.FC = () => {
)}
</Descriptions.Item>
</Descriptions>
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
detailAccount.activeOrders !== undefined ||
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
detailAccount.activeOrders !== undefined ||
detailAccount.completedOrders !== undefined || detailAccount.positionCount !== undefined) && (
<>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title={t('accountList.statistics')}
>
{detailAccount.totalOrders !== undefined && (
<Descriptions.Item label={t('accountList.totalOrders')}>
{detailAccount.totalOrders}
</Descriptions.Item>
)}
{detailAccount.activeOrders !== undefined && (
<Descriptions.Item label={t('accountList.activeOrdersCount')}>
<Tag color={detailAccount.activeOrders > 0 ? 'orange' : 'default'}>{detailAccount.activeOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.completedOrders !== undefined && (
<Descriptions.Item label={t('accountList.completedOrders')}>
<Tag color="success">{detailAccount.completedOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.positionCount !== undefined && (
<Descriptions.Item label={t('accountList.positionCount')}>
<Tag color={detailAccount.positionCount > 0 ? 'blue' : 'default'}>{detailAccount.positionCount}</Tag>
</Descriptions.Item>
)}
{detailAccount.totalPnl !== undefined && (
<Descriptions.Item label={t('accountList.totalPnl')}>
<span style={{
fontWeight: 'bold',
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{formatUSDC(detailAccount.totalPnl)} USDC
</span>
</Descriptions.Item>
)}
</Descriptions>
</>
)}
<>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title={t('accountList.statistics')}
>
{detailAccount.totalOrders !== undefined && (
<Descriptions.Item label={t('accountList.totalOrders')}>
{detailAccount.totalOrders}
</Descriptions.Item>
)}
{detailAccount.activeOrders !== undefined && (
<Descriptions.Item label={t('accountList.activeOrdersCount')}>
<Tag color={detailAccount.activeOrders > 0 ? 'orange' : 'default'}>{detailAccount.activeOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.completedOrders !== undefined && (
<Descriptions.Item label={t('accountList.completedOrders')}>
<Tag color="success">{detailAccount.completedOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.positionCount !== undefined && (
<Descriptions.Item label={t('accountList.positionCount')}>
<Tag color={detailAccount.positionCount > 0 ? 'blue' : 'default'}>{detailAccount.positionCount}</Tag>
</Descriptions.Item>
)}
{detailAccount.totalPnl !== undefined && (
<Descriptions.Item label={t('accountList.totalPnl')}>
<span style={{
fontWeight: 'bold',
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{formatUSDC(detailAccount.totalPnl)} USDC
</span>
</Descriptions.Item>
)}
</Descriptions>
</>
)}
</div>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
@@ -773,7 +773,7 @@ const AccountList: React.FC = () => {
</div>
)}
</Modal>
{/* 编辑账户 Modal */}
<Modal
title={editAccount ? `${t('accountList.editAccount')} - ${editAccount.accountName || `${t('accountList.accountName')} ${editAccount.id}`}` : t('accountList.editAccount')}
@@ -804,17 +804,17 @@ const AccountList: React.FC = () => {
showIcon
style={{ marginBottom: '24px' }}
/>
<Form.Item
label={t('accountList.accountName') || '账户名称'}
name="accountName"
>
<Input placeholder={t('accountList.accountNamePlaceholder') || '请输入账户名称(可选)'} />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button
<Button
onClick={() => {
setEditModalVisible(false)
setEditAccount(null)
@@ -844,7 +844,7 @@ const AccountList: React.FC = () => {
</div>
)}
</Modal>
{/* 导入账户 Modal */}
<Modal
title={t('accountImport.title')}
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react'
import { Modal, Form, Button, Switch, message, Space, Radio, InputNumber, Table, Select, Divider, Input, Tag, InputRef } from 'antd'
import { Modal, Form, Button, Switch, message, Space, Radio, InputNumber, Table, Select, Divider, Input, Tag, InputRef, Card, Row, Col, Statistic, Spin } from 'antd'
import { SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import { useAccountStore } from '../../store/accountStore'
@@ -36,6 +36,8 @@ const AddModal: React.FC<AddModalProps> = ({
const keywordInputRef = useRef<InputRef>(null)
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
const [leaderAssetInfo, setLeaderAssetInfo] = useState<{ total: string; available: string; position: string } | null>(null)
const [loadingAssetInfo, setLoadingAssetInfo] = useState(false)
// 导入账户modal相关状态
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
@@ -120,7 +122,6 @@ const AddModal: React.FC<AddModalProps> = ({
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined,
maxPositionCount: (template as any).maxPositionCount,
pushFilteredOrders: template.pushFilteredOrders ?? false
})
setCopyMode(template.copyMode)
@@ -132,6 +133,32 @@ const AddModal: React.FC<AddModalProps> = ({
setCopyMode(mode)
}
// 获取 Leader 资产信息
const fetchLeaderAssetInfo = async (leaderId: number) => {
if (!leaderId) return
setLoadingAssetInfo(true)
setLeaderAssetInfo(null)
try {
const response = await apiService.leaders.balance({ leaderId })
if (response.data.code === 0 && response.data.data) {
const balance = response.data.data
setLeaderAssetInfo({
total: balance.totalBalance || '0',
available: balance.availableBalance || '0',
position: balance.positionBalance || '0'
})
} else {
message.error(response.data.msg || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
}
} catch (error: any) {
console.error('获取 Leader 资产失败:', error)
message.error(error.message || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
} finally {
setLoadingAssetInfo(false)
}
}
// 处理导入账户成功
const handleAccountImportSuccess = async (accountId: number) => {
message.success(t('accountImport.importSuccess'))
@@ -249,7 +276,6 @@ const AddModal: React.FC<AddModalProps> = ({
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords
@@ -382,6 +408,7 @@ const AddModal: React.FC<AddModalProps> = ({
</div>
) : null
}
onChange={(value) => fetchLeaderAssetInfo(value)}
>
{leaders.map(leader => (
<Option key={leader.id} value={leader.id}>
@@ -391,6 +418,61 @@ const AddModal: React.FC<AddModalProps> = ({
</Select>
</Form.Item>
{/* Leader 资产信息 */}
{leaderAssetInfo && (
<Card
title={
<Space>
<span>{t('copyTradingAdd.leaderAssetInfo') || 'Leader 资产信息'}</span>
</Space>
}
size="small"
style={{ marginBottom: '16px', backgroundColor: '#f5f5f5', border: '1px solid #d9d9d9' }}
>
{loadingAssetInfo ? (
<div style={{ textAlign: 'center', padding: '24px' }}>
<Spin />
<div style={{ marginTop: '8px', color: '#999' }}>
{t('copyTradingAdd.loadingAssetInfo') || '加载资产信息中...'}
</div>
</div>
) : (
<Row gutter={16}>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.totalAsset') || '总资产'}
value={parseFloat(leaderAssetInfo.total)}
precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.availableBalance') || '可用余额'}
value={parseFloat(leaderAssetInfo.available)}
precision={4}
valueStyle={{ color: '#1890ff', fontSize: '14px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.positionAsset') || '仓位资产'}
value={parseFloat(leaderAssetInfo.position)}
precision={4}
valueStyle={{ color: '#722ed1', fontSize: '14px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
</Row>
)}
</Card>
)}
{/* 模板填充按钮 */}
<Form.Item>
<Button
@@ -716,19 +798,6 @@ const AddModal: React.FC<AddModalProps> = ({
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.maxPositionCount') || '最大仓位数量'}
name="maxPositionCount"
tooltip={t('copyTradingAdd.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingAdd.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
<Form.Item
@@ -1,9 +1,10 @@
import React, { useEffect, useState, useRef } from 'react'
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch, Tag, InputRef } from 'antd'
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch, Tag, InputRef, Card, Row, Col, Statistic } from 'antd'
import { SaveOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import type { CopyTrading, CopyTradingUpdateRequest } from '../../types'
import { useTranslation } from 'react-i18next'
import { formatUSDC } from '../../utils'
const { Option } = Select
@@ -26,11 +27,13 @@ const EditModal: React.FC<EditModalProps> = ({
const [fetching, setFetching] = useState(true)
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
const [keywords, setKeywords] = useState<string[]>([])
const keywordInputRef = useRef<InputRef>(null)
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
const [keywords, setKeywords] = useState<string[]>([])
const keywordInputRef = useRef<InputRef>(null)
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
const [leaderAssetInfo, setLeaderAssetInfo] = useState<{ total: string; available: string; position: string } | null>(null)
const [loadingAssetInfo, setLoadingAssetInfo] = useState(false)
useEffect(() => {
if (open && copyTradingId) {
@@ -88,7 +91,6 @@ const EditModal: React.FC<EditModalProps> = ({
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
maxPositionCount: found.maxPositionCount,
keywordFilterMode: found.keywordFilterMode || 'DISABLED',
configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false,
@@ -96,6 +98,9 @@ const EditModal: React.FC<EditModalProps> = ({
})
// 设置关键字列表
setKeywords(found.keywords || [])
// 获取 Leader 资产信息
fetchLeaderAssetInfo(found.leaderId)
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
onClose()
@@ -116,6 +121,30 @@ const EditModal: React.FC<EditModalProps> = ({
setCopyMode(mode)
}
// 获取 Leader 资产信息
const fetchLeaderAssetInfo = async (leaderId: number) => {
setLoadingAssetInfo(true)
setLeaderAssetInfo(null)
try {
const response = await apiService.leaders.balance({ leaderId })
if (response.data.code === 0 && response.data.data) {
const balance = response.data.data
setLeaderAssetInfo({
total: balance.totalBalance || '0',
available: balance.availableBalance || '0',
position: balance.positionBalance || '0'
})
} else {
message.error(response.data.msg || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
}
} catch (error: any) {
console.error('获取 Leader 资产失败:', error)
message.error(error.message || t('copyTradingAdd.fetchAssetInfoFailed') || '获取资产信息失败')
} finally {
setLoadingAssetInfo(false)
}
}
// 添加关键字
const handleAddKeyword = (e?: React.KeyboardEvent<HTMLInputElement>) => {
let inputValue = ''
@@ -207,8 +236,6 @@ const EditModal: React.FC<EditModalProps> = ({
minPrice: values.minPrice != null ? values.minPrice.toString() : '',
maxPrice: values.maxPrice != null ? values.maxPrice.toString() : '',
maxPositionValue: values.maxPositionValue != null ? values.maxPositionValue.toString() : '',
// 对于 maxPositionCount,如果值为 null/undefined,传 -1 表示要清空(后端会识别并设置为 null)
maxPositionCount: values.maxPositionCount != null ? values.maxPositionCount : -1,
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords
@@ -301,6 +328,59 @@ const EditModal: React.FC<EditModalProps> = ({
</Select>
</Form.Item>
{/* Leader 资产信息 */}
<Card
title={
<Space>
<span>{t('copyTradingAdd.leaderAssetInfo') || 'Leader 资产信息'}</span>
</Space>
}
size="small"
style={{ marginBottom: '16px', backgroundColor: '#f5f5f5', border: '1px solid #d9d9d9' }}
>
{loadingAssetInfo ? (
<div style={{ textAlign: 'center', padding: '24px' }}>
<Spin />
<div style={{ marginTop: '8px', color: '#999' }}>
{t('copyTradingAdd.loadingAssetInfo') || '加载资产信息中...'}
</div>
</div>
) : leaderAssetInfo ? (
<Row gutter={16}>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.totalAsset') || '总资产'}
value={parseFloat(leaderAssetInfo.total)}
precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.availableBalance') || '可用余额'}
value={parseFloat(leaderAssetInfo.available)}
precision={4}
valueStyle={{ color: '#1890ff', fontSize: '14px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
<Col span={8}>
<Statistic
title={t('copyTradingAdd.positionAsset') || '仓位资产'}
value={parseFloat(leaderAssetInfo.position)}
precision={4}
valueStyle={{ color: '#722ed1', fontSize: '14px' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Col>
</Row>
) : null}
</Card>
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
<Form.Item
@@ -639,19 +719,6 @@ const EditModal: React.FC<EditModalProps> = ({
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxPositionCount') || '最大仓位数量'}
name="maxPositionCount"
tooltip={t('copyTradingEdit.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
/>
</Form.Item>
{/* 关键字过滤 */}
<Divider>{t('copyTradingEdit.keywordFilter') || t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
@@ -69,7 +69,6 @@ const FilteredOrdersModal: React.FC<FilteredOrdersModalProps> = ({
ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
MAX_POSITION_VALUE: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' },
MAX_POSITION_COUNT: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionCount') || '超过最大仓位数量' },
MARKET_END_DATE: { color: 'cyan', text: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' },
KEYWORD_FILTER: { color: 'geekblue', text: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' }
}
+22 -22
View File
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { Card, Row, Col, Statistic, Tag, Button, message, Spin } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { formatUSDC } from '../utils'
import { formatUSDC, formatNumber } from '../utils'
import { useMediaQuery } from 'react-responsive'
import type { CopyTradingStatistics } from '../types'
@@ -13,16 +13,16 @@ const CopyTradingStatisticsPage: React.FC = () => {
useMediaQuery({ maxWidth: 768 }) // 用于响应式布局,但当前页面未使用
const [loading, setLoading] = useState(false)
const [statistics, setStatistics] = useState<CopyTradingStatistics | null>(null)
useEffect(() => {
if (copyTradingId) {
fetchStatistics()
}
}, [copyTradingId])
const fetchStatistics = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const response = await apiService.statistics.detail({ copyTradingId: parseInt(copyTradingId) })
@@ -37,25 +37,25 @@ const CopyTradingStatisticsPage: React.FC = () => {
setLoading(false)
}
}
const getPnlColor = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '#666'
return num >= 0 ? '#3f8600' : '#cf1322'
}
const getPnlIcon = (value: string) => {
const num = parseFloat(value)
if (isNaN(num)) return null
return num >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />
}
const formatPercent = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '-'
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
@@ -63,7 +63,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
</div>
)
}
if (!statistics) {
return (
<Card>
@@ -74,7 +74,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
</Card>
)
}
return (
<div>
<Card style={{ marginBottom: 16 }}>
@@ -98,7 +98,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
</div>
</div>
</Card>
{/* 基本信息卡片 */}
<Card title="基本信息" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
@@ -130,14 +130,14 @@ const CopyTradingStatisticsPage: React.FC = () => {
</Col>
</Row>
</Card>
{/* 买入统计卡片 */}
<Card title="买入统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总买入数量"
value={formatUSDC(statistics.totalBuyQuantity)}
value={formatNumber(statistics.totalBuyQuantity, 4)}
suffix=""
/>
</Col>
@@ -151,27 +151,27 @@ const CopyTradingStatisticsPage: React.FC = () => {
<Col xs={24} sm={12} md={6}>
<Statistic
title="总买入订单数"
value={statistics.totalBuyOrders}
value={formatNumber(statistics.totalBuyOrders)}
suffix="笔"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="平均买入价格"
value={formatUSDC(statistics.avgBuyPrice)}
value={formatNumber(statistics.avgBuyPrice, 4)}
suffix=""
/>
</Col>
</Row>
</Card>
{/* 卖出统计卡片 */}
<Card title="卖出统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Statistic
title="总卖出数量"
value={formatUSDC(statistics.totalSellQuantity)}
value={formatNumber(statistics.totalSellQuantity, 4)}
suffix=""
/>
</Col>
@@ -185,33 +185,33 @@ const CopyTradingStatisticsPage: React.FC = () => {
<Col xs={24} sm={12} md={8}>
<Statistic
title="总卖出订单数"
value={statistics.totalSellOrders}
value={formatNumber(statistics.totalSellOrders)}
suffix="笔"
/>
</Col>
</Row>
</Card>
{/* 持仓统计卡片 */}
<Card title="持仓统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={12}>
<Statistic
title="当前持仓数量"
value={formatUSDC(statistics.currentPositionQuantity)}
value={formatNumber(statistics.currentPositionQuantity, 4)}
suffix=""
/>
</Col>
<Col xs={24} sm={12} md={12}>
<Statistic
title="平均买入价格"
value={formatUSDC(statistics.avgBuyPrice)}
value={formatNumber(statistics.avgBuyPrice, 4)}
suffix=""
/>
</Col>
</Row>
</Card>
{/* 盈亏统计卡片 */}
<Card title="盈亏统计">
<Row gutter={[16, 16]}>
@@ -66,7 +66,6 @@ const FilteredOrdersList: React.FC = () => {
'ORDERBOOK_EMPTY': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
'PRICE_RANGE': { color: 'purple', label: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
'MAX_POSITION_VALUE': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' },
'MAX_POSITION_COUNT': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionCount') || '超过最大仓位数量' },
'MARKET_END_DATE': { color: 'cyan', label: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' },
'KEYWORD_FILTER': { color: 'geekblue', label: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' },
'UNKNOWN': { color: 'default', label: t('filteredOrdersList.filterTypes.unknown') || '未知原因' }
+423 -179
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type { Leader } from '../types'
import type { Leader, LeaderBalanceResponse } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const { Text } = Typography
@@ -15,11 +16,19 @@ const LeaderList: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [leaders, setLeaders] = useState<Leader[]>([])
const [loading, setLoading] = useState(false)
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
// 详情 Modal
const [detailModalVisible, setDetailModalVisible] = useState(false)
const [detailLeader, setDetailLeader] = useState<Leader | null>(null)
const [detailBalance, setDetailBalance] = useState<LeaderBalanceResponse | null>(null)
const [detailBalanceLoading, setDetailBalanceLoading] = useState(false)
useEffect(() => {
fetchLeaders()
}, [])
const fetchLeaders = async () => {
setLoading(true)
try {
@@ -27,253 +36,346 @@ const LeaderList: React.FC = () => {
if (response.data.code === 0 && response.data.data) {
setLeaders(response.data.data.list || [])
} else {
message.error(response.data.msg || t('leaderList.fetchFailed') || '获取 Leader 列表失败')
message.error(response.data.msg || t('leaderList.fetchFailed'))
}
} catch (error: any) {
message.error(error.message || t('leaderList.fetchFailed') || '获取 Leader 列表失败')
message.error(error.message || t('leaderList.fetchFailed'))
} finally {
setLoading(false)
}
}
// 加载所有 Leader 的余额
useEffect(() => {
const loadBalances = async () => {
for (const leader of leaders) {
if (!balanceMap[leader.id] && !balanceLoading[leader.id]) {
setBalanceLoading(prev => ({ ...prev, [leader.id]: true }))
try {
const balanceData = await apiService.leaders.balance({ leaderId: leader.id })
if (balanceData.data.code === 0 && balanceData.data.data) {
setBalanceMap(prev => ({
...prev,
[leader.id]: {
total: balanceData.data.data.totalBalance || '0',
available: balanceData.data.data.availableBalance || '0',
position: balanceData.data.data.positionBalance || '0'
}
}))
}
} catch (error) {
console.error(`获取 Leader ${leader.id} 余额失败:`, error)
setBalanceMap(prev => ({
...prev,
[leader.id]: { total: '-', available: '-', position: '-' }
}))
} finally {
setBalanceLoading(prev => ({ ...prev, [leader.id]: false }))
}
}
}
}
if (leaders.length > 0) {
loadBalances()
}
}, [leaders])
const handleDelete = async (leaderId: number) => {
try {
const response = await apiService.leaders.delete({ leaderId })
if (response.data.code === 0) {
message.success(t('leaderList.deleteSuccess') || '删除 Leader 成功')
message.success(t('leaderList.deleteSuccess'))
fetchLeaders()
} else {
message.error(response.data.msg || t('leaderList.deleteFailed') || '删除 Leader 失败')
message.error(response.data.msg || t('leaderList.deleteFailed'))
}
} catch (error: any) {
message.error(error.message || t('leaderList.deleteFailed') || '删除 Leader 失败')
message.error(error.message || t('leaderList.deleteFailed'))
}
}
const handleShowDetail = async (leader: Leader) => {
try {
setDetailModalVisible(true)
setDetailLeader(leader)
setDetailBalance(null)
setDetailBalanceLoading(false)
// 加载详情和余额
try {
const leaderDetail = await apiService.leaders.detail({ leaderId: leader.id })
if (leaderDetail.data.code === 0 && leaderDetail.data.data) {
setDetailLeader(leaderDetail.data.data)
}
// 加载余额
setDetailBalanceLoading(true)
try {
const balanceData = await apiService.leaders.balance({ leaderId: leader.id })
if (balanceData.data.code === 0 && balanceData.data.data) {
setDetailBalance(balanceData.data.data)
}
} catch (error) {
console.error('获取余额失败:', error)
setDetailBalance(null)
} finally {
setDetailBalanceLoading(false)
}
} catch (error: any) {
console.error('获取 Leader 详情失败:', error)
message.error(error.message || t('leaderList.fetchFailed'))
setDetailModalVisible(false)
setDetailLeader(null)
}
} catch (error: any) {
console.error('打开详情失败:', error)
message.error(error.message || t('leaderList.openDetailFailed'))
setDetailModalVisible(false)
setDetailLeader(null)
}
}
const handleRefreshDetailBalance = async () => {
if (!detailLeader) return
setDetailBalanceLoading(true)
try {
const balanceData = await apiService.leaders.balance({ leaderId: detailLeader.id })
if (balanceData.data.code === 0 && balanceData.data.data) {
setDetailBalance(balanceData.data.data)
message.success(t('leaderDetail.refresh'))
}
} catch (error: any) {
message.error(error.message || t('leaderDetail.fetchBalanceFailed'))
} finally {
setDetailBalanceLoading(false)
}
}
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const getPositionColumns = () => {
return [
{
title: t('leaderDetail.market'),
dataIndex: 'title',
key: 'title',
render: (title: string) => {
if (!title) return <Text type="secondary">-</Text>
const displayText = isMobile && title.length > 20 ? `${title.slice(0, 20)}...` : title
return <Text style={{ fontSize: isMobile ? '12px' : '13px' }}>{displayText}</Text>
}
},
{
title: t('leaderDetail.side'),
dataIndex: 'side',
key: 'side',
render: (side: string) => {
const color = side === 'YES' ? 'green' : 'red'
return <Tag color={color}>{side}</Tag>
}
},
{
title: t('leaderDetail.quantity'),
dataIndex: 'quantity',
key: 'quantity',
render: (quantity: string) => formatUSDC(quantity)
},
{
title: t('leaderDetail.avgPrice'),
dataIndex: 'avgPrice',
key: 'avgPrice',
render: (price: string) => formatUSDC(price)
},
{
title: t('leaderDetail.currentValue'),
dataIndex: 'currentValue',
key: 'currentValue',
render: (value: string) => formatUSDC(value)
},
{
title: t('leaderDetail.pnl'),
dataIndex: 'pnl',
key: 'pnl',
render: (pnl: string | undefined) => {
if (!pnl || pnl === '0') {
return <Text type="secondary">-</Text>
} else {
const numPnl = parseFloat(pnl)
const color = numPnl > 0 ? '#52c41a' : '#ff4d4f'
return <Text style={{ color }}>{formatUSDC(pnl)}</Text>
}
}
}
]
}
const columns = [
{
title: t('leaderList.leaderName') || 'Leader 名称',
title: t('leaderList.leaderName'),
dataIndex: 'leaderName',
key: 'leaderName',
render: (text: string, record: Leader) => text || `Leader ${record.id}`
},
{
title: t('leaderList.walletAddress') || '钱包地址',
dataIndex: 'leaderAddress',
key: 'leaderAddress',
render: (address: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? '12px' : '14px' }}>
{isMobile ? `${address.slice(0, 6)}...${address.slice(-4)}` : address}
</span>
width: 150,
render: (text: string, record: Leader) => (
<Space direction="vertical" size={0}>
<Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text>
</Space>
)
},
{
title: t('leaderList.remark') || '备注',
title: t('leaderList.remark'),
dataIndex: 'remark',
key: 'remark',
render: (remark: string | undefined) => remark ? (
<Text ellipsis={{ tooltip: remark }} style={{ maxWidth: isMobile ? 100 : 200 }}>
{remark}
</Text>
) : <Text type="secondary">-</Text>
},
{
title: t('leaderList.copyTradingCount') || '跟单关系数',
dataIndex: 'copyTradingCount',
key: 'copyTradingCount',
render: (count: number) => <Tag>{count}</Tag>
},
{
title: t('leaderList.createdAt') || '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
width: 200,
ellipsis: true,
render: (remark: string | undefined) => {
if (!remark) return <Text type="secondary">-</Text>
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</Text>
}
},
{
title: t('common.actions') || '操作',
title: t('leaderDetail.availableBalance'),
key: 'balance',
width: 150,
render: (_: any, record: Leader) => {
const balance = balanceMap[record.id]
if (!balance) return <Spin size="small" />
const displayText = balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`
return <Text style={{ color: '#1890ff', fontSize: '14px' }}>{displayText}</Text>
}
},
{
title: t('leaderList.copyTradingCount'),
dataIndex: 'copyTradingCount',
key: 'copyTradingCount',
width: 100,
render: (count: number) => <Tag color="cyan">{count}</Tag>
},
{
title: t('common.actions'),
key: 'action',
width: isMobile ? 150 : 200,
width: isMobile ? 180 : 250,
fixed: 'right' as const,
render: (_: any, record: Leader) => (
<Space size="small" wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}>
{t('common.viewDetail')}
</Button>
{record.website && (
<Button
type="link"
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
>
{t('leaderList.openWebsite') || '打开网页'}
<Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}>
{t('leaderList.openWebsite')}
</Button>
)}
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
>
{t('common.edit') || '编辑'}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}>
{t('common.edit')}
</Button>
<Popconfirm
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'}
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) || `该 Leader 还有 ${record.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined}
title={t('leaderList.deleteConfirm')}
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
{t('common.delete') || '删除'}
{t('common.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
flexWrap: 'wrap',
gap: '12px'
}}>
<h2 style={{ margin: 0 }}>{t('leaderList.title') || 'Leader 管理'}</h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/leaders/add')}
size={isMobile ? 'middle' : 'large'}
>
{t('leaderList.addLeader') || '添加 Leader'}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}>
{t('leaderList.addLeader')}
</Button>
</div>
<Card>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
{isMobile ? (
// 移动端卡片布局
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : leaders.length === 0 ? (
<Empty description={t('leaderList.noData') || '暂无 Leader 数据'} />
<Empty description={t('leaderList.noData')} />
) : (
<List
dataSource={leaders}
renderItem={(leader) => {
const date = new Date(leader.createdAt)
const formattedDate = date.toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
const balance = balanceMap[leader.id]
return (
<Card
key={leader.id}
style={{
marginBottom: '12px',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
{/* Leader 名称和地址 */}
<Card key={leader.id} style={{ marginBottom: '16px', borderRadius: '12px', boxShadow: '0 2px 6px rgba(0,0,0,0.06)', border: '1px solid #f0f0f0' }} bodyStyle={{ padding: '16px' }}>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '8px',
color: '#1890ff'
}}>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}>
{leader.leaderName || `Leader ${leader.id}`}
</div>
<div style={{
fontSize: '12px',
color: '#666',
fontFamily: 'monospace',
wordBreak: 'break-all'
}}>
<div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{leader.leaderAddress}
</div>
</div>
{/* 备注 */}
{leader.remark && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('leaderList.remark') || '备注'}
</Text>
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>
{leader.remark}
</Text>
{balance && (
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
<div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}>
{t('leaderDetail.availableBalance')}: {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
</div>
<div style={{ fontSize: '11px', color: '#666' }}>
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
</div>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{/* 跟单关系数 */}
<div style={{ marginBottom: '12px' }}>
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<Tag color="cyan">{leader.copyTradingCount} {t('leaderList.copyTradingCount')}</Tag>
</div>
{/* 创建时间 */}
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}>
{t('leaderList.createdAt') || '创建时间'}: {formattedDate}
</div>
{/* 操作按钮 */}
{leader.remark && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}</Text>
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text>
</div>
)}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
{t('common.viewDetail')}
</Button>
{leader.website && (
<Button
type="link"
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
style={{ flex: 1, minWidth: '80px' }}
>
{t('leaderList.openWebsite') || '打开网页'}
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
{t('leaderList.openWebsite')}
</Button>
)}
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.edit') || '编辑'}
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
{t('common.edit')}
</Button>
<Popconfirm
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'}
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) || `该 Leader 还有 ${leader.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined}
title={t('leaderList.deleteConfirm')}
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
onConfirm={() => handleDelete(leader.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.delete') || '删除'}
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
{t('common.delete')}
</Button>
</Popconfirm>
</div>
@@ -284,22 +386,164 @@ const LeaderList: React.FC = () => {
)}
</div>
) : (
// 桌面端表格布局
<Table
dataSource={leaders}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
pageSize: 20,
showSizeChanger: true
}}
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `${total}` }}
size="large"
style={{ fontSize: '14px' }}
/>
)}
</Card>
{/* 详情 Modal */}
<Modal
title={
<Space>
<WalletOutlined />
<span>{t('leaderDetail.title')}</span>
</Space>
}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>{t('common.close')}</Button>
]}
width={isMobile ? '95%' : 1000}
style={{ top: 20 }}
>
{!detailLeader ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : (
<>
{/* 基本信息 */}
<Descriptions
title={
<Space>
<WalletOutlined />
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.basicInfo')}</span>
</Space>
}
bordered
column={isMobile ? 1 : 2}
size={isMobile ? 'small' : 'default'}
>
<Descriptions.Item label={t('leaderDetail.leaderName')}>
{detailLeader.leaderName || `Leader ${detailLeader.id}`}
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.leaderAddress')}>
<span style={{ fontFamily: 'monospace' }}>{detailLeader.leaderAddress}</span>
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.copyTradingCount')}>
<Tag color="cyan">{detailLeader.copyTradingCount || 0}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.remark')}>
{detailLeader.remark || <Text type="secondary">-</Text>}
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.updatedAt')}>
{formatTimestamp(detailLeader.updatedAt)}
</Descriptions.Item>
<Descriptions.Item label={t('leaderDetail.website')}>
{detailLeader.website ? (
<Button type="link" icon={<GlobalOutlined />} onClick={() => window.open(detailLeader.website, '_blank', 'noopener,noreferrer')} style={{ padding: 0 }}>
{t('leaderDetail.openWebsite')}
</Button>
) : <Text type="secondary">-</Text>}
</Descriptions.Item>
</Descriptions>
<Divider />
{/* 余额信息 */}
<div style={{ marginBottom: '16px' }}>
<Space>
<WalletOutlined />
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.balanceInfo')}</span>
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={handleRefreshDetailBalance} loading={detailBalanceLoading}>
{t('leaderDetail.refresh')}
</Button>
</Space>
</div>
{detailBalanceLoading && !detailBalance ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin />
</div>
) : detailBalance ? (
<>
<Row gutter={16} style={{ marginBottom: '16px' }}>
<Col xs={24} sm={8} md={6}>
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<Statistic
title={t('leaderDetail.availableBalance')}
value={parseFloat(detailBalance.availableBalance)}
precision={4}
valueStyle={{ color: '#1890ff' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Card>
</Col>
<Col xs={24} sm={8} md={6}>
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<Statistic
title={t('leaderDetail.positionBalance')}
value={parseFloat(detailBalance.positionBalance)}
precision={4}
valueStyle={{ color: '#722ed1' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Card>
</Col>
<Col xs={24} sm={8} md={6}>
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<Statistic
title={t('leaderDetail.totalBalance')}
value={parseFloat(detailBalance.totalBalance)}
precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
suffix="USDC"
formatter={(value) => formatUSDC(value?.toString() || '0')}
/>
</Card>
</Col>
</Row>
{/* 持仓列表 */}
<Divider />
<div style={{ marginBottom: '16px' }}>
<Space>
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.positions')}</span>
<Tag color="blue">{detailBalance.positions?.length || 0}</Tag>
</Space>
</div>
{detailBalance.positions && detailBalance.positions.length > 0 ? (
<Table
dataSource={detailBalance.positions}
columns={getPositionColumns()}
rowKey={(record, index) => `${record.title}-${record.side}-${index}`}
pagination={{ pageSize: 10, showSizeChanger: !isMobile }}
scroll={{ x: isMobile ? 800 : 'auto' }}
size={isMobile ? 'small' : 'middle'}
/>
) : (
<Empty description={t('leaderDetail.noPositions')} />
)}
</>
) : (
<Empty description={t('leaderDetail.noBalanceData')} />
)}
</>
)}
</Modal>
</div>
)
}
export default LeaderList
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import type { Dayjs } from 'dayjs'
import { apiService } from '../services/api'
import type { Statistics as StatisticsType } from '../types'
import { formatUSDC } from '../utils'
import { formatUSDC, formatNumber } from '../utils'
import { useMediaQuery } from 'react-responsive'
const { RangePicker } = DatePicker
@@ -17,17 +17,17 @@ const Statistics: React.FC = () => {
const [stats, setStats] = useState<StatisticsType | null>(null)
const [loading, setLoading] = useState(false)
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null]>([null, null])
useEffect(() => {
fetchStatistics()
}, [])
const fetchStatistics = async () => {
setLoading(true)
try {
const startTime = dateRange[0] ? dateRange[0].valueOf() : undefined
const endTime = dateRange[1] ? dateRange[1].valueOf() : undefined
const response = await apiService.statistics.global({ startTime, endTime })
if (response.data.code === 0 && response.data.data) {
setStats(response.data.data)
@@ -40,11 +40,11 @@ const Statistics: React.FC = () => {
setLoading(false)
}
}
const handleDateRangeChange = (dates: [Dayjs | null, Dayjs | null] | null) => {
setDateRange(dates || [null, null])
}
const handleReset = () => {
setDateRange([null, null])
// 重置后自动刷新
@@ -52,7 +52,7 @@ const Statistics: React.FC = () => {
fetchStatistics()
}, 100)
}
return (
<div>
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px' }}>
@@ -85,13 +85,13 @@ const Statistics: React.FC = () => {
)}
</Space>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title={t('statistics.totalOrders') || '总订单数'}
value={stats?.totalOrders || 0}
value={formatNumber(stats?.totalOrders || 0)}
loading={loading}
/>
</Card>
+16 -10
View File
@@ -292,32 +292,38 @@ export const apiService = {
/**
* Leader
*/
add: (data: { leaderAddress: string; leaderName?: string; remark?: string; website?: string; category?: string }) =>
add: (data: { leaderAddress: string; leaderName?: string; remark?: string; website?: string; category?: string }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
/**
* Leader
*/
update: (data: { leaderId: number; leaderName?: string; remark?: string; website?: string; category?: string }) =>
update: (data: { leaderId: number; leaderName?: string; remark?: string; website?: string; category?: string }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
/**
* Leader
*/
delete: (data: { leaderId: number }) =>
delete: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/leaders/delete', data),
/**
* Leader
*/
list: (data: { category?: string } = {}) =>
list: (data: { category?: string } = {}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/list', data),
/**
* Leader
*/
detail: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data)
detail: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data),
/**
* Leader
*/
balance: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/balance', data)
},
/**
+47 -3
View File
@@ -79,6 +79,53 @@ export interface LeaderListResponse {
total: number
}
/**
*
*/
export interface PositionDto {
marketId: string
title: string // 市场名称
side: string // YES 或 NO
quantity: string
avgPrice: string
currentValue: string
pnl?: string
}
/**
* Account Leader
*/
export interface WalletBalanceResponse {
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
positionBalance: string // 仓位余额(持仓总价值)
totalBalance: string // 总余额 = 可用余额 + 仓位余额
positions?: PositionDto[]
}
/**
*
*/
export interface AccountBalanceResponse {
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
positionBalance: string // 仓位余额(持仓总价值)
totalBalance: string // 总余额 = 可用余额 + 仓位余额
positions?: PositionDto[]
}
/**
* Leader
*/
export interface LeaderBalanceResponse {
leaderId: number
leaderAddress: string
leaderName?: string
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
positionBalance: string // 仓位余额(持仓总价值)
totalBalance: string // 总余额 = 可用余额 + 仓位余额
positions?: PositionDto[]
}
/**
* Leader
*/
@@ -210,7 +257,6 @@ export interface CopyTrading {
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -260,7 +306,6 @@ export interface CopyTradingCreateRequest {
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -299,7 +344,6 @@ export interface CopyTradingUpdateRequest {
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
+35 -26
View File
@@ -1,61 +1,70 @@
/**
*
*
* @param value -
* @param maxDecimals -
* @returns ''
* @returns "123,456.78" ''
* @example
* formatNumber(1234567.89) => "1,234,567.89"
* formatNumber(1234567.00) => "1,234,567"
* formatNumber(1234.5678, 2) => "1,234.56"
* formatNumber(100.00) => "100"
* formatNumber(100.50) => "100.5"
* formatNumber(100.55) => "100.55"
*/
export const formatNumber = (value: string | number | undefined | null, maxDecimals?: number): string => {
if (value === undefined || value === null || value === '') {
return ''
}
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num)) {
return ''
}
// 如果有最大小数位数限制,先截断
// 处理小数位数
let numStr: string
if (maxDecimals !== undefined) {
const multiplier = Math.pow(10, maxDecimals)
const truncated = Math.floor(num * multiplier) / multiplier
return truncated.toFixed(maxDecimals).replace(/\.?0+$/, '')
numStr = truncated.toFixed(maxDecimals).replace(/\.?0+$/, '')
} else {
numStr = num.toString().replace(/\.?0+$/, '')
}
// 直接转换为字符串,然后去除尾随零
return num.toString().replace(/\.?0+$/, '')
// 分离整数和小数部分
const parts = numStr.split('.')
const integerPart = parts[0]
const decimalPart = parts[1]
// 为整数部分添加千分位分隔符
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
// 组合结果
return decimalPart ? `${formattedInteger}.${decimalPart}` : formattedInteger
}
/**
* USDC
* USDC
* 4
* @param value -
* @returns '-'
* @returns "1,234.56" '-'
* @example
* formatUSDC(1234.56) => "1,234.56"
* formatUSDC(1234567.8901) => "1,234,567.8901"
* formatUSDC(1234.00) => "1,234"
* formatUSDC(1.23) => "1.23"
* formatUSDC(1.23456) => "1.2345"
* formatUSDC(1.2) => "1.2"
* formatUSDC(1) => "1"
*/
export const formatUSDC = (value: string | number | undefined | null): string => {
if (value === undefined || value === null || value === '') {
return '-'
}
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num)) {
return '-'
}
// 使用 Math.floor 截断到4位小数(不四舍五入)
const multiplier = Math.pow(10, 4)
const truncated = Math.floor(num * multiplier) / multiplier
// 使用 toFixed(4) 确保格式一致,然后去除尾随零和小数点
return truncated.toFixed(4).replace(/\.?0+$/, '')
return formatNumber(num, 4)
}
// 统一导出 ethers 相关工具函数
@@ -97,7 +106,7 @@ export const isAutoGeneratedOrderId = (orderId: string | undefined | null): bool
/**
* Polymarket URL
* moneyline moneyline
* moneyline
* moneyline,
* @param marketSlug - slug
* @param eventSlug - slug events[0].slug 使
* @param marketCategory - sports, crypto
@@ -114,7 +123,7 @@ export const getPolymarketUrl = (
): string | null => {
// 优先使用 eventSlug(跳转用的 slug
const slug = eventSlug || marketSlug
if (slug) {
// 如果是 moneyline 市场,跳转到 moneyline 页面
if (isMoneyline === true) {
@@ -123,12 +132,12 @@ export const getPolymarketUrl = (
// 其他市场跳转到普通市场页面
return `https://polymarket.com/event/${slug}`
}
// 如果没有 slug,使用 marketId(作为后备)
if (marketId && marketId.startsWith('0x')) {
return `https://polymarket.com/condition/${marketId}`
}
return null
}