feat: 账户设置后端 action、Safe 部署与设置引导弹窗复用
账户设置: - 后端: execute-setup-step API(步骤1 Safe 一键部署/步骤2 启用交易/步骤3 代币授权) - Safe 部署: Eip712Encoder SafeCreate/CreateProxy,RelayClientService.deploySafeViaBuilderRelayer - BuilderRelayerApi: SignatureParams 增加 paymentToken/payment/paymentReceiver,TransactionRequest.nonce 可选 - 导入成功有未完成步骤时先弹设置引导再关导入弹窗;设置弹窗复用 AccountSetupStatusBlock(embedded) - AccountSetupStatusBlock: 5s 轮询、embedded 模式、onAllCompleted、移除全部完成 Tag、授权信息始终展示且右对齐额度列 - 多语言: 步骤1/2/3 文案与 actionSuccess/actionFailed CryptoTail: - SpreadDirection/SpreadMode 枚举及 Converter,V38 迁移,策略列表与 api/types 调整 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { apiService } from '../services/api'
|
||||
import type { ProxyOption } from '../types'
|
||||
import AccountSetupGuideModal from './AccountSetupGuideModal'
|
||||
|
||||
type ImportType = 'privateKey' | 'mnemonic'
|
||||
|
||||
@@ -41,6 +42,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
|
||||
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
|
||||
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
|
||||
const [setupModalVisible, setSetupModalVisible] = useState<boolean>(false)
|
||||
const [setupStatus, setSetupStatus] = useState<any>(null)
|
||||
const [importedAccountId, setImportedAccountId] = useState<number | undefined>(undefined)
|
||||
|
||||
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
|
||||
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -254,11 +258,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
|
||||
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
|
||||
const accountsResponse = await apiService.accounts.list()
|
||||
let accountId: number | undefined = undefined
|
||||
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
|
||||
const newAccounts = accountsResponse.data.data.list || []
|
||||
const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress)
|
||||
if (newAccount && onSuccess) {
|
||||
onSuccess(newAccount.id)
|
||||
if (newAccount) {
|
||||
accountId = newAccount.id
|
||||
setImportedAccountId(accountId)
|
||||
|
||||
// 检查账户设置状态
|
||||
let willShowSetupModal = false
|
||||
try {
|
||||
const setupResponse = await apiService.accounts.checkSetupStatus(accountId)
|
||||
if (setupResponse.data.code === 0 && setupResponse.data.data) {
|
||||
const status = setupResponse.data.data
|
||||
setSetupStatus(status)
|
||||
const hasIncomplete = !status.proxyDeployed || !status.tradingEnabled || !status.tokensApproved
|
||||
if (hasIncomplete) {
|
||||
setSetupModalVisible(true)
|
||||
willShowSetupModal = true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查账户设置状态失败:', error)
|
||||
}
|
||||
// 未展示设置弹窗时才调用 onSuccess,避免父组件关闭导入弹窗导致设置弹窗被卸载
|
||||
if (!willShowSetupModal && onSuccess) {
|
||||
onSuccess(accountId)
|
||||
}
|
||||
} else if (onSuccess) {
|
||||
onSuccess(0)
|
||||
}
|
||||
@@ -551,6 +578,37 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* 账户设置引导弹窗 */}
|
||||
<AccountSetupGuideModal
|
||||
visible={setupModalVisible}
|
||||
setupStatus={setupStatus}
|
||||
accountId={importedAccountId}
|
||||
onClose={() => {
|
||||
setSetupModalVisible(false)
|
||||
onSuccess?.(importedAccountId ?? 0)
|
||||
}}
|
||||
onComplete={async () => {
|
||||
// 刷新设置状态
|
||||
if (importedAccountId) {
|
||||
try {
|
||||
const setupResponse = await apiService.accounts.checkSetupStatus(importedAccountId)
|
||||
if (setupResponse.data.code === 0 && setupResponse.data.data) {
|
||||
setSetupStatus(setupResponse.data.data)
|
||||
const status = setupResponse.data.data
|
||||
// 如果所有步骤都完成了,关闭弹窗并通知父组件
|
||||
if (status.proxyDeployed && status.tradingEnabled && status.tokensApproved) {
|
||||
setSetupModalVisible(false)
|
||||
message.success(t('accountSetup.allCompleted.title'))
|
||||
onSuccess?.(importedAccountId ?? 0)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新设置状态失败:', error)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Modal, Alert, Space, Button, Typography } from 'antd'
|
||||
import { CheckCircleOutlined, ExclamationCircleOutlined, WalletOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import AccountSetupStatusBlock from './AccountSetupStatusBlock'
|
||||
import type { SetupStatus } from './AccountSetupStatusBlock'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface AccountSetupGuideModalProps {
|
||||
visible: boolean
|
||||
setupStatus: SetupStatus | null
|
||||
accountId?: number
|
||||
onClose: () => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
const AccountSetupGuideModal: React.FC<AccountSetupGuideModalProps> = ({
|
||||
visible,
|
||||
setupStatus: _initialStatus,
|
||||
accountId,
|
||||
onClose,
|
||||
onComplete
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [allCompleted, setAllCompleted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) setAllCompleted(false)
|
||||
}, [visible, accountId])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<WalletOutlined style={{ fontSize: '20px', color: '#1890ff' }} />
|
||||
<span>{t('accountSetup.title')}</span>
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
{allCompleted ? (
|
||||
<Button type="primary" onClick={onClose} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.later')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width={isMobile ? '95%' : 680}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
destroyOnClose
|
||||
maskClosable={allCompleted}
|
||||
closable
|
||||
>
|
||||
<div style={{ padding: isMobile ? '16px 0' : '24px 0' }}>
|
||||
{allCompleted ? (
|
||||
<Alert
|
||||
message={t('accountSetup.allCompleted.title')}
|
||||
description={t('accountSetup.allCompleted.description')}
|
||||
type="success"
|
||||
icon={<CheckCircleOutlined />}
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
message={t('accountSetup.incomplete.title')}
|
||||
description={t('accountSetup.incomplete.description')}
|
||||
type="warning"
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{accountId != null && accountId > 0 ? (
|
||||
<AccountSetupStatusBlock
|
||||
accountId={accountId}
|
||||
embedded
|
||||
showApprovalDetails
|
||||
onAllCompleted={() => setAllCompleted(true)}
|
||||
onRefresh={onComplete}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary">{t('accountSetup.error.description')}</Text>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24, padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('accountSetup.help')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSetupGuideModal
|
||||
@@ -0,0 +1,307 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Card, Steps, Button, Space, Tag, Spin, Typography, message } from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
WalletOutlined,
|
||||
KeyOutlined,
|
||||
SafetyOutlined,
|
||||
LinkOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { apiService } from '../services/api'
|
||||
|
||||
const { Paragraph, Text } = Typography
|
||||
|
||||
export interface SetupStatus {
|
||||
proxyDeployed: boolean
|
||||
tradingEnabled: boolean
|
||||
tokensApproved: boolean
|
||||
approvalDetails?: Record<string, string>
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface AccountSetupStatusBlockProps {
|
||||
accountId: number
|
||||
onRefresh?: () => void
|
||||
onAllCompleted?: () => void
|
||||
size?: 'small' | 'default'
|
||||
showApprovalDetails?: boolean
|
||||
/** 嵌入模式:不渲染 Card,仅渲染步骤与授权详情(供弹窗等复用) */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
/** 步骤 key 与步骤编号对应 */
|
||||
const STEP_KEYS = ['step1', 'step2', 'step3'] as const
|
||||
const stepKeyToNumber = (key: string): number =>
|
||||
STEP_KEYS.indexOf(key as typeof STEP_KEYS[number]) + 1
|
||||
|
||||
const AccountSetupStatusBlock: React.FC<AccountSetupStatusBlockProps> = ({
|
||||
accountId,
|
||||
onRefresh,
|
||||
onAllCompleted,
|
||||
size = 'default',
|
||||
showApprovalDetails = true,
|
||||
embedded = false
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (accountId <= 0) return
|
||||
try {
|
||||
const response = await apiService.accounts.checkSetupStatus(accountId)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setSetupStatus(response.data.data)
|
||||
} else {
|
||||
setSetupStatus(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取账户设置状态失败:', error)
|
||||
setSetupStatus(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchStatus()
|
||||
}, [accountId])
|
||||
|
||||
// 每 5 秒轮询最新状态(首次加载完成后且存在未完成步骤时轮询,全部完成后停止)
|
||||
useEffect(() => {
|
||||
if (accountId <= 0 || setupStatus == null) return
|
||||
const allCompleted =
|
||||
setupStatus.proxyDeployed &&
|
||||
setupStatus.tradingEnabled &&
|
||||
setupStatus.tokensApproved
|
||||
if (allCompleted) return
|
||||
const timer = setInterval(() => {
|
||||
fetchStatus()
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [accountId, setupStatus?.proxyDeployed, setupStatus?.tradingEnabled, setupStatus?.tokensApproved])
|
||||
|
||||
// 全部完成时通知父组件(供弹窗等关闭或更新用)
|
||||
const allCompleted =
|
||||
setupStatus != null &&
|
||||
setupStatus.proxyDeployed &&
|
||||
setupStatus.tradingEnabled &&
|
||||
setupStatus.tokensApproved
|
||||
useEffect(() => {
|
||||
if (allCompleted) onAllCompleted?.()
|
||||
}, [allCompleted, onAllCompleted])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await fetchStatus()
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
const handleStepAction = async (key: string) => {
|
||||
const stepNum = stepKeyToNumber(key)
|
||||
if (stepNum < 1) return
|
||||
setActionLoading(key)
|
||||
try {
|
||||
const response = await apiService.accounts.executeSetupStep(accountId, stepNum)
|
||||
const res = response.data
|
||||
if (res.code !== 0) {
|
||||
message.error(res.msg || t('accountSetup.actionFailed'))
|
||||
return
|
||||
}
|
||||
const data = res.data
|
||||
if (data?.redirectUrl) {
|
||||
window.open(data.redirectUrl, '_blank')
|
||||
}
|
||||
if (data?.success !== false) {
|
||||
await fetchStatus()
|
||||
onRefresh?.()
|
||||
if (data?.transactionHash) {
|
||||
message.success(t('accountSetup.actionSuccess'))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(t('accountSetup.actionFailed'))
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !setupStatus) {
|
||||
const loadingContent = (
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
)
|
||||
return embedded ? <div>{loadingContent}</div> : (
|
||||
<Card title={t('accountSetup.title')} size={size}>{loadingContent}</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setupStatus) {
|
||||
const errorContent = (
|
||||
<>
|
||||
<Text type="secondary">{t('accountSetup.error.description')}</Text>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRefresh}>
|
||||
{t('accountSetup.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return embedded ? <div>{errorContent}</div> : (
|
||||
<Card title={t('accountSetup.title')} size={size}>{errorContent}</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
key: 'step1',
|
||||
title: t('accountSetup.step1.title'),
|
||||
description: t('accountSetup.step1.description'),
|
||||
icon: <WalletOutlined />,
|
||||
completed: setupStatus.proxyDeployed,
|
||||
actionLabel: t('accountSetup.step1.action')
|
||||
},
|
||||
{
|
||||
key: 'step2',
|
||||
title: t('accountSetup.step2.title'),
|
||||
description: t('accountSetup.step2.description'),
|
||||
icon: <KeyOutlined />,
|
||||
completed: setupStatus.tradingEnabled,
|
||||
actionLabel: t('accountSetup.step2.action')
|
||||
},
|
||||
{
|
||||
key: 'step3',
|
||||
title: t('accountSetup.step3.title'),
|
||||
description: t('accountSetup.step3.description'),
|
||||
icon: <SafetyOutlined />,
|
||||
completed: setupStatus.tokensApproved,
|
||||
actionLabel: t('accountSetup.step3.action')
|
||||
}
|
||||
]
|
||||
|
||||
const stepsContent = (
|
||||
<>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={steps.findIndex(s => !s.completed)}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Steps.Step
|
||||
key={step.key}
|
||||
title={
|
||||
<Space>
|
||||
<span>{step.title}</span>
|
||||
{step.completed ? (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||
{t('accountSetup.completed')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="warning" icon={<CloseCircleOutlined />}>
|
||||
{t('accountSetup.pending')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Paragraph style={{ marginBottom: 8, fontSize: 14, color: '#666' }}>
|
||||
{step.description}
|
||||
</Paragraph>
|
||||
{!step.completed && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => handleStepAction(step.key)}
|
||||
loading={actionLoading === step.key}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{step.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
icon={step.icon}
|
||||
status={step.completed ? 'finish' : 'process'}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
{showApprovalDetails && setupStatus.approvalDetails && Object.keys(setupStatus.approvalDetails).length > 0 && (
|
||||
<div style={{ marginTop: 16, padding: '12px', background: '#fafafa', borderRadius: 4 }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>{t('accountSetup.approvalDetails.title')}</Text>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{Object.entries(setupStatus.approvalDetails).map(([contract, allowance]) => {
|
||||
const isUnlimited = allowance === 'unlimited'
|
||||
const isApproved = isUnlimited || parseFloat(allowance) > 0
|
||||
const displayText = isUnlimited
|
||||
? t('accountSetup.approvalDetails.unlimited')
|
||||
: isApproved
|
||||
? `${parseFloat(allowance).toFixed(2)} USDC`
|
||||
: t('accountSetup.approvalDetails.notApproved')
|
||||
return (
|
||||
<div
|
||||
key={contract}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: 13,
|
||||
minHeight: 24
|
||||
}}
|
||||
>
|
||||
<span>{t(`accountSetup.approvalDetails.${contract}`) || contract}</span>
|
||||
<span style={{ minWidth: 100, textAlign: 'right' }}>{displayText}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupStatus.error && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="danger">{setupStatus.error}</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return <div style={{ position: 'relative' }}>{stepsContent}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('accountSetup.title')}
|
||||
size={size}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
>
|
||||
{t('accountSetup.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{stepsContent}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSetupStatusBlock
|
||||
Reference in New Issue
Block a user