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:
WrBug
2026-02-18 03:01:35 +08:00
co-authored by Cursor
parent 4e1bb0bbcf
commit de89175c8e
28 changed files with 1619 additions and 188 deletions
+60 -2
View File
@@ -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
+57 -7
View File
@@ -13,6 +13,7 @@
"success": "Success",
"failed": "Failed",
"confirm": "Confirm",
"later": "Later",
"submit": "Submit",
"reset": "Reset",
"close": "Close",
@@ -237,6 +238,51 @@
"select": "Select this proxy address"
}
},
"accountSetup": {
"title": "Account Setup Check",
"completed": "Completed",
"pending": "Pending",
"refresh": "Refresh Status",
"allCompleted": {
"title": "All Setup Completed",
"description": "Your account is ready to use all features."
},
"incomplete": {
"title": "Account Setup Incomplete",
"description": "Please complete the following setup steps to ensure your account works properly."
},
"step1": {
"title": "Deploy Proxy Wallet",
"description": "Proxy wallet is required for trading on Polymarket. Safe accounts can deploy with one click; Magic accounts will be redirected to Polymarket.",
"action": "Deploy Proxy Wallet"
},
"step2": {
"title": "Enable Trading",
"description": "API credentials are required for trading. Click the button below to let the system automatically obtain and save the API Key.",
"action": "Enable Trading (One-Click)"
},
"step3": {
"title": "Approve Tokens",
"description": "You need to authorize the proxy wallet to use your USDC tokens. Click the button below to complete token approval automatically.",
"action": "Approve Tokens (One-Click)"
},
"approvalDetails": {
"title": "Token Approval Details",
"CTF_CONTRACT": "CTF Contract",
"CTF_EXCHANGE": "CTF Exchange",
"NEG_RISK_EXCHANGE": "Neg Risk Exchange",
"NEG_RISK_ADAPTER": "Neg Risk Adapter",
"notApproved": "Not Approved",
"unlimited": "Unlimited"
},
"error": {
"title": "Check Failed",
"description": "Unable to check account setup status, please try again later."
},
"actionSuccess": "Operation successful",
"actionFailed": "Operation failed, please try again later",
"help": "Tip: After completing the setup, click the \"Refresh Status\" button to update the check results. If all steps are completed, you can use account features normally."
},
"leader": {
"title": "Leader Management",
"leaderName": "Leader Name",
@@ -1452,13 +1498,17 @@
"update": "Update",
"timeWindowStartLEEnd": "Window start must not be greater than end",
"timeWindowExceed": "Time window must not exceed period length",
"minSpreadMode": "Min spread",
"minSpreadModeTip": "Whether to place an order is based on the spread between open and close in the current period. Auto: system computes a suggested spread from the last 20 klines (updated each period); Fixed: you enter a value (e.g. 30), order only when spread ≥ that value; None: no spread check, order when price is in range.",
"minSpreadModeNone": "None",
"minSpreadModeFixed": "Fixed",
"minSpreadModeAuto": "Auto",
"minSpreadValue": "Min spread value (USDC)",
"minSpreadValuePlaceholder": "e.g. 30"
"spreadMode": "Spread",
"spreadModeTip": "Whether to place an order is based on the spread between open and close in the current period. Auto: system computes a suggested spread from the last 20 klines (updated each period); Fixed: you enter a value (e.g. 30); None: no spread check, order when price is in range.",
"spreadModeNone": "None",
"spreadModeFixed": "Fixed",
"spreadModeAuto": "Auto",
"spreadValue": "Spread value (USDC)",
"spreadValuePlaceholder": "e.g. 30",
"spreadDirection": "Spread Direction",
"spreadDirectionTip": "Min spread: trigger when spread ≥ configured value, buy price fixed at 0.99; Max spread: trigger when spread ≤ configured value, buy price = trigger price + 0.02 (suitable for low-price buying).",
"spreadDirectionMin": "Min Spread",
"spreadDirectionMax": "Max Spread"
},
"redeemRequiredModal": {
"title": "Configure Auto Redeem First",
+57 -7
View File
@@ -4,6 +4,7 @@
"save": "保存",
"cancel": "取消",
"confirm": "确定",
"later": "稍后",
"delete": "删除",
"edit": "编辑",
"viewDetail": "查看详情",
@@ -236,6 +237,51 @@
"select": "选择此代理地址"
}
},
"accountSetup": {
"title": "账户设置检查",
"completed": "已完成",
"pending": "待完成",
"refresh": "刷新状态",
"allCompleted": {
"title": "所有设置已完成",
"description": "您的账户已准备就绪,可以开始使用所有功能。"
},
"incomplete": {
"title": "账户设置未完成",
"description": "请完成以下设置步骤,以确保账户可以正常使用。"
},
"step1": {
"title": "部署代理钱包",
"description": "代理钱包是您在 Polymarket 上进行交易的必要组件。Safe 账户可点击下方按钮由系统一键部署;Magic 账户将跳转至 Polymarket 完成。",
"action": "部署代理钱包"
},
"step2": {
"title": "启用交易",
"description": "需要配置 API 凭证才能进行交易。点击下方按钮由系统自动获取并保存 API Key。",
"action": "一键启用交易"
},
"step3": {
"title": "批准代币",
"description": "需要授权代理钱包使用您的 USDC 代币。点击下方按钮由系统自动完成代币授权。",
"action": "一键批准代币"
},
"approvalDetails": {
"title": "代币授权详情",
"CTF_CONTRACT": "CTF 合约",
"CTF_EXCHANGE": "CTF 交易所",
"NEG_RISK_EXCHANGE": "负风险交易所",
"NEG_RISK_ADAPTER": "负风险适配器",
"notApproved": "未授权",
"unlimited": "无限"
},
"error": {
"title": "检查失败",
"description": "无法检查账户设置状态,请稍后重试。"
},
"actionSuccess": "操作成功",
"actionFailed": "操作失败,请稍后重试",
"help": "提示:完成设置后,点击「刷新状态」按钮更新检查结果。如果所有步骤都已完成,您可以正常使用账户功能。"
},
"leader": {
"title": "Leader 管理",
"leaderName": "Leader 名称",
@@ -1451,13 +1497,17 @@
"update": "更新",
"timeWindowStartLEEnd": "时间区间开始不能大于结束",
"timeWindowExceed": "时间区间不能超过周期长度",
"minSpreadMode": "最小价差",
"minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。",
"minSpreadModeNone": "无",
"minSpreadModeFixed": "固定",
"minSpreadModeAuto": "自动",
"minSpreadValue": "最小价差数值 (USDC)",
"minSpreadValuePlaceholder": "如 30"
"spreadMode": "价差",
"spreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30);无:不校验价差,满足价格区间即下单。",
"spreadModeNone": "无",
"spreadModeFixed": "固定",
"spreadModeAuto": "自动",
"spreadValue": "价差数值 (USDC)",
"spreadValuePlaceholder": "如 30",
"spreadDirection": "价差方向",
"spreadDirectionTip": "最小价差:价差 ≥ 配置值时触发,买入价固定 0.99;最大价差:价差 ≤ 配置值时触发,买入价 = 触发价 + 0.02(适合低价买入)。",
"spreadDirectionMin": "最小价差",
"spreadDirectionMax": "最大价差"
},
"redeemRequiredModal": {
"title": "请先配置自动赎回",
+57 -7
View File
@@ -13,6 +13,7 @@
"success": "成功",
"failed": "失敗",
"confirm": "確認",
"later": "稍後",
"submit": "提交",
"reset": "重置",
"close": "關閉",
@@ -237,6 +238,51 @@
"select": "選擇此代理地址"
}
},
"accountSetup": {
"title": "帳戶設置檢查",
"completed": "已完成",
"pending": "待完成",
"refresh": "刷新狀態",
"allCompleted": {
"title": "所有設置已完成",
"description": "您的帳戶已準備就緒,可以開始使用所有功能。"
},
"incomplete": {
"title": "帳戶設置未完成",
"description": "請完成以下設置步驟,以確保帳戶可以正常使用。"
},
"step1": {
"title": "部署代理錢包",
"description": "代理錢包是您在 Polymarket 上進行交易的必要組件。Safe 帳戶可點擊下方按鈕由系統一鍵部署;Magic 帳戶將跳轉至 Polymarket 完成。",
"action": "部署代理錢包"
},
"step2": {
"title": "啟用交易",
"description": "需要配置 API 憑證才能進行交易。點擊下方按鈕由系統自動獲取並保存 API Key。",
"action": "一鍵啟用交易"
},
"step3": {
"title": "批准代幣",
"description": "需要授權代理錢包使用您的 USDC 代幣。點擊下方按鈕由系統自動完成代幣授權。",
"action": "一鍵批准代幣"
},
"approvalDetails": {
"title": "代幣授權詳情",
"CTF_CONTRACT": "CTF 合約",
"CTF_EXCHANGE": "CTF 交易所",
"NEG_RISK_EXCHANGE": "負風險交易所",
"NEG_RISK_ADAPTER": "負風險適配器",
"notApproved": "未授權",
"unlimited": "無限"
},
"error": {
"title": "檢查失敗",
"description": "無法檢查帳戶設置狀態,請稍後重試。"
},
"actionSuccess": "操作成功",
"actionFailed": "操作失敗,請稍後重試",
"help": "提示:完成設置後,點擊「刷新狀態」按鈕更新檢查結果。如果所有步驟都已完成,您可以正常使用帳戶功能。"
},
"leader": {
"title": "Leader 管理",
"leaderName": "Leader 名稱",
@@ -1452,13 +1498,17 @@
"update": "更新",
"timeWindowStartLEEnd": "時間區間開始不能大於結束",
"timeWindowExceed": "時間區間不能超過週期長度",
"minSpreadMode": "最小價差",
"minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。",
"minSpreadModeNone": "無",
"minSpreadModeFixed": "固定",
"minSpreadModeAuto": "自動",
"minSpreadValue": "最小價差數值 (USDC)",
"minSpreadValuePlaceholder": "如 30"
"spreadMode": "價差",
"spreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30);無:不校驗價差,滿足價格區間即下單。",
"spreadModeNone": "無",
"spreadModeFixed": "固定",
"spreadModeAuto": "自動",
"spreadValue": "價差數值 (USDC)",
"spreadValuePlaceholder": "如 30",
"spreadDirection": "價差方向",
"spreadDirectionTip": "最小價差:價差 ≥ 配置值時觸發,買入價固定 0.99;最大價差:價差 ≤ 配置值時觸發,買入價 = 觸發價 + 0.02(適合低價買入)。",
"spreadDirectionMin": "最小價差",
"spreadDirectionMax": "最大價差"
},
"redeemRequiredModal": {
"title": "請先配置自動贖回",
+18 -43
View File
@@ -7,6 +7,7 @@ import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
const { Title } = Typography
@@ -150,10 +151,7 @@ const AccountDetail: React.FC = () => {
onClick={() => {
setEditModalVisible(true)
editForm.setFieldsValue({
accountName: account.accountName || '',
apiKey: '', // 不显示实际值,留空表示不修改
apiSecret: '', // 不显示实际值,留空表示不修改
apiPassphrase: '' // 不显示实际值,留空表示不修改
accountName: account.accountName || ''
})
}}
size={isMobile ? 'middle' : 'large'}
@@ -214,46 +212,23 @@ const AccountDetail: React.FC = () => {
</Card>
<Divider />
<Card
title={t('account.apiCredentials')}
style={{
{accountId && (
<div 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={t('account.apiKey')}>
<Tag color={account.apiKeyConfigured ? 'success' : 'default'}>
{account.apiKeyConfigured ? t('account.configured') : t('account.notConfigured')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('account.apiSecret')}>
<Tag color={account.apiSecretConfigured ? 'success' : 'default'}>
{account.apiSecretConfigured ? t('account.configured') : t('account.notConfigured')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('account.apiPassphrase')}>
<Tag color={account.apiPassphraseConfigured ? 'success' : 'default'}>
{account.apiPassphraseConfigured ? t('account.configured') : t('account.notConfigured')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('account.apiCredentials')}>
{account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? (
<Tag color="success">{t('account.fullConfig')}</Tag>
) : (
<Tag color="warning">{t('account.partialConfig')}</Tag>
)}
</Descriptions.Item>
</Descriptions>
</Card>
margin: isMobile ? '0 -8px' : '0'
}}>
<AccountSetupStatusBlock
accountId={Number(accountId)}
onRefresh={() => { loadAccountDetail(); loadBalance() }}
size={isMobile ? 'small' : 'default'}
showApprovalDetails={true}
/>
</div>
)}
<Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} />
{(account.totalOrders !== undefined || account.totalPnl !== undefined ||
account.activeOrders !== undefined ||
account.completedOrders !== undefined || account.positionCount !== undefined) ? (
+10 -33
View File
@@ -7,6 +7,7 @@ import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
import AccountImportForm from '../components/AccountImportForm'
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
const { Title } = Typography
@@ -204,10 +205,7 @@ const AccountList: React.FC = () => {
setEditAccount(accountDetail)
editForm.setFieldsValue({
accountName: accountDetail.accountName || '',
apiKey: '', // 不显示实际值,留空表示不修改
apiSecret: '', // 不显示实际值,留空表示不修改
apiPassphrase: '' // 不显示实际值,留空表示不修改
accountName: accountDetail.accountName || ''
})
} catch (error: any) {
console.error('打开编辑失败:', error)
@@ -720,35 +718,14 @@ const AccountList: React.FC = () => {
<Divider />
<Descriptions
column={isMobile ? 1 : 2}
bordered
size={isMobile ? 'small' : 'middle'}
title={t('accountList.apiCredentials')}
>
<Descriptions.Item label={t('accountList.apiKey')}>
<Tag color={detailAccount.apiKeyConfigured ? 'success' : 'default'}>
{detailAccount.apiKeyConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('accountList.apiSecret')}>
<Tag color={detailAccount.apiSecretConfigured ? 'success' : 'default'}>
{detailAccount.apiSecretConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('accountList.apiPassphrase')}>
<Tag color={detailAccount.apiPassphraseConfigured ? 'success' : 'default'}>
{detailAccount.apiPassphraseConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('accountList.configStatus')}>
{detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? (
<Tag color="success">{t('accountList.fullConfig')}</Tag>
) : (
<Tag color="warning">{t('accountList.partialConfig')}</Tag>
)}
</Descriptions.Item>
</Descriptions>
<AccountSetupStatusBlock
accountId={detailAccount.id}
onRefresh={handleRefreshDetailBalance}
size={isMobile ? 'small' : 'default'}
showApprovalDetails={true}
/>
<Divider />
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
detailAccount.activeOrders !== undefined ||
+40 -20
View File
@@ -149,7 +149,8 @@ const CryptoTailStrategyList: React.FC = () => {
enabled: true,
amountMode: 'RATIO',
maxPrice: '1',
minSpreadMode: 'AUTO',
spreadMode: 'AUTO',
spreadDirection: 'MIN',
windowStartMinutes: 0,
windowStartSeconds: 0
})
@@ -170,8 +171,9 @@ const CryptoTailStrategyList: React.FC = () => {
maxPrice: record.maxPrice,
amountMode: record.amountMode,
amountValue: record.amountValue,
minSpreadMode: record.minSpreadMode ?? 'AUTO',
minSpreadValue: record.minSpreadValue ?? undefined,
spreadMode: record.spreadMode ?? 'AUTO',
spreadValue: record.spreadValue ?? undefined,
spreadDirection: record.spreadDirection ?? 'MIN',
enabled: record.enabled
})
setFormModalOpen(true)
@@ -204,8 +206,9 @@ const CryptoTailStrategyList: React.FC = () => {
maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined,
amountMode: v.amountMode as string,
amountValue: String(v.amountValue ?? 0),
minSpreadMode: (v.minSpreadMode as string) || 'AUTO',
minSpreadValue: v.minSpreadMode === 'FIXED' && v.minSpreadValue != null ? String(v.minSpreadValue) : (v.minSpreadMode === 'AUTO' && v.minSpreadValue != null ? String(v.minSpreadValue) : undefined),
spreadMode: (v.spreadMode as string) || 'AUTO',
spreadValue: v.spreadMode === 'FIXED' && v.spreadValue != null ? String(v.spreadValue) : (v.spreadMode === 'AUTO' && v.spreadValue != null ? String(v.spreadValue) : undefined),
spreadDirection: v.spreadDirection as string || 'MIN',
enabled: v.enabled !== false
}
if (editingId) {
@@ -218,8 +221,9 @@ const CryptoTailStrategyList: React.FC = () => {
maxPrice: payload.maxPrice,
amountMode: payload.amountMode,
amountValue: payload.amountValue,
minSpreadMode: payload.minSpreadMode,
minSpreadValue: payload.minSpreadValue,
spreadMode: payload.spreadMode,
spreadValue: payload.spreadValue,
spreadDirection: payload.spreadDirection,
enabled: payload.enabled
})
if (res.data.code === 0) {
@@ -232,7 +236,7 @@ const CryptoTailStrategyList: React.FC = () => {
} else {
const res = await apiService.cryptoTailStrategy.create({
...payload,
minSpreadValue: payload.minSpreadMode === 'FIXED' ? payload.minSpreadValue : undefined
spreadValue: payload.spreadMode === 'FIXED' ? payload.spreadValue : undefined
})
if (res.data.code === 0) {
message.success(t('common.success'))
@@ -740,7 +744,7 @@ const CryptoTailStrategyList: React.FC = () => {
destroyOnClose
>
<Alert type="warning" showIcon message={t('cryptoTailStrategy.form.walletTip')} style={{ marginBottom: 16 }} />
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', minSpreadMode: 'AUTO', enabled: true }}>
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', spreadMode: 'AUTO', spreadDirection: 'MIN', enabled: true }}>
<Form.Item name="accountId" label={t('cryptoTailStrategy.form.selectAccount')} rules={[{ required: true }]}>
<Select
placeholder={t('cryptoTailStrategy.form.selectAccount')}
@@ -834,37 +838,37 @@ const CryptoTailStrategyList: React.FC = () => {
}
</Form.Item>
<Form.Item
name="minSpreadMode"
name="spreadMode"
label={
<Space size={4}>
<span>{t('cryptoTailStrategy.form.minSpreadMode')}</span>
<Tooltip title={t('cryptoTailStrategy.form.minSpreadModeTip')}>
<span>{t('cryptoTailStrategy.form.spreadMode')}</span>
<Tooltip title={t('cryptoTailStrategy.form.spreadModeTip')}>
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
</Tooltip>
</Space>
}
>
<Radio.Group>
<Radio value="AUTO">{t('cryptoTailStrategy.form.minSpreadModeAuto')}</Radio>
<Radio value="FIXED">{t('cryptoTailStrategy.form.minSpreadModeFixed')}</Radio>
<Radio value="NONE">{t('cryptoTailStrategy.form.minSpreadModeNone')}</Radio>
<Radio value="AUTO">{t('cryptoTailStrategy.form.spreadModeAuto')}</Radio>
<Radio value="FIXED">{t('cryptoTailStrategy.form.spreadModeFixed')}</Radio>
<Radio value="NONE">{t('cryptoTailStrategy.form.spreadModeNone')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.minSpreadMode !== curr.minSpreadMode}
shouldUpdate={(prev, curr) => prev.spreadMode !== curr.spreadMode}
>
{({ getFieldValue }) =>
getFieldValue('minSpreadMode') === 'FIXED' ? (
getFieldValue('spreadMode') === 'FIXED' ? (
<Form.Item
name="minSpreadValue"
label={t('cryptoTailStrategy.form.minSpreadValue')}
name="spreadValue"
label={t('cryptoTailStrategy.form.spreadValue')}
rules={[{ required: true }]}
>
<InputNumber
min={0}
step={1}
placeholder={t('cryptoTailStrategy.form.minSpreadValuePlaceholder')}
placeholder={t('cryptoTailStrategy.form.spreadValuePlaceholder')}
style={{ width: '100%' }}
stringMode
/>
@@ -872,6 +876,22 @@ const CryptoTailStrategyList: React.FC = () => {
) : null
}
</Form.Item>
<Form.Item
name="spreadDirection"
label={
<Space size={4}>
<span>{t('cryptoTailStrategy.form.spreadDirection')}</span>
<Tooltip title={t('cryptoTailStrategy.form.spreadDirectionTip')}>
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
</Tooltip>
</Space>
}
>
<Radio.Group>
<Radio value="MIN">{t('cryptoTailStrategy.form.spreadDirectionMin')}</Radio>
<Radio value="MAX">{t('cryptoTailStrategy.form.spreadDirectionMax')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item name="enabled" valuePropName="checked">
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
</Form.Item>
+23 -6
View File
@@ -215,13 +215,28 @@ export const apiService = {
/**
*
*/
import: (data: any) =>
import: (data: any) =>
apiClient.post<ApiResponse<any>>('/accounts/import', data),
/**
*
*/
checkSetupStatus: (accountId: number) =>
apiClient.post<ApiResponse<any>>('/accounts/check-setup-status', { accountId }),
/**
* 1 URL2/3
*/
executeSetupStep: (accountId: number, step: number) =>
apiClient.post<ApiResponse<{ success: boolean; redirectUrl?: string; transactionHash?: string }>>(
'/accounts/execute-setup-step',
{ accountId, step }
),
/**
*
*/
update: (data: any) =>
update: (data: any) =>
apiClient.post<ApiResponse<any>>('/accounts/update', data),
/**
@@ -447,8 +462,9 @@ export const apiService = {
maxPrice?: string
amountMode: string
amountValue: string
minSpreadMode?: string
minSpreadValue?: string | null
spreadMode?: string
spreadValue?: string | null
spreadDirection?: string
enabled?: boolean
}) =>
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
@@ -461,8 +477,9 @@ export const apiService = {
maxPrice?: string
amountMode?: string
amountValue?: string
minSpreadMode?: string
minSpreadValue?: string | null
spreadMode?: string
spreadValue?: string | null
spreadDirection?: string
enabled?: boolean
}) =>
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
+6 -4
View File
@@ -1051,10 +1051,12 @@ export interface CryptoTailStrategyDto {
maxPrice: string
amountMode: string
amountValue: string
/** 最小价差模式: NONE, FIXED, AUTO */
minSpreadMode?: string
/** 最小价差数值(FIXED 时必填;AUTO 时可为计算值) */
minSpreadValue?: string | null
/** 价差模式: NONE, FIXED, AUTO */
spreadMode?: string
/** 价差数值 */
spreadValue?: string | null
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
spreadDirection?: string
enabled: boolean
lastTriggerAt?: number
/** 已实现总收益 USDC */