feat(frontend): 手动下单功能增强与金额模式支持

- 后端返回 amountMode 和 amountValue 字段,支持前端获取策略配置
- 手动下单确认弹窗显示用户可用余额(绿色高亮)
- 价格输入框右侧添加"获取最新价"按钮,支持实时更新价格
- 数量输入框右侧添加"最大"按钮,根据余额自动计算最大购买数量
- 修正数量计算逻辑:FIXED 和 RATIO 模式均按实际金额计算数量,保留2位小数
  - FIXED 模式:使用配置的固定金额
  - RATIO 模式:按比例计算可用余额
- 数量计算使用保留小数方式(而非向上取整),确保精确匹配投入金额
  - 示例:固定1U,价格0.4,自动填充2.5张
  - 示例:比例30%,余额20U,价格0.4,自动填充15张

Made-with: Cursor
This commit is contained in:
WrBug
2026-02-26 21:19:06 +08:00
parent 9b4d8fc001
commit 79b154515d
6 changed files with 175 additions and 31 deletions
+10 -1
View File
@@ -1676,7 +1676,16 @@
"failed": "Manual order failed: {{reason}}",
"errorSigning": "Signing failed: {reason}",
"errorNetwork": "Network error, please try again later",
"priceNotLoaded": "Price data not loaded"
"priceNotLoaded": "Price data not loaded",
"insufficientBalance": "Insufficient balance, available amount less than 1 USDC",
"fetchBalanceFailed": "Failed to fetch account balance",
"availableBalance": "Available Balance",
"fetchLatestPrice": "Latest Price",
"priceUpdated": "Price updated",
"maxSize": "Max",
"invalidPriceOrBalance": "Invalid price or balance",
"insufficientBalanceForMax": "Insufficient balance for max size",
"maxSizeUpdated": "Updated to max size"
}
}
}
+10 -1
View File
@@ -1676,7 +1676,16 @@
"failed": "手动下单失败:{{reason}}",
"errorSigning": "签名失败:{{reason}}",
"errorNetwork": "网络错误,请稍后重试",
"priceNotLoaded": "价格数据未加载"
"priceNotLoaded": "价格数据未加载",
"insufficientBalance": "余额不足,可用金额少于 1 USDC",
"fetchBalanceFailed": "获取账户余额失败",
"availableBalance": "可用余额",
"fetchLatestPrice": "获取最新价",
"priceUpdated": "价格已更新",
"maxSize": "最大",
"invalidPriceOrBalance": "价格或余额无效",
"insufficientBalanceForMax": "余额不足以购买最大数量",
"maxSizeUpdated": "已更新为最大数量"
}
}
}
+10 -1
View File
@@ -1676,7 +1676,16 @@
"failed": "手動下單失敗:{{reason}}",
"errorSigning": "簽名失敗:{reason}",
"errorNetwork": "網絡錯誤,請稍後重試",
"priceNotLoaded": "價格數據未加載"
"priceNotLoaded": "價格數據未加載",
"insufficientBalance": "餘額不足,可用金額少於 1 USDC",
"fetchBalanceFailed": "獲取賬戶餘額失敗",
"availableBalance": "可用餘額",
"fetchLatestPrice": "獲取最新價",
"priceUpdated": "價格已更新",
"maxSize": "最大",
"invalidPriceOrBalance": "價格或餘額無效",
"insufficientBalanceForMax": "餘額不足以購買最大數量",
"maxSizeUpdated": "已更新為最大數量"
}
}
}
+137 -26
View File
@@ -99,13 +99,15 @@ const CryptoTailMonitor: React.FC = () => {
size: string
totalAmount: string
bestBid: string
availableBalance: string
}>({
visible: false,
direction: 'UP',
price: '',
size: '',
totalAmount: '',
bestBid: ''
bestBid: '',
availableBalance: ''
})
const [ordering, setOrdering] = useState(false)
@@ -756,7 +758,7 @@ const CryptoTailMonitor: React.FC = () => {
parseFloat(currentSpread) < Math.min(...minSpreadLineNum)
// 手动下单:打开弹窗
const handleOpenManualOrderModal = (direction: 'UP' | 'DOWN') => {
const handleOpenManualOrderModal = async (direction: 'UP' | 'DOWN') => {
if (!pushData) {
message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded'))
return
@@ -769,18 +771,81 @@ const CryptoTailMonitor: React.FC = () => {
// 计算默认价格:最优 bid × 1.1,限制在 0~1 之间
const rawPrice = parseFloat(bestBid) * 1.1
const defaultPrice = Math.min(1, Math.max(0, rawPrice))
const defaultAmountUsdc = 10
const defaultSize = Math.ceil(defaultAmountUsdc / defaultPrice)
// 获取账户余额
let availableBalance = '0'
if (initData?.accountId) {
try {
const balanceRes = await apiService.accounts.balance({ accountId: initData.accountId })
if (balanceRes.data.code === 0 && balanceRes.data.data?.availableBalance) {
availableBalance = balanceRes.data.data.availableBalance
}
} catch (e) {
console.error('获取账户余额失败:', e)
}
}
// 使用策略配置的金额
let defaultAmountUsdc = 10
if (initData?.amountMode === 'FIXED' && initData?.amountValue) {
defaultAmountUsdc = parseFloat(initData.amountValue)
} else if (initData?.amountMode === 'RATIO' && initData?.amountValue) {
// RATIO 模式:按比例计算
const balanceNum = parseFloat(availableBalance)
const ratio = parseFloat(initData.amountValue || '10')
defaultAmountUsdc = Math.floor(balanceNum * ratio / 100)
// 至少保留 1 USDC
if (defaultAmountUsdc < 1) {
message.warning(t('cryptoTailMonitor.manualOrder.insufficientBalance'))
return
}
}
// 计算默认数量(保留2位小数,用于手动下单)
let defaultSize = (defaultAmountUsdc / defaultPrice).toFixed(2)
// 确保至少 1 张
if (parseFloat(defaultSize) < 1) {
defaultSize = '1.00'
}
// 重新计算总金额(基于实际数量)
const defaultTotalAmount = (defaultPrice * parseFloat(defaultSize)).toFixed(2)
setManualOrderModal({
visible: true,
direction,
price: defaultPrice.toFixed(4),
size: defaultSize.toFixed(2),
totalAmount: (defaultPrice * defaultSize).toFixed(2),
bestBid
size: defaultSize,
totalAmount: defaultTotalAmount,
bestBid,
availableBalance
})
}
// 获取最新价
const handleFetchLatestPrice = async () => {
if (!pushData) {
message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded'))
return
}
const latestPrice = manualOrderModal.direction === 'UP'
? pushData.currentPriceUp
: pushData.currentPriceDown
if (!latestPrice) {
message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded'))
return
}
const price = parseFloat(latestPrice)
const size = parseFloat(manualOrderModal.size)
const totalAmount = (price * size).toFixed(2)
setManualOrderModal({
...manualOrderModal,
price: price.toFixed(4),
totalAmount
})
message.success(t('cryptoTailMonitor.manualOrder.priceUpdated'))
}
const handleCloseManualOrderModal = () => {
setManualOrderModal({
visible: false,
@@ -788,7 +853,8 @@ const CryptoTailMonitor: React.FC = () => {
price: '',
size: '',
totalAmount: '',
bestBid: ''
bestBid: '',
availableBalance: ''
})
}
@@ -810,6 +876,33 @@ const CryptoTailMonitor: React.FC = () => {
setManualOrderModal({ ...manualOrderModal, size, totalAmount, price: price.toFixed(4) })
}
// 计算最大数量(截位处理)
const handleMaxSize = () => {
const price = parseFloat(manualOrderModal.price)
const balance = parseFloat(manualOrderModal.availableBalance)
if (price <= 0 || balance <= 0) {
message.warning(t('cryptoTailMonitor.manualOrder.invalidPriceOrBalance'))
return
}
// 最大数量 = Math.floor(可用余额 / 价格)
const maxSize = Math.floor(balance / price)
if (maxSize < 1) {
message.warning(t('cryptoTailMonitor.manualOrder.insufficientBalanceForMax'))
return
}
const totalAmount = (price * maxSize).toFixed(2)
setManualOrderModal({
...manualOrderModal,
size: maxSize.toFixed(2),
totalAmount
})
message.success(t('cryptoTailMonitor.manualOrder.maxSizeUpdated'))
}
const handleManualOrder = async () => {
if (!initData || !pushData) return
try {
@@ -1167,33 +1260,51 @@ const CryptoTailMonitor: React.FC = () => {
: t('cryptoTailMonitor.manualOrder.directionDown')}
</Text>
</Col>
<Col span={24}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
{t('cryptoTailMonitor.manualOrder.availableBalance')}
</Text>
<Text strong style={{ fontSize: 16, color: '#52c41a' }}>
{manualOrderModal.availableBalance ? formatNumber(manualOrderModal.availableBalance, 2) : '-'} {t('cryptoTailMonitor.manualOrder.orderUnit')}
</Text>
</Col>
<Col span={24}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
{t('cryptoTailMonitor.manualOrder.orderPrice')} ({t('cryptoTailMonitor.manualOrder.orderUnit')})
</Text>
<InputNumber
style={{ width: '100%' }}
value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined}
onChange={handlePriceChange}
min={0}
max={1}
step={0.0001}
precision={4}
placeholder="0.0000"
/>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ width: '100%' }}
value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined}
onChange={handlePriceChange}
min={0}
max={1}
step={0.0001}
precision={4}
placeholder="0.0000"
/>
<Button onClick={handleFetchLatestPrice} icon={<SyncOutlined />}>
{t('cryptoTailMonitor.manualOrder.fetchLatestPrice')}
</Button>
</Space.Compact>
</Col>
<Col span={24}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
{t('cryptoTailMonitor.manualOrder.orderSize')} ({t('cryptoTailMonitor.manualOrder.sizeUnit')})
</Text>
<InputNumber
style={{ width: '100%' }}
value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined}
onChange={handleSizeChange}
min={1}
precision={2}
placeholder="1"
/>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ width: '100%' }}
value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined}
onChange={handleSizeChange}
min={1}
precision={2}
placeholder="1"
/>
<Button onClick={handleMaxSize} type="primary" ghost>
{t('cryptoTailMonitor.manualOrder.maxSize')}
</Button>
</Space.Compact>
</Col>
<Col span={24}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>