// 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 { 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: isAddModalOpen, onOpen: onAddModalOpen, onClose: onAddModalClose } = useDisclosure(); const { isOpen: isChartModalOpen, onOpen: onChartModalOpen, onClose: onChartModalClose } = useDisclosure(); // 添加股票表单状态 const [addStockForm, setAddStockForm] = useState({ stock_code: '', relation_desc: '' }); // 主题和工具 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 handleAddStock = async () => { if (!addStockForm.stock_code.trim() || !addStockForm.relation_desc.trim()) { toast({ title: '请填写完整信息', status: 'warning', duration: 3000, isClosable: true, }); return; } try { await eventService.addRelatedStock(eventId, addStockForm); toast({ title: '添加成功', status: 'success', duration: 3000, isClosable: true, }); // 重置表单 setAddStockForm({ stock_code: '', relation_desc: '' }); onAddModalClose(); onStockAdded(); } catch (err) { toast({ title: '添加失败', description: err.message, status: 'error', duration: 5000, 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 ( {[1, 2, 3].map((i) => ( ))} ); } if (error) { return ( 加载相关股票失败: {error} ); } // ==================== 主要渲染 ==================== return ( <> {/* 工具栏 */} {/* 搜索框 */} setSearchTerm(e.target.value)} size="sm" maxW="200px" /> {/* 操作按钮 */} } size="sm" onClick={handleRefreshQuotes} isLoading={quotesLoading} aria-label="刷新报价" /> {/* 股票表格 */} {filteredAndSortedStocks.length === 0 ? ( ) : ( filteredAndSortedStocks.map((stock) => { const quote = quotes[stock.stock_code] || {}; const correlation = (stock.correlation || 0) * 100; const stockName = getStockName(stock); return ( {/* 股票代码 */} {/* 股票名称 */} {/* 产业链 */} {/* 最新价 */} {/* 涨跌幅 */} {/* 分时图 */} {/* 相关度 */} {/* 关联描述 */} {/* 操作 */} ); }) )}
{ if (sortField === 'stock_code') { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortField('stock_code'); setSortOrder('asc'); } }}> 股票代码 {sortField === 'stock_code' && (sortOrder === 'asc' ? '↑' : '↓')} 股票名称 产业链 { if (sortField === 'price') { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortField('price'); setSortOrder('desc'); } }}> 最新价 {sortField === 'price' && (sortOrder === 'asc' ? '↑' : '↓')} { if (sortField === 'change') { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortField('change'); setSortOrder('desc'); } }}> 涨跌幅 {sortField === 'change' && (sortOrder === 'asc' ? '↑' : '↓')} 分时图 { if (sortField === 'correlation') { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortField('correlation'); setSortOrder('desc'); } }}> 相关度 {sortField === 'correlation' && (sortOrder === 'asc' ? '↑' : '↓')} 关联描述 操作
{searchTerm ? '未找到匹配的股票' : '暂无相关股票数据'}
{stockName} {quotesLoading && ( 加载中... )} {stock.sector || '未知'} {quote.price ? `¥${quote.price.toFixed(2)}` : '--'} 0 ? 'red' : quote.change < 0 ? 'green' : 'gray' } size="sm" > {formatPriceChange(quote.change)} handleShowChart(stock)} /> {Math.round(correlation)}% {stock.relation_desc || '--'} } size="xs" onClick={() => handleShowChart(stock)} aria-label="查看K线图" /> } size="xs" colorScheme="red" variant="ghost" onClick={() => handleDeleteStock(stock.id, stock.stock_code)} aria-label="删除股票" />
{/* 添加股票模态框 */} {/* 股票图表模态框 */} ); }; // ==================== 子组件 ==================== // 迷你分时图组件 const MiniChart = ({ stockCode, eventTime, onChartClick }) => { const chartRef = useRef(null); const chartInstanceRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (chartRef.current && stockCode) { loadChartData(); } return () => { if (chartInstanceRef.current) { chartInstanceRef.current.dispose(); chartInstanceRef.current = null; } }; }, [stockCode, eventTime]); const loadChartData = async () => { try { setLoading(true); setError(null); const response = await stockService.getKlineData(stockCode, 'timeline', eventTime); if (!response.data || response.data.length === 0) { setError('无数据'); return; } // 初始化图表 if (!chartInstanceRef.current && chartRef.current) { chartInstanceRef.current = echarts.init(chartRef.current); } const option = generateMiniChartOption(response.data); chartInstanceRef.current.setOption(option, true); } catch (err) { console.error('加载迷你图表失败:', err); setError('加载失败'); } finally { setLoading(false); } }; const generateMiniChartOption = (data) => { const prices = data.map(item => item.close); const times = data.map(item => item.time); // 计算最高最低价格 const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); // 判断是上涨还是下跌 const isUp = prices[prices.length - 1] >= prices[0]; const lineColor = isUp ? '#ef5350' : '#26a69a'; return { grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false }, xAxis: { type: 'category', data: times, show: false, boundaryGap: false }, yAxis: { type: 'value', show: false, min: minPrice * 0.995, max: maxPrice * 1.005 }, series: [{ data: prices, type: 'line', smooth: true, symbol: 'none', lineStyle: { color: lineColor, width: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: lineColor === '#ef5350' ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' }, { offset: 1, color: lineColor === '#ef5350' ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)' } ]) }, markLine: { silent: true, symbol: 'none', label: { show: false }, lineStyle: { color: '#aaa', type: 'dashed', width: 1 }, data: [{ yAxis: prices[0] // 参考价 }] } }], tooltip: { trigger: 'axis', formatter: function(params) { if (!params || params.length === 0) return ''; const price = params[0].value.toFixed(2); const time = params[0].axisValue; const percentChange = ((price - prices[0]) / prices[0] * 100).toFixed(2); const sign = percentChange >= 0 ? '+' : ''; return `${time}
价格: ${price}
变动: ${sign}${percentChange}%`; }, position: function (pos, params, el, elRect, size) { const obj = { top: 10 }; obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 30; return obj; } }, animation: false }; }; if (loading) { return ( 加载中... ); } if (error) { return ( {error} ); } return ( ); }; // 添加股票模态框组件 const AddStockModal = ({ isOpen, onClose, formData, setFormData, onSubmit }) => { return ( 添加相关股票 股票代码 setFormData(prev => ({ ...prev, stock_code: e.target.value.toUpperCase() }))} /> 关联描述