feat(leader): Leader 管理展示跟单/回测数量并支持跳转筛选

- 后端:LeaderDto 增加 backtestCount,LeaderService 统计回测数并返回
- Leader 列表:展示跟单数、回测数,点击跳转跟单配置/回测页并带 leaderId 筛选
- 跟单配置页:从 URL 读取 leaderId 并应用筛选,Leader 下拉改用 LeaderSelect
- 回测页:支持 Leader 筛选,页面加载时请求 Leader 列表,筛选与详情统一显示规则
- LeaderSelect:支持 style,回测/跟单筛选统一使用
- 多语言:补充 leaderList 相关 key
- 移动端:Leader 管理卡片内按钮增加内边距 padding 8px 16px

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-09 02:03:17 +08:00
parent c1829fe21b
commit 5487c7c862
10 changed files with 173 additions and 40 deletions
@@ -54,6 +54,7 @@ data class LeaderDto(
val remark: String? = null, // Leader 备注(可选)
val website: String? = null, // Leader 网站(可选)
val copyTradingCount: Long = 0, // 跟单关系数量
val backtestCount: Long = 0, // 回测数量
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null, // 总盈亏(可选)
val createdAt: Long,
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
@@ -20,9 +21,10 @@ class LeaderService(
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository,
private val backtestTaskRepository: BacktestTaskRepository,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
/**
@@ -157,7 +159,8 @@ class LeaderService(
val leaderDtos = leaders.map { leader ->
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
toDto(leader, copyTradingCount)
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
toDto(leader, copyTradingCount, backtestCount)
}
Result.success(
@@ -181,7 +184,8 @@ class LeaderService(
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
Result.success(toDto(leader, copyTradingCount))
val backtestCount = backtestTaskRepository.findByLeaderId(leaderId).size.toLong()
Result.success(toDto(leader, copyTradingCount, backtestCount))
} catch (e: Exception) {
logger.error("查询 Leader 详情失败", e)
Result.failure(e)
@@ -225,7 +229,7 @@ class LeaderService(
/**
* 转换为 DTO
*/
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
return LeaderDto(
id = leader.id!!,
leaderAddress = leader.leaderAddress,
@@ -234,6 +238,7 @@ class LeaderService(
remark = leader.remark,
website = leader.website,
copyTradingCount = copyTradingCount,
backtestCount = backtestCount,
createdAt = leader.createdAt,
updatedAt = leader.updatedAt
)
+4 -1
View File
@@ -14,6 +14,7 @@ interface LeaderSelectProps {
showSearch?: boolean
allowClear?: boolean
notFoundContent?: React.ReactNode
style?: React.CSSProperties
}
const LeaderSelect: React.FC<LeaderSelectProps> = ({
@@ -25,7 +26,8 @@ const LeaderSelect: React.FC<LeaderSelectProps> = ({
disabled,
showSearch = true,
allowClear = false,
notFoundContent
notFoundContent,
style
}) => {
// 处理选择变化
const handleChange = (val: number | undefined) => {
@@ -46,6 +48,7 @@ const LeaderSelect: React.FC<LeaderSelectProps> = ({
showSearch={showSearch}
allowClear={allowClear}
notFoundContent={notFoundContent}
style={style}
optionFilterProp="label"
optionLabelProp="label"
>
+6 -1
View File
@@ -434,7 +434,10 @@
"openWebsite": "Open Website",
"all": "All",
"copyTradingCount": "Copy Trading Count",
"backtestCount": "Backtest Count",
"copyTradingRelations": "{{count}} copy trading relations",
"viewCopyTradings": "View Copy Tradings",
"viewBacktests": "View Backtests",
"createdAt": "Created At",
"noData": "No Leader data",
"fetchFailed": "Failed to get Leader list",
@@ -442,7 +445,9 @@
"deleteFailed": "Failed to delete Leader",
"deleteConfirm": "Are you sure you want to delete this Leader?",
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first",
"openDetailFailed": "Failed to open detail"
"openDetailFailed": "Failed to open detail",
"noCopyTradings": "No copy trading configs",
"noBacktests": "No backtests"
},
"leaderAdd": {
"title": "Add Leader",
+6 -1
View File
@@ -434,7 +434,10 @@
"openWebsite": "打开网页",
"all": "全部",
"copyTradingCount": "跟单数",
"backtestCount": "回测数",
"copyTradingRelations": "{{count}} 个跟单关系",
"viewCopyTradings": "查看跟单配置",
"viewBacktests": "查看回测",
"createdAt": "创建时间",
"noData": "暂无 Leader 数据",
"fetchFailed": "获取 Leader 列表失败",
@@ -442,7 +445,9 @@
"deleteFailed": "删除 Leader 失败",
"deleteConfirm": "确定要删除这个 Leader 吗?",
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系",
"openDetailFailed": "打开详情失败"
"openDetailFailed": "打开详情失败",
"noCopyTradings": "暂无跟单配置",
"noBacktests": "暂无回测"
},
"leaderAdd": {
"title": "添加 Leader",
+6 -1
View File
@@ -434,7 +434,10 @@
"openWebsite": "打開網頁",
"all": "全部",
"copyTradingCount": "跟單數",
"backtestCount": "回測數",
"copyTradingRelations": "{{count}} 個跟單關係",
"viewCopyTradings": "查看跟單配置",
"viewBacktests": "查看回測",
"createdAt": "創建時間",
"noData": "暫無 Leader 數據",
"fetchFailed": "獲取 Leader 列表失敗",
@@ -442,7 +445,9 @@
"deleteFailed": "刪除 Leader 失敗",
"deleteConfirm": "確定要刪除這個 Leader 嗎?",
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係",
"openDetailFailed": "打開詳情失敗"
"openDetailFailed": "打開詳情失敗",
"noCopyTradings": "暫無跟單配置",
"noBacktests": "暫無回測"
},
"leaderAdd": {
"title": "添加 Leader",
+39 -15
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Table, Card, Button, Select, Tag, Space, Modal, message, Row, Col, Form, Input, InputNumber, Switch, Statistic, Descriptions } from 'antd'
import { useTranslation } from 'react-i18next'
import { PlusOutlined, ReloadOutlined, DeleteOutlined, StopOutlined, EyeOutlined, RedoOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons'
@@ -13,6 +14,7 @@ import LeaderSelect from '../components/LeaderSelect'
const BacktestList: React.FC = () => {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [tasks, setTasks] = useState<BacktestTaskDto[]>([])
@@ -20,7 +22,14 @@ const BacktestList: React.FC = () => {
const [page, setPage] = useState(1)
const [size] = useState(10)
const [statusFilter, setStatusFilter] = useState<string | undefined>()
const [leaderIdFilter] = useState<number | undefined>()
const [leaderIdFilter, setLeaderIdFilter] = useState<number | undefined>(() => {
const leaderIdParam = searchParams.get('leaderId')
if (leaderIdParam) {
const id = parseInt(leaderIdParam, 10)
return isNaN(id) ? undefined : id
}
return undefined
})
const [sortBy, setSortBy] = useState<'profitAmount' | 'profitRate' | 'createdAt'>('createdAt')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
@@ -80,6 +89,15 @@ const BacktestList: React.FC = () => {
}
}
// 从 URL 读取 leaderId 并应用筛选(如从 Leader 管理页跳转过来)
useEffect(() => {
const leaderIdParam = searchParams.get('leaderId')
if (leaderIdParam) {
const id = parseInt(leaderIdParam, 10)
setLeaderIdFilter(isNaN(id) ? undefined : id)
}
}, [searchParams])
useEffect(() => {
fetchTasks()
}, [page, statusFilter, leaderIdFilter, sortBy, sortOrder])
@@ -200,22 +218,20 @@ const BacktestList: React.FC = () => {
})
}
// 获取 Leader 列表
// 获取 Leader 列表(页面加载时请求,供筛选和创建任务使用)
useEffect(() => {
if (createModalVisible) {
const fetchLeaders = async () => {
try {
const response = await apiService.leaders.list({})
if (response.data.code === 0 && response.data.data) {
setLeaders(response.data.data.list || [])
}
} catch (error) {
console.error('Failed to fetch leaders:', error)
const fetchLeaders = async () => {
try {
const response = await apiService.leaders.list({})
if (response.data.code === 0 && response.data.data) {
setLeaders(response.data.data.list || [])
}
} catch (error) {
console.error('Failed to fetch leaders:', error)
}
fetchLeaders()
}
}, [createModalVisible])
fetchLeaders()
}, [])
// 打开创建 modal
const handleCreate = () => {
@@ -487,7 +503,7 @@ const BacktestList: React.FC = () => {
dataIndex: 'leaderName',
key: 'leaderName',
width: isMobile ? 100 : 150,
render: (_: any, record: BacktestTaskDto) => record.leaderName || record.leaderAddress?.substring(0, 8) + '...' || '-'
render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}`
},
{
title: t('backtest.initialBalance'),
@@ -656,6 +672,14 @@ const BacktestList: React.FC = () => {
<Row justify="space-between" align="middle" gutter={[16, 16]}>
<Col xs={24} sm={24} md={12} lg={16}>
<Space size="middle" direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
<LeaderSelect
style={{ width: isMobile ? '100%' : 180 }}
placeholder={t('backtest.leader')}
allowClear
value={leaderIdFilter}
onChange={(value) => setLeaderIdFilter(value)}
leaders={leaders}
/>
<Select
style={{ width: isMobile ? '100%' : 150 }}
placeholder={t('backtest.status')}
@@ -1068,7 +1092,7 @@ const BacktestList: React.FC = () => {
<Descriptions column={isMobile ? 1 : 2} bordered size="small">
<Descriptions.Item label={t('backtest.taskName')}>{detailTask.taskName}</Descriptions.Item>
<Descriptions.Item label={t('backtest.leader')}>
{detailTask.leaderName || detailTask.leaderAddress}
{detailTask.leaderName || `Leader ${detailTask.leaderId}`}
</Descriptions.Item>
<Descriptions.Item label={t('backtest.initialBalance')}>
{formatUSDC(detailTask.initialBalance)} USDC
+27 -11
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd'
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
@@ -13,11 +14,13 @@ import StatisticsModal from './CopyTradingOrders/StatisticsModal'
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
import EditModal from './CopyTradingOrders/EditModal'
import AddModal from './CopyTradingOrders/AddModal'
import LeaderSelect from '../components/LeaderSelect'
const { Option } = Select
const CopyTradingList: React.FC = () => {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [copyTradings, setCopyTradings] = useState<CopyTrading[]>([])
@@ -29,8 +32,15 @@ const CopyTradingList: React.FC = () => {
accountId?: number
leaderId?: number
enabled?: boolean
}>({})
}>(() => {
const leaderIdParam = searchParams.get('leaderId')
if (leaderIdParam) {
const leaderId = parseInt(leaderIdParam, 10)
if (!isNaN(leaderId)) return { leaderId }
}
return {}
})
// Modal 状态
const [ordersModalOpen, setOrdersModalOpen] = useState(false)
const [ordersModalCopyTradingId, setOrdersModalCopyTradingId] = useState<string>('')
@@ -43,12 +53,23 @@ const CopyTradingList: React.FC = () => {
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
const [addModalOpen, setAddModalOpen] = useState(false)
// 从 URL 读取 leaderId 并应用筛选(如从 Leader 管理页跳转过来)
useEffect(() => {
const leaderIdParam = searchParams.get('leaderId')
if (leaderIdParam) {
const leaderId = parseInt(leaderIdParam, 10)
if (!isNaN(leaderId)) {
setFilters(prev => ({ ...prev, leaderId }))
}
}
}, [searchParams])
useEffect(() => {
fetchAccounts()
fetchLeaders()
fetchCopyTradings()
}, [])
useEffect(() => {
fetchCopyTradings()
}, [filters])
@@ -390,19 +411,14 @@ const CopyTradingList: React.FC = () => {
))}
</Select>
<Select
<LeaderSelect
placeholder={t('copyTradingList.filterLeader') || '筛选 Leader'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.leaderId}
onChange={(value) => setFilters({ ...filters, leaderId: value || undefined })}
>
{leaders.map(leader => (
<Option key={leader.id} value={leader.id}>
{leader.leaderName || `Leader ${leader.id}`}
</Option>
))}
</Select>
leaders={leaders}
/>
<Select
placeholder="筛选状态"
+50 -6
View File
@@ -260,7 +260,34 @@ const LeaderList: React.FC = () => {
dataIndex: 'copyTradingCount',
key: 'copyTradingCount',
width: 100,
render: (count: number) => <Tag color="cyan">{count}</Tag>
render: (count: number, record: Leader) => (
<Button
type="link"
size="small"
onClick={() => navigate(`/copy-trading?leaderId=${record.id}`)}
disabled={count === 0}
style={{ padding: 0 }}
>
<Tag color="cyan">{count}</Tag>
</Button>
)
},
{
title: t('leaderList.backtestCount'),
dataIndex: 'backtestCount',
key: 'backtestCount',
width: 100,
render: (count: number, record: Leader) => (
<Button
type="link"
size="small"
onClick={() => navigate(`/backtest?leaderId=${record.id}`)}
disabled={count === 0}
style={{ padding: 0 }}
>
<Tag color="purple">{count}</Tag>
</Button>
)
},
{
title: t('common.actions'),
@@ -345,7 +372,24 @@ const LeaderList: React.FC = () => {
<Divider style={{ margin: '12px 0' }} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<Tag color="cyan">{leader.copyTradingCount} {t('leaderList.copyTradingCount')}</Tag>
<Button
type="default"
size="small"
onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)}
disabled={leader.copyTradingCount === 0}
style={{ borderRadius: '6px', padding: '8px 16px' }}
>
{t('leaderList.viewCopyTradings')} ({leader.copyTradingCount})
</Button>
<Button
type="default"
size="small"
onClick={() => navigate(`/backtest?leaderId=${leader.id}`)}
disabled={leader.backtestCount === 0}
style={{ borderRadius: '6px', padding: '8px 16px' }}
>
{t('leaderList.viewBacktests')} ({leader.backtestCount})
</Button>
</div>
{leader.remark && (
@@ -356,15 +400,15 @@ const LeaderList: React.FC = () => {
)}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.viewDetail')}
</Button>
{leader.website && (
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('leaderList.openWebsite')}
</Button>
)}
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.edit')}
</Button>
<Popconfirm
@@ -374,7 +418,7 @@ const LeaderList: React.FC = () => {
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.delete')}
</Button>
</Popconfirm>
+25
View File
@@ -65,6 +65,7 @@ export interface Leader {
remark?: string // Leader 备注(可选)
website?: string // Leader 网站(可选)
copyTradingCount: number
backtestCount: number // 回测数量
totalOrders?: number
totalPnl?: string
createdAt: number
@@ -978,3 +979,27 @@ export interface NodeCheckResult {
responseTimeMs?: number
blockNumber?: string
}
/**
* 回测任务 DTO
*/
export interface BacktestTaskDto {
id: number
taskName: string
leaderId: number
leaderName?: string
leaderAddress?: string
initialBalance: string
finalBalance?: string
profitAmount?: string
profitRate?: string
backtestDays: number
startTime: number
endTime?: number
status: string // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
progress: number
totalTrades: number
createdAt: number
executionStartedAt?: number
executionFinishedAt?: number
}