import React, { useEffect, useState, useRef } from 'react'; import { Box, Button, Flex, Spinner, Alert, AlertIcon, Text, Stat, StatLabel, StatNumber, HStack, VStack, Tag, Badge, List, ListItem, Divider, CloseButton, Grid, Modal, ModalOverlay, ModalContent, ModalHeader, ModalFooter, ModalBody, ModalCloseButton, Icon, useColorModeValue, Tooltip } from '@chakra-ui/react'; import { InfoIcon, ViewIcon } from '@chakra-ui/icons'; import ReactECharts from 'echarts-for-react'; import { eventService } from '../../../services/eventService'; import CitedContent from '../../../components/Citation/CitedContent'; import { logger } from '../../../utils/logger'; // 节点样式配置 - 完全复刻Flask版本 const NODE_STYLES = { 'event': { color: '#ff4757', symbol: 'diamond', size: 'large' }, 'industry': { color: '#00d2d3', symbol: 'rect', size: 'medium' }, 'company': { color: '#54a0ff', symbol: 'circle', size: 'medium' }, 'policy': { color: '#10ac84', symbol: 'triangle', size: 'medium' }, 'technology': { color: '#ee5a6f', symbol: 'roundRect', size: 'medium' }, 'market': { color: '#ffd93d', symbol: 'diamond', size: 'medium' }, 'other': { color: '#a4b0be', symbol: 'circle', size: 'small' } }; // 影响方向颜色映射 - 完全复刻Flask版本 const IMPACT_COLORS = { 'positive': '#52c41a', 'negative': '#ff4d4f', 'neutral': '#999', 'mixed': '#faad14' }; // 节点类型标签映射 const NODE_TYPE_LABELS = { 'event': '事件', 'industry': '行业', 'company': '公司', 'policy': '政策', 'technology': '技术', 'market': '市场', 'other': '其他' }; // 过滤孤立节点 - 完全复刻Flask版本 function filterIsolatedNodes(nodes, edges) { logger.debug('TransmissionChain', '开始过滤孤立节点', { nodesCount: nodes?.length, edgesCount: edges?.length }); if (!nodes || !edges) { logger.debug('TransmissionChain', '节点或边数据为空'); return []; } const connectedNodeIds = new Set(); edges.forEach(edge => { connectedNodeIds.add(String(edge.source)); connectedNodeIds.add(String(edge.target)); }); // 如果图中只有一个节点且是主事件,也显示它 const mainEventNode = nodes.find(n => n.extra?.is_main_event); if (mainEventNode) { connectedNodeIds.add(String(mainEventNode.id)); } const filteredNodes = nodes.filter(node => { return connectedNodeIds.has(String(node.id)); }); logger.debug('TransmissionChain', '过滤完成', { originalCount: nodes.length, filteredCount: filteredNodes.length, connectedNodesCount: connectedNodeIds.size }); return filteredNodes; } // 计算节点连接数 function calculateNodeConnections(nodeId, edges) { return edges.filter(edge => String(edge.source) === String(nodeId) || String(edge.target) === String(nodeId) ).length; } // 计算节点大小 - 基于连接数,增大整体尺寸方便点击 function calculateNodeSize(connectionCount, isMainEvent = false) { const baseSize = 35; // 增加基础大小 const maxSize = 70; // 增加最大大小 const minSize = 25; // 增加最小大小 // 主事件节点稍大一些 if (isMainEvent) { return Math.min(maxSize, baseSize + 20); } // 基于连接数计算大小,使用对数函数避免差距过大 const sizeMultiplier = Math.log(connectionCount + 1) * 6; return Math.min(maxSize, Math.max(minSize, baseSize + sizeMultiplier)); } // 计算边的宽度 - 基于强度,但差距不要过大 function calculateEdgeWidth(strength) { const minWidth = 1; const maxWidth = 4; if (typeof strength === 'number') { const normalizedStrength = strength / 100; // 假设强度是0-100 return minWidth + (maxWidth - minWidth) * normalizedStrength; } // 如果是字符串类型的强度 switch(strength) { case 'strong': return 3; case 'medium': return 2; case 'weak': return 1; default: return 2; } } // 力导向图配置 - 完全复刻Flask版本 function getGraphOption(data) { logger.debug('TransmissionChain', 'getGraphOption被调用', { hasData: !!data, nodesCount: data?.nodes?.length, edgesCount: data?.edges?.length }); if (!data || !data.nodes || !data.edges || data.nodes.length === 0) { logger.debug('TransmissionChain', '数据为空或无效'); return { title: { text: '暂无传导链数据', left: 'center', top: 'center' }, graphic: { type: 'text', left: 'center', top: '60%', style: { text: '当前事件暂无传导链分析数据', fontSize: 14 } } }; } const filteredNodes = filterIsolatedNodes(data.nodes, data.edges); // 进一步过滤:不显示事件类型的节点 const nonEventNodes = filteredNodes.filter(node => node.extra?.node_type !== 'event'); logger.debug('TransmissionChain', '节点过滤结果', { originalCount: data.nodes.length, filteredCount: filteredNodes.length, nonEventCount: nonEventNodes.length }); if (nonEventNodes.length === 0) { logger.debug('TransmissionChain', '过滤后没有有效节点'); return { title: { text: '暂无有效节点数据', left: 'center', top: 'center' }, graphic: { type: 'text', left: 'center', top: '60%', style: { text: '当前事件的传导链节点均为孤立节点', fontSize: 14 } } }; } // 生成节点类别(排除事件类型) const categories = [...new Set(nonEventNodes.map(n => n.extra?.node_type || 'other'))].map(type => ({ name: NODE_TYPE_LABELS[type] || type, itemStyle: { color: NODE_STYLES[type]?.color || NODE_STYLES.other.color } })); // 构建图表节点数据 - 完全复刻Flask版本样式(排除事件节点) const chartNodes = nonEventNodes.map(node => { const nodeType = node.extra?.node_type || 'other'; const nodeStyle = NODE_STYLES[nodeType] || NODE_STYLES['other']; const connectionCount = calculateNodeConnections(node.id, data.edges); return { id: String(node.id), name: node.name, value: node.value, symbol: nodeStyle.symbol, symbolSize: calculateNodeSize(connectionCount, node.extra?.is_main_event), category: NODE_TYPE_LABELS[nodeType] || nodeType, itemStyle: { color: nodeStyle.color, borderColor: node.extra?.is_main_event ? '#ffd700' : '#fff', borderWidth: node.extra?.is_main_event ? 3 : 1, shadowBlur: 5, shadowColor: 'rgba(0,0,0,0.2)' }, label: { show: true, position: 'right', formatter: '{b}', fontSize: 12, fontWeight: 'bold', padding: [5, 8], // 增加标签内边距,扩大点击区域 backgroundColor: 'rgba(255,255,255,0.8)', borderRadius: 4, borderColor: '#ddd', borderWidth: 1 }, emphasis: { focus: 'none', // 减少高亮敏感性,但保持点击功能 label: { show: true, fontSize: 13, fontWeight: 'bold' }, itemStyle: { borderWidth: 3, shadowBlur: 10 } }, // 扩大节点的实际点击区域 select: { itemStyle: { borderColor: '#007bff', borderWidth: 3 } }, extra: node.extra // 包含所有额外信息 }; }); // 构建图表边数据 - 完全复刻Flask版本样式 const chartEdges = data.edges.map(edge => ({ source: String(edge.source), target: String(edge.target), value: edge.value, lineStyle: { color: IMPACT_COLORS[edge.extra?.direction] || IMPACT_COLORS.neutral, width: calculateEdgeWidth(edge.extra?.strength || 50), curveness: edge.extra?.is_circular ? 0.3 : 0, type: edge.extra?.is_circular ? 'dashed' : 'solid', // 关键:循环边用虚线 }, label: { show: false // 通常边的标签会很乱,默认关闭 }, symbol: ['none', 'arrow'], symbolSize: [0, 10], extra: edge.extra // 包含所有额外信息 })); // 完全复刻Flask版本的图表选项 return { tooltip: { trigger: 'item', formatter: (params) => { if (params.dataType === 'node') { const { name, extra } = params.data; const connectionCount = calculateNodeConnections(params.data.id, data.edges); return `
` + `${name}
` + `类型: ${NODE_TYPE_LABELS[extra?.node_type] || extra?.node_type}
` + `重要性: ${extra?.importance_score}
` + `连接数: ${connectionCount}` + `${extra?.is_main_event ? '
★ 主事件' : ''}` + `
🖱️ 点击查看详细信息` + `
`; } if (params.dataType === 'edge') { const { source, target, extra } = params.data; const sourceNode = data.nodes.find(n => String(n.id) === String(source)); const targetNode = data.nodes.find(n => String(n.id) === String(target)); if (!sourceNode || !targetNode) return '加载中...'; return `
` + `${sourceNode.name} → ${targetNode.name}
` + `类型: ${extra?.transmission_type}
` + `方向: ${extra?.direction}
` + `强度: ${extra?.strength}` + `${extra?.is_circular ? '
🔄 循环效应' : ''}` + `
`; } return params.name; } }, // 隐藏ECharts默认图例,使用自定义图例 legend: { show: false }, series: [{ type: 'graph', layout: 'force', data: chartNodes, links: chartEdges, categories: categories, roam: true, draggable: true, // 启用节点拖拽 focusNodeAdjacency: false, // 降低敏感性 force: { repulsion: 250, // 增加节点间距,给点击留更多空间 edgeLength: [100, 180], // 增加边长,扩大节点周围空间 gravity: 0.05, // 降低重力 layoutAnimation: false, // 关闭布局动画降低敏感性 friction: 0.6 // 增加摩擦力,减少移动 }, emphasis: { focus: 'none', // 降低高亮敏感性 scale: 1.1 // 轻微放大而不是强烈高亮 }, lineStyle: { opacity: 0.6, curveness: 0.1 }, label: { position: 'right', formatter: '{b}', show: true, distance: 10, // 标签距离节点更远一些 fontSize: 12 }, // 增加整体的点击敏感性 silent: false, triggerLineEvent: true, triggerEvent: true }] }; } // 桑基图配置 function getSankeyOption(data) { if (!data || !data.nodes || !data.links) { return { title: { text: '暂无桑基图数据', left: 'center', top: 'center' } }; } return { title: { text: '事件影响力传导流向', left: 'center', top: 10 }, tooltip: { trigger: 'item', triggerOn: 'mousemove', formatter: (params) => { if (params.dataType === 'node') { return `${params.name}
类型: ${params.data.type || 'N/A'}
层级: ${params.data.level || 'N/A'}
点击查看详情`; } return params.name; } }, series: [{ type: 'sankey', layout: 'none', emphasis: { focus: 'adjacency' }, nodeAlign: 'justify', layoutIterations: 0, data: data.nodes.map(node => ({ name: node.name, type: node.type, level: node.level, itemStyle: { color: node.color, borderColor: node.level === 0 ? '#ffd700' : 'transparent', borderWidth: node.level === 0 ? 3 : 0 } })), links: data.links.map(link => ({ source: data.nodes[link.source]?.name, target: data.nodes[link.target]?.name, value: link.value, lineStyle: { color: 'source', opacity: 0.6, curveness: 0.5 } })), label: { color: 'rgba(0,0,0,0.7)', fontSize: 12 } }] }; } const TransmissionChainAnalysis = ({ eventId }) => { // 状态管理 const [graphData, setGraphData] = useState(null); const [sankeyData, setSankeyData] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [nodeDetail, setNodeDetail] = useState(null); // 新增:存储API获取的详细节点信息 const [transmissionPath, setTransmissionPath] = useState([]); const [viewMode, setViewMode] = useState('graph'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [chartReady, setChartReady] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); // 新增:控制弹窗显示 const [stats, setStats] = useState({ totalNodes: 0, involvedIndustries: 0, relatedCompanies: 0, positiveImpact: 0, negativeImpact: 0, circularEffect: 0 }); const containerRef = useRef(null); const modalBgColor = useColorModeValue('white', 'gray.800'); const modalBorderColor = useColorModeValue('gray.200', 'gray.600'); // 延迟初始化图表,确保DOM容器准备好 useEffect(() => { const timer = setTimeout(() => { setChartReady(true); }, 100); return () => clearTimeout(timer); }, []); // 计算统计信息 useEffect(() => { if (graphData && graphData.nodes) { setStats({ totalNodes: graphData.nodes.length, involvedIndustries: graphData.nodes.filter(n => n.extra?.node_type === 'industry').length, relatedCompanies: graphData.nodes.filter(n => n.extra?.node_type === 'company').length, positiveImpact: graphData.edges?.filter(e => e.extra?.direction === 'positive').length || 0, negativeImpact: graphData.edges?.filter(e => e.extra?.direction === 'negative').length || 0, circularEffect: graphData.edges?.filter(e => e.extra?.is_circular).length || 0 }); } }, [graphData]); // 加载数据 useEffect(() => { async function fetchData() { setLoading(true); setError(null); try { logger.debug('TransmissionChain', '开始加载传导链数据', { eventId }); const [graphRes, sankeyRes] = await Promise.all([ eventService.getTransmissionChainAnalysis(eventId), eventService.getSankeyData(eventId) ]); logger.debug('TransmissionChain', 'API响应', { graphSuccess: graphRes.success, graphNodesCount: graphRes.data?.nodes?.length, graphEdgesCount: graphRes.data?.edges?.length, sankeySuccess: sankeyRes.success }); if (graphRes.success && graphRes.data) { setGraphData(graphRes.data); } else { logger.warn('TransmissionChain', '传导链数据加载失败', { success: graphRes.success, eventId }); setGraphData(null); } if (sankeyRes.success && sankeyRes.data) { setSankeyData(sankeyRes.data); } else { logger.warn('TransmissionChain', '桑基图数据加载失败', { success: sankeyRes.success, eventId }); setSankeyData(null); } } catch (e) { logger.error('TransmissionChain', 'fetchData', e, { eventId }); setError('加载传导链数据失败'); } finally { setLoading(false); } } if (eventId) { fetchData(); } }, [eventId]); // BFS路径查找 - 完全复刻Flask版本 function findPath(nodes, edges, fromId, toId) { const adj = {}; edges.forEach(e => { if (!adj[e.source]) adj[e.source] = []; adj[e.source].push(e.target); }); const queue = [[fromId, [nodes.find(n => String(n.id) === String(fromId))]]]; const visited = new Set([fromId]); while (queue.length > 0) { const [current, path] = queue.shift(); if (String(current) === String(toId)) { return path; } (adj[current] || []).forEach(next => { if (!visited.has(next)) { visited.add(next); const nextNode = nodes.find(n => String(n.id) === String(next)); if (nextNode) { queue.push([next, [...path, nextNode]]); } } }); } return []; } // 获取节点详情 - 完全复刻Flask版本API调用 async function getChainNodeDetail(nodeId) { try { const response = await fetch(`/api/events/${eventId}/chain-node/${nodeId}`); const result = await response.json(); if (result.success) { return result.data; } else { logger.error('TransmissionChain', 'getChainNodeDetail', new Error(result.message), { nodeId, eventId }); return null; } } catch (error) { logger.error('TransmissionChain', 'getChainNodeDetail', error, { nodeId, eventId }); return null; } } // 力导向图节点点击事件 const handleGraphNodeClick = async (params) => { logger.debug('TransmissionChain', '图表节点点击', { dataType: params.dataType, componentType: params.componentType, hasData: !!params.data, nodeId: params.data?.id }); // 处理节点点击(包括节点本体和标签) if ((params.dataType === 'node' || params.componentType === 'series') && params.data && params.data.id) { // 获取基本节点信息 const clickedNode = graphData.nodes.find(n => String(n.id) === String(params.data.id)); if (clickedNode) { setSelectedNode(clickedNode); // 计算传导路径 const mainEventNode = graphData?.nodes?.find(n => n.extra?.is_main_event); if (mainEventNode && String(clickedNode.id) !== String(mainEventNode.id)) { const path = findPath(graphData.nodes, graphData.edges, mainEventNode.id, clickedNode.id); setTransmissionPath(path); } else { setTransmissionPath([]); } // 获取详细节点信息(包括parents和children) logger.debug('TransmissionChain', '获取节点详情', { nodeId: params.data.id, nodeName: clickedNode.name }); const detail = await getChainNodeDetail(params.data.id); setNodeDetail(detail); // 打开弹窗 setIsModalOpen(true); } } }; // 桑基图节点点击事件 const handleSankeyNodeClick = async (params) => { if (params.dataType === 'node' && params.data && params.data.name) { logger.debug('TransmissionChain', '桑基图节点点击', { nodeName: params.data.name }); // 通过名称在原始数据中查找对应的节点 if (graphData && graphData.nodes) { const clickedNode = graphData.nodes.find(n => n.name === params.data.name); if (clickedNode) { setSelectedNode(clickedNode); // 计算传导路径 const mainEventNode = graphData?.nodes?.find(n => n.extra?.is_main_event); if (mainEventNode && String(clickedNode.id) !== String(mainEventNode.id)) { const path = findPath(graphData.nodes, graphData.edges, mainEventNode.id, clickedNode.id); setTransmissionPath(path); } else { setTransmissionPath([]); } // 获取详细节点信息(包括parents和children) logger.debug('TransmissionChain', '获取桑基图节点详情', { nodeId: clickedNode.id, nodeName: clickedNode.name }); const detail = await getChainNodeDetail(clickedNode.id); setNodeDetail(detail); // 打开弹窗 setIsModalOpen(true); } else { logger.warn('TransmissionChain', '未找到对应的节点数据', { nodeName: params.data.name }); // 创建一个临时节点信息用于显示 const tempNode = { id: params.data.name, name: params.data.name, extra: { node_type: params.data.type, description: `桑基图节点 - ${params.data.name}`, importance_score: 'N/A' } }; setSelectedNode(tempNode); setTransmissionPath([]); setNodeDetail(null); // 打开弹窗 setIsModalOpen(true); } } } }; return ( {/* 统计信息条 */} 总节点数 {stats.totalNodes} 涉及行业 {stats.involvedIndustries} 相关公司 {stats.relatedCompanies} 正向影响 {stats.positiveImpact} 负向影响 {stats.negativeImpact} 循环效应 {stats.circularEffect} {/* 自定义图例 */} {Object.entries(NODE_STYLES).map(([type, style]) => ( {NODE_TYPE_LABELS[type] || type} ))} {/* 视图切换按钮 */} {loading && ( )} {error && ( {error} )} {!loading && !error && ( {/* 提示信息 */} 点击图表中的节点可以查看详细信息 {/* 图表容器 */} {chartReady && ( <> {viewMode === 'graph' ? ( { // 减少不必要的重新渲染 return JSON.stringify(prevProps.option) !== JSON.stringify(props.option); }} /> ) : ( { // 减少不必要的重新渲染 return JSON.stringify(prevProps.option) !== JSON.stringify(props.option); }} /> )} )} )} {/* 节点详情弹窗 */} setIsModalOpen(false)} size="xl"> {selectedNode ? '节点详情' : '传导链分析'} {selectedNode && ( {NODE_TYPE_LABELS[selectedNode.extra?.node_type] || selectedNode.extra?.node_type} )} {selectedNode ? ( {/* 节点基本信息 */} 基本信息 名称: {selectedNode.name} {selectedNode.extra?.is_main_event && ( 主事件 )} 类型: {NODE_TYPE_LABELS[selectedNode.extra?.node_type] || selectedNode.extra?.node_type} 重要性评分: {selectedNode.extra?.importance_score || 'N/A'} {selectedNode.extra?.stock_code && ( 股票代码: {selectedNode.extra.stock_code} )} {nodeDetail ? ( 总连接: {nodeDetail.node.total_connections || 0} 来源: {nodeDetail.node.incoming_connections || 0} 目标: {nodeDetail.node.outgoing_connections || 0} ) : ( 总连接: {graphData ? graphData.edges.filter(e => String(e.source) === String(selectedNode.id) || String(e.target) === String(selectedNode.id) ).length : 0} 来源: {graphData ? graphData.edges.filter(e => String(e.target) === String(selectedNode.id) ).length : 0} 目标: {graphData ? graphData.edges.filter(e => String(e.source) === String(selectedNode.id) ).length : 0} )} {/* 节点描述 */} {selectedNode.extra?.description && ( 描述 {selectedNode.extra.description}(AI合成) )} {/* 传导路径 */} {transmissionPath && transmissionPath.length > 0 && ( 传导路径 {transmissionPath.map((node, index) => ( {index === 0 && '🚀 '} {index === transmissionPath.length - 1 && '🎯 '} {index > 0 && index < transmissionPath.length - 1 && '➡️ '} {node.name} ))} )} {/* 影响来源 */} {(() => { const sourcesFromAPI = nodeDetail && nodeDetail.parents && nodeDetail.parents.length > 0; const sourcesFromGraph = graphData && graphData.edges.filter(e => String(e.target) === String(selectedNode.id) ).length > 0; if (sourcesFromAPI) { return ( 影响来源 ({nodeDetail.parents.length})(AI合成) {nodeDetail.parents.map((parent, index) => ( {parent.name} {parent.transmission_mechanism_citation?.data ? ( 机制: ) : parent.transmission_mechanism ? ( 机制: {parent.transmission_mechanism}(AI合成) ) : null} {parent.direction} {parent.is_circular && ( 🔄 循环 )} ))} ); } return null; })()} {/* 影响输出 */} {(() => { const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.length > 0; if (targetsFromAPI) { return ( 影响输出 ({nodeDetail.children.length}) {nodeDetail.children.map((child, index) => ( {child.name} {child.transmission_mechanism?.data ? ( 机制: ) : child.transmission_mechanism ? ( 机制: {child.transmission_mechanism}(AI合成) ) : null} {child.direction} ))} ); } return null; })()} ) : ( 传导链分析 点击图表中的节点查看详细信息 )} ); }; export default TransmissionChainAnalysis;