Files
vf_react/src/views/TradingSimulation/components/PositionsList.js

702 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/TradingSimulation/components/PositionsList.js - 持仓列表组件(现代化版本)
import React, { useState } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Heading,
Text,
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
HStack,
VStack,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
FormControl,
FormLabel,
Select,
Alert,
AlertIcon,
AlertDescription,
useDisclosure,
useToast,
useColorModeValue,
useColorMode,
Icon,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
SimpleGrid,
Flex
} from '@chakra-ui/react';
import { FiTrendingUp, FiTrendingDown, FiMinus, FiBarChart2, FiPieChart } from 'react-icons/fi';
// 导入现有的高质量组件
import BarChart from '../../../components/Charts/BarChart';
import PieChart from '../../../components/Charts/PieChart';
import IconBox from '../../../components/Icons/IconBox';
import { logger } from '../../../utils/logger';
// 计算涨跌幅的辅助函数
const calculateChange = (currentPrice, avgPrice) => {
if (!avgPrice || avgPrice === 0) return { change: 0, changePercent: 0 };
const change = currentPrice - avgPrice;
const changePercent = (change / avgPrice) * 100;
return { change, changePercent };
};
export default function PositionsList({ positions, account, onSellStock, tradingEvents }) {
const [selectedPosition, setSelectedPosition] = useState(null);
const [sellQuantity, setSellQuantity] = useState(0);
const [orderType, setOrderType] = useState('MARKET');
const [limitPrice, setLimitPrice] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [hasTracked, setHasTracked] = React.useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
const cardBg = useColorModeValue('white', 'gray.800');
const textColor = useColorModeValue('gray.700', 'white');
const secondaryColor = useColorModeValue('gray.500', 'gray.400');
// 🎯 追踪持仓查看 - 组件加载时触发一次
React.useEffect(() => {
if (!hasTracked && positions && positions.length > 0 && tradingEvents && tradingEvents.trackSimulationHoldingsViewed) {
const totalMarketValue = positions.reduce((sum, pos) => sum + (pos.marketValue || pos.quantity * pos.currentPrice || 0), 0);
const totalCost = positions.reduce((sum, pos) => sum + (pos.totalCost || pos.quantity * pos.avgPrice || 0), 0);
const totalProfit = positions.reduce((sum, pos) => sum + (pos.profit || 0), 0);
tradingEvents.trackSimulationHoldingsViewed({
count: positions.length,
totalValue: totalMarketValue,
totalCost,
profitLoss: totalProfit,
});
setHasTracked(true);
}
}, [positions, tradingEvents, hasTracked]);
// 格式化货币
const formatCurrency = (amount) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2
}).format(amount);
};
// 计算持仓盈亏(使用后端返回的数据)
const calculatePositionProfit = (position) => {
return {
profit: position.profit || 0,
profitPercent: position.profitRate || 0,
currentPrice: position.currentPrice || position.avgPrice,
marketValue: position.marketValue || (position.currentPrice * position.quantity)
};
};
// 打开卖出对话框
const handleSellClick = (position) => {
setSelectedPosition(position);
setSellQuantity(position.availableQuantity); // 默认全部可卖数量
setLimitPrice(position.currentPrice?.toString() || position.avgPrice.toString());
// 🎯 追踪卖出按钮点击
if (tradingEvents && tradingEvents.trackSellButtonClicked) {
tradingEvents.trackSellButtonClicked({
stockCode: position.stockCode,
stockName: position.stockName,
quantity: position.quantity,
profitLoss: position.profit || 0,
}, 'holdings');
}
onOpen();
};
// 执行卖出
const handleSellConfirm = async () => {
if (!selectedPosition || sellQuantity <= 0) return;
setIsLoading(true);
const price = orderType === 'LIMIT' ? parseFloat(limitPrice) : selectedPosition.currentPrice || selectedPosition.avgPrice;
try {
const result = await onSellStock(
selectedPosition.stockCode,
sellQuantity,
orderType,
orderType === 'LIMIT' ? parseFloat(limitPrice) : null
);
if (result.success) {
logger.info('PositionsList', '卖出成功', {
stockCode: selectedPosition.stockCode,
stockName: selectedPosition.stockName,
quantity: sellQuantity,
orderType,
orderId: result.orderId
});
// 🎯 追踪卖出成功
if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) {
tradingEvents.trackSimulationOrderPlaced({
stockCode: selectedPosition.stockCode,
stockName: selectedPosition.stockName,
direction: 'sell',
quantity: sellQuantity,
price,
orderType,
success: true,
});
}
toast({
title: '卖出成功',
description: `已卖出 ${selectedPosition.stockName} ${sellQuantity}`,
status: 'success',
duration: 3000,
isClosable: true,
});
onClose();
}
} catch (error) {
logger.error('PositionsList', 'handleSellConfirm', error, {
stockCode: selectedPosition?.stockCode,
stockName: selectedPosition?.stockName,
quantity: sellQuantity,
orderType
});
// 🎯 追踪卖出失败
if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) {
tradingEvents.trackSimulationOrderPlaced({
stockCode: selectedPosition.stockCode,
stockName: selectedPosition.stockName,
direction: 'sell',
quantity: sellQuantity,
price,
orderType,
success: false,
errorMessage: error.message,
});
}
toast({
title: '卖出失败',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
// 计算卖出金额
const calculateSellAmount = () => {
if (!selectedPosition || !sellQuantity) return { totalAmount: 0, commission: 0, stampTax: 0, netAmount: 0 };
const price = orderType === 'LIMIT' ? parseFloat(limitPrice) || 0 : selectedPosition.currentPrice || selectedPosition.avgPrice;
const totalAmount = price * sellQuantity;
const commission = Math.max(totalAmount * 0.00025, 5); // 万分之2.5
const stampTax = totalAmount * 0.001; // 千分之1
const transferFee = totalAmount * 0.00002; // 万分之0.2
const netAmount = totalAmount - commission - stampTax - transferFee;
return { totalAmount, commission, stampTax, transferFee, netAmount };
};
// 安全地计算总持仓统计
const calculateTotalStats = () => {
if (!Array.isArray(positions) || positions.length === 0) {
return { totalMarketValue: 0, totalCost: 0, totalProfit: 0, totalProfitPercent: 0 };
}
let totalMarketValue = 0;
let totalCost = 0;
let totalProfit = 0;
positions.forEach(position => {
if (position) {
const { marketValue, profit } = calculatePositionProfit(position);
totalMarketValue += marketValue || 0;
totalCost += position.totalCost || (position.quantity * position.avgPrice) || 0;
totalProfit += profit || 0;
}
});
const totalProfitPercent = totalCost > 0 ? (totalProfit / totalCost) * 100 : 0;
return { totalMarketValue, totalCost, totalProfit, totalProfitPercent };
};
const { totalMarketValue, totalCost, totalProfit, totalProfitPercent } = calculateTotalStats();
const { totalAmount, commission, stampTax, transferFee, netAmount } = calculateSellAmount();
if (!positions || positions.length === 0) {
return (
<Card bg={cardBg} shadow="lg">
<CardBody>
<VStack spacing={4} py={8}>
<Icon as={FiMinus} size="48px" color={secondaryColor} />
<Text color={secondaryColor} fontSize="lg">暂无持仓</Text>
<Text color={secondaryColor} fontSize="sm">
请前往交易面板买入股票
</Text>
</VStack>
</CardBody>
</Card>
);
}
// 安全地准备持仓分布图表数据
const safePositions = Array.isArray(positions) ? positions : [];
const hasPositions = safePositions.length > 0;
const positionDistributionData = hasPositions ? safePositions.map(pos => pos?.marketValue || 0) : [];
const positionDistributionLabels = hasPositions ? safePositions.map(pos => pos?.stockName || pos?.stockCode || '') : [];
const positionDistributionOptions = {
labels: positionDistributionLabels,
colors: ['#4299E1', '#48BB78', '#ED8936', '#9F7AEA', '#F56565', '#38B2AC', '#ECC94B'],
chart: {
width: "100%",
height: "300px"
},
legend: {
show: true,
position: 'right',
fontSize: '12px'
},
dataLabels: {
enabled: true,
formatter: function (val) {
return (val || 0).toFixed(1) + "%"
}
},
tooltip: {
enabled: true,
theme: "dark",
y: {
formatter: function(val) {
return formatCurrency(val || 0)
}
}
}
};
// 安全地准备盈亏分布柱状图数据
const profitBarData = hasPositions ? [{
name: '盈亏分布',
data: safePositions.map(pos => pos?.profit || 0)
}] : [];
const xAxisLabelColor = useColorModeValue('#718096', '#A0AEC0');
const yAxisLabelColor = useColorModeValue('#718096', '#A0AEC0');
const gridBorderColor = useColorModeValue('#E2E8F0', '#4A5568');
const profitBarOptions = {
chart: {
toolbar: { show: false },
height: 300
},
plotOptions: {
bar: {
borderRadius: 8,
columnWidth: "60%",
colors: {
ranges: [{
from: -1000000,
to: 0,
color: '#F56565'
}, {
from: 0.01,
to: 1000000,
color: '#48BB78'
}]
}
}
},
xaxis: {
categories: hasPositions ? safePositions.map(pos => pos?.stockCode || '') : [],
labels: {
style: {
colors: xAxisLabelColor,
fontSize: '12px'
}
}
},
yaxis: {
labels: {
style: {
colors: yAxisLabelColor,
fontSize: '12px'
},
formatter: function (val) {
return '¥' + ((val || 0) / 1000).toFixed(1) + 'k'
}
}
},
tooltip: {
theme: "dark",
y: {
formatter: function(val) {
return formatCurrency(val || 0)
}
}
},
grid: {
strokeDashArray: 5,
borderColor: gridBorderColor
}
};
return (
<>
<VStack spacing={6} align="stretch">
{/* 现代化持仓概览 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
{/* 左侧:核心统计 */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Text fontSize="lg" fontWeight="bold" color={textColor}>持仓概览</Text>
<Badge colorScheme="blue" variant="solid" borderRadius="full">
{positions.length} 只股票
</Badge>
</HStack>
</CardHeader>
<CardBody>
<SimpleGrid columns={1} spacing={4}>
{/* 持仓市值 */}
<Card variant="outline">
<CardBody>
<Flex direction="row" align="center" justify="center" w="100%">
<Stat me="auto">
<StatLabel fontSize="sm" color={secondaryColor} fontWeight="bold">
持仓市值
</StatLabel>
<StatNumber fontSize="xl" color={textColor} fontWeight="bold">
{formatCurrency(totalMarketValue)}
</StatNumber>
</Stat>
<IconBox
as="div"
h={"40px"}
w={"40px"}
bg="linear-gradient(90deg, #4481EB 0%, #04BEFE 100%)"
icon={<Icon as={FiBarChart2} color="white" w="20px" h="20px" />}
/>
</Flex>
</CardBody>
</Card>
{/* 总盈亏 */}
<Card variant="outline">
<CardBody>
<Flex direction="row" align="center" justify="center" w="100%">
<Stat me="auto">
<StatLabel fontSize="sm" color={secondaryColor} fontWeight="bold">
总盈亏
</StatLabel>
<StatNumber fontSize="xl" color={totalProfit >= 0 ? 'green.500' : 'red.500'} fontWeight="bold">
{formatCurrency(totalProfit)}
</StatNumber>
<StatHelpText color={totalProfit >= 0 ? 'green.500' : 'red.500'} fontSize="sm" fontWeight="bold">
<StatArrow type={totalProfit >= 0 ? 'increase' : 'decrease'} />
{(totalProfitPercent || 0).toFixed(2)}%
</StatHelpText>
</Stat>
<IconBox
as="div"
h={"40px"}
w={"40px"}
bg={totalProfit >= 0
? "linear-gradient(90deg, #4FD1C7 0%, #81E6D9 100%)"
: "linear-gradient(90deg, #FEB2B2 0%, #F56565 100%)"
}
icon={
<Icon
as={totalProfit >= 0 ? FiTrendingUp : FiTrendingDown}
color="white"
w="20px"
h="20px"
/>
}
/>
</Flex>
</CardBody>
</Card>
</SimpleGrid>
</CardBody>
</Card>
{/* 右侧:持仓分布图 */}
{hasPositions && (
<Card>
<CardHeader>
<HStack justify="space-between">
<Text fontSize="lg" fontWeight="bold" color={textColor}>持仓分布</Text>
<Icon as={FiPieChart} color="blue.500" />
</HStack>
</CardHeader>
<CardBody>
<Box h="300px">
<PieChart
chartData={positionDistributionData}
chartOptions={positionDistributionOptions}
/>
</Box>
</CardBody>
</Card>
)}
</SimpleGrid>
{/* 盈亏分析图表 */}
{hasPositions && (
<Card>
<CardHeader>
<HStack justify="space-between">
<Text fontSize="lg" fontWeight="bold" color={textColor}>盈亏分析</Text>
<Badge
colorScheme={totalProfit >= 0 ? 'green' : 'red'}
variant="solid"
borderRadius="full"
>
{totalProfit >= 0 ? '整体盈利' : '整体亏损'}
</Badge>
</HStack>
</CardHeader>
<CardBody>
<Box h="300px">
<BarChart
chartData={profitBarData}
chartOptions={profitBarOptions}
/>
</Box>
</CardBody>
</Card>
)}
{/* 持仓列表 */}
<Card bg={cardBg} shadow="lg">
<CardHeader>
<HStack justify="space-between">
<Heading size="md">我的持仓</Heading>
<Badge colorScheme="blue" variant="solid">
{positions.length} 只股票
</Badge>
</HStack>
</CardHeader>
<CardBody>
<Box overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>股票代码</Th>
<Th>股票名称</Th>
<Th isNumeric>持仓数量</Th>
<Th isNumeric>成本价</Th>
<Th isNumeric>现价</Th>
<Th isNumeric>市值</Th>
<Th isNumeric>盈亏</Th>
<Th isNumeric>盈亏比例</Th>
<Th>操作</Th>
</Tr>
</Thead>
<Tbody>
{safePositions.map((position) => {
if (!position) return null;
const { profit, profitPercent, currentPrice, marketValue } = calculatePositionProfit(position);
const { change, changePercent } = calculateChange(currentPrice, position.avgPrice);
return (
<Tr key={position.stockCode || position.id}>
<Td fontWeight="medium">{position.stockCode || '-'}</Td>
<Td>{position.stockName || '-'}</Td>
<Td isNumeric>{(position.quantity || 0).toLocaleString()}</Td>
<Td isNumeric>¥{(position.avgPrice || 0).toFixed(2)}</Td>
<Td isNumeric>
<VStack spacing={0} align="end">
<Text>¥{(currentPrice || 0).toFixed(2)}</Text>
<Text
fontSize="xs"
color={(change || 0) >= 0 ? 'green.500' : 'red.500'}
>
{(change || 0) >= 0 ? '+' : ''}{(changePercent || 0).toFixed(2)}%
</Text>
</VStack>
</Td>
<Td isNumeric>{formatCurrency(marketValue)}</Td>
<Td isNumeric>
<Text color={profit >= 0 ? 'green.500' : 'red.500'}>
{formatCurrency(profit)}
</Text>
</Td>
<Td isNumeric>
<HStack justify="end" spacing={1}>
<Icon
as={profit >= 0 ? FiTrendingUp : FiTrendingDown}
color={profit >= 0 ? 'green.500' : 'red.500'}
/>
<Text color={(profit || 0) >= 0 ? 'green.500' : 'red.500'}>
{(profitPercent || 0).toFixed(2)}%
</Text>
</HStack>
</Td>
<Td>
<Button
colorScheme="red"
size="sm"
variant="outline"
onClick={() => handleSellClick(position)}
isDisabled={(position.availableQuantity || 0) <= 0}
>
卖出
</Button>
</Td>
</Tr>
);
}).filter(Boolean)}
</Tbody>
</Table>
</Box>
</CardBody>
</Card>
</VStack>
{/* 卖出对话框 */}
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>
卖出 {selectedPosition?.stockName}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
{selectedPosition && (
<Alert status="info">
<AlertIcon />
<AlertDescription>
当前持仓: {selectedPosition.quantity} 可卖: {selectedPosition.availableQuantity} 成本价: ¥{(selectedPosition.avgPrice || 0).toFixed(2)}
</AlertDescription>
</Alert>
)}
<FormControl>
<FormLabel>卖出数量</FormLabel>
<NumberInput
value={sellQuantity}
onChange={(value) => setSellQuantity(parseInt(value) || 0)}
min={1}
max={selectedPosition?.availableQuantity || 0}
step={100}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>订单类型</FormLabel>
<Select value={orderType} onChange={(e) => setOrderType(e.target.value)}>
<option value="MARKET">市价单</option>
<option value="LIMIT">限价单</option>
</Select>
</FormControl>
{orderType === 'LIMIT' && (
<FormControl>
<FormLabel>限价价格</FormLabel>
<NumberInput
value={limitPrice}
onChange={(value) => setLimitPrice(value)}
precision={2}
min={0}
>
<NumberInputField />
</NumberInput>
</FormControl>
)}
{/* 卖出金额计算 */}
{sellQuantity > 0 && (
<Card bg={useColorModeValue('gray.50', 'gray.700')}>
<CardBody py={3}>
<VStack spacing={2} align="stretch">
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>交易金额</Text>
<Text fontSize="sm">{formatCurrency(totalAmount)}</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>手续费</Text>
<Text fontSize="sm">{formatCurrency(commission)}</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>印花税</Text>
<Text fontSize="sm">{formatCurrency(stampTax)}</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>过户费</Text>
<Text fontSize="sm">{formatCurrency(transferFee)}</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="md" fontWeight="bold">实收金额</Text>
<Text fontSize="md" fontWeight="bold" color="green.500">
{formatCurrency(netAmount)}
</Text>
</HStack>
</VStack>
</CardBody>
</Card>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
取消
</Button>
<Button
colorScheme="red"
onClick={handleSellConfirm}
isLoading={isLoading}
loadingText="卖出中..."
isDisabled={sellQuantity <= 0 || sellQuantity > (selectedPosition?.availableQuantity || 0)}
>
确认卖出
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}