feat: 添加关键字过滤功能并优化市场 slug 处理

主要变更:
1. 关键字过滤功能
   - 添加关键字过滤模式(白名单/黑名单/禁用)
   - 支持关键字列表配置(JSON 存储)
   - 实现关键字匹配逻辑(不区分大小写)
   - 数据库迁移:V20__add_keyword_filter.sql

2. 市场 slug 优化
   - 区分显示用 slug 和跳转用 eventSlug
   - 添加 event_slug 字段到 markets 表(V21__add_event_slug_to_markets.sql)
   - 从 events[0].slug 获取跳转用的 slug 并存入数据库
   - 减少 API 请求,提高性能

3. UI 优化
   - 将创建/编辑跟单配置改为 Modal 方式
   - 删除独立的 CopyTradingAdd 和 CopyTradingEdit 页面
   - 更新前端跳转逻辑,优先使用 eventSlug

4. API 响应优化
   - 更新 MarketResponse 添加 events 字段
   - 统一从 events[0].slug 获取跳转用的 slug
   - 更新所有相关服务使用新的 slug 获取逻辑
This commit is contained in:
WrBug
2026-01-08 17:47:00 +08:00
parent 3d5a923caf
commit 9ed5190bfe
33 changed files with 738 additions and 773 deletions
-4
View File
@@ -23,8 +23,6 @@ import TemplateList from './pages/TemplateList'
import TemplateAdd from './pages/TemplateAdd'
import TemplateEdit from './pages/TemplateEdit'
import CopyTradingList from './pages/CopyTradingList'
import CopyTradingAdd from './pages/CopyTradingAdd'
import CopyTradingEdit from './pages/CopyTradingEdit'
import CopyTradingStatistics from './pages/CopyTradingStatistics'
import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders'
import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
@@ -250,8 +248,6 @@ function App() {
<Route path="/templates/add" element={<ProtectedRoute><TemplateAdd /></ProtectedRoute>} />
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/copy-trading/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
<Route path="/copy-trading/edit/:id" element={<ProtectedRoute><CopyTradingEdit /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
@@ -227,8 +227,8 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Radio value="safe">
{t('accountImport.walletTypeSafe')}
</Radio>
<Radio value="magic">
{t('accountImport.walletTypeMagic')}
<Radio value="magic" disabled>
{t('accountImport.walletTypeMagic')} {t('accountImport.magicNotSupported')}
</Radio>
</Radio.Group>
</Form.Item>
+28 -2
View File
@@ -30,7 +30,10 @@
"total": "Total",
"items": "items",
"prev": "Previous",
"next": "Next"
"previous": "Previous",
"next": "Next",
"page": "Page",
"pageOf": "Page"
},
"account": {
"title": "Account Management",
@@ -203,7 +206,8 @@
"walletType": "Wallet Type",
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
"walletTypeMagic": "Magic (Email/Social Login)",
"walletTypeSafe": "Web3 Wallet"
"walletTypeSafe": "Web3 Wallet",
"magicNotSupported": "(Not Supported)"
},
"leader": {
"title": "Leader Management",
@@ -753,6 +757,17 @@
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"keywordFilter": "Keyword Filter",
"keywordFilterMode": "Filter Mode",
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
"disabled": "Disabled",
"whitelist": "Whitelist",
"blacklist": "Blacklist",
"keywords": "Keywords",
"keywordPlaceholder": "Enter keyword, press Enter to add",
"keywordExists": "Keyword already exists",
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
"configName": "Configuration Name",
"configNameRequired": "Please enter configuration name",
"configNamePlaceholder": "e.g., Copy Trading Config 1",
@@ -839,6 +854,17 @@
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"keywordFilter": "Keyword Filter",
"keywordFilterMode": "Filter Mode",
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
"disabled": "Disabled",
"whitelist": "Whitelist",
"blacklist": "Blacklist",
"keywords": "Keywords",
"keywordPlaceholder": "Enter keyword, press Enter to add",
"keywordExists": "Keyword already exists",
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
"configName": "Configuration Name",
"configNameRequired": "Please enter configuration name",
"configNamePlaceholder": "e.g., Copy Trading Config 1",
+27 -1
View File
@@ -27,7 +27,10 @@
"total": "共",
"items": "条",
"prev": "上一页",
"previous": "上一页",
"next": "下一页",
"page": "页",
"pageOf": "第",
"success": "成功",
"failed": "失败",
"close": "关闭"
@@ -203,7 +206,8 @@
"walletType": "钱包类型",
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
"walletTypeSafe": "Web3钱包"
"walletTypeSafe": "Web3钱包",
"magicNotSupported": "(暂不支持)"
},
"leader": {
"title": "Leader 管理",
@@ -765,6 +769,17 @@
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
"disabled": "不启用",
"whitelist": "白名单",
"blacklist": "黑名单",
"keywords": "关键字",
"keywordPlaceholder": "输入关键字,按回车添加",
"keywordExists": "关键字已存在",
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
"create": "创建跟单配置",
@@ -851,6 +866,17 @@
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
"disabled": "不启用",
"whitelist": "白名单",
"blacklist": "黑名单",
"keywords": "关键字",
"keywordPlaceholder": "输入关键字,按回车添加",
"keywordExists": "关键字已存在",
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
"save": "保存",
+28 -2
View File
@@ -30,7 +30,10 @@
"total": "共",
"items": "條",
"prev": "上一頁",
"next": "一頁"
"previous": "一頁",
"next": "下一頁",
"page": "頁",
"pageOf": "第"
},
"account": {
"title": "賬戶管理",
@@ -203,7 +206,8 @@
"walletType": "錢包類型",
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
"walletTypeSafe": "Web3錢包"
"walletTypeSafe": "Web3錢包",
"magicNotSupported": "(暫不支持)"
},
"leader": {
"title": "Leader 管理",
@@ -753,6 +757,17 @@
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
"disabled": "不啟用",
"whitelist": "白名單",
"blacklist": "黑名單",
"keywords": "關鍵字",
"keywordPlaceholder": "輸入關鍵字,按回車添加",
"keywordExists": "關鍵字已存在",
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"configName": "配置名",
"configNameRequired": "請輸入配置名",
"configNamePlaceholder": "例如:跟單配置1",
@@ -839,6 +854,17 @@
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
"disabled": "不啟用",
"whitelist": "白名單",
"blacklist": "黑名單",
"keywords": "關鍵字",
"keywordPlaceholder": "輸入關鍵字,按回車添加",
"keywordExists": "關鍵字已存在",
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"configName": "配置名",
"configNameRequired": "請輸入配置名",
"configNamePlaceholder": "例如:跟單配置1",
-614
View File
@@ -1,614 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Divider, Spin, Select, Input } from 'antd'
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { CopyTrading, CopyTradingUpdateRequest } from '../types'
import { useTranslation } from 'react-i18next'
const { Title } = Typography
const { Option } = Select
const CopyTradingEdit: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [fetching, setFetching] = useState(true)
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
useEffect(() => {
if (id) {
fetchCopyTrading(parseInt(id))
}
}, [id])
const fetchCopyTrading = async (copyTradingId: number) => {
setFetching(true)
try {
const response = await apiService.copyTrading.list({})
if (response.data.code === 0 && response.data.data) {
const found = response.data.data.list.find((ct: CopyTrading) => ct.id === copyTradingId)
if (found) {
setCopyTrading(found)
setCopyMode(found.copyMode)
setOriginalEnabled(found.enabled) // 保存原始的enabled状态
// 填充表单数据
form.setFieldsValue({
accountId: found.accountId,
leaderId: found.leaderId,
copyMode: found.copyMode,
copyRatio: found.copyRatio ? parseFloat(found.copyRatio) * 100 : 100, // 转换为百分比显示
fixedAmount: found.fixedAmount ? parseFloat(found.fixedAmount) : undefined,
maxOrderSize: found.maxOrderSize ? parseFloat(found.maxOrderSize) : undefined,
minOrderSize: found.minOrderSize ? parseFloat(found.minOrderSize) : undefined,
maxDailyLoss: found.maxDailyLoss ? parseFloat(found.maxDailyLoss) : undefined,
maxDailyOrders: found.maxDailyOrders,
priceTolerance: found.priceTolerance ? parseFloat(found.priceTolerance) : undefined,
delaySeconds: found.delaySeconds,
pollIntervalSeconds: found.pollIntervalSeconds,
useWebSocket: found.useWebSocket,
websocketReconnectInterval: found.websocketReconnectInterval,
websocketMaxRetries: found.websocketMaxRetries,
supportSell: found.supportSell,
minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined,
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
maxPositionCount: found.maxPositionCount,
configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false
})
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
navigate('/copy-trading')
}
} else {
message.error(response.data.msg || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
navigate('/copy-trading')
}
} catch (error: any) {
message.error(error.message || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
navigate('/copy-trading')
} finally {
setFetching(false)
}
}
const handleCopyModeChange = (mode: 'RATIO' | 'FIXED') => {
setCopyMode(mode)
}
const handleSubmit = async (values: any) => {
// 前端校验
if (values.copyMode === 'FIXED') {
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
message.error('固定金额必须 >= 1')
return
}
}
if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && Number(values.minOrderSize) < 1) {
message.error('最小金额必须 >= 1')
return
}
if (!id) {
message.error('配置ID不存在')
return
}
setLoading(true)
try {
const request: CopyTradingUpdateRequest = {
copyTradingId: parseInt(id),
enabled: originalEnabled, // 保持原有的enabled状态,不修改
copyMode: values.copyMode,
copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined,
fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined,
maxOrderSize: values.maxOrderSize?.toString(),
minOrderSize: values.minOrderSize?.toString(),
maxDailyLoss: values.maxDailyLoss?.toString(),
maxDailyOrders: values.maxDailyOrders,
priceTolerance: values.priceTolerance?.toString(),
delaySeconds: values.delaySeconds,
pollIntervalSeconds: values.pollIntervalSeconds,
useWebSocket: values.useWebSocket,
websocketReconnectInterval: values.websocketReconnectInterval,
websocketMaxRetries: values.websocketMaxRetries,
supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
configName: values.configName?.trim() || undefined,
pushFailedOrders: values.pushFailedOrders
}
const response = await apiService.copyTrading.update(request)
if (response.data.code === 0) {
message.success(t('copyTradingEdit.saveSuccess') || '更新跟单配置成功')
navigate('/copy-trading')
} else {
message.error(response.data.msg || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
}
} catch (error: any) {
message.error(error.message || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
} finally {
setLoading(false)
}
}
if (fetching) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
)
}
if (!copyTrading) {
return null
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/copy-trading')}
>
{t('common.back') || '返回'}
</Button>
</div>
<Card>
<Title level={4}>{t('copyTradingEdit.title') || '编辑跟单配置'}</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
{/* 基础信息(只读) */}
<Form.Item
label={t('copyTradingEdit.configName') || '配置名'}
name="configName"
rules={[
{ required: true, message: t('copyTradingEdit.configNameRequired') || '请输入配置名' },
{ whitespace: true, message: t('copyTradingEdit.configNameRequired') || '配置名不能为空' }
]}
tooltip={t('copyTradingEdit.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
>
<Input
placeholder={t('copyTradingEdit.configNamePlaceholder') || '例如:跟单配置1'}
maxLength={255}
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectWallet') || t('copyTradingEdit.selectWallet') || '钱包'}
name="accountId"
>
<Select disabled>
<Option value={copyTrading.accountId}>
{copyTrading.accountName || `账户 ${copyTrading.accountId}`} ({copyTrading.walletAddress.slice(0, 6)}...{copyTrading.walletAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectLeader') || t('copyTradingEdit.selectLeader') || 'Leader'}
name="leaderId"
>
<Select disabled>
<Option value={copyTrading.leaderId}>
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
{/* 跟单金额模式 */}
<Form.Item
label={t('copyTradingEdit.copyMode') || '跟单金额模式'}
name="copyMode"
tooltip={t('copyTradingEdit.copyModeTooltip') || '选择跟单金额的计算方式'}
rules={[{ required: true }]}
>
<Radio.Group onChange={(e) => handleCopyModeChange(e.target.value)}>
<Radio value="RATIO">{t('copyTradingEdit.ratioMode') || '比例模式'}</Radio>
<Radio value="FIXED">{t('copyTradingEdit.fixedAmountMode') || '固定金额模式'}</Radio>
</Radio.Group>
</Form.Item>
{copyMode === 'RATIO' && (
<Form.Item
label={t('copyTradingEdit.copyRatio') || '跟单比例'}
name="copyRatio"
tooltip={t('copyTradingEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比'}
>
<InputNumber
min={0.01}
max={10000}
step={0.01}
precision={2}
style={{ width: '100%' }}
addonAfter="%"
placeholder={t('copyTradingEdit.copyRatioPlaceholder') || '例如:100 表示 100%1:1 跟单)'}
parser={(value) => {
console.log('[CopyTradingEdit copyRatio parser] 输入值:', value, '类型:', typeof value)
// 移除 % 符号和其他非数字字符(保留小数点和负号)
const cleaned = (value || '').toString().replace(/%/g, '').trim()
console.log('[CopyTradingEdit copyRatio parser] 清理后:', cleaned)
const parsed = parseFloat(cleaned) || 0
console.log('[CopyTradingEdit copyRatio parser] 解析后:', parsed)
if (parsed > 10000) {
console.log('[CopyTradingEdit copyRatio parser] 超过最大值,返回 10000')
return 10000
}
if (parsed < 0.01) {
console.log('[CopyTradingEdit copyRatio parser] 小于最小值,返回 0.01')
return 0.01
}
console.log('[CopyTradingEdit copyRatio parser] 返回:', parsed)
return parsed
}}
formatter={(value) => {
console.log('[CopyTradingEdit copyRatio formatter] 输入值:', value, '类型:', typeof value)
if (!value && value !== 0) {
console.log('[CopyTradingEdit copyRatio formatter] 空值,返回空字符串')
return ''
}
const num = parseFloat(value.toString())
console.log('[CopyTradingEdit copyRatio formatter] 解析后:', num)
if (isNaN(num)) {
console.log('[CopyTradingEdit copyRatio formatter] NaN,返回空字符串')
return ''
}
if (num > 10000) {
console.log('[CopyTradingEdit copyRatio formatter] 超过最大值,返回 10000')
return '10000'
}
const result = num.toString().replace(/\.0+$/, '')
console.log('[CopyTradingEdit copyRatio formatter] 格式化后返回:', result)
return result
}}
/>
</Form.Item>
)}
{copyMode === 'FIXED' && (
<Form.Item
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 (USDC)'}
name="fixedAmount"
rules={[
{ required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' },
{
validator: (_, value) => {
if (value !== undefined && value !== null && value !== '') {
const amount = Number(value)
if (isNaN(amount)) {
return Promise.reject(new Error(t('copyTradingEdit.invalidNumber') || '请输入有效的数字'))
}
if (amount < 1) {
return Promise.reject(new Error(t('copyTradingEdit.fixedAmountMin') || '固定金额必须 >= 1'))
}
}
return Promise.resolve()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
)}
{copyMode === 'RATIO' && (
<>
<Form.Item
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'}
name="maxOrderSize"
tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
>
<InputNumber
min={0.0001}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 (USDC)'}
name="minOrderSize"
tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
rules={[
{
validator: (_, value) => {
if (value === undefined || value === null || value === '') {
return Promise.resolve()
}
if (typeof value === 'number' && value < 1) {
return Promise.reject(new Error(t('copyTradingEdit.minOrderSizeMin') || '最小金额必须 >= 1'))
}
return Promise.resolve()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
</>
)}
<Form.Item
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 (USDC)'}
name="maxDailyLoss"
tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxDailyOrders') || '每日最大跟单订单数'}
name="maxDailyOrders"
tooltip={t('copyTradingEdit.maxDailyOrdersTooltip') || '限制每日最多跟单的订单数量'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyOrdersPlaceholder') || '默认 100(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.priceTolerance') || '价格容忍度 (%)'}
name="priceTolerance"
tooltip={t('copyTradingEdit.priceToleranceTooltip') || '允许跟单价格在 Leader 价格基础上的调整范围'}
>
<InputNumber
min={0}
max={100}
step={0.1}
precision={2}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.delaySeconds') || '跟单延迟 (秒)'}
name="delaySeconds"
tooltip={t('copyTradingEdit.delaySecondsTooltip') || '跟单延迟时间,0 表示立即跟单'}
>
<InputNumber
min={0}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.delaySecondsPlaceholder') || '默认 0(立即跟单)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
label={t('copyTradingEdit.priceRange') || '价格区间'}
name="priceRange"
tooltip={t('copyTradingEdit.priceRangeTooltip') || '配置价格区间,仅在指定价格区间内的订单才会下单。例如:0.11-0.89 表示区间在0.11和0.89之间;-0.89 表示0.89以下都可以;0.11- 表示0.11以上都可以'}
>
<Input.Group compact style={{ display: 'flex' }}>
<Form.Item name="minPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.minPricePlaceholder') || '最低价(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
<Form.Item name="maxPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.maxPricePlaceholder') || '最高价(可选)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
</Input.Group>
</Form.Item>
<Divider>{t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'}</Divider>
<Form.Item
label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 (USDC)'}
name="maxPositionValue"
tooltip={t('copyTradingEdit.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxPositionCount') || '最大仓位数量'}
name="maxPositionCount"
tooltip={t('copyTradingEdit.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
{/* 跟单卖出 */}
<Form.Item
label={t('copyTradingEdit.supportSell') || '跟单卖出'}
name="supportSell"
tooltip={t('copyTradingEdit.supportSellTooltip') || '是否跟单 Leader 的卖出订单'}
valuePropName="checked"
>
<Switch />
</Form.Item>
{/* 推送失败订单 */}
<Form.Item
label={t('copyTradingEdit.pushFailedOrders') || '推送失败订单'}
name="pushFailedOrders"
tooltip={t('copyTradingEdit.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
>
{t('copyTradingEdit.save') || '保存'}
</Button>
<Button onClick={() => navigate('/copy-trading')}>
{t('common.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default CopyTradingEdit
+10 -3
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { useNavigate } 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 { useTranslation } from 'react-i18next'
@@ -13,12 +12,12 @@ import CopyTradingOrdersModal from './CopyTradingOrders/index'
import StatisticsModal from './CopyTradingOrders/StatisticsModal'
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
import EditModal from './CopyTradingOrders/EditModal'
import AddModal from './CopyTradingOrders/AddModal'
const { Option } = Select
const CopyTradingList: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [copyTradings, setCopyTradings] = useState<CopyTrading[]>([])
@@ -42,6 +41,7 @@ const CopyTradingList: React.FC = () => {
const [filteredOrdersModalCopyTradingId, setFilteredOrdersModalCopyTradingId] = useState<string>('')
const [editModalOpen, setEditModalOpen] = useState(false)
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
const [addModalOpen, setAddModalOpen] = useState(false)
useEffect(() => {
fetchAccounts()
@@ -369,7 +369,7 @@ const CopyTradingList: React.FC = () => {
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/copy-trading/add')}
onClick={() => setAddModalOpen(true)}
>
{t('copyTradingList.addCopyTrading') || '新增跟单'}
</Button>
@@ -668,6 +668,13 @@ const CopyTradingList: React.FC = () => {
fetchCopyTradings()
}}
/>
<AddModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSuccess={() => {
fetchCopyTradings()
}}
/>
</div>
)
}
@@ -1,22 +1,29 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Modal, Table, Select, Divider, Input } from 'antd'
import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import type { Leader, CopyTradingTemplate, CopyTradingCreateRequest } from '../types'
import { formatUSDC } from '../utils'
import React, { useEffect, useState, useRef } from 'react'
import { Modal, Form, Button, Switch, message, Space, Radio, InputNumber, Table, Select, Divider, Input, Tag, InputRef } from 'antd'
import { SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import { useAccountStore } from '../../store/accountStore'
import type { Leader, CopyTradingTemplate, CopyTradingCreateRequest } from '../../types'
import { formatUSDC } from '../../utils'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import AccountImportForm from '../components/AccountImportForm'
import LeaderAddForm from '../components/LeaderAddForm'
import AccountImportForm from '../../components/AccountImportForm'
import LeaderAddForm from '../../components/LeaderAddForm'
const { Title } = Typography
const { Option } = Select
const CopyTradingAdd: React.FC = () => {
interface AddModalProps {
open: boolean
onClose: () => void
onSuccess?: () => void
}
const AddModal: React.FC<AddModalProps> = ({
open,
onClose,
onSuccess
}) => {
const { t } = useTranslation()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [form] = Form.useForm()
@@ -25,6 +32,8 @@ const CopyTradingAdd: React.FC = () => {
const [templates, setTemplates] = useState<CopyTradingTemplate[]>([])
const [templateModalVisible, setTemplateModalVisible] = useState(false)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [keywords, setKeywords] = useState<string[]>([])
const keywordInputRef = useRef<InputRef>(null)
// 导入账户modal相关状态
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
@@ -52,14 +61,24 @@ const CopyTradingAdd: React.FC = () => {
}
useEffect(() => {
fetchAccounts()
fetchLeaders()
fetchTemplates()
// 生成默认配置名
const defaultConfigName = generateDefaultConfigName()
form.setFieldsValue({ configName: defaultConfigName })
}, [])
if (open) {
fetchAccounts()
fetchLeaders()
fetchTemplates()
// 生成默认配置名
const defaultConfigName = generateDefaultConfigName()
form.setFieldsValue({ configName: defaultConfigName })
// 重置关键字列表
setKeywords([])
} else {
// 关闭时重置表单
form.resetFields()
setKeywords([])
setCopyMode('RATIO')
}
}, [open])
const fetchLeaders = async () => {
try {
@@ -140,6 +159,45 @@ const CopyTradingAdd: React.FC = () => {
leaderAddForm.resetFields()
}
// 添加关键字
const handleAddKeyword = (e?: React.KeyboardEvent<HTMLInputElement>) => {
let inputValue = ''
if (e) {
// 从键盘事件获取输入值
const target = e.target as HTMLInputElement
inputValue = target.value.trim()
} else if (keywordInputRef.current) {
// 从输入框 ref 获取值
inputValue = keywordInputRef.current.input?.value?.trim() || ''
}
if (!inputValue) {
return
}
// 检查是否已存在
if (keywords.includes(inputValue)) {
message.warning(t('copyTradingAdd.keywordExists') || '关键字已存在')
return
}
// 添加关键字
const newKeywords = [...keywords, inputValue]
setKeywords(newKeywords)
// 清空输入框
if (keywordInputRef.current) {
keywordInputRef.current.input!.value = ''
}
}
// 删除关键字
const handleRemoveKeyword = (index: number) => {
const newKeywords = keywords.filter((_, i) => i !== index)
setKeywords(newKeywords)
}
const handleSubmit = async (values: any) => {
// 前端校验
if (values.copyMode === 'FIXED') {
@@ -180,6 +238,10 @@ const CopyTradingAdd: React.FC = () => {
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords
: undefined,
configName: values.configName?.trim(),
pushFailedOrders: values.pushFailedOrders ?? false
}
@@ -188,7 +250,10 @@ const CopyTradingAdd: React.FC = () => {
if (response.data.code === 0) {
message.success(t('copyTradingAdd.createSuccess') || '创建跟单配置成功')
navigate('/copy-trading')
onClose()
if (onSuccess) {
onSuccess()
}
} else {
message.error(response.data.msg || t('copyTradingAdd.createFailed') || '创建跟单配置失败')
}
@@ -200,19 +265,17 @@ const CopyTradingAdd: React.FC = () => {
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/copy-trading')}
>
{t('common.back') || '返回'}
</Button>
</div>
<Card>
<Title level={4}>{t('copyTradingAdd.title') || '新增跟单配置'}</Title>
<>
<Modal
title={t('copyTradingAdd.title') || '新增跟单配置'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
destroyOnClose
>
<Form
form={form}
layout="vertical"
@@ -231,7 +294,8 @@ const CopyTradingAdd: React.FC = () => {
websocketReconnectInterval: 5000,
websocketMaxRetries: 10,
supportSell: true,
pushFailedOrders: false
pushFailedOrders: false,
keywordFilterMode: 'DISABLED'
}}
>
{/* 基础信息 */}
@@ -352,42 +416,18 @@ const CopyTradingAdd: React.FC = () => {
addonAfter="%"
placeholder={t('copyTradingAdd.copyRatioPlaceholder') || '例如:100 表示 100%1:1 跟单),默认 100%'}
parser={(value) => {
console.log('[CopyTradingAdd copyRatio parser] 输入值:', value, '类型:', typeof value)
// 移除 % 符号和其他非数字字符(保留小数点和负号)
const cleaned = (value || '').toString().replace(/%/g, '').trim()
console.log('[CopyTradingAdd copyRatio parser] 清理后:', cleaned)
const parsed = parseFloat(cleaned) || 0
console.log('[CopyTradingAdd copyRatio parser] 解析后:', parsed)
if (parsed > 10000) {
console.log('[CopyTradingAdd copyRatio parser] 超过最大值,返回 10000')
return 10000
}
if (parsed < 0.01) {
console.log('[CopyTradingAdd copyRatio parser] 小于最小值,返回 0.01')
return 0.01
}
console.log('[CopyTradingAdd copyRatio parser] 返回:', parsed)
if (parsed > 10000) return 10000
if (parsed < 0.01) return 0.01
return parsed
}}
formatter={(value) => {
console.log('[CopyTradingAdd copyRatio formatter] 输入值:', value, '类型:', typeof value)
if (!value && value !== 0) {
console.log('[CopyTradingAdd copyRatio formatter] 空值,返回空字符串')
return ''
}
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
console.log('[CopyTradingAdd copyRatio formatter] 解析后:', num)
if (isNaN(num)) {
console.log('[CopyTradingAdd copyRatio formatter] NaN,返回空字符串')
return ''
}
if (num > 10000) {
console.log('[CopyTradingAdd copyRatio formatter] 超过最大值,返回 10000')
return '10000'
}
const result = num.toString().replace(/\.0+$/, '')
console.log('[CopyTradingAdd copyRatio formatter] 格式化后返回:', result)
return result
if (isNaN(num)) return ''
if (num > 10000) return '10000'
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
@@ -674,6 +714,75 @@ const CopyTradingAdd: React.FC = () => {
/>
</Form.Item>
<Divider>{t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
<Form.Item
label={t('copyTradingAdd.keywordFilterMode') || '过滤模式'}
name="keywordFilterMode"
tooltip={t('copyTradingAdd.keywordFilterModeTooltip') || '选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤'}
>
<Radio.Group>
<Radio value="DISABLED">{t('copyTradingAdd.disabled') || '不启用'}</Radio>
<Radio value="WHITELIST">{t('copyTradingAdd.whitelist') || '白名单'}</Radio>
<Radio value="BLACKLIST">{t('copyTradingAdd.blacklist') || '黑名单'}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) =>
prevValues.keywordFilterMode !== currentValues.keywordFilterMode
}>
{({ getFieldValue }) => {
const filterMode = getFieldValue('keywordFilterMode')
if (filterMode !== 'WHITELIST' && filterMode !== 'BLACKLIST') {
return null
}
return (
<>
<Form.Item label={t('copyTradingAdd.keywords') || '关键字'}>
<Space.Compact style={{ width: '100%' }}>
<Input
ref={keywordInputRef}
placeholder={t('copyTradingAdd.keywordPlaceholder') || '输入关键字,按回车添加'}
onPressEnter={(e) => handleAddKeyword(e)}
/>
<Button
type="primary"
onClick={() => handleAddKeyword()}
>
{t('common.add') || '添加'}
</Button>
</Space.Compact>
{keywords.length > 0 && (
<div style={{ marginTop: 8 }}>
<Space wrap>
{keywords.map((keyword, index) => (
<Tag
key={index}
closable
onClose={() => handleRemoveKeyword(index)}
color={filterMode === 'WHITELIST' ? 'green' : 'red'}
>
{keyword}
</Tag>
))}
</Space>
</div>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
{filterMode === 'WHITELIST'
? (t('copyTradingAdd.whitelistTooltip') || '💡 白名单模式:只跟单包含上述任意关键字的市场标题')
: (t('copyTradingAdd.blacklistTooltip') || '💡 黑名单模式:不跟单包含上述任意关键字的市场标题')
}
</div>
</Form.Item>
</>
)
}}
</Form.Item>
<Divider>{t('copyTradingAdd.advancedSettings') || '高级设置'}</Divider>
{/* 跟单卖出 */}
@@ -706,13 +815,13 @@ const CopyTradingAdd: React.FC = () => {
>
{t('copyTradingAdd.create') || '创建跟单配置'}
</Button>
<Button onClick={() => navigate('/copy-trading')}>
<Button onClick={onClose}>
{t('common.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</Modal>
{/* 模板选择 Modal */}
<Modal
@@ -812,8 +921,9 @@ const CopyTradingAdd: React.FC = () => {
showCancelButton={true}
/>
</Modal>
</div>
</>
)
}
export default CopyTradingAdd
export default AddModal
@@ -173,7 +173,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
key: 'marketId',
width: isMobile ? 120 : 200,
render: (text: string, record: BuyOrderInfo) => {
const marketUrl = getPolymarketUrl(record.marketSlug, record.marketCategory, record.marketId)
const marketUrl = getPolymarketUrl(record.marketSlug, record.eventSlug, record.marketCategory, record.marketId)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{record.marketTitle ? (
@@ -307,7 +307,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
{groupedOrders.map((group) => {
const isExpanded = expandedMarkets.has(group.marketId)
const marketDisplayName = group.marketTitle || group.marketId.slice(0, 8) + '...' + group.marketId.slice(-6)
const marketUrl = getPolymarketUrl(group.marketSlug, group.marketCategory, group.marketId)
const marketUrl = getPolymarketUrl(group.marketSlug, group.eventSlug, group.marketCategory, group.marketId)
const orders = group.orders as BuyOrderInfo[]
return (
@@ -564,7 +564,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
{order.marketTitle ? (
(() => {
const marketUrl = getPolymarketUrl(order.marketSlug, order.marketCategory, order.marketId)
const marketUrl = getPolymarketUrl(order.marketSlug, order.eventSlug, order.marketCategory, order.marketId)
return marketUrl ? (
<a
href={marketUrl}
@@ -717,7 +717,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
{t('common.previous') || '上一页'}
</Button>
<span style={{ margin: '0 16px' }}>
{t('common.page') || '第'} {page} / {Math.ceil(groupedTotal / limit)} {t('common.page') || '页'}
{t('common.pageOf') || '第'} {page} / {Math.ceil(groupedTotal / limit)} {t('common.page') || '页'}
</span>
<Button
onClick={() => {
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch } from 'antd'
import React, { useEffect, useState, useRef } from 'react'
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch, Tag, InputRef } from 'antd'
import { SaveOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import type { CopyTrading, CopyTradingUpdateRequest } from '../../types'
@@ -27,6 +27,8 @@ const EditModal: React.FC<EditModalProps> = ({
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
const [keywords, setKeywords] = useState<string[]>([])
const keywordInputRef = useRef<InputRef>(null)
useEffect(() => {
if (open && copyTradingId) {
@@ -67,9 +69,12 @@ const EditModal: React.FC<EditModalProps> = ({
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
maxPositionCount: found.maxPositionCount,
keywordFilterMode: found.keywordFilterMode || 'DISABLED',
configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false
})
// 设置关键字列表
setKeywords(found.keywords || [])
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
onClose()
@@ -90,6 +95,40 @@ const EditModal: React.FC<EditModalProps> = ({
setCopyMode(mode)
}
// 添加关键字
const handleAddKeyword = (e?: React.KeyboardEvent<HTMLInputElement>) => {
let inputValue = ''
if (e) {
const target = e.target as HTMLInputElement
inputValue = target.value.trim()
} else if (keywordInputRef.current) {
inputValue = keywordInputRef.current.input?.value?.trim() || ''
}
if (!inputValue) {
return
}
if (keywords.includes(inputValue)) {
message.warning(t('copyTradingEdit.keywordExists') || t('copyTradingAdd.keywordExists') || '关键字已存在')
return
}
const newKeywords = [...keywords, inputValue]
setKeywords(newKeywords)
if (keywordInputRef.current) {
keywordInputRef.current.input!.value = ''
}
}
// 删除关键字
const handleRemoveKeyword = (index: number) => {
const newKeywords = keywords.filter((_, i) => i !== index)
setKeywords(newKeywords)
}
const handleSubmit = async (values: any) => {
if (values.copyMode === 'FIXED') {
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
@@ -133,6 +172,10 @@ const EditModal: React.FC<EditModalProps> = ({
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords
: undefined,
configName: values.configName?.trim() || undefined,
pushFailedOrders: values.pushFailedOrders
}
@@ -178,6 +221,9 @@ const EditModal: React.FC<EditModalProps> = ({
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
keywordFilterMode: 'DISABLED'
}}
>
<Form.Item
label={t('copyTradingEdit.configName') || '配置名'}
@@ -567,6 +613,76 @@ const EditModal: React.FC<EditModalProps> = ({
/>
</Form.Item>
{/* 关键字过滤 */}
<Divider>{t('copyTradingEdit.keywordFilter') || t('copyTradingAdd.keywordFilter') || '关键字过滤'}</Divider>
<Form.Item
label={t('copyTradingEdit.keywordFilterMode') || t('copyTradingAdd.keywordFilterMode') || '过滤模式'}
name="keywordFilterMode"
tooltip={t('copyTradingEdit.keywordFilterModeTooltip') || t('copyTradingAdd.keywordFilterModeTooltip') || '选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤'}
>
<Radio.Group>
<Radio value="DISABLED">{t('copyTradingEdit.disabled') || t('copyTradingAdd.disabled') || '不启用'}</Radio>
<Radio value="WHITELIST">{t('copyTradingEdit.whitelist') || t('copyTradingAdd.whitelist') || '白名单'}</Radio>
<Radio value="BLACKLIST">{t('copyTradingEdit.blacklist') || t('copyTradingAdd.blacklist') || '黑名单'}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) =>
prevValues.keywordFilterMode !== currentValues.keywordFilterMode
}>
{({ getFieldValue }) => {
const filterMode = getFieldValue('keywordFilterMode')
if (filterMode !== 'WHITELIST' && filterMode !== 'BLACKLIST') {
return null
}
return (
<>
<Form.Item label={t('copyTradingEdit.keywords') || t('copyTradingAdd.keywords') || '关键字'}>
<Space.Compact style={{ width: '100%' }}>
<Input
ref={keywordInputRef}
placeholder={t('copyTradingEdit.keywordPlaceholder') || t('copyTradingAdd.keywordPlaceholder') || '输入关键字,按回车添加'}
onPressEnter={(e) => handleAddKeyword(e)}
/>
<Button
type="primary"
onClick={() => handleAddKeyword()}
>
{t('common.add') || '添加'}
</Button>
</Space.Compact>
{keywords.length > 0 && (
<div style={{ marginTop: 8 }}>
<Space wrap>
{keywords.map((keyword, index) => (
<Tag
key={index}
closable
onClose={() => handleRemoveKeyword(index)}
color={filterMode === 'WHITELIST' ? 'green' : 'red'}
>
{keyword}
</Tag>
))}
</Space>
</div>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
{filterMode === 'WHITELIST'
? (t('copyTradingEdit.whitelistTooltip') || t('copyTradingAdd.whitelistTooltip') || '💡 白名单模式:只跟单包含上述任意关键字的市场标题')
: (t('copyTradingEdit.blacklistTooltip') || t('copyTradingAdd.blacklistTooltip') || '💡 黑名单模式:不跟单包含上述任意关键字的市场标题')
}
</div>
</Form.Item>
</>
)
}}
</Form.Item>
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
<Form.Item
@@ -169,7 +169,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
key: 'marketId',
width: isMobile ? 120 : 200,
render: (text: string, record: SellOrderInfo) => {
const marketUrl = getPolymarketUrl(record.marketSlug, record.marketCategory, record.marketId)
const marketUrl = getPolymarketUrl(record.marketSlug, record.eventSlug, record.marketCategory, record.marketId)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{record.marketTitle ? (
@@ -291,7 +291,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
{groupedOrders.map((group) => {
const isExpanded = expandedMarkets.has(group.marketId)
const marketDisplayName = group.marketTitle || group.marketId.slice(0, 8) + '...' + group.marketId.slice(-6)
const marketUrl = getPolymarketUrl(group.marketSlug, group.marketCategory, group.marketId)
const marketUrl = getPolymarketUrl(group.marketSlug, group.eventSlug, group.marketCategory, group.marketId)
const pnlColor = getPnlColor(group.stats.totalPnl || '0')
const orders = group.orders as SellOrderInfo[]
@@ -546,7 +546,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
{order.marketTitle ? (
(() => {
const marketUrl = getPolymarketUrl(order.marketSlug, order.marketCategory, order.marketId)
const marketUrl = getPolymarketUrl(order.marketSlug, order.eventSlug, order.marketCategory, order.marketId)
return marketUrl ? (
<a
href={marketUrl}
@@ -689,7 +689,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
{t('common.previous') || '上一页'}
</Button>
<span style={{ margin: '0 16px' }}>
{t('common.page') || '第'} {page} / {Math.ceil(groupedTotal / limit)} {t('common.page') || '页'}
{t('common.pageOf') || '第'} {page} / {Math.ceil(groupedTotal / limit)} {t('common.page') || '页'}
</span>
<Button
onClick={() => {
+4 -4
View File
@@ -582,9 +582,9 @@ const PositionList: React.FC = () => {
)}
<div style={{ flex: 1, minWidth: 0 }}>
{position.marketTitle ? (
position.marketSlug ? (
(position.eventSlug || position.marketSlug) ? (
<a
href={`https://polymarket.com/event/${position.marketSlug}`}
href={`https://polymarket.com/event/${position.eventSlug || position.marketSlug}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
@@ -838,8 +838,8 @@ const PositionList: React.FC = () => {
dataIndex: 'marketTitle',
key: 'marketTitle',
render: (text: string | undefined, record: AccountPosition) => {
const url = record.marketSlug
? `https://polymarket.com/event/${record.marketSlug}`
const url = record.eventSlug || record.marketSlug
? `https://polymarket.com/event/${record.eventSlug || record.marketSlug}`
: null
const handleTitleClick = (e: React.MouseEvent) => {
+19 -5
View File
@@ -210,6 +210,9 @@ export interface CopyTrading {
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
configName?: string // 配置名(可选,但提供时必须非空)
pushFailedOrders: boolean // 推送失败订单(默认关闭)
@@ -256,6 +259,9 @@ export interface CopyTradingCreateRequest {
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
configName?: string // 配置名(可选,但提供时必须非空)
pushFailedOrders?: boolean // 推送失败订单(可选)
@@ -290,6 +296,9 @@ export interface CopyTradingUpdateRequest {
// 最大仓位配置
maxPositionValue?: string // 最大仓位金额(USDC),NULL表示不启用
maxPositionCount?: number // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
keywordFilterMode?: 'DISABLED' | 'WHITELIST' | 'BLACKLIST' // 关键字过滤模式
keywords?: string[] // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
configName?: string // 配置名(可选,但提供时必须非空)
pushFailedOrders?: boolean // 推送失败订单(可选)
@@ -372,7 +381,8 @@ export interface AccountPosition {
proxyAddress: string
marketId: string
marketTitle?: string
marketSlug?: string
marketSlug?: string // 显示用的 slug
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketIcon?: string // 市场图标 URL
side: string // 结果名称(如 "YES", "NO", "Pakistan" 等)
outcomeIndex?: number // 结果索引(0, 1, 2...),用于计算 tokenId
@@ -628,7 +638,8 @@ export interface BuyOrderInfo {
leaderTradeId: string
marketId: string
marketTitle?: string // 市场名称
marketSlug?: string // 市场 slug(用于构建 URL
marketSlug?: string // 市场 slug(用于显示
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketCategory?: string // 市场分类(sports, crypto 等)
side: string
quantity: string
@@ -648,7 +659,8 @@ export interface SellOrderInfo {
leaderTradeId: string
marketId: string
marketTitle?: string // 市场名称
marketSlug?: string // 市场 slug(用于构建 URL
marketSlug?: string // 市场 slug(用于显示
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketCategory?: string // 市场分类(sports, crypto 等)
side: string
quantity: string
@@ -666,7 +678,8 @@ export interface MatchedOrderInfo {
buyOrderId: string
marketId?: string // 市场ID
marketTitle?: string // 市场名称
marketSlug?: string // 市场 slug(用于构建 URL
marketSlug?: string // 市场 slug(用于显示
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketCategory?: string // 市场分类(sports, crypto 等)
matchedQuantity: string
buyPrice: string
@@ -729,7 +742,8 @@ export interface MarketOrderStats {
export interface MarketOrderGroup {
marketId: string
marketTitle?: string
marketSlug?: string
marketSlug?: string // 显示用的 slug
eventSlug?: string // 跳转用的 slug(从 events[0].slug 获取)
marketCategory?: string
stats: MarketOrderStats
orders: BuyOrderInfo[] | SellOrderInfo[] // 订单列表
+9 -5
View File
@@ -98,7 +98,8 @@ export const isAutoGeneratedOrderId = (orderId: string | undefined | null): bool
* Polymarket URL
* moneyline moneyline
* moneyline
* @param marketSlug - slug
* @param marketSlug - slug
* @param eventSlug - slug events[0].slug 使
* @param marketCategory - sports, crypto
* @param marketId - ID
* @param isMoneyline - moneyline
@@ -106,18 +107,21 @@ export const isAutoGeneratedOrderId = (orderId: string | undefined | null): bool
*/
export const getPolymarketUrl = (
marketSlug?: string | null,
eventSlug?: string | null, // 跳转用的 slug(优先使用)
_marketCategory?: string | null, // 保留参数以便未来使用
marketId?: string | null,
isMoneyline?: boolean
): string | null => {
// 优先使用 slug
if (marketSlug) {
// 优先使用 eventSlug(跳转用的 slug
const slug = eventSlug || marketSlug
if (slug) {
// 如果是 moneyline 市场,跳转到 moneyline 页面
if (isMoneyline === true) {
return `https://polymarket.com/event/${marketSlug}/moneyline`
return `https://polymarket.com/event/${slug}/moneyline`
}
// 其他市场跳转到普通市场页面
return `https://polymarket.com/event/${marketSlug}`
return `https://polymarket.com/event/${slug}`
}
// 如果没有 slug,使用 marketId(作为后备)