Merge branch 'main' into backtest

This commit is contained in:
WrBug
2026-01-31 01:06:54 +08:00
7 changed files with 751 additions and 604 deletions
-103
View File
@@ -1,103 +0,0 @@
name: Telegram Notification on PR Merge
on:
pull_request:
types:
- closed # 当 PR 被关闭(合并或关闭)时触发
jobs:
notify:
runs-on: ubuntu-latest
# 只在 PR 被合并到 main 分支时执行
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get PR details
id: pr_details
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
# 获取 PR 详细信息
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
# 获取 PR 变更的文件列表
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
# 提取 PR 描述(body),保留换行,限制长度
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
if [ ${#PR_BODY} -gt 500 ]; then
PR_BODY="${PR_BODY:0:500}..."
fi
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
echo "$PR_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查 PR 是否被合并(而不是仅关闭)
PR_MERGED="${{ github.event.pull_request.merged }}"
if [ "$PR_MERGED" != "true" ]; then
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
exit 0
fi
# 检查必要的环境变量
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取 PR 基本信息
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_TITLE="${{ github.event.pull_request.title }}"
PR_URL="${{ github.event.pull_request.html_url }}"
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
# 获取 PR 详细信息
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
# 转义 PR 标题中的 HTML 特殊字符
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g')
# 构建消息内容(仅包含关键信息)
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 1
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 1
fi
+241
View File
@@ -0,0 +1,241 @@
# PolyHermes v2.0.3 Release Notes
## 📋 版本信息
- **版本号**: v2.0.3
- **发布日期**: 2026-01-31
- **基础版本**: v2.0.2
## 🎯 改动摘要
本次版本主要优化了用户界面显示,包括数值格式化、Leader列表优化、移除不必要的配置项,提升了用户体验。
---
## ✨ 新功能
### 1. 为所有数值显示添加千分位分隔符
**功能描述**
- ✅ 重构 `formatNumber``formatUSDC` 函数,默认添加千分位分隔符
- ✅ 所有数值(金额、数量、价格等)现在默认显示千分位
- ✅ 自动去除尾随零,提升可读性
- ✅ 示例:`1234567.89` 显示为 `1,234,567.89`
**影响范围**
- `frontend/src/utils/index.ts` - 工具函数
- `frontend/src/pages/Statistics.tsx` - 统计页面
- `frontend/src/pages/CopyTradingStatistics.tsx` - 跟单统计页面
- `frontend/src/pages/PositionList.tsx` - 持仓列表
- `frontend/src/pages/AccountList.tsx` - 账户列表
**提交**: 40081c2
---
### 2. 在创建跟单配置时显示Leader资产信息
**功能描述**
- ✅ 选择Leader后自动获取并显示资产信息
- ✅ 显示总资产、可用余额、仓位资产
- ✅ 使用Card和Statistic组件美观展示
- ✅ 支持中英文多语言
- ✅ 使用formatUSDC格式化显示金额
**影响范围**
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加跟单配置弹窗
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑跟单配置弹窗
- `frontend/src/locales/**/common.json` - 多语言文件
**提交**: 390b3ee
---
### 3. Leader列表显示仓位资产
**功能描述**
- ✅ Leader列表新增仓位资产显示
- ✅ 显示Leader的总资产、可用余额、仓位资产
- ✅ 优化资产信息展示方式
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt` - Leader DTO
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
**提交**: 3350039
---
## 🔧 优化改进
### 1. Leader列表优化
**改进内容**
- ✅ 后端过滤价值为0的仓位
- ✅ 持仓列表显示市场名称而非ID
- ✅ 列表移除分类和创建时间列
- ✅ 文案'跟单关系数'改为'跟单数'
- ✅ 持仓DTO添加title字段
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt` - 区块链服务
**提交**: 0bdc0c7
---
### 2. 列表只显示可用余额
**改进内容**
- ✅ 账户列表和Leader列表只显示可用余额
- ✅ 简化界面,减少信息冗余
**影响范围**
- `frontend/src/pages/AccountList.tsx` - 账户列表
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt` - 账户服务
**提交**: 17eea01
---
### 3. 移除仓位资产列
**改进内容**
- ✅ 移除不必要的仓位资产列显示
- ✅ 简化界面布局
**影响范围**
- `frontend/src/pages/PositionList.tsx` - 持仓列表
**提交**: 6980781
---
## 🗑️ 移除功能
### 移除跟单最大仓位数量(maxPositionCount)配置
**移除原因**
- 该配置项使用频率低,且增加了系统复杂度
- 简化跟单配置,提升用户体验
**移除内容**
- ✅ 数据库:创建迁移文件 V26 删除 `max_position_count` 字段
- ✅ 后端:移除实体类、DTO、服务中的 `maxPositionCount` 相关代码
- ✅ 后端:移除 `FilterResult` 中的 `FAILED_MAX_POSITION_COUNT` 状态
- ✅ 后端:移除 `CopyTradingFilterService` 中的最大仓位数量检查逻辑
- ✅ 前端:移除类型定义、表单字段和国际化翻译
- ✅ 前端:移除过滤订单列表中的 `MAX_POSITION_COUNT` 类型
**影响范围**
- `backend/src/main/resources/db/migration/V26__remove_max_position_count.sql` - 数据库迁移
- `backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt` - 实体类
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt` - DTO
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt` - 过滤服务
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加表单
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑表单
- `frontend/src/types/index.ts` - 类型定义
**提交**: e8fd1b5
---
## 🐛 Bug 修复
### 修复TypeScript类型错误
**修复内容**
- ✅ 修复编译时的TypeScript类型错误
- ✅ 修复Spin导入问题
- ✅ 修复Table fixed类型问题
- ✅ 修复size类型问题
**影响范围**
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
**提交**: 8097660
---
## ⚠️ 潜在问题和注意事项
### 1. 数值格式化变更
**影响**
- 所有数值现在默认显示千分位分隔符
- 如果之前有代码依赖特定的数值格式,可能需要调整
**建议**
- 检查是否有代码依赖特定的数值格式
- 确认数值显示是否符合预期
### 2. 移除maxPositionCount配置
**影响**
- 如果之前使用了最大仓位数量限制功能,升级后将不再可用
- 需要手动调整跟单策略
**建议**
- 升级前检查是否有跟单配置使用了最大仓位数量限制
- 如有需要,可以手动调整跟单策略
### 3. Leader列表显示变更
**影响**
- Leader列表现在只显示价值大于0的仓位
- 列表布局和显示内容有所调整
**建议**
- 升级后检查Leader列表显示是否符合预期
- 确认仓位信息是否正确显示
---
## 📊 文件变更统计
- **修改文件数**: 28
- **新增行数**: 1490
- **删除行数**: 897
---
## 🔄 升级建议
1. **检查数值显示**
- 升级后检查所有数值显示是否符合预期
- 确认千分位分隔符显示正确
2. **检查跟单配置**
- 如果有使用最大仓位数量限制的配置,需要手动调整
- 确认跟单功能正常工作
3. **检查Leader列表**
- 升级后检查Leader列表显示是否正确
- 确认仓位信息是否完整
4. **数据库迁移**
- 升级时会自动执行数据库迁移 V26
- 迁移会删除 `max_position_count` 字段
- 建议在升级前备份数据库
---
## 📝 完整提交列表
- 40081c2 - feat: 为所有数值显示添加千分位分隔符
- e8fd1b5 - 移除跟单最大仓位数量(maxPositionCount)配置
- 390b3ee - feat: 在创建跟单配置时显示Leader资产信息
- 8097660 - fix: 修复TypeScript类型错误
- 17eea01 - refactor: 列表只显示可用余额
- 6980781 - refactor: 移除仓位资产列
- 3350039 - feat: Leader列表显示仓位资产
- 0bdc0c7 - feat: Leader列表优化
---
## 🙏 致谢
感谢所有贡献者和用户的支持与反馈!
+41 -41
View File
@@ -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' }}>
+8 -8
View File
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { Card, Row, Col, Statistic, Tag, Button, message, Spin } from 'antd' import { 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>
+193 -193
View File
@@ -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
+2 -2
View File
@@ -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>
+27 -18
View File
@@ -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 等)