Files
vf_react/src/views/EventDetail/components/RelatedStocks.js
2025-11-03 18:11:21 +08:00

536 lines
22 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/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;