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,364 @@
// src/views/TradingSimulation/components/AccountOverview.js - 账户概览组件(现代化版本)
import React from 'react';
import {
Grid,
GridItem,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Box,
Text,
Badge,
VStack,
HStack,
Progress,
useColorModeValue,
Icon,
Flex,
SimpleGrid,
Card,
CardBody,
CardHeader
} from '@chakra-ui/react';
import { FiTrendingUp, FiTrendingDown, FiDollarSign, FiPieChart, FiTarget, FiActivity } from 'react-icons/fi';
// 导入现有的图表组件
import DonutChart from '../../../components/Charts/DonutChart';
import IconBox from '../../../components/Icons/IconBox';
export default function AccountOverview({ account }) {
const textColor = useColorModeValue('gray.700', 'white');
const secondaryColor = useColorModeValue('gray.500', 'gray.400');
const profitColor = account?.totalProfit >= 0 ? 'green.500' : 'red.500';
const profitBg = useColorModeValue(
account?.totalProfit >= 0 ? 'green.50' : 'red.50',
account?.totalProfit >= 0 ? 'green.900' : 'red.900'
);
if (!account) {
return (
<Box p={4}>
<Text color={secondaryColor}>暂无账户数据</Text>
</Box>
);
}
// 安全地计算资产配置比例
const cashRatio = account?.totalAssets > 0 ? (account.availableCash / account.totalAssets) * 100 : 0;
const stockRatio = account?.totalAssets > 0 ? (account.marketValue / account.totalAssets) * 100 : 0;
// 格式化数字
const formatCurrency = (amount) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount || 0);
};
const formatPercent = (percent) => {
return `${(percent || 0) >= 0 ? '+' : ''}${(percent || 0).toFixed(2)}%`;
};
// 安全地准备资产配置饼图数据
const assetAllocationData = account?.totalAssets > 0 ? [
(account.availableCash / account.totalAssets) * 100,
(account.marketValue / account.totalAssets) * 100
] : [100, 0];
const assetAllocationOptions = {
labels: ['现金资产', '股票资产'],
colors: ['#4299E1', '#48BB78'],
chart: {
width: "100%",
height: "280px"
},
states: {
hover: {
filter: {
type: "none",
},
},
},
legend: {
show: true,
position: 'bottom',
fontSize: '12px',
labels: {
colors: textColor
}
},
dataLabels: {
enabled: true,
style: {
fontSize: '12px',
fontWeight: 'bold',
colors: ['#fff']
},
formatter: function (val) {
return (val || 0).toFixed(1) + "%"
}
},
plotOptions: {
pie: {
expandOnClick: false,
donut: {
labels: {
show: false,
},
},
},
},
fill: {
colors: ['#4299E1', '#48BB78'],
},
tooltip: {
enabled: true,
theme: "dark",
y: {
formatter: function(val) {
return formatCurrency(val / 100 * (account?.totalAssets || 0))
}
}
},
};
return (
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
{/* 左侧:主要资产数据 */}
<VStack spacing={6} align="stretch">
{/* 核心资产指标 */}
<SimpleGrid columns={{ base: 2, md: 2 }} spacing={4}>
{/* 总资产卡片 */}
<Card minH="120px">
<CardBody>
<Flex direction="row" align="center" justify="center" w="100%">
<Stat me="auto">
<StatLabel
fontSize="sm"
color={secondaryColor}
fontWeight="bold"
pb=".1rem"
>
总资产
</StatLabel>
<Flex>
<StatNumber fontSize="lg" color={textColor} fontWeight="bold">
{formatCurrency(account?.totalAssets)}
</StatNumber>
</Flex>
</Stat>
<IconBox
as="div"
h={"45px"}
w={"45px"}
bg="linear-gradient(90deg, #4481EB 0%, #04BEFE 100%)"
icon={<Icon as={FiDollarSign} color="white" w="24px" h="24px" />}
/>
</Flex>
</CardBody>
</Card>
{/* 总收益卡片 */}
<Card minH="120px">
<CardBody>
<Flex direction="row" align="center" justify="center" w="100%">
<Stat me="auto">
<StatLabel
fontSize="sm"
color={secondaryColor}
fontWeight="bold"
pb=".1rem"
>
总收益
</StatLabel>
<Flex align="center">
<StatNumber fontSize="lg" color={profitColor} fontWeight="bold">
{formatCurrency(account?.totalProfit)}
</StatNumber>
<StatArrow
type={(account?.totalProfit || 0) >= 0 ? 'increase' : 'decrease'}
color={profitColor}
ml={2}
/>
</Flex>
<StatHelpText color={profitColor} fontSize="sm" fontWeight="bold">
{formatPercent(account?.totalProfitPercent)}
</StatHelpText>
</Stat>
<IconBox
as="div"
h={"45px"}
w={"45px"}
bg={(account?.totalProfit || 0) >= 0
? "linear-gradient(90deg, #4FD1C7 0%, #81E6D9 100%)"
: "linear-gradient(90deg, #FEB2B2 0%, #F56565 100%)"
}
icon={
<Icon
as={(account?.totalProfit || 0) >= 0 ? FiTrendingUp : FiTrendingDown}
color="white"
w="24px"
h="24px"
/>
}
/>
</Flex>
</CardBody>
</Card>
{/* 可用资金卡片 */}
<Card minH="120px">
<CardBody>
<Flex direction="row" align="center" justify="center" w="100%">
<Stat me="auto">
<StatLabel
fontSize="sm"
color={secondaryColor}
fontWeight="bold"
pb=".1rem"
>
可用资金
</StatLabel>
<Flex>
<StatNumber fontSize="lg" color={textColor} fontWeight="bold">
{formatCurrency(account?.availableCash)}
</StatNumber>
</Flex>
<StatHelpText color={secondaryColor} fontSize="xs">
现金占比: {(cashRatio || 0).toFixed(1)}%
</StatHelpText>
</Stat>
<IconBox
as="div"
h={"45px"}
w={"45px"}
bg="linear-gradient(90deg, #FFE57F 0%, #FFB74D 100%)"
icon={<Icon as={FiTarget} color="white" w="24px" h="24px" />}
/>
</Flex>
</CardBody>
</Card>
{/* 持仓市值卡片 */}
<Card minH="120px">
<CardBody>
<Flex direction="row" align="center" justify="center" w="100%">
<Stat me="auto">
<StatLabel
fontSize="sm"
color={secondaryColor}
fontWeight="bold"
pb=".1rem"
>
持仓市值
</StatLabel>
<Flex>
<StatNumber fontSize="lg" color={textColor} fontWeight="bold">
{formatCurrency(account?.marketValue)}
</StatNumber>
</Flex>
<StatHelpText color={secondaryColor} fontSize="xs">
持仓占比: {(stockRatio || 0).toFixed(1)}%
</StatHelpText>
</Stat>
<IconBox
as="div"
h={"45px"}
w={"45px"}
bg="linear-gradient(90deg, #9F7AEA 0%, #805AD5 100%)"
icon={<Icon as={FiPieChart} color="white" w="24px" h="24px" />}
/>
</Flex>
</CardBody>
</Card>
</SimpleGrid>
{/* 风险等级和账户状态 */}
<Card>
<CardBody>
<VStack spacing={4}>
<HStack justify="space-between" w="full">
<HStack spacing={3}>
<IconBox
as="div"
h={"35px"}
w={"35px"}
bg="linear-gradient(90deg, #F093FB 0%, #F5576C 100%)"
icon={<Icon as={FiActivity} color="white" w="16px" h="16px" />}
/>
<VStack align="start" spacing={0}>
<Text fontSize="sm" fontWeight="bold" color={textColor}>账户状态</Text>
<Text fontSize="xs" color={secondaryColor}>风险等级: 中等</Text>
</VStack>
</HStack>
<Badge
colorScheme={(account?.totalProfit || 0) >= 0 ? 'green' : 'red'}
variant="solid"
borderRadius="full"
px={3}
py={1}
>
{(account?.totalProfit || 0) >= 0 ? '盈利中' : '亏损中'}
</Badge>
</HStack>
<HStack justify="space-between" w="full" fontSize="xs" color={secondaryColor}>
<Text>创建时间: {account?.createdAt ? new Date(account.createdAt).toLocaleDateString('zh-CN') : '-'}</Text>
<Text>最后更新: {account?.lastUpdated ? new Date(account.lastUpdated).toLocaleString('zh-CN') : '-'}</Text>
</HStack>
</VStack>
</CardBody>
</Card>
</VStack>
{/* 右侧:资产配置图表 */}
<Card>
<CardHeader>
<HStack justify="space-between" w="full">
<Text fontSize="lg" fontWeight="bold" color={textColor}>
资产配置
</Text>
<Badge colorScheme="blue" variant="outline">
实时更新
</Badge>
</HStack>
</CardHeader>
<CardBody>
<Box h="280px">
<DonutChart
chartData={assetAllocationData}
chartOptions={assetAllocationOptions}
/>
</Box>
{/* 详细配置信息 */}
<VStack spacing={3} mt={4}>
<HStack justify="space-between" w="full">
<HStack spacing={2}>
<Box w={3} h={3} bg="blue.400" borderRadius="sm" />
<Text fontSize="sm" color={textColor}>现金资产</Text>
</HStack>
<Text fontSize="sm" fontWeight="bold" color={textColor}>
{formatCurrency(account?.availableCash)}
</Text>
</HStack>
<HStack justify="space-between" w="full">
<HStack spacing={2}>
<Box w={3} h={3} bg="green.400" borderRadius="sm" />
<Text fontSize="sm" color={textColor}>股票资产</Text>
</HStack>
<Text fontSize="sm" fontWeight="bold" color={textColor}>
{formatCurrency(account?.marketValue)}
</Text>
</HStack>
</VStack>
</CardBody>
</Card>
</SimpleGrid>
);
}

View File

@@ -0,0 +1,470 @@
// src/views/TradingSimulation/components/MarginTrading.js - 融资融券组件
import React, { useState } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Heading,
Text,
Button,
Badge,
VStack,
HStack,
Grid,
GridItem,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Stat,
StatLabel,
StatNumber,
StatHelpText,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Progress,
Divider,
useColorModeValue,
Icon
} from '@chakra-ui/react';
import {
FiTrendingUp,
FiTrendingDown,
FiDollarSign,
FiAlertTriangle,
FiInfo,
FiShield
} from 'react-icons/fi';
export default function MarginTrading({ account, onMarginBuy, onShortSell }) {
const [activeTab, setActiveTab] = useState(0); // 0: 融资买入, 1: 融券卖出
const cardBg = useColorModeValue('white', 'gray.800');
const textColor = useColorModeValue('gray.700', 'white');
const secondaryColor = useColorModeValue('gray.500', 'gray.400');
const warningBg = useColorModeValue('orange.50', 'orange.900');
const errorBg = useColorModeValue('red.50', 'red.900');
// 格式化货币
const formatCurrency = (amount) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2
}).format(amount);
};
// 计算融资融券相关数据
const calculateMarginData = () => {
if (!account) return {};
// 模拟融资融券额度(通常为账户资产的一定比例)
const marginCreditLimit = account.totalAssets * 1.0; // 1倍杠杆
const shortCreditLimit = account.totalAssets * 0.5; // 0.5倍融券额度
// 已使用额度
const usedMarginCredit = account.marginBalance || 0;
const usedShortCredit = account.shortBalance || 0;
// 可用额度
const availableMarginCredit = marginCreditLimit - usedMarginCredit;
const availableShortCredit = shortCreditLimit - usedShortCredit;
// 使用率
const marginUsageRate = marginCreditLimit > 0 ? (usedMarginCredit / marginCreditLimit) * 100 : 0;
const shortUsageRate = shortCreditLimit > 0 ? (usedShortCredit / shortCreditLimit) * 100 : 0;
// 风险等级
const riskLevel = marginUsageRate > 80 ? 'HIGH' : marginUsageRate > 50 ? 'MEDIUM' : 'LOW';
return {
marginCreditLimit,
shortCreditLimit,
usedMarginCredit,
usedShortCredit,
availableMarginCredit,
availableShortCredit,
marginUsageRate,
shortUsageRate,
riskLevel
};
};
const marginData = calculateMarginData();
// 获取风险等级颜色
const getRiskColor = (level) => {
switch (level) {
case 'HIGH': return 'red';
case 'MEDIUM': return 'orange';
case 'LOW': return 'green';
default: return 'gray';
}
};
// 获取风险等级文本
const getRiskText = (level) => {
switch (level) {
case 'HIGH': return '高风险';
case 'MEDIUM': return '中风险';
case 'LOW': return '低风险';
default: return '未知';
}
};
if (!account) {
return (
<Alert status="warning">
<AlertIcon />
<AlertTitle>账户未初始化</AlertTitle>
<AlertDescription>请先初始化交易账户</AlertDescription>
</Alert>
);
}
return (
<VStack spacing={6} align="stretch">
{/* 重要风险提示 */}
<Alert status="warning" variant="left-accent">
<AlertIcon />
<Box>
<AlertTitle>风险提示</AlertTitle>
<AlertDescription>
融资融券交易具有杠杆效应可能放大收益和亏损请充分了解相关风险谨慎操作
本模拟系统仅供学习使用实际交易请咨询专业投资顾问
</AlertDescription>
</Box>
</Alert>
{/* 账户信用状况 */}
<Card bg={cardBg} shadow="lg">
<CardHeader>
<HStack justify="space-between">
<Heading size="md">信用账户状况</Heading>
<Badge
colorScheme={getRiskColor(marginData.riskLevel)}
variant="solid"
size="lg"
>
{getRiskText(marginData.riskLevel)}
</Badge>
</HStack>
</CardHeader>
<CardBody>
<Grid templateColumns="repeat(12, 1fr)" gap={6}>
{/* 融资信息 */}
<GridItem colSpan={{ base: 12, md: 6 }}>
<Card bg={warningBg} border="1px solid" borderColor="orange.200">
<CardHeader pb={2}>
<HStack>
<Icon as={FiTrendingUp} color="orange.500" />
<Heading size="sm">融资信息</Heading>
</HStack>
</CardHeader>
<CardBody pt={2}>
<VStack spacing={3} align="stretch">
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>融资授信额度</Text>
<Text fontSize="sm" fontWeight="medium">
{formatCurrency(marginData.marginCreditLimit)}
</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>已使用融资</Text>
<Text fontSize="sm" fontWeight="medium" color="orange.600">
{formatCurrency(marginData.usedMarginCredit)}
</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>可用融资</Text>
<Text fontSize="sm" fontWeight="medium" color="green.600">
{formatCurrency(marginData.availableMarginCredit)}
</Text>
</HStack>
<Box>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color={secondaryColor}>使用率</Text>
<Text fontSize="xs" fontWeight="medium">
{(marginData.marginUsageRate || 0).toFixed(1)}%
</Text>
</HStack>
<Progress
value={marginData.marginUsageRate}
colorScheme={getRiskColor(marginData.riskLevel)}
size="sm"
borderRadius="full"
/>
</Box>
</VStack>
</CardBody>
</Card>
</GridItem>
{/* 融券信息 */}
<GridItem colSpan={{ base: 12, md: 6 }}>
<Card bg={errorBg} border="1px solid" borderColor="red.200">
<CardHeader pb={2}>
<HStack>
<Icon as={FiTrendingDown} color="red.500" />
<Heading size="sm">融券信息</Heading>
</HStack>
</CardHeader>
<CardBody pt={2}>
<VStack spacing={3} align="stretch">
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>融券授信额度</Text>
<Text fontSize="sm" fontWeight="medium">
{formatCurrency(marginData.shortCreditLimit)}
</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>已使用融券</Text>
<Text fontSize="sm" fontWeight="medium" color="red.600">
{formatCurrency(marginData.usedShortCredit)}
</Text>
</HStack>
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>可用融券</Text>
<Text fontSize="sm" fontWeight="medium" color="green.600">
{formatCurrency(marginData.availableShortCredit)}
</Text>
</HStack>
<Box>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color={secondaryColor}>使用率</Text>
<Text fontSize="xs" fontWeight="medium">
{(marginData.shortUsageRate || 0).toFixed(1)}%
</Text>
</HStack>
<Progress
value={marginData.shortUsageRate}
colorScheme={getRiskColor(marginData.riskLevel)}
size="sm"
borderRadius="full"
/>
</Box>
</VStack>
</CardBody>
</Card>
</GridItem>
</Grid>
</CardBody>
</Card>
{/* 融资融券交易区域 */}
<Card bg={cardBg} shadow="lg">
<CardHeader>
<Heading size="md">融资融券交易</Heading>
</CardHeader>
<CardBody>
<Tabs index={activeTab} onChange={setActiveTab} colorScheme="blue">
<TabList>
<Tab color="orange.500" _selected={{ color: 'orange.600', borderColor: 'orange.500' }}>
<HStack>
<Icon as={FiTrendingUp} />
<Text>融资买入</Text>
</HStack>
</Tab>
<Tab color="red.500" _selected={{ color: 'red.600', borderColor: 'red.500' }}>
<HStack>
<Icon as={FiTrendingDown} />
<Text>融券卖出</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
{/* 融资买入面板 */}
<TabPanel px={0}>
<VStack spacing={6} align="stretch">
<Alert status="info">
<AlertIcon />
<Box>
<AlertTitle fontSize="md">融资买入</AlertTitle>
<AlertDescription>
使用券商资金买入股票需要支付融资利息当股价上涨时可以获得放大收益
但股价下跌时亏损也会被放大
</AlertDescription>
</Box>
</Alert>
<Card bg={useColorModeValue('orange.50', 'orange.900')}>
<CardBody>
<Grid templateColumns="repeat(3, 1fr)" gap={4}>
<Stat textAlign="center">
<StatLabel>可用融资额度</StatLabel>
<StatNumber color="orange.600">
{formatCurrency(marginData.availableMarginCredit)}
</StatNumber>
<StatHelpText>
<Icon as={FiDollarSign} mr={1} />
杠杆比例: 1:1
</StatHelpText>
</Stat>
<Stat textAlign="center">
<StatLabel>融资利率</StatLabel>
<StatNumber color="orange.600">8.35%</StatNumber>
<StatHelpText>年化利率</StatHelpText>
</Stat>
<Stat textAlign="center">
<StatLabel>最低维持担保比例</StatLabel>
<StatNumber color="orange.600">130%</StatNumber>
<StatHelpText>低于此比例将被强制平仓</StatHelpText>
</Stat>
</Grid>
</CardBody>
</Card>
<Alert status="warning" variant="subtle">
<AlertIcon />
<AlertDescription>
<strong>注意</strong>
并签署相关协议请在"交易面板"中进行普通股票交易
</AlertDescription>
</Alert>
<Button
colorScheme="orange"
size="lg"
isDisabled={true}
leftIcon={<Icon as={FiAlertTriangle} />}
>
融资买入功能演示版本暂不可用
</Button>
</VStack>
</TabPanel>
{/* 融券卖出面板 */}
<TabPanel px={0}>
<VStack spacing={6} align="stretch">
<Alert status="info">
<AlertIcon />
<Box>
<AlertTitle fontSize="md">融券卖出</AlertTitle>
<AlertDescription>
借入股票后卖出当股价下跌时买入还券获利这是一种做空策略
适合在预期股价下跌时使用但风险较高
</AlertDescription>
</Box>
</Alert>
<Card bg={useColorModeValue('red.50', 'red.900')}>
<CardBody>
<Grid templateColumns="repeat(3, 1fr)" gap={4}>
<Stat textAlign="center">
<StatLabel>可用融券额度</StatLabel>
<StatNumber color="red.600">
{formatCurrency(marginData.availableShortCredit)}
</StatNumber>
<StatHelpText>
<Icon as={FiDollarSign} mr={1} />
杠杆比例: 1:0.5
</StatHelpText>
</Stat>
<Stat textAlign="center">
<StatLabel>融券费率</StatLabel>
<StatNumber color="red.600">10.35%</StatNumber>
<StatHelpText>年化费率</StatHelpText>
</Stat>
<Stat textAlign="center">
<StatLabel>最低维持担保比例</StatLabel>
<StatNumber color="red.600">130%</StatNumber>
<StatHelpText>低于此比例将被强制平仓</StatHelpText>
</Stat>
</Grid>
</CardBody>
</Card>
<Alert status="error" variant="subtle">
<AlertIcon />
<AlertDescription>
<strong>高风险警告</strong>
当股价持续上涨时亏损可能超过本金请谨慎操作
</AlertDescription>
</Alert>
<Alert status="warning" variant="subtle">
<AlertIcon />
<AlertDescription>
<strong>注意</strong>
包括但不限于资产规模投资经验等要求
</AlertDescription>
</Alert>
<Button
colorScheme="red"
size="lg"
isDisabled={true}
leftIcon={<Icon as={FiAlertTriangle} />}
>
融券卖出功能演示版本暂不可用
</Button>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
{/* 相关说明和风险提示 */}
<Card bg={cardBg} shadow="lg">
<CardHeader>
<HStack>
<Icon as={FiInfo} color="blue.500" />
<Heading size="md">融资融券知识</Heading>
</HStack>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
<Box>
<Heading size="sm" mb={2} color="blue.600">什么是融资融券</Heading>
<Text fontSize="sm" color={secondaryColor} lineHeight="tall">
融资融券是指投资者向证券公司借入资金买入股票融资或借入股票卖出融券的交易行为
它为投资者提供了杠杆工具可以放大投资收益但同时也放大了投资风险
</Text>
</Box>
<Divider />
<Box>
<Heading size="sm" mb={2} color="orange.600">融资交易</Heading>
<Text fontSize="sm" color={secondaryColor} lineHeight="tall">
<strong>原理</strong>向券商借钱买股票,看涨时使用<br/>
<strong>收益</strong>股价上涨时获得放大收益<br/>
<strong>风险</strong>股价下跌时亏损放大,需支付融资利息<br/>
<strong>强平</strong>130%
</Text>
</Box>
<Divider />
<Box>
<Heading size="sm" mb={2} color="red.600">融券交易</Heading>
<Text fontSize="sm" color={secondaryColor} lineHeight="tall">
<strong>原理</strong>向券商借股票来卖,看跌时使用<br/>
<strong>收益</strong>股价下跌时低价买入还券获利<br/>
<strong>风险</strong>股价上涨时亏损可能无限大,需支付融券费用<br/>
<strong>强平</strong>130%
</Text>
</Box>
<Alert status="error" variant="left-accent">
<AlertIcon />
<Box>
<AlertTitle fontSize="sm">重要提醒</AlertTitle>
<AlertDescription fontSize="sm">
融资融券交易风险极高可能导致超出本金的亏损投资者应当充分了解相关风险
具备相应的风险承受能力并在专业人士指导下进行操作
</AlertDescription>
</Box>
</Alert>
</VStack>
</CardBody>
</Card>
</VStack>
);
}

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>
</>
);
}

View File

@@ -0,0 +1,393 @@
// src/views/TradingSimulation/components/TradingHistory.js - 交易历史组件
import React, { useState, useMemo } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Heading,
Text,
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
HStack,
VStack,
Select,
Input,
InputGroup,
InputLeftElement,
Flex,
Spacer,
Icon,
useColorModeValue,
Alert,
AlertIcon,
AlertDescription,
Tooltip,
useToast
} from '@chakra-ui/react';
import { FiSearch, FiFilter, FiClock, FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
export default function TradingHistory({ history, onCancelOrder }) {
const [filterType, setFilterType] = useState('ALL'); // ALL, BUY, SELL
const [filterStatus, setFilterStatus] = useState('ALL'); // ALL, FILLED, PENDING, CANCELLED
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('createdAt'); // createdAt, stockCode, amount
const [sortOrder, setSortOrder] = useState('desc'); // desc, asc
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 formatDateTime = (dateString) => {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
// 过滤和排序历史记录
const filteredAndSortedHistory = useMemo(() => {
let filtered = [...(history || [])];
// 按类型过滤
if (filterType !== 'ALL') {
filtered = filtered.filter(trade => trade.type === filterType);
}
// 按状态过滤
if (filterStatus !== 'ALL') {
filtered = filtered.filter(trade => trade.status === filterStatus);
}
// 按搜索词过滤
if (searchTerm) {
filtered = filtered.filter(trade =>
trade.stockCode.includes(searchTerm) ||
trade.stockName.toLowerCase().includes(searchTerm.toLowerCase()) ||
trade.orderId.includes(searchTerm)
);
}
// 排序
filtered.sort((a, b) => {
let aValue, bValue;
switch (sortBy) {
case 'stockCode':
aValue = a.stockCode;
bValue = b.stockCode;
break;
case 'amount':
aValue = a.totalAmount;
bValue = b.totalAmount;
break;
default:
aValue = new Date(a.createdAt);
bValue = new Date(b.createdAt);
}
if (sortOrder === 'desc') {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
} else {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
}
});
return filtered;
}, [history, filterType, filterStatus, searchTerm, sortBy, sortOrder]);
// 获取状态样式
const getStatusBadge = (status) => {
switch (status) {
case 'FILLED':
return <Badge colorScheme="green" variant="solid">已成交</Badge>;
case 'PENDING':
return <Badge colorScheme="yellow" variant="solid">待成交</Badge>;
case 'CANCELLED':
return <Badge colorScheme="gray" variant="solid">已撤销</Badge>;
default:
return <Badge colorScheme="gray">未知</Badge>;
}
};
// 获取交易类型样式
const getTypeBadge = (type) => {
switch (type) {
case 'BUY':
return <Badge colorScheme="green" variant="outline">买入</Badge>;
case 'SELL':
return <Badge colorScheme="red" variant="outline">卖出</Badge>;
default:
return <Badge colorScheme="gray" variant="outline">未知</Badge>;
}
};
// 撤销订单
const handleCancelOrder = async (orderId) => {
try {
await onCancelOrder(orderId);
toast({
title: '撤单成功',
description: `订单 ${orderId} 已撤销`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error) {
toast({
title: '撤单失败',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
// 计算统计数据
const stats = useMemo(() => {
const filled = history?.filter(t => t.status === 'FILLED') || [];
const totalTrades = filled.length;
const buyTrades = filled.filter(t => t.type === 'BUY').length;
const sellTrades = filled.filter(t => t.type === 'SELL').length;
const totalVolume = filled.reduce((sum, t) => sum + t.totalAmount, 0);
return { totalTrades, buyTrades, sellTrades, totalVolume };
}, [history]);
if (!history || history.length === 0) {
return (
<Card bg={cardBg} shadow="lg">
<CardBody>
<VStack spacing={4} py={8}>
<Icon as={FiClock} size="48px" color={secondaryColor} />
<Text color={secondaryColor} fontSize="lg">暂无交易记录</Text>
<Text color={secondaryColor} fontSize="sm">
完成首笔交易后历史记录将在这里显示
</Text>
</VStack>
</CardBody>
</Card>
);
}
return (
<VStack spacing={6} align="stretch">
{/* 交易统计 */}
<Card bg={cardBg} shadow="lg">
<CardHeader>
<Heading size="md">交易统计</Heading>
</CardHeader>
<CardBody>
<HStack spacing={8} justify="space-around">
<VStack>
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
{stats.totalTrades}
</Text>
<Text fontSize="sm" color={secondaryColor}>总交易次数</Text>
</VStack>
<VStack>
<Text fontSize="2xl" fontWeight="bold" color="green.500">
{stats.buyTrades}
</Text>
<Text fontSize="sm" color={secondaryColor}>买入次数</Text>
</VStack>
<VStack>
<Text fontSize="2xl" fontWeight="bold" color="red.500">
{stats.sellTrades}
</Text>
<Text fontSize="sm" color={secondaryColor}>卖出次数</Text>
</VStack>
<VStack>
<Text fontSize="2xl" fontWeight="bold" color="purple.500">
{formatCurrency(stats.totalVolume)}
</Text>
<Text fontSize="sm" color={secondaryColor}>累计成交额</Text>
</VStack>
</HStack>
</CardBody>
</Card>
{/* 过滤和搜索 */}
<Card bg={cardBg} shadow="lg">
<CardBody>
<Flex direction={{ base: 'column', md: 'row' }} gap={4} align="end">
<Box flex={1}>
<Text fontSize="sm" color={secondaryColor} mb={2}>搜索</Text>
<InputGroup>
<InputLeftElement>
<Icon as={FiSearch} color={secondaryColor} />
</InputLeftElement>
<Input
placeholder="搜索股票代码、名称或订单号..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</InputGroup>
</Box>
<Box minW="120px">
<Text fontSize="sm" color={secondaryColor} mb={2}>交易类型</Text>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)}>
<option value="ALL">全部</option>
<option value="BUY">买入</option>
<option value="SELL">卖出</option>
</Select>
</Box>
<Box minW="120px">
<Text fontSize="sm" color={secondaryColor} mb={2}>订单状态</Text>
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
<option value="ALL">全部</option>
<option value="FILLED">已成交</option>
<option value="PENDING">待成交</option>
<option value="CANCELLED">已撤销</option>
</Select>
</Box>
<Box minW="120px">
<Text fontSize="sm" color={secondaryColor} mb={2}>排序方式</Text>
<Select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [field, order] = e.target.value.split('-');
setSortBy(field);
setSortOrder(order);
}}
>
<option value="createdAt-desc">时间 </option>
<option value="createdAt-asc">时间 </option>
<option value="stockCode-asc">股票代码 </option>
<option value="amount-desc">金额 </option>
<option value="amount-asc">金额 </option>
</Select>
</Box>
</Flex>
</CardBody>
</Card>
{/* 交易历史列表 */}
<Card bg={cardBg} shadow="lg">
<CardHeader>
<HStack justify="space-between">
<Heading size="md">交易历史</Heading>
<Badge colorScheme="blue" variant="solid">
{filteredAndSortedHistory.length} 条记录
</Badge>
</HStack>
</CardHeader>
<CardBody>
{filteredAndSortedHistory.length === 0 ? (
<Alert status="info">
<AlertIcon />
<AlertDescription>
没有找到符合条件的交易记录
</AlertDescription>
</Alert>
) : (
<Box overflowX="auto">
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th>订单号</Th>
<Th>交易时间</Th>
<Th>股票</Th>
<Th>类型</Th>
<Th isNumeric>数量</Th>
<Th isNumeric>价格</Th>
<Th isNumeric>成交金额</Th>
<Th isNumeric>手续费</Th>
<Th>状态</Th>
<Th>操作</Th>
</Tr>
</Thead>
<Tbody>
{filteredAndSortedHistory.map((trade) => (
<Tr key={trade.orderId}>
<Td>
<Tooltip label={trade.orderId}>
<Text fontSize="xs" fontFamily="mono">
{trade.orderId.substring(0, 12)}...
</Text>
</Tooltip>
</Td>
<Td>
<Text fontSize="xs">
{formatDateTime(trade.createdAt)}
</Text>
</Td>
<Td>
<VStack spacing={0} align="start">
<Text fontSize="sm" fontWeight="medium">
{trade.stockCode}
</Text>
<Text fontSize="xs" color={secondaryColor}>
{trade.stockName}
</Text>
</VStack>
</Td>
<Td>{getTypeBadge(trade.type)}</Td>
<Td isNumeric>{trade.quantity.toLocaleString()}</Td>
<Td isNumeric>¥{(trade.price || 0).toFixed(2)}</Td>
<Td isNumeric>
<Text color={trade.type === 'BUY' ? 'red.500' : 'green.500'}>
{formatCurrency(trade.totalAmount)}
</Text>
</Td>
<Td isNumeric>
<VStack spacing={0} align="end">
<Text fontSize="sm">
{formatCurrency(trade.commission)}
</Text>
{trade.stampTax && (
<Text fontSize="xs" color={secondaryColor}>
印花税: {formatCurrency(trade.stampTax)}
</Text>
)}
</VStack>
</Td>
<Td>{getStatusBadge(trade.status)}</Td>
<Td>
{trade.status === 'PENDING' && (
<Button
size="xs"
colorScheme="red"
variant="outline"
onClick={() => handleCancelOrder(trade.orderId)}
>
撤单
</Button>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
)}
</CardBody>
</Card>
</VStack>
);
}

View File

@@ -0,0 +1,536 @@
// src/views/TradingSimulation/components/TradingPanel.js - 交易面板组件(现代化版本)
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Heading,
Text,
Input,
Button,
Select,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
FormControl,
FormLabel,
FormErrorMessage,
VStack,
HStack,
Grid,
GridItem,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Badge,
Alert,
AlertIcon,
AlertDescription,
Stat,
StatLabel,
StatNumber,
StatHelpText,
useToast,
useColorModeValue,
Spinner,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Icon,
InputGroup,
InputLeftElement,
Flex
} from '@chakra-ui/react';
import { FiSearch, FiTrendingUp, FiTrendingDown, FiDollarSign, FiZap, FiTarget } from 'react-icons/fi';
// 导入现有的高质量组件
import IconBox from '../../../components/Icons/IconBox';
export default function TradingPanel({ account, onBuyStock, onSellStock, searchStocks }) {
const [activeTab, setActiveTab] = useState(0); // 0: 买入, 1: 卖出
const [searchTerm, setSearchTerm] = useState('');
const [selectedStock, setSelectedStock] = useState(null);
const [quantity, setQuantity] = useState(100);
const [orderType, setOrderType] = useState('MARKET');
const [limitPrice, setLimitPrice] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({});
const [filteredStocks, setFilteredStocks] = useState([]);
const [showStockList, setShowStockList] = useState(false);
const toast = useToast();
const searchInputRef = useRef();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textColor = useColorModeValue('gray.700', 'white');
const secondaryColor = useColorModeValue('gray.500', 'gray.400');
const headerBgGradient = useColorModeValue('linear(to-r, blue.500, purple.600)', 'linear(to-r, blue.500, purple.600)');
const stockRowHoverBg = useColorModeValue('gray.50', 'gray.700');
const selectedInfoBg = useColorModeValue('blue.50', 'blue.900');
const calcCardBg = useColorModeValue('gray.50', 'gray.700');
// 搜索股票
useEffect(() => {
const searchDebounced = setTimeout(async () => {
if (searchTerm.length >= 2) {
try {
const results = await searchStocks(searchTerm);
// 转换为组件需要的格式
const formattedResults = results.map(stock => [
stock.stock_code,
{
name: stock.stock_name,
price: stock.current_price || 0, // 使用后端返回的真实价格
change: 0, // 暂时设为0可以后续计算
changePercent: 0
}
]);
setFilteredStocks(formattedResults);
setShowStockList(true);
} catch (error) {
console.error('搜索股票失败:', error);
setFilteredStocks([]);
setShowStockList(false);
}
} else {
setFilteredStocks([]);
setShowStockList(false);
}
}, 300); // 300ms 防抖
return () => clearTimeout(searchDebounced);
}, [searchTerm, searchStocks]);
// 选择股票
const handleSelectStock = (code, stock) => {
setSelectedStock({ code, ...stock });
setSearchTerm(`${code} ${stock.name}`);
setShowStockList(false);
setLimitPrice(stock.price.toString());
};
// 清除选择
const clearSelection = () => {
setSelectedStock(null);
setSearchTerm('');
setShowStockList(false);
setLimitPrice('');
setErrors({});
};
// 验证表单
const validateForm = () => {
const newErrors = {};
if (!selectedStock) {
newErrors.stock = '请选择股票';
}
if (!quantity || quantity <= 0) {
newErrors.quantity = '请输入有效的买入数量';
}
if (quantity % 100 !== 0) {
newErrors.quantity = '股票数量必须是100的倍数';
}
if (orderType === 'LIMIT' && (!limitPrice || parseFloat(limitPrice) <= 0)) {
newErrors.limitPrice = '请输入有效的限价';
}
// 买入资金检查
if (activeTab === 0 && selectedStock) {
const price = orderType === 'LIMIT' ? parseFloat(limitPrice) : selectedStock.price;
const totalCost = price * quantity;
const commission = Math.max(totalCost * 0.0003, 5);
const totalAmount = totalCost + commission;
if (totalAmount > account.availableCash) {
newErrors.funds = '可用资金不足';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 执行交易
const handleTrade = async () => {
if (!validateForm()) return;
setIsLoading(true);
try {
let result;
if (activeTab === 0) {
// 买入
result = await onBuyStock(
selectedStock.code,
quantity,
orderType,
orderType === 'LIMIT' ? parseFloat(limitPrice) : null
);
} else {
// 卖出
result = await onSellStock(
selectedStock.code,
quantity,
orderType,
orderType === 'LIMIT' ? parseFloat(limitPrice) : null
);
}
if (result.success) {
toast({
title: activeTab === 0 ? '买入成功' : '卖出成功',
description: `订单号: ${result.orderId}`,
status: 'success',
duration: 3000,
isClosable: true,
});
// 重置表单
setQuantity(100);
clearSelection();
}
} catch (error) {
toast({
title: activeTab === 0 ? '买入失败' : '卖出失败',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
// 计算交易金额
const calculateTradeAmount = () => {
if (!selectedStock || !quantity) return { totalCost: 0, commission: 0, totalAmount: 0 };
const price = orderType === 'LIMIT' ? parseFloat(limitPrice) || 0 : selectedStock.price;
const totalCost = price * quantity;
const commission = Math.max(totalCost * 0.0003, 5);
const stampTax = activeTab === 1 ? totalCost * 0.001 : 0; // 卖出印花税
const totalAmount = activeTab === 0
? totalCost + commission
: totalCost - commission - stampTax;
return { totalCost, commission, stampTax, totalAmount };
};
const { totalCost, commission, stampTax, totalAmount } = calculateTradeAmount();
// 格式化货币
const formatCurrency = (amount) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2
}).format(amount);
};
return (
<Card shadow="2xl" borderRadius="2xl" overflow="hidden">
{/* 现代化标题栏 */}
<Box
bgGradient={headerBgGradient}
color="white"
p={6}
>
<HStack justify="space-between">
<HStack spacing={3}>
<IconBox
as="div"
h={"40px"}
w={"40px"}
bg="whiteAlpha.200"
icon={<Icon as={FiZap} color="white" w="20px" h="20px" />}
/>
<VStack align="start" spacing={0}>
<Heading size="md">智能交易面板</Heading>
<Text fontSize="sm" opacity={0.8}>快速下单实时成交</Text>
</VStack>
</HStack>
<Badge colorScheme="whiteAlpha" variant="solid" borderRadius="full">
7×24小时
</Badge>
</HStack>
</Box>
<CardBody p={6}>
<Tabs index={activeTab} onChange={setActiveTab} colorScheme="blue">
<TabList>
<Tab color="green.500" _selected={{ color: 'green.600', borderColor: 'green.500' }}>
买入
</Tab>
<Tab color="red.500" _selected={{ color: 'red.600', borderColor: 'red.500' }}>
卖出
</Tab>
</TabList>
<TabPanels>
<TabPanel px={0}>
<VStack spacing={6} align="stretch">
{/* 现代化股票搜索 */}
<FormControl isInvalid={errors.stock}>
<FormLabel fontWeight="bold" color={textColor}>选择股票</FormLabel>
<Box position="relative">
<InputGroup size="lg">
<InputLeftElement>
<Icon as={FiSearch} color="gray.400" />
</InputLeftElement>
<Input
ref={searchInputRef}
placeholder="输入股票代码或名称搜索..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
borderRadius="xl"
border="2px solid"
borderColor="gray.200"
_focus={{
borderColor: "blue.400",
boxShadow: "0 0 0 3px rgba(66, 153, 225, 0.1)"
}}
_hover={{
borderColor: "gray.300"
}}
/>
</InputGroup>
{showStockList && filteredStocks.length > 0 && (
<Box
position="absolute"
top="100%"
left={0}
right={0}
bg={cardBg}
border="1px solid"
borderColor={borderColor}
borderRadius="md"
shadow="lg"
zIndex={10}
maxH="300px"
overflowY="auto"
>
<Table size="sm">
<Thead>
<Tr>
<Th>代码</Th>
<Th>名称</Th>
<Th isNumeric>价格</Th>
<Th isNumeric>涨跌</Th>
</Tr>
</Thead>
<Tbody>
{filteredStocks.map(([code, stock]) => (
<Tr
key={code}
cursor="pointer"
_hover={{ bg: stockRowHoverBg }}
onClick={() => handleSelectStock(code, stock)}
>
<Td fontWeight="medium">{code}</Td>
<Td>{stock.name}</Td>
<Td isNumeric>{(stock.price || 0).toFixed(2)}</Td>
<Td isNumeric>
<Text color={(stock.change || 0) >= 0 ? 'green.500' : 'red.500'}>
{(stock.change || 0) >= 0 ? '+' : ''}{(stock.change || 0).toFixed(2)}
({(stock.changePercent || 0).toFixed(2)}%)
</Text>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
)}
</Box>
{errors.stock && <FormErrorMessage>{errors.stock}</FormErrorMessage>}
</FormControl>
{/* 选中股票信息 */}
{selectedStock && (
<Card bg={selectedInfoBg} borderColor="blue.200">
<CardBody py={3}>
<Grid templateColumns="repeat(4, 1fr)" gap={4}>
<Stat>
<StatLabel fontSize="xs">股票代码</StatLabel>
<StatNumber fontSize="md">{selectedStock.code}</StatNumber>
</Stat>
<Stat>
<StatLabel fontSize="xs">股票名称</StatLabel>
<StatNumber fontSize="md">{selectedStock.name}</StatNumber>
</Stat>
<Stat>
<StatLabel fontSize="xs">当前价格</StatLabel>
<StatNumber fontSize="md">¥{(selectedStock.price || 0).toFixed(2)}</StatNumber>
</Stat>
<Stat>
<StatLabel fontSize="xs">涨跌幅</StatLabel>
<StatNumber
fontSize="md"
color={(selectedStock.change || 0) >= 0 ? 'green.500' : 'red.500'}
>
{(selectedStock.change || 0) >= 0 ? '+' : ''}{(selectedStock.changePercent || 0).toFixed(2)}%
</StatNumber>
</Stat>
</Grid>
</CardBody>
</Card>
)}
{/* 交易参数 */}
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
<FormControl isInvalid={errors.quantity}>
<FormLabel>买入数量</FormLabel>
<NumberInput
value={quantity}
onChange={(value) => setQuantity(parseInt(value) || 0)}
min={100}
step={100}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
{errors.quantity && <FormErrorMessage>{errors.quantity}</FormErrorMessage>}
</FormControl>
<FormControl>
<FormLabel>订单类型</FormLabel>
<Select value={orderType} onChange={(e) => setOrderType(e.target.value)}>
<option value="MARKET">市价单</option>
<option value="LIMIT">限价单</option>
</Select>
</FormControl>
</Grid>
{/* 限价输入 */}
{orderType === 'LIMIT' && (
<FormControl isInvalid={errors.limitPrice}>
<FormLabel>限价价格</FormLabel>
<NumberInput
value={limitPrice}
onChange={(value) => setLimitPrice(value)}
precision={2}
min={0}
>
<NumberInputField />
</NumberInput>
{errors.limitPrice && <FormErrorMessage>{errors.limitPrice}</FormErrorMessage>}
</FormControl>
)}
{/* 交易金额计算 */}
{selectedStock && quantity > 0 && (
<Card bg={calcCardBg}>
<CardBody py={3}>
<VStack spacing={2} align="stretch">
<HStack justify="space-between">
<Text fontSize="sm" color={secondaryColor}>交易金额</Text>
<Text fontSize="sm" fontWeight="medium">{formatCurrency(totalCost)}</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="md" fontWeight="bold" color={textColor}>所需资金</Text>
<Text fontSize="md" fontWeight="bold" color="green.500">
{formatCurrency(totalAmount)}
</Text>
</HStack>
{/* 资金检查 */}
{errors.funds && (
<Alert status="error" size="sm">
<AlertIcon />
<AlertDescription>{errors.funds}</AlertDescription>
</Alert>
)}
</VStack>
</CardBody>
</Card>
)}
{/* 现代化操作按钮 */}
<HStack spacing={4}>
<Button
size="lg"
flex={1}
bgGradient="linear(to-r, green.400, green.600)"
color="white"
borderRadius="xl"
fontWeight="bold"
isLoading={isLoading}
loadingText="买入中..."
onClick={handleTrade}
isDisabled={!selectedStock || !quantity}
_hover={{
bgGradient: "linear(to-r, green.500, green.700)",
transform: "translateY(-2px)",
shadow: "xl"
}}
_active={{
transform: "translateY(0)"
}}
transition="all 0.2s"
leftIcon={<Icon as={FiTarget} />}
>
立即买入
</Button>
<Button
variant="outline"
size="lg"
borderRadius="xl"
borderColor="gray.300"
color="gray.600"
onClick={clearSelection}
isDisabled={isLoading}
_hover={{
bg: "gray.50",
borderColor: "gray.400",
transform: "translateY(-1px)"
}}
transition="all 0.2s"
>
清除
</Button>
</HStack>
</VStack>
</TabPanel>
{/* 卖出面板 - 与买入类似,但需要检查持仓 */}
<TabPanel px={0}>
<VStack spacing={6} align="stretch">
<Text color={secondaryColor} textAlign="center">
卖出功能与买入类似需要检查持仓数量
</Text>
{/* 这里可以复制买入的逻辑,但需要增加持仓检查 */}
<Alert status="info">
<AlertIcon />
<AlertDescription>
卖出功能请在"我的持仓"页面中进行操作
</AlertDescription>
</Alert>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,383 @@
// src/views/TradingSimulation/hooks/useTradingAccount.js - 模拟盘账户管理 Hook
import { useState, useCallback } from 'react';
import { useAuth } from '../../../contexts/AuthContext';
// API 基础URL - 修复HTTPS混合内容问题
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '' // 生产环境使用相对路径通过nginx代理
: (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
// API 请求封装
const apiRequest = async (url, options = {}) => {
const response = await fetch(`${API_BASE_URL}${url}`, {
credentials: 'include', // 包含session cookie
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json();
};
// 数据字段映射函数
const mapAccountData = (backendData) => {
return {
id: backendData.account_id,
accountName: backendData.account_name,
initialCash: backendData.initial_capital,
availableCash: backendData.available_cash,
frozenCash: backendData.frozen_cash,
marketValue: backendData.position_value,
totalAssets: backendData.total_assets,
totalProfit: backendData.total_profit,
totalProfitPercent: backendData.total_profit_rate,
dailyProfit: backendData.daily_profit,
dailyProfitRate: backendData.daily_profit_rate,
riskLevel: 'MEDIUM', // 默认值
marginBalance: 0,
shortBalance: 0,
lastUpdated: backendData.updated_at,
createdAt: backendData.created_at
};
};
const mapPositionData = (backendPositions) => {
return backendPositions.map(pos => ({
id: pos.id,
stockCode: pos.stock_code,
stockName: pos.stock_name,
quantity: pos.position_qty,
availableQuantity: pos.available_qty,
frozenQuantity: pos.frozen_qty,
avgPrice: pos.avg_cost,
currentPrice: pos.current_price,
totalCost: pos.position_qty * pos.avg_cost,
marketValue: pos.market_value,
profit: pos.profit,
profitRate: pos.profit_rate,
todayProfit: pos.today_profit,
todayProfitRate: pos.today_profit_rate,
updatedAt: pos.updated_at
}));
};
const mapOrderData = (backendOrders) => {
return backendOrders.map(order => ({
id: order.id,
orderId: order.order_no,
stockCode: order.stock_code,
stockName: order.stock_name,
type: order.order_type, // 添加 type 字段
orderType: order.order_type,
priceType: order.price_type,
orderPrice: order.order_price,
quantity: order.order_qty,
filledQuantity: order.filled_qty,
price: order.filled_price, // 添加 price 字段
filledPrice: order.filled_price,
totalAmount: order.filled_amount, // 添加 totalAmount 字段
filledAmount: order.filled_amount,
commission: order.commission,
stampTax: order.stamp_tax,
transferFee: order.transfer_fee,
totalFee: order.total_fee,
status: order.status,
rejectReason: order.reject_reason,
createdAt: order.order_time,
filledAt: order.filled_time
}));
};
export function useTradingAccount() {
const { user } = useAuth();
const [account, setAccount] = useState(null);
const [positions, setPositions] = useState([]);
const [tradingHistory, setTradingHistory] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [stockQuotes, setStockQuotes] = useState({});
// 搜索股票
const searchStocks = useCallback(async (keyword) => {
// 调试模式:返回模拟数据
if (!user || user.id === 'demo') {
console.log('🔧 调试模式:模拟股票搜索', keyword);
const mockStocks = [
{
stock_code: '000001',
stock_name: '平安银行',
current_price: 12.50,
pinyin_abbr: 'payh',
security_type: 'A股',
exchange: '深交所'
},
{
stock_code: '600036',
stock_name: '招商银行',
current_price: 42.80,
pinyin_abbr: 'zsyh',
security_type: 'A股',
exchange: '上交所'
},
{
stock_code: '688256',
stock_name: '寒武纪',
current_price: 1394.94,
pinyin_abbr: 'hwj',
security_type: 'A股',
exchange: '上交所科创板'
}
];
return mockStocks.filter(stock =>
stock.stock_code.includes(keyword) ||
stock.stock_name.includes(keyword) ||
stock.pinyin_abbr.includes(keyword.toLowerCase())
);
}
try {
const response = await apiRequest(`/api/stocks/search?q=${encodeURIComponent(keyword)}&limit=10`);
return response.data || [];
} catch (error) {
console.error('搜索股票失败:', error);
return [];
}
}, [user]);
// 刷新账户数据
const refreshAccount = useCallback(async () => {
// 调试模式使用模拟数据因为后端API可能有CORS问题
if (!user || user.id === 'demo') {
console.log('🔧 调试模式:使用模拟账户数据');
setAccount({
id: 'demo',
accountName: '演示账户',
initialCash: 1000000,
availableCash: 950000,
frozenCash: 0,
marketValue: 50000,
totalAssets: 1000000,
totalProfit: 0,
totalProfitPercent: 0,
dailyProfit: 0,
dailyProfitRate: 0,
riskLevel: 'MEDIUM',
marginBalance: 0,
shortBalance: 0,
lastUpdated: new Date().toISOString(),
createdAt: new Date().toISOString()
});
setPositions([]);
setTradingHistory([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
// 获取账户信息
const accountResponse = await apiRequest('/api/simulation/account');
setAccount(mapAccountData(accountResponse.data));
// 获取持仓信息
const positionsResponse = await apiRequest('/api/simulation/positions');
setPositions(mapPositionData(positionsResponse.data || []));
// 获取交易历史
const ordersResponse = await apiRequest('/api/simulation/orders?limit=100');
setTradingHistory(mapOrderData(ordersResponse.data || []));
} catch (err) {
console.error('刷新账户数据失败:', err);
setError('加载账户数据失败: ' + err.message);
} finally {
setIsLoading(false);
}
}, [user]);
// 买入股票
const buyStock = useCallback(async (stockCode, quantity, orderType = 'MARKET', limitPrice = null) => {
if (!account) {
throw new Error('账户未初始化');
}
// 调试模式:模拟买入成功
if (!user || user.id === 'demo') {
console.log('🔧 调试模式:模拟买入', { stockCode, quantity, orderType });
return { success: true, orderId: 'demo_' + Date.now() };
}
setIsLoading(true);
try {
const requestData = {
stock_code: stockCode,
order_type: 'BUY',
order_qty: quantity,
price_type: orderType
};
const response = await apiRequest('/api/simulation/place-order', {
method: 'POST',
body: JSON.stringify(requestData)
});
// 刷新账户数据
await refreshAccount();
return { success: true, orderId: response.data.order_no };
} catch (err) {
throw new Error('买入失败: ' + err.message);
} finally {
setIsLoading(false);
}
}, [account, user, refreshAccount]);
// 卖出股票
const sellStock = useCallback(async (stockCode, quantity, orderType = 'MARKET', limitPrice = null) => {
if (!account) {
throw new Error('账户未初始化');
}
// 调试模式:模拟卖出成功
if (!user || user.id === 'demo') {
console.log('🔧 调试模式:模拟卖出', { stockCode, quantity, orderType });
return { success: true, orderId: 'demo_' + Date.now() };
}
setIsLoading(true);
try {
const requestData = {
stock_code: stockCode,
order_type: 'SELL',
order_qty: quantity,
price_type: orderType
};
const response = await apiRequest('/api/simulation/place-order', {
method: 'POST',
body: JSON.stringify(requestData)
});
// 刷新账户数据
await refreshAccount();
return { success: true, orderId: response.data.order_no };
} catch (err) {
throw new Error('卖出失败: ' + err.message);
} finally {
setIsLoading(false);
}
}, [account, user, refreshAccount]);
// 撤销订单
const cancelOrder = useCallback(async (orderId) => {
setIsLoading(true);
try {
const response = await apiRequest(`/api/simulation/cancel-order/${orderId}`, {
method: 'POST'
});
// 刷新交易历史
const ordersResponse = await apiRequest('/api/simulation/orders?limit=100');
setTradingHistory(mapOrderData(ordersResponse.data || []));
return { success: true };
} catch (err) {
throw new Error('撤单失败: ' + err.message);
} finally {
setIsLoading(false);
}
}, []);
// 获取交易记录
const getTransactions = useCallback(async (options = {}) => {
try {
const params = new URLSearchParams();
if (options.limit) params.append('limit', options.limit);
if (options.date) params.append('date', options.date);
const response = await apiRequest(`/api/simulation/transactions?${params.toString()}`);
return response.data || [];
} catch (error) {
console.error('获取交易记录失败:', error);
return [];
}
}, []);
// 获取资产历史
const getAssetHistory = useCallback(async (days = 30) => {
// 调试模式demo用户返回模拟数据避免CORS
if (!user || user.id === 'demo') {
const now = Date.now();
const data = Array.from({ length: days }, (_, i) => {
const date = new Date(now - (days - 1 - i) * 24 * 3600 * 1000);
// 简单生成一条平滑的收益曲线
const value = Math.sin(i / 5) * 0.01 + 0.001 * i;
return { date: date.toISOString().slice(0, 10), value };
});
return data;
}
try {
const response = await apiRequest(`/api/simulation/statistics?days=${days}`);
return response.data?.daily_returns || [];
} catch (error) {
console.error('获取资产历史失败:', error);
return [];
}
}, [user]);
// 获取股票实时行情(如果需要的话)
const getStockQuotes = useCallback(async (stockCodes) => {
try {
// 这里可以调用自选股实时行情接口
const response = await apiRequest('/api/account/watchlist/quotes');
if (response.success) {
const quotes = {};
response.data.forEach(item => {
quotes[item.stock_code] = {
name: item.stock_name,
price: item.current_price,
change: item.change,
changePercent: item.change_percent
};
});
setStockQuotes(quotes);
return quotes;
}
return {};
} catch (error) {
console.error('获取股票行情失败:', error);
return {};
}
}, []);
return {
account,
positions,
tradingHistory,
isLoading,
error,
stockQuotes,
buyStock,
sellStock,
cancelOrder,
refreshAccount,
searchStocks,
getTransactions,
getAssetHistory,
getStockQuotes
};
}

View File

@@ -0,0 +1,394 @@
// src/views/TradingSimulation/index.js - 模拟盘系统主页面
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Flex,
Grid,
GridItem,
Tab,
Tabs,
TabList,
TabPanels,
TabPanel,
Card,
CardHeader,
CardBody,
Heading,
Text,
Button,
Badge,
VStack,
HStack,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
useColorModeValue,
Spinner,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Link,
} from '@chakra-ui/react';
import { useAuth } from '../../contexts/AuthContext';
// 导入子组件
import AccountOverview from './components/AccountOverview';
import TradingPanel from './components/TradingPanel';
import PositionsList from './components/PositionsList';
import TradingHistory from './components/TradingHistory';
import MarginTrading from './components/MarginTrading';
// 导入现有的高质量组件
import LineChart from '../../components/Charts/LineChart';
// 导入导航栏组件
import HomeNavbar from '../../components/Navbars/HomeNavbar';
// 模拟盘账户管理 Hook
import { useTradingAccount } from './hooks/useTradingAccount';
export default function TradingSimulation() {
// ========== 1. 所有 Hooks 必须放在最顶部,不能有任何条件判断 ==========
const { user, isAuthenticated } = useAuth();
const [activeTab, setActiveTab] = useState(0);
const [assetHistory, setAssetHistory] = useState([]); // 移到这里!
// 使用模拟账户管理 Hook
const {
account,
positions,
tradingHistory,
isLoading,
error,
buyStock,
sellStock,
cancelOrder,
refreshAccount,
searchStocks,
getAssetHistory
} = useTradingAccount();
// 所有的 useColorModeValue 也必须在顶部
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const xAxisLabelColor = useColorModeValue('#718096', '#A0AEC0');
const yAxisLabelColor = useColorModeValue('#718096', '#A0AEC0');
const gridBorderColor = useColorModeValue('#E2E8F0', '#4A5568');
// ========== 2. 所有 useEffect 也必须在条件返回之前 ==========
useEffect(() => {
// 调试模式:即使没有登录也加载模拟数据
refreshAccount();
}, [refreshAccount]);
// 调试:观察认证状态变化
useEffect(() => {
try {
console.log('🔍 TradingSimulation mounted. isAuthenticated=', isAuthenticated, 'user=', user);
} catch {}
}, [isAuthenticated, user]);
// 获取资产历史数据的 useEffect
useEffect(() => {
if (account) {
getAssetHistory(30).then(data => {
setAssetHistory(data || []);
}).catch(err => {
console.error('获取资产历史失败:', err);
setAssetHistory([]);
});
}
}, [account, getAssetHistory]);
// ========== 3. 数据处理和计算(不是 Hooks可以放在这里==========
// 准备资产走势图表数据(使用真实数据,安全处理)
const hasAssetData = Array.isArray(assetHistory) && assetHistory.length > 0;
const assetTrendData = hasAssetData ? [{
name: "总资产",
data: assetHistory.map(item => {
// 安全地获取数据避免undefined错误
if (!item) return 0;
return item.closing_assets || item.total_assets || 0;
})
}] : [];
const assetTrendOptions = hasAssetData ? {
chart: {
toolbar: { show: false },
height: 350
},
tooltip: {
theme: "dark",
y: {
formatter: function(val) {
return '¥' + (val || 0).toLocaleString()
}
}
},
dataLabels: {
enabled: false,
},
stroke: {
curve: "smooth",
width: 3
},
xaxis: {
type: "datetime",
categories: assetHistory.map(item => {
// 安全地获取日期
if (!item) return '';
return item.date || '';
}),
labels: {
style: {
colors: xAxisLabelColor,
fontSize: "12px",
},
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
labels: {
style: {
colors: yAxisLabelColor,
fontSize: "12px",
},
formatter: function(val) {
return '¥' + ((val || 0) / 10000).toFixed(1) + 'w'
}
},
},
legend: {
show: false,
},
grid: {
strokeDashArray: 5,
borderColor: gridBorderColor
},
fill: {
type: "gradient",
gradient: {
shade: "light",
type: "vertical",
shadeIntensity: 0.5,
gradientToColors: undefined,
inverseColors: true,
opacityFrom: 0.8,
opacityTo: 0,
stops: [],
},
colors: [account?.totalProfit >= 0 ? "#48BB78" : "#F56565"],
},
colors: [account?.totalProfit >= 0 ? "#48BB78" : "#F56565"],
} : {};
// ========== 4. 现在可以安全地进行条件返回了 ==========
if (isLoading) {
return (
<Container maxW="7xl" py={8}>
<Flex justify="center" align="center" minH="400px">
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" />
<Text>正在加载模拟盘数据...</Text>
</VStack>
</Flex>
</Container>
);
}
if (error) {
return (
<Container maxW="7xl" py={8}>
<Alert status="error">
<AlertIcon />
<AlertTitle>加载失败</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</Container>
);
}
// ========== 5. 主要渲染逻辑 ==========
return (
<Box minH="100vh" bg={bgColor}>
{/* 导航栏 */}
<HomeNavbar />
<Container maxW="7xl" py={8}>
{!isAuthenticated ? (
<Alert status="warning">
<AlertIcon />
<AlertTitle>需要登录</AlertTitle>
<AlertDescription>
请先登录以访问模拟盘系统
</AlertDescription>
</Alert>
) : (
<VStack spacing={8} align="stretch">
{/* 现代化页面标题 */}
<Box textAlign="center" py={6}>
<Heading
size="2xl"
bgGradient="linear(to-r, blue.400, purple.500)"
bgClip="text"
fontWeight="extrabold"
mb={3}
>
智能模拟交易平台
</Heading>
<Text fontSize="xl" color="gray.600" maxW="2xl" mx="auto">
体验真实交易环境提升投资技能零风险练手
</Text>
</Box>
{/* 主要功能区域 - 放在上面 */}
<Tabs
index={activeTab}
onChange={setActiveTab}
variant="soft-rounded"
colorScheme="blue"
size="lg"
>
<TabList bg={cardBg} p={2} borderRadius="xl" shadow="sm">
<Tab fontWeight="bold">💹 交易面板</Tab>
<Tab fontWeight="bold">📊 我的持仓</Tab>
<Tab fontWeight="bold">📋 交易历史</Tab>
<Tab fontWeight="bold">💰 融资融券</Tab>
</TabList>
<TabPanels>
{/* 交易面板 */}
<TabPanel px={0}>
<TradingPanel
account={account}
onBuyStock={buyStock}
onSellStock={sellStock}
searchStocks={searchStocks}
/>
</TabPanel>
{/* 我的持仓 */}
<TabPanel px={0}>
<PositionsList
positions={positions}
account={account}
onSellStock={sellStock}
/>
</TabPanel>
{/* 交易历史 */}
<TabPanel px={0}>
<TradingHistory
history={tradingHistory}
onCancelOrder={cancelOrder}
/>
</TabPanel>
{/* 融资融券 */}
<TabPanel px={0}>
<MarginTrading
account={account}
onMarginBuy={buyStock}
onShortSell={sellStock}
/>
</TabPanel>
</TabPanels>
</Tabs>
{/* 统计数据区域 - 放在下面 */}
<VStack spacing={6} align="stretch">
{/* 账户概览统计 */}
<Box>
<Heading size="lg" mb={4} color={useColorModeValue('gray.700', 'white')}>
📊 账户统计分析
</Heading>
<AccountOverview account={account} />
</Box>
{/* 资产走势图表 - 只在有数据时显示 */}
{hasAssetData && (
<Card>
<CardHeader>
<HStack justify="space-between">
<Text fontSize="lg" fontWeight="bold" color={useColorModeValue('gray.700', 'white')}>
📈 资产走势分析
</Text>
<Badge colorScheme="purple" variant="outline">
{assetHistory.length}
</Badge>
</HStack>
</CardHeader>
<CardBody>
<Box h="350px">
<LineChart
chartData={assetTrendData}
chartOptions={assetTrendOptions}
/>
</Box>
</CardBody>
</Card>
)}
{/* 无数据提示 */}
{!hasAssetData && account && (
<Card>
<CardBody>
<VStack spacing={4} py={8}>
<Text fontSize="lg" color="gray.500">📊 暂无历史数据</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
开始交易后这里将显示您的资产走势图表和详细统计分析
</Text>
</VStack>
</CardBody>
</Card>
)}
</VStack>
{/* 风险提示 */}
<Alert status="info" variant="left-accent">
<AlertIcon />
<Box>
<AlertTitle>风险提示</AlertTitle>
<AlertDescription>
本系统为模拟交易所有数据仅供学习和练习使用不构成实际投资建议
模拟盘起始资金为100万元人民币
</AlertDescription>
</Box>
</Alert>
</VStack>
)}
</Container>
{/* Footer区域 */}
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Container maxW="7xl">
<VStack spacing={2}>
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal
_hover={{ color: 'gray.600' }}
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
</HStack>
</VStack>
</Container>
</Box>
</Box>
);
}