feat(frontend): 优化多个列表页面 UI,添加空状态提示

- 优化 AccountList、BacktestList、CopyTradingList、CryptoTailStrategyList、LeaderList、TemplateList、UserList 页面操作栏
- 添加空数据状态提示
- 添加相关多语言翻译 key

Made-with: Cursor
This commit is contained in:
WrBug
2026-03-01 06:48:51 +08:00
parent f86749e85d
commit 4ebfacfc21
10 changed files with 1288 additions and 920 deletions
+12 -4
View File
@@ -42,7 +42,8 @@
"pageOf": "Page", "pageOf": "Page",
"ascending": "Ascending", "ascending": "Ascending",
"descending": "Descending", "descending": "Descending",
"day": "day" "day": "day",
"orders": "orders"
}, },
"account": { "account": {
"title": "Account Management", "title": "Account Management",
@@ -176,7 +177,8 @@
"updateFailed": "Failed to update account", "updateFailed": "Failed to update account",
"getDetailFailedForEdit": "Failed to get account detail", "getDetailFailedForEdit": "Failed to get account detail",
"loading": "Loading...", "loading": "Loading...",
"fetchFailed": "Failed to get account list" "fetchFailed": "Failed to get account list",
"noData": "No account data"
}, },
"accountImport": { "accountImport": {
"title": "Import Account", "title": "Import Account",
@@ -660,7 +662,8 @@
"deleteSuccess": "User deleted successfully", "deleteSuccess": "User deleted successfully",
"deleteFailed": "Failed to delete user", "deleteFailed": "Failed to delete user",
"deleteConfirm": "Are you sure you want to delete this user?", "deleteConfirm": "Are you sure you want to delete this user?",
"total": "Total {{total}} items" "total": "Total {{total}} items",
"noData": "No user data"
}, },
"statistics": { "statistics": {
"title": "Statistics", "title": "Statistics",
@@ -1121,7 +1124,9 @@
"updateStatusFailed": "Failed to update copy trading status", "updateStatusFailed": "Failed to update copy trading status",
"deleteSuccess": "Copy trading deleted successfully", "deleteSuccess": "Copy trading deleted successfully",
"deleteFailed": "Failed to delete copy trading", "deleteFailed": "Failed to delete copy trading",
"deleteConfirm": "Are you sure you want to delete this copy trading relationship?" "deleteConfirm": "Are you sure you want to delete this copy trading relationship?",
"profitRate": "Profit Rate",
"noData": "No copy trading configuration"
}, },
"notificationSettings": { "notificationSettings": {
"title": "Notification Settings", "title": "Notification Settings",
@@ -1351,6 +1356,8 @@
"title": "Backtest", "title": "Backtest",
"taskName": "Task Name", "taskName": "Task Name",
"leader": "Leader", "leader": "Leader",
"balance": "Balance (Init→Final)",
"profit": "Profit (Amount/Rate)",
"initialBalance": "Initial Balance", "initialBalance": "Initial Balance",
"backtestDays": "Backtest Days", "backtestDays": "Backtest Days",
"status": "Status", "status": "Status",
@@ -1399,6 +1406,7 @@
"createCopyTradingSuccess": "Copy trading config created successfully", "createCopyTradingSuccess": "Copy trading config created successfully",
"noTasks": "No backtest tasks", "noTasks": "No backtest tasks",
"noTrades": "No trade records", "noTrades": "No trade records",
"noData": "No backtest data",
"fetchTasksFailed": "Failed to fetch task list", "fetchTasksFailed": "Failed to fetch task list",
"fetchTaskDetailFailed": "Failed to fetch task detail", "fetchTaskDetailFailed": "Failed to fetch task detail",
"fetchTradesFailed": "Failed to fetch trade records", "fetchTradesFailed": "Failed to fetch trade records",
+12 -4
View File
@@ -42,7 +42,8 @@
"copyFailed": "复制失败", "copyFailed": "复制失败",
"ascending": "升序", "ascending": "升序",
"descending": "降序", "descending": "降序",
"day": "天" "day": "天",
"orders": "单"
}, },
"login": { "login": {
"title": "登录", "title": "登录",
@@ -176,7 +177,8 @@
"updateFailed": "更新账户失败", "updateFailed": "更新账户失败",
"getDetailFailedForEdit": "获取账户详情失败", "getDetailFailedForEdit": "获取账户详情失败",
"loading": "加载中...", "loading": "加载中...",
"fetchFailed": "获取账户列表失败" "fetchFailed": "获取账户列表失败",
"noData": "暂无账户数据"
}, },
"accountImport": { "accountImport": {
"title": "导入账户", "title": "导入账户",
@@ -660,7 +662,8 @@
"deleteSuccess": "删除用户成功", "deleteSuccess": "删除用户成功",
"deleteFailed": "删除用户失败", "deleteFailed": "删除用户失败",
"deleteConfirm": "确定要删除这个用户吗?", "deleteConfirm": "确定要删除这个用户吗?",
"total": "共 {{total}} 条" "total": "共 {{total}} 条",
"noData": "暂无用户数据"
}, },
"statistics": { "statistics": {
"title": "统计信息", "title": "统计信息",
@@ -1121,7 +1124,9 @@
"updateStatusFailed": "更新跟单状态失败", "updateStatusFailed": "更新跟单状态失败",
"deleteSuccess": "删除跟单成功", "deleteSuccess": "删除跟单成功",
"deleteFailed": "删除跟单失败", "deleteFailed": "删除跟单失败",
"deleteConfirm": "确定要删除这个跟单关系吗?" "deleteConfirm": "确定要删除这个跟单关系吗?",
"profitRate": "收益率",
"noData": "暂无跟单配置"
}, },
"notificationSettings": { "notificationSettings": {
"title": "消息推送设置", "title": "消息推送设置",
@@ -1351,6 +1356,8 @@
"title": "回测", "title": "回测",
"taskName": "任务名称", "taskName": "任务名称",
"leader": "Leader", "leader": "Leader",
"balance": "资金 (初始→最终)",
"profit": "收益 (金额/比例)",
"initialBalance": "初始资金", "initialBalance": "初始资金",
"backtestDays": "回测天数", "backtestDays": "回测天数",
"status": "状态", "status": "状态",
@@ -1399,6 +1406,7 @@
"createCopyTradingSuccess": "跟单配置创建成功", "createCopyTradingSuccess": "跟单配置创建成功",
"noTasks": "暂无回测任务", "noTasks": "暂无回测任务",
"noTrades": "暂无交易记录", "noTrades": "暂无交易记录",
"noData": "暂无回测数据",
"fetchTasksFailed": "获取任务列表失败", "fetchTasksFailed": "获取任务列表失败",
"fetchTaskDetailFailed": "获取任务详情失败", "fetchTaskDetailFailed": "获取任务详情失败",
"fetchTradesFailed": "获取交易记录失败", "fetchTradesFailed": "获取交易记录失败",
+12 -4
View File
@@ -42,7 +42,8 @@
"pageOf": "第", "pageOf": "第",
"ascending": "升序", "ascending": "升序",
"descending": "降序", "descending": "降序",
"day": "天" "day": "天",
"orders": "單"
}, },
"account": { "account": {
"title": "賬戶管理", "title": "賬戶管理",
@@ -176,7 +177,8 @@
"updateFailed": "更新賬戶失敗", "updateFailed": "更新賬戶失敗",
"getDetailFailedForEdit": "獲取賬戶詳情失敗", "getDetailFailedForEdit": "獲取賬戶詳情失敗",
"loading": "加載中...", "loading": "加載中...",
"fetchFailed": "獲取賬戶列表失敗" "fetchFailed": "獲取賬戶列表失敗",
"noData": "暫無賬戶數據"
}, },
"accountImport": { "accountImport": {
"title": "導入賬戶", "title": "導入賬戶",
@@ -660,7 +662,8 @@
"deleteSuccess": "刪除用戶成功", "deleteSuccess": "刪除用戶成功",
"deleteFailed": "刪除用戶失敗", "deleteFailed": "刪除用戶失敗",
"deleteConfirm": "確定要刪除這個用戶嗎?", "deleteConfirm": "確定要刪除這個用戶嗎?",
"total": "共 {{total}} 條" "total": "共 {{total}} 條",
"noData": "暫無用戶數據"
}, },
"statistics": { "statistics": {
"title": "統計信息", "title": "統計信息",
@@ -1121,7 +1124,9 @@
"updateStatusFailed": "更新跟單狀態失敗", "updateStatusFailed": "更新跟單狀態失敗",
"deleteSuccess": "刪除跟單成功", "deleteSuccess": "刪除跟單成功",
"deleteFailed": "刪除跟單失敗", "deleteFailed": "刪除跟單失敗",
"deleteConfirm": "確定要刪除這個跟單關係嗎?" "deleteConfirm": "確定要刪除這個跟單關係嗎?",
"profitRate": "收益率",
"noData": "暫無跟單配置"
}, },
"notificationSettings": { "notificationSettings": {
"title": "消息推送設置", "title": "消息推送設置",
@@ -1351,6 +1356,8 @@
"title": "回測", "title": "回測",
"taskName": "任務名稱", "taskName": "任務名稱",
"leader": "Leader", "leader": "Leader",
"balance": "資金 (初始→最終)",
"profit": "收益 (金額/比例)",
"initialBalance": "初始資金", "initialBalance": "初始資金",
"backtestDays": "回測天數", "backtestDays": "回測天數",
"status": "狀態", "status": "狀態",
@@ -1399,6 +1406,7 @@
"createCopyTradingSuccess": "跟單配置創建成功", "createCopyTradingSuccess": "跟單配置創建成功",
"noTasks": "暫無回測任務", "noTasks": "暫無回測任務",
"noTrades": "暫無交易記錄", "noTrades": "暫無交易記錄",
"noData": "暫無回測數據",
"fetchTasksFailed": "獲取任務列表失敗", "fetchTasksFailed": "獲取任務列表失敗",
"fetchTaskDetailFailed": "獲取任務詳情失敗", "fetchTaskDetailFailed": "獲取任務詳情失敗",
"fetchTradesFailed": "獲取交易記錄失敗", "fetchTradesFailed": "獲取交易記錄失敗",
+232 -190
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert } from 'antd' import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert, Tooltip, List, Empty } from 'antd'
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons' import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAccountStore } from '../store/accountStore' import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types' import type { Account } from '../types'
@@ -331,171 +331,79 @@ const AccountList: React.FC = () => {
{ {
title: t('accountList.action'), title: t('accountList.action'),
key: 'action', key: 'action',
width: 140,
render: (_: any, record: Account) => ( render: (_: any, record: Account) => (
<Space size="small"> <Space size={4}>
<Button <Tooltip title={t('accountList.detail')}>
type="link" <div
size="small" onClick={() => handleShowDetail(record)}
onClick={() => handleShowDetail(record)} style={{
> display: 'flex',
{t('accountList.detail')} alignItems: 'center',
</Button> justifyContent: 'center',
<Button width: '32px',
type="link" height: '32px',
size="small" cursor: 'pointer',
icon={<EditOutlined />} borderRadius: '6px',
onClick={() => handleShowEdit(record)} transition: 'background-color 0.2s'
> }}
{t('accountList.edit')} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
</Button> onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
onConfirm={() => handleDelete(record)}
okText={t('accountList.deleteConfirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Button type="link" size="small" danger>
{t('accountList.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
const mobileColumns = [
{
title: t('accountList.accountName'),
key: 'info',
render: (_: any, record: Account) => {
return (
<div style={{ padding: '8px 0' }}>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
fontSize: '16px'
}}>
{record.accountName || `${t('accountList.accountName')} ${record.id}`}
</div>
<div style={{
fontSize: '11px',
color: '#666',
marginBottom: '8px',
wordBreak: 'break-all',
fontFamily: 'monospace',
lineHeight: '1.4'
}}>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress ? `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}` : '-'}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation()
handleCopy(record.walletAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation()
handleCopy(record.proxyAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
{record.walletType && (
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.walletType')}:</strong>{' '}
<Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}>
{record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</div>
)}
</div>
<div style={{
fontSize: '14px',
fontWeight: '500',
color: '#1890ff'
}}>
{t('accountList.totalBalance')}: {balanceLoading[record.id] ? (
<Spin size="small" style={{ marginLeft: '4px' }} />
) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? (
`${formatUSDC(balanceMap[record.id].total)} USDC`
) : (
'-'
)}
</div>
{balanceMap[record.id] && balanceMap[record.id].available !== '-' && (
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px'
}}>
{t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC
</div>
)}
</div>
)
}
},
{
title: t('accountList.action'),
key: 'action',
width: 100,
render: (_: any, record: Account) => (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Button
type="primary"
size="small"
block
onClick={() => handleShowDetail(record)}
style={{ minHeight: '32px' }}
>
{t('accountList.viewDetail')}
</Button>
<Button
size="small"
block
icon={<EditOutlined />}
onClick={() => handleShowEdit(record)}
style={{ minHeight: '32px' }}
>
{t('accountList.edit')}
</Button>
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
onConfirm={() => handleDelete(record)}
okText={t('accountList.deleteConfirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Button
size="small"
block
danger
style={{ minHeight: '32px' }}
> >
{t('accountList.delete')} <EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</Button> </div>
</Tooltip>
<Tooltip title={t('accountList.edit')}>
<div
onClick={() => handleShowEdit(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
onConfirm={() => handleDelete(record)}
okText={t('accountList.deleteConfirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Tooltip title={t('accountList.delete')}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm> </Popconfirm>
</Space> </Space>
) )
@@ -519,16 +427,15 @@ const AccountList: React.FC = () => {
<Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}> <Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>
{t('accountList.title')} {t('accountList.title')}
</Title> </Title>
<Button <Tooltip title={t('accountList.importAccount')}>
type="primary" <Button
icon={<PlusOutlined />} type="primary"
onClick={() => setAccountImportModalVisible(true)} icon={<PlusOutlined />}
size={isMobile ? 'middle' : 'large'} onClick={() => setAccountImportModalVisible(true)}
block={isMobile} size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: '44px' } : undefined} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
> />
{t('accountList.importAccount')} </Tooltip>
</Button>
</div> </div>
<Card style={{ <Card style={{
@@ -536,21 +443,156 @@ const AccountList: React.FC = () => {
borderRadius: isMobile ? '0' : undefined borderRadius: isMobile ? '0' : undefined
}}> }}>
{isMobile ? ( {isMobile ? (
<Table loading ? (
dataSource={accounts} <div style={{ textAlign: 'center', padding: '40px' }}>
columns={mobileColumns} <Spin size="large" />
rowKey="id" </div>
loading={loading} ) : accounts.length === 0 ? (
pagination={{ <Empty description={t('accountList.noData')} />
pageSize: 10, ) : (
showSizeChanger: false, <List
simple: true, dataSource={accounts}
size: 'small' renderItem={(account) => {
}} const balance = balanceMap[account.id]
scroll={{ x: 'max-content' }}
size="small" return (
style={{ fontSize: '14px' }} <Card
/> key={account.id}
style={{
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: '0' }}
>
{/* 头部区域 */}
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<WalletOutlined style={{ fontSize: '14px' }} />
<span>{account.accountName || `${t('accountList.accountName')} ${account.id}`}</span>
</div>
<div style={{ fontSize: '10px', opacity: '0.85', fontFamily: 'monospace', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{account.walletAddress ? `${account.walletAddress.slice(0, 6)}...${account.walletAddress.slice(-4)}` : '-'}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined style={{ fontSize: '12px', color: 'rgba(255,255,255,0.85)' }} />}
onClick={() => handleCopy(account.walletAddress)}
style={{ padding: '0 4px', height: 'auto' }}
/>
</div>
</div>
{/* 资产区域 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#fafafa',
borderBottom: '1px solid #f0f0f0',
minHeight: '42px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('accountList.totalBalance')}
</div>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
{balance?.total && balance.total !== '-' ? `${formatUSDC(balance.total)} USDC` : '- USDC'}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('accountList.walletType')}
</div>
<div style={{ fontSize: '12px' }}>
{account.walletType ? (
<Tag color={account.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ margin: 0 }}>
{account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
) : '-'}
</div>
</div>
</div>
</div>
{/* 地址信息区域 */}
<div style={{
padding: '8px 12px',
fontSize: '11px',
color: '#8c8c8c',
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{t('accountList.proxyAddress')}: {account.proxyAddress ? `${account.proxyAddress.slice(0, 6)}...${account.proxyAddress.slice(-4)}` : '-'}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined style={{ fontSize: '12px' }} />}
onClick={() => handleCopy(account.proxyAddress)}
style={{ padding: '0 4px', height: 'auto' }}
/>
</div>
</div>
{/* 图标操作栏 */}
<div style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center'
}}>
<Tooltip title={t('accountList.detail')}>
<div
onClick={() => handleShowDetail(account)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.detail')}</span>
</div>
</Tooltip>
<Tooltip title={t('accountList.edit')}>
<div
onClick={() => handleShowEdit(account)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.edit')}</span>
</div>
</Tooltip>
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
account.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
onConfirm={() => handleDelete(account)}
okText={t('accountList.deleteConfirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Tooltip title={t('accountList.delete')}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.delete')}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
</Card>
)
}}
/>
)
) : ( ) : (
<Table <Table
dataSource={accounts} dataSource={accounts}
+396 -240
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions } from 'antd' import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions, List, Empty, Spin, Tooltip, Popconfirm } from 'antd'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons' import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons'
import { formatUSDC } from '../utils' import { formatUSDC } from '../utils'
@@ -118,49 +118,35 @@ const BacktestList: React.FC = () => {
} }
// 删除任务 // 删除任务
const handleDelete = (id: number) => { const handleDelete = async (id: number) => {
Modal.confirm({ try {
title: t('backtest.deleteConfirm'), const response = await backtestService.delete({ id })
okText: t('common.confirm'), if (response.data.code === 0) {
cancelText: t('common.cancel'), message.success(t('backtest.deleteSuccess'))
onOk: async () => { fetchTasks()
try { } else {
const response = await backtestService.delete({ id }) message.error(response.data.msg || t('backtest.deleteFailed'))
if (response.data.code === 0) {
message.success(t('backtest.deleteSuccess'))
fetchTasks()
} else {
message.error(response.data.msg || t('backtest.deleteFailed'))
}
} catch (error) {
console.error('Failed to delete backtest task:', error)
message.error(t('backtest.deleteFailed'))
}
} }
}) } catch (error) {
console.error('Failed to delete backtest task:', error)
message.error(t('backtest.deleteFailed'))
}
} }
// 停止任务 // 停止任务
const handleStop = (id: number) => { const handleStop = async (id: number) => {
Modal.confirm({ try {
title: t('backtest.stopConfirm'), const response = await backtestService.stop({ id })
okText: t('common.confirm'), if (response.data.code === 0) {
cancelText: t('common.cancel'), message.success(t('backtest.stopSuccess'))
onOk: async () => { fetchTasks()
try { } else {
const response = await backtestService.stop({ id }) message.error(response.data.msg || t('backtest.stopFailed'))
if (response.data.code === 0) {
message.success(t('backtest.stopSuccess'))
fetchTasks()
} else {
message.error(response.data.msg || t('backtest.stopFailed'))
}
} catch (error) {
console.error('Failed to stop backtest task:', error)
message.error(t('backtest.stopFailed'))
}
} }
}) } catch (error) {
console.error('Failed to stop backtest task:', error)
message.error(t('backtest.stopFailed'))
}
} }
// 按配置重新测试(仅已完成任务) // 按配置重新测试(仅已完成任务)
@@ -502,168 +488,227 @@ const BacktestList: React.FC = () => {
title: t('backtest.taskName'), title: t('backtest.taskName'),
dataIndex: 'taskName', dataIndex: 'taskName',
key: 'taskName', key: 'taskName',
width: isMobile ? 120 : 150 width: isMobile ? 120 : 140,
ellipsis: true
}, },
{ {
title: t('backtest.leader'), title: t('backtest.leader'),
dataIndex: 'leaderName', dataIndex: 'leaderName',
key: 'leaderName', key: 'leaderName',
width: isMobile ? 100 : 150, width: isMobile ? 100 : 120,
ellipsis: true,
render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}` render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}`
}, },
{ {
title: t('backtest.initialBalance'), title: t('backtest.balance'),
dataIndex: 'initialBalance', key: 'balance',
key: 'initialBalance', width: 160,
width: 120, render: (_: any, record: BacktestTaskDto) => (
render: (value: string) => formatUSDC(value) <div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{formatUSDC(record.initialBalance)}</div>
<div style={{ fontWeight: 500 }}>{record.finalBalance ? formatUSDC(record.finalBalance) : '-'}</div>
</div>
)
}, },
{ {
title: t('backtest.finalBalance'), title: t('backtest.profit'),
dataIndex: 'finalBalance', key: 'profit',
key: 'finalBalance', width: 140,
width: 120, render: (_: any, record: BacktestTaskDto) => {
render: (value: string | null) => value ? formatUSDC(value) : '-' const profitAmount = record.profitAmount ? parseFloat(record.profitAmount) : null
}, const profitRate = record.profitRate ? parseFloat(record.profitRate) : null
{ const color = profitAmount !== null ? (profitAmount >= 0 ? '#52c41a' : '#ff4d4f') : undefined
title: t('backtest.profitAmount'), return (
dataIndex: 'profitAmount', <div>
key: 'profitAmount', <div style={{ fontWeight: 500, color }}>
width: 120, {profitAmount !== null ? formatUSDC(record.profitAmount) : '-'}
render: (value: string | null) => value ? ( </div>
<span style={{ color: parseFloat(value) >= 0 ? '#52c41a' : '#ff4d4f' }}> <div style={{ fontSize: 12, color }}>
{formatUSDC(value)} {profitRate !== null ? `${record.profitRate}%` : '-'}
</span> </div>
) : '-' </div>
}, )
{ }
title: t('backtest.profitRate'),
dataIndex: 'profitRate',
key: 'profitRate',
width: 100,
render: (value: string | null) => value ? (
<span style={{ color: parseFloat(value) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{value}%
</span>
) : '-'
},
{
title: t('backtest.backtestDays'),
dataIndex: 'backtestDays',
key: 'backtestDays',
width: 100,
render: (value: number) => `${value} ${t('common.day')}`
}, },
{ {
title: t('backtest.status'), title: t('backtest.status'),
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 100, width: 130,
render: (status: string) => ( render: (status: string, record: BacktestTaskDto) => {
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag> const isRunning = status === 'RUNNING' || status === 'PENDING'
) return (
}, <div>
{ <Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
title: t('backtest.progress'), {isRunning && record.progress !== undefined && (
dataIndex: 'progress', <div style={{ marginTop: 4, fontSize: 11, color: '#8c8c8c' }}>
key: 'progress', {record.progress}%
width: 120, </div>
render: (progress: number) => ( )}
<div style={{ width: '100%' }}>
<div style={{ marginBottom: 4 }}>{progress}%</div>
<div style={{ width: '100%', height: 6, backgroundColor: '#f0f0f0', borderRadius: 3 }}>
<div
style={{
width: `${progress}%`,
height: '100%',
backgroundColor: progress === 100 ? '#52c41a' : '#1890ff',
borderRadius: 3,
transition: 'width 0.3s ease'
}}
/>
</div> </div>
</div> )
) }
}, },
{ {
title: t('backtest.totalTrades'), title: t('backtest.totalTrades'),
dataIndex: 'totalTrades', dataIndex: 'totalTrades',
key: 'totalTrades', key: 'totalTrades',
width: 100 width: 80,
align: 'center' as const
}, },
{ {
title: t('backtest.createdAt'), title: t('backtest.createdAt'),
dataIndex: 'createdAt', dataIndex: 'createdAt',
key: 'createdAt', key: 'createdAt',
width: isMobile ? 150 : 180, width: isMobile ? 150 : 150,
render: (timestamp: number) => new Date(timestamp).toLocaleString() render: (timestamp: number) => new Date(timestamp).toLocaleString()
}, },
{ {
title: t('common.actions'), title: t('common.actions'),
key: 'actions', key: 'actions',
fixed: isMobile ? false : ('right' as const), fixed: isMobile ? false : ('right' as const),
width: isMobile ? 100 : 150, width: isMobile ? 100 : 180,
render: (_: any, record: BacktestTaskDto) => ( render: (_: any, record: BacktestTaskDto) => (
<Space size="small"> <Space size={4}>
<Button <Tooltip title={t('common.viewDetail')}>
type="link" <div
size="small" onClick={() => handleViewDetail(record.id)}
icon={<EyeOutlined />} style={{
onClick={() => handleViewDetail(record.id)} display: 'flex',
> alignItems: 'center',
{t('common.viewDetail')} justifyContent: 'center',
</Button> width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
{record.status === 'COMPLETED' && ( {record.status === 'COMPLETED' && (
<> <>
<Button <Tooltip title={t('backtest.rerun')}>
type="link" <div
size="small" onClick={() => handleRerun(record)}
icon={<SyncOutlined />} style={{
onClick={() => handleRerun(record)} display: 'flex',
> alignItems: 'center',
{t('backtest.rerun')} justifyContent: 'center',
</Button> width: '32px',
<Button height: '32px',
type="link" cursor: 'pointer',
size="small" borderRadius: '6px',
icon={<CopyOutlined />} transition: 'background-color 0.2s'
onClick={() => handleCreateCopyTrading(record)} }}
> onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
{t('backtest.createCopyTrading')} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
</Button> >
<SyncOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={t('backtest.createCopyTrading')}>
<div
onClick={() => handleCreateCopyTrading(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
</> </>
)} )}
{record.status === 'RUNNING' && ( {record.status === 'RUNNING' && (
<Button <Popconfirm
type="link" title={t('backtest.stopConfirm')}
size="small" onConfirm={() => handleStop(record.id)}
danger okText={t('common.confirm')}
icon={<StopOutlined />} cancelText={t('common.cancel')}
onClick={() => handleStop(record.id)}
> >
{t('backtest.stop')} <Tooltip title={t('backtest.stop')}>
</Button> <div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<StopOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
</Popconfirm>
)} )}
{(record.status === 'STOPPED' || record.status === 'FAILED') && ( {(record.status === 'STOPPED' || record.status === 'FAILED') && (
<Button <Tooltip title={t('backtest.retry')}>
type="link" <div
size="small" onClick={() => handleRetry(record.id)}
icon={<RedoOutlined />} style={{
onClick={() => handleRetry(record.id)} display: 'flex',
> alignItems: 'center',
{t('backtest.retry')} justifyContent: 'center',
</Button> width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<RedoOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
)} )}
{(record.status === 'PENDING' || record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && ( {(record.status === 'PENDING' || record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && (
<Button <Popconfirm
type="link" title={t('backtest.deleteConfirm')}
size="small" onConfirm={() => handleDelete(record.id)}
danger okText={t('common.confirm')}
icon={<DeleteOutlined />} cancelText={t('common.cancel')}
onClick={() => handleDelete(record.id)}
> >
{t('common.delete')} <Tooltip title={t('common.delete')}>
</Button> <div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm>
)} )}
</Space> </Space>
) )
@@ -671,94 +716,205 @@ const BacktestList: React.FC = () => {
] ]
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: isMobile ? 0 : 24 }}>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px', padding: isMobile ? '0 8px' : 0 }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('backtest.title')}</h2>
<Space size={8}>
<Tooltip title={t('backtest.createTask')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreate}
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
/>
</Tooltip>
<Tooltip title={t('common.refresh')}>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }} />
</Tooltip>
</Space>
</div>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}> <Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* 头部操作栏 */} {/* 筛选栏 */}
<Row justify="space-between" align="middle" gutter={[16, 16]}> <Row gutter={[16, 16]} style={{ marginBottom: isMobile ? 12 : 0 }}>
<Col xs={24} sm={24} md={12} lg={16}> <Col xs={24} sm={12} md={6}>
<Space size="middle" direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}> <LeaderSelect
<LeaderSelect style={{ width: '100%' }}
style={{ width: isMobile ? '100%' : 180 }} placeholder={t('backtest.leader')}
placeholder={t('backtest.leader')} allowClear
allowClear value={leaderIdFilter}
value={leaderIdFilter} onChange={(value) => setLeaderIdFilter(value)}
onChange={(value) => setLeaderIdFilter(value)} leaders={leaders}
leaders={leaders} />
/>
<Select
style={{ width: isMobile ? '100%' : 150 }}
placeholder={t('backtest.status')}
allowClear
onChange={(value) => setStatusFilter(value)}
value={statusFilter}
>
<Select.Option value="PENDING">{t('backtest.statusPending')}</Select.Option>
<Select.Option value="RUNNING">{t('backtest.statusRunning')}</Select.Option>
<Select.Option value="COMPLETED">{t('backtest.statusCompleted')}</Select.Option>
<Select.Option value="STOPPED">{t('backtest.statusStopped')}</Select.Option>
<Select.Option value="FAILED">{t('backtest.statusFailed')}</Select.Option>
</Select>
<Select
style={{ width: isMobile ? '100%' : 150 }}
placeholder={t('backtest.sortBy')}
onChange={(value) => setSortBy(value)}
value={sortBy}
>
<Select.Option value="profitAmount">{t('backtest.profitAmount')}</Select.Option>
<Select.Option value="profitRate">{t('backtest.profitRate')}</Select.Option>
<Select.Option value="createdAt">{t('backtest.createdAt')}</Select.Option>
</Select>
<Select
style={{ width: isMobile ? '100%' : 120 }}
placeholder={t('backtest.sortOrder')}
onChange={(value) => setSortOrder(value)}
value={sortOrder}
>
<Select.Option value="asc">{t('common.ascending')}</Select.Option>
<Select.Option value="desc">{t('common.descending')}</Select.Option>
</Select>
</Space>
</Col> </Col>
<Col xs={24} sm={24} md={12} lg={8} style={{ textAlign: isMobile ? 'left' : 'right' }}> <Col xs={24} sm={12} md={6}>
<Space style={{ width: isMobile ? '100%' : 'auto' }}> <Select
<Button style={{ width: '100%' }}
type="primary" placeholder={t('backtest.status')}
icon={<PlusOutlined />} allowClear
onClick={handleCreate} onChange={(value) => setStatusFilter(value)}
style={{ flex: isMobile ? 1 : undefined }} value={statusFilter}
> >
{isMobile ? t('common.create') : t('backtest.createTask')} <Select.Option value="PENDING">{t('backtest.statusPending')}</Select.Option>
</Button> <Select.Option value="RUNNING">{t('backtest.statusRunning')}</Select.Option>
<Button <Select.Option value="COMPLETED">{t('backtest.statusCompleted')}</Select.Option>
icon={<ReloadOutlined />} <Select.Option value="STOPPED">{t('backtest.statusStopped')}</Select.Option>
onClick={handleRefresh} <Select.Option value="FAILED">{t('backtest.statusFailed')}</Select.Option>
loading={loading} </Select>
style={{ flex: isMobile ? 1 : undefined }} </Col>
> <Col xs={24} sm={12} md={6}>
{t('common.refresh')} <Select style={{ width: '100%' }} placeholder={t('backtest.sortBy')} onChange={(value) => setSortBy(value)} value={sortBy}>
</Button> <Select.Option value="profitAmount">{t('backtest.profitAmount')}</Select.Option>
</Space> <Select.Option value="profitRate">{t('backtest.profitRate')}</Select.Option>
<Select.Option value="createdAt">{t('backtest.createdAt')}</Select.Option>
</Select>
</Col>
<Col xs={24} sm={12} md={6}>
<Select style={{ width: '100%' }} placeholder={t('backtest.sortOrder')} onChange={(value) => setSortOrder(value)} value={sortOrder}>
<Select.Option value="asc">{t('common.ascending')}</Select.Option>
<Select.Option value="desc">{t('common.descending')}</Select.Option>
</Select>
</Col> </Col>
</Row> </Row>
{/* 数据表格 */} {/* 数据:移动端卡片 / 桌面端表格 */}
<Table {isMobile ? (
columns={columns} <>
dataSource={tasks} {loading ? (
rowKey="id" <div style={{ textAlign: 'center', padding: '40px' }}>
loading={loading} <Spin size="large" />
pagination={{ </div>
current: page, ) : tasks.length === 0 ? (
pageSize: size, <Empty description={t('backtest.noData') || t('common.noData')} />
total, ) : (
showSizeChanger: false, <List
showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`, dataSource={tasks}
onChange: (newPage) => setPage(newPage), renderItem={(task) => (
simple: isMobile <Card
}} key={task.id}
scroll={isMobile ? { x: 1200 } : { x: 1400 }} style={{
/> marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: 0 }}
>
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px' }}>{task.taskName}</div>
<div style={{ fontSize: '12px', opacity: '0.9' }}>{task.leaderName || `Leader ${task.leaderId}`}</div>
</div>
<div style={{ padding: '8px 12px', backgroundColor: '#fafafa', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitAmount')}</div>
{task.profitAmount != null ? (
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>{formatUSDC(task.profitAmount)} USDC</div>
) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitRate')}</div>
{task.profitRate != null ? (
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitRate) >= 0 ? '#52c41a' : '#ff4d4f' }}>{task.profitRate}%</div>
) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.status')}</div>
<Tag color={getStatusColor(task.status)}>{getStatusText(task.status)}</Tag>
</div>
</div>
<div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
{t('backtest.initialBalance')}: {formatUSDC(task.initialBalance)} · {t('backtest.backtestDays')}: {task.backtestDays} {t('common.day')} · {t('backtest.progress')}: {task.progress}%
</div>
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-around', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}>
<Tooltip title={t('common.viewDetail')}>
<div onClick={() => handleViewDetail(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.viewDetail')}</span>
</div>
</Tooltip>
{task.status === 'COMPLETED' && (
<>
<Tooltip title={t('backtest.rerun')}>
<div onClick={() => handleRerun(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<SyncOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.rerun')}</span>
</div>
</Tooltip>
<Tooltip title={t('backtest.createCopyTrading')}>
<div onClick={() => handleCreateCopyTrading(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.createCopyTrading')}</span>
</div>
</Tooltip>
</>
)}
{task.status === 'RUNNING' && (
<Tooltip title={t('backtest.stop')}>
<div onClick={() => handleStop(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<StopOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.stop')}</span>
</div>
</Tooltip>
)}
{(task.status === 'STOPPED' || task.status === 'FAILED') && (
<Tooltip title={t('backtest.retry')}>
<div onClick={() => handleRetry(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<RedoOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.retry')}</span>
</div>
</Tooltip>
)}
{(task.status === 'PENDING' || task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && (
<Tooltip title={t('common.delete')}>
<div onClick={() => handleDelete(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete')}</span>
</div>
</Tooltip>
)}
</div>
</Card>
)}
/>
)}
{/* 移动端分页 */}
{!loading && tasks.length > 0 && total > size && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 16 }}>
<Button size="small" disabled={page <= 1} onClick={() => setPage(p => Math.max(1, p - 1))}>{t('common.prev')}</Button>
<span style={{ margin: '0 12px', lineHeight: '24px' }}>{page} / {Math.ceil(total / size)}</span>
<Button size="small" disabled={page >= Math.ceil(total / size)} onClick={() => setPage(p => p + 1)}>{t('common.next')}</Button>
</div>
)}
</>
) : (
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: false,
showTotal: (totalCount) => `${t('common.total')} ${totalCount} ${t('common.items')}`,
onChange: (newPage) => setPage(newPage),
simple: isMobile
}}
scroll={{ x: 1000 }}
/>
)}
</Space> </Space>
</Card> </Card>
+159 -184
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd' import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Spin, List, Empty, Tooltip } from 'antd'
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons' import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined, WalletOutlined, UserOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
import { apiService } from '../services/api' import { apiService } from '../services/api'
@@ -384,19 +384,21 @@ const CopyTradingList: React.FC = () => {
return ( return (
<div> <div>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}> <h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('copyTradingList.title') || '跟单配置管理'}</h2>
<h2 style={{ margin: 0 }}>{t('copyTradingList.title') || '跟单配置管理'}</h2> <Tooltip title={t('copyTradingList.addCopyTrading') || '新增跟单'}>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => setAddModalOpen(true)} onClick={() => setAddModalOpen(true)}
> size={isMobile ? 'middle' : 'large'}
{t('copyTradingList.addCopyTrading') || '新增跟单'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
</Button> />
</div> </Tooltip>
</div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Select <Select
placeholder={t('copyTradingList.filterWallet') || '筛选钱包'} placeholder={t('copyTradingList.filterWallet') || '筛选钱包'}
allowClear allowClear
@@ -421,14 +423,14 @@ const CopyTradingList: React.FC = () => {
/> />
<Select <Select
placeholder="筛选状态" placeholder={t('common.status') || '状态'}
allowClear allowClear
style={{ width: isMobile ? '100%' : 150 }} style={{ width: isMobile ? '100%' : 150 }}
value={filters.enabled} value={filters.enabled}
onChange={(value) => setFilters({ ...filters, enabled: value !== undefined ? value : undefined })} onChange={(value) => setFilters({ ...filters, enabled: value !== undefined ? value : undefined })}
> >
<Option value={true}></Option> <Option value={true}>{t('common.enabled') || '开启'}</Option>
<Option value={false}></Option> <Option value={false}>{t('common.disabled') || '停止'}</Option>
</Select> </Select>
</div> </div>
@@ -440,207 +442,180 @@ const CopyTradingList: React.FC = () => {
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : copyTradings.length === 0 ? ( ) : copyTradings.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}> <Empty description={t('copyTradingList.noData') || '暂无跟单配置'} />
</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <List
{copyTradings.map((record) => { dataSource={copyTradings}
renderItem={(record) => {
const stats = statisticsMap[record.id] const stats = statisticsMap[record.id]
const date = new Date(record.createdAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
return ( return (
<Card <Card
key={record.id} key={record.id}
style={{ style={{
borderRadius: '12px', marginBottom: '10px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)', borderRadius: '10px',
border: '1px solid #e8e8e8' boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}} }}
bodyStyle={{ padding: '16px' }} bodyStyle={{ padding: '0' }}
> >
{/* 基本信息 */} {/* 头部区域 - 配置名称 */}
<div style={{ marginBottom: '12px' }}> <div style={{
<div style={{ padding: '10px 12px',
fontSize: '18px', background: 'var(--ant-color-primary, #1677ff)',
fontWeight: 'bold', color: '#fff'
marginBottom: '8px', }}>
color: '#1890ff' <div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
}}> <span>{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}</span>
{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}
</div>
<div style={{
fontSize: '14px',
marginBottom: '8px',
color: '#666'
}}>
{record.copyMode === 'RATIO'
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(2).replace(/\.0+$/, '')}%`
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}`
}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', justifyContent: 'space-between' }}>
<Tag color={record.enabled ? 'green' : 'red'}>
{record.enabled ? '启用' : '禁用'}
</Tag>
<Switch <Switch
checked={record.enabled} checked={record.enabled}
onChange={() => handleToggleStatus(record)} onChange={() => handleToggleStatus(record)}
checkedChildren="开启" checkedChildren={t('copyTradingList.enabled') || '开启'}
unCheckedChildren="停止" unCheckedChildren={t('copyTradingList.disabled') || '停止'}
size="small" size="small"
/> />
</div> </div>
</div> <div style={{ fontSize: '12px', opacity: '0.9' }}>
{record.copyMode === 'RATIO'
<Divider style={{ margin: '12px 0' }} /> ? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC`
{/* 账户信息 */} }
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{record.accountName || `账户 ${record.accountId}`}
</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)}
</div> </div>
</div> </div>
{/* Leader 信息 */} {/* 盈亏区域 - 常驻显示 */}
<div style={{ marginBottom: '12px' }}> <div style={{
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader</div> padding: '8px 12px',
<div style={{ fontSize: '14px', fontWeight: '500' }}> backgroundColor: '#fafafa',
{record.leaderName || `Leader ${record.leaderId}`} borderBottom: '1px solid #f0f0f0',
</div> minHeight: '42px',
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}> display: 'flex',
{record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)} alignItems: 'center'
</div> }}>
</div> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
{/* 总盈亏 */} <div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{stats && ( {t('copyTradingList.totalPnl') || '总盈亏'}
<div style={{ marginBottom: '12px' }}> </div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div> {stats ? (
<div style={{ <div style={{
fontSize: '16px', fontSize: '14px',
fontWeight: 'bold', fontWeight: '600',
color: getPnlColor(stats.totalPnl), color: getPnlColor(stats.totalPnl),
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '4px' gap: '4px'
}}> }}>
{getPnlIcon(stats.totalPnl)} {getPnlIcon(stats.totalPnl)}
{formatUSDC(stats.totalPnl)} USDC {formatUSDC(stats.totalPnl)} USDC
</div>
) : loadingStatistics.has(record.id) ? (
<Spin size="small" />
) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)}
</div> </div>
<div style={{ {stats && (
fontSize: '12px', <div style={{ textAlign: 'right' }}>
color: getPnlColor(stats.totalPnlPercent), <div style={{ fontSize: '10px', color: '#8c8c8c' }}>
marginTop: '4px' {t('copyTradingList.profitRate') || '收益率'}
}}> </div>
{formatPercent(stats.totalPnlPercent)} <div style={{
</div> fontSize: '12px',
</div> fontWeight: '500',
)} color: getPnlColor(stats.totalPnlPercent)
}}>
{loadingStatistics.has(record.id) && ( {formatPercent(stats.totalPnlPercent)}
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}> </div>
... </div>
</div> )}
)}
{/* 创建时间 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
: {formattedDate}
</div> </div>
</div> </div>
{/* 操作按钮 */} {/* 账户和Leader信息区域 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div style={{
<Button padding: '8px 12px',
type="primary" fontSize: '11px',
size="small" color: '#8c8c8c',
icon={<EditOutlined />} borderBottom: '1px solid #f0f0f0'
onClick={() => { }}>
setEditModalCopyTradingId(record.id.toString()) <div style={{ display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
setEditModalOpen(true) <WalletOutlined style={{ fontSize: '12px', marginRight: '4px', color: '#1890ff' }} />
}} <span>{t('copyTradingList.wallet') || '账户'}: {record.accountName || `#${record.accountId}`}</span>
style={{ flex: 1, minWidth: '80px' }} </div>
> <div style={{ display: 'flex', alignItems: 'center' }}>
{t('common.edit') || '编辑'} <UserOutlined style={{ fontSize: '12px', marginRight: '4px', color: '#722ed1' }} />
</Button> <span>Leader: {record.leaderName || `#${record.leaderId}`}</span>
<Button </div>
size="small" </div>
icon={<BarChartOutlined />}
onClick={() => { {/* 图标操作栏 */}
setStatisticsModalCopyTradingId(record.id.toString()) <div style={{
setStatisticsModalOpen(true) padding: '8px 12px',
}} display: 'flex',
style={{ flex: 1, minWidth: '80px' }} justifyContent: 'space-around',
> alignItems: 'center'
{t('copyTradingList.statistics') || '统计'} }}>
</Button> <Tooltip title={t('common.edit') || '编辑'}>
<Dropdown <div
menu={{ onClick={() => {
items: [ setEditModalCopyTradingId(record.id.toString())
{ setEditModalOpen(true)
key: 'matchedOrders', }}
label: t('copyTradingList.matchedOrders') || '已成交订单', style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
icon: <UnorderedListOutlined />,
onClick: () => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}
},
{
key: 'filteredOrders',
label: t('copyTradingList.filteredOrders') || '已过滤订单',
icon: <UnorderedListOutlined />,
onClick: () => {
setFilteredOrdersModalCopyTradingId(record.id.toString())
setFilteredOrdersModalOpen(true)
}
}
]
}}
trigger={['click']}
>
<Button
size="small"
icon={<UnorderedListOutlined />}
style={{ flex: 1, minWidth: '80px' }}
> >
{t('copyTradingList.orders') || '订单'} <EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
</Button> <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit') || '编辑'}</span>
</Dropdown> </div>
</Tooltip>
<Tooltip title={t('copyTradingList.statistics') || '统计'}>
<div
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<BarChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('copyTradingList.statistics') || '统计'}</span>
</div>
</Tooltip>
<Tooltip title={t('copyTradingList.orders') || '订单'}>
<div
onClick={() => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<UnorderedListOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('copyTradingList.orders') || '订单'}</span>
</div>
</Tooltip>
<Popconfirm <Popconfirm
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'} title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
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 <Tooltip title={t('common.delete') || '删除'}>
danger <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
size="small" <DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
icon={<DeleteOutlined />} <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
style={{ flex: 1, minWidth: '80px' }} </div>
> </Tooltip>
{t('common.delete') || '删除'}
</Button>
</Popconfirm> </Popconfirm>
</div> </div>
</Card> </Card>
) )
})} }}
</div> />
)} )}
</div> </div>
) : ( ) : (
+101 -100
View File
@@ -21,12 +21,11 @@ import {
Tabs, Tabs,
DatePicker, DatePicker,
Empty, Empty,
Typography, Typography
Divider
} from 'antd' } from 'antd'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons' import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined, DeleteOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api' import { apiService } from '../services/api'
@@ -543,17 +542,28 @@ const CryptoTailStrategyList: React.FC = () => {
} }
return ( return (
<div style={{ padding: isMobile ? 12 : 24 }}> <div style={{ padding: isMobile ? 0 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px', padding: isMobile ? '0 8px' : 0 }}>
<h1 style={{ margin: 0, fontSize: isMobile ? 20 : 24 }}>{t('cryptoTailStrategy.list.title')}</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Button <h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('cryptoTailStrategy.list.title')}</h2>
type="link" <Button
icon={<FileTextOutlined />} type="link"
onClick={() => window.open(getGuideUrl(), '_blank')} icon={<FileTextOutlined />}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 14 : 16 }} onClick={() => window.open(getGuideUrl(), '_blank')}
> style={{ padding: 0, height: 'auto', fontSize: isMobile ? 14 : 16 }}
{t('cryptoTailStrategy.list.configGuide')} >
</Button> {t('cryptoTailStrategy.list.configGuide')}
</Button>
</div>
<Tooltip title={t('cryptoTailStrategy.list.addStrategy')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openAddModal}
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
/>
</Tooltip>
</div> </div>
{binanceUnhealthy.length > 0 && list.some((s) => s.enabled) && ( {binanceUnhealthy.length > 0 && list.some((s) => s.enabled) && (
<Alert <Alert
@@ -583,20 +593,17 @@ const CryptoTailStrategyList: React.FC = () => {
</Button> </Button>
</div> </div>
} }
style={{ marginBottom: 16 }} style={{ marginBottom: 16, margin: isMobile ? '0 8px 16px' : undefined }}
/> />
)} )}
<Alert <Alert
type="warning" type="warning"
showIcon showIcon
message={t('cryptoTailStrategy.list.walletTip')} message={t('cryptoTailStrategy.list.walletTip')}
style={{ marginBottom: 16 }} style={{ marginBottom: 16, margin: isMobile ? '0 8px 16px' : undefined }}
/> />
<Card> <Card style={{ borderRadius: isMobile ? 0 : '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: isMobile ? 'none' : '1px solid #e8e8e8' }} styles={{ body: { padding: isMobile ? 12 : 24 } }}>
<div style={{ marginBottom: 16, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}> <div style={{ marginBottom: 16, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>
{t('cryptoTailStrategy.list.addStrategy')}
</Button>
<Select <Select
placeholder={t('cryptoTailStrategy.form.selectAccount')} placeholder={t('cryptoTailStrategy.form.selectAccount')}
allowClear allowClear
@@ -619,86 +626,80 @@ const CryptoTailStrategyList: React.FC = () => {
</div> </div>
<Spin spinning={loading}> <Spin spinning={loading}>
{isMobile ? ( {isMobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{list.map((item) => ( {list.map((item) => {
<Card const accountLabel = getAccountLabel(item.accountId)
key={item.id} const marketTitle = marketOptions.find((m) => m.slug === item.marketSlugPrefix)?.title ?? item.marketTitle ?? item.marketSlugPrefix ?? '-'
size="small" return (
styles={{ body: { padding: 16 } }} <Card
style={{ borderLeft: `3px solid ${item.enabled ? 'var(--ant-colorSuccess)' : 'var(--ant-colorBorder)'}` }} key={item.id}
> style={{
<Typography.Text strong style={{ fontSize: 15, wordBreak: 'break-word', whiteSpace: 'normal', display: 'block', marginBottom: 8 }}> marginBottom: 0,
{item.name || (item.marketTitle ?? item.marketSlugPrefix) || '-'} borderRadius: '10px',
</Typography.Text> boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 4, fontSize: 13, wordBreak: 'break-word', whiteSpace: 'normal' }}> border: '1px solid #e8e8e8',
{t('cryptoTailStrategy.list.account')}: {getAccountLabel(item.accountId)} overflow: 'hidden'
</Typography.Text> }}
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 13, wordBreak: 'break-word', whiteSpace: 'normal' }}> bodyStyle={{ padding: 0 }}
{marketOptions.find((m) => m.slug === item.marketSlugPrefix)?.title ?? item.marketSlugPrefix ?? '-'} >
</Typography.Text> <div style={{
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px', marginBottom: 12 }}> padding: '10px 12px',
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}> background: item.enabled ? 'var(--ant-color-primary, #1677ff)' : 'var(--ant-color-fill-secondary, #f0f0f0)',
{t('cryptoTailStrategy.list.timeWindow')} color: item.enabled ? '#fff' : 'var(--ant-color-text-secondary, #666)'
</Typography.Text> }}>
<Typography.Text style={{ fontSize: 12 }}>{formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds, false)}</Typography.Text> <div style={{ fontSize: '15px', fontWeight: '600', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}> <span style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>{item.name || marketTitle || '-'}</span>
{t('cryptoTailStrategy.list.priceRange')} <Switch checked={item.enabled} onChange={() => handleToggle(item)} size="small" />
</Typography.Text> </div>
<Typography.Text style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>{formatPriceRange(item.minPrice, item.maxPrice)}</Typography.Text> </div>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}> <div style={{ padding: '8px 12px', backgroundColor: '#fafafa', borderBottom: '1px solid #f0f0f0' }}>
{t('cryptoTailStrategy.list.amountMode')} <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
</Typography.Text> <div>
<Typography.Text style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}> <div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.totalRealizedPnl')}</div>
{(item.amountMode?.toUpperCase() ?? '') === 'RATIO' {item.totalRealizedPnl != null ? (
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(item.amountValue, 2) || '0'}%` <div style={{ fontSize: '14px', fontWeight: '600', color: pnlColor(item.totalRealizedPnl) }}>{formatUSDC(item.totalRealizedPnl)} USDC</div>
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(item.amountValue)} USDC`} ) : (
</Typography.Text> <div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
</div> )}
<Divider style={{ margin: '10px 0' }} /> </div>
<Space wrap style={{ marginBottom: 12 }}> <div style={{ textAlign: 'right' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.winRate')}</div>
{t('cryptoTailStrategy.list.totalRealizedPnl')}:{' '} {item.winRate != null ? <Tag color="blue" style={{ margin: 0 }}>{(Number(item.winRate) * 100).toFixed(1)}%</Tag> : <span style={{ fontSize: '12px', color: '#8c8c8c' }}>-</span>}
</Typography.Text> </div>
{item.totalRealizedPnl != null ? ( </div>
<Typography.Text style={{ color: pnlColor(item.totalRealizedPnl) ?? undefined, fontWeight: 500, fontSize: 12 }}> </div>
{formatUSDC(item.totalRealizedPnl)} USDC <div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
</Typography.Text> <div>{t('cryptoTailStrategy.list.account')}: {accountLabel}</div>
) : ( <div>{t('cryptoTailStrategy.list.market')}: {marketTitle}</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>-</Typography.Text> </div>
)} <div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
{item.winRate != null && ( {t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds, false)} · {t('cryptoTailStrategy.list.priceRange')}: {formatPriceRange(item.minPrice, item.maxPrice)}
<> </div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>·</Typography.Text> <div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-around', alignItems: 'center' }}>
<Tag color="blue" style={{ margin: 0 }}>{t('cryptoTailStrategy.list.winRate')} {(Number(item.winRate) * 100).toFixed(1)}%</Tag> <Tooltip title={t('cryptoTailStrategy.list.edit')}>
</> <div onClick={() => openEditModal(item)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
)} <EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} />
</Space> <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.edit')}</span>
<Divider style={{ margin: '10px 0' }} /> </div>
<Space wrap size="small"> </Tooltip>
<Switch <Tooltip title={t('cryptoTailStrategy.list.viewTriggers')}>
checked={item.enabled} <div onClick={() => openTriggers(item.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
onChange={() => handleToggle(item)} <UnorderedListOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
size="small" <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewTriggers')}</span>
/> </div>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(item)}> </Tooltip>
{t('cryptoTailStrategy.list.edit')} <Popconfirm title={t('cryptoTailStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(item.id)} okText={t('common.confirm')} cancelText={t('common.cancel')}>
</Button> <Tooltip title={t('cryptoTailStrategy.list.delete')}>
<Button type="link" size="small" icon={<UnorderedListOutlined />} onClick={() => openTriggers(item.id)}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
{t('cryptoTailStrategy.list.viewTriggers')} <DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
</Button> <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.delete')}</span>
<Popconfirm </div>
title={t('cryptoTailStrategy.list.deleteConfirm')} </Tooltip>
onConfirm={() => handleDelete(item.id)} </Popconfirm>
okText={t('common.confirm')} </div>
cancelText={t('common.cancel')} </Card>
> )
<Button type="link" size="small" danger> })}
{t('cryptoTailStrategy.list.delete')}
</Button>
</Popconfirm>
</Space>
</Card>
))}
</div> </div>
) : ( ) : (
<Table <Table
+11 -12
View File
@@ -307,7 +307,7 @@ const LeaderList: React.FC = () => {
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
> >
<GlobalOutlined style={{ fontSize: '16px', color: '#fa8c16' }} /> <GlobalOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div> </div>
</Tooltip> </Tooltip>
)} )}
@@ -328,7 +328,7 @@ const LeaderList: React.FC = () => {
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
> >
<EditOutlined style={{ fontSize: '16px', color: '#52c41a' }} /> <EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div> </div>
</Tooltip> </Tooltip>
@@ -354,7 +354,7 @@ const LeaderList: React.FC = () => {
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
> >
<Badge count={record.copyTradingCount} size="small" offset={[-4, -4]}> <Badge count={record.copyTradingCount} size="small" offset={[-4, -4]}>
<CopyOutlined style={{ fontSize: '16px', color: '#13c2c2' }} /> <CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</Badge> </Badge>
</div> </div>
</Tooltip> </Tooltip>
@@ -381,7 +381,7 @@ const LeaderList: React.FC = () => {
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
> >
<Badge count={record.backtestCount} size="small" offset={[-4, -4]}> <Badge count={record.backtestCount} size="small" offset={[-4, -4]}>
<LineChartOutlined style={{ fontSize: '16px', color: '#722ed1' }} /> <LineChartOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</Badge> </Badge>
</div> </div>
</Tooltip> </Tooltip>
@@ -421,9 +421,9 @@ const LeaderList: React.FC = () => {
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2> <h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}> <Tooltip title={t('leaderList.addLeader')}>
{t('leaderList.addLeader')} <Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }} />
</Button> </Tooltip>
</div> </div>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}> <Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
@@ -440,7 +440,6 @@ const LeaderList: React.FC = () => {
dataSource={leaders} dataSource={leaders}
renderItem={(leader) => { renderItem={(leader) => {
const balance = balanceMap[leader.id] const balance = balanceMap[leader.id]
const isLoading = balanceLoading[leader.id]
return ( return (
<Card <Card
@@ -457,7 +456,7 @@ const LeaderList: React.FC = () => {
{/* 头部区域 - 名称和地址 */} {/* 头部区域 - 名称和地址 */}
<div style={{ <div style={{
padding: '10px 12px', padding: '10px 12px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'var(--ant-color-primary, #1677ff)',
color: '#fff' color: '#fff'
}}> }}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -539,7 +538,7 @@ const LeaderList: React.FC = () => {
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
> >
<EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} /> <EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit')}</span> <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit')}</span>
</div> </div>
</Tooltip> </Tooltip>
@@ -557,7 +556,7 @@ const LeaderList: React.FC = () => {
}} }}
> >
<Badge count={leader.copyTradingCount} size="small" offset={[-2, -2]}> <Badge count={leader.copyTradingCount} size="small" offset={[-2, -2]}>
<CopyOutlined style={{ fontSize: '18px', color: '#13c2c2' }} /> <CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
</Badge> </Badge>
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewCopyTradings')}</span> <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewCopyTradings')}</span>
</div> </div>
@@ -576,7 +575,7 @@ const LeaderList: React.FC = () => {
}} }}
> >
<Badge count={leader.backtestCount} size="small" offset={[-2, -2]}> <Badge count={leader.backtestCount} size="small" offset={[-2, -2]}>
<LineChartOutlined style={{ fontSize: '18px', color: '#722ed1' }} /> <LineChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
</Badge> </Badge>
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewBacktests')}</span> <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewBacktests')}</span>
</div> </div>
+198 -145
View File
@@ -1,6 +1,6 @@
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, Input, Modal, Form, Radio, InputNumber, Switch, Divider, Spin } from 'antd' import { Card, Table, Button, Space, Tag, Popconfirm, message, Input, Modal, Form, Radio, InputNumber, Switch, Divider, Spin, Empty, List, Tooltip } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons' import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api' import { apiService } from '../services/api'
@@ -212,25 +212,50 @@ const TemplateList: React.FC = () => {
{ {
title: t('common.actions') || '操作', title: t('common.actions') || '操作',
key: 'action', key: 'action',
width: isMobile ? 120 : 200, width: isMobile ? 120 : 120,
fixed: 'right' as const,
render: (_: any, record: CopyTradingTemplate) => ( render: (_: any, record: CopyTradingTemplate) => (
<Space size="small"> <Space size={4}>
<Button <Tooltip title={t('common.edit') || '编辑'}>
type="link" <div
size="small" onClick={() => navigate(`/templates/edit/${record.id}`)}
icon={<EditOutlined />} style={{
onClick={() => navigate(`/templates/edit/${record.id}`)} display: 'flex',
> alignItems: 'center',
{t('common.edit') || '编辑'} justifyContent: 'center',
</Button> width: '32px',
<Button height: '32px',
type="link" cursor: 'pointer',
size="small" borderRadius: '6px',
icon={<CopyOutlined />} transition: 'background-color 0.2s'
onClick={() => handleCopy(record)} }}
> onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
{t('templateList.copy') || '复制'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
</Button> >
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={t('templateList.copy') || '复制'}>
<div
onClick={() => handleCopy(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Popconfirm <Popconfirm
title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'} title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'}
description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'} description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'}
@@ -238,14 +263,24 @@ const TemplateList: React.FC = () => {
okText={t('common.confirm') || '确定'} okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'} cancelText={t('common.cancel') || '取消'}
> >
<Button <Tooltip title={t('common.delete') || '删除'}>
type="link" <div
size="small" style={{
danger display: 'flex',
icon={<DeleteOutlined />} alignItems: 'center',
> justifyContent: 'center',
{t('common.delete') || '删除'} width: '32px',
</Button> height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm> </Popconfirm>
</Space> </Space>
) )
@@ -254,26 +289,29 @@ const TemplateList: React.FC = () => {
return ( return (
<div> <div>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}> <h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('templateList.title') || '跟单模板管理'}</h2>
<h2 style={{ margin: 0 }}>{t('templateList.title') || '跟单模板管理'}</h2> <Space size={8}>
<Space> <Search
<Search placeholder={t('templateList.searchPlaceholder') || '搜索模板名称'}
placeholder={t('templateList.searchPlaceholder') || '搜索模板名称'} allowClear
allowClear style={{ width: isMobile ? 120 : 200 }}
style={{ width: isMobile ? 150 : 250 }} onSearch={setSearchText}
onSearch={setSearchText} onChange={(e) => setSearchText(e.target.value)}
onChange={(e) => setSearchText(e.target.value)} />
/> <Tooltip title={t('templateList.addTemplate') || '新增模板'}>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => navigate('/templates/add')} onClick={() => navigate('/templates/add')}
> size={isMobile ? 'middle' : 'large'}
{t('templateList.addTemplate') || '新增模板'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
</Button> />
</Space> </Tooltip>
</div> </Space>
</div>
<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 ? (
// 移动端卡片布局 // 移动端卡片布局
@@ -283,116 +321,133 @@ const TemplateList: React.FC = () => {
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : filteredTemplates.length === 0 ? ( ) : filteredTemplates.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}> <Empty description={t('templateList.noData') || '暂无模板数据'} />
{t('templateList.noData') || '暂无模板数据'}
</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <List
{filteredTemplates.map((template) => { dataSource={filteredTemplates}
const date = new Date(template.createdAt) renderItem={(template) => {
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={template.id} key={template.id}
style={{ style={{
borderRadius: '12px', marginBottom: '10px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)', borderRadius: '10px',
border: '1px solid #e8e8e8' boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}} }}
bodyStyle={{ padding: '16px' }} bodyStyle={{ padding: '0' }}
> >
{/* 模板名称和模式 */} {/* 头部区域 - 模板名称 */}
<div style={{ marginBottom: '12px' }}> <div style={{
<div style={{ padding: '10px 12px',
fontSize: '16px', background: 'var(--ant-color-primary, #1677ff)',
fontWeight: 'bold', color: '#fff'
marginBottom: '8px', }}>
color: '#1890ff' <div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px' }}>
}}>
{template.templateName} {template.templateName}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}> <div style={{ fontSize: '12px', opacity: '0.9' }}>
<Tag color={template.copyMode === 'RATIO' ? 'blue' : 'green'}>
{template.copyMode === 'RATIO' ? (t('templateList.ratioMode') || '比例模式') : (t('templateList.fixedAmountMode') || '固定金额模式')}
</Tag>
<Tag color={template.supportSell ? 'green' : 'red'}>
{template.supportSell ? (t('templateList.supportSell') || '跟单卖出') : (t('templateList.notSupportSell') || '不跟单卖出')}
</Tag>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* 跟单配置 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.copyConfig') || '跟单配置'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{template.copyMode === 'RATIO' {template.copyMode === 'RATIO'
? `${t('templateList.ratio') || '比例'} ${template.copyRatio}x` ? `${t('templateList.ratioMode') || '比例模式'} ${(parseFloat(template.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
: template.fixedAmount : `${t('templateList.fixedAmountMode') || '固定金额'} ${formatUSDC(template.fixedAmount || '0')} USDC`
? `${t('templateList.fixedAmount') || '固定'} ${formatUSDC(template.fixedAmount)} USDC`
: '-'
} }
</div> </div>
</div> </div>
{/* 其他配置信息 */} {/* 配置信息区域 */}
{template.copyMode === 'RATIO' && ( <div style={{
<div style={{ marginBottom: '12px' }}> padding: '8px 12px',
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.amountLimit') || '金额限制'}</div> backgroundColor: '#fafafa',
<div style={{ fontSize: '13px', color: '#333' }}> borderBottom: '1px solid #f0f0f0',
{template.maxOrderSize && ( minHeight: '42px',
<span>{t('templateList.max') || '最大'}: {formatUSDC(template.maxOrderSize)} USDC</span> display: 'flex',
)} alignItems: 'center'
{template.maxOrderSize && template.minOrderSize && <span> | </span>} }}>
{template.minOrderSize && ( <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<span>{t('templateList.min') || '最小'}: {formatUSDC(template.minOrderSize)} USDC</span> <div>
)} <div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#999' }}>{t('templateList.notSet') || '未设置'}</span>} {t('templateList.supportSell') || '跟单卖出'}
</div>
<div style={{ fontSize: '12px', fontWeight: '500' }}>
<Tag color={template.supportSell ? 'green' : 'red'} style={{ margin: 0, fontSize: '10px' }}>
{template.supportSell ? (t('common.yes') || '是') : (t('common.no') || '否')}
</Tag>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('templateList.maxDailyOrders') || '每日最大'}
</div>
<div style={{ fontSize: '12px', fontWeight: '500', color: '#1890ff' }}>
{template.maxDailyOrders} {t('common.orders') || '单'}
</div>
</div> </div>
</div> </div>
</div>
{/* 金额限制区域(仅比例模式显示) */}
{template.copyMode === 'RATIO' && (
<div style={{
padding: '6px 12px',
fontSize: '11px',
color: '#8c8c8c',
borderBottom: '1px solid #f0f0f0'
}}>
<span style={{ color: '#d48806' }}>{t('templateList.amountLimit') || '金额限制'}: </span>
{template.maxOrderSize && (
<span>{t('templateList.max') || '最大'} {formatUSDC(template.maxOrderSize)} USDC</span>
)}
{template.maxOrderSize && template.minOrderSize && <span> | </span>}
{template.minOrderSize && (
<span>{t('templateList.min') || '最小'} {formatUSDC(template.minOrderSize)} USDC</span>
)}
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#bfbfbf' }}>{t('templateList.notSet') || '未设置'}</span>}
</div>
)} )}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.otherConfig') || '其他配置'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('templateList.maxDailyOrders') || '每日最大订单'}: {template.maxDailyOrders} | {t('templateList.priceTolerance') || '价格容忍度'}: {template.priceTolerance}%
</div>
</div>
{/* 创建时间 */} {/* 创建时间 */}
<div style={{ marginBottom: '16px' }}> <div style={{
<div style={{ fontSize: '12px', color: '#999' }}> padding: '6px 12px',
{t('common.createdAt') || '创建时间'}: {formattedDate} fontSize: '11px',
</div> color: '#8c8c8c'
}}>
{t('common.createdAt') || '创建时间'}: {new Date(template.createdAt).toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</div> </div>
{/* 操作按钮 */} {/* 图标操作栏 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div style={{
<Button padding: '8px 12px',
type="primary" display: 'flex',
size="small" justifyContent: 'space-around',
icon={<EditOutlined />} alignItems: 'center'
onClick={() => navigate(`/templates/edit/${template.id}`)} }}>
style={{ flex: 1, minWidth: '80px' }} <Tooltip title={t('common.edit') || '编辑'}>
> <div
{t('common.edit') || '编辑'} onClick={() => navigate(`/templates/edit/${template.id}`)}
</Button> style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
<Button >
size="small" <EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
icon={<CopyOutlined />} <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit') || '编辑'}</span>
onClick={() => handleCopy(template)} </div>
style={{ flex: 1, minWidth: '80px' }} </Tooltip>
>
{t('templateList.copy') || '复制'} <Tooltip title={t('templateList.copy') || '复制'}>
</Button> <div
onClick={() => handleCopy(template)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('templateList.copy') || '复制'}</span>
</div>
</Tooltip>
<Popconfirm <Popconfirm
title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'} title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'}
description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'} description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'}
@@ -400,20 +455,18 @@ const TemplateList: React.FC = () => {
okText={t('common.confirm') || '确定'} okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'} cancelText={t('common.cancel') || '取消'}
> >
<Button <Tooltip title={t('common.delete') || '删除'}>
danger <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
size="small" <DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
icon={<DeleteOutlined />} <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
style={{ flex: 1, minWidth: '80px' }} </div>
> </Tooltip>
{t('common.delete') || '删除'}
</Button>
</Popconfirm> </Popconfirm>
</div> </div>
</Card> </Card>
) )
})} }}
</div> />
)} )}
</div> </div>
) : ( ) : (
+155 -37
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input } from 'antd' import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, List, Empty, Tooltip, Spin } from 'antd'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons' import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined, UserOutlined, KeyOutlined } 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 { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
@@ -219,46 +219,164 @@ const UserList: React.FC = () => {
return ( return (
<div> <div>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}> <Title level={isMobile ? 4 : 3} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>{t('userList.title') || '用户管理'}</Title>
<Title level={4} style={{ margin: 0 }}>{t('userList.title') || '用户管理'}</Title> <Space size={8} wrap>
<Space> <Button
<Button icon={<KeyOutlined />}
icon={<EditOutlined />} onClick={() => setUpdateOwnPasswordModalVisible(true)}
onClick={() => setUpdateOwnPasswordModalVisible(true)} size={isMobile ? 'middle' : 'large'}
> style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }}
{t('userList.updateMyPassword') || '修改我的密码'} >
</Button> {isMobile ? (t('userList.updateMyPassword') || '改密') : (t('userList.updateMyPassword') || '修改我的密码')}
<Button </Button>
icon={<ReloadOutlined />} <Button
onClick={fetchUsers} icon={<ReloadOutlined />}
loading={loading} onClick={fetchUsers}
> loading={loading}
{t('common.refresh') || '刷新'} size={isMobile ? 'middle' : 'large'}
</Button> style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }}
{isDefaultUser && ( >
{t('common.refresh') || '刷新'}
</Button>
{isDefaultUser && (
<Tooltip title={t('userList.addUser') || '新增用户'}>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => setCreateModalVisible(true)} onClick={() => setCreateModalVisible(true)}
> size={isMobile ? 'middle' : 'large'}
{t('userList.addUser') || '新增用户'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
</Button> />
</Tooltip>
)}
</Space>
</div>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
{isMobile ? (
// 移动端卡片布局
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : users.length === 0 ? (
<Empty description={t('userList.noData') || '暂无用户数据'} />
) : (
<List
dataSource={users}
renderItem={(user) => (
<Card
key={user.id}
style={{
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: '0' }}
>
{/* 头部区域 - 用户名 */}
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<UserOutlined style={{ fontSize: '14px' }} />
<span>{user.username}</span>
</div>
<div style={{ fontSize: '12px', opacity: '0.9' }}>
ID: {user.id}
</div>
</div>
{/* 角色信息区域 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#fafafa',
borderBottom: '1px solid #f0f0f0',
minHeight: '42px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('userList.role') || '角色'}
</div>
<Tag color={user.isDefault ? 'red' : 'blue'} style={{ margin: 0 }}>
{user.isDefault ? (t('userList.defaultAccount') || '默认账户') : (t('userList.normalUser') || '普通用户')}
</Tag>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('common.createdAt') || '创建时间'}
</div>
<div style={{ fontSize: '12px', fontWeight: '500', color: '#666' }}>
{new Date(user.createdAt).toLocaleDateString(i18n.language || 'zh-CN')}
</div>
</div>
</div>
</div>
{/* 操作区域(仅管理员可见) */}
{isDefaultUser && !user.isDefault && (
<div style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center'
}}>
<Tooltip title={t('userList.updatePassword') || '修改密码'}>
<div
onClick={() => {
setSelectedUser(user)
setUpdatePasswordModalVisible(true)
}}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<KeyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('userList.updatePassword') || '改密'}</span>
</div>
</Tooltip>
<Popconfirm
title={t('userList.deleteConfirm') || '确定要删除这个用户吗?'}
onConfirm={() => handleDelete(user)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
>
<Tooltip title={t('common.delete') || '删除'}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
)}
</Card>
)}
/>
)} )}
</Space> </div>
</div> ) : (
<Table <Table
columns={columns} columns={columns}
dataSource={users} dataSource={users}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={{ pagination={{
pageSize: isMobile ? 10 : 20, pageSize: isMobile ? 10 : 20,
showSizeChanger: !isMobile, showSizeChanger: !isMobile,
showTotal: (total) => t('userList.total', { total }) || `${total}` showTotal: (total) => t('userList.total', { total }) || `${total}`
}} }}
scroll={isMobile ? { x: 600 } : undefined} scroll={isMobile ? { x: 600 } : undefined}
/> />
)}
</Card> </Card>
{/* 创建用户弹窗 */} {/* 创建用户弹窗 */}