Files
vf_react/src/views/EventDetail/components/TransmissionChainAnalysis.js
zdl f2713e5e0a fix: 桑基图标题位置调整,避免被图表遮挡
- 标题 top 调整为 5
- 桑基图 series 添加 top: 50 给标题留出空间
- 添加 bottom, left, right 边距配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-15 14:22:09 +08:00

1320 lines
50 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.

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,
Center
} from '@chakra-ui/react';
import { InfoIcon, ViewIcon } from '@chakra-ui/icons';
import { Share2, GitBranch, Inbox } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { eventService } from '../../../services/eventService';
import CitedContent from '../../../components/Citation/CitedContent';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
// 节点样式配置 - 完全复刻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 `<div style="text-align: left;">` +
`<b style="font-size: 14px;">${name}</b><br/>` +
`类型: ${NODE_TYPE_LABELS[extra?.node_type] || extra?.node_type}<br/>` +
`重要性: ${extra?.importance_score}<br/>` +
`连接数: ${connectionCount}` +
`${extra?.is_main_event ? '<br/><span style="color: #ffd700;">★ 主事件</span>' : ''}` +
`<br/><span style="color: #007bff; font-weight: bold;">🖱️ 点击查看详细信息</span>` +
`</div>`;
}
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 `<div style="text-align: left;">` +
`<b>${sourceNode.name}${targetNode.name}</b><br/>` +
`类型: ${extra?.transmission_type}<br/>` +
`方向: ${extra?.direction}<br/>` +
`强度: ${extra?.strength}` +
`${extra?.is_circular ? '<br/>🔄 循环效应' : ''}` +
`</div>`;
}
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: 5,
textStyle: {
color: '#00d2d3',
fontSize: 16,
fontWeight: 'bold'
}
},
tooltip: {
trigger: 'item',
triggerOn: 'mousemove',
backgroundColor: 'rgba(30, 30, 30, 0.95)',
borderColor: '#444',
textStyle: {
color: '#fff'
},
formatter: (params) => {
if (params.dataType === 'node') {
return `<div style="text-align: left;">` +
`<b style="font-size: 14px; color: #fff;">${params.name}</b><br/>` +
`<span style="color: #aaa;">类型:</span> <span style="color: #00d2d3;">${params.data.type || 'N/A'}</span><br/>` +
`<span style="color: #aaa;">层级:</span> <span style="color: #ffd700;">${params.data.level || 'N/A'}</span><br/>` +
`<span style="color: #4dabf7; text-decoration: underline; cursor: pointer;">🖱️ 点击查看详情</span>` +
`</div>`;
}
return params.name;
}
},
series: [{
type: 'sankey',
layout: 'none',
top: 50, // 给标题留出空间
bottom: 20,
left: 20,
right: 150, // 右侧留空间给标签
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
},
// 节点标签样式 - 突出显示,可点击感知
label: {
show: true,
color: '#fff',
fontSize: 13,
fontWeight: 'bold',
padding: [4, 8],
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 4,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderWidth: 1,
// 添加下划线效果表示可点击
rich: {
clickable: {
textDecoration: 'underline',
color: '#4dabf7'
}
}
}
})),
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.25, // 从0.6降低到0.25
curveness: 0.5
}
})),
// 全局标签样式
label: {
show: true,
position: 'right',
color: '#fff',
fontSize: 13,
fontWeight: 'bold',
padding: [4, 8],
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 4,
borderColor: 'rgba(77, 171, 247, 0.5)',
borderWidth: 1,
formatter: '{b}'
},
// 高亮时的样式
emphasis: {
focus: 'adjacency',
label: {
color: '#4dabf7',
fontSize: 14,
fontWeight: 'bold',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: '#4dabf7',
borderWidth: 2
},
lineStyle: {
opacity: 0.5
}
}
}]
};
}
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 = PROFESSIONAL_COLORS.background.card;
const modalBorderColor = PROFESSIONAL_COLORS.border.default;
// 关闭弹窗并清空状态
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedNode(null);
setNodeDetail(null);
setTransmissionPath([]);
};
// 延迟初始化图表确保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(`${getApiBase()}/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 (
<Box>
{/* 统计信息条 */}
<Box
mb={4}
p={4}
borderRadius="lg"
border="1px solid"
borderColor={PROFESSIONAL_COLORS.border.default}
bg={PROFESSIONAL_COLORS.background.secondary}
>
<Flex wrap="wrap" gap={{ base: 3, md: 6 }}>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">总节点数</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.totalNodes}</StatNumber>
</Stat>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">涉及行业</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.involvedIndustries}</StatNumber>
</Stat>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">相关公司</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.relatedCompanies}</StatNumber>
</Stat>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">正向影响</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#10B981">{stats.positiveImpact}</StatNumber>
</Stat>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">负向影响</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#EF4444">{stats.negativeImpact}</StatNumber>
</Stat>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">循环效应</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#A855F7">{stats.circularEffect}</StatNumber>
</Stat>
</Flex>
</Box>
{/* 自定义图例 */}
<Flex mb={4} wrap="wrap" gap={2}>
{Object.entries(NODE_STYLES).map(([type, style]) => (
<Tag
key={type}
size="sm"
px={2}
py={1}
bg={PROFESSIONAL_COLORS.background.secondary}
color={PROFESSIONAL_COLORS.text.primary}
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
<Box w={2.5} h={2.5} bg={style.color} borderRadius="sm" mr={1.5} />
{NODE_TYPE_LABELS[type] || type}
</Tag>
))}
</Flex>
{/* 视图切换按钮 */}
<Flex mb={4} gap={2}>
<Button
leftIcon={<Icon as={Share2} boxSize={4} />}
bg={viewMode === 'graph' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary}
color={viewMode === 'graph' ? 'black' : PROFESSIONAL_COLORS.text.primary}
_hover={{
bg: viewMode === 'graph' ? PROFESSIONAL_COLORS.gold[600] : PROFESSIONAL_COLORS.background.cardHover,
}}
onClick={() => setViewMode('graph')}
size="sm"
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
力导向图
</Button>
<Button
leftIcon={<Icon as={GitBranch} boxSize={4} />}
bg={viewMode === 'sankey' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary}
color={viewMode === 'sankey' ? 'black' : PROFESSIONAL_COLORS.text.primary}
_hover={{
bg: viewMode === 'sankey' ? PROFESSIONAL_COLORS.gold[600] : PROFESSIONAL_COLORS.background.cardHover,
}}
onClick={() => setViewMode('sankey')}
size="sm"
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
桑基图
</Button>
</Flex>
{loading && (
<Flex justify="center" align="center" h="400px">
<Spinner size="xl" />
</Flex>
)}
{error && (
<Alert
status="error"
mb={4}
bg="rgba(239, 68, 68, 0.1)"
color="#EF4444"
borderWidth="1px"
borderColor="#EF4444"
borderRadius="md"
>
<AlertIcon />
{error}
</Alert>
)}
{!loading && !error && (
<Box>
{/* 图表容器 - 宽高比 2:1H5 自适应 */}
<Box
position="relative"
w="100%"
pb={{ base: "75%", md: "50%" }}
border="1px solid"
borderColor={PROFESSIONAL_COLORS.border.default}
borderRadius="lg"
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
bg={PROFESSIONAL_COLORS.background.card}
ref={containerRef}
>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
p={4}
>
{/* 提示信息 - 固定在左上角 */}
<Text
position="absolute"
top={2}
left={3}
fontSize="xs"
color={PROFESSIONAL_COLORS.text.muted}
zIndex={1}
bg="rgba(0, 0, 0, 0.5)"
px={2}
py={1}
borderRadius="md"
>
<Icon as={ViewIcon} mr={1} boxSize={3} />
点击节点查看详情
</Text>
{chartReady && (
<>
{/* 空状态提示 */}
{(viewMode === 'graph' && (!graphData || !graphData.nodes || graphData.nodes.length === 0)) ||
(viewMode === 'sankey' && (!sankeyData || !sankeyData.nodes || sankeyData.nodes.length === 0)) ? (
<Center h="100%" flexDirection="column">
<Icon as={Inbox} boxSize={12} color={PROFESSIONAL_COLORS.text.muted} />
<Text mt={4} color={PROFESSIONAL_COLORS.text.muted} fontSize="sm">
暂无传导链数据
</Text>
</Center>
) : (
<>
{viewMode === 'graph' ? (
<ReactECharts
option={graphData ? getGraphOption(graphData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleGraphNodeClick
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
) : (
<ReactECharts
option={sankeyData ? getSankeyOption(sankeyData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleSankeyNodeClick
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
)}
</>
)}
</>
)}
</Box>
</Box>
</Box>
)}
{/* 节点详情弹窗 */}
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={handleCloseModal} size="xl">
<ModalOverlay />
<ModalContent maxH="80vh" bg={modalBgColor}>
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor} pr={12}>
<HStack justify="space-between" pr={2}>
<Text color={PROFESSIONAL_COLORS.text.primary}>{selectedNode ? '节点详情' : '传导链分析'}</Text>
{selectedNode && (
<Badge
bg="rgba(59, 130, 246, 0.15)"
color="#3B82F6"
borderWidth="1px"
borderColor="#3B82F6"
>
{NODE_TYPE_LABELS[selectedNode.extra?.node_type] || selectedNode.extra?.node_type}
</Badge>
)}
</HStack>
</ModalHeader>
<ModalCloseButton
color={PROFESSIONAL_COLORS.text.secondary}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
/>
<ModalBody overflowY="auto">
{selectedNode ? (
<VStack align="stretch" spacing={4}>
{/* 节点基本信息 */}
<Box>
<Text fontWeight="bold" mb={2} color={PROFESSIONAL_COLORS.gold[500]}>基本信息</Text>
<VStack align="stretch" spacing={2}>
<HStack align="center">
<Text fontSize="sm" color={PROFESSIONAL_COLORS.text.primary}>
<strong>名称:</strong> {selectedNode.name}
</Text>
{selectedNode.extra?.is_main_event && (
<Badge
bg="rgba(239, 68, 68, 0.15)"
color="#EF4444"
borderWidth="1px"
borderColor="#EF4444"
size="sm"
>
主事件
</Badge>
)}
</HStack>
<Text fontSize="sm" color={PROFESSIONAL_COLORS.text.primary}>
<strong>类型:</strong> {NODE_TYPE_LABELS[selectedNode.extra?.node_type] || selectedNode.extra?.node_type}
</Text>
<Text fontSize="sm" color={PROFESSIONAL_COLORS.text.primary}>
<strong>重要性评分:</strong> {selectedNode.extra?.importance_score || 'N/A'}
</Text>
{selectedNode.extra?.stock_code && (
<Text fontSize="sm" color={PROFESSIONAL_COLORS.text.primary}>
<strong>股票代码:</strong>
<Badge
bg="rgba(6, 182, 212, 0.15)"
color="#06B6D4"
borderWidth="1px"
borderColor="#06B6D4"
ml={2}
>
{selectedNode.extra.stock_code}
</Badge>
</Text>
)}
{nodeDetail ? (
<HStack spacing={4}>
<Badge
bg={PROFESSIONAL_COLORS.background.secondary}
color={PROFESSIONAL_COLORS.text.primary}
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
总连接: {nodeDetail.node.total_connections || 0}
</Badge>
<Badge
bg="rgba(16, 185, 129, 0.15)"
color="#10B981"
borderWidth="1px"
borderColor="#10B981"
>
来源: {nodeDetail.node.incoming_connections || 0}
</Badge>
<Badge
bg="rgba(251, 146, 60, 0.15)"
color="#FB923C"
borderWidth="1px"
borderColor="#FB923C"
>
目标: {nodeDetail.node.outgoing_connections || 0}
</Badge>
</HStack>
) : (
<HStack spacing={4}>
<Badge
bg={PROFESSIONAL_COLORS.background.secondary}
color={PROFESSIONAL_COLORS.text.primary}
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
总连接: {graphData ? graphData.edges.filter(e =>
String(e.source) === String(selectedNode.id) || String(e.target) === String(selectedNode.id)
).length : 0}
</Badge>
<Badge
bg="rgba(16, 185, 129, 0.15)"
color="#10B981"
borderWidth="1px"
borderColor="#10B981"
>
来源: {graphData ? graphData.edges.filter(e =>
String(e.target) === String(selectedNode.id)
).length : 0}
</Badge>
<Badge
bg="rgba(251, 146, 60, 0.15)"
color="#FB923C"
borderWidth="1px"
borderColor="#FB923C"
>
目标: {graphData ? graphData.edges.filter(e =>
String(e.source) === String(selectedNode.id)
).length : 0}
</Badge>
</HStack>
)}
</VStack>
</Box>
{/* 节点描述 */}
{selectedNode.extra?.description && (
<Box>
<Text fontWeight="bold" mb={2} color={PROFESSIONAL_COLORS.gold[500]}>描述</Text>
<Box
fontSize="sm"
color={PROFESSIONAL_COLORS.text.secondary}
borderLeft="3px solid"
borderColor="#3B82F6"
pl={3}
bg={PROFESSIONAL_COLORS.background.secondary}
p={2}
borderRadius="md"
fontStyle="italic"
>
{selectedNode.extra.description?.data ? (
<CitedContent
data={selectedNode.extra.description}
title=""
textColor={PROFESSIONAL_COLORS.text.primary}
/>
) : (
`${selectedNode.extra.description}AI合成`
)}
</Box>
</Box>
)}
{/* 传导路径 */}
{transmissionPath && transmissionPath.length > 0 && (
<Box>
<Text fontWeight="bold" mb={2} color={PROFESSIONAL_COLORS.gold[500]}>传导路径</Text>
<Box
bg={PROFESSIONAL_COLORS.background.secondary}
p={3}
borderRadius="md"
borderLeft="4px solid"
borderColor="#3B82F6"
>
<List spacing={1}>
{transmissionPath.map((node, index) => (
<ListItem key={index} fontSize="sm" color={PROFESSIONAL_COLORS.text.primary}>
{index === 0 && '🚀 '}
{index === transmissionPath.length - 1 && '🎯 '}
{index > 0 && index < transmissionPath.length - 1 && '➡️ '}
{node.name}
</ListItem>
))}
</List>
</Box>
</Box>
)}
{/* 影响来源 */}
{(() => {
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 (
<Box>
<Text fontWeight="bold" mb={2} color={PROFESSIONAL_COLORS.gold[500]}>
影响来源 ({nodeDetail.parents.length})AI合成
</Text>
<List spacing={2}>
{nodeDetail.parents.map((parent, index) => (
<ListItem
key={index}
p={2}
bg={PROFESSIONAL_COLORS.background.secondary}
borderRadius="md"
borderLeft="3px solid"
borderColor="#10B981"
position="relative"
>
<HStack position="absolute" top={2} right={2} spacing={2} zIndex={1}>
{parent.direction && (
<Badge
bg={
parent.direction === 'positive' ? 'rgba(16, 185, 129, 0.15)' :
parent.direction === 'negative' ? 'rgba(239, 68, 68, 0.15)' :
'rgba(107, 114, 128, 0.15)'
}
color={
parent.direction === 'positive' ? '#10B981' :
parent.direction === 'negative' ? '#EF4444' :
'#6B7280'
}
borderWidth="1px"
borderColor={
parent.direction === 'positive' ? '#10B981' :
parent.direction === 'negative' ? '#EF4444' :
'#6B7280'
}
size="sm"
>
{parent.direction === 'positive' ? '正向影响' :
parent.direction === 'negative' ? '负向影响' :
parent.direction === 'neutral' ? '中性影响' : '未知'}
</Badge>
)}
{parent.is_circular && (
<Badge
bg="rgba(168, 85, 247, 0.15)"
color="#A855F7"
borderWidth="1px"
borderColor="#A855F7"
size="sm"
>
🔄 循环
</Badge>
)}
</HStack>
<VStack align="stretch" spacing={1}>
<Text
fontWeight="bold"
fontSize="sm"
color={PROFESSIONAL_COLORS.text.primary}
pr={parent.direction || parent.is_circular ? 20 : 0}
>
{parent.name}
</Text>
{parent.transmission_mechanism?.data ? (
<CitedContent
data={parent.transmission_mechanism}
title=""
prefix="机制:"
prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }}
textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{
marginTop: 8,
backgroundColor: 'transparent',
padding: 0,
}}
showAIBadge={false}
/>
) : parent.transmission_mechanism ? (
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary}>
机制: {parent.transmission_mechanism}AI合成
</Text>
) : null}
</VStack>
</ListItem>
))}
</List>
</Box>
);
}
return null;
})()}
{/* 影响输出 */}
{(() => {
const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.length > 0;
if (targetsFromAPI) {
return (
<Box>
<Text fontWeight="bold" mb={2} color={PROFESSIONAL_COLORS.gold[500]}>
影响输出 ({nodeDetail.children.length})AI合成
</Text>
<List spacing={2}>
{nodeDetail.children.map((child, index) => (
<ListItem
key={index}
p={2}
bg={PROFESSIONAL_COLORS.background.secondary}
borderRadius="md"
borderLeft="3px solid"
borderColor="#FB923C"
position="relative"
>
{child.direction && (
<Box position="absolute" top={2} right={2} zIndex={1}>
<Badge
bg={
child.direction === 'positive' ? 'rgba(16, 185, 129, 0.15)' :
child.direction === 'negative' ? 'rgba(239, 68, 68, 0.15)' :
'rgba(107, 114, 128, 0.15)'
}
color={
child.direction === 'positive' ? '#10B981' :
child.direction === 'negative' ? '#EF4444' :
'#6B7280'
}
borderWidth="1px"
borderColor={
child.direction === 'positive' ? '#10B981' :
child.direction === 'negative' ? '#EF4444' :
'#6B7280'
}
size="sm"
>
{child.direction === 'positive' ? '正向影响' :
child.direction === 'negative' ? '负向影响' :
child.direction === 'neutral' ? '中性影响' : '未知'}
</Badge>
</Box>
)}
<VStack align="stretch" spacing={1}>
<Text fontWeight="bold" fontSize="sm" color={PROFESSIONAL_COLORS.text.primary} pr={child.direction ? 20 : 0}>{child.name}</Text>
{child.transmission_mechanism?.data ? (
<CitedContent
data={child.transmission_mechanism}
title=""
prefix="机制:"
prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }}
textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{
marginTop: 8,
backgroundColor: 'transparent',
padding: 0,
}}
showAIBadge={false}
/>
) : child.transmission_mechanism ? (
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary}>
机制: {child.transmission_mechanism}AI合成
</Text>
) : null}
</VStack>
</ListItem>
))}
</List>
</Box>
);
}
return null;
})()}
</VStack>
) : (
<VStack align="center" justify="center" h="300px" spacing={4}>
<Text fontSize="lg" color="gray.500">传导链分析</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
点击图表中的节点查看详细信息
</Text>
</VStack>
)}
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor={modalBorderColor}>
<Button
onClick={handleCloseModal}
variant="ghost"
color={PROFESSIONAL_COLORS.text.secondary}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};
export default TransmissionChainAnalysis;