feat(frontend): 优化加密价差监控页面体验

- 刷新页面时保留当前选择的策略(localStorage 缓存)
- 移动端下单弹窗改为 BottomSheet 样式,更紧凑
- 买入按钮实时显示当前价格,如 "买入 Up (0.50)"
- 周期切换时自动关闭弹窗并提示用户

Made-with: Cursor
This commit is contained in:
WrBug
2026-02-27 04:11:55 +08:00
parent e5acad5091
commit 5483a1aa77
4 changed files with 266 additions and 93 deletions
+2 -1
View File
@@ -1685,7 +1685,8 @@
"maxSize": "Max",
"invalidPriceOrBalance": "Invalid price or balance",
"insufficientBalanceForMax": "Insufficient balance for max size",
"maxSizeUpdated": "Updated to max size"
"maxSizeUpdated": "Updated to max size",
"periodChanged": "Period has changed, popup closed"
}
}
}
+2 -1
View File
@@ -1685,7 +1685,8 @@
"maxSize": "最大",
"invalidPriceOrBalance": "价格或余额无效",
"insufficientBalanceForMax": "余额不足以购买最大数量",
"maxSizeUpdated": "已更新为最大数量"
"maxSizeUpdated": "已更新为最大数量",
"periodChanged": "周期已切换,弹窗已关闭"
}
}
}
+2 -1
View File
@@ -1685,7 +1685,8 @@
"maxSize": "最大",
"invalidPriceOrBalance": "價格或餘額無效",
"insufficientBalanceForMax": "餘額不足以購買最大數量",
"maxSizeUpdated": "已更新為最大數量"
"maxSizeUpdated": "已更新為最大數量",
"periodChanged": "週期已切換,彈窗已關閉"
}
}
}
+260 -90
View File
@@ -17,6 +17,7 @@ import {
InputNumber,
message
} from 'antd'
import { Popup as AntdMobilePopup } from 'antd-mobile'
import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined, ShoppingCartOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
@@ -33,6 +34,10 @@ import type {
const { Title, Text } = Typography
// localStorage keys
const PERIOD_SWITCH_MODE_KEY = 'cryptoTailMonitor_periodSwitchMode'
const SELECTED_STRATEGY_ID_KEY = 'cryptoTailMonitor_selectedStrategyId'
/** 分时图数据点:时间戳、BTC 价格 USDC、市场 Up/Down 价格 0-1 */
interface PriceDataPoint {
time: number
@@ -49,8 +54,11 @@ const CryptoTailMonitor: React.FC = () => {
const [strategies, setStrategies] = useState<CryptoTailStrategyDto[]>([])
const [strategiesLoading, setStrategiesLoading] = useState(false)
// 选中的策略
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null)
// 选中的策略(从 localStorage 恢复)
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(() => {
const cached = localStorage.getItem(SELECTED_STRATEGY_ID_KEY)
return cached != null ? parseInt(cached, 10) : null
})
// 监控数据
const [initData, setInitData] = useState<CryptoTailMonitorInitResponse | null>(null)
@@ -69,9 +77,6 @@ const CryptoTailMonitor: React.FC = () => {
selectedStrategyIdRef.current = selectedStrategyId
}, [selectedStrategyId])
// localStorage key for period switch mode
const PERIOD_SWITCH_MODE_KEY = 'cryptoTailMonitor_periodSwitchMode'
// 记录首次数据进入时间(用于中途进入时的横轴起点)
const [firstDataTime, setFirstDataTime] = useState<number | null>(null)
// 标记是否已切换过周期(切换后使用完整周期)
@@ -100,6 +105,7 @@ const CryptoTailMonitor: React.FC = () => {
totalAmount: string
bestBid: string
availableBalance: string
periodStartUnix: number | null
}>({
visible: false,
direction: 'UP',
@@ -107,10 +113,21 @@ const CryptoTailMonitor: React.FC = () => {
size: '',
totalAmount: '',
bestBid: '',
availableBalance: ''
availableBalance: '',
periodStartUnix: null
})
const [ordering, setOrdering] = useState(false)
// 检测周期切换,关闭弹窗并提示用户
useEffect(() => {
if (manualOrderModal.visible && manualOrderModal.periodStartUnix != null && pushData) {
if (pushData.periodStartUnix !== manualOrderModal.periodStartUnix) {
message.warning(t('cryptoTailMonitor.manualOrder.periodChanged'))
handleCloseManualOrderModal()
}
}
}, [pushData?.periodStartUnix, manualOrderModal.visible, manualOrderModal.periodStartUnix])
// 获取策略列表
useEffect(() => {
const fetchStrategies = async () => {
@@ -118,10 +135,18 @@ const CryptoTailMonitor: React.FC = () => {
try {
const res = await apiService.cryptoTailStrategy.list({ enabled: true })
if (res.data.code === 0 && res.data.data) {
setStrategies(res.data.data.list ?? [])
// 自动选择第一个策略
if (res.data.data.list?.length > 0 && !selectedStrategyId) {
setSelectedStrategyId(res.data.data.list[0].id)
const strategyList = res.data.data.list ?? []
setStrategies(strategyList)
// 从 localStorage 恢复选中的策略
const cachedId = localStorage.getItem(SELECTED_STRATEGY_ID_KEY)
const cachedStrategyId = cachedId != null ? parseInt(cachedId, 10) : null
// 检查缓存的策略是否在列表中
const isValidCached = cachedStrategyId != null && strategyList.some(s => s.id === cachedStrategyId)
if (isValidCached) {
setSelectedStrategyId(cachedStrategyId)
} else if (strategyList.length > 0) {
// 自动选择第一个策略
setSelectedStrategyId(strategyList[0].id)
}
}
} catch (e) {
@@ -133,6 +158,13 @@ const CryptoTailMonitor: React.FC = () => {
fetchStrategies()
}, [])
// 保存选中的策略到 localStorage
useEffect(() => {
if (selectedStrategyId != null) {
localStorage.setItem(SELECTED_STRATEGY_ID_KEY, String(selectedStrategyId))
}
}, [selectedStrategyId])
// 初始化监控数据
useEffect(() => {
if (!selectedStrategyId) {
@@ -818,7 +850,8 @@ const CryptoTailMonitor: React.FC = () => {
size: defaultSize,
totalAmount: defaultTotalAmount,
bestBid,
availableBalance
availableBalance,
periodStartUnix: pushData.periodStartUnix
})
}
@@ -854,7 +887,8 @@ const CryptoTailMonitor: React.FC = () => {
size: '',
totalAmount: '',
bestBid: '',
availableBalance: ''
availableBalance: '',
periodStartUnix: null
})
}
@@ -1160,7 +1194,7 @@ const CryptoTailMonitor: React.FC = () => {
block
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
>
{t('cryptoTailMonitor.manualOrder.buttonUp')}
{t('cryptoTailMonitor.manualOrder.buttonUp')} {pushData?.currentPriceUp ? `(${formatNumber(pushData.currentPriceUp, 2)})` : ''}
</Button>
</Col>
<Col span={12}>
@@ -1173,7 +1207,7 @@ const CryptoTailMonitor: React.FC = () => {
block
style={{ backgroundColor: '#fa8c16', borderColor: '#fa8c16' }}
>
{t('cryptoTailMonitor.manualOrder.buttonDown')}
{t('cryptoTailMonitor.manualOrder.buttonDown')} {pushData?.currentPriceDown ? `(${formatNumber(pushData.currentPriceDown, 2)})` : ''}
</Button>
</Col>
</Row>
@@ -1211,48 +1245,162 @@ const CryptoTailMonitor: React.FC = () => {
</>
)}
{/* 手动下单确认弹窗 */}
<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')}
{/* 手动下单确认弹窗 - 桌面端使用 Modal */}
{!isMobile && (
<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={480}
>
{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.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>
<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>
<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 }}>
{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>
)}
{/* 移动端 BottomSheet 弹窗 */}
{isMobile && (
<AntdMobilePopup
visible={manualOrderModal.visible}
onMaskClick={handleCloseManualOrderModal}
onClose={handleCloseManualOrderModal}
bodyStyle={{
borderRadius: '16px 16px 0 0',
padding: '12px 16px',
maxHeight: '70vh',
overflow: 'auto'
}}
>
{initData && (
<div>
{/* 标题栏 */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12
}}>
<Text strong style={{ fontSize: 16 }}>
{t('cryptoTailMonitor.manualOrder.confirmTitle')}
</Text>
<Button type="text" onClick={handleCloseManualOrderModal} style={{ padding: 0, fontSize: 18 }}>
</Button>
</div>
{/* 市场信息 + 方向 + 余额(一行) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, flexWrap: 'wrap', gap: 8 }}>
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<Text type="secondary" style={{ fontSize: 12 }}>{t('cryptoTailMonitor.manualOrder.marketTitle')}: </Text>
<Text style={{ fontSize: 13 }} ellipsis>{pushData?.marketTitle ?? initData.marketTitle}</Text>
</div>
<Text
strong
style={{
fontSize: 14,
color: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16'
}}
>
@@ -1260,22 +1408,26 @@ const CryptoTailMonitor: React.FC = () => {
? t('cryptoTailMonitor.manualOrder.directionUp')
: t('cryptoTailMonitor.manualOrder.directionDown')}
</Text>
</Col>
<Col span={24}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>
{t('cryptoTailMonitor.manualOrder.availableBalance')}
</div>
{/* 可用余额 */}
<div style={{ marginBottom: 10 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{t('cryptoTailMonitor.manualOrder.availableBalance')}:
</Text>
<Text strong style={{ fontSize: 16, color: '#52c41a' }}>
<Text strong style={{ fontSize: 14, color: '#52c41a', marginLeft: 4 }}>
{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')})
</div>
{/* 价格输入 */}
<div style={{ marginBottom: 10 }}>
<Text type="secondary" style={{ display: 'block', marginBottom: 2, fontSize: 12 }}>
{t('cryptoTailMonitor.manualOrder.orderPrice')}
</Text>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ width: '100%' }}
style={{ width: '100%', height: 36 }}
value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined}
onChange={handlePriceChange}
min={0}
@@ -1284,47 +1436,65 @@ const CryptoTailMonitor: React.FC = () => {
precision={4}
placeholder="0.0000"
/>
<Button onClick={handleFetchLatestPrice} icon={<SyncOutlined />}>
<Button onClick={handleFetchLatestPrice} icon={<SyncOutlined />} style={{ height: 36, fontSize: 12 }}>
{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')})
</div>
{/* 数量输入 */}
<div style={{ marginBottom: 10 }}>
<Text type="secondary" style={{ display: 'block', marginBottom: 2, fontSize: 12 }}>
{t('cryptoTailMonitor.manualOrder.orderSize')}
</Text>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
style={{ width: '100%' }}
style={{ width: '100%', height: 36 }}
value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined}
onChange={handleSizeChange}
min={1}
precision={2}
placeholder="1"
/>
<Button onClick={handleMaxSize} type="primary" ghost>
<Button onClick={handleMaxSize} type="primary" ghost style={{ height: 36, fontSize: 12 }}>
{t('cryptoTailMonitor.manualOrder.maxSize')}
</Button>
</Space.Compact>
</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>
{/* 总金额 + 账户(一行) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>{t('cryptoTailMonitor.manualOrder.totalAmount')}: </Text>
<Text strong style={{ fontSize: 16, marginLeft: 4 }}>
{manualOrderModal.totalAmount} {t('cryptoTailMonitor.manualOrder.orderUnit')}
</Text>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>{t('cryptoTailMonitor.manualOrder.account')}: </Text>
<Text style={{ fontSize: 12 }}>{initData.accountName || `#${initData.accountId}`}</Text>
</div>
</div>
{/* 确认按钮 */}
<Button
type="primary"
block
onClick={handleManualOrder}
loading={ordering}
style={{
height: 44,
borderRadius: 8,
backgroundColor: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16',
borderColor: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16'
}}
>
{t('cryptoTailMonitor.manualOrder.confirm')}
</Button>
</div>
)}
</AntdMobilePopup>
)}
{/* 移动端底部悬浮按钮 */}
{isMobile && (
@@ -1351,7 +1521,7 @@ const CryptoTailMonitor: React.FC = () => {
loading={ordering}
style={{ flex: 1, backgroundColor: '#1890ff', borderColor: '#1890ff', height: 44, borderRadius: '6px 0 0 6px' }}
>
{t('cryptoTailMonitor.manualOrder.buttonUp')}
{t('cryptoTailMonitor.manualOrder.buttonUp')} {pushData?.currentPriceUp ? `(${formatNumber(pushData.currentPriceUp, 2)})` : ''}
</Button>
<Button
type="primary"
@@ -1361,7 +1531,7 @@ const CryptoTailMonitor: React.FC = () => {
loading={ordering}
style={{ flex: 1, backgroundColor: '#fa8c16', borderColor: '#fa8c16', height: 44, borderRadius: '0 6px 6px 0' }}
>
{t('cryptoTailMonitor.manualOrder.buttonDown')}
{t('cryptoTailMonitor.manualOrder.buttonDown')} {pushData?.currentPriceDown ? `(${formatNumber(pushData.currentPriceDown, 2)})` : ''}
</Button>
</div>
</div>