feat: 赎回批量执行与仓位页体验优化

- 赎回:同一账户多市场合并为一笔交易,减少 Relayer 调用次数
- 仓位页:可赎回统计静默刷新,赎回按钮不再常驻 loading
- 账户导入表单、API、多语言等相关改动

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-14 00:20:41 +08:00
co-authored by Cursor
parent bd323fca35
commit cf2c8a611c
10 changed files with 597 additions and 53 deletions
+211 -45
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd'
import { QuestionCircleOutlined } from '@ant-design/icons'
import { useState, useEffect } from 'react'
import { Form, Input, Button, Radio, Space, Alert, Card, Spin, message } from 'antd'
import { CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useAccountStore } from '../store/accountStore'
import {
@@ -9,12 +9,14 @@ import {
getPrivateKeyFromMnemonic,
isValidWalletAddress,
isValidPrivateKey,
isValidMnemonic
isValidMnemonic,
formatUSDC
} from '../utils'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import type { ProxyOption } from '../types'
type ImportType = 'privateKey' | 'mnemonic'
type WalletType = 'magic' | 'safe'
interface AccountImportFormProps {
form: any
@@ -35,9 +37,12 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
const isMobile = useMediaQuery({ maxWidth: 768 })
const { importAccount, loading } = useAccountStore()
const [importType, setImportType] = useState<ImportType>('privateKey')
const [walletType, setWalletType] = useState<WalletType>('safe')
const [derivedAddress, setDerivedAddress] = useState<string>('')
const [addressError, setAddressError] = useState<string>('')
const [proxyOptions, setProxyOptions] = useState<ProxyOption[]>([])
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
// 当私钥输入时,自动推导地址
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -45,6 +50,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!privateKey) {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -52,6 +60,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!isValidPrivateKey(privateKey)) {
setAddressError(t('accountImport.privateKeyInvalid'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -62,9 +73,17 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
// 延迟获取代理选项(避免频繁请求)
setTimeout(() => {
fetchProxyOptions(address, privateKey, null)
}, 500)
} catch (error: any) {
setAddressError(error.message || t('accountImport.addressError'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
}
}
@@ -74,6 +93,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!mnemonic) {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -81,6 +103,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!isValidMnemonic(mnemonic)) {
setAddressError(t('accountImport.mnemonicInvalid'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -91,14 +116,85 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
// 延迟获取代理选项(避免频繁请求)
setTimeout(() => {
fetchProxyOptions(address, null, mnemonic)
}, 500)
} catch (error: any) {
setAddressError(error.message || t('accountImport.addressErrorMnemonic'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
}
}
// 获取代理地址选项
const fetchProxyOptions = async (walletAddress: string, privateKey: string | null, mnemonic: string | null) => {
if (!walletAddress || (!privateKey && !mnemonic)) {
return
}
setLoadingProxyOptions(true)
try {
const response = await apiService.accounts.checkProxyOptions({
walletAddress,
privateKey: privateKey || undefined,
mnemonic: mnemonic || undefined
})
if (response.data.code === 0 && response.data.data) {
const options = response.data.data.options || []
setProxyOptions(options)
// 如果有选项,进入选择步骤
if (options.length > 0) {
setStep('select')
// 如果有资产,默认选择第一个有资产的选项
const hasAssetsOption = options.find(opt => opt.hasAssets)
if (hasAssetsOption) {
setSelectedProxyType(hasAssetsOption.walletType)
} else {
// 否则选择第一个选项
setSelectedProxyType(options[0].walletType)
}
} else {
setStep('input')
message.warning(t('accountImport.proxyOption.error') || '未获取到代理地址选项')
}
} else {
setProxyOptions([])
setStep('input')
message.error(response.data.msg || '获取代理地址选项失败')
}
} catch (error: any) {
setProxyOptions([])
setStep('input')
message.error(error.message || '获取代理地址选项失败')
} finally {
setLoadingProxyOptions(false)
}
}
// 切换导入方式时重置状态
useEffect(() => {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importType])
const handleSubmit = async (values: any) => {
try {
// 如果还在输入步骤,需要先选择代理地址
if (step === 'input' || !selectedProxyType) {
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
}
let privateKey: string
let walletAddress: string
@@ -124,14 +220,11 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 如果用户手动输入了地址,验证是否与推导的地址一致
if (values.walletAddress) {
if (values.walletAddress !== derivedAddressFromMnemonic) {
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
walletAddress = derivedAddressFromMnemonic
} else {
// 地址匹配,使用用户输入的地址
walletAddress = values.walletAddress
}
} else {
// 如果用户没有输入地址,使用推导的地址
walletAddress = derivedAddressFromMnemonic
}
}
@@ -145,14 +238,13 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
privateKey: privateKey,
walletAddress: walletAddress,
accountName: values.accountName,
walletType: walletType
walletType: selectedProxyType
})
// 等待store更新
await new Promise(resolve => setTimeout(resolve, 100))
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
const { apiService } = await import('../services/api')
const accountsResponse = await apiService.accounts.list()
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
const newAccounts = accountsResponse.data.data.list || []
@@ -160,11 +252,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (newAccount && onSuccess) {
onSuccess(newAccount.id)
} else if (onSuccess) {
// 如果找不到账户,仍然调用onSuccess(可能在其他地方处理)
onSuccess(0)
}
} else if (onSuccess) {
// API调用失败,仍然调用onSuccess
onSuccess(0)
}
@@ -197,41 +287,12 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
value={importType}
onChange={(e) => {
setImportType(e.target.value)
setDerivedAddress('')
setAddressError('')
form.setFieldsValue({ walletAddress: '' })
}}
>
<Radio value="privateKey">{t('accountImport.privateKey')}</Radio>
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label={
<span>
{t('accountImport.walletType')}{' '}
<Tooltip
title={t('accountImport.walletTypeHelp')}
overlayInnerStyle={{ whiteSpace: 'pre-line', maxWidth: '300px' }}
>
<QuestionCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
>
<Radio.Group
value={walletType}
onChange={(e) => setWalletType(e.target.value)}
>
<Radio value="safe">
{t('accountImport.walletTypeSafe')}
</Radio>
<Radio value="magic">
{t('accountImport.walletTypeMagic')}
</Radio>
</Radio.Group>
</Form.Item>
{importType === 'privateKey' ? (
<>
@@ -250,13 +311,14 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
}
]}
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
help={addressError || ''}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={3}
placeholder={t('accountImport.privateKeyPlaceholder')}
onChange={handlePrivateKeyChange}
disabled={loadingProxyOptions}
/>
</Form.Item>
@@ -282,6 +344,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Input
placeholder={t('accountImport.walletAddressPlaceholder')}
readOnly={!!derivedAddress}
disabled={loadingProxyOptions}
/>
</Form.Item>
</>
@@ -302,13 +365,14 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
}
]}
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
help={addressError || ''}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={4}
placeholder={t('accountImport.mnemonicPlaceholder')}
onChange={handleMnemonicChange}
disabled={loadingProxyOptions}
/>
</Form.Item>
@@ -334,11 +398,113 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Input
placeholder={t('accountImport.walletAddressPlaceholder')}
readOnly={!!derivedAddress}
disabled={loadingProxyOptions}
/>
</Form.Item>
</>
)}
{/* 请求代理地址时的 loading 提示 */}
{loadingProxyOptions && step === 'input' && (
<Form.Item>
<Alert
message={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Spin size="small" />
<span>{t('accountImport.loadingProxyOptions')}</span>
</div>
}
type="info"
showIcon={false}
style={{ marginBottom: '16px' }}
/>
</Form.Item>
)}
{/* 代理地址选项选择 */}
{step === 'select' && (
<Form.Item
label={t('accountImport.selectProxyOption')}
required
rules={[
{
validator: () => {
if (!selectedProxyType) {
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
}
return Promise.resolve()
}
}
]}
>
{loadingProxyOptions ? (
<Spin tip={t('accountImport.loadingProxyOptions')} />
) : (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{proxyOptions.map((option) => (
<Card
key={option.walletType}
hoverable
onClick={() => setSelectedProxyType(option.walletType)}
style={{
cursor: 'pointer',
border: selectedProxyType === option.walletType ? '2px solid #1890ff' : '1px solid #d9d9d9',
backgroundColor: selectedProxyType === option.walletType ? '#e6f7ff' : '#fff'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}>
<Radio checked={selectedProxyType === option.walletType} />
<strong style={{ marginLeft: '8px' }}>
{t(`accountImport.proxyOption.${option.walletType}.title`)}
</strong>
{option.hasAssets && (
<span style={{ marginLeft: '8px', color: '#52c41a' }}>
<CheckCircleOutlined /> {t('accountImport.proxyOption.hasAssets')}
</span>
)}
{option.error && (
<span style={{ marginLeft: '8px', color: '#ff4d4f' }}>
<ExclamationCircleOutlined /> {t('accountImport.proxyOption.error')}
</span>
)}
</div>
<div style={{ marginLeft: '24px', color: '#666', fontSize: '14px', marginBottom: '8px' }}>
{t(`accountImport.proxyOption.${option.walletType}.description`)}
</div>
<div style={{ marginLeft: '24px', fontSize: '12px', color: '#999', marginBottom: '4px' }}>
{t('accountImport.proxyOption.proxyAddress')}: {option.proxyAddress || '-'}
</div>
{option.error ? (
<div style={{ marginLeft: '24px', color: '#ff4d4f', fontSize: '12px' }}>
{option.error}
</div>
) : (
<div style={{ marginLeft: '24px', display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
<span>
{t('accountImport.proxyOption.availableBalance')}: {formatUSDC(option.availableBalance)} USDC
</span>
<span>
{t('accountImport.proxyOption.positionBalance')}: {formatUSDC(option.positionBalance)} USDC
</span>
<span>
{t('accountImport.proxyOption.totalBalance')}: {formatUSDC(option.totalBalance)} USDC
</span>
<span>
{t('accountImport.proxyOption.positionCount')}: {option.positionCount}
</span>
</div>
)}
</div>
</div>
</Card>
))}
</Space>
)}
</Form.Item>
)}
<Form.Item
label={t('accountImport.accountName')}
name="accountName"
@@ -352,6 +518,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
type="primary"
htmlType="submit"
loading={loading}
disabled={step !== 'select' || !selectedProxyType || loadingProxyOptions}
size={isMobile ? 'middle' : 'large'}
>
{t('accountImport.importAccount')}
@@ -369,4 +536,3 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
export default AccountImportForm