Merge branch 'dev'
Made-with: Cursor
This commit is contained in:
+22
-11
@@ -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_matched(filled),否则用 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>} />
|
||||
|
||||
{/* 默认重定向到登录页 */}
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "周期已切换,弹窗已关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
{/* 创建用户弹窗 */}
|
||||
|
||||
@@ -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
@@ -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[] // 变量列表
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user