Initial commit

This commit is contained in:
2025-10-11 11:55:25 +08:00
parent 467dad8449
commit 8107dee8d3
2879 changed files with 610575 additions and 0 deletions

View File

@@ -0,0 +1,627 @@
// 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';
// 计算涨跌幅的辅助函数
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 }) {
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 { 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');
// 格式化货币
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());
onOpen();
};
// 执行卖出
const handleSellConfirm = async () => {
if (!selectedPosition || sellQuantity <= 0) return;
setIsLoading(true);
try {
const result = await onSellStock(
selectedPosition.stockCode,
sellQuantity,
orderType,
orderType === 'LIMIT' ? parseFloat(limitPrice) : null
);
if (result.success) {
toast({
title: '卖出成功',
description: `已卖出 ${selectedPosition.stockName} ${sellQuantity}`,
status: 'success',
duration: 3000,
isClosable: true,
});
onClose();
}
} catch (error) {
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>
</>
);
}