Merge branch 'dev'

Made-with: Cursor
This commit is contained in:
WrBug
2026-03-30 12:55:20 +08:00
55 changed files with 6858 additions and 1993 deletions
+22 -11
View File
@@ -29,6 +29,7 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
import FilteredOrdersList from './pages/FilteredOrdersList'
import SystemSettings from './pages/SystemSettings'
import NotificationSettingsPage from './pages/NotificationSettingsPage'
import ApiHealthStatus from './pages/ApiHealthStatus'
import RpcNodeSettings from './pages/RpcNodeSettings'
import Announcements from './pages/Announcements'
@@ -116,28 +117,37 @@ function App() {
}
}
// 优先使用订单详情中的数据,如果没有则使用 WebSocket 消息中的数据
const price = orderDetail ? parseFloat(orderDetail.price).toFixed(4) : parseFloat(order.price).toFixed(4)
const size = orderDetail ? parseFloat(orderDetail.size).toFixed(2) : parseFloat(order.original_size).toFixed(2)
const filled = orderDetail ? parseFloat(orderDetail.filled).toFixed(2) : parseFloat(order.size_matched).toFixed(2)
// 实际成交价 = original_size*price/size_matched;有成交时数量用 size_matched
const size = orderDetail ? orderDetail.size : order.original_size
const filled = orderDetail ? orderDetail.filled : order.size_matched
const sizeNum = parseFloat(size).toFixed(2)
const filledNum = parseFloat(filled).toFixed(2)
const hasFilled = parseFloat(filled) > 0
const price = orderDetail
? (orderDetail.avgFilledPrice ?? orderDetail.price)
: (hasFilled
? (parseFloat(order.original_size) * parseFloat(order.price) / parseFloat(order.size_matched)).toString()
: order.price)
const priceStr = parseFloat(price).toFixed(4)
const status = orderDetail?.status || 'UNKNOWN'
// 有成交时展示数量用 size_matchedfilled),否则用 original_size
const displaySize = (orderDetail?.avgFilledPrice || (orderDetail == null && hasFilled)) ? filledNum : sizeNum
// 构建描述信息
let description = `${t('order.market')}: ${marketName}\n${sideText} ${size} @ ${price}`
let description = `${t('order.market')}: ${marketName}\n${sideText} ${displaySize} @ ${priceStr}`
// 如果有订单详情,显示更详细的信息
if (orderDetail) {
description += `\n${t('order.status')}: ${status}`
if (parseFloat(filled) > 0) {
description += ` | ${t('order.filled')}: ${filled}`
if (parseFloat(filledNum) > 0) {
description += ` | ${t('order.filled')}: ${filledNum}`
}
const remaining = (parseFloat(size) - parseFloat(filled)).toFixed(2)
const remaining = (parseFloat(sizeNum) - parseFloat(filledNum)).toFixed(2)
if (parseFloat(remaining) > 0) {
description += ` | ${t('order.remaining')}: ${remaining}`
}
} else if (order.type === 'UPDATE' && parseFloat(order.size_matched) > 0) {
// 如果没有订单详情,使用 WebSocket 消息中的已成交数量
description += `\n${t('order.filled')}: ${filled}`
description += `\n${t('order.filled')}: ${filledNum}`
}
// 根据订单类型选择通知类型
@@ -268,6 +278,7 @@ function App() {
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
<Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
<Route path="/system-settings/notification" element={<ProtectedRoute><NotificationSettingsPage /></ProtectedRoute>} />
<Route path="/system-settings/rpc-nodes" element={<ProtectedRoute><RpcNodeSettings /></ProtectedRoute>} /> <Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
{/* 默认重定向到登录页 */}
+5
View File
@@ -207,6 +207,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
icon: <SettingOutlined />,
label: t('menu.systemOverview') || '通用设置'
},
{
key: '/system-settings/notification',
icon: <NotificationOutlined />,
label: t('menu.notifications') || '消息推送设置'
},
{
key: '/system-settings/rpc-nodes',
icon: <ApiOutlined />,
+161 -5
View File
@@ -42,7 +42,8 @@
"pageOf": "Page",
"ascending": "Ascending",
"descending": "Descending",
"day": "day"
"day": "day",
"orders": "orders"
},
"account": {
"title": "Account Management",
@@ -176,7 +177,8 @@
"updateFailed": "Failed to update account",
"getDetailFailedForEdit": "Failed to get account detail",
"loading": "Loading...",
"fetchFailed": "Failed to get account list"
"fetchFailed": "Failed to get account list",
"noData": "No account data"
},
"accountImport": {
"title": "Import Account",
@@ -660,7 +662,8 @@
"deleteSuccess": "User deleted successfully",
"deleteFailed": "Failed to delete user",
"deleteConfirm": "Are you sure you want to delete this user?",
"total": "Total {{total}} items"
"total": "Total {{total}} items",
"noData": "No user data"
},
"statistics": {
"title": "Statistics",
@@ -1121,7 +1124,9 @@
"updateStatusFailed": "Failed to update copy trading status",
"deleteSuccess": "Copy trading deleted successfully",
"deleteFailed": "Failed to delete copy trading",
"deleteConfirm": "Are you sure you want to delete this copy trading relationship?"
"deleteConfirm": "Are you sure you want to delete this copy trading relationship?",
"profitRate": "Profit Rate",
"noData": "No copy trading configuration"
},
"notificationSettings": {
"title": "Notification Settings",
@@ -1162,7 +1167,95 @@
"getChatIdsFailed": "Failed to get Chat IDs",
"getChatIdsNoToken": "Please enter Bot Token first",
"getChatIdsNoMessage": "Chat ID not found, please send a message to the bot first (e.g., /start), then retry",
"getChatIdsButton": "Get Chat ID"
"getChatIdsButton": "Get Chat ID",
"botConfig": "Bot Configuration",
"templateConfig": "Template Configuration",
"templates": {
"title": "Message Template Configuration",
"templateType": "Template Type",
"templateContent": "Template Content",
"isDefault": "Default Template",
"isCustom": "Custom Template",
"resetToDefault": "Reset to Default",
"resetConfirm": "Are you sure you want to reset to default? Your custom content will be lost.",
"resetSuccess": "Reset successfully",
"resetFailed": "Reset failed",
"saveSuccess": "Saved successfully",
"saveFailed": "Save failed",
"testSuccess": "Test message sent successfully, please check Telegram",
"testFailed": "Failed to send test message",
"variables": "Available Variables",
"clickToCopy": "Click to copy",
"copied": "Copied",
"commonVariables": "Common Variables",
"orderVariables": "Order Variables",
"copyTradingVariables": "Copy Trading Variables",
"redeemVariables": "Redeem Variables",
"errorVariables": "Error Variables",
"filterVariables": "Filter Variables",
"strategyVariables": "Strategy Variables",
"contentPlaceholder": "Enter template content here, use {{variable}} to insert dynamic content",
"variableLabels": {
"account_name": "Account Name",
"wallet_address": "Wallet Address",
"time": "Time",
"order_id": "Order ID",
"market_title": "Market Title",
"market_link": "Market Link",
"side": "Side",
"outcome": "Outcome",
"price": "Price",
"quantity": "Quantity",
"amount": "Amount",
"available_balance": "Available Balance",
"leader_name": "Leader Name",
"config_name": "Copy Trading Config Name",
"error_message": "Error Message",
"filter_type": "Filter Type",
"filter_reason": "Filter Reason",
"strategy_name": "Strategy Name",
"transaction_hash": "Transaction Hash",
"total_value": "Total Redeem Value"
},
"variableDescriptions": {
"account_name": "Account name executing the order",
"wallet_address": "Wallet address (masked)",
"time": "Notification send time",
"order_id": "Unique order identifier",
"market_title": "Market/Event name",
"market_link": "Polymarket market link",
"side": "Order direction (Buy/Sell)",
"outcome": "Market outcome (YES/NO, etc.)",
"price": "Order price",
"quantity": "Order quantity (shares)",
"amount": "Order amount (USDC)",
"available_balance": "Account available balance (USDC)",
"leader_name": "Leader name/alias being copied",
"config_name": "Copy trading configuration name",
"error_message": "Order failure reason",
"filter_type": "Type of order filter",
"filter_reason": "Detailed reason for order filtering",
"strategy_name": "Crypto spread strategy name",
"transaction_hash": "Redeem transaction hash",
"total_value": "Total redeem value (USDC)"
}
},
"templateTypes": {
"ORDER_SUCCESS": "Order Success",
"ORDER_FAILED": "Order Failed",
"ORDER_FILTERED": "Order Filtered",
"CRYPTO_TAIL_SUCCESS": "Crypto Spread Strategy Success",
"REDEEM_SUCCESS": "Position Redeem Success",
"REDEEM_NO_RETURN": "Position Settled (No Return)"
},
"templateTypeDescriptions": {
"ORDER_SUCCESS": "Notification sent when order is successfully created",
"ORDER_FAILED": "Notification sent when order creation fails",
"ORDER_FILTERED": "Notification sent when order is filtered by risk control",
"CRYPTO_TAIL_SUCCESS": "Notification sent when crypto spread strategy order succeeds",
"REDEEM_SUCCESS": "Notification sent when position is successfully redeemed",
"REDEEM_NO_RETURN": "Notification sent when position is settled with no return"
}
},
"telegramConfig": {
"title": "Telegram Configuration Guide",
@@ -1351,6 +1444,8 @@
"title": "Backtest",
"taskName": "Task Name",
"leader": "Leader",
"balance": "Balance (Init→Final)",
"profit": "Profit (Amount/Rate)",
"initialBalance": "Initial Balance",
"backtestDays": "Backtest Days",
"status": "Status",
@@ -1399,6 +1494,7 @@
"createCopyTradingSuccess": "Copy trading config created successfully",
"noTasks": "No backtest tasks",
"noTrades": "No trade records",
"noData": "No backtest data",
"fetchTasksFailed": "Failed to fetch task list",
"fetchTaskDetailFailed": "Failed to fetch task detail",
"fetchTradesFailed": "Failed to fetch trade records",
@@ -1496,7 +1592,9 @@
"strategyName": "Strategy Name",
"account": "Account",
"market": "Market",
"marketAndTime": "Market / Time",
"timeWindow": "Time Window",
"config": "Config",
"priceRange": "Price Range",
"amountMode": "Amount Mode",
"ratio": "Ratio",
@@ -1509,6 +1607,7 @@
"disable": "Disable",
"delete": "Delete",
"viewTriggers": "Orders",
"viewPnlCurve": "PnL Curve",
"deleteConfirm": "Delete this strategy?",
"fetchFailed": "Failed to fetch list",
"configGuide": "Configuration Guide"
@@ -1576,6 +1675,20 @@
"emptySuccess": "No success records",
"emptyFail": "No failed records",
"totalCount": "{count} record(s)"
},
"pnlCurve": {
"title": "PnL Curve",
"totalPnl": "Total PnL",
"settledCount": "Settled",
"winRate": "Win Rate",
"maxDrawdown": "Max Drawdown",
"timeRange": "Time Range",
"today": "Today",
"last7Days": "Last 7 Days",
"last30Days": "Last 30 Days",
"all": "All",
"customRange": "Custom",
"empty": "No settled orders yet, cannot show PnL curve"
}
},
"cryptoTailMonitor": {
@@ -1644,6 +1757,49 @@
"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",
"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",
"periodChanged": "Period has changed, popup closed"
}
}
}
+161 -5
View File
@@ -42,7 +42,8 @@
"copyFailed": "复制失败",
"ascending": "升序",
"descending": "降序",
"day": "天"
"day": "天",
"orders": "单"
},
"login": {
"title": "登录",
@@ -176,7 +177,8 @@
"updateFailed": "更新账户失败",
"getDetailFailedForEdit": "获取账户详情失败",
"loading": "加载中...",
"fetchFailed": "获取账户列表失败"
"fetchFailed": "获取账户列表失败",
"noData": "暂无账户数据"
},
"accountImport": {
"title": "导入账户",
@@ -660,7 +662,8 @@
"deleteSuccess": "删除用户成功",
"deleteFailed": "删除用户失败",
"deleteConfirm": "确定要删除这个用户吗?",
"total": "共 {{total}} 条"
"total": "共 {{total}} 条",
"noData": "暂无用户数据"
},
"statistics": {
"title": "统计信息",
@@ -1121,7 +1124,9 @@
"updateStatusFailed": "更新跟单状态失败",
"deleteSuccess": "删除跟单成功",
"deleteFailed": "删除跟单失败",
"deleteConfirm": "确定要删除这个跟单关系吗?"
"deleteConfirm": "确定要删除这个跟单关系吗?",
"profitRate": "收益率",
"noData": "暂无跟单配置"
},
"notificationSettings": {
"title": "消息推送设置",
@@ -1162,7 +1167,95 @@
"getChatIdsFailed": "获取 Chat IDs 失败",
"getChatIdsNoToken": "请先填写 Bot Token",
"getChatIdsNoMessage": "未找到 Chat ID,请先向机器人发送一条消息(如 /start),然后重试",
"getChatIdsButton": "获取 Chat ID"
"getChatIdsButton": "获取 Chat ID",
"botConfig": "机器人配置",
"templateConfig": "模板配置",
"templates": {
"title": "消息模板配置",
"templateType": "模板类型",
"templateContent": "模板内容",
"isDefault": "默认模板",
"isCustom": "自定义模板",
"resetToDefault": "重置为默认",
"resetConfirm": "确定要重置为默认模板吗?您的自定义内容将丢失。",
"resetSuccess": "重置成功",
"resetFailed": "重置失败",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"testSuccess": "测试消息发送成功,请检查 Telegram",
"testFailed": "测试消息发送失败",
"variables": "可用变量",
"clickToCopy": "点击复制",
"copied": "已复制",
"commonVariables": "通用变量",
"orderVariables": "订单变量",
"copyTradingVariables": "跟单变量",
"redeemVariables": "赎回变量",
"errorVariables": "错误变量",
"filterVariables": "过滤变量",
"strategyVariables": "策略变量",
"contentPlaceholder": "在此输入模板内容,使用 {{变量名}} 插入动态内容",
"variableLabels": {
"account_name": "账户名称",
"wallet_address": "钱包地址",
"time": "时间",
"order_id": "订单ID",
"market_title": "市场标题",
"market_link": "市场链接",
"side": "方向",
"outcome": "市场方向",
"price": "价格",
"quantity": "数量",
"amount": "金额",
"available_balance": "可用余额",
"leader_name": "Leader 名称",
"config_name": "跟单配置名",
"error_message": "错误信息",
"filter_type": "过滤类型",
"filter_reason": "过滤原因",
"strategy_name": "策略名称",
"transaction_hash": "交易哈希",
"total_value": "赎回总价值"
},
"variableDescriptions": {
"account_name": "执行订单的账户名称",
"wallet_address": "钱包地址(已脱敏)",
"time": "通知发送时间",
"order_id": "订单唯一标识",
"market_title": "市场/事件名称",
"market_link": "Polymarket 市场链接",
"side": "订单方向(买入/卖出)",
"outcome": "市场方向(YES/NO 等)",
"price": "订单价格",
"quantity": "订单数量(shares",
"amount": "订单金额(USDC",
"available_balance": "账户可用余额(USDC",
"leader_name": "跟单的 Leader 名称/备注",
"config_name": "跟单配置名称",
"error_message": "订单失败原因",
"filter_type": "订单被过滤的类型",
"filter_reason": "订单被过滤的详细原因",
"strategy_name": "加密价差策略名称",
"transaction_hash": "赎回交易的哈希值",
"total_value": "赎回的总价值(USDC"
}
},
"templateTypes": {
"ORDER_SUCCESS": "订单成功通知",
"ORDER_FAILED": "订单失败通知",
"ORDER_FILTERED": "订单过滤通知",
"CRYPTO_TAIL_SUCCESS": "加密价差策略成功通知",
"REDEEM_SUCCESS": "仓位赎回成功通知",
"REDEEM_NO_RETURN": "仓位结算(无收益)通知"
},
"templateTypeDescriptions": {
"ORDER_SUCCESS": "订单创建成功时发送的通知",
"ORDER_FAILED": "订单创建失败时发送的通知",
"ORDER_FILTERED": "订单被风控过滤时发送的通知",
"CRYPTO_TAIL_SUCCESS": "加密价差策略下单成功时发送的通知",
"REDEEM_SUCCESS": "仓位赎回成功时发送的通知",
"REDEEM_NO_RETURN": "仓位结算但无收益时发送的通知"
}
},
"telegramConfig": {
"title": "Telegram 配置说明",
@@ -1351,6 +1444,8 @@
"title": "回测",
"taskName": "任务名称",
"leader": "Leader",
"balance": "资金 (初始→最终)",
"profit": "收益 (金额/比例)",
"initialBalance": "初始资金",
"backtestDays": "回测天数",
"status": "状态",
@@ -1399,6 +1494,7 @@
"createCopyTradingSuccess": "跟单配置创建成功",
"noTasks": "暂无回测任务",
"noTrades": "暂无交易记录",
"noData": "暂无回测数据",
"fetchTasksFailed": "获取任务列表失败",
"fetchTaskDetailFailed": "获取任务详情失败",
"fetchTradesFailed": "获取交易记录失败",
@@ -1496,7 +1592,9 @@
"strategyName": "策略名称",
"account": "账户",
"market": "关联市场",
"marketAndTime": "市场/时间",
"timeWindow": "时间区间",
"config": "配置",
"priceRange": "价格区间",
"amountMode": "投入方式",
"ratio": "比例",
@@ -1509,6 +1607,7 @@
"disable": "停用",
"delete": "删除",
"viewTriggers": "订单",
"viewPnlCurve": "收益曲线",
"deleteConfirm": "确定删除该策略?",
"fetchFailed": "获取列表失败",
"configGuide": "配置指南"
@@ -1576,6 +1675,20 @@
"emptySuccess": "暂无成功记录",
"emptyFail": "暂无失败记录",
"totalCount": "共 {count} 条"
},
"pnlCurve": {
"title": "收益曲线",
"totalPnl": "总收益",
"settledCount": "已结算笔数",
"winRate": "胜率",
"maxDrawdown": "最大回撤",
"timeRange": "时间范围",
"today": "今日",
"last7Days": "近7天",
"last30Days": "近30天",
"all": "全部",
"customRange": "自定义",
"empty": "暂无已结算订单,无法展示收益曲线"
}
},
"cryptoTailMonitor": {
@@ -1644,6 +1757,49 @@
"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": "价格数据未加载",
"insufficientBalance": "余额不足,可用金额少于 1 USDC",
"fetchBalanceFailed": "获取账户余额失败",
"availableBalance": "可用余额",
"fetchLatestPrice": "获取最新价",
"priceUpdated": "价格已更新",
"maxSize": "最大",
"invalidPriceOrBalance": "价格或余额无效",
"insufficientBalanceForMax": "余额不足以购买最大数量",
"maxSizeUpdated": "已更新为最大数量",
"periodChanged": "周期已切换,弹窗已关闭"
}
}
}
+161 -5
View File
@@ -42,7 +42,8 @@
"pageOf": "第",
"ascending": "升序",
"descending": "降序",
"day": "天"
"day": "天",
"orders": "單"
},
"account": {
"title": "賬戶管理",
@@ -176,7 +177,8 @@
"updateFailed": "更新賬戶失敗",
"getDetailFailedForEdit": "獲取賬戶詳情失敗",
"loading": "加載中...",
"fetchFailed": "獲取賬戶列表失敗"
"fetchFailed": "獲取賬戶列表失敗",
"noData": "暫無賬戶數據"
},
"accountImport": {
"title": "導入賬戶",
@@ -660,7 +662,8 @@
"deleteSuccess": "刪除用戶成功",
"deleteFailed": "刪除用戶失敗",
"deleteConfirm": "確定要刪除這個用戶嗎?",
"total": "共 {{total}} 條"
"total": "共 {{total}} 條",
"noData": "暫無用戶數據"
},
"statistics": {
"title": "統計信息",
@@ -1121,7 +1124,9 @@
"updateStatusFailed": "更新跟單狀態失敗",
"deleteSuccess": "刪除跟單成功",
"deleteFailed": "刪除跟單失敗",
"deleteConfirm": "確定要刪除這個跟單關係嗎?"
"deleteConfirm": "確定要刪除這個跟單關係嗎?",
"profitRate": "收益率",
"noData": "暫無跟單配置"
},
"notificationSettings": {
"title": "消息推送設置",
@@ -1162,7 +1167,95 @@
"getChatIdsFailed": "獲取 Chat IDs 失敗",
"getChatIdsNoToken": "請先填寫 Bot Token",
"getChatIdsNoMessage": "未找到 Chat ID,請先向機器人發送一條消息(如 /start),然後重試",
"getChatIdsButton": "獲取 Chat ID"
"getChatIdsButton": "獲取 Chat ID",
"botConfig": "機器人配置",
"templateConfig": "模板配置",
"templates": {
"title": "消息模板配置",
"templateType": "模板類型",
"templateContent": "模板內容",
"isDefault": "默認模板",
"isCustom": "自定義模板",
"resetToDefault": "重置為默認",
"resetConfirm": "確定要重置為默認模板嗎?您的自定義內容將丟失。",
"resetSuccess": "重置成功",
"resetFailed": "重置失敗",
"saveSuccess": "保存成功",
"saveFailed": "保存失敗",
"testSuccess": "測試消息發送成功,請檢查 Telegram",
"testFailed": "測試消息發送失敗",
"variables": "可用變量",
"clickToCopy": "點擊複製",
"copied": "已複製",
"commonVariables": "通用變量",
"orderVariables": "訂單變量",
"copyTradingVariables": "跟單變量",
"redeemVariables": "贖回變量",
"errorVariables": "錯誤變量",
"filterVariables": "過濾變量",
"strategyVariables": "策略變量",
"contentPlaceholder": "在此輸入模板內容,使用 {{變量名}} 插入動態內容",
"variableLabels": {
"account_name": "賬戶名稱",
"wallet_address": "錢包地址",
"time": "時間",
"order_id": "訂單ID",
"market_title": "市場標題",
"market_link": "市場鏈接",
"side": "方向",
"outcome": "市場方向",
"price": "價格",
"quantity": "數量",
"amount": "金額",
"available_balance": "可用餘額",
"leader_name": "Leader 名稱",
"config_name": "跟單配置名",
"error_message": "錯誤信息",
"filter_type": "過濾類型",
"filter_reason": "過濾原因",
"strategy_name": "策略名稱",
"transaction_hash": "交易哈希",
"total_value": "贖回總價值"
},
"variableDescriptions": {
"account_name": "執行訂單的賬戶名稱",
"wallet_address": "錢包地址(已脫敏)",
"time": "通知發送時間",
"order_id": "訂單唯一標識",
"market_title": "市場/事件名稱",
"market_link": "Polymarket 市場鏈接",
"side": "訂單方向(買入/賣出)",
"outcome": "市場方向(YES/NO 等)",
"price": "訂單價格",
"quantity": "訂單數量(shares",
"amount": "訂單金額(USDC",
"available_balance": "賬戶可用餘額(USDC",
"leader_name": "跟單的 Leader 名稱/備註",
"config_name": "跟單配置名稱",
"error_message": "訂單失敗原因",
"filter_type": "訂單被過濾的類型",
"filter_reason": "訂單被過濾的詳細原因",
"strategy_name": "加密價差策略名稱",
"transaction_hash": "贖回交易的哈希值",
"total_value": "贖回的總價值(USDC"
}
},
"templateTypes": {
"ORDER_SUCCESS": "訂單成功通知",
"ORDER_FAILED": "訂單失敗通知",
"ORDER_FILTERED": "訂單過濾通知",
"CRYPTO_TAIL_SUCCESS": "加密價差策略成功通知",
"REDEEM_SUCCESS": "倉位贖回成功通知",
"REDEEM_NO_RETURN": "倉位結算(無收益)通知"
},
"templateTypeDescriptions": {
"ORDER_SUCCESS": "訂單創建成功時發送的通知",
"ORDER_FAILED": "訂單創建失敗時發送的通知",
"ORDER_FILTERED": "訂單被風控過濾時發送的通知",
"CRYPTO_TAIL_SUCCESS": "加密價差策略下單成功時發送的通知",
"REDEEM_SUCCESS": "倉位贖回成功時發送的通知",
"REDEEM_NO_RETURN": "倉位結算但無收益時發送的通知"
}
},
"telegramConfig": {
"title": "Telegram 配置說明",
@@ -1351,6 +1444,8 @@
"title": "回測",
"taskName": "任務名稱",
"leader": "Leader",
"balance": "資金 (初始→最終)",
"profit": "收益 (金額/比例)",
"initialBalance": "初始資金",
"backtestDays": "回測天數",
"status": "狀態",
@@ -1399,6 +1494,7 @@
"createCopyTradingSuccess": "跟單配置創建成功",
"noTasks": "暫無回測任務",
"noTrades": "暫無交易記錄",
"noData": "暫無回測數據",
"fetchTasksFailed": "獲取任務列表失敗",
"fetchTaskDetailFailed": "獲取任務詳情失敗",
"fetchTradesFailed": "獲取交易記錄失敗",
@@ -1496,7 +1592,9 @@
"strategyName": "策略名稱",
"account": "賬戶",
"market": "關聯市場",
"marketAndTime": "市場/時間",
"timeWindow": "時間區間",
"config": "配置",
"priceRange": "價格區間",
"amountMode": "投入方式",
"ratio": "比例",
@@ -1509,6 +1607,7 @@
"disable": "停用",
"delete": "刪除",
"viewTriggers": "訂單",
"viewPnlCurve": "收益曲線",
"deleteConfirm": "確定刪除該策略?",
"fetchFailed": "獲取列表失敗",
"configGuide": "配置指南"
@@ -1576,6 +1675,20 @@
"emptySuccess": "暫無成功記錄",
"emptyFail": "暫無失敗記錄",
"totalCount": "共 {count} 條"
},
"pnlCurve": {
"title": "收益曲線",
"totalPnl": "總收益",
"settledCount": "已結算筆數",
"winRate": "勝率",
"maxDrawdown": "最大回撤",
"timeRange": "時間範圍",
"today": "今日",
"last7Days": "近7天",
"last30Days": "近30天",
"all": "全部",
"customRange": "自定義",
"empty": "暫無已結算訂單,無法展示收益曲線"
}
},
"cryptoTailMonitor": {
@@ -1644,6 +1757,49 @@
"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": "價格數據未加載",
"insufficientBalance": "餘額不足,可用金額少於 1 USDC",
"fetchBalanceFailed": "獲取賬戶餘額失敗",
"availableBalance": "可用餘額",
"fetchLatestPrice": "獲取最新價",
"priceUpdated": "價格已更新",
"maxSize": "最大",
"invalidPriceOrBalance": "價格或餘額無效",
"insufficientBalanceForMax": "餘額不足以購買最大數量",
"maxSizeUpdated": "已更新為最大數量",
"periodChanged": "週期已切換,彈窗已關閉"
}
}
}
+232 -190
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert } from 'antd'
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert, Tooltip, List, Empty } from 'antd'
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
@@ -331,171 +331,79 @@ const AccountList: React.FC = () => {
{
title: t('accountList.action'),
key: 'action',
width: 140,
render: (_: any, record: Account) => (
<Space size="small">
<Button
type="link"
size="small"
onClick={() => handleShowDetail(record)}
>
{t('accountList.detail')}
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleShowEdit(record)}
>
{t('accountList.edit')}
</Button>
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
onConfirm={() => handleDelete(record)}
okText={t('accountList.deleteConfirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Button type="link" size="small" danger>
{t('accountList.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
const mobileColumns = [
{
title: t('accountList.accountName'),
key: 'info',
render: (_: any, record: Account) => {
return (
<div style={{ padding: '8px 0' }}>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
fontSize: '16px'
}}>
{record.accountName || `${t('accountList.accountName')} ${record.id}`}
</div>
<div style={{
fontSize: '11px',
color: '#666',
marginBottom: '8px',
wordBreak: 'break-all',
fontFamily: 'monospace',
lineHeight: '1.4'
}}>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress ? `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}` : '-'}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation()
handleCopy(record.walletAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation()
handleCopy(record.proxyAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
{record.walletType && (
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.walletType')}:</strong>{' '}
<Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}>
{record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</div>
)}
</div>
<div style={{
fontSize: '14px',
fontWeight: '500',
color: '#1890ff'
}}>
{t('accountList.totalBalance')}: {balanceLoading[record.id] ? (
<Spin size="small" style={{ marginLeft: '4px' }} />
) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? (
`${formatUSDC(balanceMap[record.id].total)} USDC`
) : (
'-'
)}
</div>
{balanceMap[record.id] && balanceMap[record.id].available !== '-' && (
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px'
}}>
{t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC
</div>
)}
</div>
)
}
},
{
title: t('accountList.action'),
key: 'action',
width: 100,
render: (_: any, record: Account) => (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Button
type="primary"
size="small"
block
onClick={() => handleShowDetail(record)}
style={{ minHeight: '32px' }}
>
{t('accountList.viewDetail')}
</Button>
<Button
size="small"
block
icon={<EditOutlined />}
onClick={() => handleShowEdit(record)}
style={{ minHeight: '32px' }}
>
{t('accountList.edit')}
</Button>
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
onConfirm={() => handleDelete(record)}
okText={t('accountList.deleteConfirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Button
size="small"
block
danger
style={{ minHeight: '32px' }}
<Space size={4}>
<Tooltip title={t('accountList.detail')}>
<div
onClick={() => handleShowDetail(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
{t('accountList.delete')}
</Button>
<EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={t('accountList.edit')}>
<div
onClick={() => handleShowEdit(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
record.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
onConfirm={() => handleDelete(record)}
okText={t('accountList.deleteConfirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Tooltip title={t('accountList.delete')}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm>
</Space>
)
@@ -519,16 +427,15 @@ const AccountList: React.FC = () => {
<Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>
{t('accountList.title')}
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setAccountImportModalVisible(true)}
size={isMobile ? 'middle' : 'large'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
>
{t('accountList.importAccount')}
</Button>
<Tooltip title={t('accountList.importAccount')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setAccountImportModalVisible(true)}
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
/>
</Tooltip>
</div>
<Card style={{
@@ -536,21 +443,156 @@ const AccountList: React.FC = () => {
borderRadius: isMobile ? '0' : undefined
}}>
{isMobile ? (
<Table
dataSource={accounts}
columns={mobileColumns}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: false,
simple: true,
size: 'small'
}}
scroll={{ x: 'max-content' }}
size="small"
style={{ fontSize: '14px' }}
/>
loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : accounts.length === 0 ? (
<Empty description={t('accountList.noData')} />
) : (
<List
dataSource={accounts}
renderItem={(account) => {
const balance = balanceMap[account.id]
return (
<Card
key={account.id}
style={{
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: '0' }}
>
{/* 头部区域 */}
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<WalletOutlined style={{ fontSize: '14px' }} />
<span>{account.accountName || `${t('accountList.accountName')} ${account.id}`}</span>
</div>
<div style={{ fontSize: '10px', opacity: '0.85', fontFamily: 'monospace', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{account.walletAddress ? `${account.walletAddress.slice(0, 6)}...${account.walletAddress.slice(-4)}` : '-'}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined style={{ fontSize: '12px', color: 'rgba(255,255,255,0.85)' }} />}
onClick={() => handleCopy(account.walletAddress)}
style={{ padding: '0 4px', height: 'auto' }}
/>
</div>
</div>
{/* 资产区域 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#fafafa',
borderBottom: '1px solid #f0f0f0',
minHeight: '42px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('accountList.totalBalance')}
</div>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
{balance?.total && balance.total !== '-' ? `${formatUSDC(balance.total)} USDC` : '- USDC'}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('accountList.walletType')}
</div>
<div style={{ fontSize: '12px' }}>
{account.walletType ? (
<Tag color={account.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ margin: 0 }}>
{account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
) : '-'}
</div>
</div>
</div>
</div>
{/* 地址信息区域 */}
<div style={{
padding: '8px 12px',
fontSize: '11px',
color: '#8c8c8c',
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{t('accountList.proxyAddress')}: {account.proxyAddress ? `${account.proxyAddress.slice(0, 6)}...${account.proxyAddress.slice(-4)}` : '-'}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined style={{ fontSize: '12px' }} />}
onClick={() => handleCopy(account.proxyAddress)}
style={{ padding: '0 4px', height: 'auto' }}
/>
</div>
</div>
{/* 图标操作栏 */}
<div style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center'
}}>
<Tooltip title={t('accountList.detail')}>
<div
onClick={() => handleShowDetail(account)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.detail')}</span>
</div>
</Tooltip>
<Tooltip title={t('accountList.edit')}>
<div
onClick={() => handleShowEdit(account)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.edit')}</span>
</div>
</Tooltip>
<Popconfirm
title={t('accountList.deleteConfirm')}
description={
account.apiKeyConfigured
? t('accountList.deleteConfirmDesc')
: t('accountList.deleteConfirmDescSimple')
}
onConfirm={() => handleDelete(account)}
okText={t('accountList.deleteConfirmOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Tooltip title={t('accountList.delete')}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('accountList.delete')}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
</Card>
)
}}
/>
)
) : (
<Table
dataSource={accounts}
+396 -240
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions } from 'antd'
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions, List, Empty, Spin, Tooltip, Popconfirm } from 'antd'
import { useTranslation } from 'react-i18next'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons'
import { formatUSDC } from '../utils'
@@ -118,49 +118,35 @@ const BacktestList: React.FC = () => {
}
// 删除任务
const handleDelete = (id: number) => {
Modal.confirm({
title: t('backtest.deleteConfirm'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
try {
const response = await backtestService.delete({ id })
if (response.data.code === 0) {
message.success(t('backtest.deleteSuccess'))
fetchTasks()
} else {
message.error(response.data.msg || t('backtest.deleteFailed'))
}
} catch (error) {
console.error('Failed to delete backtest task:', error)
message.error(t('backtest.deleteFailed'))
}
const handleDelete = async (id: number) => {
try {
const response = await backtestService.delete({ id })
if (response.data.code === 0) {
message.success(t('backtest.deleteSuccess'))
fetchTasks()
} else {
message.error(response.data.msg || t('backtest.deleteFailed'))
}
})
} catch (error) {
console.error('Failed to delete backtest task:', error)
message.error(t('backtest.deleteFailed'))
}
}
// 停止任务
const handleStop = (id: number) => {
Modal.confirm({
title: t('backtest.stopConfirm'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
try {
const response = await backtestService.stop({ id })
if (response.data.code === 0) {
message.success(t('backtest.stopSuccess'))
fetchTasks()
} else {
message.error(response.data.msg || t('backtest.stopFailed'))
}
} catch (error) {
console.error('Failed to stop backtest task:', error)
message.error(t('backtest.stopFailed'))
}
const handleStop = async (id: number) => {
try {
const response = await backtestService.stop({ id })
if (response.data.code === 0) {
message.success(t('backtest.stopSuccess'))
fetchTasks()
} else {
message.error(response.data.msg || t('backtest.stopFailed'))
}
})
} catch (error) {
console.error('Failed to stop backtest task:', error)
message.error(t('backtest.stopFailed'))
}
}
// 按配置重新测试(仅已完成任务)
@@ -502,168 +488,227 @@ const BacktestList: React.FC = () => {
title: t('backtest.taskName'),
dataIndex: 'taskName',
key: 'taskName',
width: isMobile ? 120 : 150
width: isMobile ? 120 : 140,
ellipsis: true
},
{
title: t('backtest.leader'),
dataIndex: 'leaderName',
key: 'leaderName',
width: isMobile ? 100 : 150,
width: isMobile ? 100 : 120,
ellipsis: true,
render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}`
},
{
title: t('backtest.initialBalance'),
dataIndex: 'initialBalance',
key: 'initialBalance',
width: 120,
render: (value: string) => formatUSDC(value)
title: t('backtest.balance'),
key: 'balance',
width: 160,
render: (_: any, record: BacktestTaskDto) => (
<div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{formatUSDC(record.initialBalance)}</div>
<div style={{ fontWeight: 500 }}>{record.finalBalance ? formatUSDC(record.finalBalance) : '-'}</div>
</div>
)
},
{
title: t('backtest.finalBalance'),
dataIndex: 'finalBalance',
key: 'finalBalance',
width: 120,
render: (value: string | null) => value ? formatUSDC(value) : '-'
},
{
title: t('backtest.profitAmount'),
dataIndex: 'profitAmount',
key: 'profitAmount',
width: 120,
render: (value: string | null) => value ? (
<span style={{ color: parseFloat(value) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{formatUSDC(value)}
</span>
) : '-'
},
{
title: t('backtest.profitRate'),
dataIndex: 'profitRate',
key: 'profitRate',
width: 100,
render: (value: string | null) => value ? (
<span style={{ color: parseFloat(value) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{value}%
</span>
) : '-'
},
{
title: t('backtest.backtestDays'),
dataIndex: 'backtestDays',
key: 'backtestDays',
width: 100,
render: (value: number) => `${value} ${t('common.day')}`
title: t('backtest.profit'),
key: 'profit',
width: 140,
render: (_: any, record: BacktestTaskDto) => {
const profitAmount = record.profitAmount ? parseFloat(record.profitAmount) : null
const profitRate = record.profitRate ? parseFloat(record.profitRate) : null
const color = profitAmount !== null ? (profitAmount >= 0 ? '#52c41a' : '#ff4d4f') : undefined
return (
<div>
<div style={{ fontWeight: 500, color }}>
{profitAmount !== null ? formatUSDC(record.profitAmount) : '-'}
</div>
<div style={{ fontSize: 12, color }}>
{profitRate !== null ? `${record.profitRate}%` : '-'}
</div>
</div>
)
}
},
{
title: t('backtest.status'),
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => (
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
)
},
{
title: t('backtest.progress'),
dataIndex: 'progress',
key: 'progress',
width: 120,
render: (progress: number) => (
<div style={{ width: '100%' }}>
<div style={{ marginBottom: 4 }}>{progress}%</div>
<div style={{ width: '100%', height: 6, backgroundColor: '#f0f0f0', borderRadius: 3 }}>
<div
style={{
width: `${progress}%`,
height: '100%',
backgroundColor: progress === 100 ? '#52c41a' : '#1890ff',
borderRadius: 3,
transition: 'width 0.3s ease'
}}
/>
width: 130,
render: (status: string, record: BacktestTaskDto) => {
const isRunning = status === 'RUNNING' || status === 'PENDING'
return (
<div>
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
{isRunning && record.progress !== undefined && (
<div style={{ marginTop: 4, fontSize: 11, color: '#8c8c8c' }}>
{record.progress}%
</div>
)}
</div>
</div>
)
)
}
},
{
title: t('backtest.totalTrades'),
dataIndex: 'totalTrades',
key: 'totalTrades',
width: 100
width: 80,
align: 'center' as const
},
{
title: t('backtest.createdAt'),
dataIndex: 'createdAt',
key: 'createdAt',
width: isMobile ? 150 : 180,
width: isMobile ? 150 : 150,
render: (timestamp: number) => new Date(timestamp).toLocaleString()
},
{
title: t('common.actions'),
key: 'actions',
fixed: isMobile ? false : ('right' as const),
width: isMobile ? 100 : 150,
width: isMobile ? 100 : 180,
render: (_: any, record: BacktestTaskDto) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.id)}
>
{t('common.viewDetail')}
</Button>
<Space size={4}>
<Tooltip title={t('common.viewDetail')}>
<div
onClick={() => handleViewDetail(record.id)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
{record.status === 'COMPLETED' && (
<>
<Button
type="link"
size="small"
icon={<SyncOutlined />}
onClick={() => handleRerun(record)}
>
{t('backtest.rerun')}
</Button>
<Button
type="link"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCreateCopyTrading(record)}
>
{t('backtest.createCopyTrading')}
</Button>
<Tooltip title={t('backtest.rerun')}>
<div
onClick={() => handleRerun(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<SyncOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={t('backtest.createCopyTrading')}>
<div
onClick={() => handleCreateCopyTrading(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
</>
)}
{record.status === 'RUNNING' && (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
onClick={() => handleStop(record.id)}
<Popconfirm
title={t('backtest.stopConfirm')}
onConfirm={() => handleStop(record.id)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
{t('backtest.stop')}
</Button>
<Tooltip title={t('backtest.stop')}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<StopOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
</Popconfirm>
)}
{(record.status === 'STOPPED' || record.status === 'FAILED') && (
<Button
type="link"
size="small"
icon={<RedoOutlined />}
onClick={() => handleRetry(record.id)}
>
{t('backtest.retry')}
</Button>
<Tooltip title={t('backtest.retry')}>
<div
onClick={() => handleRetry(record.id)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<RedoOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
)}
{(record.status === 'PENDING' || record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && (
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record.id)}
<Popconfirm
title={t('backtest.deleteConfirm')}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
{t('common.delete')}
</Button>
<Tooltip title={t('common.delete')}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm>
)}
</Space>
)
@@ -671,94 +716,205 @@ const BacktestList: React.FC = () => {
]
return (
<div style={{ padding: 24 }}>
<Card>
<div style={{ padding: isMobile ? 0 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px', padding: isMobile ? '0 8px' : 0 }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('backtest.title')}</h2>
<Space size={8}>
<Tooltip title={t('backtest.createTask')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreate}
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
/>
</Tooltip>
<Tooltip title={t('common.refresh')}>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={loading} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }} />
</Tooltip>
</Space>
</div>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* 头部操作栏 */}
<Row justify="space-between" align="middle" gutter={[16, 16]}>
<Col xs={24} sm={24} md={12} lg={16}>
<Space size="middle" direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
<LeaderSelect
style={{ width: isMobile ? '100%' : 180 }}
placeholder={t('backtest.leader')}
allowClear
value={leaderIdFilter}
onChange={(value) => setLeaderIdFilter(value)}
leaders={leaders}
/>
<Select
style={{ width: isMobile ? '100%' : 150 }}
placeholder={t('backtest.status')}
allowClear
onChange={(value) => setStatusFilter(value)}
value={statusFilter}
>
<Select.Option value="PENDING">{t('backtest.statusPending')}</Select.Option>
<Select.Option value="RUNNING">{t('backtest.statusRunning')}</Select.Option>
<Select.Option value="COMPLETED">{t('backtest.statusCompleted')}</Select.Option>
<Select.Option value="STOPPED">{t('backtest.statusStopped')}</Select.Option>
<Select.Option value="FAILED">{t('backtest.statusFailed')}</Select.Option>
</Select>
<Select
style={{ width: isMobile ? '100%' : 150 }}
placeholder={t('backtest.sortBy')}
onChange={(value) => setSortBy(value)}
value={sortBy}
>
<Select.Option value="profitAmount">{t('backtest.profitAmount')}</Select.Option>
<Select.Option value="profitRate">{t('backtest.profitRate')}</Select.Option>
<Select.Option value="createdAt">{t('backtest.createdAt')}</Select.Option>
</Select>
<Select
style={{ width: isMobile ? '100%' : 120 }}
placeholder={t('backtest.sortOrder')}
onChange={(value) => setSortOrder(value)}
value={sortOrder}
>
<Select.Option value="asc">{t('common.ascending')}</Select.Option>
<Select.Option value="desc">{t('common.descending')}</Select.Option>
</Select>
</Space>
{/* 筛选栏 */}
<Row gutter={[16, 16]} style={{ marginBottom: isMobile ? 12 : 0 }}>
<Col xs={24} sm={12} md={6}>
<LeaderSelect
style={{ width: '100%' }}
placeholder={t('backtest.leader')}
allowClear
value={leaderIdFilter}
onChange={(value) => setLeaderIdFilter(value)}
leaders={leaders}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={8} style={{ textAlign: isMobile ? 'left' : 'right' }}>
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreate}
style={{ flex: isMobile ? 1 : undefined }}
>
{isMobile ? t('common.create') : t('backtest.createTask')}
</Button>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
style={{ flex: isMobile ? 1 : undefined }}
>
{t('common.refresh')}
</Button>
</Space>
<Col xs={24} sm={12} md={6}>
<Select
style={{ width: '100%' }}
placeholder={t('backtest.status')}
allowClear
onChange={(value) => setStatusFilter(value)}
value={statusFilter}
>
<Select.Option value="PENDING">{t('backtest.statusPending')}</Select.Option>
<Select.Option value="RUNNING">{t('backtest.statusRunning')}</Select.Option>
<Select.Option value="COMPLETED">{t('backtest.statusCompleted')}</Select.Option>
<Select.Option value="STOPPED">{t('backtest.statusStopped')}</Select.Option>
<Select.Option value="FAILED">{t('backtest.statusFailed')}</Select.Option>
</Select>
</Col>
<Col xs={24} sm={12} md={6}>
<Select style={{ width: '100%' }} placeholder={t('backtest.sortBy')} onChange={(value) => setSortBy(value)} value={sortBy}>
<Select.Option value="profitAmount">{t('backtest.profitAmount')}</Select.Option>
<Select.Option value="profitRate">{t('backtest.profitRate')}</Select.Option>
<Select.Option value="createdAt">{t('backtest.createdAt')}</Select.Option>
</Select>
</Col>
<Col xs={24} sm={12} md={6}>
<Select style={{ width: '100%' }} placeholder={t('backtest.sortOrder')} onChange={(value) => setSortOrder(value)} value={sortOrder}>
<Select.Option value="asc">{t('common.ascending')}</Select.Option>
<Select.Option value="desc">{t('common.descending')}</Select.Option>
</Select>
</Col>
</Row>
{/* 数据表格 */}
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: false,
showTotal: (total) => `${t('common.total')} ${total} ${t('common.items')}`,
onChange: (newPage) => setPage(newPage),
simple: isMobile
}}
scroll={isMobile ? { x: 1200 } : { x: 1400 }}
/>
{/* 数据:移动端卡片 / 桌面端表格 */}
{isMobile ? (
<>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : tasks.length === 0 ? (
<Empty description={t('backtest.noData') || t('common.noData')} />
) : (
<List
dataSource={tasks}
renderItem={(task) => (
<Card
key={task.id}
style={{
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: 0 }}
>
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px' }}>{task.taskName}</div>
<div style={{ fontSize: '12px', opacity: '0.9' }}>{task.leaderName || `Leader ${task.leaderId}`}</div>
</div>
<div style={{ padding: '8px 12px', backgroundColor: '#fafafa', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitAmount')}</div>
{task.profitAmount != null ? (
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>{formatUSDC(task.profitAmount)} USDC</div>
) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitRate')}</div>
{task.profitRate != null ? (
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitRate) >= 0 ? '#52c41a' : '#ff4d4f' }}>{task.profitRate}%</div>
) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.status')}</div>
<Tag color={getStatusColor(task.status)}>{getStatusText(task.status)}</Tag>
</div>
</div>
<div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
{t('backtest.initialBalance')}: {formatUSDC(task.initialBalance)} · {t('backtest.backtestDays')}: {task.backtestDays} {t('common.day')} · {t('backtest.progress')}: {task.progress}%
</div>
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-around', alignItems: 'center', flexWrap: 'wrap', gap: 4 }}>
<Tooltip title={t('common.viewDetail')}>
<div onClick={() => handleViewDetail(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.viewDetail')}</span>
</div>
</Tooltip>
{task.status === 'COMPLETED' && (
<>
<Tooltip title={t('backtest.rerun')}>
<div onClick={() => handleRerun(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<SyncOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.rerun')}</span>
</div>
</Tooltip>
<Tooltip title={t('backtest.createCopyTrading')}>
<div onClick={() => handleCreateCopyTrading(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.createCopyTrading')}</span>
</div>
</Tooltip>
</>
)}
{task.status === 'RUNNING' && (
<Tooltip title={t('backtest.stop')}>
<div onClick={() => handleStop(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<StopOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.stop')}</span>
</div>
</Tooltip>
)}
{(task.status === 'STOPPED' || task.status === 'FAILED') && (
<Tooltip title={t('backtest.retry')}>
<div onClick={() => handleRetry(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<RedoOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('backtest.retry')}</span>
</div>
</Tooltip>
)}
{(task.status === 'PENDING' || task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && (
<Tooltip title={t('common.delete')}>
<div onClick={() => handleDelete(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete')}</span>
</div>
</Tooltip>
)}
</div>
</Card>
)}
/>
)}
{/* 移动端分页 */}
{!loading && tasks.length > 0 && total > size && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 16 }}>
<Button size="small" disabled={page <= 1} onClick={() => setPage(p => Math.max(1, p - 1))}>{t('common.prev')}</Button>
<span style={{ margin: '0 12px', lineHeight: '24px' }}>{page} / {Math.ceil(total / size)}</span>
<Button size="small" disabled={page >= Math.ceil(total / size)} onClick={() => setPage(p => p + 1)}>{t('common.next')}</Button>
</div>
)}
</>
) : (
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: false,
showTotal: (totalCount) => `${t('common.total')} ${totalCount} ${t('common.items')}`,
onChange: (newPage) => setPage(newPage),
simple: isMobile
}}
scroll={{ x: 1000 }}
/>
)}
</Space>
</Card>
+252 -237
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd'
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Spin, List, Empty, Tooltip } from 'antd'
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined, WalletOutlined, UserOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import type { MenuProps } from 'antd'
import { apiService } from '../services/api'
@@ -297,7 +297,7 @@ const CopyTradingList: React.FC = () => {
{
title: t('common.actions') || '操作',
key: 'action',
width: isMobile ? 100 : 200,
width: isMobile ? 100 : 160,
fixed: 'right' as const,
render: (_: any, record: CopyTrading) => {
const menuItems: MenuProps['items'] = [
@@ -321,61 +321,101 @@ const CopyTradingList: React.FC = () => {
}
}
]
return (
<Space size={isMobile ? 'small' : 'middle'} wrap>
{!isMobile && (
<>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
>
{t('common.edit') || '编辑'}
</Button>
<Button
type="link"
size="small"
icon={<BarChartOutlined />}
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
>
{t('copyTradingList.statistics') || '统计'}
</Button>
</>
)}
<Space size={4}>
<Tooltip title={t('common.edit') || '编辑'}>
<div
onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
>
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={t('copyTradingList.statistics') || '统计'}>
<div
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
>
<BarChartOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
<Button
type="link"
size="small"
icon={<UnorderedListOutlined />}
>
{isMobile ? '' : (t('copyTradingList.orders') || '订单')}
</Button>
</Dropdown>
{!isMobile && (
<Popconfirm
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
<Tooltip title={t('copyTradingList.orders') || '订单'}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
>
{t('common.delete') || '删除'}
</Button>
</Popconfirm>
)}
<UnorderedListOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
</Dropdown>
<Popconfirm
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
>
<Tooltip title={t('common.delete') || '删除'}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#fff1f0' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm>
</Space>
)
}
@@ -384,19 +424,21 @@ const CopyTradingList: React.FC = () => {
return (
<div>
<Card>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<h2 style={{ margin: 0 }}>{t('copyTradingList.title') || '跟单配置管理'}</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('copyTradingList.title') || '跟单配置管理'}</h2>
<Tooltip title={t('copyTradingList.addCopyTrading') || '新增跟单'}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setAddModalOpen(true)}
>
{t('copyTradingList.addCopyTrading') || '新增跟单'}
</Button>
</div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
/>
</Tooltip>
</div>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Select
placeholder={t('copyTradingList.filterWallet') || '筛选钱包'}
allowClear
@@ -421,14 +463,14 @@ const CopyTradingList: React.FC = () => {
/>
<Select
placeholder="筛选状态"
placeholder={t('common.status') || '状态'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.enabled}
onChange={(value) => setFilters({ ...filters, enabled: value !== undefined ? value : undefined })}
>
<Option value={true}></Option>
<Option value={false}></Option>
<Option value={true}>{t('common.enabled') || '开启'}</Option>
<Option value={false}>{t('common.disabled') || '停止'}</Option>
</Select>
</div>
@@ -440,207 +482,180 @@ const CopyTradingList: React.FC = () => {
<Spin size="large" />
</div>
) : copyTradings.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
</div>
<Empty description={t('copyTradingList.noData') || '暂无跟单配置'} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{copyTradings.map((record) => {
<List
dataSource={copyTradings}
renderItem={(record) => {
const stats = statisticsMap[record.id]
const date = new Date(record.createdAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
return (
<Card
key={record.id}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: '16px' }}
bodyStyle={{ padding: '0' }}
>
{/* 基本信息 */}
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '8px',
color: '#1890ff'
}}>
{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}
</div>
<div style={{
fontSize: '14px',
marginBottom: '8px',
color: '#666'
}}>
{record.copyMode === 'RATIO'
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(2).replace(/\.0+$/, '')}%`
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}`
}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', justifyContent: 'space-between' }}>
<Tag color={record.enabled ? 'green' : 'red'}>
{record.enabled ? '启用' : '禁用'}
</Tag>
{/* 头部区域 - 配置名称 */}
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{record.configName || t('copyTradingList.configNameNotProvided') || '未提供'}</span>
<Switch
checked={record.enabled}
onChange={() => handleToggleStatus(record)}
checkedChildren="开启"
unCheckedChildren="停止"
checkedChildren={t('copyTradingList.enabled') || '开启'}
unCheckedChildren={t('copyTradingList.disabled') || '停止'}
size="small"
/>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* 账户信息 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{record.accountName || `账户 ${record.accountId}`}
</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)}
<div style={{ fontSize: '12px', opacity: '0.9' }}>
{record.copyMode === 'RATIO'
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC`
}
</div>
</div>
{/* Leader 信息 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{record.leaderName || `Leader ${record.leaderId}`}
</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
{record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)}
</div>
</div>
{/* 总盈亏 */}
{stats && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(stats.totalPnl),
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
{getPnlIcon(stats.totalPnl)}
{formatUSDC(stats.totalPnl)} USDC
{/* 盈亏区域 - 常驻显示 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#fafafa',
borderBottom: '1px solid #f0f0f0',
minHeight: '42px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('copyTradingList.totalPnl') || '总盈亏'}
</div>
{stats ? (
<div style={{
fontSize: '14px',
fontWeight: '600',
color: getPnlColor(stats.totalPnl),
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
{getPnlIcon(stats.totalPnl)}
{formatUSDC(stats.totalPnl)} USDC
</div>
) : loadingStatistics.has(record.id) ? (
<Spin size="small" />
) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)}
</div>
<div style={{
fontSize: '12px',
color: getPnlColor(stats.totalPnlPercent),
marginTop: '4px'
}}>
{formatPercent(stats.totalPnlPercent)}
</div>
</div>
)}
{loadingStatistics.has(record.id) && (
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}>
...
</div>
)}
{/* 创建时间 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
: {formattedDate}
{stats && (
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('copyTradingList.profitRate') || '收益率'}
</div>
<div style={{
fontSize: '12px',
fontWeight: '500',
color: getPnlColor(stats.totalPnlPercent)
}}>
{formatPercent(stats.totalPnlPercent)}
</div>
</div>
)}
</div>
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.edit') || '编辑'}
</Button>
<Button
size="small"
icon={<BarChartOutlined />}
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
style={{ flex: 1, minWidth: '80px' }}
>
{t('copyTradingList.statistics') || '统计'}
</Button>
<Dropdown
menu={{
items: [
{
key: 'matchedOrders',
label: t('copyTradingList.matchedOrders') || '已成交订单',
icon: <UnorderedListOutlined />,
onClick: () => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}
},
{
key: 'filteredOrders',
label: t('copyTradingList.filteredOrders') || '已过滤订单',
icon: <UnorderedListOutlined />,
onClick: () => {
setFilteredOrdersModalCopyTradingId(record.id.toString())
setFilteredOrdersModalOpen(true)
}
}
]
}}
trigger={['click']}
>
<Button
size="small"
icon={<UnorderedListOutlined />}
style={{ flex: 1, minWidth: '80px' }}
{/* 账户和Leader信息区域 */}
<div style={{
padding: '8px 12px',
fontSize: '11px',
color: '#8c8c8c',
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '4px' }}>
<WalletOutlined style={{ fontSize: '12px', marginRight: '4px', color: '#1890ff' }} />
<span>{t('copyTradingList.wallet') || '账户'}: {record.accountName || `#${record.accountId}`}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<UserOutlined style={{ fontSize: '12px', marginRight: '4px', color: '#722ed1' }} />
<span>Leader: {record.leaderName || `#${record.leaderId}`}</span>
</div>
</div>
{/* 图标操作栏 */}
<div style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center'
}}>
<Tooltip title={t('common.edit') || '编辑'}>
<div
onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
{t('copyTradingList.orders') || '订单'}
</Button>
</Dropdown>
<EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit') || '编辑'}</span>
</div>
</Tooltip>
<Tooltip title={t('copyTradingList.statistics') || '统计'}>
<div
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<BarChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('copyTradingList.statistics') || '统计'}</span>
</div>
</Tooltip>
<Tooltip title={t('copyTradingList.orders') || '订单'}>
<div
onClick={() => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<UnorderedListOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('copyTradingList.orders') || '订单'}</span>
</div>
</Tooltip>
<Popconfirm
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
>
<Button
danger
size="small"
icon={<DeleteOutlined />}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.delete') || '删除'}
</Button>
<Tooltip title={t('common.delete') || '删除'}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
</Card>
)
})}
</div>
}}
/>
)}
</div>
) : (
+628 -36
View File
@@ -12,9 +12,13 @@ import {
Alert,
Radio,
Button,
Tooltip
Tooltip,
Modal,
InputNumber,
message
} from 'antd'
import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined } from '@ant-design/icons'
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'
import * as echarts from 'echarts'
@@ -30,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
@@ -46,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)
@@ -61,9 +72,10 @@ const CryptoTailMonitor: React.FC = () => {
const marketChartRef = useRef<HTMLDivElement>(null)
const marketChartInstance = useRef<echarts.ECharts | null>(null)
const lastPeriodStartRef = useRef<number | null>(null)
// localStorage key for period switch mode
const PERIOD_SWITCH_MODE_KEY = 'cryptoTailMonitor_periodSwitchMode'
const selectedStrategyIdRef = useRef<number | null>(null)
useEffect(() => {
selectedStrategyIdRef.current = selectedStrategyId
}, [selectedStrategyId])
// 记录首次数据进入时间(用于中途进入时的横轴起点)
const [firstDataTime, setFirstDataTime] = useState<number | null>(null)
@@ -84,6 +96,38 @@ 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
availableBalance: string
periodStartUnix: number | null
}>({
visible: false,
direction: 'UP',
price: '',
size: '',
totalAmount: '',
bestBid: '',
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 () => {
@@ -91,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) {
@@ -106,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) {
@@ -127,7 +186,7 @@ const CryptoTailMonitor: React.FC = () => {
setPendingPeriodData(null)
setIsViewingOldPeriod(false)
try {
const res = await apiService.cryptoTailStrategy.monitorInit(selectedStrategyId)
const res = await apiService.cryptoTailStrategy.monitorInit({ strategyId: selectedStrategyId })
if (res.data.code === 0 && res.data.data) {
setInitData(res.data.data)
} else {
@@ -172,11 +231,23 @@ const CryptoTailMonitor: React.FC = () => {
const lastPeriod = lastPeriodStartRef.current
if (pushPeriod != null && pushPeriod !== lastPeriod) {
// 新周期到来
// 新周期到来:重新拉取 init(含 tokenIds),再更新状态
lastPeriodStartRef.current = pushPeriod
const marketTitle = (data as { marketTitle?: string }).marketTitle
const applyFreshInit = (fresh: CryptoTailMonitorInitResponse) => {
if (selectedStrategyIdRef.current !== fresh.strategyId) return
const merged: CryptoTailMonitorInitResponse = {
...fresh,
periodStartUnix: pushPeriod,
marketTitle: marketTitle ?? fresh.marketTitle ?? ''
}
if (periodSwitchMode === 'manual' && lastPeriod != null) {
setPendingPeriodData(p => p ? { ...p, initData: merged } : null)
} else {
setInitData(merged)
}
}
if (periodSwitchMode === 'manual' && lastPeriod != null) {
// 手动模式:保存新周期数据到 pending,保留当前显示
setPendingPeriodData({
periodStartUnix: pushPeriod,
priceHistory: [newPoint],
@@ -184,26 +255,35 @@ const CryptoTailMonitor: React.FC = () => {
pushData: data
})
setIsViewingOldPeriod(true)
// 更新 pending 数据的 initData
setInitData(prev => {
if (prev) {
setPendingPeriodData(p => p ? { ...p, initData: { ...prev, periodStartUnix: pushPeriod, marketTitle: (data as { marketTitle?: string }).marketTitle ?? prev.marketTitle } } : null)
apiService.cryptoTailStrategy.monitorInit({ strategyId: selectedStrategyId!, periodStartUnix: pushPeriod }).then(res => {
if (res.data?.code === 0 && res.data?.data) applyFreshInit(res.data.data)
else {
setInitData(prev => {
if (prev) setPendingPeriodData(p => p ? { ...p, initData: { ...prev, periodStartUnix: pushPeriod, marketTitle: marketTitle ?? prev.marketTitle ?? '' } } : null)
return prev ?? null
})
}
return prev
}).catch(() => {
setInitData(prev => {
if (prev) setPendingPeriodData(p => p ? { ...p, initData: { ...prev, periodStartUnix: pushPeriod, marketTitle: marketTitle ?? prev.marketTitle ?? '' } } : null)
return prev ?? null
})
})
} else {
// 自动模式或首次推送:直接切换
if (lastPeriod != null) {
setHasSwitchedPeriod(true)
}
if (lastPeriod != null) setHasSwitchedPeriod(true)
setFirstDataTime(newPoint.time)
setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod, marketTitle: (data as { marketTitle?: string }).marketTitle ?? prev.marketTitle } : null)
setPriceHistory([newPoint])
setPushData(data)
setIsViewingOldPeriod(false)
setPendingPeriodData(null)
return
apiService.cryptoTailStrategy.monitorInit({ strategyId: selectedStrategyId!, periodStartUnix: pushPeriod }).then(res => {
if (res.data?.code === 0 && res.data?.data) applyFreshInit(res.data.data)
else setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod, marketTitle: marketTitle ?? prev.marketTitle ?? '' } : null)
}).catch(() => {
setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod, marketTitle: marketTitle ?? prev.marketTitle ?? '' } : null)
})
}
return
} else {
// 同周期:追加数据
if (periodSwitchMode === 'manual' && isViewingOldPeriod && pendingPeriodData) {
@@ -709,6 +789,188 @@ const CryptoTailMonitor: React.FC = () => {
const spreadBelowThreshold = currentSpread != null && currentSpread !== '' && minSpreadLineNum.length > 0 &&
parseFloat(currentSpread) < Math.min(...minSpreadLineNum)
// 手动下单:打开弹窗
const handleOpenManualOrderModal = async (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~0.99 之间
const rawPrice = parseFloat(bestBid) * 1.1
const defaultPrice = Math.min(0.99, Math.max(0, rawPrice))
// 获取账户余额
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 = 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,
totalAmount: defaultTotalAmount,
bestBid,
availableBalance,
periodStartUnix: pushData.periodStartUnix
})
}
// 获取最新价
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 = Math.min(0.99, 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,
direction: 'UP',
price: '',
size: '',
totalAmount: '',
bestBid: '',
availableBalance: '',
periodStartUnix: null
})
}
const handlePriceChange = (value: number | null) => {
if (value === null) return
const clamped = Math.min(0.99, 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(0.99, Math.max(0, priceRaw))
const totalAmount = (price * value).toFixed(2)
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
}
// 最大数量 = 余额 / 价格,保留2位小数
let maxSize = Math.floor((balance / price) * 100) / 100
// 确保至少 1 张
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 {
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(0.99, 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 }}>
@@ -717,18 +979,22 @@ const CryptoTailMonitor: React.FC = () => {
{/* 顶部控制区 */}
<Card style={{ marginBottom: 16 }}>
<Space wrap size="middle">
<Space>
<Space direction={isMobile ? 'vertical' : 'horizontal'} size="middle" style={{ width: '100%' }}>
<Space direction={isMobile ? 'vertical' : 'horizontal'} size="small" style={{ width: isMobile ? '100%' : 'auto' }}>
<Text strong>{t('cryptoTailMonitor.selectStrategy')}</Text>
<Select
style={{ minWidth: isMobile ? 200 : 300 }}
style={{ minWidth: isMobile ? '100%' : 300, width: isMobile ? '100%' : 'auto' }}
loading={strategiesLoading}
value={selectedStrategyId}
onChange={(id) => setSelectedStrategyId(id)}
placeholder={t('cryptoTailMonitor.selectStrategyPlaceholder')}
popupMatchSelectWidth={false}
dropdownStyle={{ minWidth: isMobile ? 280 : 'auto', wordWrap: 'break-word', whiteSpace: 'normal' }}
optionLabelProp="label"
options={strategies.map(s => ({
label: `${s.name || s.marketSlugPrefix} (${s.intervalSeconds === 300 ? '5m' : '15m'})`,
value: s.id
value: s.id,
style: { whiteSpace: 'normal', wordWrap: 'break-word' }
}))}
/>
</Space>
@@ -914,29 +1180,63 @@ const CryptoTailMonitor: React.FC = () => {
/>
</Card>
{/* 手动下单 */}
{!isMobile ? (
<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')} {pushData?.currentPriceUp ? `(${formatNumber(pushData.currentPriceUp, 2)})` : ''}
</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')} {pushData?.currentPriceDown ? `(${formatNumber(pushData.currentPriceDown, 2)})` : ''}
</Button>
</Col>
</Row>
</Card>
) : null}
{/* 策略信息 */}
<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 +1244,298 @@ const CryptoTailMonitor: React.FC = () => {
</Card>
</>
)}
{/* 手动下单确认弹窗 - 桌面端使用 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'
}}
>
{manualOrderModal.direction === 'UP'
? t('cryptoTailMonitor.manualOrder.directionUp')
: t('cryptoTailMonitor.manualOrder.directionDown')}
</Text>
</div>
{/* 可用余额 */}
<div style={{ marginBottom: 10 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{t('cryptoTailMonitor.manualOrder.availableBalance')}:
</Text>
<Text strong style={{ fontSize: 14, color: '#52c41a', marginLeft: 4 }}>
{manualOrderModal.availableBalance ? formatNumber(manualOrderModal.availableBalance, 2) : '-'} {t('cryptoTailMonitor.manualOrder.orderUnit')}
</Text>
</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%', height: 36 }}
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 />} style={{ height: 36, fontSize: 12 }}>
{t('cryptoTailMonitor.manualOrder.fetchLatestPrice')}
</Button>
</Space.Compact>
</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%', height: 36 }}
value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined}
onChange={handleSizeChange}
min={1}
precision={2}
placeholder="1"
/>
<Button onClick={handleMaxSize} type="primary" ghost style={{ height: 36, fontSize: 12 }}>
{t('cryptoTailMonitor.manualOrder.maxSize')}
</Button>
</Space.Compact>
</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 && (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
padding: '12px 16px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderTop: '1px solid #f0f0f0',
boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.06)'
}}
>
<div style={{ display: 'flex', width: '100%', gap: 0 }}>
<Button
type="primary"
icon={<ShoppingCartOutlined />}
disabled={!pushData || pushData.triggered || pushData.periodEnded}
onClick={() => handleOpenManualOrderModal('UP')}
loading={ordering}
style={{ flex: 1, backgroundColor: '#1890ff', borderColor: '#1890ff', height: 44, borderRadius: '6px 0 0 6px' }}
>
{t('cryptoTailMonitor.manualOrder.buttonUp')} {pushData?.currentPriceUp ? `(${formatNumber(pushData.currentPriceUp, 2)})` : ''}
</Button>
<Button
type="primary"
icon={<ShoppingCartOutlined />}
disabled={!pushData || pushData.triggered || pushData.periodEnded}
onClick={() => handleOpenManualOrderModal('DOWN')}
loading={ordering}
style={{ flex: 1, backgroundColor: '#fa8c16', borderColor: '#fa8c16', height: 44, borderRadius: '0 6px 6px 0' }}
>
{t('cryptoTailMonitor.manualOrder.buttonDown')} {pushData?.currentPriceDown ? `(${formatNumber(pushData.currentPriceDown, 2)})` : ''}
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,149 @@
import { useEffect, useRef } from 'react'
import { Modal, Row, Col, Statistic, Button, Space, Empty } from 'antd'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import dayjs from 'dayjs'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { formatUSDC } from '../utils'
import type { CryptoTailPnlCurveResponse } from '../types'
export interface CryptoTailPnlCurveModalProps {
open: boolean
onClose: () => void
data: CryptoTailPnlCurveResponse | null
loading: boolean
strategyName: string
preset: 'today' | '7d' | '30d' | 'all'
onPresetChange: (preset: 'today' | '7d' | '30d' | 'all') => void
onRefresh: () => void
}
const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props) => {
const { open, onClose, data, loading, strategyName, preset, onPresetChange, onRefresh } = props
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const chartRef = useRef<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
useEffect(() => {
if (!open || !data?.curveData?.length || !chartRef.current) return
if (chartInstance.current) {
const dom = chartInstance.current.getDom()
if (!dom || !document.contains(dom)) {
chartInstance.current.dispose()
chartInstance.current = null
}
}
if (!chartInstance.current) chartInstance.current = echarts.init(chartRef.current)
const option: EChartsOption = {
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const arr = params as Array<{ value: [number, number] }>
const v = arr[0]?.value
if (!v) return ''
const d = data.curveData.find((p) => p.timestamp === v[0])
if (!d) return ''
return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '<br/>' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': ' + formatUSDC(d.cumulativePnl) + ' USDC'
}
},
grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true },
xAxis: { type: 'time' },
yAxis: { type: 'value', axisLabel: { formatter: (val: number) => String(val) + ' USDC' } },
series: [{
name: t('cryptoTailStrategy.pnlCurve.totalPnl'),
type: 'line',
data: data.curveData.map((p) => [p.timestamp, parseFloat(p.cumulativePnl)]),
smooth: preset === 'all',
symbol: 'circle',
symbolSize: 4,
lineStyle: { width: 2, color: '#1890ff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
])
}
}]
}
chartInstance.current.setOption(option, true)
chartInstance.current.resize()
}, [open, data, preset, t])
useEffect(() => {
if (!open) {
chartInstance.current?.dispose()
chartInstance.current = null
}
}, [open])
useEffect(() => {
if (!open || !chartInstance.current) return
const handleResize = () => chartInstance.current?.resize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [open])
const pnlColor = (value: string | null | undefined): string | undefined => {
if (value == null || value === '') return undefined
const num = parseFloat(value)
if (Number.isNaN(num)) return undefined
if (num > 0) return '#52c41a'
if (num < 0) return '#ff4d4f'
return undefined
}
return (
<Modal
title={t('cryptoTailStrategy.pnlCurve.title') + ' - ' + strategyName}
open={open}
onCancel={onClose}
footer={null}
width={Math.min(800, window.innerWidth - 48)}
destroyOnClose
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Statistic
title={t('cryptoTailStrategy.pnlCurve.totalPnl')}
value={data?.totalRealizedPnl != null ? formatUSDC(data.totalRealizedPnl) : '-'}
suffix="USDC"
valueStyle={{ color: pnlColor(data?.totalRealizedPnl ?? null) }}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic title={t('cryptoTailStrategy.pnlCurve.settledCount')} value={data?.settledCount ?? 0} />
</Col>
<Col xs={12} sm={6}>
<Statistic
title={t('cryptoTailStrategy.pnlCurve.winRate')}
value={data?.winRate != null ? (Number(data.winRate) * 100).toFixed(1) + '%' : '-'}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title={t('cryptoTailStrategy.pnlCurve.maxDrawdown')}
value={data?.maxDrawdown != null ? '-' + formatUSDC(data.maxDrawdown) : '-'}
suffix="USDC"
valueStyle={{ color: data?.maxDrawdown ? '#ff4d4f' : undefined }}
/>
</Col>
</Row>
<Space wrap style={{ marginBottom: 16 }}>
<Button size="small" type={preset === 'today' ? 'primary' : 'default'} onClick={() => onPresetChange('today')}>{t('cryptoTailStrategy.pnlCurve.today')}</Button>
<Button size="small" type={preset === '7d' ? 'primary' : 'default'} onClick={() => onPresetChange('7d')}>{t('cryptoTailStrategy.pnlCurve.last7Days')}</Button>
<Button size="small" type={preset === '30d' ? 'primary' : 'default'} onClick={() => onPresetChange('30d')}>{t('cryptoTailStrategy.pnlCurve.last30Days')}</Button>
<Button size="small" type={preset === 'all' ? 'primary' : 'default'} onClick={() => onPresetChange('all')}>{t('cryptoTailStrategy.pnlCurve.all')}</Button>
<Button size="small" onClick={onRefresh} loading={loading}>{t('common.refresh')}</Button>
</Space>
{!data && loading
? <div style={{ height: 320, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{t('common.loading')}</div>
: !data?.curveData?.length
? <Empty description={t('cryptoTailStrategy.pnlCurve.empty')} style={{ marginTop: 24 }} />
: <div ref={chartRef} style={{ width: '100%', height: isMobile ? 260 : 320 }} />}
</Modal>
)
}
export default CryptoTailPnlCurveModal
+306 -172
View File
@@ -21,19 +21,19 @@ import {
Tabs,
DatePicker,
Empty,
Typography,
Divider
Typography
} from 'antd'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, LineChartOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined, DeleteOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types'
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto, CryptoTailPnlCurveResponse } from '../types'
import { formatUSDC, formatNumber } from '../utils'
import { getVersionInfo } from '../utils/version'
import CryptoTailPnlCurveModal from './CryptoTailPnlCurveModal'
const CryptoTailStrategyList: React.FC = () => {
const { t, i18n } = useTranslation()
@@ -59,6 +59,14 @@ const CryptoTailStrategyList: React.FC = () => {
const [triggersLoading, setTriggersLoading] = useState(false)
const [form] = Form.useForm()
const [pnlCurveModalOpen, setPnlCurveModalOpen] = useState(false)
const [pnlCurveStrategyId, setPnlCurveStrategyId] = useState<number | null>(null)
const [pnlCurveStrategyName, setPnlCurveStrategyName] = useState('')
const [pnlCurvePreset, setPnlCurvePreset] = useState<'today' | '7d' | '30d' | 'all'>('all')
const [pnlCurveCustomRange, setPnlCurveCustomRange] = useState<[Dayjs | null, Dayjs | null]>([null, null])
const [pnlCurveData, setPnlCurveData] = useState<CryptoTailPnlCurveResponse | null>(null)
const [pnlCurveLoading, setPnlCurveLoading] = useState(false)
/** 币安 API 健康状态:仅保留「不可用」的项,用于强提醒 */
const [binanceUnhealthy, setBinanceUnhealthy] = useState<Array<{ name: string; message: string }>>([])
const [binanceCheckLoading, setBinanceCheckLoading] = useState(false)
@@ -328,6 +336,61 @@ const CryptoTailStrategyList: React.FC = () => {
await loadTriggerRecords(strategyId, 'success', { page: 1, pageSize: 20, dateRange: [null, null] })
}
const getPnlCurveTimeRange = (): { startDate?: number; endDate?: number } => {
if (pnlCurvePreset === 'all') return {}
const now = dayjs()
if (pnlCurvePreset === 'today') {
return { startDate: now.startOf('day').valueOf(), endDate: now.valueOf() }
}
if (pnlCurvePreset === '7d') {
return { startDate: now.subtract(7, 'day').startOf('day').valueOf(), endDate: now.valueOf() }
}
if (pnlCurvePreset === '30d') {
return { startDate: now.subtract(30, 'day').startOf('day').valueOf(), endDate: now.valueOf() }
}
if (pnlCurveCustomRange[0] != null && pnlCurveCustomRange[1] != null) {
return {
startDate: pnlCurveCustomRange[0].startOf('day').valueOf(),
endDate: pnlCurveCustomRange[1].endOf('day').valueOf()
}
}
return {}
}
const loadPnlCurve = async () => {
if (pnlCurveStrategyId == null) return
setPnlCurveLoading(true)
try {
const { startDate, endDate } = getPnlCurveTimeRange()
const res = await apiService.cryptoTailStrategy.pnlCurve({
strategyId: pnlCurveStrategyId,
startDate,
endDate
})
if (res.data.code === 0 && res.data.data) {
setPnlCurveData(res.data.data)
}
} catch (e) {
console.error('Failed to load PnL curve:', e)
} finally {
setPnlCurveLoading(false)
}
}
const openPnlCurve = (record: CryptoTailStrategyDto) => {
setPnlCurveStrategyId(record.id)
setPnlCurveStrategyName(record.name ?? record.marketTitle ?? record.marketSlugPrefix ?? '')
setPnlCurvePreset('all')
setPnlCurveCustomRange([null, null])
setPnlCurveModalOpen(true)
}
useEffect(() => {
if (pnlCurveModalOpen && pnlCurveStrategyId != null) {
loadPnlCurve()
}
}, [pnlCurveModalOpen, pnlCurveStrategyId, pnlCurvePreset, pnlCurveCustomRange])
const onTriggerTabChange = (key: string) => {
const next = key === 'success' ? 'success' : 'fail'
setTriggerTab(next)
@@ -389,7 +452,7 @@ const CryptoTailStrategyList: React.FC = () => {
title: t('common.status'),
dataIndex: 'enabled',
key: 'enabled',
width: 80,
width: 72,
render: (enabled: boolean, record: CryptoTailStrategyDto) => (
<Switch
checked={enabled}
@@ -402,7 +465,8 @@ const CryptoTailStrategyList: React.FC = () => {
title: t('cryptoTailStrategy.list.strategyName'),
dataIndex: 'name',
key: 'name',
width: isMobile ? 100 : 160,
width: isMobile ? 100 : 140,
ellipsis: true,
render: (name: string | undefined, r: CryptoTailStrategyDto) => (
<Typography.Text strong style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{name || (r.marketTitle ?? r.marketSlugPrefix) || '-'}
@@ -413,7 +477,8 @@ const CryptoTailStrategyList: React.FC = () => {
title: t('cryptoTailStrategy.list.account'),
dataIndex: 'accountId',
key: 'accountId',
width: isMobile ? 90 : 120,
width: isMobile ? 90 : 100,
ellipsis: true,
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{getAccountLabel(r.accountId)}
@@ -421,99 +486,149 @@ const CryptoTailStrategyList: React.FC = () => {
)
},
{
title: t('cryptoTailStrategy.list.market'),
key: 'market',
width: isMobile ? 120 : 200,
title: t('cryptoTailStrategy.list.marketAndTime'),
key: 'marketAndTime',
width: isMobile ? 120 : 180,
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'}
</Typography.Text>
<div>
<Typography.Text style={{ wordBreak: 'break-word', whiteSpace: 'normal', display: 'block' }}>
{marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'pre-line' }}>
{formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds, false)}
</Typography.Text>
</div>
)
},
{
title: t('cryptoTailStrategy.list.timeWindow'),
key: 'timeWindow',
width: isMobile ? 100 : 120,
title: t('cryptoTailStrategy.list.config'),
key: 'config',
width: isMobile ? 100 : 140,
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'pre-line' }}>
{formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)}
</Typography.Text>
)
},
{
title: t('cryptoTailStrategy.list.priceRange'),
key: 'priceRange',
width: isMobile ? 90 : 120,
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{formatPriceRange(r.minPrice, r.maxPrice)}
</Typography.Text>
)
},
{
title: t('cryptoTailStrategy.list.amountMode'),
key: 'amountMode',
width: isMobile ? 90 : 120,
render: (_: unknown, r: CryptoTailStrategyDto) => (
<Typography.Text type="secondary" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{(r.amountMode?.toUpperCase() ?? '') === 'RATIO'
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%`
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC`}
</Typography.Text>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{formatPriceRange(r.minPrice, r.maxPrice)}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{(r.amountMode?.toUpperCase() ?? '') === 'RATIO'
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%`
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)}`}
</Typography.Text>
</div>
)
},
{
title: t('cryptoTailStrategy.list.totalRealizedPnl'),
key: 'totalRealizedPnl',
key: 'pnl',
width: isMobile ? 90 : 120,
render: (_: unknown, r: CryptoTailStrategyDto) => {
const text = r.totalRealizedPnl != null ? `${formatUSDC(r.totalRealizedPnl)} USDC` : '-'
const text = r.totalRealizedPnl != null ? formatUSDC(r.totalRealizedPnl) : '-'
const color = pnlColor(r.totalRealizedPnl)
return color ? (
<Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.Text>
) : (
<Typography.Text type="secondary">{text}</Typography.Text>
return (
<div>
{color ? (
<Typography.Text style={{ color, fontWeight: 500 }}>{text}</Typography.Text>
) : (
<Typography.Text type="secondary">{text}</Typography.Text>
)}
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{r.winRate != null ? `${(Number(r.winRate) * 100).toFixed(1)}%` : '-'}
</Typography.Text>
</div>
)
}
},
{
title: t('cryptoTailStrategy.list.winRate'),
key: 'winRate',
width: isMobile ? 70 : 90,
render: (_: unknown, r: CryptoTailStrategyDto) =>
r.winRate != null ? (
<Tag color="blue">{(Number(r.winRate) * 100).toFixed(1)}%</Tag>
) : (
<Typography.Text type="secondary">-</Typography.Text>
)
},
{
title: t('cryptoTailStrategy.list.actions'),
key: 'actions',
width: isMobile ? 120 : 200,
width: isMobile ? 120 : 140,
fixed: 'right' as const,
render: (_: unknown, record: CryptoTailStrategyDto) => (
<Space size="small" wrap>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
{t('cryptoTailStrategy.list.edit')}
</Button>
<Button
type="link"
size="small"
icon={<UnorderedListOutlined />}
onClick={() => openTriggers(record.id)}
>
{t('cryptoTailStrategy.list.viewTriggers')}
</Button>
<Space size={4}>
<Tooltip title={t('cryptoTailStrategy.list.edit')}>
<div
onClick={() => openEditModal(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
>
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={t('cryptoTailStrategy.list.viewTriggers')}>
<div
onClick={() => openTriggers(record.id)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
>
<UnorderedListOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={t('cryptoTailStrategy.list.viewPnlCurve')}>
<div
onClick={() => openPnlCurve(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
>
<LineChartOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Popconfirm
title={t('cryptoTailStrategy.list.deleteConfirm')}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="link" size="small" danger>
{t('cryptoTailStrategy.list.delete')}
</Button>
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#fff1f0' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm>
</Space>
)
@@ -543,17 +658,28 @@ const CryptoTailStrategyList: React.FC = () => {
}
return (
<div style={{ padding: isMobile ? 12 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<h1 style={{ margin: 0, fontSize: isMobile ? 20 : 24 }}>{t('cryptoTailStrategy.list.title')}</h1>
<Button
type="link"
icon={<FileTextOutlined />}
onClick={() => window.open(getGuideUrl(), '_blank')}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 14 : 16 }}
>
{t('cryptoTailStrategy.list.configGuide')}
</Button>
<div style={{ padding: isMobile ? 0 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px', padding: isMobile ? '0 8px' : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('cryptoTailStrategy.list.title')}</h2>
<Button
type="link"
icon={<FileTextOutlined />}
onClick={() => window.open(getGuideUrl(), '_blank')}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 14 : 16 }}
>
{t('cryptoTailStrategy.list.configGuide')}
</Button>
</div>
<Tooltip title={t('cryptoTailStrategy.list.addStrategy')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openAddModal}
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
/>
</Tooltip>
</div>
{binanceUnhealthy.length > 0 && list.some((s) => s.enabled) && (
<Alert
@@ -583,20 +709,17 @@ const CryptoTailStrategyList: React.FC = () => {
</Button>
</div>
}
style={{ marginBottom: 16 }}
style={{ marginBottom: 16, margin: isMobile ? '0 8px 16px' : undefined }}
/>
)}
<Alert
type="warning"
showIcon
message={t('cryptoTailStrategy.list.walletTip')}
style={{ marginBottom: 16 }}
style={{ marginBottom: 16, margin: isMobile ? '0 8px 16px' : undefined }}
/>
<Card>
<Card style={{ borderRadius: isMobile ? 0 : '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: isMobile ? 'none' : '1px solid #e8e8e8' }} styles={{ body: { padding: isMobile ? 12 : 24 } }}>
<div style={{ marginBottom: 16, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>
{t('cryptoTailStrategy.list.addStrategy')}
</Button>
<Select
placeholder={t('cryptoTailStrategy.form.selectAccount')}
allowClear
@@ -619,86 +742,86 @@ const CryptoTailStrategyList: React.FC = () => {
</div>
<Spin spinning={loading}>
{isMobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{list.map((item) => (
<Card
key={item.id}
size="small"
styles={{ body: { padding: 16 } }}
style={{ borderLeft: `3px solid ${item.enabled ? 'var(--ant-colorSuccess)' : 'var(--ant-colorBorder)'}` }}
>
<Typography.Text strong style={{ fontSize: 15, wordBreak: 'break-word', whiteSpace: 'normal', display: 'block', marginBottom: 8 }}>
{item.name || (item.marketTitle ?? item.marketSlugPrefix) || '-'}
</Typography.Text>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 4, fontSize: 13, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{t('cryptoTailStrategy.list.account')}: {getAccountLabel(item.accountId)}
</Typography.Text>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8, fontSize: 13, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{marketOptions.find((m) => m.slug === item.marketSlugPrefix)?.title ?? item.marketSlugPrefix ?? '-'}
</Typography.Text>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px', marginBottom: 12 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{t('cryptoTailStrategy.list.timeWindow')}
</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}>{formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds, false)}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{t('cryptoTailStrategy.list.priceRange')}
</Typography.Text>
<Typography.Text style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>{formatPriceRange(item.minPrice, item.maxPrice)}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{t('cryptoTailStrategy.list.amountMode')}
</Typography.Text>
<Typography.Text style={{ fontSize: 12, wordBreak: 'break-word', whiteSpace: 'normal' }}>
{(item.amountMode?.toUpperCase() ?? '') === 'RATIO'
? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(item.amountValue, 2) || '0'}%`
: `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(item.amountValue)} USDC`}
</Typography.Text>
</div>
<Divider style={{ margin: '10px 0' }} />
<Space wrap style={{ marginBottom: 12 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('cryptoTailStrategy.list.totalRealizedPnl')}:{' '}
</Typography.Text>
{item.totalRealizedPnl != null ? (
<Typography.Text style={{ color: pnlColor(item.totalRealizedPnl) ?? undefined, fontWeight: 500, fontSize: 12 }}>
{formatUSDC(item.totalRealizedPnl)} USDC
</Typography.Text>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>-</Typography.Text>
)}
{item.winRate != null && (
<>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>·</Typography.Text>
<Tag color="blue" style={{ margin: 0 }}>{t('cryptoTailStrategy.list.winRate')} {(Number(item.winRate) * 100).toFixed(1)}%</Tag>
</>
)}
</Space>
<Divider style={{ margin: '10px 0' }} />
<Space wrap size="small">
<Switch
checked={item.enabled}
onChange={() => handleToggle(item)}
size="small"
/>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(item)}>
{t('cryptoTailStrategy.list.edit')}
</Button>
<Button type="link" size="small" icon={<UnorderedListOutlined />} onClick={() => openTriggers(item.id)}>
{t('cryptoTailStrategy.list.viewTriggers')}
</Button>
<Popconfirm
title={t('cryptoTailStrategy.list.deleteConfirm')}
onConfirm={() => handleDelete(item.id)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="link" size="small" danger>
{t('cryptoTailStrategy.list.delete')}
</Button>
</Popconfirm>
</Space>
</Card>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{list.map((item) => {
const accountLabel = getAccountLabel(item.accountId)
const marketTitle = marketOptions.find((m) => m.slug === item.marketSlugPrefix)?.title ?? item.marketTitle ?? item.marketSlugPrefix ?? '-'
return (
<Card
key={item.id}
style={{
marginBottom: 0,
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: 0 }}
>
<div style={{
padding: '10px 12px',
background: item.enabled ? 'var(--ant-color-primary, #1677ff)' : 'var(--ant-color-fill-secondary, #f0f0f0)',
color: item.enabled ? '#fff' : 'var(--ant-color-text-secondary, #666)'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>{item.name || marketTitle || '-'}</span>
<Switch checked={item.enabled} onChange={() => handleToggle(item)} size="small" />
</div>
</div>
<div style={{ padding: '8px 12px', backgroundColor: '#fafafa', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.totalRealizedPnl')}</div>
{item.totalRealizedPnl != null ? (
<div style={{ fontSize: '14px', fontWeight: '600', color: pnlColor(item.totalRealizedPnl) }}>{formatUSDC(item.totalRealizedPnl)} USDC</div>
) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.winRate')}</div>
{item.winRate != null ? <Tag color="blue" style={{ margin: 0 }}>{(Number(item.winRate) * 100).toFixed(1)}%</Tag> : <span style={{ fontSize: '12px', color: '#8c8c8c' }}>-</span>}
</div>
</div>
</div>
<div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
<div>{t('cryptoTailStrategy.list.account')}: {accountLabel}</div>
<div>{t('cryptoTailStrategy.list.market')}: {marketTitle}</div>
</div>
<div style={{ padding: '6px 12px', fontSize: '11px', color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
{t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds, false)} · {t('cryptoTailStrategy.list.priceRange')}: {formatPriceRange(item.minPrice, item.maxPrice)}
</div>
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-around', alignItems: 'center' }}>
<Tooltip title={t('cryptoTailStrategy.list.edit')}>
<div onClick={() => openEditModal(item)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.edit')}</span>
</div>
</Tooltip>
<Tooltip title={t('cryptoTailStrategy.list.viewTriggers')}>
<div onClick={() => openTriggers(item.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<UnorderedListOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewTriggers')}</span>
</div>
</Tooltip>
<Tooltip title={t('cryptoTailStrategy.list.viewPnlCurve')}>
<div onClick={() => openPnlCurve(item)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<LineChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.viewPnlCurve')}</span>
</div>
</Tooltip>
<Popconfirm title={t('cryptoTailStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(item.id)} okText={t('common.confirm')} cancelText={t('common.cancel')}>
<Tooltip title={t('cryptoTailStrategy.list.delete')}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('cryptoTailStrategy.list.delete')}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
</Card>
)
})}
</div>
) : (
<Table
@@ -706,7 +829,7 @@ const CryptoTailStrategyList: React.FC = () => {
columns={columns}
dataSource={list}
pagination={{ pageSize: 20 }}
scroll={{ x: 900 }}
scroll={{ x: 720 }}
/>
)}
</Spin>
@@ -735,6 +858,17 @@ const CryptoTailStrategyList: React.FC = () => {
<p>{t('cryptoTailStrategy.redeemRequiredModal.description')}</p>
</Modal>
<CryptoTailPnlCurveModal
open={pnlCurveModalOpen}
onClose={() => setPnlCurveModalOpen(false)}
data={pnlCurveData}
loading={pnlCurveLoading}
strategyName={pnlCurveStrategyName}
preset={pnlCurvePreset}
onPresetChange={setPnlCurvePreset}
onRefresh={loadPnlCurve}
/>
<Modal
title={editingId ? t('cryptoTailStrategy.form.update') : t('cryptoTailStrategy.form.create')}
open={formModalOpen}
+285 -112
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col, Tooltip, Badge } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined, CopyOutlined, LineChartOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type { Leader, LeaderBalanceResponse } from '../types'
@@ -225,11 +225,11 @@ const LeaderList: React.FC = () => {
title: t('leaderList.leaderName'),
dataIndex: 'leaderName',
key: 'leaderName',
width: 150,
width: 200,
render: (text: string, record: Leader) => (
<Space direction="vertical" size={0}>
<Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text>
<Text type="secondary" style={{ fontSize: '12px', fontFamily: 'monospace' }}>{record.leaderAddress}</Text>
</Space>
)
},
@@ -237,76 +237,155 @@ const LeaderList: React.FC = () => {
title: t('leaderList.remark'),
dataIndex: 'remark',
key: 'remark',
width: 200,
width: 180,
ellipsis: true,
render: (remark: string | undefined) => {
if (!remark) return <Text type="secondary">-</Text>
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</Text>
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 160 }}>{remark}</Text>
}
},
{
title: t('leaderDetail.availableBalance'),
key: 'balance',
width: 150,
width: 180,
render: (_: any, record: Leader) => {
const balance = balanceMap[record.id]
if (!balance) return <Spin size="small" />
const displayText = balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`
return <Text style={{ color: '#1890ff', fontSize: '14px' }}>{displayText}</Text>
return (
<Space direction="vertical" size={0}>
<Text style={{ color: '#52c41a', fontSize: '14px', fontWeight: '500' }}>
{balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
</Text>
</Space>
)
}
},
{
title: t('leaderList.copyTradingCount'),
dataIndex: 'copyTradingCount',
key: 'copyTradingCount',
width: 100,
render: (count: number, record: Leader) => (
<Button
type="link"
size="small"
onClick={() => navigate(`/copy-trading?leaderId=${record.id}`)}
disabled={count === 0}
style={{ padding: 0 }}
>
<Tag color="cyan">{count}</Tag>
</Button>
)
},
{
title: t('leaderList.backtestCount'),
dataIndex: 'backtestCount',
key: 'backtestCount',
width: 100,
render: (count: number, record: Leader) => (
<Button
type="link"
size="small"
onClick={() => navigate(`/backtest?leaderId=${record.id}`)}
disabled={count === 0}
style={{ padding: 0 }}
>
<Tag color="purple">{count}</Tag>
</Button>
)
},
{
title: t('common.actions'),
key: 'action',
width: isMobile ? 180 : 250,
width: 200,
fixed: 'right' as const,
render: (_: any, record: Leader) => (
<Space size="small" wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}>
{t('common.viewDetail')}
</Button>
<Space size={4}>
<Tooltip title={t('common.viewDetail')}>
<div
onClick={() => handleShowDetail(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
{record.website && (
<Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}>
{t('leaderList.openWebsite')}
</Button>
<Tooltip title={t('leaderList.openWebsite')}>
<div
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<GlobalOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
)}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}>
{t('common.edit')}
</Button>
<Tooltip title={t('common.edit')}>
<div
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={`${t('leaderList.viewCopyTradings')} (${record.copyTradingCount})`}>
<div
onClick={() => {
if (record.copyTradingCount > 0) {
navigate(`/copy-trading?leaderId=${record.id}`)
}
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: record.copyTradingCount === 0 ? 'not-allowed' : 'pointer',
borderRadius: '6px',
opacity: record.copyTradingCount === 0 ? 0.4 : 1,
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => record.copyTradingCount > 0 && (e.currentTarget.style.backgroundColor = '#f0f0f0')}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Badge count={record.copyTradingCount} size="small" offset={[-4, -4]}>
<CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</Badge>
</div>
</Tooltip>
<Tooltip title={`${t('leaderList.viewBacktests')} (${record.backtestCount})`}>
<div
onClick={() => {
if (record.backtestCount > 0) {
navigate(`/backtest?leaderId=${record.id}`)
}
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: record.backtestCount === 0 ? 'not-allowed' : 'pointer',
borderRadius: '6px',
opacity: record.backtestCount === 0 ? 0.4 : 1,
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => record.backtestCount > 0 && (e.currentTarget.style.backgroundColor = '#f0f0f0')}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Badge count={record.backtestCount} size="small" offset={[-4, -4]}>
<LineChartOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</Badge>
</div>
</Tooltip>
<Popconfirm
title={t('leaderList.deleteConfirm')}
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
@@ -314,9 +393,24 @@ const LeaderList: React.FC = () => {
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
{t('common.delete')}
</Button>
<Tooltip title={t('common.delete')}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm>
</Space>
)
@@ -327,9 +421,9 @@ const LeaderList: React.FC = () => {
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}>
{t('leaderList.addLeader')}
</Button>
<Tooltip title={t('leaderList.addLeader')}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }} />
</Tooltip>
</div>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
@@ -348,69 +442,145 @@ const LeaderList: React.FC = () => {
const balance = balanceMap[leader.id]
return (
<Card key={leader.id} style={{ marginBottom: '16px', borderRadius: '12px', boxShadow: '0 2px 6px rgba(0,0,0,0.06)', border: '1px solid #f0f0f0' }} bodyStyle={{ padding: '16px' }}>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}>
{leader.leaderName || `Leader ${leader.id}`}
<Card
key={leader.id}
style={{
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: '0' }}
>
{/* 头部区域 - 名称和地址 */}
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{leader.leaderName || `Leader ${leader.id}`}</span>
{leader.website && (
<GlobalOutlined
style={{ fontSize: '13px', cursor: 'pointer', opacity: 0.8 }}
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
/>
)}
</div>
<div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}>
<div style={{ fontSize: '10px', opacity: '0.85', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{leader.leaderAddress}
</div>
</div>
{balance && (
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
<div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}>
{t('leaderDetail.availableBalance')}: {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
{/* 资产区域 - 常驻显示 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#fafafa',
borderBottom: '1px solid #f0f0f0',
minHeight: '42px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('leaderDetail.availableBalance')}
</div>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
{balance?.available && balance.available !== '-' ? `${formatUSDC(balance.available)} USDC` : '- USDC'}
</div>
</div>
<div style={{ fontSize: '11px', color: '#666' }}>
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('leaderDetail.positionBalance')}
</div>
<div style={{ fontSize: '14px', fontWeight: '500', color: '#722ed1' }}>
{balance?.position && balance.position !== '-' ? formatUSDC(balance.position) : '-'}
</div>
</div>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<Button
type="default"
size="small"
onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)}
disabled={leader.copyTradingCount === 0}
style={{ borderRadius: '6px', padding: '8px 16px' }}
>
{t('leaderList.viewCopyTradings')} ({leader.copyTradingCount})
</Button>
<Button
type="default"
size="small"
onClick={() => navigate(`/backtest?leaderId=${leader.id}`)}
disabled={leader.backtestCount === 0}
style={{ borderRadius: '6px', padding: '8px 16px' }}
>
{t('leaderList.viewBacktests')} ({leader.backtestCount})
</Button>
</div>
{/* 备注区域 */}
{leader.remark && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}</Text>
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text>
<div style={{
padding: '6px 12px',
backgroundColor: '#fffbe6',
borderBottom: '1px solid #ffe58f',
fontSize: '11px',
color: '#8c8c8c'
}}>
<span style={{ color: '#d48806' }}>{t('leaderList.remark')}</span>
<span>{leader.remark}</span>
</div>
)}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.viewDetail')}
</Button>
{leader.website && (
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('leaderList.openWebsite')}
</Button>
)}
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.edit')}
</Button>
{/* 图标操作栏 */}
<div style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center'
}}>
<Tooltip title={t('common.viewDetail')}>
<div
onClick={() => handleShowDetail(leader)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.viewDetail')}</span>
</div>
</Tooltip>
<Tooltip title={t('common.edit')}>
<div
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit')}</span>
</div>
</Tooltip>
<Tooltip title={t('leaderList.viewCopyTradings')}>
<div
onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: leader.copyTradingCount === 0 ? 'not-allowed' : 'pointer',
padding: '4px 8px',
opacity: leader.copyTradingCount === 0 ? 0.4 : 1
}}
>
<Badge count={leader.copyTradingCount} size="small" offset={[-2, -2]}>
<CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
</Badge>
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewCopyTradings')}</span>
</div>
</Tooltip>
<Tooltip title={t('leaderList.viewBacktests')}>
<div
onClick={() => navigate(`/backtest?leaderId=${leader.id}`)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: leader.backtestCount === 0 ? 'not-allowed' : 'pointer',
padding: '4px 8px',
opacity: leader.backtestCount === 0 ? 0.4 : 1
}}
>
<Badge count={leader.backtestCount} size="small" offset={[-2, -2]}>
<LineChartOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
</Badge>
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewBacktests')}</span>
</div>
</Tooltip>
<Popconfirm
title={t('leaderList.deleteConfirm')}
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
@@ -418,9 +588,12 @@ const LeaderList: React.FC = () => {
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.delete')}
</Button>
<Tooltip title={t('common.delete')}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete')}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
</Card>
@@ -0,0 +1,718 @@
import React, { useEffect, useState, useCallback } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, Switch, Tooltip, Row, Col, Tabs } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined, ReloadOutlined, CheckOutlined, RobotOutlined, FormOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type { NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse, TemplateVariable } from '../types'
import { useMediaQuery } from 'react-responsive'
import { TelegramConfigForm } from '../components/notifications'
import TextArea from 'antd/es/input/TextArea'
const { Title, Text, Paragraph } = Typography
const variableTagStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
cursor: 'pointer',
marginBottom: 6,
marginRight: 6,
borderRadius: 6,
padding: '4px 10px',
fontSize: 12,
transition: 'all 0.2s ease',
border: '1px solid #e8e8e8',
background: '#ffffff',
color: 'rgba(0, 0, 0, 0.65)',
}
const variableTagHoverStyle: React.CSSProperties = {
borderColor: '#1890ff',
background: '#e6f7ff',
color: '#1890ff',
transform: 'translateY(-1px)',
boxShadow: '0 2px 4px rgba(24, 144, 255, 0.2)',
}
/**
*
*/
const CATEGORY_LABELS: Record<string, string> = {
common: 'notificationSettings.templates.commonVariables',
order: 'notificationSettings.templates.orderVariables',
copy_trading: 'notificationSettings.templates.copyTradingVariables',
redeem: 'notificationSettings.templates.redeemVariables',
error: 'notificationSettings.templates.errorVariables',
filter: 'notificationSettings.templates.filterVariables',
strategy: 'notificationSettings.templates.strategyVariables'
}
const NotificationSettingsPage: React.FC = () => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
// 机器人配置相关状态
const [configs, setConfigs] = useState<NotificationConfig[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingConfig, setEditingConfig] = useState<NotificationConfig | null>(null)
const [form] = Form.useForm()
const [testLoading, setTestLoading] = useState(false)
// 模板配置相关状态
const [templateTypes, setTemplateTypes] = useState<TemplateTypeInfo[]>([])
const [selectedTemplateType, setSelectedTemplateType] = useState<string>('ORDER_SUCCESS')
const [currentTemplate, setCurrentTemplate] = useState<NotificationTemplate | null>(null)
const [templateVariables, setTemplateVariables] = useState<TemplateVariablesResponse | null>(null)
const [templateContent, setTemplateContent] = useState('')
const [testTemplateLoading, setTestTemplateLoading] = useState(false)
// 加载机器人配置
useEffect(() => {
fetchConfigs()
}, [])
// 加载模板类型
useEffect(() => {
fetchTemplateTypes()
}, [])
// 当选中的模板类型改变时,加载模板详情和变量
useEffect(() => {
if (selectedTemplateType) {
fetchTemplateDetail(selectedTemplateType)
fetchTemplateVariables(selectedTemplateType)
}
}, [selectedTemplateType])
const fetchConfigs = async () => {
setLoading(true)
try {
const response = await apiService.notifications.list({ type: 'telegram' })
if (response.data.code === 0 && response.data.data) {
setConfigs(response.data.data)
} else {
message.error(response.data.msg || t('notificationSettings.fetchFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.fetchFailed'))
} finally {
setLoading(false)
}
}
const fetchTemplateTypes = async () => {
try {
const response = await apiService.notifications.getTemplateTypes()
if (response.data.code === 0 && response.data.data) {
setTemplateTypes(response.data.data)
}
} catch (error) {
console.error('获取模板类型失败:', error)
}
}
const fetchTemplateDetail = async (templateType: string) => {
try {
const response = await apiService.notifications.getTemplateDetail({ templateType })
if (response.data.code === 0 && response.data.data) {
setCurrentTemplate(response.data.data)
setTemplateContent(response.data.data.templateContent)
}
} catch (error) {
console.error('获取模板详情失败:', error)
}
}
const fetchTemplateVariables = async (templateType: string) => {
try {
const response = await apiService.notifications.getTemplateVariables({ templateType })
if (response.data.code === 0 && response.data.data) {
setTemplateVariables(response.data.data)
}
} catch (error) {
console.error('获取模板变量失败:', error)
}
}
// 机器人配置相关方法
const handleCreate = () => {
setEditingConfig(null)
form.resetFields()
form.setFieldsValue({
type: 'telegram',
enabled: true,
config: {
botToken: '',
chatIds: []
}
})
setModalVisible(true)
}
const handleEdit = (config: NotificationConfig) => {
setEditingConfig(config)
let botToken = ''
let chatIds = ''
if (config.config) {
if ('data' in config.config && config.config.data) {
const data = config.config.data as any
botToken = data.botToken || ''
if (data.chatIds) {
if (Array.isArray(data.chatIds)) {
chatIds = data.chatIds.join(',')
} else if (typeof data.chatIds === 'string') {
chatIds = data.chatIds
}
}
} else {
if ('botToken' in config.config) {
botToken = (config.config as any).botToken || ''
}
if ('chatIds' in config.config) {
const ids = (config.config as any).chatIds
if (Array.isArray(ids)) {
chatIds = ids.join(',')
} else if (typeof ids === 'string') {
chatIds = ids
}
}
}
}
form.setFieldsValue({
type: config.type,
name: config.name,
enabled: config.enabled,
config: {
botToken: botToken,
chatIds: chatIds
}
})
setModalVisible(true)
}
const handleDelete = async (id: number) => {
try {
const response = await apiService.notifications.delete({ id })
if (response.data.code === 0) {
message.success(t('notificationSettings.deleteSuccess'))
fetchConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.deleteFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.deleteFailed'))
}
}
const handleUpdateEnabled = async (id: number, enabled: boolean) => {
try {
const response = await apiService.notifications.updateEnabled({ id, enabled })
if (response.data.code === 0) {
message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess'))
fetchConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.updateStatusFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.updateStatusFailed'))
}
}
const handleTest = async () => {
setTestLoading(true)
try {
const response = await apiService.notifications.test({ message: '这是一条测试消息' })
if (response.data.code === 0 && response.data.data) {
message.success(t('notificationSettings.testSuccess'))
} else {
message.error(response.data.msg || t('notificationSettings.testFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.testFailed'))
} finally {
setTestLoading(false)
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
const chatIds = typeof values.config.chatIds === 'string'
? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
: values.config.chatIds || []
const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = {
type: values.type,
name: values.name,
enabled: values.enabled,
config: {
botToken: values.config.botToken,
chatIds: chatIds
}
}
if (editingConfig?.id) {
const updateData = {
...configData,
id: editingConfig.id
} as NotificationConfigUpdateRequest
const response = await apiService.notifications.update(updateData)
if (response.data.code === 0) {
message.success(t('notificationSettings.updateSuccess'))
setModalVisible(false)
fetchConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.updateFailed'))
}
} else {
const response = await apiService.notifications.create(configData)
if (response.data.code === 0) {
message.success(t('notificationSettings.createSuccess'))
setModalVisible(false)
fetchConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.createFailed'))
}
}
} catch (error: any) {
if (error.errorFields) {
return
}
message.error(error.message || t('message.error'))
}
}
const getConfigFormComponent = (type: string) => {
switch (type?.toLowerCase()) {
case 'telegram':
return <TelegramConfigForm form={form} />
default:
return null
}
}
// 模板配置相关方法
const handleTemplateTypeChange = (type: string) => {
setSelectedTemplateType(type)
}
const handleTemplateContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setTemplateContent(e.target.value)
}
const handleSaveTemplate = async () => {
try {
const response = await apiService.notifications.updateTemplate({
templateType: selectedTemplateType,
templateContent: templateContent
})
if (response.data.code === 0) {
message.success(t('notificationSettings.templates.saveSuccess'))
fetchTemplateDetail(selectedTemplateType)
} else {
message.error(response.data.msg || t('notificationSettings.templates.saveFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.templates.saveFailed'))
}
}
const handleResetTemplate = async () => {
try {
const response = await apiService.notifications.resetTemplate({
templateType: selectedTemplateType
})
if (response.data.code === 0) {
message.success(t('notificationSettings.templates.resetSuccess'))
fetchTemplateDetail(selectedTemplateType)
} else {
message.error(response.data.msg || t('notificationSettings.templates.resetFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.templates.resetFailed'))
}
}
const handleTestTemplate = async () => {
setTestTemplateLoading(true)
try {
const response = await apiService.notifications.testTemplate({
templateType: selectedTemplateType,
templateContent: templateContent
})
if (response.data.code === 0 && response.data.data) {
message.success(t('notificationSettings.templates.testSuccess'))
} else {
message.error(response.data.msg || t('notificationSettings.templates.testFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.templates.testFailed'))
} finally {
setTestTemplateLoading(false)
}
}
const handleCopyVariable = useCallback((variable: string) => {
const text = `{{${variable}}}`
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
message.success(t('notificationSettings.templates.copied'))
}).catch(() => {
fallbackCopy(text)
})
} else {
fallbackCopy(text)
}
function fallbackCopy(text: string) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
textArea.style.top = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
message.success(t('notificationSettings.templates.copied'))
} catch {
message.error(t('common.copyFailed'))
}
document.body.removeChild(textArea)
}
}, [t])
const [variableHoverKey, setVariableHoverKey] = useState<string | null>(null)
const renderVariableItem = (variable: TemplateVariable) => {
const isHover = variableHoverKey === variable.key
const label = t(`notificationSettings.templates.variableLabels.${variable.key}`)
const description = t(`notificationSettings.templates.variableDescriptions.${variable.key}`)
const variableElement = (
<span
role="button"
tabIndex={0}
style={{ ...variableTagStyle, ...(isHover && !isMobile ? variableTagHoverStyle : {}) }}
onClick={() => handleCopyVariable(variable.key)}
onMouseEnter={() => !isMobile && setVariableHoverKey(variable.key)}
onMouseLeave={() => !isMobile && setVariableHoverKey(null)}
onKeyDown={(e) => e.key === 'Enter' && handleCopyVariable(variable.key)}
>
<span style={{ fontFamily: 'monospace' }}>{label}</span>
</span>
)
if (isMobile) {
return (
<span key={variable.key} style={{ display: 'inline-block' }}>
{variableElement}
</span>
)
}
return (
<Tooltip key={variable.key} title={description || `{{${variable.key}}}`} placement="top">
{variableElement}
</Tooltip>
)
}
const renderVariablesPanel = () => {
if (!templateVariables) return null
return (
<Card
size="small"
title={
<span style={{ fontSize: 13, fontWeight: 500 }}>
{t('notificationSettings.templates.variables')}
</span>
}
style={{ height: '100%', borderRadius: 8 }}
bodyStyle={{ padding: '12px 16px', maxHeight: 420, overflowY: 'auto' }}
>
{templateVariables.categories.map(category => {
const categoryVariables = templateVariables.variables.filter(v => v.category === category.key)
if (categoryVariables.length === 0) return null
return (
<div key={category.key} style={{ marginBottom: 16 }}>
<Text type="secondary" style={{ marginBottom: 8, display: 'block', fontSize: 12 }}>
{t(CATEGORY_LABELS[category.key])}
</Text>
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{categoryVariables.sort((a, b) => a.sortOrder - b.sortOrder).map(renderVariableItem)}
</div>
</div>
)
})}
<Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0, fontSize: 11, textAlign: 'center' }}>
{t('notificationSettings.templates.clickToCopy')}
</Paragraph>
</Card>
)
}
// 机器人配置表格列
const configColumns = [
{
title: t('notificationSettings.configName'),
dataIndex: 'name',
key: 'name',
},
{
title: t('notificationSettings.type'),
dataIndex: 'type',
key: 'type',
render: (type: string) => <Tag color="blue">{type.toUpperCase()}</Tag>
},
{
title: t('notificationSettings.status'),
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) => (
<Tag color={enabled ? 'green' : 'default'}>
{enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')}
</Tag>
)
},
{
title: t('notificationSettings.chatIds'),
key: 'chatIds',
render: (_: any, record: NotificationConfig) => {
let chatIds: string[] = []
if (record.config) {
if ('data' in record.config && record.config.data) {
const data = (record.config as any).data
if (data.chatIds) {
if (Array.isArray(data.chatIds)) {
chatIds = data.chatIds.filter((id: any) => id && String(id).trim())
} else if (typeof data.chatIds === 'string') {
chatIds = data.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
}
}
} else if ('chatIds' in record.config) {
const ids = (record.config as any).chatIds
if (Array.isArray(ids)) {
chatIds = ids.filter((id: any) => id && String(id).trim())
} else if (typeof ids === 'string') {
chatIds = (ids as string).split(',').map((id: string) => id.trim()).filter((id: string) => id)
}
}
}
return chatIds.length > 0 ? (
<Text type="secondary" style={{ fontSize: '12px' }}>
{chatIds.join(', ')}
</Text>
) : (
<Text type="danger" style={{ fontSize: '12px' }}>{t('notificationSettings.chatIdsNotConfigured')}</Text>
)
}
},
{
title: t('common.actions'),
key: 'action',
width: isMobile ? 120 : 200,
render: (_: any, record: NotificationConfig) => (
<Space size="small" wrap>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
{t('notificationSettings.edit')}
</Button>
<Switch
checked={record.enabled}
size="small"
onChange={(checked) => handleUpdateEnabled(record.id!, checked)}
/>
<Button
type="link"
size="small"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTest}
>
{t('notificationSettings.test')}
</Button>
<Popconfirm
title={t('notificationSettings.deleteConfirm')}
onConfirm={() => handleDelete(record.id!)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
{t('notificationSettings.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
const templateTypeTabItems = templateTypes.map(type => ({
key: type.type,
label: (
<Tooltip title={t(`notificationSettings.templateTypeDescriptions.${type.type}`)} placement="top">
<span>{t(`notificationSettings.templateTypes.${type.type}`)}</span>
</Tooltip>
),
}))
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Title level={2} style={{ margin: 0 }}>{t('notificationSettings.title')}</Title>
</div>
{/* 机器人配置 */}
<Card
title={
<Space>
<RobotOutlined />
<span>{t('notificationSettings.botConfig')}</span>
</Space>
}
style={{ marginBottom: '16px' }}
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
{t('notificationSettings.addConfig')}
</Button>
}
>
<Table
columns={configColumns}
dataSource={configs}
loading={loading}
rowKey="id"
pagination={false}
scroll={{ x: isMobile ? 600 : 'auto' }}
/>
</Card>
{/* 模板配置 */}
<Card
title={
<Space>
<FormOutlined />
<span>{t('notificationSettings.templateConfig')}</span>
</Space>
}
style={{ marginBottom: '16px' }}
>
<Tabs
activeKey={selectedTemplateType}
onChange={handleTemplateTypeChange}
items={templateTypeTabItems}
style={{ marginBottom: 16 }}
tabBarStyle={{ marginBottom: 0 }}
type={isMobile ? 'line' : 'card'}
size={isMobile ? 'small' : 'middle'}
/>
<Row gutter={[16, 16]}>
<Col xs={24} sm={24} md={17}>
<Card size="small" bordered={false} style={{ background: '#fafafa', marginBottom: 12, borderRadius: 8 }} bodyStyle={{ padding: '10px 16px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<Space wrap size="small">
<Text type="secondary" style={{ fontSize: 13 }}>{t('notificationSettings.templates.templateContent')}</Text>
{currentTemplate && (
<Tag color={currentTemplate.isDefault ? 'green' : 'blue'} style={{ margin: 0 }}>
{currentTemplate.isDefault ? t('notificationSettings.templates.isDefault') : t('notificationSettings.templates.isCustom')}
</Tag>
)}
</Space>
<Space wrap size="small">
<Popconfirm
title={t('notificationSettings.templates.resetConfirm')}
onConfirm={handleResetTemplate}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button size="small" icon={<ReloadOutlined />}>
{t('notificationSettings.templates.resetToDefault')}
</Button>
</Popconfirm>
<Button size="small" type="primary" icon={<CheckOutlined />} onClick={handleSaveTemplate}>
{t('common.save')}
</Button>
<Button size="small" icon={<SendOutlined />} loading={testTemplateLoading} onClick={handleTestTemplate}>
{t('notificationSettings.test')}
</Button>
</Space>
</div>
</Card>
<TextArea
value={templateContent}
onChange={handleTemplateContentChange}
rows={isMobile ? 15 : 16}
style={{ fontFamily: 'monospace', fontSize: 13, borderRadius: 8, resize: 'none' }}
placeholder={t('notificationSettings.templates.contentPlaceholder')}
/>
</Col>
<Col xs={24} sm={24} md={7}>
{renderVariablesPanel()}
</Col>
</Row>
</Card>
<Modal
title={editingConfig ? t('notificationSettings.editConfig') : t('notificationSettings.addConfig')}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={isMobile ? '90%' : 600}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Form
form={form}
layout="vertical"
>
<Form.Item
name="type"
label={t('notificationSettings.type')}
rules={[{ required: true, message: t('notificationSettings.typeRequired') }]}
>
<Input disabled value="telegram" />
</Form.Item>
<Form.Item
name="name"
label={t('notificationSettings.configName')}
rules={[{ required: true, message: t('notificationSettings.configNameRequired') }]}
>
<Input placeholder={t('notificationSettings.configNamePlaceholder')} />
</Form.Item>
<Form.Item
name="enabled"
label={t('notificationSettings.enabled')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate={(prevValues, currentValues) => {
return prevValues.type !== currentValues.type ||
prevValues.config !== currentValues.config
}}>
{() => {
const currentType = form.getFieldValue('type') || 'telegram'
return getConfigFormComponent(currentType)
}}
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default NotificationSettingsPage
+55 -116
View File
@@ -833,7 +833,7 @@ const PositionList: React.FC = () => {
)
}
// 根据仓位类型动态生成列(历史仓位不显示当前价格、当前价值、状态列
// 根据仓位类型动态生成列(优化后的紧凑布局
const columns = useMemo(() => {
const baseColumns: any[] = [
{
@@ -853,7 +853,6 @@ const PositionList: React.FC = () => {
objectFit: 'cover'
}}
onError={(e) => {
// 图片加载失败时隐藏
e.currentTarget.style.display = 'none'
}}
/>
@@ -876,7 +875,7 @@ const PositionList: React.FC = () => {
</div>
),
fixed: isMobile ? ('left' as const) : undefined,
width: isMobile ? 150 : 200
width: isMobile ? 120 : 160
},
{
title: '市场',
@@ -924,7 +923,7 @@ const PositionList: React.FC = () => {
</div>
)
},
width: isMobile ? 200 : 250
width: isMobile ? 180 : 220
},
{
title: '方向',
@@ -933,136 +932,76 @@ const PositionList: React.FC = () => {
render: (side: string) => (
<Tag color={getSideColor(side)}>{side}</Tag>
),
width: 80
width: 70
},
{
title: '数量',
dataIndex: 'quantity',
key: 'quantity',
render: (quantity: string) => formatNumber(quantity, 4),
title: '持仓',
key: 'position',
render: (_: any, record: AccountPosition) => (
<div>
<div style={{ fontWeight: '500' }}>{formatNumber(record.quantity, 4)}</div>
<div style={{ fontSize: '12px', color: '#999' }}>@{formatNumber(record.avgPrice, 4)}</div>
</div>
),
align: 'right' as const,
width: 100
},
{
title: '平均价格',
dataIndex: 'avgPrice',
key: 'avgPrice',
render: (price: string) => formatNumber(price, 4),
align: 'right' as const,
width: 120
},
{
title: '开仓价值',
dataIndex: 'initialValue',
key: 'initialValue',
render: (value: string) => (
<span>
{formatUSDC(value)} USDC
</span>
<span>{formatUSDC(value)} USDC</span>
),
align: 'right' as const,
width: 120
width: 110
},
]
// 只有当前仓位才显示当前价格和当前价值列
// 只有当前仓位才显示当前价值/盈亏合并
if (positionFilter === 'current') {
baseColumns.push(
{
title: '当前价格',
dataIndex: 'currentPrice',
key: 'currentPrice',
render: (price: string) => formatNumber(price, 4),
align: 'right' as const,
width: 120
},
{
title: '当前价值',
dataIndex: 'currentValue',
key: 'currentValue',
render: (value: string) => (
<span style={{ fontWeight: 'bold' }}>
{formatUSDC(value)} USDC
</span>
),
align: 'right' as const,
width: 120,
sorter: (a: AccountPosition, b: AccountPosition) => {
const valA = parseFloat(a.currentValue || '0')
const valB = parseFloat(b.currentValue || '0')
return valA - valB
},
defaultSortOrder: 'descend' as const
}
)
}
baseColumns.push({
title: '当前价值 / 盈亏',
key: 'valueAndPnl',
render: (_: any, record: AccountPosition) => {
const pnlNum = parseFloat(record.pnl || '0')
const realizedPnl = record.realizedPnl ? parseFloat(record.realizedPnl) : null
const percentRealizedPnl = record.percentRealizedPnl ? parseFloat(record.percentRealizedPnl) : null
// 只有当前仓位才显示盈亏和已实现盈亏列
if (positionFilter === 'current') {
baseColumns.push(
{
title: '盈亏',
dataIndex: 'pnl',
key: 'pnl',
render: (pnl: string, record: AccountPosition) => {
const pnlNum = parseFloat(pnl || '0')
const percentPnl = parseFloat(record.percentPnl || '0')
return (
<div>
<div style={{
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
fontWeight: 'bold'
}}>
{pnlNum >= 0 ? '+' : ''}{formatUSDC(pnl)} USDC
</div>
<div style={{
fontSize: '12px',
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
}}>
{formatPercent(record.percentPnl)}
</div>
return (
<div>
<div style={{ fontWeight: '600', marginBottom: '2px' }}>
{formatUSDC(record.currentValue)} USDC
</div>
)
},
align: 'right' as const,
width: 150,
sorter: (a: AccountPosition, b: AccountPosition) => {
const pnlA = parseFloat(a.pnl || '0')
const pnlB = parseFloat(b.pnl || '0')
return pnlA - pnlB
}
<div style={{
fontSize: '13px',
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
fontWeight: '500'
}}>
{pnlNum >= 0 ? '+' : ''}{formatUSDC(record.pnl)} ({formatPercent(record.percentPnl)})
</div>
{realizedPnl !== null && (
<div style={{
fontSize: '11px',
color: '#999',
marginTop: '2px'
}}>
: {realizedPnl >= 0 ? '+' : ''}{formatUSDC(record.realizedPnl)}
{percentRealizedPnl !== null && ` (${formatPercent(record.percentRealizedPnl)})`}
</div>
)}
</div>
)
},
{
title: '已实现盈亏',
dataIndex: 'realizedPnl',
key: 'realizedPnl',
render: (realizedPnl: string | undefined, record: AccountPosition) => {
if (!realizedPnl) return '-'
const pnlNum = parseFloat(realizedPnl)
const percentPnl = parseFloat(record.percentRealizedPnl || '0')
return (
<div>
<div style={{
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
fontWeight: 'bold'
}}>
{pnlNum >= 0 ? '+' : ''}{formatUSDC(realizedPnl)} USDC
</div>
{record.percentRealizedPnl && (
<div style={{
fontSize: '12px',
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
}}>
{formatPercent(record.percentRealizedPnl)}
</div>
)}
</div>
)
},
align: 'right' as const,
width: 150
}
)
align: 'right' as const,
width: 160,
sorter: (a: AccountPosition, b: AccountPosition) => {
const valA = parseFloat(a.currentValue || '0')
const valB = parseFloat(b.currentValue || '0')
return valA - valB
},
defaultSortOrder: 'descend' as const
})
}
// 只有当前仓位才显示操作列
@@ -1084,7 +1023,7 @@ const PositionList: React.FC = () => {
)}
</Space>
),
width: 150,
width: 80,
fixed: isMobile ? ('right' as const) : undefined
})
}
+21 -317
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react'
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Select, Table, Tag, Popconfirm, Modal } from 'antd'
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, NotificationOutlined, KeyOutlined, LinkOutlined, PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert, Select } from 'antd'
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined, GlobalOutlined, NotificationOutlined, KeyOutlined, LinkOutlined, RightOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { SystemConfig, BuilderApiKeyUpdateRequest, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types'
import { TelegramConfigForm } from '../components/notifications'
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
import SystemUpdate from './SystemUpdate'
const { Title, Text, Paragraph } = Typography
@@ -33,20 +33,13 @@ interface ProxyCheckResponse {
const SystemSettings: React.FC = () => {
const { t, i18n: i18nInstance } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate()
// 第一部分:多语言
const [languageForm] = Form.useForm()
const [currentLang, setCurrentLang] = useState<string>('auto')
// 第二部分:消息推送设
const [notificationConfigs, setNotificationConfigs] = useState<NotificationConfig[]>([])
const [notificationLoading, setNotificationLoading] = useState(false)
const [notificationModalVisible, setNotificationModalVisible] = useState(false)
const [editingNotificationConfig, setEditingNotificationConfig] = useState<NotificationConfig | null>(null)
const [notificationForm] = Form.useForm()
const [testLoading, setTestLoading] = useState(false)
// 第三部分:Relayer配置
// 第二部分:Relayer配
const [relayerForm] = Form.useForm()
const [autoRedeemForm] = Form.useForm()
const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null)
@@ -67,7 +60,6 @@ const SystemSettings: React.FC = () => {
languageForm.setFieldsValue({ language: savedLanguage })
// 加载其他配置
fetchNotificationConfigs()
fetchSystemConfig()
fetchProxyConfig()
}, [])
@@ -103,246 +95,7 @@ const SystemSettings: React.FC = () => {
}
}
// ==================== 第二部分:消息推送设置 ====================
const fetchNotificationConfigs = async () => {
setNotificationLoading(true)
try {
const response = await apiService.notifications.list({ type: 'telegram' })
if (response.data.code === 0 && response.data.data) {
setNotificationConfigs(response.data.data)
} else {
message.error(response.data.msg || t('notificationSettings.fetchFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.fetchFailed'))
} finally {
setNotificationLoading(false)
}
}
const handleNotificationCreate = () => {
setEditingNotificationConfig(null)
notificationForm.resetFields()
notificationForm.setFieldsValue({
type: 'telegram',
enabled: true,
config: {
botToken: '',
chatIds: []
}
})
setNotificationModalVisible(true)
}
const handleNotificationEdit = (config: NotificationConfig) => {
setEditingNotificationConfig(config)
let botToken = ''
let chatIds = ''
if (config.config) {
if ('data' in config.config && config.config.data) {
const data = config.config.data as any
botToken = data.botToken || ''
if (data.chatIds) {
if (Array.isArray(data.chatIds)) {
chatIds = data.chatIds.join(',')
} else if (typeof data.chatIds === 'string') {
chatIds = data.chatIds
}
}
} else {
if ('botToken' in config.config) {
botToken = (config.config as any).botToken || ''
}
if ('chatIds' in config.config) {
const ids = (config.config as any).chatIds
if (Array.isArray(ids)) {
chatIds = ids.join(',')
} else if (typeof ids === 'string') {
chatIds = ids
}
}
}
}
notificationForm.setFieldsValue({
type: config.type,
name: config.name,
enabled: config.enabled,
config: {
botToken: botToken,
chatIds: chatIds
}
})
setNotificationModalVisible(true)
}
const handleNotificationDelete = async (id: number) => {
try {
const response = await apiService.notifications.delete({ id })
if (response.data.code === 0) {
message.success(t('notificationSettings.deleteSuccess'))
fetchNotificationConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.deleteFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.deleteFailed'))
}
}
const handleNotificationUpdateEnabled = async (id: number, enabled: boolean) => {
try {
const response = await apiService.notifications.updateEnabled({ id, enabled })
if (response.data.code === 0) {
message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess'))
fetchNotificationConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.updateStatusFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.updateStatusFailed'))
}
}
const handleNotificationTest = async () => {
setTestLoading(true)
try {
const response = await apiService.notifications.test({ message: '这是一条测试消息' })
if (response.data.code === 0 && response.data.data) {
message.success(t('notificationSettings.testSuccess'))
} else {
message.error(response.data.msg || t('notificationSettings.testFailed'))
}
} catch (error: any) {
message.error(error.message || t('notificationSettings.testFailed'))
} finally {
setTestLoading(false)
}
}
const handleNotificationSubmit = async () => {
try {
const values = await notificationForm.validateFields()
const chatIds = typeof values.config.chatIds === 'string'
? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id)
: values.config.chatIds || []
const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = {
type: values.type,
name: values.name,
enabled: values.enabled,
config: {
botToken: values.config.botToken,
chatIds: chatIds
}
}
if (editingNotificationConfig?.id) {
const updateData = {
...configData,
id: editingNotificationConfig.id
} as NotificationConfigUpdateRequest
const response = await apiService.notifications.update(updateData)
if (response.data.code === 0) {
message.success(t('notificationSettings.updateSuccess'))
setNotificationModalVisible(false)
fetchNotificationConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.updateFailed'))
}
} else {
const response = await apiService.notifications.create(configData)
if (response.data.code === 0) {
message.success(t('notificationSettings.createSuccess'))
setNotificationModalVisible(false)
fetchNotificationConfigs()
} else {
message.error(response.data.msg || t('notificationSettings.createFailed'))
}
}
} catch (error: any) {
if (error.errorFields) {
return
}
message.error(error.message || t('message.error'))
}
}
const notificationColumns = [
{
title: t('notificationSettings.configName'),
dataIndex: 'name',
key: 'name',
},
{
title: t('notificationSettings.type'),
dataIndex: 'type',
key: 'type',
render: (type: string) => <Tag color="blue">{type.toUpperCase()}</Tag>
},
{
title: t('notificationSettings.status'),
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) => (
<Tag color={enabled ? 'green' : 'default'}>
{enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')}
</Tag>
)
},
{
title: t('common.actions'),
key: 'action',
width: isMobile ? 120 : 200,
render: (_: any, record: NotificationConfig) => (
<Space size="small" wrap>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleNotificationEdit(record)}
>
{t('notificationSettings.edit')}
</Button>
<Switch
checked={record.enabled}
size="small"
onChange={(checked) => handleNotificationUpdateEnabled(record.id!, checked)}
/>
<Button
type="link"
size="small"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleNotificationTest}
>
{t('notificationSettings.test')}
</Button>
<Popconfirm
title={t('notificationSettings.deleteConfirm')}
onConfirm={() => handleNotificationDelete(record.id!)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
{t('notificationSettings.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
// ==================== 第三部分:Relayer配置 ====================
// ==================== 第二部分:Relayer配置 ====================
const fetchSystemConfig = async () => {
try {
const response = await apiService.systemConfig.get()
@@ -551,7 +304,7 @@ const SystemSettings: React.FC = () => {
</Form>
</Card>
{/* 第二部分:消息推送设置 */}
{/* 第二部分:消息推送设置(独立页面入口) */}
<Card
title={
<Space>
@@ -563,73 +316,24 @@ const SystemSettings: React.FC = () => {
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleNotificationCreate}
icon={<RightOutlined />}
onClick={() => navigate('/system-settings/notification')}
>
{t('notificationSettings.addConfig')}
{t('notificationSettings.title')}
</Button>
}
>
<Table
columns={notificationColumns}
dataSource={notificationConfigs}
loading={notificationLoading}
rowKey="id"
pagination={false}
scroll={{ x: isMobile ? 600 : 'auto' }}
/>
<Modal
title={editingNotificationConfig ? t('notificationSettings.editConfig') : t('notificationSettings.addConfig')}
open={notificationModalVisible}
onOk={handleNotificationSubmit}
onCancel={() => setNotificationModalVisible(false)}
width={isMobile ? '90%' : 600}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
{t('notificationSettings.botConfig')}{t('notificationSettings.templateConfig')}
</Paragraph>
<Button
type="link"
icon={<RightOutlined />}
onClick={() => navigate('/system-settings/notification')}
style={{ padding: 0 }}
>
<Form
form={notificationForm}
layout="vertical"
>
<Form.Item
name="type"
label={t('notificationSettings.type')}
rules={[{ required: true, message: t('notificationSettings.typeRequired') }]}
>
<Input disabled value="telegram" />
</Form.Item>
<Form.Item
name="name"
label={t('notificationSettings.configName')}
rules={[{ required: true, message: t('notificationSettings.configNameRequired') }]}
>
<Input placeholder={t('notificationSettings.configNamePlaceholder')} />
</Form.Item>
<Form.Item
name="enabled"
label={t('notificationSettings.enabled')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate={(prevValues, currentValues) => {
return prevValues.type !== currentValues.type ||
prevValues.config !== currentValues.config
}}>
{() => {
const currentType = notificationForm.getFieldValue('type') || 'telegram'
if (currentType === 'telegram') {
return <TelegramConfigForm form={notificationForm} />
}
return null
}}
</Form.Item>
</Form>
</Modal>
{t('notificationSettings.title')}
</Button>
</Card>
{/* 第三部分:Relayer配置 */}
+198 -145
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Input, Modal, Form, Radio, InputNumber, Switch, Divider, Spin } from 'antd'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Input, Modal, Form, Radio, InputNumber, Switch, Divider, Spin, Empty, List, Tooltip } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
@@ -212,25 +212,50 @@ const TemplateList: React.FC = () => {
{
title: t('common.actions') || '操作',
key: 'action',
width: isMobile ? 120 : 200,
width: isMobile ? 120 : 120,
fixed: 'right' as const,
render: (_: any, record: CopyTradingTemplate) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/templates/edit/${record.id}`)}
>
{t('common.edit') || '编辑'}
</Button>
<Button
type="link"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(record)}
>
{t('templateList.copy') || '复制'}
</Button>
<Space size={4}>
<Tooltip title={t('common.edit') || '编辑'}>
<div
onClick={() => navigate(`/templates/edit/${record.id}`)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Tooltip title={t('templateList.copy') || '复制'}>
<div
onClick={() => handleCopy(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<CopyOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
<Popconfirm
title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'}
description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'}
@@ -238,14 +263,24 @@ const TemplateList: React.FC = () => {
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
{t('common.delete') || '删除'}
</Button>
<Tooltip title={t('common.delete') || '删除'}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm>
</Space>
)
@@ -254,26 +289,29 @@ const TemplateList: React.FC = () => {
return (
<div>
<Card>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<h2 style={{ margin: 0 }}>{t('templateList.title') || '跟单模板管理'}</h2>
<Space>
<Search
placeholder={t('templateList.searchPlaceholder') || '搜索模板名称'}
allowClear
style={{ width: isMobile ? 150 : 250 }}
onSearch={setSearchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('templateList.title') || '跟单模板管理'}</h2>
<Space size={8}>
<Search
placeholder={t('templateList.searchPlaceholder') || '搜索模板名称'}
allowClear
style={{ width: isMobile ? 120 : 200 }}
onSearch={setSearchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<Tooltip title={t('templateList.addTemplate') || '新增模板'}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/templates/add')}
>
{t('templateList.addTemplate') || '新增模板'}
</Button>
</Space>
</div>
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
/>
</Tooltip>
</Space>
</div>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
{isMobile ? (
// 移动端卡片布局
@@ -283,116 +321,133 @@ const TemplateList: React.FC = () => {
<Spin size="large" />
</div>
) : filteredTemplates.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('templateList.noData') || '暂无模板数据'}
</div>
<Empty description={t('templateList.noData') || '暂无模板数据'} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{filteredTemplates.map((template) => {
const date = new Date(template.createdAt)
const formattedDate = date.toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
<List
dataSource={filteredTemplates}
renderItem={(template) => {
return (
<Card
key={template.id}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: '16px' }}
bodyStyle={{ padding: '0' }}
>
{/* 模板名称和模式 */}
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '8px',
color: '#1890ff'
}}>
{/* 头部区域 - 模板名称 */}
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px' }}>
{template.templateName}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag color={template.copyMode === 'RATIO' ? 'blue' : 'green'}>
{template.copyMode === 'RATIO' ? (t('templateList.ratioMode') || '比例模式') : (t('templateList.fixedAmountMode') || '固定金额模式')}
</Tag>
<Tag color={template.supportSell ? 'green' : 'red'}>
{template.supportSell ? (t('templateList.supportSell') || '跟单卖出') : (t('templateList.notSupportSell') || '不跟单卖出')}
</Tag>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* 跟单配置 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.copyConfig') || '跟单配置'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
<div style={{ fontSize: '12px', opacity: '0.9' }}>
{template.copyMode === 'RATIO'
? `${t('templateList.ratio') || '比例'} ${template.copyRatio}x`
: template.fixedAmount
? `${t('templateList.fixedAmount') || '固定'} ${formatUSDC(template.fixedAmount)} USDC`
: '-'
? `${t('templateList.ratioMode') || '比例模式'} ${(parseFloat(template.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
: `${t('templateList.fixedAmountMode') || '固定金额'} ${formatUSDC(template.fixedAmount || '0')} USDC`
}
</div>
</div>
{/* 其他配置信息 */}
{template.copyMode === 'RATIO' && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.amountLimit') || '金额限制'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{template.maxOrderSize && (
<span>{t('templateList.max') || '最大'}: {formatUSDC(template.maxOrderSize)} USDC</span>
)}
{template.maxOrderSize && template.minOrderSize && <span> | </span>}
{template.minOrderSize && (
<span>{t('templateList.min') || '最小'}: {formatUSDC(template.minOrderSize)} USDC</span>
)}
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#999' }}>{t('templateList.notSet') || '未设置'}</span>}
{/* 配置信息区域 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#fafafa',
borderBottom: '1px solid #f0f0f0',
minHeight: '42px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('templateList.supportSell') || '跟单卖出'}
</div>
<div style={{ fontSize: '12px', fontWeight: '500' }}>
<Tag color={template.supportSell ? 'green' : 'red'} style={{ margin: 0, fontSize: '10px' }}>
{template.supportSell ? (t('common.yes') || '是') : (t('common.no') || '否')}
</Tag>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('templateList.maxDailyOrders') || '每日最大'}
</div>
<div style={{ fontSize: '12px', fontWeight: '500', color: '#1890ff' }}>
{template.maxDailyOrders} {t('common.orders') || '单'}
</div>
</div>
</div>
</div>
{/* 金额限制区域(仅比例模式显示) */}
{template.copyMode === 'RATIO' && (
<div style={{
padding: '6px 12px',
fontSize: '11px',
color: '#8c8c8c',
borderBottom: '1px solid #f0f0f0'
}}>
<span style={{ color: '#d48806' }}>{t('templateList.amountLimit') || '金额限制'}: </span>
{template.maxOrderSize && (
<span>{t('templateList.max') || '最大'} {formatUSDC(template.maxOrderSize)} USDC</span>
)}
{template.maxOrderSize && template.minOrderSize && <span> | </span>}
{template.minOrderSize && (
<span>{t('templateList.min') || '最小'} {formatUSDC(template.minOrderSize)} USDC</span>
)}
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#bfbfbf' }}>{t('templateList.notSet') || '未设置'}</span>}
</div>
)}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('templateList.otherConfig') || '其他配置'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('templateList.maxDailyOrders') || '每日最大订单'}: {template.maxDailyOrders} | {t('templateList.priceTolerance') || '价格容忍度'}: {template.priceTolerance}%
</div>
</div>
{/* 创建时间 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('common.createdAt') || '创建时间'}: {formattedDate}
</div>
<div style={{
padding: '6px 12px',
fontSize: '11px',
color: '#8c8c8c'
}}>
{t('common.createdAt') || '创建时间'}: {new Date(template.createdAt).toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/templates/edit/${template.id}`)}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.edit') || '编辑'}
</Button>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(template)}
style={{ flex: 1, minWidth: '80px' }}
>
{t('templateList.copy') || '复制'}
</Button>
{/* 图标操作栏 */}
<div style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center'
}}>
<Tooltip title={t('common.edit') || '编辑'}>
<div
onClick={() => navigate(`/templates/edit/${template.id}`)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EditOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit') || '编辑'}</span>
</div>
</Tooltip>
<Tooltip title={t('templateList.copy') || '复制'}>
<div
onClick={() => handleCopy(template)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<CopyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('templateList.copy') || '复制'}</span>
</div>
</Tooltip>
<Popconfirm
title={t('templateList.deleteConfirm') || '确定要删除这个模板吗?'}
description={t('templateList.deleteConfirmDesc') || '删除后无法恢复,请确保没有跟单关系在使用该模板'}
@@ -400,20 +455,18 @@ const TemplateList: React.FC = () => {
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
>
<Button
danger
size="small"
icon={<DeleteOutlined />}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.delete') || '删除'}
</Button>
<Tooltip title={t('common.delete') || '删除'}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
</Card>
)
})}
</div>
}}
/>
)}
</div>
) : (
+155 -37
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input } from 'antd'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, List, Empty, Tooltip, Spin } from 'antd'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
@@ -219,46 +219,164 @@ const UserList: React.FC = () => {
return (
<div>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<Title level={4} style={{ margin: 0 }}>{t('userList.title') || '用户管理'}</Title>
<Space>
<Button
icon={<EditOutlined />}
onClick={() => setUpdateOwnPasswordModalVisible(true)}
>
{t('userList.updateMyPassword') || '修改我的密码'}
</Button>
<Button
icon={<ReloadOutlined />}
onClick={fetchUsers}
loading={loading}
>
{t('common.refresh') || '刷新'}
</Button>
{isDefaultUser && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<Title level={isMobile ? 4 : 3} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>{t('userList.title') || '用户管理'}</Title>
<Space size={8} wrap>
<Button
icon={<KeyOutlined />}
onClick={() => setUpdateOwnPasswordModalVisible(true)}
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }}
>
{isMobile ? (t('userList.updateMyPassword') || '改密') : (t('userList.updateMyPassword') || '修改我的密码')}
</Button>
<Button
icon={<ReloadOutlined />}
onClick={fetchUsers}
loading={loading}
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px' }}
>
{t('common.refresh') || '刷新'}
</Button>
{isDefaultUser && (
<Tooltip title={t('userList.addUser') || '新增用户'}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalVisible(true)}
>
{t('userList.addUser') || '新增用户'}
</Button>
size={isMobile ? 'middle' : 'large'}
style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}
/>
</Tooltip>
)}
</Space>
</div>
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
{isMobile ? (
// 移动端卡片布局
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : users.length === 0 ? (
<Empty description={t('userList.noData') || '暂无用户数据'} />
) : (
<List
dataSource={users}
renderItem={(user) => (
<Card
key={user.id}
style={{
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: '0' }}
>
{/* 头部区域 - 用户名 */}
<div style={{
padding: '10px 12px',
background: 'var(--ant-color-primary, #1677ff)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<UserOutlined style={{ fontSize: '14px' }} />
<span>{user.username}</span>
</div>
<div style={{ fontSize: '12px', opacity: '0.9' }}>
ID: {user.id}
</div>
</div>
{/* 角色信息区域 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#fafafa',
borderBottom: '1px solid #f0f0f0',
minHeight: '42px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('userList.role') || '角色'}
</div>
<Tag color={user.isDefault ? 'red' : 'blue'} style={{ margin: 0 }}>
{user.isDefault ? (t('userList.defaultAccount') || '默认账户') : (t('userList.normalUser') || '普通用户')}
</Tag>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('common.createdAt') || '创建时间'}
</div>
<div style={{ fontSize: '12px', fontWeight: '500', color: '#666' }}>
{new Date(user.createdAt).toLocaleDateString(i18n.language || 'zh-CN')}
</div>
</div>
</div>
</div>
{/* 操作区域(仅管理员可见) */}
{isDefaultUser && !user.isDefault && (
<div style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center'
}}>
<Tooltip title={t('userList.updatePassword') || '修改密码'}>
<div
onClick={() => {
setSelectedUser(user)
setUpdatePasswordModalVisible(true)
}}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<KeyOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('userList.updatePassword') || '改密'}</span>
</div>
</Tooltip>
<Popconfirm
title={t('userList.deleteConfirm') || '确定要删除这个用户吗?'}
onConfirm={() => handleDelete(user)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
>
<Tooltip title={t('common.delete') || '删除'}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete') || '删除'}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
)}
</Card>
)}
/>
)}
</Space>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
pageSize: isMobile ? 10 : 20,
showSizeChanger: !isMobile,
showTotal: (total) => t('userList.total', { total }) || `${total}`
}}
scroll={isMobile ? { x: 600 } : undefined}
/>
</div>
) : (
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
pageSize: isMobile ? 10 : 20,
showSizeChanger: !isMobile,
showTotal: (total) => t('userList.total', { total }) || `${total}`
}}
scroll={isMobile ? { x: 600 } : undefined}
/>
)}
</Card>
{/* 创建用户弹窗 */}
+61 -5
View File
@@ -1,5 +1,5 @@
import axios, { AxiosInstance, AxiosError } from 'axios'
import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest } from '../types'
import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse } from '../types'
import { getToken, setToken, removeToken } from '../utils'
import { wsManager } from './websocket'
import i18n from '../i18n/config'
@@ -494,14 +494,26 @@ export const apiService = {
endDate?: number
}) =>
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
pnlCurve: (data: import('../types').CryptoTailPnlCurveRequest) =>
apiClient.post<ApiResponse<import('../types').CryptoTailPnlCurveResponse>>('/crypto-tail-strategy/pnl-curve', data),
marketOptions: () =>
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
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 })
monitorInit: (data: { strategyId: number; periodStartUnix?: number }) =>
apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', data),
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
*/
@@ -674,7 +686,51 @@ export const apiService = {
* Telegram Chat IDs
*/
getTelegramChatIds: (data: { botToken: string }) =>
apiClient.post<ApiResponse<string[]>>('/system/notifications/telegram/get-chat-ids', data)
apiClient.post<ApiResponse<string[]>>('/system/notifications/telegram/get-chat-ids', data),
// ==================== 模板相关 API ====================
/**
*
*/
getTemplateTypes: () =>
apiClient.post<ApiResponse<TemplateTypeInfo[]>>('/system/notifications/templates/types', {}),
/**
*
*/
getTemplates: () =>
apiClient.post<ApiResponse<NotificationTemplate[]>>('/system/notifications/templates/list', {}),
/**
*
*/
getTemplateDetail: (data: { templateType: string }) =>
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/detail', data),
/**
*
*/
getTemplateVariables: (data: { templateType: string }) =>
apiClient.post<ApiResponse<TemplateVariablesResponse>>('/system/notifications/templates/variables', data),
/**
*
*/
updateTemplate: (data: { templateType: string; templateContent: string }) =>
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/update', data),
/**
*
*/
resetTemplate: (data: { templateType: string }) =>
apiClient.post<ApiResponse<NotificationTemplate>>('/system/notifications/templates/reset', data),
/**
*
*/
testTemplate: (data: { templateType: string; templateContent?: string }) =>
apiClient.post<ApiResponse<boolean>>('/system/notifications/templates/test', data)
},
/**
+107 -1
View File
@@ -580,12 +580,13 @@ export interface OrderMessage {
/**
* API
* price avgFilledPrice
*/
export interface OrderDetail {
id: string // 订单 ID
market: string // 市场 ID (condition ID)
side: string // BUY/SELL
price: string // 价
price: string // 订单限
size: string // 订单大小
filled: string // 已成交数量
status: string // 订单状态
@@ -593,6 +594,7 @@ export interface OrderDetail {
marketName?: string // 市场名称
marketSlug?: string // 市场 slug
marketIcon?: string // 市场图标
avgFilledPrice?: string // 平均成交价(有成交时优先展示)
}
/**
@@ -1097,6 +1099,33 @@ export interface CryptoTailStrategyTriggerDto {
createdAt: number
}
/** 收益曲线请求 */
export interface CryptoTailPnlCurveRequest {
strategyId: number
startDate?: number
endDate?: number
}
/** 收益曲线单点 */
export interface CryptoTailPnlCurvePoint {
timestamp: number
cumulativePnl: string
pointPnl: string
settledCount: number
}
/** 收益曲线响应 */
export interface CryptoTailPnlCurveResponse {
strategyId: number
strategyName: string
totalRealizedPnl: string
settledCount: number
winCount: number
winRate: string | null
maxDrawdown: string | null
curveData: CryptoTailPnlCurvePoint[]
}
/**
*
*/
@@ -1156,6 +1185,10 @@ export interface CryptoTailMonitorInitResponse {
currentTimestamp: number
/** 是否启用 */
enabled: boolean
/** 投入金额模式: FIXED or RATIO */
amountMode?: string
/** 投入金额数值 */
amountValue?: string
}
/**
@@ -1203,3 +1236,76 @@ 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
}
// ==================== 消息模板相关类型 ====================
/**
*
*/
export interface NotificationTemplate {
id?: number
templateType: string // 模板类型
templateContent: string // 模板内容
isDefault: boolean // 是否使用默认模板
createdAt?: number
updatedAt?: number
}
/**
*
*/
export interface TemplateTypeInfo {
type: string // 模板类型
name: string // 类型名称
description: string // 类型描述
}
/**
*
*/
export interface TemplateVariable {
key: string // 变量名
category: string // 分类
sortOrder: number // 排序顺序
}
/**
*
*/
export interface TemplateVariableCategory {
key: string // 分类 key
sortOrder: number // 排序顺序
}
/**
*
*/
export interface TemplateVariablesResponse {
templateType: string // 模板类型
categories: TemplateVariableCategory[] // 分类列表
variables: TemplateVariable[] // 变量列表
}