feat(crypto-tail): 价差策略监控页手动下单与 UI 优化
- 后端:手动下单 API,trigger_type 区分 AUTO/MANUAL,价格 4 位小数向上取整 - 前端:监控页手动买入 Up/Down 按钮、二次确认弹窗、策略信息移动端适配 - 下单价格最多 4 位小数;确认页方向仅显示 Up/Down;按钮文案改为「买入 Up/Down」 Made-with: Cursor
This commit is contained in:
@@ -1644,6 +1644,39 @@
|
||||
"switchToLatest": "Switch to Latest Period",
|
||||
"periodEnded": "Current period has ended",
|
||||
"newPeriodAvailable": "New period has started"
|
||||
},
|
||||
"manualOrder": {
|
||||
"title": "Manual Order",
|
||||
"buttonUp": "Buy Up",
|
||||
"buttonDown": "Buy Down",
|
||||
"confirmTitle": "Manual Order Confirmation",
|
||||
"marketTitle": "Market Title",
|
||||
"direction": "Direction",
|
||||
"directionUp": "Up",
|
||||
"directionDown": "Down",
|
||||
"orderPrice": "Order Price",
|
||||
"orderSize": "Order Size",
|
||||
"totalAmount": "Total Amount",
|
||||
"account": "Account",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm Order",
|
||||
"orderUnit": "USDC",
|
||||
"sizeUnit": "shares",
|
||||
"statusNotOrdered": "Not Ordered",
|
||||
"statusOrdered": "This period has been ordered",
|
||||
"statusOrderedAuto": "This period has been auto-ordered",
|
||||
"statusOrderedManual": "This period has been manually ordered",
|
||||
"errorInsufficientBalance": "Insufficient account balance, available: {balance} USDC",
|
||||
"errorPriceOutOfRange": "Price must be between 0 and 1",
|
||||
"errorMinSize": "Size cannot be less than 1 share",
|
||||
"errorExceedsBalance": "Total amount exceeds available balance",
|
||||
"errorPriceRequired": "Please enter order price",
|
||||
"errorSizeRequired": "Please enter order size",
|
||||
"success": "Manual order successful",
|
||||
"failed": "Manual order failed: {{reason}}",
|
||||
"errorSigning": "Signing failed: {reason}",
|
||||
"errorNetwork": "Network error, please try again later",
|
||||
"priceNotLoaded": "Price data not loaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1644,6 +1644,39 @@
|
||||
"switchToLatest": "切换到最新周期",
|
||||
"periodEnded": "当前周期已结束",
|
||||
"newPeriodAvailable": "新周期已开始"
|
||||
},
|
||||
"manualOrder": {
|
||||
"title": "手动下单",
|
||||
"buttonUp": "买入 Up",
|
||||
"buttonDown": "买入 Down",
|
||||
"confirmTitle": "手动下单确认",
|
||||
"marketTitle": "市场标题",
|
||||
"direction": "方向",
|
||||
"directionUp": "Up",
|
||||
"directionDown": "Down",
|
||||
"orderPrice": "下单价格",
|
||||
"orderSize": "下单数量",
|
||||
"totalAmount": "总金额",
|
||||
"account": "账户",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认下单",
|
||||
"orderUnit": "USDC",
|
||||
"sizeUnit": "张",
|
||||
"statusNotOrdered": "未下单",
|
||||
"statusOrdered": "本周期已下单",
|
||||
"statusOrderedAuto": "本周期已自动下单",
|
||||
"statusOrderedManual": "本周期已手动下单",
|
||||
"errorInsufficientBalance": "账户余额不足,可用余额:{{balance}} USDC",
|
||||
"errorPriceOutOfRange": "价格必须在 0~1 之间",
|
||||
"errorMinSize": "数量不能少于 1 张",
|
||||
"errorExceedsBalance": "总金额超过可用余额",
|
||||
"errorPriceRequired": "请输入下单价格",
|
||||
"errorSizeRequired": "请输入下单数量",
|
||||
"success": "手动下单成功",
|
||||
"failed": "手动下单失败:{{reason}}",
|
||||
"errorSigning": "签名失败:{{reason}}",
|
||||
"errorNetwork": "网络错误,请稍后重试",
|
||||
"priceNotLoaded": "价格数据未加载"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1644,6 +1644,39 @@
|
||||
"switchToLatest": "切換到最新週期",
|
||||
"periodEnded": "當前週期已結束",
|
||||
"newPeriodAvailable": "新週期已開始"
|
||||
},
|
||||
"manualOrder": {
|
||||
"title": "手動下單",
|
||||
"buttonUp": "買入 Up",
|
||||
"buttonDown": "買入 Down",
|
||||
"confirmTitle": "手動下單確認",
|
||||
"marketTitle": "市場標題",
|
||||
"direction": "方向",
|
||||
"directionUp": "Up",
|
||||
"directionDown": "Down",
|
||||
"orderPrice": "下單價格",
|
||||
"orderSize": "下單數量",
|
||||
"totalAmount": "總金額",
|
||||
"account": "賬戶",
|
||||
"cancel": "取消",
|
||||
"confirm": "確認下單",
|
||||
"orderUnit": "USDC",
|
||||
"sizeUnit": "張",
|
||||
"statusNotOrdered": "未下單",
|
||||
"statusOrdered": "本週期已下單",
|
||||
"statusOrderedAuto": "本週期已自動下單",
|
||||
"statusOrderedManual": "本週期已手動下單",
|
||||
"errorInsufficientBalance": "賬戶餘額不足,可用餘額:{balance} USDC",
|
||||
"errorPriceOutOfRange": "價格必須在 0~1 之間",
|
||||
"errorMinSize": "數量不能少於 1 張",
|
||||
"errorExceedsBalance": "總金額超過可用餘額",
|
||||
"errorPriceRequired": "請輸入下單價格",
|
||||
"errorSizeRequired": "請輸入下單數量",
|
||||
"success": "手動下單成功",
|
||||
"failed": "手動下單失敗:{{reason}}",
|
||||
"errorSigning": "簽名失敗:{reason}",
|
||||
"errorNetwork": "網絡錯誤,請稍後重試",
|
||||
"priceNotLoaded": "價格數據未加載"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
Alert,
|
||||
Radio,
|
||||
Button,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Modal,
|
||||
InputNumber,
|
||||
message
|
||||
} from 'antd'
|
||||
import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined, ShoppingCartOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import * as echarts from 'echarts'
|
||||
@@ -84,6 +87,24 @@ const CryptoTailMonitor: React.FC = () => {
|
||||
// 标记当前是否在查看旧周期(手动模式下)
|
||||
const [isViewingOldPeriod, setIsViewingOldPeriod] = useState<boolean>(false)
|
||||
|
||||
// 手动下单状态
|
||||
const [manualOrderModal, setManualOrderModal] = useState<{
|
||||
visible: boolean
|
||||
direction: 'UP' | 'DOWN'
|
||||
price: string
|
||||
size: string
|
||||
totalAmount: string
|
||||
bestBid: string
|
||||
}>({
|
||||
visible: false,
|
||||
direction: 'UP',
|
||||
price: '',
|
||||
size: '',
|
||||
totalAmount: '',
|
||||
bestBid: ''
|
||||
})
|
||||
const [ordering, setOrdering] = useState(false)
|
||||
|
||||
// 获取策略列表
|
||||
useEffect(() => {
|
||||
const fetchStrategies = async () => {
|
||||
@@ -709,6 +730,94 @@ const CryptoTailMonitor: React.FC = () => {
|
||||
const spreadBelowThreshold = currentSpread != null && currentSpread !== '' && minSpreadLineNum.length > 0 &&
|
||||
parseFloat(currentSpread) < Math.min(...minSpreadLineNum)
|
||||
|
||||
// 手动下单:打开弹窗
|
||||
const handleOpenManualOrderModal = (direction: 'UP' | 'DOWN') => {
|
||||
if (!pushData) {
|
||||
message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded'))
|
||||
return
|
||||
}
|
||||
const bestBid = direction === 'UP' ? pushData.currentPriceUp : pushData.currentPriceDown
|
||||
if (!bestBid) {
|
||||
message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded'))
|
||||
return
|
||||
}
|
||||
// 计算默认价格:最优 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)
|
||||
setManualOrderModal({
|
||||
visible: true,
|
||||
direction,
|
||||
price: defaultPrice.toFixed(4),
|
||||
size: defaultSize.toFixed(2),
|
||||
totalAmount: (defaultPrice * defaultSize).toFixed(2),
|
||||
bestBid
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseManualOrderModal = () => {
|
||||
setManualOrderModal({
|
||||
visible: false,
|
||||
direction: 'UP',
|
||||
price: '',
|
||||
size: '',
|
||||
totalAmount: '',
|
||||
bestBid: ''
|
||||
})
|
||||
}
|
||||
|
||||
const handlePriceChange = (value: number | null) => {
|
||||
if (value === null) return
|
||||
const clamped = Math.min(1, Math.max(0, value))
|
||||
const price = clamped.toFixed(4)
|
||||
const size = parseFloat(manualOrderModal.size)
|
||||
const totalAmount = (clamped * size).toFixed(2)
|
||||
setManualOrderModal({ ...manualOrderModal, price, totalAmount })
|
||||
}
|
||||
|
||||
const handleSizeChange = (value: number | null) => {
|
||||
if (value === null) return
|
||||
const size = value.toFixed(2)
|
||||
const priceRaw = parseFloat(manualOrderModal.price)
|
||||
const price = Math.min(1, Math.max(0, priceRaw))
|
||||
const totalAmount = (price * value).toFixed(2)
|
||||
setManualOrderModal({ ...manualOrderModal, size, totalAmount, price: price.toFixed(4) })
|
||||
}
|
||||
|
||||
const handleManualOrder = async () => {
|
||||
if (!initData || !pushData) return
|
||||
try {
|
||||
setOrdering(true)
|
||||
const tokenIds: string[] = []
|
||||
if (initData.tokenIdUp) tokenIds.push(initData.tokenIdUp)
|
||||
if (initData.tokenIdDown) tokenIds.push(initData.tokenIdDown)
|
||||
const request = {
|
||||
strategyId: initData.strategyId,
|
||||
periodStartUnix: pushData.periodStartUnix,
|
||||
direction: manualOrderModal.direction,
|
||||
price: Math.min(1, Math.max(0, parseFloat(manualOrderModal.price) || 0)).toFixed(4),
|
||||
size: manualOrderModal.size,
|
||||
marketTitle: pushData.marketTitle || initData.marketTitle,
|
||||
tokenIds
|
||||
}
|
||||
const res = await apiService.cryptoTailStrategy.manualOrder(request)
|
||||
if (res.data.code === 0 && res.data.data?.success) {
|
||||
message.success(t('cryptoTailMonitor.manualOrder.success'))
|
||||
handleCloseManualOrderModal()
|
||||
} else {
|
||||
const reason = res.data.msg?.trim() || 'unknown'
|
||||
message.error(t('cryptoTailMonitor.manualOrder.failed', { reason }))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { msg?: string } }; message?: string }
|
||||
const reason = err?.response?.data?.msg?.trim() ?? err?.message?.trim() ?? 'unknown'
|
||||
message.error(t('cryptoTailMonitor.manualOrder.failed', { reason }))
|
||||
} finally {
|
||||
setOrdering(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 12 : 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>
|
||||
@@ -914,29 +1023,61 @@ const CryptoTailMonitor: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 手动下单 */}
|
||||
<Card title={t('cryptoTailMonitor.manualOrder.title')} style={{ marginTop: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ShoppingCartOutlined />}
|
||||
disabled={!pushData || pushData.triggered || pushData.periodEnded}
|
||||
onClick={() => handleOpenManualOrderModal('UP')}
|
||||
loading={ordering}
|
||||
block
|
||||
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
|
||||
>
|
||||
{t('cryptoTailMonitor.manualOrder.buttonUp')}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ShoppingCartOutlined />}
|
||||
disabled={!pushData || pushData.triggered || pushData.periodEnded}
|
||||
onClick={() => handleOpenManualOrderModal('DOWN')}
|
||||
loading={ordering}
|
||||
block
|
||||
style={{ backgroundColor: '#fa8c16', borderColor: '#fa8c16' }}
|
||||
>
|
||||
{t('cryptoTailMonitor.manualOrder.buttonDown')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 策略信息 */}
|
||||
<Card title={t('cryptoTailMonitor.strategyInfo.title')} style={{ marginTop: 16 }}>
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col span={12}>
|
||||
<Row gutter={[16, isMobile ? 12 : 8]}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.market')}: </Text>
|
||||
<Text>{pushData?.marketTitle ?? initData.marketTitle}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.interval')}: </Text>
|
||||
<Text>{initData.intervalSeconds === 300 ? '5m' : '15m'}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.account')}: </Text>
|
||||
<Text>{initData.accountName || `#${initData.accountId}`}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadMode')}: </Text>
|
||||
<Text>{initData.minSpreadMode}</Text>
|
||||
{initData.minSpreadMode === 'FIXED' && initData.minSpreadValue && (
|
||||
<Text> ({formatNumber(initData.minSpreadValue, 4)})</Text>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadDirection')}: </Text>
|
||||
<Text>{(initData.spreadDirection ?? 'MIN') === 'MAX' ? t('cryptoTailMonitor.stat.configuredSpreadMax') : t('cryptoTailMonitor.stat.configuredSpreadMin')}</Text>
|
||||
</Col>
|
||||
@@ -944,6 +1085,103 @@ const CryptoTailMonitor: React.FC = () => {
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 手动下单确认弹窗 */}
|
||||
<Modal
|
||||
title={t('cryptoTailMonitor.manualOrder.confirmTitle')}
|
||||
open={manualOrderModal.visible}
|
||||
onCancel={handleCloseManualOrderModal}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCloseManualOrderModal}>
|
||||
{t('cryptoTailMonitor.manualOrder.cancel')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
onClick={handleManualOrder}
|
||||
loading={ordering}
|
||||
style={
|
||||
manualOrderModal.direction === 'UP'
|
||||
? { backgroundColor: '#1890ff', borderColor: '#1890ff' }
|
||||
: { backgroundColor: '#fa8c16', borderColor: '#fa8c16' }
|
||||
}
|
||||
>
|
||||
{t('cryptoTailMonitor.manualOrder.confirm')}
|
||||
</Button>
|
||||
]}
|
||||
width={isMobile ? '100%' : 480}
|
||||
style={isMobile ? { maxWidth: '100%', top: 0, paddingBottom: 0 } : undefined}
|
||||
>
|
||||
{initData && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<Row gutter={[12, 8]}>
|
||||
<Col span={24}>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
|
||||
{t('cryptoTailMonitor.manualOrder.marketTitle')}
|
||||
</Text>
|
||||
<Text>{pushData?.marketTitle ?? initData.marketTitle}</Text>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
|
||||
{t('cryptoTailMonitor.manualOrder.direction')}
|
||||
</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16'
|
||||
}}
|
||||
>
|
||||
{manualOrderModal.direction === 'UP'
|
||||
? t('cryptoTailMonitor.manualOrder.directionUp')
|
||||
: t('cryptoTailMonitor.manualOrder.directionDown')}
|
||||
</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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
|
||||
{t('cryptoTailMonitor.manualOrder.totalAmount')}
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{manualOrderModal.totalAmount} {t('cryptoTailMonitor.manualOrder.orderUnit')}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
|
||||
{t('cryptoTailMonitor.manualOrder.account')}
|
||||
</Text>
|
||||
<Text>{initData.accountName || `#${initData.accountId}`}</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -499,9 +499,19 @@ export const apiService = {
|
||||
autoMinSpread: (data: { intervalSeconds: number }) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data),
|
||||
monitorInit: (strategyId: number) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId })
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId }),
|
||||
manualOrder: (data: {
|
||||
strategyId: number
|
||||
periodStartUnix: number
|
||||
direction: 'UP' | 'DOWN'
|
||||
price: string
|
||||
size: string
|
||||
marketTitle: string
|
||||
tokenIds: string[]
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data)
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 订单管理 API
|
||||
*/
|
||||
|
||||
@@ -1156,6 +1156,10 @@ export interface CryptoTailMonitorInitResponse {
|
||||
currentTimestamp: number
|
||||
/** 是否启用 */
|
||||
enabled: boolean
|
||||
/** 投入金额模式: FIXED or RATIO */
|
||||
amountMode?: string
|
||||
/** 投入金额数值 */
|
||||
amountValue?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1203,3 +1207,27 @@ export interface CryptoTailMonitorPushData {
|
||||
/** 周期是否已结束 */
|
||||
periodEnded: boolean
|
||||
}
|
||||
|
||||
export interface CryptoTailManualOrderResponse {
|
||||
/** 是否成功 */
|
||||
success: boolean
|
||||
/** 订单ID */
|
||||
orderId?: string
|
||||
/** 提示消息 */
|
||||
message: string
|
||||
/** 下单详情 */
|
||||
orderDetails?: ManualOrderDetails
|
||||
}
|
||||
|
||||
export interface ManualOrderDetails {
|
||||
/** 策略ID */
|
||||
strategyId: number
|
||||
/** 方向 */
|
||||
direction: string
|
||||
/** 下单价格 */
|
||||
price: string
|
||||
/** 下单数量 */
|
||||
size: string
|
||||
/** 总金额 */
|
||||
totalAmount: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user