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

553 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// src/views/TradingSimulation/components/TradingPanel.js - 交易面板组件(现代化版本)
import React, { useState, useRef, useEffect } from 'react';
import { logger } from '../../../utils/logger';
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) {
logger.error('TradingPanel', 'handleStockSearch', error, { searchTerm });
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) {
logger.info('TradingPanel', `${activeTab === 0 ? '买入' : '卖出'}成功`, {
orderId: result.orderId,
stockCode: selectedStock.code,
quantity,
orderType
});
// ✅ 保留交易成功toast关键用户操作反馈
toast({
title: activeTab === 0 ? '买入成功' : '卖出成功',
description: `订单号: ${result.orderId}`,
status: 'success',
duration: 3000,
isClosable: true,
});
// 重置表单
setQuantity(100);
clearSelection();
}
} catch (error) {
logger.error('TradingPanel', `${activeTab === 0 ? '买入' : '卖出'}失败`, error, {
stockCode: selectedStock?.code,
quantity,
orderType
});
// ✅ 保留交易失败toast关键用户操作错误反馈
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>
);
}