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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user