Initial commit

This commit is contained in:
2025-10-11 11:55:25 +08:00
parent 467dad8449
commit 8107dee8d3
2879 changed files with 610575 additions and 0 deletions

View 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;