Initial commit
This commit is contained in:
503
src/views/EventDetail/components/RelatedStocks.js
Normal file
503
src/views/EventDetail/components/RelatedStocks.js
Normal file
@@ -0,0 +1,503 @@
|
||||
// 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';
|
||||
|
||||
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);
|
||||
|
||||
console.log('获取股票报价,代码:', codes, '事件时间:', eventTime);
|
||||
|
||||
const response = await stockService.getQuotes(codes, eventTime);
|
||||
console.log('股票报价响应:', response);
|
||||
|
||||
setQuotes(response || {});
|
||||
} catch (err) {
|
||||
console.error('获取股票报价失败:', err);
|
||||
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 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={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}>
|
||||
{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;
|
||||
Reference in New Issue
Block a user