397 lines
13 KiB
JavaScript
397 lines
13 KiB
JavaScript
// 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';
|
||
import { logger } from '../../../utils/logger';
|
||
|
||
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);
|
||
logger.info('TradingHistory', '撤单成功', { orderId });
|
||
toast({
|
||
title: '撤单成功',
|
||
description: `订单 ${orderId} 已撤销`,
|
||
status: 'success',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
} catch (error) {
|
||
logger.error('TradingHistory', 'handleCancelOrder', error, { orderId });
|
||
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>
|
||
);
|
||
}
|