feat: 优化仓位管理页面功能和样式
- 添加卡片和列表视图切换功能 - 移动端支持卡片折叠/展开,默认只显示关键信息 - 历史仓位不显示盈亏和平仓收益 - 优化账户筛选:移除搜索功能,按账户名称排序 - 移动端强制使用卡片视图,隐藏视图切换按钮 - 优化侧边栏布局:固定高度,内容区域可滚动 - 优化当前仓位/历史仓位切换组件样式 - 修复账户筛选全部账户选项不生效的问题
This commit is contained in:
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.controller
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.service.AccountService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
@@ -204,5 +205,29 @@ class AccountController(
|
||||
ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有账户的仓位列表
|
||||
*/
|
||||
@PostMapping("/positions/list")
|
||||
fun getAllPositions(): ResponseEntity<ApiResponse<PositionListResponse>> {
|
||||
return try {
|
||||
val result = runBlocking { accountService.getAllPositions() }
|
||||
result.fold(
|
||||
onSuccess = { positionListResponse ->
|
||||
val total = positionListResponse.currentPositions.size + positionListResponse.historyPositions.size
|
||||
logger.info("成功查询仓位列表: 当前仓位 ${positionListResponse.currentPositions.size} 个,历史仓位 ${positionListResponse.historyPositions.size} 个,共 $total 个")
|
||||
ResponseEntity.ok(ApiResponse.success(positionListResponse))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询仓位列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询仓位列表失败: ${e.message}"))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询仓位列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询仓位列表失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,3 +96,39 @@ data class PositionDto(
|
||||
val pnl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户仓位信息(用于仓位管理页面)
|
||||
*/
|
||||
data class AccountPositionDto(
|
||||
val accountId: Long,
|
||||
val accountName: String?,
|
||||
val walletAddress: String,
|
||||
val proxyAddress: String,
|
||||
val marketId: String,
|
||||
val marketTitle: String?,
|
||||
val marketSlug: String?,
|
||||
val marketIcon: String?, // 市场图标 URL
|
||||
val side: String, // YES 或 NO
|
||||
val quantity: String,
|
||||
val avgPrice: String,
|
||||
val currentPrice: String,
|
||||
val currentValue: String,
|
||||
val initialValue: String,
|
||||
val pnl: String,
|
||||
val percentPnl: String,
|
||||
val realizedPnl: String?,
|
||||
val percentRealizedPnl: String?,
|
||||
val redeemable: Boolean,
|
||||
val mergeable: Boolean,
|
||||
val endDate: String?,
|
||||
val isCurrent: Boolean = true // true: 当前仓位(有持仓),false: 历史仓位(已平仓)
|
||||
)
|
||||
|
||||
/**
|
||||
* 仓位列表响应
|
||||
*/
|
||||
data class PositionListResponse(
|
||||
val currentPositions: List<AccountPositionDto>,
|
||||
val historyPositions: List<AccountPositionDto>
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -549,6 +550,84 @@ class AccountService(
|
||||
return cleanKey.length == 64 && cleanKey.matches(Regex("^[0-9a-fA-F]{64}$"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有账户的仓位列表
|
||||
* 返回所有账户的仓位信息,包括账户信息
|
||||
*/
|
||||
suspend fun getAllPositions(): Result<PositionListResponse> {
|
||||
return try {
|
||||
val accounts = accountRepository.findAll()
|
||||
val currentPositions = mutableListOf<AccountPositionDto>()
|
||||
val historyPositions = mutableListOf<AccountPositionDto>()
|
||||
|
||||
// 遍历所有账户,查询每个账户的仓位
|
||||
accounts.forEach { account ->
|
||||
if (account.proxyAddress.isNotBlank()) {
|
||||
try {
|
||||
// 查询所有仓位(不限制 sortBy,获取当前和历史仓位)
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress, sortBy = null)
|
||||
if (positionsResult.isSuccess) {
|
||||
val positions = positionsResult.getOrNull() ?: emptyList()
|
||||
// 遍历所有仓位,区分当前仓位和历史仓位
|
||||
positions.forEach { pos ->
|
||||
val currentValue = pos.currentValue?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
val curPrice = pos.curPrice?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
|
||||
// 判断是否为当前仓位:currentValue != 0 且 curPrice != 0
|
||||
// 使用 eq 方法判断值是否等于 0
|
||||
val isCurrent = !currentValue.eq(BigDecimal.ZERO) && !curPrice.eq(BigDecimal.ZERO)
|
||||
|
||||
val positionDto = AccountPositionDto(
|
||||
accountId = account.id!!,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
proxyAddress = account.proxyAddress,
|
||||
marketId = pos.conditionId ?: "",
|
||||
marketTitle = pos.title ?: "",
|
||||
marketSlug = pos.slug ?: "",
|
||||
marketIcon = pos.icon, // 市场图标
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentPrice = pos.curPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
initialValue = pos.initialValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString() ?: "0",
|
||||
percentPnl = pos.percentPnl?.toString() ?: "0",
|
||||
realizedPnl = pos.realizedPnl?.toString(),
|
||||
percentRealizedPnl = pos.percentRealizedPnl?.toString(),
|
||||
redeemable = pos.redeemable ?: false,
|
||||
mergeable = pos.mergeable ?: false,
|
||||
endDate = pos.endDate,
|
||||
isCurrent = isCurrent // 标识是当前仓位还是历史仓位
|
||||
)
|
||||
|
||||
// 根据 isCurrent 分别添加到对应的列表
|
||||
if (isCurrent) {
|
||||
currentPositions.add(positionDto)
|
||||
} else {
|
||||
historyPositions.add(positionDto)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询账户 ${account.id} 仓位失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按照接口返回的顺序返回,不进行排序
|
||||
// 前端负责本地排序
|
||||
Result.success(PositionListResponse(
|
||||
currentPositions = currentPositions,
|
||||
historyPositions = historyPositions
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询所有仓位失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否有活跃订单
|
||||
* 使用账户的 API Key 查询该账户的活跃订单
|
||||
|
||||
@@ -216,13 +216,15 @@ class BlockchainService(
|
||||
* 通过 Polymarket Data API 查询
|
||||
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
|
||||
*/
|
||||
suspend fun getPositions(proxyWalletAddress: String): Result<List<PositionResponse>> {
|
||||
suspend fun getPositions(proxyWalletAddress: String, sortBy: String? = "CURRENT"): Result<List<PositionResponse>> {
|
||||
return try {
|
||||
// 使用代理钱包地址查询仓位
|
||||
// sortBy=CURRENT 表示只返回当前仓位
|
||||
val response = dataApi.getPositions(
|
||||
user = proxyWalletAddress,
|
||||
limit = 500, // 最大限制
|
||||
offset = 0
|
||||
offset = 0,
|
||||
sortBy = sortBy
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
|
||||
@@ -61,6 +61,7 @@ fun BigInteger.multi(value: Any): BigDecimal {
|
||||
/**
|
||||
* 大于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值大于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
@@ -68,12 +69,16 @@ fun Any?.gt(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() > target.toSafeBigDecimal()
|
||||
val thisValue = this.toSafeBigDecimal()
|
||||
val targetValue = target.toSafeBigDecimal()
|
||||
// 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
return thisValue.compareTo(targetValue) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 大于等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值大于等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
@@ -81,12 +86,16 @@ fun Any?.gte(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() >= target.toSafeBigDecimal()
|
||||
val thisValue = this.toSafeBigDecimal()
|
||||
val targetValue = target.toSafeBigDecimal()
|
||||
// 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
return thisValue.compareTo(targetValue) >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 小于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值小于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
@@ -94,12 +103,16 @@ fun Any?.lt(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() < target.toSafeBigDecimal()
|
||||
val thisValue = this.toSafeBigDecimal()
|
||||
val targetValue = target.toSafeBigDecimal()
|
||||
// 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
return thisValue.compareTo(targetValue) < 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 小于等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值小于等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
@@ -107,12 +120,16 @@ fun Any?.lte(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() <= target.toSafeBigDecimal()
|
||||
val thisValue = this.toSafeBigDecimal()
|
||||
val targetValue = target.toSafeBigDecimal()
|
||||
// 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
return thisValue.compareTo(targetValue) <= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值是否相等
|
||||
* 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
@@ -120,12 +137,17 @@ fun Any?.eq(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() == target.toSafeBigDecimal()
|
||||
val thisValue = this.toSafeBigDecimal()
|
||||
val targetValue = target.toSafeBigDecimal()
|
||||
// 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
// 例如:"0.0" 和 "0" 在数值上相等,但 scale 不同
|
||||
return thisValue.compareTo(targetValue) == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 不等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值是否不相等
|
||||
* 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值不等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
@@ -133,6 +155,9 @@ fun Any?.neq(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() != target.toSafeBigDecimal()
|
||||
val thisValue = this.toSafeBigDecimal()
|
||||
val targetValue = target.toSafeBigDecimal()
|
||||
// 使用 compareTo 方法比较,避免 BigDecimal 的 scale 问题
|
||||
return thisValue.compareTo(targetValue) != 0
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import AccountEdit from './pages/AccountEdit'
|
||||
import LeaderList from './pages/LeaderList'
|
||||
import LeaderAdd from './pages/LeaderAdd'
|
||||
import ConfigPage from './pages/ConfigPage'
|
||||
import OrderList from './pages/OrderList'
|
||||
import PositionList from './pages/PositionList'
|
||||
import Statistics from './pages/Statistics'
|
||||
|
||||
function App() {
|
||||
@@ -26,7 +26,7 @@ function App() {
|
||||
<Route path="/leaders" element={<LeaderList />} />
|
||||
<Route path="/leaders/add" element={<LeaderAdd />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/orders" element={<OrderList />} />
|
||||
<Route path="/positions" element={<PositionList />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -41,9 +41,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
label: '跟单配置'
|
||||
},
|
||||
{
|
||||
key: '/orders',
|
||||
key: '/positions',
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: '订单管理'
|
||||
label: '仓位管理'
|
||||
},
|
||||
{
|
||||
key: '/statistics',
|
||||
@@ -108,8 +108,18 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
|
||||
// 桌面端布局
|
||||
return (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Sider width={200} style={{ background: '#001529' }}>
|
||||
<AntLayout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
<Sider
|
||||
width={200}
|
||||
style={{
|
||||
background: '#001529',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '64px',
|
||||
display: 'flex',
|
||||
@@ -117,7 +127,8 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold'
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
Polymarket 跟单
|
||||
</div>
|
||||
@@ -126,11 +137,20 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => handleMenuClick(key)}
|
||||
style={{ height: 'calc(100vh - 64px)', borderRight: 0 }}
|
||||
style={{
|
||||
height: 'calc(100vh - 64px)',
|
||||
borderRight: 0,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
<AntLayout>
|
||||
<Content style={{ padding: '24px', background: '#f0f2f5', minHeight: '100vh' }}>
|
||||
<AntLayout style={{ marginLeft: 200, height: '100vh' }}>
|
||||
<Content style={{
|
||||
padding: '24px',
|
||||
background: '#f0f2f5',
|
||||
height: '100vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{children}
|
||||
</Content>
|
||||
</AntLayout>
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { Card, Table, Tag, message, Space, Input, Radio, Select, Button, Row, Col, Empty } from 'antd'
|
||||
import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined, UpOutlined, DownOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import type { AccountPosition, Account } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
type PositionFilter = 'current' | 'historical'
|
||||
type ViewMode = 'card' | 'list'
|
||||
|
||||
const PositionList: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [currentPositions, setCurrentPositions] = useState<AccountPosition[]>([])
|
||||
const [historyPositions, setHistoryPositions] = useState<AccountPosition[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [accountsLoading, setAccountsLoading] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [positionFilter, setPositionFilter] = useState<PositionFilter>('current')
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(isMobile ? 'card' : 'list')
|
||||
const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
fetchPositions()
|
||||
}, [])
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
setAccountsLoading(true)
|
||||
try {
|
||||
const response = await apiService.accounts.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setAccounts(response.data.data.list || [])
|
||||
} else {
|
||||
message.error(response.data.msg || '获取账户列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取账户列表失败')
|
||||
} finally {
|
||||
setAccountsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPositions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.accounts.positionsList()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setCurrentPositions(response.data.data.currentPositions || [])
|
||||
setHistoryPositions(response.data.data.historyPositions || [])
|
||||
} else {
|
||||
message.error(response.data.msg || '获取仓位列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取仓位列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据筛选器选择对应的仓位列表
|
||||
const basePositions = useMemo(() => {
|
||||
return positionFilter === 'current' ? currentPositions : historyPositions
|
||||
}, [positionFilter, currentPositions, historyPositions])
|
||||
|
||||
// 本地搜索和筛选过滤
|
||||
const filteredPositions = useMemo(() => {
|
||||
let filtered = basePositions
|
||||
|
||||
// 1. 先按账户筛选
|
||||
if (selectedAccountId !== undefined) {
|
||||
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
||||
}
|
||||
|
||||
// 2. 最后按关键词搜索
|
||||
if (searchKeyword.trim()) {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
filtered = filtered.filter(position => {
|
||||
// 搜索账户名
|
||||
if (position.accountName?.toLowerCase().includes(keyword)) {
|
||||
return true
|
||||
}
|
||||
// 搜索钱包地址
|
||||
if (position.walletAddress.toLowerCase().includes(keyword)) {
|
||||
return true
|
||||
}
|
||||
// 搜索市场标题
|
||||
if (position.marketTitle?.toLowerCase().includes(keyword)) {
|
||||
return true
|
||||
}
|
||||
// 搜索市场slug
|
||||
if (position.marketSlug?.toLowerCase().includes(keyword)) {
|
||||
return true
|
||||
}
|
||||
// 搜索市场ID
|
||||
if (position.marketId.toLowerCase().includes(keyword)) {
|
||||
return true
|
||||
}
|
||||
// 搜索方向(YES/NO)
|
||||
if (position.side.toLowerCase().includes(keyword)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [basePositions, searchKeyword, selectedAccountId])
|
||||
|
||||
const getSideColor = (side: string) => {
|
||||
return side === 'YES' ? 'green' : 'red'
|
||||
}
|
||||
|
||||
const formatNumber = (value: string | undefined, decimals: number = 2) => {
|
||||
if (!value) return '-'
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return value
|
||||
return num.toFixed(decimals)
|
||||
}
|
||||
|
||||
const formatPercent = (value: string | undefined) => {
|
||||
if (!value) return '-'
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return value
|
||||
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`
|
||||
}
|
||||
|
||||
// 切换卡片展开/折叠状态
|
||||
const toggleCard = (cardKey: string) => {
|
||||
setExpandedCards(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(cardKey)) {
|
||||
newSet.delete(cardKey)
|
||||
} else {
|
||||
newSet.add(cardKey)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染卡片视图
|
||||
const renderCardView = () => {
|
||||
if (filteredPositions.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
description="暂无仓位数据"
|
||||
style={{ padding: '60px 0' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
{filteredPositions.map((position, index) => {
|
||||
const pnlNum = parseFloat(position.pnl || '0')
|
||||
const isProfit = pnlNum >= 0
|
||||
// 只有当前仓位才根据盈亏显示边框颜色
|
||||
const borderColor = positionFilter === 'current'
|
||||
? (isProfit ? 'rgba(82, 196, 26, 0.2)' : 'rgba(245, 34, 45, 0.2)')
|
||||
: 'rgba(0,0,0,0.06)'
|
||||
|
||||
const cardKey = `${position.accountId}-${position.marketId}-${index}`
|
||||
const isExpanded = expandedCards.has(cardKey)
|
||||
// 移动端需要折叠功能,桌面端始终展开
|
||||
const shouldCollapse = isMobile && !isExpanded
|
||||
|
||||
return (
|
||||
<Col
|
||||
key={cardKey}
|
||||
xs={24}
|
||||
sm={12}
|
||||
lg={8}
|
||||
xl={6}
|
||||
>
|
||||
<Card
|
||||
hoverable={!isMobile}
|
||||
onClick={() => isMobile && toggleCard(cardKey)}
|
||||
style={{
|
||||
height: '100%',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
transition: 'all 0.3s ease',
|
||||
border: `1px solid ${borderColor}`,
|
||||
cursor: isMobile ? 'pointer' : 'default'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
{/* 头部:市场图标和标题 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
{position.marketIcon && (
|
||||
<img
|
||||
src={position.marketIcon}
|
||||
alt={position.marketTitle || 'Market'}
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '8px',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{position.marketTitle ? (
|
||||
position.marketSlug ? (
|
||||
<a
|
||||
href={`https://polymarket.com/event/${position.marketSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#1890ff',
|
||||
textDecoration: 'none',
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}
|
||||
>
|
||||
{position.marketTitle}
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ fontWeight: 'bold', fontSize: '15px', lineHeight: '1.4' }}>
|
||||
{position.marketTitle}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '12px', color: '#999' }}>
|
||||
{position.marketId.slice(0, 16)}...
|
||||
</div>
|
||||
)}
|
||||
{position.marketSlug && (
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>
|
||||
{position.marketSlug}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账户信息 */}
|
||||
<div style={{ marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: '500', fontSize: '14px', color: '#333' }}>
|
||||
{position.accountName || `账户 ${position.accountId}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace', marginTop: '2px' }}>
|
||||
{position.walletAddress.slice(0, 6)}...{position.walletAddress.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
<Tag color={getSideColor(position.side)} style={{ margin: 0 }}>
|
||||
{position.side}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关键数据 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
{/* 移动端折叠时,显示盈亏(使用简单样式) */}
|
||||
{shouldCollapse && positionFilter === 'current' && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#666' }}>盈亏</span>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: isProfit ? '#52c41a' : '#f5222d'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}{formatNumber(position.pnl, 2)} USDC
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开时显示所有数据 */}
|
||||
{!shouldCollapse && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#666' }}>数量</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: '500' }}>
|
||||
{formatNumber(position.quantity, 4)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#666' }}>平均价格</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: '500' }}>
|
||||
{formatNumber(position.avgPrice, 4)}
|
||||
</span>
|
||||
</div>
|
||||
{positionFilter === 'current' && position.currentPrice && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#666' }}>当前价格</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: '500' }}>
|
||||
{formatNumber(position.currentPrice, 4)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#666' }}>当前价值</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: '600' }}>
|
||||
{formatNumber(position.currentValue, 2)} USDC
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 移动端展开/折叠指示器 */}
|
||||
{isMobile && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid #f0f0f0'
|
||||
}}>
|
||||
{isExpanded ? (
|
||||
<UpOutlined style={{ color: '#999', fontSize: '14px' }} />
|
||||
) : (
|
||||
<DownOutlined style={{ color: '#999', fontSize: '14px' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 盈亏信息 - 突出显示(仅当前仓位显示,仅展开时显示) */}
|
||||
{positionFilter === 'current' && !shouldCollapse && (
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
background: isProfit ? 'rgba(82, 196, 26, 0.08)' : 'rgba(245, 34, 45, 0.08)',
|
||||
border: `1px solid ${isProfit ? 'rgba(82, 196, 26, 0.2)' : 'rgba(245, 34, 45, 0.2)'}`
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#666' }}>盈亏</span>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: isProfit ? '#52c41a' : '#f5222d'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}{formatNumber(position.pnl, 2)} USDC
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
color: isProfit ? '#52c41a' : '#f5222d',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{formatPercent(position.percentPnl)}
|
||||
</span>
|
||||
</div>
|
||||
{position.realizedPnl && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>已实现盈亏</span>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: parseFloat(position.realizedPnl) >= 0 ? '#52c41a' : '#f5222d',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{parseFloat(position.realizedPnl) >= 0 ? '+' : ''}{formatNumber(position.realizedPnl, 2)} USDC
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态标签(移动端折叠时隐藏) */}
|
||||
{positionFilter === 'current' && !shouldCollapse && (position.redeemable || position.mergeable) && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{position.redeemable && (
|
||||
<Tag color="green" style={{ margin: 0 }}>可赎回</Tag>
|
||||
)}
|
||||
{position.mergeable && (
|
||||
<Tag color="blue" style={{ margin: 0 }}>可合并</Tag>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
// 根据仓位类型动态生成列(历史仓位不显示当前价格、当前价值、状态列)
|
||||
const columns = useMemo(() => {
|
||||
const baseColumns: any[] = [
|
||||
{
|
||||
title: '',
|
||||
key: 'icon',
|
||||
width: 50,
|
||||
render: (_: any, record: AccountPosition) => {
|
||||
if (!record.marketIcon) return null
|
||||
return (
|
||||
<img
|
||||
src={record.marketIcon}
|
||||
alt={record.marketTitle || 'Market'}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '4px',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 图片加载失败时隐藏
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
fixed: isMobile ? ('left' as const) : undefined
|
||||
},
|
||||
{
|
||||
title: '账户',
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
render: (text: string | undefined, record: AccountPosition) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold' }}>
|
||||
{text || `账户 ${record.accountId}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-6)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
fixed: isMobile ? ('left' as const) : undefined,
|
||||
width: isMobile ? 150 : 200
|
||||
},
|
||||
{
|
||||
title: '市场',
|
||||
dataIndex: 'marketTitle',
|
||||
key: 'marketTitle',
|
||||
render: (text: string | undefined, record: AccountPosition) => {
|
||||
const url = record.marketSlug
|
||||
? `https://polymarket.com/event/${record.marketSlug}`
|
||||
: null
|
||||
|
||||
const handleTitleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{text ? (
|
||||
<div>
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleTitleClick}
|
||||
style={{ fontWeight: 'bold', color: '#1890ff', textDecoration: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ fontWeight: 'bold' }}>{text}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '12px' }}>
|
||||
{record.marketId.slice(0, 10)}...
|
||||
</div>
|
||||
)}
|
||||
{record.marketSlug && (
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>{record.marketSlug}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
width: isMobile ? 200 : 250
|
||||
},
|
||||
{
|
||||
title: '方向',
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
render: (side: string) => (
|
||||
<Tag color={getSideColor(side)}>{side}</Tag>
|
||||
),
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
render: (quantity: string) => formatNumber(quantity, 4),
|
||||
align: 'right' as const,
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '平均价格',
|
||||
dataIndex: 'avgPrice',
|
||||
key: 'avgPrice',
|
||||
render: (price: string) => formatNumber(price, 4),
|
||||
align: 'right' as const,
|
||||
width: 120
|
||||
},
|
||||
]
|
||||
|
||||
// 只有当前仓位才显示当前价格和当前价值列
|
||||
if (positionFilter === 'current') {
|
||||
baseColumns.push(
|
||||
{
|
||||
title: '当前价格',
|
||||
dataIndex: 'currentPrice',
|
||||
key: 'currentPrice',
|
||||
render: (price: string) => formatNumber(price, 4),
|
||||
align: 'right' as const,
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '当前价值',
|
||||
dataIndex: 'currentValue',
|
||||
key: 'currentValue',
|
||||
render: (value: string) => (
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
{formatNumber(value, 2)} USDC
|
||||
</span>
|
||||
),
|
||||
align: 'right' as const,
|
||||
width: 120,
|
||||
sorter: (a: AccountPosition, b: AccountPosition) => {
|
||||
const valA = parseFloat(a.currentValue || '0')
|
||||
const valB = parseFloat(b.currentValue || '0')
|
||||
return valA - valB
|
||||
},
|
||||
defaultSortOrder: 'descend' as const
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 只有当前仓位才显示盈亏和已实现盈亏列
|
||||
if (positionFilter === 'current') {
|
||||
baseColumns.push(
|
||||
{
|
||||
title: '盈亏',
|
||||
dataIndex: 'pnl',
|
||||
key: 'pnl',
|
||||
render: (pnl: string, record: AccountPosition) => {
|
||||
const pnlNum = parseFloat(pnl || '0')
|
||||
const percentPnl = parseFloat(record.percentPnl || '0')
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}{formatNumber(pnl, 2)} USDC
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
||||
}}>
|
||||
{formatPercent(record.percentPnl)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
align: 'right' as const,
|
||||
width: 150,
|
||||
sorter: (a: AccountPosition, b: AccountPosition) => {
|
||||
const pnlA = parseFloat(a.pnl || '0')
|
||||
const pnlB = parseFloat(b.pnl || '0')
|
||||
return pnlA - pnlB
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '已实现盈亏',
|
||||
dataIndex: 'realizedPnl',
|
||||
key: 'realizedPnl',
|
||||
render: (realizedPnl: string | undefined, record: AccountPosition) => {
|
||||
if (!realizedPnl) return '-'
|
||||
const pnlNum = parseFloat(realizedPnl)
|
||||
const percentPnl = parseFloat(record.percentRealizedPnl || '0')
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}{formatNumber(realizedPnl, 2)} USDC
|
||||
</div>
|
||||
{record.percentRealizedPnl && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
||||
}}>
|
||||
{formatPercent(record.percentRealizedPnl)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
align: 'right' as const,
|
||||
width: 150
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 只有当前仓位才显示状态列
|
||||
if (positionFilter === 'current') {
|
||||
baseColumns.push({
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (_: any, record: AccountPosition) => (
|
||||
<Space size="small">
|
||||
{record.redeemable && <Tag color="green">可赎回</Tag>}
|
||||
{record.mergeable && <Tag color="blue">可合并</Tag>}
|
||||
</Space>
|
||||
),
|
||||
width: 120
|
||||
})
|
||||
}
|
||||
|
||||
return baseColumns
|
||||
}, [positionFilter, isMobile])
|
||||
|
||||
// 统计当前和历史仓位数量(根据账户筛选)
|
||||
const filteredCurrentPositions = useMemo(() => {
|
||||
if (selectedAccountId === undefined) return currentPositions
|
||||
return currentPositions.filter(p => p.accountId === selectedAccountId)
|
||||
}, [currentPositions, selectedAccountId])
|
||||
|
||||
const filteredHistoryPositions = useMemo(() => {
|
||||
if (selectedAccountId === undefined) return historyPositions
|
||||
return historyPositions.filter(p => p.accountId === selectedAccountId)
|
||||
}, [historyPositions, selectedAccountId])
|
||||
|
||||
const currentCount = filteredCurrentPositions.length
|
||||
const historicalCount = filteredHistoryPositions.length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px', marginBottom: '12px' }}>
|
||||
<h2 style={{ margin: 0 }}>仓位管理</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: isMobile ? '1 1 100%' : '0 0 auto', flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="搜索账户、市场、方向..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: isMobile ? '100%' : 300 }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Button.Group>
|
||||
<Button
|
||||
type={viewMode === 'list' ? 'primary' : 'default'}
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => setViewMode('list')}
|
||||
title="列表视图"
|
||||
/>
|
||||
<Button
|
||||
type={viewMode === 'card' ? 'primary' : 'default'}
|
||||
icon={<AppstoreOutlined />}
|
||||
onClick={() => setViewMode('card')}
|
||||
title="卡片视图"
|
||||
/>
|
||||
</Button.Group>
|
||||
)}
|
||||
<span style={{ color: '#999', fontSize: '14px', whiteSpace: 'nowrap' }}>
|
||||
{searchKeyword || selectedAccountId !== undefined
|
||||
? `找到 ${filteredPositions.length} / ${basePositions.length} 个仓位`
|
||||
: `共 ${basePositions.length} 个仓位`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
placeholder="选择账户"
|
||||
value={selectedAccountId ?? null}
|
||||
onChange={(value) => setSelectedAccountId(value ?? undefined)}
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
loading={accountsLoading}
|
||||
options={[
|
||||
{ value: null, label: '全部账户' },
|
||||
...accounts
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.accountName || `账户 ${a.id}`).toLowerCase()
|
||||
const nameB = (b.accountName || `账户 ${b.id}`).toLowerCase()
|
||||
return nameA.localeCompare(nameB, 'zh-CN')
|
||||
})
|
||||
.map(account => ({
|
||||
value: account.id,
|
||||
label: account.accountName || `账户 ${account.id}`
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
<div style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '4px',
|
||||
borderRadius: '8px',
|
||||
display: 'inline-flex',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<Radio.Group
|
||||
value={positionFilter}
|
||||
onChange={(e) => setPositionFilter(e.target.value)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ display: 'flex', gap: '4px' }}
|
||||
>
|
||||
<Radio.Button
|
||||
value="current"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5',
|
||||
transition: 'all 0.3s ease',
|
||||
background: positionFilter === 'current' ? '#1890ff' : 'transparent',
|
||||
color: positionFilter === 'current' ? '#fff' : '#666',
|
||||
fontWeight: positionFilter === 'current' ? '500' : 'normal',
|
||||
boxShadow: positionFilter === 'current' ? '0 2px 4px rgba(24, 144, 255, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>当前仓位</span>
|
||||
<Tag
|
||||
color={positionFilter === 'current' ? 'default' : 'blue'}
|
||||
style={{
|
||||
margin: 0,
|
||||
borderRadius: '10px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
padding: '0 8px',
|
||||
background: positionFilter === 'current' ? 'rgba(255, 255, 255, 0.3)' : undefined,
|
||||
color: positionFilter === 'current' ? '#fff' : undefined,
|
||||
border: positionFilter === 'current' ? 'none' : undefined
|
||||
}}
|
||||
>
|
||||
{currentCount}
|
||||
</Tag>
|
||||
</span>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
value="historical"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
height: 'auto',
|
||||
lineHeight: '1.5',
|
||||
transition: 'all 0.3s ease',
|
||||
background: positionFilter === 'historical' ? '#1890ff' : 'transparent',
|
||||
color: positionFilter === 'historical' ? '#fff' : '#666',
|
||||
fontWeight: positionFilter === 'historical' ? '500' : 'normal',
|
||||
boxShadow: positionFilter === 'historical' ? '0 2px 4px rgba(24, 144, 255, 0.2)' : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>历史仓位</span>
|
||||
<Tag
|
||||
color={positionFilter === 'historical' ? 'default' : 'default'}
|
||||
style={{
|
||||
margin: 0,
|
||||
borderRadius: '10px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
padding: '0 8px',
|
||||
background: positionFilter === 'historical' ? 'rgba(255, 255, 255, 0.3)' : undefined,
|
||||
color: positionFilter === 'historical' ? '#fff' : undefined,
|
||||
border: positionFilter === 'historical' ? 'none' : undefined
|
||||
}}
|
||||
>
|
||||
{historicalCount}
|
||||
</Tag>
|
||||
</span>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isMobile || viewMode === 'card') ? (
|
||||
<Card loading={loading}>
|
||||
{renderCardView()}
|
||||
{filteredPositions.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={filteredPositions}
|
||||
columns={columns}
|
||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
|
||||
}}
|
||||
scroll={isMobile ? { x: 1500 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PositionList
|
||||
|
||||
@@ -91,7 +91,13 @@ export const apiService = {
|
||||
* 设置默认账户
|
||||
*/
|
||||
setDefault: (data: { accountId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/set-default', data)
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/set-default', data),
|
||||
|
||||
/**
|
||||
* 查询所有账户的仓位列表
|
||||
*/
|
||||
positionsList: () =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/list', {})
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,3 +151,39 @@ export interface Statistics {
|
||||
maxLoss: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户仓位信息
|
||||
*/
|
||||
export interface AccountPosition {
|
||||
accountId: number
|
||||
accountName?: string
|
||||
walletAddress: string
|
||||
proxyAddress: string
|
||||
marketId: string
|
||||
marketTitle?: string
|
||||
marketSlug?: string
|
||||
marketIcon?: string // 市场图标 URL
|
||||
side: string // YES 或 NO
|
||||
quantity: string
|
||||
avgPrice: string
|
||||
currentPrice: string
|
||||
currentValue: string
|
||||
initialValue: string
|
||||
pnl: string
|
||||
percentPnl: string
|
||||
realizedPnl?: string
|
||||
percentRealizedPnl?: string
|
||||
redeemable: boolean
|
||||
mergeable: boolean
|
||||
endDate?: string
|
||||
isCurrent: boolean // true: 当前仓位(有持仓),false: 历史仓位(已平仓)
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓位列表响应
|
||||
*/
|
||||
export interface PositionListResponse {
|
||||
currentPositions: AccountPosition[]
|
||||
historyPositions: AccountPosition[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user