Initial commit: Polymarket copy trading bot

- Backend: Spring Boot + Kotlin implementation
  - Account management with private key import
  - Leader management
  - Copy trading configuration
  - Order synchronization
  - Balance and position queries via Polymarket API
  - Ethereum RPC integration for USDC balance
  - Proxy address calculation

- Frontend: React + TypeScript
  - Account management UI
  - Mobile responsive design
  - Account import with private key/mnemonic support
  - Balance display and account details modal

- Database: MySQL with Flyway migrations
- API Integration: Polymarket CLOB API, Data API, Ethereum RPC
This commit is contained in:
WrBug
2025-11-21 04:32:08 +08:00
commit 4f7fef145f
70 changed files with 14618 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Descriptions, Button, Space, Tag, Spin, message, Typography, Divider } from 'antd'
import { ArrowLeftOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const AccountDetail: React.FC = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const isMobile = useMediaQuery({ maxWidth: 768 })
const accountId = searchParams.get('id')
const { fetchAccountDetail, fetchAccountBalance } = useAccountStore()
const [account, setAccount] = useState<Account | null>(null)
const [balance, setBalance] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [balanceLoading, setBalanceLoading] = useState(false)
useEffect(() => {
if (accountId) {
loadAccountDetail()
loadBalance()
} else {
message.error('账户ID不能为空')
navigate('/accounts')
}
}, [accountId])
const loadAccountDetail = async () => {
if (!accountId) return
setLoading(true)
try {
const accountData = await fetchAccountDetail(Number(accountId))
setAccount(accountData)
} catch (error: any) {
message.error(error.message || '获取账户详情失败')
navigate('/accounts')
} finally {
setLoading(false)
}
}
const loadBalance = async () => {
if (!accountId) return
setBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(Number(accountId))
setBalance(balanceData.balance || null)
} catch (error: any) {
console.error('获取余额失败:', error)
// 余额查询失败不显示错误,只显示 "-"
setBalance(null)
} finally {
setBalanceLoading(false)
}
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
)
}
if (!account) {
return null
}
return (
<div style={{
padding: isMobile ? '0' : undefined,
margin: isMobile ? '0 -8px' : undefined
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: isMobile ? '12px' : '16px',
flexWrap: 'wrap',
gap: '12px',
padding: isMobile ? '0 8px' : '0'
}}>
<Space wrap>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/accounts')}
size={isMobile ? 'middle' : 'large'}
>
</Button>
<Title level={isMobile ? 4 : 2} style={{ margin: 0, fontSize: isMobile ? '16px' : undefined }}>
{account.accountName || `账户 ${account.id}`}
</Title>
</Space>
<Space wrap style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
icon={<ReloadOutlined />}
onClick={loadBalance}
loading={balanceLoading}
size={isMobile ? 'middle' : 'large'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => navigate(`/accounts/edit?id=${account.id}`)}
size={isMobile ? 'middle' : 'large'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
</Space>
</div>
<Card style={{
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}>
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
style={{ fontSize: isMobile ? '14px' : undefined }}
>
<Descriptions.Item label="账户ID">
{account.id}
</Descriptions.Item>
<Descriptions.Item label="账户名称">
{account.accountName || '-'}
</Descriptions.Item>
<Descriptions.Item label="钱包地址" span={isMobile ? 1 : 2}>
<span style={{
fontFamily: 'monospace',
fontSize: isMobile ? '11px' : '14px',
wordBreak: 'break-all',
lineHeight: '1.4',
display: 'block'
}}>
{account.walletAddress}
</span>
</Descriptions.Item>
<Descriptions.Item label="默认账户">
<Tag color={account.isDefault ? 'gold' : 'default'}>
{account.isDefault ? '是' : '否'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="账户余额">
{balanceLoading ? (
<Spin size="small" />
) : balance ? (
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{balance} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
)}
</Descriptions.Item>
</Descriptions>
</Card>
<Divider />
<Card
title="API 凭证配置"
style={{
marginTop: isMobile ? '12px' : '16px',
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}
>
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
style={{ fontSize: isMobile ? '14px' : undefined }}
>
<Descriptions.Item label="API Key">
<Tag color={account.apiKeyConfigured ? 'success' : 'default'}>
{account.apiKeyConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="API Secret">
<Tag color={account.apiSecretConfigured ? 'success' : 'default'}>
{account.apiSecretConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="API Passphrase">
<Tag color={account.apiPassphraseConfigured ? 'success' : 'default'}>
{account.apiPassphraseConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="配置状态">
{account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? (
<Tag color="success"></Tag>
) : (
<Tag color="warning"></Tag>
)}
</Descriptions.Item>
</Descriptions>
</Card>
{account.totalOrders !== undefined || account.totalPnl !== undefined ? (
<>
<Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} />
<Card
title="交易统计"
style={{
marginTop: isMobile ? '12px' : '16px',
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}
>
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
style={{ fontSize: isMobile ? '14px' : undefined }}
>
{account.totalOrders !== undefined && (
<Descriptions.Item label="总订单数">
{account.totalOrders}
</Descriptions.Item>
)}
{account.totalPnl !== undefined && (
<Descriptions.Item label="总盈亏">
<span style={{
fontWeight: 'bold',
color: account.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{account.totalPnl} USDC
</span>
</Descriptions.Item>
)}
</Descriptions>
</Card>
</>
) : null}
</div>
)
}
export default AccountDetail
+361
View File
@@ -0,0 +1,361 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, message, Typography, Radio, Space, Alert, Checkbox } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore'
import {
getAddressFromPrivateKey,
getAddressFromMnemonic,
getPrivateKeyFromMnemonic,
isValidWalletAddress,
isValidPrivateKey,
isValidMnemonic
} from '../utils/ethers'
import { useMediaQuery } from 'react-responsive'
const { Title, Text } = Typography
type ImportType = 'privateKey' | 'mnemonic'
const AccountImport: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { importAccount, loading } = useAccountStore()
const [form] = Form.useForm()
const [importType, setImportType] = useState<ImportType>('privateKey')
const [derivedAddress, setDerivedAddress] = useState<string>('')
const [addressError, setAddressError] = useState<string>('')
// 当私钥输入时,自动推导地址
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const privateKey = e.target.value.trim()
if (!privateKey) {
setDerivedAddress('')
setAddressError('')
return
}
// 验证私钥格式
if (!isValidPrivateKey(privateKey)) {
setAddressError('私钥格式不正确(应为64位十六进制字符串)')
setDerivedAddress('')
return
}
try {
const address = getAddressFromPrivateKey(privateKey)
setDerivedAddress(address)
setAddressError('')
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
} catch (error: any) {
setAddressError(error.message || '无法从私钥推导地址')
setDerivedAddress('')
}
}
// 当助记词输入时,自动推导地址
const handleMnemonicChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const mnemonic = e.target.value.trim()
if (!mnemonic) {
setDerivedAddress('')
setAddressError('')
return
}
// 验证助记词格式
if (!isValidMnemonic(mnemonic)) {
setAddressError('助记词格式不正确(应为12或24个单词,用空格分隔)')
setDerivedAddress('')
return
}
try {
const address = getAddressFromMnemonic(mnemonic, 0)
setDerivedAddress(address)
setAddressError('')
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
} catch (error: any) {
setAddressError(error.message || '无法从助记词推导地址')
setDerivedAddress('')
}
}
const handleSubmit = async (values: any) => {
try {
let privateKey: string
let walletAddress: string
if (importType === 'privateKey') {
// 私钥模式
privateKey = values.privateKey
walletAddress = values.walletAddress
// 验证推导的地址和输入的地址是否一致
if (derivedAddress && walletAddress !== derivedAddress) {
message.error('钱包地址与私钥不匹配')
return
}
} else {
// 助记词模式
if (!values.mnemonic) {
message.error('请输入助记词')
return
}
// 从助记词导出私钥和地址
privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0)
const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0)
// 如果用户手动输入了地址,验证是否与推导的地址一致
if (values.walletAddress) {
if (values.walletAddress !== derivedAddressFromMnemonic) {
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
message.warning(`输入的地址与助记词推导的地址不一致。推导的地址: ${derivedAddressFromMnemonic},将使用推导的地址`)
walletAddress = derivedAddressFromMnemonic
} else {
// 地址匹配,使用用户输入的地址
walletAddress = values.walletAddress
}
} else {
// 如果用户没有输入地址,使用推导的地址
walletAddress = derivedAddressFromMnemonic
}
}
// 验证钱包地址格式
if (!isValidWalletAddress(walletAddress)) {
message.error('钱包地址格式不正确')
return
}
await importAccount({
privateKey: privateKey,
walletAddress: walletAddress,
accountName: values.accountName,
apiKey: values.apiKey,
apiSecret: values.apiSecret,
apiPassphrase: values.apiPassphrase,
isDefault: values.isDefault || false
})
message.success('导入账户成功')
navigate('/accounts')
} catch (error: any) {
message.error(error.message || '导入账户失败')
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/accounts')}
style={{ marginBottom: '16px' }}
>
</Button>
<Title level={2} style={{ margin: 0 }}></Title>
</div>
<Card>
<Alert
message="安全提示"
description="私钥将加密存储在后端,请确保网络连接安全。建议使用 HTTPS 连接。"
type="warning"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item label="导入方式">
<Radio.Group
value={importType}
onChange={(e) => {
setImportType(e.target.value)
setDerivedAddress('')
setAddressError('')
form.setFieldsValue({ walletAddress: '' })
}}
>
<Radio value="privateKey"></Radio>
<Radio value="mnemonic"></Radio>
</Radio.Group>
</Form.Item>
{importType === 'privateKey' ? (
<>
<Form.Item
label="私钥"
name="privateKey"
rules={[
{ required: true, message: '请输入私钥' },
{
validator: (_, value) => {
if (!value) return Promise.resolve()
if (!isValidPrivateKey(value)) {
return Promise.reject(new Error('私钥格式不正确(应为64位十六进制字符串)'))
}
return Promise.resolve()
}
}
]}
help={addressError || (derivedAddress ? `推导地址: ${derivedAddress}` : '')}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={3}
placeholder="请输入私钥(64位十六进制字符串,可选0x前缀)"
onChange={handlePrivateKeyChange}
/>
</Form.Item>
<Form.Item
label="钱包地址"
name="walletAddress"
rules={[
{ required: true, message: '请输入钱包地址' },
{
validator: (_, value) => {
if (!value) return Promise.resolve()
if (!isValidWalletAddress(value)) {
return Promise.reject(new Error('钱包地址格式不正确'))
}
if (derivedAddress && value !== derivedAddress) {
return Promise.reject(new Error('钱包地址与私钥不匹配'))
}
return Promise.resolve()
}
}
]}
>
<Input
placeholder="钱包地址(将从私钥自动推导)"
readOnly={!!derivedAddress}
/>
</Form.Item>
</>
) : (
<>
<Form.Item
label="助记词"
name="mnemonic"
rules={[
{ required: true, message: '请输入助记词' },
{
validator: (_, value) => {
if (!value) return Promise.resolve()
if (!isValidMnemonic(value)) {
return Promise.reject(new Error('助记词格式不正确(应为12或24个单词,用空格分隔)'))
}
return Promise.resolve()
}
}
]}
help={addressError || (derivedAddress ? `推导地址: ${derivedAddress}` : '')}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={4}
placeholder="请输入12或24个单词的助记词(用空格分隔)"
onChange={handleMnemonicChange}
/>
</Form.Item>
<Form.Item
label="钱包地址"
name="walletAddress"
rules={[
{ required: true, message: '请输入钱包地址' },
{
validator: (_, value) => {
if (!value) return Promise.resolve()
if (!isValidWalletAddress(value)) {
return Promise.reject(new Error('钱包地址格式不正确'))
}
if (derivedAddress && value !== derivedAddress) {
return Promise.reject(new Error('钱包地址与助记词不匹配'))
}
return Promise.resolve()
}
}
]}
>
<Input
placeholder="钱包地址(将从助记词自动推导)"
readOnly={!!derivedAddress}
/>
</Form.Item>
</>
)}
<Form.Item
label="账户名称"
name="accountName"
>
<Input placeholder="可选,用于标识账户" />
</Form.Item>
<Form.Item
label="API Key"
name="apiKey"
help="Polymarket API Key(可选,用于 L2 API 认证)"
>
<Input.Password placeholder="可选,Polymarket API Key" />
</Form.Item>
<Form.Item
label="API Secret"
name="apiSecret"
help="Polymarket API Secret(可选,用于 HMAC 签名)"
>
<Input.Password placeholder="可选,Polymarket API Secret" />
</Form.Item>
<Form.Item
label="API Passphrase"
name="apiPassphrase"
help="Polymarket API Passphrase(可选,用于加密/解密密钥)"
>
<Input.Password placeholder="可选,Polymarket API Passphrase" />
</Form.Item>
<Form.Item
name="isDefault"
valuePropName="checked"
>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={loading}
size={isMobile ? 'middle' : 'large'}
>
</Button>
<Button onClick={() => navigate('/accounts')}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default AccountImport
+575
View File
@@ -0,0 +1,575 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider } from 'antd'
import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const AccountList: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, loading, fetchAccounts, deleteAccount, setDefaultAccount, fetchAccountBalance, fetchAccountDetail } = useAccountStore()
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
const [detailModalVisible, setDetailModalVisible] = useState(false)
const [detailAccount, setDetailAccount] = useState<Account | null>(null)
const [detailBalance, setDetailBalance] = useState<{ total: string; available: string; position: string; positions: any[] } | null>(null)
const [detailBalanceLoading, setDetailBalanceLoading] = useState(false)
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
// 加载所有账户的余额
useEffect(() => {
const loadBalances = async () => {
for (const account of accounts) {
if (!balanceMap[account.id] && !balanceLoading[account.id]) {
setBalanceLoading(prev => ({ ...prev, [account.id]: true }))
try {
const balanceData = await fetchAccountBalance(account.id)
setBalanceMap(prev => ({
...prev,
[account.id]: {
total: balanceData.totalBalance || '0',
available: balanceData.availableBalance || '0',
position: balanceData.positionBalance || '0'
}
}))
} catch (error) {
console.error(`获取账户 ${account.id} 余额失败:`, error)
setBalanceMap(prev => ({
...prev,
[account.id]: { total: '-', available: '-', position: '-' }
}))
} finally {
setBalanceLoading(prev => ({ ...prev, [account.id]: false }))
}
}
}
}
if (accounts.length > 0) {
loadBalances()
}
}, [accounts])
const handleDelete = async (account: Account) => {
try {
await deleteAccount(account.id)
message.success('删除账户成功')
} catch (error: any) {
message.error(error.message || '删除账户失败')
}
}
const handleSetDefault = async (account: Account) => {
try {
await setDefaultAccount(account.id)
message.success('设置默认账户成功')
} catch (error: any) {
message.error(error.message || '设置默认账户失败')
}
}
const handleShowDetail = async (account: Account) => {
try {
setDetailModalVisible(true)
setDetailAccount(account)
setDetailBalance(null)
setDetailBalanceLoading(false)
// 加载详情和余额
try {
const accountDetail = await fetchAccountDetail(account.id)
setDetailAccount(accountDetail)
// 加载余额
setDetailBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(account.id)
setDetailBalance({
total: balanceData.totalBalance || '0',
available: balanceData.availableBalance || '0',
position: balanceData.positionBalance || '0',
positions: balanceData.positions || []
})
} catch (error) {
console.error('获取余额失败:', error)
setDetailBalance(null)
} finally {
setDetailBalanceLoading(false)
}
} catch (error: any) {
console.error('获取账户详情失败:', error)
message.error(error.message || '获取账户详情失败')
setDetailModalVisible(false)
setDetailAccount(null)
}
} catch (error: any) {
console.error('打开详情失败:', error)
message.error('打开详情失败')
setDetailModalVisible(false)
setDetailAccount(null)
}
}
const handleRefreshDetailBalance = async () => {
if (!detailAccount) return
setDetailBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(detailAccount.id)
setDetailBalance({
total: balanceData.totalBalance || '0',
available: balanceData.availableBalance || '0',
position: balanceData.positionBalance || '0',
positions: balanceData.positions || []
})
message.success('余额刷新成功')
} catch (error: any) {
message.error(error.message || '刷新余额失败')
} finally {
setDetailBalanceLoading(false)
}
}
const columns = [
{
title: '账户名称',
dataIndex: 'accountName',
key: 'accountName',
render: (text: string, record: Account) => text || `账户 ${record.id}`
},
{
title: '钱包地址',
dataIndex: 'walletAddress',
key: 'walletAddress',
render: (address: string) => (
<span style={{ fontFamily: 'monospace' }}>{address}</span>
)
},
{
title: '默认账户',
dataIndex: 'isDefault',
key: 'isDefault',
render: (isDefault: boolean, record: Account) => (
<Button
type="text"
icon={isDefault ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined />}
onClick={() => !isDefault && handleSetDefault(record)}
disabled={isDefault}
/>
)
},
{
title: 'API 凭证',
key: 'apiCredentials',
render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
return (
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'}>
{allConfigured ? '完整配置' : partialConfigured ? '部分配置' : '未配置'}
</Tag>
)
}
},
{
title: '余额',
dataIndex: 'balance',
key: 'balance',
render: (_: any, record: Account) => {
if (balanceLoading[record.id]) {
return <Spin size="small" />
}
const balanceObj = balanceMap[record.id]
const balance = balanceObj?.total || record.balance || '-'
return balance && balance !== '-' && typeof balance === 'string' ? `${balance} USDC` : '-'
}
},
{
title: '操作',
key: 'action',
render: (_: any, record: Account) => (
<Space size="small">
<Button
type="link"
size="small"
onClick={() => handleShowDetail(record)}
>
</Button>
<Popconfirm
title="确定要删除这个账户吗?"
description={
record.apiKeyConfigured
? "删除账户前,请确保已取消所有活跃订单。删除后无法恢复,请谨慎操作!"
: "删除后无法恢复,请谨慎操作!"
}
onConfirm={() => handleDelete(record)}
okText="确定删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
)
}
]
const mobileColumns = [
{
title: '账户信息',
key: 'info',
render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
return (
<div style={{ padding: '8px 0' }}>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
fontSize: '16px'
}}>
{record.accountName || `账户 ${record.id}`}
</div>
<div style={{
fontSize: '11px',
color: '#666',
marginBottom: '8px',
wordBreak: 'break-all',
fontFamily: 'monospace',
lineHeight: '1.4'
}}>
{record.walletAddress}
</div>
<div style={{ marginBottom: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<Tag color={record.isDefault ? 'gold' : 'default'} style={{ margin: 0 }}>
{record.isDefault ? '默认' : '普通'}
</Tag>
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'} style={{ margin: 0 }}>
{allConfigured ? '完整配置' : partialConfigured ? '部分配置' : '未配置'}
</Tag>
</div>
<div style={{
fontSize: '14px',
fontWeight: '500',
color: '#1890ff'
}}>
: {balanceLoading[record.id] ? (
<Spin size="small" style={{ marginLeft: '4px' }} />
) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? (
`${balanceMap[record.id].total} USDC`
) : (
'-'
)}
</div>
{balanceMap[record.id] && balanceMap[record.id].available !== '-' && (
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px'
}}>
: {balanceMap[record.id].available} USDC | : {balanceMap[record.id].position} USDC
</div>
)}
</div>
)
}
},
{
title: '操作',
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' }}
>
</Button>
{!record.isDefault && (
<Button
size="small"
block
icon={<StarOutlined />}
onClick={() => handleSetDefault(record)}
style={{ minHeight: '32px' }}
>
</Button>
)}
<Popconfirm
title="确定要删除这个账户吗?"
description={
record.apiKeyConfigured
? "删除账户前,请确保已取消所有活跃订单。删除后无法恢复,请谨慎操作!"
: "删除后无法恢复,请谨慎操作!"
}
onConfirm={() => handleDelete(record)}
okText="确定删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
size="small"
block
danger
style={{ minHeight: '32px' }}
>
</Button>
</Popconfirm>
</Space>
)
}
]
return (
<div style={{
padding: isMobile ? '0' : undefined,
margin: isMobile ? '0 -8px' : undefined
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: isMobile ? '12px' : '16px',
flexWrap: 'wrap',
gap: '12px',
padding: isMobile ? '0 8px' : '0'
}}>
<Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/accounts/import')}
size={isMobile ? 'middle' : 'large'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
</div>
<Card style={{
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}>
{isMobile ? (
<Table
dataSource={accounts}
columns={mobileColumns}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: false,
simple: true,
size: 'small'
}}
scroll={{ x: 'max-content' }}
size="small"
style={{ fontSize: '14px' }}
/>
) : (
<Table
dataSource={accounts}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
pageSize: 20,
showSizeChanger: true
}}
/>
)}
</Card>
{/* 账户详情 Modal */}
<Modal
title={detailAccount ? (detailAccount.accountName || `账户 ${detailAccount.id}`) : '账户详情'}
open={detailModalVisible}
onCancel={() => {
setDetailModalVisible(false)
setDetailAccount(null)
setDetailBalance(null)
}}
footer={[
<Button
key="refresh"
icon={<ReloadOutlined />}
onClick={handleRefreshDetailBalance}
loading={detailBalanceLoading}
disabled={!detailAccount}
>
</Button>,
<Button
key="close"
onClick={() => {
setDetailModalVisible(false)
setDetailAccount(null)
setDetailBalance(null)
}}
>
</Button>
]}
width={isMobile ? '95%' : 800}
style={{ top: isMobile ? 20 : 50 }}
destroyOnClose
maskClosable
closable
>
{detailAccount ? (
<div>
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
>
<Descriptions.Item label="账户ID">
{detailAccount.id}
</Descriptions.Item>
<Descriptions.Item label="账户名称">
{detailAccount.accountName || '-'}
</Descriptions.Item>
<Descriptions.Item label="钱包地址" span={isMobile ? 1 : 2}>
<span style={{
fontFamily: 'monospace',
fontSize: isMobile ? '11px' : '13px',
wordBreak: 'break-all',
lineHeight: '1.4',
display: 'block'
}}>
{detailAccount.walletAddress || '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label="默认账户">
<Tag color={detailAccount.isDefault ? 'gold' : 'default'}>
{detailAccount.isDefault ? '是' : '否'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="总余额" span={isMobile ? 1 : 2}>
{detailBalanceLoading ? (
<Spin size="small" />
) : detailBalance ? (
<span style={{ fontWeight: 'bold', color: '#1890ff', fontSize: '16px' }}>
{detailBalance.total} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
)}
</Descriptions.Item>
<Descriptions.Item label="可用余额">
{detailBalanceLoading ? (
<Spin size="small" />
) : detailBalance ? (
<span style={{ color: '#52c41a' }}>
{detailBalance.available} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
)}
</Descriptions.Item>
<Descriptions.Item label="仓位余额">
{detailBalanceLoading ? (
<Spin size="small" />
) : detailBalance ? (
<span style={{ color: '#1890ff' }}>
{detailBalance.position} USDC
</span>
) : (
<span style={{ color: '#999' }}>-</span>
)}
</Descriptions.Item>
</Descriptions>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title="API 凭证配置"
>
<Descriptions.Item label="API Key">
<Tag color={detailAccount.apiKeyConfigured ? 'success' : 'default'}>
{detailAccount.apiKeyConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="API Secret">
<Tag color={detailAccount.apiSecretConfigured ? 'success' : 'default'}>
{detailAccount.apiSecretConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="API Passphrase">
<Tag color={detailAccount.apiPassphraseConfigured ? 'success' : 'default'}>
{detailAccount.apiPassphraseConfigured ? '已配置' : '未配置'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="配置状态">
{detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? (
<Tag color="success"></Tag>
) : (
<Tag color="warning"></Tag>
)}
</Descriptions.Item>
</Descriptions>
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined) && (
<>
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title="交易统计"
>
{detailAccount.totalOrders !== undefined && (
<Descriptions.Item label="总订单数">
{detailAccount.totalOrders}
</Descriptions.Item>
)}
{detailAccount.totalPnl !== undefined && (
<Descriptions.Item label="总盈亏">
<span style={{
fontWeight: 'bold',
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}>
{detailAccount.totalPnl} USDC
</span>
</Descriptions.Item>
)}
</Descriptions>
</>
)}
</div>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px' }}>...</div>
</div>
)}
</Modal>
</div>
)
}
export default AccountList
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useState } from 'react'
import { Card, Form, Input, Button, Switch, Radio, InputNumber, message, Typography, Space } from 'antd'
import { SaveOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { CopyTradingConfig } from '../types'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const ConfigPage: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [config, setConfig] = useState<CopyTradingConfig | null>(null)
useEffect(() => {
fetchConfig()
}, [])
const fetchConfig = async () => {
try {
const response = await apiService.config.get()
if (response.data.code === 0 && response.data.data) {
const data = response.data.data
setConfig(data)
form.setFieldsValue(data)
} else {
message.error(response.data.msg || '获取配置失败')
}
} catch (error: any) {
message.error(error.message || '获取配置失败')
}
}
const handleSubmit = async (values: any) => {
setLoading(true)
try {
const response = await apiService.config.update(values)
if (response.data.code === 0) {
message.success('更新配置成功')
fetchConfig()
} else {
message.error(response.data.msg || '更新配置失败')
}
} catch (error: any) {
message.error(error.message || '更新配置失败')
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Title level={2} style={{ margin: 0 }}></Title>
</div>
<Card>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item
label="跟单金额模式"
name="copyMode"
rules={[{ required: true, message: '请选择跟单金额模式' }]}
>
<Radio.Group>
<Radio value="RATIO"></Radio>
<Radio value="FIXED"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.copyMode !== currentValues.copyMode}
>
{({ getFieldValue }) => {
const copyMode = getFieldValue('copyMode')
return copyMode === 'RATIO' ? (
<Form.Item
label="跟单比例"
name="copyRatio"
rules={[{ required: true, message: '请输入跟单比例' }]}
help="跟单金额 = Leader 订单金额 × 跟单比例"
>
<InputNumber
min={0.1}
max={10}
step={0.1}
style={{ width: '100%' }}
placeholder="例如:1.0 表示 1:1 跟单"
/>
</Form.Item>
) : (
<Form.Item
label="固定跟单金额"
name="fixedAmount"
rules={[{ required: true, message: '请输入固定跟单金额' }]}
help="无论 Leader 订单大小如何,跟单金额都固定"
>
<InputNumber
min={0.01}
step={0.01}
style={{ width: '100%' }}
placeholder="USDC"
/>
</Form.Item>
)
}}
</Form.Item>
<Form.Item
label="单笔订单最大金额"
name="maxOrderSize"
rules={[{ required: true, message: '请输入最大金额' }]}
>
<InputNumber
min={0.01}
step={0.01}
style={{ width: '100%' }}
placeholder="USDC"
/>
</Form.Item>
<Form.Item
label="单笔订单最小金额"
name="minOrderSize"
rules={[{ required: true, message: '请输入最小金额' }]}
>
<InputNumber
min={0.01}
step={0.01}
style={{ width: '100%' }}
placeholder="USDC"
/>
</Form.Item>
<Form.Item
label="每日最大亏损限制"
name="maxDailyLoss"
rules={[{ required: true, message: '请输入最大亏损限制' }]}
>
<InputNumber
min={0}
step={0.01}
style={{ width: '100%' }}
placeholder="USDC"
/>
</Form.Item>
<Form.Item
label="每日最大跟单订单数"
name="maxDailyOrders"
rules={[{ required: true, message: '请输入最大订单数' }]}
>
<InputNumber
min={1}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label="价格容忍度"
name="priceTolerance"
rules={[{ required: true, message: '请输入价格容忍度' }]}
help="百分比,允许价格在 Leader 价格 ± 容忍度范围内调整"
>
<InputNumber
min={0}
max={100}
step={0.1}
style={{ width: '100%' }}
addonAfter="%"
/>
</Form.Item>
<Form.Item
label="跟单延迟"
name="delaySeconds"
rules={[{ required: true, message: '请输入跟单延迟' }]}
help="延迟 N 秒后跟单(0 表示立即跟单)"
>
<InputNumber
min={0}
style={{ width: '100%' }}
addonAfter="秒"
/>
</Form.Item>
<Form.Item
label="轮询间隔"
name="pollIntervalSeconds"
rules={[{ required: true, message: '请输入轮询间隔' }]}
help="轮询 Leader 交易的间隔(仅在 WebSocket 不可用时使用)"
>
<InputNumber
min={1}
style={{ width: '100%' }}
addonAfter="秒"
/>
</Form.Item>
<Form.Item
label="优先使用 WebSocket 推送"
name="useWebSocket"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="启用全局跟单"
name="enabled"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
size={isMobile ? 'middle' : 'large'}
>
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default ConfigPage
+146
View File
@@ -0,0 +1,146 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, Select, Switch, message, Typography } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const { Option } = Select
const LeaderAdd: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
const handleSubmit = async (values: any) => {
setLoading(true)
try {
const response = await apiService.leaders.add({
leaderAddress: values.leaderAddress,
leaderName: values.leaderName,
accountId: values.accountId,
category: values.category,
enabled: values.enabled !== false
})
if (response.data.code === 0) {
message.success('添加 Leader 成功')
navigate('/leaders')
} else {
message.error(response.data.msg || '添加 Leader 失败')
}
} catch (error: any) {
message.error(error.message || '添加 Leader 失败')
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/leaders')}
style={{ marginBottom: '16px' }}
>
</Button>
<Title level={2} style={{ margin: 0 }}> Leader</Title>
</div>
<Card>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
initialValues={{
enabled: true
}}
>
<Form.Item
label="Leader 钱包地址"
name="leaderAddress"
rules={[
{ required: true, message: '请输入 Leader 钱包地址' },
{
pattern: /^0x[a-fA-F0-9]{40}$/,
message: '钱包地址格式不正确'
}
]}
>
<Input placeholder="0x..." />
</Form.Item>
<Form.Item
label="Leader 名称"
name="leaderName"
>
<Input placeholder="可选,用于标识 Leader" />
</Form.Item>
<Form.Item
label="使用的账户"
name="accountId"
help="选择用于跟单此 Leader 的账户,不选择则使用默认账户"
>
<Select placeholder="选择账户(可选)" allowClear>
{accounts.map(account => (
<Option key={account.id} value={account.id}>
{account.accountName || account.walletAddress} {account.isDefault && '(默认)'}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="分类筛选"
name="category"
help="仅跟单该分类的交易,不选择则跟单所有分类"
>
<Select placeholder="选择分类(可选)" allowClear>
<Option value="sports">Sports</Option>
<Option value="crypto">Crypto</Option>
</Select>
</Form.Item>
<Form.Item
label="启用跟单"
name="enabled"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={loading}
size={isMobile ? 'middle' : 'large'}
>
Leader
</Button>
<Button onClick={() => navigate('/leaders')}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default LeaderAdd
+155
View File
@@ -0,0 +1,155 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useState } from 'react'
import type { Leader } from '../types'
import { useMediaQuery } from 'react-responsive'
const LeaderList: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [leaders, setLeaders] = useState<Leader[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchLeaders()
}, [])
const fetchLeaders = async () => {
setLoading(true)
try {
const response = await apiService.leaders.list()
if (response.data.code === 0 && response.data.data) {
setLeaders(response.data.data.list || [])
} else {
message.error(response.data.msg || '获取 Leader 列表失败')
}
} catch (error: any) {
message.error(error.message || '获取 Leader 列表失败')
} finally {
setLoading(false)
}
}
const handleDelete = async (leaderId: number) => {
try {
const response = await apiService.leaders.delete({ leaderId })
if (response.data.code === 0) {
message.success('删除 Leader 成功')
fetchLeaders()
} else {
message.error(response.data.msg || '删除 Leader 失败')
}
} catch (error: any) {
message.error(error.message || '删除 Leader 失败')
}
}
const columns = [
{
title: 'Leader 名称',
dataIndex: 'leaderName',
key: 'leaderName',
render: (text: string, record: Leader) => text || `Leader ${record.id}`
},
{
title: '钱包地址',
dataIndex: 'leaderAddress',
key: 'leaderAddress',
render: (address: string) => (
<span style={{ fontFamily: 'monospace' }}>{address}</span>
)
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
render: (category: string | undefined) => category ? (
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
) : <Tag></Tag>
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) => (
<Tag color={enabled ? 'success' : 'default'}>
{enabled ? '启用' : '禁用'}
</Tag>
)
},
{
title: '跟单比例',
dataIndex: 'copyRatio',
key: 'copyRatio',
render: (ratio: string) => `${ratio}x`
},
{
title: '操作',
key: 'action',
render: (_: any, record: Leader) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
>
</Button>
<Popconfirm
title="确定要删除这个 Leader 吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
)
}
]
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
flexWrap: 'wrap',
gap: '12px'
}}>
<h2>Leader </h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/leaders/add')}
size={isMobile ? 'middle' : 'large'}
>
Leader
</Button>
</div>
<Card>
<Table
dataSource={leaders}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
pageSize: isMobile ? 10 : 20,
showSizeChanger: !isMobile
}}
/>
</Card>
</div>
)
}
export default LeaderList
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react'
import { Card, Table, Tag, Space, message } from 'antd'
import { apiService } from '../services/api'
import type { CopyOrder } from '../types'
import { useMediaQuery } from 'react-responsive'
const OrderList: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [orders, setOrders] = useState<CopyOrder[]>([])
const [loading, setLoading] = useState(false)
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0
})
useEffect(() => {
fetchOrders()
}, [pagination.current, pagination.pageSize])
const fetchOrders = async () => {
setLoading(true)
try {
const response = await apiService.orders.list({
page: pagination.current,
limit: pagination.pageSize
})
if (response.data.code === 0 && response.data.data) {
setOrders(response.data.data.list || [])
setPagination(prev => ({
...prev,
total: response.data.data?.total || 0
}))
} else {
message.error(response.data.msg || '获取订单列表失败')
}
} catch (error: any) {
message.error(error.message || '获取订单列表失败')
} finally {
setLoading(false)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'filled':
return 'success'
case 'cancelled':
return 'default'
case 'failed':
return 'error'
default:
return 'processing'
}
}
const getSideColor = (side: string) => {
return side === 'BUY' ? 'green' : 'red'
}
const columns = [
{
title: 'Leader',
dataIndex: 'leaderName',
key: 'leaderName',
render: (text: string, record: CopyOrder) => text || record.leaderAddress.slice(0, 10) + '...'
},
{
title: '市场',
dataIndex: 'marketId',
key: 'marketId',
render: (marketId: string) => (
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>
{marketId.slice(0, 10)}...
</span>
)
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
render: (category: string) => (
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
)
},
{
title: '方向',
dataIndex: 'side',
key: 'side',
render: (side: string) => (
<Tag color={getSideColor(side)}>{side}</Tag>
)
},
{
title: '价格',
dataIndex: 'price',
key: 'price'
},
{
title: '数量',
dataIndex: 'size',
key: 'size'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={getStatusColor(status)}>{status}</Tag>
)
},
{
title: '盈亏',
dataIndex: 'pnl',
key: 'pnl',
render: (pnl: string | undefined) => pnl ? (
<span style={{ color: pnl.startsWith('-') ? 'red' : 'green' }}>
{pnl} USDC
</span>
) : '-'
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (timestamp: number) => new Date(timestamp).toLocaleString()
}
]
return (
<div>
<div style={{ marginBottom: '16px' }}>
<h2></h2>
</div>
<Card>
<Table
dataSource={orders}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: !isMobile,
onChange: (page, pageSize) => {
setPagination(prev => ({ ...prev, current: page, pageSize }))
}
}}
scroll={isMobile ? { x: 800 } : undefined}
/>
</Card>
</div>
)
}
export default OrderList
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { Card, Row, Col, Statistic, message } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { Statistics as StatisticsType } from '../types'
import { useMediaQuery } from 'react-responsive'
const Statistics: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [stats, setStats] = useState<StatisticsType | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchStatistics()
}, [])
const fetchStatistics = async () => {
setLoading(true)
try {
const response = await apiService.statistics.global()
if (response.data.code === 0 && response.data.data) {
setStats(response.data.data)
} else {
message.error(response.data.msg || '获取统计信息失败')
}
} catch (error: any) {
message.error(error.message || '获取统计信息失败')
} finally {
setLoading(false)
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<h2></h2>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="总订单数"
value={stats?.totalOrders || 0}
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="总盈亏"
value={stats?.totalPnl || '0'}
precision={2}
prefix={stats?.totalPnl && parseFloat(stats.totalPnl) >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
valueStyle={{ color: stats?.totalPnl && parseFloat(stats.totalPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }}
suffix="USDC"
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="胜率"
value={stats?.winRate || '0'}
precision={2}
suffix="%"
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="平均盈亏"
value={stats?.avgPnl || '0'}
precision={2}
suffix="USDC"
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="最大盈利"
value={stats?.maxProfit || '0'}
precision={2}
prefix={<ArrowUpOutlined />}
valueStyle={{ color: '#3f8600' }}
suffix="USDC"
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={8}>
<Card>
<Statistic
title="最大亏损"
value={stats?.maxLoss || '0'}
precision={2}
prefix={<ArrowDownOutlined />}
valueStyle={{ color: '#cf1322' }}
suffix="USDC"
loading={loading}
/>
</Card>
</Col>
</Row>
</div>
)
}
export default Statistics