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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user