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
+37
View File
@@ -0,0 +1,37 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import Layout from './components/Layout'
import AccountList from './pages/AccountList'
import AccountImport from './pages/AccountImport'
import AccountDetail from './pages/AccountDetail'
import LeaderList from './pages/LeaderList'
import LeaderAdd from './pages/LeaderAdd'
import ConfigPage from './pages/ConfigPage'
import OrderList from './pages/OrderList'
import Statistics from './pages/Statistics'
function App() {
return (
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<AccountList />} />
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/import" element={<AccountImport />} />
<Route path="/accounts/detail" element={<AccountDetail />} />
<Route path="/leaders" element={<LeaderList />} />
<Route path="/leaders/add" element={<LeaderAdd />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/orders" element={<OrderList />} />
<Route path="/statistics" element={<Statistics />} />
</Routes>
</Layout>
</BrowserRouter>
</ConfigProvider>
)
}
export default App
+142
View File
@@ -0,0 +1,142 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Layout as AntLayout, Menu, Drawer, Button } from 'antd'
import { useMediaQuery } from 'react-responsive'
import {
WalletOutlined,
UserOutlined,
SettingOutlined,
UnorderedListOutlined,
BarChartOutlined,
MenuOutlined
} from '@ant-design/icons'
import type { ReactNode } from 'react'
const { Header, Content, Sider } = AntLayout
interface LayoutProps {
children: ReactNode
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const menuItems = [
{
key: '/accounts',
icon: <WalletOutlined />,
label: '账户管理'
},
{
key: '/leaders',
icon: <UserOutlined />,
label: 'Leader 管理'
},
{
key: '/config',
icon: <SettingOutlined />,
label: '跟单配置'
},
{
key: '/orders',
icon: <UnorderedListOutlined />,
label: '订单管理'
},
{
key: '/statistics',
icon: <BarChartOutlined />,
label: '统计信息'
}
]
const handleMenuClick = (key: string) => {
navigate(key)
if (isMobile) {
setMobileMenuOpen(false)
}
}
if (isMobile) {
// 移动端布局
return (
<AntLayout style={{ minHeight: '100vh' }}>
<Header style={{
background: '#001529',
padding: '0 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
Polymarket
</div>
<Button
type="text"
icon={<MenuOutlined />}
style={{ color: '#fff' }}
onClick={() => setMobileMenuOpen(true)}
/>
</Header>
<Content style={{
padding: '12px 8px',
background: '#f0f2f5',
minHeight: 'calc(100vh - 64px)'
}}>
{children}
</Content>
<Drawer
title="导航菜单"
placement="left"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
bodyStyle={{ padding: 0 }}
>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => handleMenuClick(key)}
style={{ border: 'none' }}
/>
</Drawer>
</AntLayout>
)
}
// 桌面端布局
return (
<AntLayout style={{ minHeight: '100vh' }}>
<Sider width={200} style={{ background: '#001529' }}>
<div style={{
height: '64px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '18px',
fontWeight: 'bold'
}}>
Polymarket
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => handleMenuClick(key)}
style={{ height: 'calc(100vh - 64px)', borderRight: 0 }}
/>
</Sider>
<AntLayout>
<Content style={{ padding: '24px', background: '#f0f2f5', minHeight: '100vh' }}>
{children}
</Content>
</AntLayout>
</AntLayout>
)
}
export default Layout
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+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
+185
View File
@@ -0,0 +1,185 @@
import axios, { AxiosInstance } from 'axios'
import type { ApiResponse } from '../types'
/**
* API 基础配置
*/
const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
/**
* 请求拦截器
*/
apiClient.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)
/**
* 响应拦截器
*/
apiClient.interceptors.response.use(
(response) => {
return response
},
(error) => {
if (error.response) {
console.error('API 错误:', error.response.data)
} else if (error.request) {
console.error('网络错误:', error.request)
} else {
console.error('请求错误:', error.message)
}
return Promise.reject(error)
}
)
/**
* API 服务
*/
export const apiService = {
/**
* 账户管理 API
*/
accounts: {
/**
* 导入账户
*/
import: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/import', data),
/**
* 更新账户
*/
update: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/update', data),
/**
* 删除账户
*/
delete: (data: { accountId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/delete', data),
/**
* 查询账户列表
*/
list: () =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/list', {}),
/**
* 查询账户详情
*/
detail: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/detail', data),
/**
* 查询账户余额
*/
balance: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/balance', data),
/**
* 设置默认账户
*/
setDefault: (data: { accountId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/set-default', data)
},
/**
* Leader 管理 API
*/
leaders: {
/**
* 添加 Leader
*/
add: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
/**
* 更新 Leader
*/
update: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
/**
* 删除 Leader
*/
delete: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/leaders/delete', data),
/**
* 查询 Leader 列表
*/
list: (data: { enabled?: boolean; category?: string } = {}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/list', data)
},
/**
* 配置管理 API
*/
config: {
/**
* 获取全局配置
*/
get: () =>
apiClient.post<ApiResponse<any>>('/copy-trading/config/get', {}),
/**
* 更新全局配置
*/
update: (data: any) =>
apiClient.post<ApiResponse<void>>('/copy-trading/config/update', data)
},
/**
* 订单管理 API
*/
orders: {
/**
* 查询跟单订单列表
*/
list: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/orders/list', data),
/**
* 取消跟单订单
*/
cancel: (data: { copyOrderId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/orders/cancel', data)
},
/**
* 统计 API
*/
statistics: {
/**
* 获取全局统计
*/
global: (data: { startTime?: number; endTime?: number } = {}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/global', data),
/**
* 获取 Leader 统计
*/
leader: (data: { leaderId: number; startTime?: number; endTime?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/leader', data),
/**
* 获取分类统计
*/
category: (data: { category: string; startTime?: number; endTime?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/category', data)
}
}
export default apiClient
+153
View File
@@ -0,0 +1,153 @@
import { create } from 'zustand'
import type { Account } from '../types'
import { apiService } from '../services/api'
interface AccountStore {
accounts: Account[]
currentAccount: Account | null
loading: boolean
error: string | null
// Actions
fetchAccounts: () => Promise<void>
setCurrentAccount: (account: Account | null) => void
importAccount: (data: any) => Promise<void>
updateAccount: (data: any) => Promise<void>
deleteAccount: (accountId: number) => Promise<void>
setDefaultAccount: (accountId: number) => Promise<void>
fetchAccountDetail: (accountId: number) => Promise<Account>
fetchAccountBalance: (accountId: number) => Promise<{
availableBalance: string
positionBalance: string
totalBalance: string
positions: any[]
}>
}
export const useAccountStore = create<AccountStore>((set, get) => ({
accounts: [],
currentAccount: null,
loading: false,
error: null,
fetchAccounts: async () => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.list()
if (response.data.code === 0 && response.data.data) {
const accounts = response.data.data.list || []
set({ accounts, loading: false })
// 设置默认账户为当前账户
const defaultAccount = accounts.find((a: Account) => a.isDefault)
if (defaultAccount) {
set({ currentAccount: defaultAccount })
}
} else {
set({ error: response.data.msg || '获取账户列表失败', loading: false })
}
} catch (error: any) {
set({ error: error.message || '获取账户列表失败', loading: false })
}
},
setCurrentAccount: (account) => {
set({ currentAccount: account })
},
importAccount: async (data) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.import(data)
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
set({ error: response.data.msg || '导入账户失败', loading: false })
throw new Error(response.data.msg || '导入账户失败')
}
} catch (error: any) {
set({ error: error.message || '导入账户失败', loading: false })
throw error
}
},
updateAccount: async (data) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.update(data)
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
set({ error: response.data.msg || '更新账户失败', loading: false })
throw new Error(response.data.msg || '更新账户失败')
}
} catch (error: any) {
set({ error: error.message || '更新账户失败', loading: false })
throw error
}
},
deleteAccount: async (accountId) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.delete({ accountId })
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
set({ error: response.data.msg || '删除账户失败', loading: false })
throw new Error(response.data.msg || '删除账户失败')
}
} catch (error: any) {
set({ error: error.message || '删除账户失败', loading: false })
throw error
}
},
setDefaultAccount: async (accountId) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.setDefault({ accountId })
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
set({ error: response.data.msg || '设置默认账户失败', loading: false })
throw new Error(response.data.msg || '设置默认账户失败')
}
} catch (error: any) {
set({ error: error.message || '设置默认账户失败', loading: false })
throw error
}
},
fetchAccountDetail: async (accountId) => {
set({ loading: true, error: null })
try {
const response = await apiService.accounts.detail({ accountId })
if (response.data.code === 0 && response.data.data) {
set({ loading: false })
return response.data.data
} else {
const errorMsg = response.data.msg || '获取账户详情失败'
set({ error: errorMsg, loading: false })
throw new Error(errorMsg)
}
} catch (error: any) {
set({ error: error.message || '获取账户详情失败', loading: false })
throw error
}
},
fetchAccountBalance: async (accountId) => {
try {
const response = await apiService.accounts.balance({ accountId })
if (response.data.code === 0 && response.data.data) {
return response.data.data
} else {
throw new Error(response.data.msg || '获取账户余额失败')
}
} catch (error: any) {
throw error
}
}
}))
+32
View File
@@ -0,0 +1,32 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
/* 移动端适配 */
@media (max-width: 768px) {
body {
font-size: 14px;
}
}
/* 桌面端适配 */
@media (min-width: 769px) {
body {
font-size: 16px;
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* API 统一响应格式
*/
export interface ApiResponse<T> {
code: number
data: T | null
msg: string
}
/**
* 账户信息
*/
export interface Account {
id: number
walletAddress: string
accountName?: string
isDefault: boolean
apiKeyConfigured: boolean
apiSecretConfigured: boolean
apiPassphraseConfigured: boolean
balance?: string
totalOrders?: number
totalPnl?: string
}
/**
* 账户列表响应
*/
export interface AccountListResponse {
list: Account[]
total: number
}
/**
* 账户导入请求
*/
export interface AccountImportRequest {
privateKey: string
walletAddress: string
accountName?: string
apiKey?: string
apiSecret?: string
apiPassphrase?: string
isDefault?: boolean
}
/**
* 账户更新请求
*/
export interface AccountUpdateRequest {
accountId: number
accountName?: string
apiKey?: string
apiSecret?: string
apiPassphrase?: string
isDefault?: boolean
}
/**
* Leader 信息
*/
export interface Leader {
id: number
leaderAddress: string
leaderName?: string
accountId?: number
category?: string
enabled: boolean
copyRatio: string
totalOrders?: number
totalPnl?: string
}
/**
* Leader 列表响应
*/
export interface LeaderListResponse {
list: Leader[]
total: number
}
/**
* Leader 添加请求
*/
export interface LeaderAddRequest {
leaderAddress: string
leaderName?: string
accountId?: number
category?: string
enabled?: boolean
}
/**
* 跟单配置
*/
export interface CopyTradingConfig {
copyMode: 'RATIO' | 'FIXED'
copyRatio: string
fixedAmount?: string
maxOrderSize: string
minOrderSize: string
maxDailyLoss: string
maxDailyOrders: number
priceTolerance: string
delaySeconds: number
pollIntervalSeconds: number
useWebSocket: boolean
websocketReconnectInterval: number
websocketMaxRetries: number
enabled: boolean
}
/**
* 跟单订单
*/
export interface CopyOrder {
id: number
accountId: number
leaderId: number
leaderAddress: string
leaderName?: string
marketId: string
category: string
side: 'BUY' | 'SELL'
price: string
size: string
copyRatio: string
orderId?: string
status: string
filledSize: string
pnl?: string
createdAt: number
}
/**
* 订单列表响应
*/
export interface OrderListResponse {
list: CopyOrder[]
total: number
page: number
limit: number
}
/**
* 统计信息
*/
export interface Statistics {
totalOrders: number
totalPnl: string
winRate: string
avgPnl: string
maxProfit: string
maxLoss: string
}
+150
View File
@@ -0,0 +1,150 @@
import { ethers } from 'ethers'
/**
* 从私钥推导钱包地址
*/
export function getAddressFromPrivateKey(privateKey: string): string {
try {
// 移除 0x 前缀(如果有)
const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey
// 创建钱包
const wallet = new ethers.Wallet(`0x${cleanKey}`)
return wallet.address
} catch (error) {
throw new Error(`无效的私钥: ${error}`)
}
}
/**
* 从助记词推导钱包地址
* @param mnemonic 助记词(12或24个单词,用空格分隔)
* @param index 派生路径索引(默认0,使用第一个地址)
*/
export function getAddressFromMnemonic(mnemonic: string, index: number = 0): string {
try {
// 验证助记词格式
if (!isValidMnemonic(mnemonic)) {
throw new Error('助记词格式不正确')
}
// ethers.js v6: 如果 index 为 0,可以直接使用 Wallet.fromPhrase
// 它默认使用路径 m/44'/60'/0'/0/0
if (index === 0) {
const wallet = ethers.Wallet.fromPhrase(mnemonic.trim())
return wallet.address
}
// 对于其他索引,使用 HDNodeWallet
// 从助记词创建 Mnemonic 对象
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
// 使用标准 BIP44 路径直接创建钱包:m/44'/60'/0'/0/index
// ethers.js v6: HDNodeWallet.fromMnemonic 可以直接指定路径
const derivationPath = `m/44'/60'/0'/0/${index}`
const wallet = ethers.HDNodeWallet.fromMnemonic(mnemonicObj, derivationPath)
return wallet.address
} catch (error: any) {
// 如果直接指定路径失败,尝试分步派生
try {
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
// 先创建根节点(不指定路径)
const rootNode = ethers.HDNodeWallet.fromMnemonic(mnemonicObj)
// 使用相对路径(不以 m/ 开头)
const relativePath = `44'/60'/0'/0/${index}`
const wallet = rootNode.derivePath(relativePath)
return wallet.address
} catch (fallbackError: any) {
throw new Error(`无效的助记词: ${error.message || error}`)
}
}
}
/**
* 从助记词导出私钥
* @param mnemonic 助记词(12或24个单词,用空格分隔)
* @param index 派生路径索引(默认0,使用第一个地址)
*/
export function getPrivateKeyFromMnemonic(mnemonic: string, index: number = 0): string {
try {
// 验证助记词格式
if (!isValidMnemonic(mnemonic)) {
throw new Error('助记词格式不正确')
}
// ethers.js v6: 如果 index 为 0,可以直接使用 Wallet.fromPhrase
// 它默认使用路径 m/44'/60'/0'/0/0
if (index === 0) {
const wallet = ethers.Wallet.fromPhrase(mnemonic.trim())
return wallet.privateKey
}
// 对于其他索引,使用 HDNodeWallet
// 从助记词创建 Mnemonic 对象
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
// 使用标准 BIP44 路径直接创建钱包:m/44'/60'/0'/0/index
// ethers.js v6: HDNodeWallet.fromMnemonic 可以直接指定路径
const derivationPath = `m/44'/60'/0'/0/${index}`
const wallet = ethers.HDNodeWallet.fromMnemonic(mnemonicObj, derivationPath)
return wallet.privateKey
} catch (error: any) {
// 如果直接指定路径失败,尝试分步派生
try {
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
// 先创建根节点(不指定路径)
const rootNode = ethers.HDNodeWallet.fromMnemonic(mnemonicObj)
// 使用相对路径(不以 m/ 开头)
const relativePath = `44'/60'/0'/0/${index}`
const wallet = rootNode.derivePath(relativePath)
return wallet.privateKey
} catch (fallbackError: any) {
throw new Error(`无法从助记词导出私钥: ${error.message || error}`)
}
}
}
/**
* 验证助记词格式
*/
export function isValidMnemonic(mnemonic: string): boolean {
try {
if (!mnemonic || !mnemonic.trim()) {
return false
}
const words = mnemonic.trim().split(/\s+/)
// 助记词应该是 12 或 24 个单词
if (words.length !== 12 && words.length !== 24) {
return false
}
// 使用 ethers 验证助记词
ethers.Mnemonic.fromPhrase(mnemonic.trim())
return true
} catch {
return false
}
}
/**
* 验证钱包地址格式
*/
export function isValidWalletAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address)
}
/**
* 验证私钥格式
*/
export function isValidPrivateKey(privateKey: string): boolean {
try {
const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey
return /^[a-fA-F0-9]{64}$/.test(cleanKey)
} catch {
return false
}
}