Initial commit
This commit is contained in:
536
src/views/TradingSimulation/components/TradingPanel.js
Normal file
536
src/views/TradingSimulation/components/TradingPanel.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user