536 lines
22 KiB
JavaScript
536 lines
22 KiB
JavaScript
// src/views/EventDetail/components/RelatedStocks.js - 完整修改版本
|
||
|
||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
import {
|
||
Box,
|
||
HStack,
|
||
VStack,
|
||
Text,
|
||
Button,
|
||
Badge,
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalCloseButton,
|
||
ModalBody,
|
||
ModalFooter,
|
||
useDisclosure,
|
||
Skeleton,
|
||
Alert,
|
||
AlertIcon,
|
||
Table,
|
||
Thead,
|
||
Tbody,
|
||
Tr,
|
||
Th,
|
||
Td,
|
||
TableContainer,
|
||
Input,
|
||
Textarea,
|
||
FormControl,
|
||
FormLabel,
|
||
ButtonGroup,
|
||
IconButton,
|
||
Tooltip,
|
||
useColorModeValue,
|
||
CircularProgress,
|
||
CircularProgressLabel,
|
||
Flex,
|
||
Spacer,
|
||
useToast
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
FaPlus,
|
||
FaTrash,
|
||
FaChartLine,
|
||
FaRedo,
|
||
FaSearch
|
||
} from 'react-icons/fa';
|
||
import * as echarts from 'echarts';
|
||
import StockChartModal from '../../../components/StockChart/StockChartModal';
|
||
|
||
import { eventService, stockService } from '../../../services/eventService';
|
||
import { logger } from '../../../utils/logger';
|
||
|
||
const RelatedStocks = ({
|
||
eventId,
|
||
eventTime, // 新增:从父组件传递事件时间
|
||
stocks = [],
|
||
loading = false,
|
||
error = null,
|
||
onStockAdded = () => {},
|
||
onStockDeleted = () => {}
|
||
}) => {
|
||
// ==================== 状态管理 ====================
|
||
const [stocksData, setStocksData] = useState(stocks);
|
||
const [quotes, setQuotes] = useState({});
|
||
const [quotesLoading, setQuotesLoading] = useState(false);
|
||
const [selectedStock, setSelectedStock] = useState(null);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [sortField, setSortField] = useState('correlation');
|
||
const [sortOrder, setSortOrder] = useState('desc');
|
||
|
||
// 模态框状态
|
||
const {
|
||
isOpen: isChartModalOpen,
|
||
onOpen: onChartModalOpen,
|
||
onClose: onChartModalClose
|
||
} = useDisclosure();
|
||
|
||
|
||
// 主题和工具
|
||
const toast = useToast();
|
||
const tableBg = useColorModeValue('white', 'gray.800');
|
||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||
|
||
// ==================== 副作用处理 ====================
|
||
useEffect(() => {
|
||
setStocksData(stocks);
|
||
}, [stocks]);
|
||
|
||
useEffect(() => {
|
||
if (stocksData.length > 0) {
|
||
fetchQuotes();
|
||
}
|
||
}, [stocksData, eventTime]); // 添加 eventTime 依赖
|
||
|
||
// ==================== API 调用函数 ====================
|
||
const fetchQuotes = useCallback(async () => {
|
||
if (stocksData.length === 0) return;
|
||
|
||
try {
|
||
setQuotesLoading(true);
|
||
const codes = stocksData.map(stock => stock.stock_code);
|
||
|
||
logger.debug('RelatedStocks', '获取股票报价', {
|
||
codes,
|
||
eventTime,
|
||
stockCount: codes.length
|
||
});
|
||
|
||
const response = await stockService.getQuotes(codes, eventTime);
|
||
logger.debug('RelatedStocks', '股票报价响应', {
|
||
hasResponse: !!response,
|
||
quotesCount: response ? Object.keys(response).length : 0
|
||
});
|
||
|
||
setQuotes(response || {});
|
||
} catch (err) {
|
||
logger.error('RelatedStocks', 'fetchQuotes', err, {
|
||
stockCount: stocksData.length,
|
||
eventTime
|
||
});
|
||
toast({
|
||
title: '获取股票报价失败',
|
||
description: err.message,
|
||
status: 'error',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
} finally {
|
||
setQuotesLoading(false);
|
||
}
|
||
}, [stocksData, eventTime, toast]);
|
||
|
||
const handleRefreshQuotes = () => {
|
||
fetchQuotes();
|
||
toast({
|
||
title: '正在刷新报价...',
|
||
status: 'info',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
};
|
||
|
||
|
||
const handleDeleteStock = async (stockId, stockCode) => {
|
||
if (!window.confirm(`确定要删除股票 ${stockCode} 吗?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await eventService.deleteRelatedStock(stockId);
|
||
|
||
toast({
|
||
title: '删除成功',
|
||
status: 'success',
|
||
duration: 3000,
|
||
isClosable: true,
|
||
});
|
||
|
||
onStockDeleted();
|
||
} catch (err) {
|
||
toast({
|
||
title: '删除失败',
|
||
description: err.message,
|
||
status: 'error',
|
||
duration: 5000,
|
||
isClosable: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleShowChart = (stock) => {
|
||
setSelectedStock(stock);
|
||
onChartModalOpen();
|
||
};
|
||
|
||
// ==================== 辅助函数 ====================
|
||
const getCorrelationColor = (correlation) => {
|
||
const value = (correlation || 0) * 100;
|
||
if (value >= 80) return 'red';
|
||
if (value >= 60) return 'orange';
|
||
return 'green';
|
||
};
|
||
|
||
const formatPriceChange = (change) => {
|
||
if (!change && change !== 0) return '--';
|
||
const prefix = change > 0 ? '+' : '';
|
||
return `${prefix}${change.toFixed(2)}%`;
|
||
};
|
||
|
||
const getStockName = (stock) => {
|
||
// 优先使用API返回的名称
|
||
const quote = quotes[stock.stock_code];
|
||
if (quote && quote.name) {
|
||
return quote.name;
|
||
}
|
||
|
||
// 其次使用数据库中的名称
|
||
if (stock.stock_name) {
|
||
return stock.stock_name;
|
||
}
|
||
|
||
// 最后使用默认格式
|
||
return `股票${stock.stock_code.split('.')[0]}`;
|
||
};
|
||
|
||
const getRelationDesc = (relationDesc) => {
|
||
// 处理空值
|
||
if (!relationDesc) return '--';
|
||
|
||
// 如果是字符串,直接返回
|
||
if (typeof relationDesc === 'string') {
|
||
return relationDesc;
|
||
}
|
||
|
||
// 如果是对象且包含data数组
|
||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||
const firstItem = relationDesc.data[0];
|
||
if (firstItem) {
|
||
// 优先使用 query_part,其次使用 sentences
|
||
return firstItem.query_part || firstItem.sentences || '--';
|
||
}
|
||
}
|
||
|
||
// 其他情况返回默认值
|
||
return '--';
|
||
};
|
||
|
||
// ==================== 数据处理 ====================
|
||
const filteredAndSortedStocks = stocksData
|
||
.filter(stock => {
|
||
const stockName = getStockName(stock);
|
||
return stock.stock_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
stockName.toLowerCase().includes(searchTerm.toLowerCase());
|
||
})
|
||
.sort((a, b) => {
|
||
let aValue, bValue;
|
||
|
||
switch (sortField) {
|
||
case 'correlation':
|
||
aValue = a.correlation || 0;
|
||
bValue = b.correlation || 0;
|
||
break;
|
||
case 'change':
|
||
aValue = quotes[a.stock_code]?.change || 0;
|
||
bValue = quotes[b.stock_code]?.change || 0;
|
||
break;
|
||
case 'price':
|
||
aValue = quotes[a.stock_code]?.price || 0;
|
||
bValue = quotes[b.stock_code]?.price || 0;
|
||
break;
|
||
default:
|
||
aValue = a[sortField] || '';
|
||
bValue = b[sortField] || '';
|
||
}
|
||
|
||
if (sortOrder === 'asc') {
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
});
|
||
|
||
// ==================== 渲染状态 ====================
|
||
if (loading) {
|
||
return (
|
||
<VStack spacing={4}>
|
||
{[1, 2, 3].map((i) => (
|
||
<HStack key={`skeleton-${i}`} w="100%" spacing={4}>
|
||
<Skeleton height="20px" width="100px" />
|
||
<Skeleton height="20px" width="150px" />
|
||
<Skeleton height="20px" width="80px" />
|
||
<Skeleton height="20px" width="100px" />
|
||
<Skeleton height="20px" width="60px" />
|
||
<Skeleton height="20px" width="120px" />
|
||
</HStack>
|
||
))}
|
||
</VStack>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Alert status="error" borderRadius="lg">
|
||
<AlertIcon />
|
||
加载相关股票失败: {error}
|
||
</Alert>
|
||
);
|
||
}
|
||
|
||
// ==================== 主要渲染 ====================
|
||
return (
|
||
<>
|
||
{/* 工具栏 */}
|
||
<Flex mb={4} align="center" wrap="wrap" gap={4}>
|
||
{/* 搜索框 */}
|
||
<HStack>
|
||
<FaSearch />
|
||
<Input
|
||
placeholder="搜索股票代码或名称..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
size="sm"
|
||
maxW="200px"
|
||
/>
|
||
</HStack>
|
||
|
||
<Spacer />
|
||
|
||
{/* 操作按钮 */}
|
||
<HStack>
|
||
<Tooltip label="刷新报价">
|
||
<IconButton
|
||
icon={<FaRedo />}
|
||
size="sm"
|
||
onClick={handleRefreshQuotes}
|
||
isLoading={quotesLoading}
|
||
aria-label="刷新报价"
|
||
/>
|
||
</Tooltip>
|
||
|
||
</HStack>
|
||
</Flex>
|
||
|
||
{/* 股票表格 */}
|
||
<Box bg={tableBg} borderRadius="lg" overflow="hidden" border="1px solid" borderColor={borderColor}>
|
||
<TableContainer>
|
||
<Table size="sm">
|
||
<Thead>
|
||
<Tr>
|
||
<Th cursor="pointer" onClick={() => {
|
||
if (sortField === 'stock_code') {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField('stock_code');
|
||
setSortOrder('asc');
|
||
}
|
||
}}>
|
||
股票代码 {sortField === 'stock_code' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||
</Th>
|
||
<Th>股票名称</Th>
|
||
<Th>产业链</Th>
|
||
<Th isNumeric cursor="pointer" onClick={() => {
|
||
if (sortField === 'price') {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField('price');
|
||
setSortOrder('desc');
|
||
}
|
||
}}>
|
||
最新价 {sortField === 'price' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||
</Th>
|
||
<Th isNumeric cursor="pointer" onClick={() => {
|
||
if (sortField === 'change') {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField('change');
|
||
setSortOrder('desc');
|
||
}
|
||
}}>
|
||
涨跌幅 {sortField === 'change' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||
</Th>
|
||
<Th textAlign="center" cursor="pointer" onClick={() => {
|
||
if (sortField === 'correlation') {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField('correlation');
|
||
setSortOrder('desc');
|
||
}
|
||
}}>
|
||
相关度 {sortField === 'correlation' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||
</Th>
|
||
<Th>关联描述</Th>
|
||
<Th>操作</Th>
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{filteredAndSortedStocks.length === 0 ? (
|
||
<Tr>
|
||
<Td colSpan={9} textAlign="center" py={8} color="gray.500">
|
||
{searchTerm ? '未找到匹配的股票' : '暂无相关股票数据'}
|
||
</Td>
|
||
</Tr>
|
||
) : (
|
||
filteredAndSortedStocks.map((stock) => {
|
||
const quote = quotes[stock.stock_code] || {};
|
||
const correlation = (stock.correlation || 0) * 100;
|
||
const stockName = getStockName(stock);
|
||
|
||
return (
|
||
<Tr key={stock.id} _hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}>
|
||
{/* 股票代码 */}
|
||
<Td>
|
||
<Button
|
||
variant="link"
|
||
color="blue.500"
|
||
fontFamily="mono"
|
||
fontWeight="bold"
|
||
p={0}
|
||
onClick={() => handleShowChart(stock)}
|
||
>
|
||
{stock.stock_code}
|
||
</Button>
|
||
</Td>
|
||
|
||
{/* 股票名称 */}
|
||
<Td>
|
||
<VStack align="flex-start" spacing={0}>
|
||
<Text fontSize="sm" fontWeight="medium">
|
||
{stockName}
|
||
</Text>
|
||
{quotesLoading && (
|
||
<Text fontSize="xs" color="gray.500">
|
||
加载中...
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
</Td>
|
||
|
||
{/* 产业链 */}
|
||
<Td>
|
||
<Badge size="sm" variant="outline">
|
||
{stock.sector || '未知'}
|
||
</Badge>
|
||
</Td>
|
||
|
||
{/* 最新价 */}
|
||
<Td isNumeric>
|
||
<Text fontSize="sm" fontWeight="medium">
|
||
{quote.price ? `¥${quote.price.toFixed(2)}` : '--'}
|
||
</Text>
|
||
</Td>
|
||
|
||
{/* 涨跌幅 */}
|
||
<Td isNumeric>
|
||
<Badge
|
||
colorScheme={
|
||
!quote.change ? 'gray' :
|
||
quote.change > 0 ? 'red' :
|
||
quote.change < 0 ? 'green' : 'gray'
|
||
}
|
||
size="sm"
|
||
>
|
||
{formatPriceChange(quote.change)}
|
||
</Badge>
|
||
</Td>
|
||
|
||
|
||
{/* 相关度 */}
|
||
<Td textAlign="center">
|
||
<CircularProgress
|
||
value={correlation}
|
||
color={getCorrelationColor(stock.correlation)}
|
||
size="50px"
|
||
>
|
||
<CircularProgressLabel fontSize="xs">
|
||
{Math.round(correlation)}%
|
||
</CircularProgressLabel>
|
||
</CircularProgress>
|
||
</Td>
|
||
|
||
{/* 关联描述 */}
|
||
<Td maxW="200px">
|
||
<Text fontSize="xs" noOfLines={2}>
|
||
{getRelationDesc(stock.relation_desc)}
|
||
</Text>
|
||
</Td>
|
||
|
||
{/* 操作 */}
|
||
<Td>
|
||
<HStack spacing={1}>
|
||
<Tooltip label="股票详情">
|
||
<Button
|
||
size="xs"
|
||
colorScheme="blue"
|
||
variant="solid"
|
||
onClick={() => {
|
||
const stockCode = stock.stock_code.split('.')[0];
|
||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||
}}
|
||
>
|
||
股票详情
|
||
</Button>
|
||
</Tooltip>
|
||
|
||
<Tooltip label="查看K线图">
|
||
<IconButton
|
||
icon={<FaChartLine />}
|
||
size="xs"
|
||
onClick={() => handleShowChart(stock)}
|
||
aria-label="查看K线图"
|
||
/>
|
||
</Tooltip>
|
||
|
||
<Tooltip label="删除">
|
||
<IconButton
|
||
icon={<FaTrash />}
|
||
size="xs"
|
||
colorScheme="red"
|
||
variant="ghost"
|
||
onClick={() => handleDeleteStock(stock.id, stock.stock_code)}
|
||
aria-label="删除股票"
|
||
/>
|
||
</Tooltip>
|
||
</HStack>
|
||
</Td>
|
||
</Tr>
|
||
);
|
||
})
|
||
)}
|
||
</Tbody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Box>
|
||
|
||
|
||
{/* 股票图表模态框 */}
|
||
<StockChartModal
|
||
isOpen={isChartModalOpen}
|
||
onClose={onChartModalClose}
|
||
stock={selectedStock}
|
||
eventTime={eventTime}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// ==================== 子组件 ====================
|
||
|
||
|
||
|
||
// 现在使用统一的StockChartModal组件,无需重复代码
|
||
|
||
export default RelatedStocks; |