Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1eae8a40b | |||
| ca655f351d | |||
| 40081c2464 | |||
| e8fd1b503b | |||
| 390b3ee876 | |||
| 80976609c7 | |||
| 17eea0183f | |||
| 6980781f89 | |||
| 3350039f05 | |||
| 0bdc0c74d1 |
@@ -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/&/\&/g' | sed 's/</\</g' | sed 's/>/\>/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
|
|
||||||
|
|
||||||
@@ -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列表优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 致谢
|
||||||
|
|
||||||
|
感谢所有贡献者和用户的支持与反馈!
|
||||||
|
|
||||||
+30
@@ -159,6 +159,36 @@ class LeaderController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
|
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
|
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(
|
data class PositionDto(
|
||||||
val marketId: String,
|
val marketId: String,
|
||||||
|
val title: String?, // 市场名称
|
||||||
val side: String, // YES 或 NO
|
val side: String, // YES 或 NO
|
||||||
val quantity: String,
|
val quantity: String,
|
||||||
val avgPrice: String,
|
val avgPrice: String,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ data class CopyTradingCreateRequest(
|
|||||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
@@ -75,7 +74,6 @@ data class CopyTradingUpdateRequest(
|
|||||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
@@ -151,7 +149,6 @@ data class CopyTradingDto(
|
|||||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ data class LeaderListRequest(
|
|||||||
val category: String? = null // sports 或 crypto
|
val category: String? = null // sports 或 crypto
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leader 余额请求
|
||||||
|
*/
|
||||||
|
data class LeaderBalanceRequest(
|
||||||
|
val leaderId: Long // LeaderID(必需)
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leader 信息响应
|
* Leader 信息响应
|
||||||
*/
|
*/
|
||||||
@@ -61,3 +68,16 @@ data class LeaderListResponse(
|
|||||||
val total: Long
|
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)
|
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
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)
|
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||||
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
|
|||||||
+10
-59
@@ -288,68 +288,19 @@ class AccountService(
|
|||||||
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询 USDC 余额和持仓信息
|
// 使用通用方法查询余额
|
||||||
val balanceResult = runBlocking {
|
val balanceResult = runBlocking {
|
||||||
try {
|
blockchainService.getWalletBalance(account.proxyAddress)
|
||||||
// 查询持仓信息(用于返回持仓列表)
|
|
||||||
// 使用代理地址查询持仓(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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success(balanceResult)
|
balanceResult.map { walletBalance: WalletBalanceResponse ->
|
||||||
|
AccountBalanceResponse(
|
||||||
|
availableBalance = walletBalance.availableBalance,
|
||||||
|
positionBalance = walletBalance.positionBalance,
|
||||||
|
totalBalance = walletBalance.totalBalance,
|
||||||
|
positions = walletBalance.positions
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("查询账户余额失败", e)
|
logger.error("查询账户余额失败", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
|
|||||||
+79
-1
@@ -8,9 +8,12 @@ import com.wrbug.polymarketbot.api.PolymarketDataApi
|
|||||||
import com.wrbug.polymarketbot.api.PositionResponse
|
import com.wrbug.polymarketbot.api.PositionResponse
|
||||||
import com.wrbug.polymarketbot.api.ValueResponse
|
import com.wrbug.polymarketbot.api.ValueResponse
|
||||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
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.EthereumUtils
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||||
@@ -255,7 +258,6 @@ class BlockchainService(
|
|||||||
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
||||||
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
||||||
Result.success(balance)
|
Result.success(balance)
|
||||||
@@ -265,6 +267,82 @@ class BlockchainService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询钱包余额(通用方法)
|
||||||
|
* 用于 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 余额
|
* 通过 RPC 查询 USDC 余额
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-34
@@ -315,7 +315,7 @@ class CopyTradingFilterService(
|
|||||||
outcomeIndex: Int?
|
outcomeIndex: Int?
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 如果未配置仓位限制,直接通过
|
// 如果未配置仓位限制,直接通过
|
||||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
if (copyTrading.maxPositionValue == null) {
|
||||||
return FilterResult.passed()
|
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()
|
return FilterResult.passed()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||||
|
|||||||
-15
@@ -100,7 +100,6 @@ class CopyTradingService(
|
|||||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||||
maxPositionCount = request.maxPositionCount,
|
|
||||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
keywords = convertKeywordsToJson(request.keywords),
|
keywords = convertKeywordsToJson(request.keywords),
|
||||||
maxMarketEndDate = request.maxMarketEndDate,
|
maxMarketEndDate = request.maxMarketEndDate,
|
||||||
@@ -132,7 +131,6 @@ class CopyTradingService(
|
|||||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||||
maxPositionCount = request.maxPositionCount,
|
|
||||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
keywords = convertKeywordsToJson(request.keywords),
|
keywords = convertKeywordsToJson(request.keywords),
|
||||||
maxMarketEndDate = request.maxMarketEndDate,
|
maxMarketEndDate = request.maxMarketEndDate,
|
||||||
@@ -164,7 +162,6 @@ class CopyTradingService(
|
|||||||
minPrice = config.minPrice,
|
minPrice = config.minPrice,
|
||||||
maxPrice = config.maxPrice,
|
maxPrice = config.maxPrice,
|
||||||
maxPositionValue = config.maxPositionValue,
|
maxPositionValue = config.maxPositionValue,
|
||||||
maxPositionCount = config.maxPositionCount,
|
|
||||||
keywordFilterMode = config.keywordFilterMode,
|
keywordFilterMode = config.keywordFilterMode,
|
||||||
keywords = config.keywords,
|
keywords = config.keywords,
|
||||||
configName = configName,
|
configName = configName,
|
||||||
@@ -282,16 +279,6 @@ class CopyTradingService(
|
|||||||
} else {
|
} else {
|
||||||
copyTrading.maxPositionValue
|
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,
|
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
||||||
keywords = if (request.keywords != null) {
|
keywords = if (request.keywords != null) {
|
||||||
convertKeywordsToJson(request.keywords)
|
convertKeywordsToJson(request.keywords)
|
||||||
@@ -520,7 +507,6 @@ class CopyTradingService(
|
|||||||
minPrice = copyTrading.minPrice?.toPlainString(),
|
minPrice = copyTrading.minPrice?.toPlainString(),
|
||||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||||
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
||||||
maxPositionCount = copyTrading.maxPositionCount,
|
|
||||||
keywordFilterMode = copyTrading.keywordFilterMode,
|
keywordFilterMode = copyTrading.keywordFilterMode,
|
||||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||||
configName = copyTrading.configName,
|
configName = copyTrading.configName,
|
||||||
@@ -585,7 +571,6 @@ class CopyTradingService(
|
|||||||
val minPrice: BigDecimal?,
|
val minPrice: BigDecimal?,
|
||||||
val maxPrice: BigDecimal?,
|
val maxPrice: BigDecimal?,
|
||||||
val maxPositionValue: BigDecimal?,
|
val maxPositionValue: BigDecimal?,
|
||||||
val maxPositionCount: Int?,
|
|
||||||
val keywordFilterMode: String,
|
val keywordFilterMode: String,
|
||||||
val keywords: String?, // JSON 字符串
|
val keywords: String?, // JSON 字符串
|
||||||
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
||||||
|
|||||||
-8
@@ -20,8 +20,6 @@ enum class FilterStatus {
|
|||||||
FAILED_ORDER_DEPTH,
|
FAILED_ORDER_DEPTH,
|
||||||
/** 失败:超过最大仓位金额 */
|
/** 失败:超过最大仓位金额 */
|
||||||
FAILED_MAX_POSITION_VALUE,
|
FAILED_MAX_POSITION_VALUE,
|
||||||
/** 失败:超过最大仓位数量 */
|
|
||||||
FAILED_MAX_POSITION_COUNT,
|
|
||||||
/** 失败:关键字过滤 */
|
/** 失败:关键字过滤 */
|
||||||
FAILED_KEYWORD_FILTER,
|
FAILED_KEYWORD_FILTER,
|
||||||
/** 失败:市场截止时间超出限制 */
|
/** 失败:市场截止时间超出限制 */
|
||||||
@@ -88,12 +86,6 @@ data class FilterResult(
|
|||||||
reason = reason
|
reason = reason
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 超过最大仓位数量 */
|
|
||||||
fun maxPositionCountFailed(reason: String) = FilterResult(
|
|
||||||
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
|
|
||||||
reason = reason
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 关键字过滤失败 */
|
/** 关键字过滤失败 */
|
||||||
fun keywordFilterFailed(reason: String) = FilterResult(
|
fun keywordFilterFailed(reason: String) = FilterResult(
|
||||||
status = FilterStatus.FAILED_KEYWORD_FILTER,
|
status = FilterStatus.FAILED_KEYWORD_FILTER,
|
||||||
|
|||||||
+38
-1
@@ -5,10 +5,12 @@ import com.wrbug.polymarketbot.entity.Leader
|
|||||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
|
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||||
import com.wrbug.polymarketbot.util.CategoryValidator
|
import com.wrbug.polymarketbot.util.CategoryValidator
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leader 管理服务
|
* Leader 管理服务
|
||||||
@@ -17,7 +19,8 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
class LeaderService(
|
class LeaderService(
|
||||||
private val leaderRepository: LeaderRepository,
|
private val leaderRepository: LeaderRepository,
|
||||||
private val accountRepository: AccountRepository,
|
private val accountRepository: AccountRepository,
|
||||||
private val copyTradingRepository: CopyTradingRepository
|
private val copyTradingRepository: CopyTradingRepository,
|
||||||
|
private val blockchainService: BlockchainService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
|
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
|
||||||
@@ -185,6 +188,40 @@ class LeaderService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 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
|
* 转换为 DTO
|
||||||
*/
|
*/
|
||||||
|
|||||||
-1
@@ -1430,7 +1430,6 @@ open class CopyOrderTrackingService(
|
|||||||
FilterStatus.FAILED_SPREAD -> "SPREAD"
|
FilterStatus.FAILED_SPREAD -> "SPREAD"
|
||||||
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
||||||
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
||||||
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
|
|
||||||
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
||||||
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
|
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;
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"viewDetail": "View Details",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -481,6 +482,37 @@
|
|||||||
"fetchFailed": "Failed to get Leader details",
|
"fetchFailed": "Failed to get Leader details",
|
||||||
"invalidId": "Invalid Leader ID"
|
"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": {
|
"userList": {
|
||||||
"title": "User Management",
|
"title": "User Management",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
@@ -686,9 +718,6 @@
|
|||||||
"maxPositionValue": "Max Position Value (USDC)",
|
"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",
|
"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)",
|
"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",
|
"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.",
|
"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",
|
"pushFilteredOrders": "Push Filtered Orders",
|
||||||
@@ -760,9 +789,6 @@
|
|||||||
"maxPositionValue": "Max Position Value (USDC)",
|
"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",
|
"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)",
|
"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",
|
"keywordFilter": "Keyword Filter",
|
||||||
"keywordFilterMode": "Filter Mode",
|
"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.",
|
"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",
|
"noAccounts": "No accounts",
|
||||||
"importAccount": "Import Account",
|
"importAccount": "Import Account",
|
||||||
"noLeaders": "No Leaders",
|
"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": {
|
"copyTradingEdit": {
|
||||||
"title": "Edit Copy Trading Config",
|
"title": "Edit Copy Trading Config",
|
||||||
@@ -867,9 +899,6 @@
|
|||||||
"maxPositionValue": "Max Position Value (USDC)",
|
"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",
|
"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)",
|
"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",
|
"keywordFilter": "Keyword Filter",
|
||||||
"keywordFilterMode": "Filter Mode",
|
"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.",
|
"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",
|
"orderbookEmpty": "Orderbook Empty",
|
||||||
"priceRange": "Price Range Mismatch",
|
"priceRange": "Price Range Mismatch",
|
||||||
"maxPositionValue": "Exceeds Max Position Value",
|
"maxPositionValue": "Exceeds Max Position Value",
|
||||||
"maxPositionCount": "Exceeds Max Position Count",
|
|
||||||
"marketEndDate": "Market End Date Exceeds Limit",
|
"marketEndDate": "Market End Date Exceeds Limit",
|
||||||
"unknown": "Unknown Reason"
|
"unknown": "Unknown Reason"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"confirm": "确定",
|
"confirm": "确定",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
|
"viewDetail": "查看详情",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
@@ -216,7 +217,7 @@
|
|||||||
"walletAddress": "钱包地址",
|
"walletAddress": "钱包地址",
|
||||||
"category": "分类",
|
"category": "分类",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"copyTradingCount": "跟单关系数",
|
"copyTradingCount": "跟单数",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"action": "操作",
|
"action": "操作",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
@@ -424,7 +425,7 @@
|
|||||||
"website": "网站",
|
"website": "网站",
|
||||||
"openWebsite": "打开网页",
|
"openWebsite": "打开网页",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"copyTradingCount": "跟单关系数",
|
"copyTradingCount": "跟单数",
|
||||||
"copyTradingRelations": "{{count}} 个跟单关系",
|
"copyTradingRelations": "{{count}} 个跟单关系",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"noData": "暂无 Leader 数据",
|
"noData": "暂无 Leader 数据",
|
||||||
@@ -481,6 +482,37 @@
|
|||||||
"fetchFailed": "获取 Leader 详情失败",
|
"fetchFailed": "获取 Leader 详情失败",
|
||||||
"invalidId": "Leader ID 无效"
|
"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": {
|
"userList": {
|
||||||
"title": "用户管理",
|
"title": "用户管理",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
@@ -686,9 +718,6 @@
|
|||||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||||
"maxPositionCount": "最大仓位数量",
|
|
||||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
|
||||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
|
||||||
"supportSell": "跟单卖出",
|
"supportSell": "跟单卖出",
|
||||||
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
|
||||||
"pushFilteredOrders": "推送已过滤订单",
|
"pushFilteredOrders": "推送已过滤订单",
|
||||||
@@ -774,9 +803,6 @@
|
|||||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||||
"maxPositionCount": "最大仓位数量",
|
|
||||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
|
||||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
|
||||||
"keywordFilter": "关键字过滤",
|
"keywordFilter": "关键字过滤",
|
||||||
"keywordFilterMode": "过滤模式",
|
"keywordFilterMode": "过滤模式",
|
||||||
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
|
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
|
||||||
@@ -808,7 +834,13 @@
|
|||||||
"noAccounts": "暂无账户",
|
"noAccounts": "暂无账户",
|
||||||
"importAccount": "导入账户",
|
"importAccount": "导入账户",
|
||||||
"noLeaders": "暂无 Leader",
|
"noLeaders": "暂无 Leader",
|
||||||
"addLeader": "添加 Leader"
|
"addLeader": "添加 Leader",
|
||||||
|
"leaderAssetInfo": "Leader 资产信息",
|
||||||
|
"loadingAssetInfo": "加载资产信息中...",
|
||||||
|
"totalAsset": "总资产",
|
||||||
|
"availableBalance": "可用余额",
|
||||||
|
"positionAsset": "仓位资产",
|
||||||
|
"fetchAssetInfoFailed": "获取资产信息失败"
|
||||||
},
|
},
|
||||||
"copyTradingEdit": {
|
"copyTradingEdit": {
|
||||||
"title": "编辑跟单配置",
|
"title": "编辑跟单配置",
|
||||||
@@ -881,9 +913,6 @@
|
|||||||
"maxPositionValue": "最大仓位金额 (USDC)",
|
"maxPositionValue": "最大仓位金额 (USDC)",
|
||||||
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
"maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制",
|
||||||
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
"maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)",
|
||||||
"maxPositionCount": "最大仓位数量",
|
|
||||||
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
|
|
||||||
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
|
|
||||||
"keywordFilter": "关键字过滤",
|
"keywordFilter": "关键字过滤",
|
||||||
"keywordFilterMode": "过滤模式",
|
"keywordFilterMode": "过滤模式",
|
||||||
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
|
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
|
||||||
@@ -934,7 +963,6 @@
|
|||||||
"orderbookEmpty": "订单簿为空",
|
"orderbookEmpty": "订单簿为空",
|
||||||
"priceRange": "价格区间不符",
|
"priceRange": "价格区间不符",
|
||||||
"maxPositionValue": "超过最大仓位金额",
|
"maxPositionValue": "超过最大仓位金额",
|
||||||
"maxPositionCount": "超过最大仓位数量",
|
|
||||||
"marketEndDate": "市场截止时间超出限制",
|
"marketEndDate": "市场截止时间超出限制",
|
||||||
"unknown": "未知原因"
|
"unknown": "未知原因"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
|
"viewDetail": "查看詳情",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
@@ -216,7 +217,7 @@
|
|||||||
"walletAddress": "錢包地址",
|
"walletAddress": "錢包地址",
|
||||||
"category": "分類",
|
"category": "分類",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"copyTradingCount": "跟單關係數",
|
"copyTradingCount": "跟單數",
|
||||||
"createdAt": "創建時間",
|
"createdAt": "創建時間",
|
||||||
"action": "操作",
|
"action": "操作",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
@@ -424,7 +425,7 @@
|
|||||||
"website": "網站",
|
"website": "網站",
|
||||||
"openWebsite": "打開網頁",
|
"openWebsite": "打開網頁",
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"copyTradingCount": "跟單關係數",
|
"copyTradingCount": "跟單數",
|
||||||
"copyTradingRelations": "{{count}} 個跟單關係",
|
"copyTradingRelations": "{{count}} 個跟單關係",
|
||||||
"createdAt": "創建時間",
|
"createdAt": "創建時間",
|
||||||
"noData": "暫無 Leader 數據",
|
"noData": "暫無 Leader 數據",
|
||||||
@@ -481,6 +482,37 @@
|
|||||||
"fetchFailed": "獲取 Leader 詳情失敗",
|
"fetchFailed": "獲取 Leader 詳情失敗",
|
||||||
"invalidId": "Leader ID 無效"
|
"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": {
|
"userList": {
|
||||||
"title": "用戶管理",
|
"title": "用戶管理",
|
||||||
"username": "用戶名",
|
"username": "用戶名",
|
||||||
@@ -686,9 +718,6 @@
|
|||||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||||
"maxPositionCount": "最大倉位數量",
|
|
||||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
|
||||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
|
||||||
"supportSell": "跟單賣出",
|
"supportSell": "跟單賣出",
|
||||||
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
|
||||||
"pushFilteredOrders": "推送已過濾訂單",
|
"pushFilteredOrders": "推送已過濾訂單",
|
||||||
@@ -760,9 +789,6 @@
|
|||||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||||
"maxPositionCount": "最大倉位數量",
|
|
||||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
|
||||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
|
||||||
"keywordFilter": "關鍵字過濾",
|
"keywordFilter": "關鍵字過濾",
|
||||||
"keywordFilterMode": "過濾模式",
|
"keywordFilterMode": "過濾模式",
|
||||||
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
|
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
|
||||||
@@ -805,10 +831,16 @@
|
|||||||
"fetchLeaderFailed": "獲取 Leader 列表失敗",
|
"fetchLeaderFailed": "獲取 Leader 列表失敗",
|
||||||
"fetchTemplateFailed": "獲取模板列表失敗",
|
"fetchTemplateFailed": "獲取模板列表失敗",
|
||||||
"templateName": "模板名稱",
|
"templateName": "模板名稱",
|
||||||
"noAccounts": "暫無賬戶",
|
"noAccounts": "暫無帳戶",
|
||||||
"importAccount": "導入賬戶",
|
"importAccount": "導入帳戶",
|
||||||
"noLeaders": "暫無 Leader",
|
"noLeaders": "暫無 Leader",
|
||||||
"addLeader": "添加 Leader"
|
"addLeader": "添加 Leader",
|
||||||
|
"leaderAssetInfo": "Leader 資產資訊",
|
||||||
|
"loadingAssetInfo": "加載資產資訊中...",
|
||||||
|
"totalAsset": "總資產",
|
||||||
|
"availableBalance": "可用餘額",
|
||||||
|
"positionAsset": "倉位資產",
|
||||||
|
"fetchAssetInfoFailed": "獲取資產資訊失敗"
|
||||||
},
|
},
|
||||||
"copyTradingEdit": {
|
"copyTradingEdit": {
|
||||||
"title": "編輯跟單配置",
|
"title": "編輯跟單配置",
|
||||||
@@ -867,9 +899,6 @@
|
|||||||
"maxPositionValue": "最大倉位金額 (USDC)",
|
"maxPositionValue": "最大倉位金額 (USDC)",
|
||||||
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
"maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制",
|
||||||
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
"maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)",
|
||||||
"maxPositionCount": "最大倉位數量",
|
|
||||||
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
|
|
||||||
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
|
|
||||||
"keywordFilter": "關鍵字過濾",
|
"keywordFilter": "關鍵字過濾",
|
||||||
"keywordFilterMode": "過濾模式",
|
"keywordFilterMode": "過濾模式",
|
||||||
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
|
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
|
||||||
@@ -934,7 +963,6 @@
|
|||||||
"orderbookEmpty": "訂單簿為空",
|
"orderbookEmpty": "訂單簿為空",
|
||||||
"priceRange": "價格區間不符",
|
"priceRange": "價格區間不符",
|
||||||
"maxPositionValue": "超過最大倉位金額",
|
"maxPositionValue": "超過最大倉位金額",
|
||||||
"maxPositionCount": "超過最大倉位數量",
|
|
||||||
"marketEndDate": "市場截止時間超出限制",
|
"marketEndDate": "市場截止時間超出限制",
|
||||||
"unknown": "未知原因"
|
"unknown": "未知原因"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -724,47 +724,47 @@ const AccountList: React.FC = () => {
|
|||||||
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
|
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
|
||||||
detailAccount.activeOrders !== undefined ||
|
detailAccount.activeOrders !== undefined ||
|
||||||
detailAccount.completedOrders !== undefined || detailAccount.positionCount !== undefined) && (
|
detailAccount.completedOrders !== undefined || detailAccount.positionCount !== undefined) && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Descriptions
|
<Descriptions
|
||||||
column={isMobile ? 1 : 2}
|
column={isMobile ? 1 : 2}
|
||||||
bordered
|
bordered
|
||||||
size={isMobile ? 'small' : 'middle'}
|
size={isMobile ? 'small' : 'middle'}
|
||||||
title={t('accountList.statistics')}
|
title={t('accountList.statistics')}
|
||||||
>
|
>
|
||||||
{detailAccount.totalOrders !== undefined && (
|
{detailAccount.totalOrders !== undefined && (
|
||||||
<Descriptions.Item label={t('accountList.totalOrders')}>
|
<Descriptions.Item label={t('accountList.totalOrders')}>
|
||||||
{detailAccount.totalOrders}
|
{detailAccount.totalOrders}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
{detailAccount.activeOrders !== undefined && (
|
{detailAccount.activeOrders !== undefined && (
|
||||||
<Descriptions.Item label={t('accountList.activeOrdersCount')}>
|
<Descriptions.Item label={t('accountList.activeOrdersCount')}>
|
||||||
<Tag color={detailAccount.activeOrders > 0 ? 'orange' : 'default'}>{detailAccount.activeOrders}</Tag>
|
<Tag color={detailAccount.activeOrders > 0 ? 'orange' : 'default'}>{detailAccount.activeOrders}</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
{detailAccount.completedOrders !== undefined && (
|
{detailAccount.completedOrders !== undefined && (
|
||||||
<Descriptions.Item label={t('accountList.completedOrders')}>
|
<Descriptions.Item label={t('accountList.completedOrders')}>
|
||||||
<Tag color="success">{detailAccount.completedOrders}</Tag>
|
<Tag color="success">{detailAccount.completedOrders}</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
{detailAccount.positionCount !== undefined && (
|
{detailAccount.positionCount !== undefined && (
|
||||||
<Descriptions.Item label={t('accountList.positionCount')}>
|
<Descriptions.Item label={t('accountList.positionCount')}>
|
||||||
<Tag color={detailAccount.positionCount > 0 ? 'blue' : 'default'}>{detailAccount.positionCount}</Tag>
|
<Tag color={detailAccount.positionCount > 0 ? 'blue' : 'default'}>{detailAccount.positionCount}</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
{detailAccount.totalPnl !== undefined && (
|
{detailAccount.totalPnl !== undefined && (
|
||||||
<Descriptions.Item label={t('accountList.totalPnl')}>
|
<Descriptions.Item label={t('accountList.totalPnl')}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
|
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
|
||||||
}}>
|
}}>
|
||||||
{formatUSDC(detailAccount.totalPnl)} USDC
|
{formatUSDC(detailAccount.totalPnl)} USDC
|
||||||
</span>
|
</span>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react'
|
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 { SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import { apiService } from '../../services/api'
|
import { apiService } from '../../services/api'
|
||||||
import { useAccountStore } from '../../store/accountStore'
|
import { useAccountStore } from '../../store/accountStore'
|
||||||
@@ -36,6 +36,8 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
const keywordInputRef = useRef<InputRef>(null)
|
const keywordInputRef = useRef<InputRef>(null)
|
||||||
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
|
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
|
||||||
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
|
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相关状态
|
// 导入账户modal相关状态
|
||||||
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
|
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
|
||||||
@@ -120,7 +122,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
|
||||||
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
|
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
|
||||||
maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined,
|
maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined,
|
||||||
maxPositionCount: (template as any).maxPositionCount,
|
|
||||||
pushFilteredOrders: template.pushFilteredOrders ?? false
|
pushFilteredOrders: template.pushFilteredOrders ?? false
|
||||||
})
|
})
|
||||||
setCopyMode(template.copyMode)
|
setCopyMode(template.copyMode)
|
||||||
@@ -132,6 +133,32 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
setCopyMode(mode)
|
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) => {
|
const handleAccountImportSuccess = async (accountId: number) => {
|
||||||
message.success(t('accountImport.importSuccess'))
|
message.success(t('accountImport.importSuccess'))
|
||||||
@@ -249,7 +276,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
minPrice: values.minPrice?.toString(),
|
minPrice: values.minPrice?.toString(),
|
||||||
maxPrice: values.maxPrice?.toString(),
|
maxPrice: values.maxPrice?.toString(),
|
||||||
maxPositionValue: values.maxPositionValue?.toString(),
|
maxPositionValue: values.maxPositionValue?.toString(),
|
||||||
maxPositionCount: values.maxPositionCount,
|
|
||||||
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
|
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
|
||||||
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
|
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
|
||||||
? keywords
|
? keywords
|
||||||
@@ -382,6 +408,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
onChange={(value) => fetchLeaderAssetInfo(value)}
|
||||||
>
|
>
|
||||||
{leaders.map(leader => (
|
{leaders.map(leader => (
|
||||||
<Option key={leader.id} value={leader.id}>
|
<Option key={leader.id} value={leader.id}>
|
||||||
@@ -391,6 +418,61 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</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>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
@@ -716,19 +798,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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>
|
<Divider>{t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react'
|
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 { SaveOutlined } from '@ant-design/icons'
|
||||||
import { apiService } from '../../services/api'
|
import { apiService } from '../../services/api'
|
||||||
import type { CopyTrading, CopyTradingUpdateRequest } from '../../types'
|
import type { CopyTrading, CopyTradingUpdateRequest } from '../../types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { formatUSDC } from '../../utils'
|
||||||
|
|
||||||
const { Option } = Select
|
const { Option } = Select
|
||||||
|
|
||||||
@@ -26,11 +27,13 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
const [fetching, setFetching] = useState(true)
|
const [fetching, setFetching] = useState(true)
|
||||||
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
|
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
|
||||||
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
|
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
|
||||||
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
|
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
|
||||||
const [keywords, setKeywords] = useState<string[]>([])
|
const [keywords, setKeywords] = useState<string[]>([])
|
||||||
const keywordInputRef = useRef<InputRef>(null)
|
const keywordInputRef = useRef<InputRef>(null)
|
||||||
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
|
const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState<number | undefined>()
|
||||||
const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR')
|
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(() => {
|
useEffect(() => {
|
||||||
if (open && copyTradingId) {
|
if (open && copyTradingId) {
|
||||||
@@ -88,7 +91,6 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
|
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
|
||||||
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
|
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
|
||||||
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
|
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
|
||||||
maxPositionCount: found.maxPositionCount,
|
|
||||||
keywordFilterMode: found.keywordFilterMode || 'DISABLED',
|
keywordFilterMode: found.keywordFilterMode || 'DISABLED',
|
||||||
configName: found.configName || '',
|
configName: found.configName || '',
|
||||||
pushFailedOrders: found.pushFailedOrders ?? false,
|
pushFailedOrders: found.pushFailedOrders ?? false,
|
||||||
@@ -96,6 +98,9 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
})
|
})
|
||||||
// 设置关键字列表
|
// 设置关键字列表
|
||||||
setKeywords(found.keywords || [])
|
setKeywords(found.keywords || [])
|
||||||
|
|
||||||
|
// 获取 Leader 资产信息
|
||||||
|
fetchLeaderAssetInfo(found.leaderId)
|
||||||
} else {
|
} else {
|
||||||
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
|
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
|
||||||
onClose()
|
onClose()
|
||||||
@@ -116,6 +121,30 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
setCopyMode(mode)
|
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>) => {
|
const handleAddKeyword = (e?: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
let inputValue = ''
|
let inputValue = ''
|
||||||
@@ -207,8 +236,6 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
minPrice: values.minPrice != null ? values.minPrice.toString() : '',
|
minPrice: values.minPrice != null ? values.minPrice.toString() : '',
|
||||||
maxPrice: values.maxPrice != null ? values.maxPrice.toString() : '',
|
maxPrice: values.maxPrice != null ? values.maxPrice.toString() : '',
|
||||||
maxPositionValue: values.maxPositionValue != null ? values.maxPositionValue.toString() : '',
|
maxPositionValue: values.maxPositionValue != null ? values.maxPositionValue.toString() : '',
|
||||||
// 对于 maxPositionCount,如果值为 null/undefined,传 -1 表示要清空(后端会识别并设置为 null)
|
|
||||||
maxPositionCount: values.maxPositionCount != null ? values.maxPositionCount : -1,
|
|
||||||
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
|
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
|
||||||
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
|
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
|
||||||
? keywords
|
? keywords
|
||||||
@@ -301,6 +328,59 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</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>
|
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -639,19 +719,6 @@ const EditModal: React.FC<EditModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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>
|
<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') || '订单簿为空' },
|
ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
|
||||||
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
|
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
|
||||||
MAX_POSITION_VALUE: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' },
|
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') || '市场截止时间超出限制' },
|
MARKET_END_DATE: { color: 'cyan', text: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' },
|
||||||
KEYWORD_FILTER: { color: 'geekblue', text: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' }
|
KEYWORD_FILTER: { color: 'geekblue', text: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
|
|||||||
import { Card, Row, Col, Statistic, Tag, Button, message, Spin } from 'antd'
|
import { Card, Row, Col, Statistic, Tag, Button, message, Spin } from 'antd'
|
||||||
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined } from '@ant-design/icons'
|
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined } from '@ant-design/icons'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC, formatNumber } from '../utils'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import type { CopyTradingStatistics } from '../types'
|
import type { CopyTradingStatistics } from '../types'
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
|||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="总买入数量"
|
title="总买入数量"
|
||||||
value={formatUSDC(statistics.totalBuyQuantity)}
|
value={formatNumber(statistics.totalBuyQuantity, 4)}
|
||||||
suffix=""
|
suffix=""
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -151,14 +151,14 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
|||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="总买入订单数"
|
title="总买入订单数"
|
||||||
value={statistics.totalBuyOrders}
|
value={formatNumber(statistics.totalBuyOrders)}
|
||||||
suffix="笔"
|
suffix="笔"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={6}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="平均买入价格"
|
title="平均买入价格"
|
||||||
value={formatUSDC(statistics.avgBuyPrice)}
|
value={formatNumber(statistics.avgBuyPrice, 4)}
|
||||||
suffix=""
|
suffix=""
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -171,7 +171,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
|||||||
<Col xs={24} sm={12} md={8}>
|
<Col xs={24} sm={12} md={8}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="总卖出数量"
|
title="总卖出数量"
|
||||||
value={formatUSDC(statistics.totalSellQuantity)}
|
value={formatNumber(statistics.totalSellQuantity, 4)}
|
||||||
suffix=""
|
suffix=""
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -185,7 +185,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
|||||||
<Col xs={24} sm={12} md={8}>
|
<Col xs={24} sm={12} md={8}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="总卖出订单数"
|
title="总卖出订单数"
|
||||||
value={statistics.totalSellOrders}
|
value={formatNumber(statistics.totalSellOrders)}
|
||||||
suffix="笔"
|
suffix="笔"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -198,14 +198,14 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
|||||||
<Col xs={24} sm={12} md={12}>
|
<Col xs={24} sm={12} md={12}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="当前持仓数量"
|
title="当前持仓数量"
|
||||||
value={formatUSDC(statistics.currentPositionQuantity)}
|
value={formatNumber(statistics.currentPositionQuantity, 4)}
|
||||||
suffix=""
|
suffix=""
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={12}>
|
<Col xs={24} sm={12} md={12}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="平均买入价格"
|
title="平均买入价格"
|
||||||
value={formatUSDC(statistics.avgBuyPrice)}
|
value={formatNumber(statistics.avgBuyPrice, 4)}
|
||||||
suffix=""
|
suffix=""
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const FilteredOrdersList: React.FC = () => {
|
|||||||
'ORDERBOOK_EMPTY': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
|
'ORDERBOOK_EMPTY': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
|
||||||
'PRICE_RANGE': { color: 'purple', label: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
|
'PRICE_RANGE': { color: 'purple', label: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' },
|
||||||
'MAX_POSITION_VALUE': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' },
|
'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') || '市场截止时间超出限制' },
|
'MARKET_END_DATE': { color: 'cyan', label: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' },
|
||||||
'KEYWORD_FILTER': { color: 'geekblue', label: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' },
|
'KEYWORD_FILTER': { color: 'geekblue', label: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' },
|
||||||
'UNKNOWN': { color: 'default', label: t('filteredOrdersList.filterTypes.unknown') || '未知原因' }
|
'UNKNOWN': { color: 'default', label: t('filteredOrdersList.filterTypes.unknown') || '未知原因' }
|
||||||
|
|||||||
+411
-167
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd'
|
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 } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import type { Leader } from '../types'
|
import type { Leader, LeaderBalanceResponse } from '../types'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import { formatUSDC } from '../utils'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -15,6 +16,14 @@ const LeaderList: React.FC = () => {
|
|||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
const [leaders, setLeaders] = useState<Leader[]>([])
|
const [leaders, setLeaders] = useState<Leader[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
fetchLeaders()
|
fetchLeaders()
|
||||||
@@ -27,110 +36,259 @@ const LeaderList: React.FC = () => {
|
|||||||
if (response.data.code === 0 && response.data.data) {
|
if (response.data.code === 0 && response.data.data) {
|
||||||
setLeaders(response.data.data.list || [])
|
setLeaders(response.data.data.list || [])
|
||||||
} else {
|
} else {
|
||||||
message.error(response.data.msg || t('leaderList.fetchFailed') || '获取 Leader 列表失败')
|
message.error(response.data.msg || t('leaderList.fetchFailed'))
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || t('leaderList.fetchFailed') || '获取 Leader 列表失败')
|
message.error(error.message || t('leaderList.fetchFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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) => {
|
const handleDelete = async (leaderId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiService.leaders.delete({ leaderId })
|
const response = await apiService.leaders.delete({ leaderId })
|
||||||
if (response.data.code === 0) {
|
if (response.data.code === 0) {
|
||||||
message.success(t('leaderList.deleteSuccess') || '删除 Leader 成功')
|
message.success(t('leaderList.deleteSuccess'))
|
||||||
fetchLeaders()
|
fetchLeaders()
|
||||||
} else {
|
} else {
|
||||||
message.error(response.data.msg || t('leaderList.deleteFailed') || '删除 Leader 失败')
|
message.error(response.data.msg || t('leaderList.deleteFailed'))
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('leaderList.leaderName') || 'Leader 名称',
|
title: t('leaderList.leaderName'),
|
||||||
dataIndex: 'leaderName',
|
dataIndex: 'leaderName',
|
||||||
key: 'leaderName',
|
key: 'leaderName',
|
||||||
render: (text: string, record: Leader) => text || `Leader ${record.id}`
|
width: 150,
|
||||||
},
|
render: (text: string, record: Leader) => (
|
||||||
{
|
<Space direction="vertical" size={0}>
|
||||||
title: t('leaderList.walletAddress') || '钱包地址',
|
<Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
|
||||||
dataIndex: 'leaderAddress',
|
<Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text>
|
||||||
key: 'leaderAddress',
|
</Space>
|
||||||
render: (address: string) => (
|
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? '12px' : '14px' }}>
|
|
||||||
{isMobile ? `${address.slice(0, 6)}...${address.slice(-4)}` : address}
|
|
||||||
</span>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('leaderList.remark') || '备注',
|
title: t('leaderList.remark'),
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
key: 'remark',
|
key: 'remark',
|
||||||
render: (remark: string | undefined) => remark ? (
|
width: 200,
|
||||||
<Text ellipsis={{ tooltip: remark }} style={{ maxWidth: isMobile ? 100 : 200 }}>
|
ellipsis: true,
|
||||||
{remark}
|
render: (remark: string | undefined) => {
|
||||||
</Text>
|
if (!remark) return <Text type="secondary">-</Text>
|
||||||
) : <Text type="secondary">-</Text>
|
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</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'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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',
|
key: 'action',
|
||||||
width: isMobile ? 150 : 200,
|
width: isMobile ? 180 : 250,
|
||||||
|
fixed: 'right' as const,
|
||||||
render: (_: any, record: Leader) => (
|
render: (_: any, record: Leader) => (
|
||||||
<Space size="small" wrap>
|
<Space size="small" wrap>
|
||||||
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}>
|
||||||
|
{t('common.viewDetail')}
|
||||||
|
</Button>
|
||||||
{record.website && (
|
{record.website && (
|
||||||
<Button
|
<Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}>
|
||||||
type="link"
|
{t('leaderList.openWebsite')}
|
||||||
size="small"
|
|
||||||
icon={<GlobalOutlined />}
|
|
||||||
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
|
|
||||||
>
|
|
||||||
{t('leaderList.openWebsite') || '打开网页'}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}>
|
||||||
type="link"
|
{t('common.edit')}
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
|
|
||||||
>
|
|
||||||
{t('common.edit') || '编辑'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'}
|
title={t('leaderList.deleteConfirm')}
|
||||||
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) || `该 Leader 还有 ${record.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined}
|
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
|
||||||
onConfirm={() => handleDelete(record.id)}
|
onConfirm={() => handleDelete(record.id)}
|
||||||
okText={t('common.confirm') || '确定'}
|
okText={t('common.confirm')}
|
||||||
cancelText={t('common.cancel') || '取消'}
|
cancelText={t('common.cancel')}
|
||||||
>
|
>
|
||||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||||
{t('common.delete') || '删除'}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -140,140 +298,84 @@ const LeaderList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
display: 'flex',
|
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
|
||||||
justifyContent: 'space-between',
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}>
|
||||||
alignItems: 'center',
|
{t('leaderList.addLeader')}
|
||||||
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'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{isMobile ? (
|
||||||
// 移动端卡片布局
|
|
||||||
<div>
|
<div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
) : leaders.length === 0 ? (
|
) : leaders.length === 0 ? (
|
||||||
<Empty description={t('leaderList.noData') || '暂无 Leader 数据'} />
|
<Empty description={t('leaderList.noData')} />
|
||||||
) : (
|
) : (
|
||||||
<List
|
<List
|
||||||
dataSource={leaders}
|
dataSource={leaders}
|
||||||
renderItem={(leader) => {
|
renderItem={(leader) => {
|
||||||
const date = new Date(leader.createdAt)
|
const balance = balanceMap[leader.id]
|
||||||
const formattedDate = date.toLocaleString(i18n.language || 'zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<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' }}>
|
||||||
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 名称和地址 */}
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<div style={{
|
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}>
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: '8px',
|
|
||||||
color: '#1890ff'
|
|
||||||
}}>
|
|
||||||
{leader.leaderName || `Leader ${leader.id}`}
|
{leader.leaderName || `Leader ${leader.id}`}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||||
fontSize: '12px',
|
|
||||||
color: '#666',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
wordBreak: 'break-all'
|
|
||||||
}}>
|
|
||||||
{leader.leaderAddress}
|
{leader.leaderAddress}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 备注 */}
|
{balance && (
|
||||||
{leader.remark && (
|
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
{t('leaderDetail.availableBalance')}: {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
|
||||||
{t('leaderList.remark') || '备注'}:
|
</div>
|
||||||
</Text>
|
<div style={{ fontSize: '11px', color: '#666' }}>
|
||||||
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>
|
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
|
||||||
{leader.remark}
|
</div>
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
{/* 跟单关系数 */}
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<Tag color="cyan">{leader.copyTradingCount} {t('leaderList.copyTradingCount')}</Tag>
|
||||||
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 创建时间 */}
|
{leader.remark && (
|
||||||
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
{t('leaderList.createdAt') || '创建时间'}: {formattedDate}
|
<Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}:</Text>
|
||||||
</div>
|
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<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 && (
|
{leader.website && (
|
||||||
<Button
|
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||||
type="link"
|
{t('leaderList.openWebsite')}
|
||||||
size="small"
|
|
||||||
icon={<GlobalOutlined />}
|
|
||||||
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
|
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
|
||||||
>
|
|
||||||
{t('leaderList.openWebsite') || '打开网页'}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||||
type="link"
|
{t('common.edit')}
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
|
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
|
||||||
>
|
|
||||||
{t('common.edit') || '编辑'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'}
|
title={t('leaderList.deleteConfirm')}
|
||||||
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) || `该 Leader 还有 ${leader.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined}
|
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
|
||||||
onConfirm={() => handleDelete(leader.id)}
|
onConfirm={() => handleDelete(leader.id)}
|
||||||
okText={t('common.confirm') || '确定'}
|
okText={t('common.confirm')}
|
||||||
cancelText={t('common.cancel') || '取消'}
|
cancelText={t('common.cancel')}
|
||||||
>
|
>
|
||||||
<Button
|
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||||
type="link"
|
{t('common.delete')}
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
style={{ flex: 1, minWidth: '80px' }}
|
|
||||||
>
|
|
||||||
{t('common.delete') || '删除'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,22 +386,164 @@ const LeaderList: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 桌面端表格布局
|
|
||||||
<Table
|
<Table
|
||||||
dataSource={leaders}
|
dataSource={leaders}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }}
|
||||||
pageSize: 20,
|
size="large"
|
||||||
showSizeChanger: true
|
style={{ fontSize: '14px' }}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LeaderList
|
export default LeaderList
|
||||||
|
|
||||||
|
|||||||
+193
-193
@@ -8,7 +8,7 @@ import { getPositionKey } from '../types'
|
|||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { useWebSocketSubscription } from '../hooks/useWebSocket'
|
import { useWebSocketSubscription } from '../hooks/useWebSocket'
|
||||||
import { wsManager } from '../services/websocket'
|
import { wsManager } from '../services/websocket'
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC, formatNumber as formatNumberUtil } from '../utils'
|
||||||
|
|
||||||
type PositionFilter = 'current' | 'historical'
|
type PositionFilter = 'current' | 'historical'
|
||||||
type ViewMode = 'card' | 'list'
|
type ViewMode = 'card' | 'list'
|
||||||
@@ -328,7 +328,7 @@ const PositionList: React.FC = () => {
|
|||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
const num = parseFloat(value)
|
const num = parseFloat(value)
|
||||||
if (isNaN(num)) return value
|
if (isNaN(num)) return value
|
||||||
return num.toFixed(decimals)
|
return formatNumberUtil(value, decimals)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPercent = (value: string | undefined) => {
|
const formatPercent = (value: string | undefined) => {
|
||||||
@@ -823,132 +823,132 @@ const PositionList: React.FC = () => {
|
|||||||
// 根据仓位类型动态生成列(历史仓位不显示当前价格、当前价值、状态列)
|
// 根据仓位类型动态生成列(历史仓位不显示当前价格、当前价值、状态列)
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const baseColumns: any[] = [
|
const baseColumns: any[] = [
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
key: 'icon',
|
key: 'icon',
|
||||||
width: 50,
|
width: 50,
|
||||||
render: (_: any, record: AccountPosition) => {
|
render: (_: any, record: AccountPosition) => {
|
||||||
if (!record.marketIcon) return null
|
if (!record.marketIcon) return null
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={record.marketIcon}
|
src={record.marketIcon}
|
||||||
alt={record.marketTitle || 'Market'}
|
alt={record.marketTitle || 'Market'}
|
||||||
style={{
|
style={{
|
||||||
width: '32px',
|
width: '32px',
|
||||||
height: '32px',
|
height: '32px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
objectFit: 'cover'
|
objectFit: 'cover'
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 图片加载失败时隐藏
|
// 图片加载失败时隐藏
|
||||||
e.currentTarget.style.display = 'none'
|
e.currentTarget.style.display = 'none'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
fixed: isMobile ? ('left' as const) : undefined
|
||||||
},
|
},
|
||||||
fixed: isMobile ? ('left' as const) : undefined
|
{
|
||||||
},
|
title: '账户',
|
||||||
{
|
dataIndex: 'accountName',
|
||||||
title: '账户',
|
key: 'accountName',
|
||||||
dataIndex: 'accountName',
|
render: (text: string | undefined, record: AccountPosition) => (
|
||||||
key: 'accountName',
|
|
||||||
render: (text: string | undefined, record: AccountPosition) => (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 'bold' }}>
|
|
||||||
{text || `账户 ${record.accountId}`}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
|
||||||
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-6)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
fixed: isMobile ? ('left' as const) : undefined,
|
|
||||||
width: isMobile ? 150 : 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '市场',
|
|
||||||
dataIndex: 'marketTitle',
|
|
||||||
key: 'marketTitle',
|
|
||||||
render: (text: string | undefined, record: AccountPosition) => {
|
|
||||||
const url = record.eventSlug || record.marketSlug
|
|
||||||
? `https://polymarket.com/event/${record.eventSlug || record.marketSlug}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
const handleTitleClick = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (url) {
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
<div>
|
||||||
{text ? (
|
<div style={{ fontWeight: 'bold' }}>
|
||||||
<div>
|
{text || `账户 ${record.accountId}`}
|
||||||
{url ? (
|
</div>
|
||||||
<a
|
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||||
href={url}
|
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-6)}
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={handleTitleClick}
|
|
||||||
style={{ fontWeight: 'bold', color: '#1890ff', textDecoration: 'none', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div style={{ fontWeight: 'bold' }}>{text}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '12px' }}>
|
|
||||||
{record.marketId.slice(0, 10)}...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{record.marketSlug && (
|
|
||||||
<div style={{ fontSize: '12px', color: '#999' }}>{record.marketSlug}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
|
fixed: isMobile ? ('left' as const) : undefined,
|
||||||
|
width: isMobile ? 150 : 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '市场',
|
||||||
|
dataIndex: 'marketTitle',
|
||||||
|
key: 'marketTitle',
|
||||||
|
render: (text: string | undefined, record: AccountPosition) => {
|
||||||
|
const url = record.eventSlug || record.marketSlug
|
||||||
|
? `https://polymarket.com/event/${record.eventSlug || record.marketSlug}`
|
||||||
|
: null
|
||||||
|
|
||||||
|
const handleTitleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{text ? (
|
||||||
|
<div>
|
||||||
|
{url ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={handleTitleClick}
|
||||||
|
style={{ fontWeight: 'bold', color: '#1890ff', textDecoration: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontWeight: 'bold' }}>{text}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '12px' }}>
|
||||||
|
{record.marketId.slice(0, 10)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record.marketSlug && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#999' }}>{record.marketSlug}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
width: isMobile ? 200 : 250
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '方向',
|
||||||
|
dataIndex: 'side',
|
||||||
|
key: 'side',
|
||||||
|
render: (side: string) => (
|
||||||
|
<Tag color={getSideColor(side)}>{side}</Tag>
|
||||||
|
),
|
||||||
|
width: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
key: 'quantity',
|
||||||
|
render: (quantity: string) => formatNumber(quantity, 4),
|
||||||
|
align: 'right' as const,
|
||||||
|
width: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平均价格',
|
||||||
|
dataIndex: 'avgPrice',
|
||||||
|
key: 'avgPrice',
|
||||||
|
render: (price: string) => formatNumber(price, 4),
|
||||||
|
align: 'right' as const,
|
||||||
|
width: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '开仓价值',
|
||||||
|
dataIndex: 'initialValue',
|
||||||
|
key: 'initialValue',
|
||||||
|
render: (value: string) => (
|
||||||
|
<span>
|
||||||
|
{formatUSDC(value)} USDC
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
align: 'right' as const,
|
||||||
|
width: 120
|
||||||
},
|
},
|
||||||
width: isMobile ? 200 : 250
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '方向',
|
|
||||||
dataIndex: 'side',
|
|
||||||
key: 'side',
|
|
||||||
render: (side: string) => (
|
|
||||||
<Tag color={getSideColor(side)}>{side}</Tag>
|
|
||||||
),
|
|
||||||
width: 80
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '数量',
|
|
||||||
dataIndex: 'quantity',
|
|
||||||
key: 'quantity',
|
|
||||||
render: (quantity: string) => formatNumber(quantity, 4),
|
|
||||||
align: 'right' as const,
|
|
||||||
width: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '平均价格',
|
|
||||||
dataIndex: 'avgPrice',
|
|
||||||
key: 'avgPrice',
|
|
||||||
render: (price: string) => formatNumber(price, 4),
|
|
||||||
align: 'right' as const,
|
|
||||||
width: 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '开仓价值',
|
|
||||||
dataIndex: 'initialValue',
|
|
||||||
key: 'initialValue',
|
|
||||||
render: (value: string) => (
|
|
||||||
<span>
|
|
||||||
{formatUSDC(value)} USDC
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
align: 'right' as const,
|
|
||||||
width: 120
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// 只有当前仓位才显示当前价格和当前价值列
|
// 只有当前仓位才显示当前价格和当前价值列
|
||||||
@@ -985,70 +985,70 @@ const PositionList: React.FC = () => {
|
|||||||
|
|
||||||
// 只有当前仓位才显示盈亏和已实现盈亏列
|
// 只有当前仓位才显示盈亏和已实现盈亏列
|
||||||
if (positionFilter === 'current') {
|
if (positionFilter === 'current') {
|
||||||
baseColumns.push(
|
baseColumns.push(
|
||||||
{
|
{
|
||||||
title: '盈亏',
|
title: '盈亏',
|
||||||
dataIndex: 'pnl',
|
dataIndex: 'pnl',
|
||||||
key: 'pnl',
|
key: 'pnl',
|
||||||
render: (pnl: string, record: AccountPosition) => {
|
render: (pnl: string, record: AccountPosition) => {
|
||||||
const pnlNum = parseFloat(pnl || '0')
|
const pnlNum = parseFloat(pnl || '0')
|
||||||
const percentPnl = parseFloat(record.percentPnl || '0')
|
const percentPnl = parseFloat(record.percentPnl || '0')
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{
|
<div style={{
|
||||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}>
|
}}>
|
||||||
{pnlNum >= 0 ? '+' : ''}{formatUSDC(pnl)} USDC
|
{pnlNum >= 0 ? '+' : ''}{formatUSDC(pnl)} USDC
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
|
||||||
}}>
|
|
||||||
{formatPercent(record.percentPnl)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
align: 'right' as const,
|
|
||||||
width: 150,
|
|
||||||
sorter: (a: AccountPosition, b: AccountPosition) => {
|
|
||||||
const pnlA = parseFloat(a.pnl || '0')
|
|
||||||
const pnlB = parseFloat(b.pnl || '0')
|
|
||||||
return pnlA - pnlB
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '已实现盈亏',
|
|
||||||
dataIndex: 'realizedPnl',
|
|
||||||
key: 'realizedPnl',
|
|
||||||
render: (realizedPnl: string | undefined, record: AccountPosition) => {
|
|
||||||
if (!realizedPnl) return '-'
|
|
||||||
const pnlNum = parseFloat(realizedPnl)
|
|
||||||
const percentPnl = parseFloat(record.percentRealizedPnl || '0')
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{pnlNum >= 0 ? '+' : ''}{formatUSDC(realizedPnl)} USDC
|
|
||||||
</div>
|
|
||||||
{record.percentRealizedPnl && (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
||||||
}}>
|
}}>
|
||||||
{formatPercent(record.percentRealizedPnl)}
|
{formatPercent(record.percentPnl)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
},
|
||||||
|
align: 'right' as const,
|
||||||
|
width: 150,
|
||||||
|
sorter: (a: AccountPosition, b: AccountPosition) => {
|
||||||
|
const pnlA = parseFloat(a.pnl || '0')
|
||||||
|
const pnlB = parseFloat(b.pnl || '0')
|
||||||
|
return pnlA - pnlB
|
||||||
|
}
|
||||||
},
|
},
|
||||||
align: 'right' as const,
|
{
|
||||||
width: 150
|
title: '已实现盈亏',
|
||||||
}
|
dataIndex: 'realizedPnl',
|
||||||
)
|
key: 'realizedPnl',
|
||||||
|
render: (realizedPnl: string | undefined, record: AccountPosition) => {
|
||||||
|
if (!realizedPnl) return '-'
|
||||||
|
const pnlNum = parseFloat(realizedPnl)
|
||||||
|
const percentPnl = parseFloat(record.percentRealizedPnl || '0')
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{pnlNum >= 0 ? '+' : ''}{formatUSDC(realizedPnl)} USDC
|
||||||
|
</div>
|
||||||
|
{record.percentRealizedPnl && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
||||||
|
}}>
|
||||||
|
{formatPercent(record.percentRealizedPnl)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
align: 'right' as const,
|
||||||
|
width: 150
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有当前仓位才显示操作列
|
// 只有当前仓位才显示操作列
|
||||||
@@ -1097,7 +1097,7 @@ const PositionList: React.FC = () => {
|
|||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px', marginBottom: '12px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<h2 style={{ margin: 0 }}>仓位管理</h2>
|
<h2 style={{ margin: 0 }}>仓位管理</h2>
|
||||||
{/* WebSocket 连接状态指示器 */}
|
{/* WebSocket 连接状态指示器 */}
|
||||||
<Tag
|
<Tag
|
||||||
color={wsConnected ? 'green' : 'orange'}
|
color={wsConnected ? 'green' : 'orange'}
|
||||||
@@ -1163,9 +1163,9 @@ const PositionList: React.FC = () => {
|
|||||||
return nameA.localeCompare(nameB, 'zh-CN')
|
return nameA.localeCompare(nameB, 'zh-CN')
|
||||||
})
|
})
|
||||||
.map(account => ({
|
.map(account => ({
|
||||||
value: account.id,
|
value: account.id,
|
||||||
label: account.accountName || `账户 ${account.id}`
|
label: account.accountName || `账户 ${account.id}`
|
||||||
}))
|
}))
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
@@ -1176,10 +1176,10 @@ const PositionList: React.FC = () => {
|
|||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
gap: '4px'
|
gap: '4px'
|
||||||
}}>
|
}}>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={positionFilter}
|
value={positionFilter}
|
||||||
onChange={(e) => setPositionFilter(e.target.value)}
|
onChange={(e) => setPositionFilter(e.target.value)}
|
||||||
size={isMobile ? 'small' : 'middle'}
|
size={isMobile ? 'small' : 'middle'}
|
||||||
style={{ display: 'flex', gap: '4px' }}
|
style={{ display: 'flex', gap: '4px' }}
|
||||||
>
|
>
|
||||||
<Radio.Button
|
<Radio.Button
|
||||||
@@ -1215,7 +1215,7 @@ const PositionList: React.FC = () => {
|
|||||||
{currentCount}
|
{currentCount}
|
||||||
</Tag>
|
</Tag>
|
||||||
</span>
|
</span>
|
||||||
</Radio.Button>
|
</Radio.Button>
|
||||||
<Radio.Button
|
<Radio.Button
|
||||||
value="historical"
|
value="historical"
|
||||||
style={{
|
style={{
|
||||||
@@ -1249,8 +1249,8 @@ const PositionList: React.FC = () => {
|
|||||||
{historicalCount}
|
{historicalCount}
|
||||||
</Tag>
|
</Tag>
|
||||||
</span>
|
</span>
|
||||||
</Radio.Button>
|
</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</div>
|
</div>
|
||||||
{redeemableSummary && redeemableSummary.totalCount > 0 && (
|
{redeemableSummary && redeemableSummary.totalCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import type { Statistics as StatisticsType } from '../types'
|
import type { Statistics as StatisticsType } from '../types'
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC, formatNumber } from '../utils'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
|
||||||
const { RangePicker } = DatePicker
|
const { RangePicker } = DatePicker
|
||||||
@@ -91,7 +91,7 @@ const Statistics: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t('statistics.totalOrders') || '总订单数'}
|
title={t('statistics.totalOrders') || '总订单数'}
|
||||||
value={stats?.totalOrders || 0}
|
value={formatNumber(stats?.totalOrders || 0)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -317,7 +317,13 @@ export const apiService = {
|
|||||||
* 查询 Leader 详情
|
* 查询 Leader 详情
|
||||||
*/
|
*/
|
||||||
detail: (data: { leaderId: number }) =>
|
detail: (data: { leaderId: number }) =>
|
||||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data)
|
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 Leader 余额
|
||||||
|
*/
|
||||||
|
balance: (data: { leaderId: number }) =>
|
||||||
|
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/balance', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -79,6 +79,53 @@ export interface LeaderListResponse {
|
|||||||
total: number
|
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 添加请求
|
* Leader 添加请求
|
||||||
*/
|
*/
|
||||||
@@ -210,7 +257,6 @@ export interface CopyTrading {
|
|||||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
||||||
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
|
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
|
||||||
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
|
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
@@ -260,7 +306,6 @@ export interface CopyTradingCreateRequest {
|
|||||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
||||||
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
|
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
|
||||||
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
|
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
@@ -299,7 +344,6 @@ export interface CopyTradingUpdateRequest {
|
|||||||
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
|
||||||
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
|
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
|
||||||
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
|
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
|
|||||||
+27
-18
@@ -1,12 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 格式化数字,自动去除尾随零
|
* 格式化数字,添加千分位分隔符,自动去除尾随零
|
||||||
* @param value - 数字值(字符串或数字)
|
* @param value - 数字值(字符串或数字)
|
||||||
* @param maxDecimals - 最大小数位数(默认不限制)
|
* @param maxDecimals - 最大小数位数(默认不限制)
|
||||||
* @returns 格式化后的字符串,如果值为空或无效则返回 ''
|
* @returns 格式化后的字符串,如 "123,456.78",如果值为空或无效则返回 ''
|
||||||
* @example
|
* @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.00) => "100"
|
||||||
* formatNumber(100.50) => "100.5"
|
* formatNumber(100.50) => "100.5"
|
||||||
* formatNumber(100.55) => "100.55"
|
|
||||||
*/
|
*/
|
||||||
export const formatNumber = (value: string | number | undefined | null, maxDecimals?: number): string => {
|
export const formatNumber = (value: string | number | undefined | null, maxDecimals?: number): string => {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
@@ -18,26 +20,38 @@ export const formatNumber = (value: string | number | undefined | null, maxDecim
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有最大小数位数限制,先截断
|
// 处理小数位数
|
||||||
|
let numStr: string
|
||||||
if (maxDecimals !== undefined) {
|
if (maxDecimals !== undefined) {
|
||||||
const multiplier = Math.pow(10, maxDecimals)
|
const multiplier = Math.pow(10, maxDecimals)
|
||||||
const truncated = Math.floor(num * multiplier) / multiplier
|
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 位小数,自动去除尾随零(截断,不四舍五入)
|
* 最多显示 4 位小数,自动去除尾随零(截断,不四舍五入)
|
||||||
* @param value - 金额值(字符串或数字)
|
* @param value - 金额值(字符串或数字)
|
||||||
* @returns 格式化后的字符串,如果值为空或无效则返回 '-'
|
* @returns 格式化后的字符串,如 "1,234.56",如果值为空或无效则返回 '-'
|
||||||
* @example
|
* @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.23) => "1.23"
|
||||||
* formatUSDC(1.23456) => "1.2345"
|
|
||||||
* formatUSDC(1.2) => "1.2"
|
|
||||||
* formatUSDC(1) => "1"
|
* formatUSDC(1) => "1"
|
||||||
*/
|
*/
|
||||||
export const formatUSDC = (value: string | number | undefined | null): string => {
|
export const formatUSDC = (value: string | number | undefined | null): string => {
|
||||||
@@ -50,12 +64,7 @@ export const formatUSDC = (value: string | number | undefined | null): string =>
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Math.floor 截断到4位小数(不四舍五入)
|
return formatNumber(num, 4)
|
||||||
const multiplier = Math.pow(10, 4)
|
|
||||||
const truncated = Math.floor(num * multiplier) / multiplier
|
|
||||||
|
|
||||||
// 使用 toFixed(4) 确保格式一致,然后去除尾随零和小数点
|
|
||||||
return truncated.toFixed(4).replace(/\.?0+$/, '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一导出 ethers 相关工具函数
|
// 统一导出 ethers 相关工具函数
|
||||||
@@ -97,7 +106,7 @@ export const isAutoGeneratedOrderId = (orderId: string | undefined | null): bool
|
|||||||
/**
|
/**
|
||||||
* 构建 Polymarket 市场 URL
|
* 构建 Polymarket 市场 URL
|
||||||
* 对于 moneyline 市场,跳转到 moneyline 页面
|
* 对于 moneyline 市场,跳转到 moneyline 页面
|
||||||
* 注意:目前无法自动判断市场是否为 moneyline,需要后端提供标识
|
* 注意:目前无法自动判断市场是否为 moneyline,需要后端提供标识
|
||||||
* @param marketSlug - 市场 slug(用于显示)
|
* @param marketSlug - 市场 slug(用于显示)
|
||||||
* @param eventSlug - 跳转用的 slug(从 events[0].slug 获取,优先使用)
|
* @param eventSlug - 跳转用的 slug(从 events[0].slug 获取,优先使用)
|
||||||
* @param marketCategory - 市场分类(sports, crypto 等)
|
* @param marketCategory - 市场分类(sports, crypto 等)
|
||||||
|
|||||||
Reference in New Issue
Block a user