553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
// 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>
|
||
);
|
||
}
|