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
-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) => {