feat(frontend): 优化 Leader 列表 UI,使用图标操作栏

- 移动端:Cell 样式美化,渐变背景头部,资产常驻显示
- PC 端:操作列改用图标,合并跟单/回测数到图标 Badge
- 跟单/回测数量用 Badge 显示,数量为 0 时半透明禁用

Made-with: Cursor
This commit is contained in:
WrBug
2026-03-01 05:57:34 +08:00
parent 3cc37dac55
commit f86749e85d
+283 -109
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col, Tooltip, Badge } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined, CopyOutlined, LineChartOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type { Leader, LeaderBalanceResponse } from '../types'
@@ -225,11 +225,11 @@ const LeaderList: React.FC = () => {
title: t('leaderList.leaderName'),
dataIndex: 'leaderName',
key: 'leaderName',
width: 150,
width: 200,
render: (text: string, record: Leader) => (
<Space direction="vertical" size={0}>
<Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text>
<Text type="secondary" style={{ fontSize: '12px', fontFamily: 'monospace' }}>{record.leaderAddress}</Text>
</Space>
)
},
@@ -237,76 +237,155 @@ const LeaderList: React.FC = () => {
title: t('leaderList.remark'),
dataIndex: 'remark',
key: 'remark',
width: 200,
width: 180,
ellipsis: true,
render: (remark: string | undefined) => {
if (!remark) return <Text type="secondary">-</Text>
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</Text>
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 160 }}>{remark}</Text>
}
},
{
title: t('leaderDetail.availableBalance'),
key: 'balance',
width: 150,
width: 180,
render: (_: any, record: Leader) => {
const balance = balanceMap[record.id]
if (!balance) return <Spin size="small" />
const displayText = balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`
return <Text style={{ color: '#1890ff', fontSize: '14px' }}>{displayText}</Text>
return (
<Space direction="vertical" size={0}>
<Text style={{ color: '#52c41a', fontSize: '14px', fontWeight: '500' }}>
{balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
</Text>
</Space>
)
}
},
{
title: t('leaderList.copyTradingCount'),
dataIndex: 'copyTradingCount',
key: 'copyTradingCount',
width: 100,
render: (count: number, record: Leader) => (
<Button
type="link"
size="small"
onClick={() => navigate(`/copy-trading?leaderId=${record.id}`)}
disabled={count === 0}
style={{ padding: 0 }}
>
<Tag color="cyan">{count}</Tag>
</Button>
)
},
{
title: t('leaderList.backtestCount'),
dataIndex: 'backtestCount',
key: 'backtestCount',
width: 100,
render: (count: number, record: Leader) => (
<Button
type="link"
size="small"
onClick={() => navigate(`/backtest?leaderId=${record.id}`)}
disabled={count === 0}
style={{ padding: 0 }}
>
<Tag color="purple">{count}</Tag>
</Button>
)
},
{
title: t('common.actions'),
key: 'action',
width: isMobile ? 180 : 250,
width: 200,
fixed: 'right' as const,
render: (_: any, record: Leader) => (
<Space size="small" wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}>
{t('common.viewDetail')}
</Button>
<Space size={4}>
<Tooltip title={t('common.viewDetail')}>
<div
onClick={() => handleShowDetail(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} />
</div>
</Tooltip>
{record.website && (
<Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}>
{t('leaderList.openWebsite')}
</Button>
<Tooltip title={t('leaderList.openWebsite')}>
<div
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<GlobalOutlined style={{ fontSize: '16px', color: '#fa8c16' }} />
</div>
</Tooltip>
)}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}>
{t('common.edit')}
</Button>
<Tooltip title={t('common.edit')}>
<div
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<EditOutlined style={{ fontSize: '16px', color: '#52c41a' }} />
</div>
</Tooltip>
<Tooltip title={`${t('leaderList.viewCopyTradings')} (${record.copyTradingCount})`}>
<div
onClick={() => {
if (record.copyTradingCount > 0) {
navigate(`/copy-trading?leaderId=${record.id}`)
}
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: record.copyTradingCount === 0 ? 'not-allowed' : 'pointer',
borderRadius: '6px',
opacity: record.copyTradingCount === 0 ? 0.4 : 1,
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => record.copyTradingCount > 0 && (e.currentTarget.style.backgroundColor = '#f0f0f0')}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Badge count={record.copyTradingCount} size="small" offset={[-4, -4]}>
<CopyOutlined style={{ fontSize: '16px', color: '#13c2c2' }} />
</Badge>
</div>
</Tooltip>
<Tooltip title={`${t('leaderList.viewBacktests')} (${record.backtestCount})`}>
<div
onClick={() => {
if (record.backtestCount > 0) {
navigate(`/backtest?leaderId=${record.id}`)
}
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: record.backtestCount === 0 ? 'not-allowed' : 'pointer',
borderRadius: '6px',
opacity: record.backtestCount === 0 ? 0.4 : 1,
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => record.backtestCount > 0 && (e.currentTarget.style.backgroundColor = '#f0f0f0')}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Badge count={record.backtestCount} size="small" offset={[-4, -4]}>
<LineChartOutlined style={{ fontSize: '16px', color: '#722ed1' }} />
</Badge>
</div>
</Tooltip>
<Popconfirm
title={t('leaderList.deleteConfirm')}
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
@@ -314,9 +393,24 @@ const LeaderList: React.FC = () => {
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
{t('common.delete')}
</Button>
<Tooltip title={t('common.delete')}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} />
</div>
</Tooltip>
</Popconfirm>
</Space>
)
@@ -346,71 +440,148 @@ const LeaderList: React.FC = () => {
dataSource={leaders}
renderItem={(leader) => {
const balance = balanceMap[leader.id]
const isLoading = balanceLoading[leader.id]
return (
<Card key={leader.id} style={{ marginBottom: '16px', borderRadius: '12px', boxShadow: '0 2px 6px rgba(0,0,0,0.06)', border: '1px solid #f0f0f0' }} bodyStyle={{ padding: '16px' }}>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}>
{leader.leaderName || `Leader ${leader.id}`}
<Card
key={leader.id}
style={{
marginBottom: '10px',
borderRadius: '10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8',
overflow: 'hidden'
}}
bodyStyle={{ padding: '0' }}
>
{/* 头部区域 - 名称和地址 */}
<div style={{
padding: '10px 12px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff'
}}>
<div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>{leader.leaderName || `Leader ${leader.id}`}</span>
{leader.website && (
<GlobalOutlined
style={{ fontSize: '13px', cursor: 'pointer', opacity: 0.8 }}
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
/>
)}
</div>
<div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}>
<div style={{ fontSize: '10px', opacity: '0.85', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{leader.leaderAddress}
</div>
</div>
{balance && (
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
<div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}>
{t('leaderDetail.availableBalance')}: {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
{/* 资产区域 - 常驻显示 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#fafafa',
borderBottom: '1px solid #f0f0f0',
minHeight: '42px',
display: 'flex',
alignItems: 'center'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('leaderDetail.availableBalance')}
</div>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
{balance?.available && balance.available !== '-' ? `${formatUSDC(balance.available)} USDC` : '- USDC'}
</div>
</div>
<div style={{ fontSize: '11px', color: '#666' }}>
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
{t('leaderDetail.positionBalance')}
</div>
<div style={{ fontSize: '14px', fontWeight: '500', color: '#722ed1' }}>
{balance?.position && balance.position !== '-' ? formatUSDC(balance.position) : '-'}
</div>
</div>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<Button
type="default"
size="small"
onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)}
disabled={leader.copyTradingCount === 0}
style={{ borderRadius: '6px', padding: '8px 16px' }}
>
{t('leaderList.viewCopyTradings')} ({leader.copyTradingCount})
</Button>
<Button
type="default"
size="small"
onClick={() => navigate(`/backtest?leaderId=${leader.id}`)}
disabled={leader.backtestCount === 0}
style={{ borderRadius: '6px', padding: '8px 16px' }}
>
{t('leaderList.viewBacktests')} ({leader.backtestCount})
</Button>
</div>
{/* 备注区域 */}
{leader.remark && (
<div style={{ marginBottom: '12px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}</Text>
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text>
<div style={{
padding: '6px 12px',
backgroundColor: '#fffbe6',
borderBottom: '1px solid #ffe58f',
fontSize: '11px',
color: '#8c8c8c'
}}>
<span style={{ color: '#d48806' }}>{t('leaderList.remark')}</span>
<span>{leader.remark}</span>
</div>
)}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.viewDetail')}
</Button>
{leader.website && (
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('leaderList.openWebsite')}
</Button>
)}
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.edit')}
</Button>
{/* 图标操作栏 */}
<div style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center'
}}>
<Tooltip title={t('common.viewDetail')}>
<div
onClick={() => handleShowDetail(leader)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.viewDetail')}</span>
</div>
</Tooltip>
<Tooltip title={t('common.edit')}>
<div
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}
>
<EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit')}</span>
</div>
</Tooltip>
<Tooltip title={t('leaderList.viewCopyTradings')}>
<div
onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: leader.copyTradingCount === 0 ? 'not-allowed' : 'pointer',
padding: '4px 8px',
opacity: leader.copyTradingCount === 0 ? 0.4 : 1
}}
>
<Badge count={leader.copyTradingCount} size="small" offset={[-2, -2]}>
<CopyOutlined style={{ fontSize: '18px', color: '#13c2c2' }} />
</Badge>
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewCopyTradings')}</span>
</div>
</Tooltip>
<Tooltip title={t('leaderList.viewBacktests')}>
<div
onClick={() => navigate(`/backtest?leaderId=${leader.id}`)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: leader.backtestCount === 0 ? 'not-allowed' : 'pointer',
padding: '4px 8px',
opacity: leader.backtestCount === 0 ? 0.4 : 1
}}
>
<Badge count={leader.backtestCount} size="small" offset={[-2, -2]}>
<LineChartOutlined style={{ fontSize: '18px', color: '#722ed1' }} />
</Badge>
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewBacktests')}</span>
</div>
</Tooltip>
<Popconfirm
title={t('leaderList.deleteConfirm')}
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
@@ -418,9 +589,12 @@ const LeaderList: React.FC = () => {
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}>
{t('common.delete')}
</Button>
<Tooltip title={t('common.delete')}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}>
<DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete')}</span>
</div>
</Tooltip>
</Popconfirm>
</div>
</Card>