diff --git a/src/views/Concept/components/HierarchyView.js b/src/views/Concept/components/HierarchyView.js index 511be394..bcfffde5 100644 --- a/src/views/Concept/components/HierarchyView.js +++ b/src/views/Concept/components/HierarchyView.js @@ -1,12 +1,10 @@ /** * HierarchyView - 概念层级思维导图视图 * - * 功能: - * 1. 思维导图式展示概念层级结构(lv1 → lv2 → lv3 → concepts) - * 2. 显示各层级的涨跌幅数据和概念数量 - * 3. 点击分类后切换到列表视图显示该分类下的概念 + * 使用 ECharts Tree 图表实现真正的思维导图效果 + * 支持径向布局(radial)和正交布局(orthogonal) */ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Box, VStack, @@ -17,416 +15,191 @@ import { Spinner, Center, Flex, - Collapse, + ButtonGroup, + Button, useBreakpointValue, Tooltip, - Tag, - TagLabel, - Wrap, - WrapItem, + IconButton, } from '@chakra-ui/react'; -import { - ChevronRightIcon, - ChevronDownIcon, -} from '@chakra-ui/icons'; +import ReactECharts from 'echarts-for-react'; import { FaLayerGroup, - FaArrowUp, - FaArrowDown, - FaTags, - FaChartLine, - FaBrain, - FaMicrochip, - FaRobot, - FaMobileAlt, - FaCar, - FaBolt, - FaPlane, - FaShieldAlt, - FaLandmark, - FaFlask, - FaShoppingCart, - FaCoins, - FaGlobe, - FaHeartbeat, - FaAtom, + FaSitemap, + FaProjectDiagram, + FaExpand, + FaCompress, + FaRedo, } from 'react-icons/fa'; -import { keyframes } from '@emotion/react'; import { logger } from '../../../utils/logger'; -// 脉冲动画 -const pulseAnimation = keyframes` - 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.4); } - 70% { transform: scale(1.02); box-shadow: 0 0 0 10px rgba(139, 92, 246, 0); } - 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(139, 92, 246, 0); } -`; - -// 连接线动画 -const flowAnimation = keyframes` - 0% { stroke-dashoffset: 20; } - 100% { stroke-dashoffset: 0; } -`; - -// 一级分类图标映射 -const LV1_ICONS = { - '人工智能': FaBrain, - '半导体': FaMicrochip, - '机器人': FaRobot, - '消费电子': FaMobileAlt, - '智能驾驶与汽车': FaCar, - '新能源与电力': FaBolt, - '空天经济': FaPlane, - '国防军工': FaShieldAlt, - '政策与主题': FaLandmark, - '周期与材料': FaFlask, - '大消费': FaShoppingCart, - '数字经济与金融科技': FaCoins, - '全球宏观与贸易': FaGlobe, - '医药健康': FaHeartbeat, - '前沿科技': FaAtom, -}; - // 一级分类颜色映射 const LV1_COLORS = { - '人工智能': { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' }, - '半导体': { bg: 'blue', gradient: 'linear(135deg, #4facfe 0%, #00f2fe 100%)' }, - '机器人': { bg: 'cyan', gradient: 'linear(135deg, #43e97b 0%, #38f9d7 100%)' }, - '消费电子': { bg: 'pink', gradient: 'linear(135deg, #fa709a 0%, #fee140 100%)' }, - '智能驾驶与汽车': { bg: 'orange', gradient: 'linear(135deg, #f093fb 0%, #f5576c 100%)' }, - '新能源与电力': { bg: 'green', gradient: 'linear(135deg, #11998e 0%, #38ef7d 100%)' }, - '空天经济': { bg: 'teal', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' }, - '国防军工': { bg: 'red', gradient: 'linear(135deg, #eb3349 0%, #f45c43 100%)' }, - '政策与主题': { bg: 'yellow', gradient: 'linear(135deg, #f6d365 0%, #fda085 100%)' }, - '周期与材料': { bg: 'gray', gradient: 'linear(135deg, #bdc3c7 0%, #2c3e50 100%)' }, - '大消费': { bg: 'pink', gradient: 'linear(135deg, #ff758c 0%, #ff7eb3 100%)' }, - '数字经济与金融科技': { bg: 'blue', gradient: 'linear(135deg, #4776e6 0%, #8e54e9 100%)' }, - '全球宏观与贸易': { bg: 'teal', gradient: 'linear(135deg, #00cdac 0%, #8ddad5 100%)' }, - '医药健康': { bg: 'green', gradient: 'linear(135deg, #56ab2f 0%, #a8e063 100%)' }, - '前沿科技': { bg: 'purple', gradient: 'linear(135deg, #a18cd1 0%, #fbc2eb 100%)' }, + '人工智能': '#8B5CF6', + '半导体': '#3B82F6', + '机器人': '#10B981', + '消费电子': '#EC4899', + '智能驾驶与汽车': '#F97316', + '新能源与电力': '#22C55E', + '空天经济': '#06B6D4', + '国防军工': '#EF4444', + '政策与主题': '#F59E0B', + '周期与材料': '#6B7280', + '大消费': '#F472B6', + '数字经济与金融科技': '#6366F1', + '全球宏观与贸易': '#14B8A6', + '医药健康': '#84CC16', + '前沿科技': '#A855F7', }; // 获取涨跌幅颜色 const getChangeColor = (value) => { - if (value === null || value === undefined) return 'gray'; - return value > 0 ? 'red' : value < 0 ? 'green' : 'gray'; + if (value === null || value === undefined) return '#9CA3AF'; + return value > 0 ? '#EF4444' : value < 0 ? '#22C55E' : '#9CA3AF'; }; // 格式化涨跌幅 const formatChangePercent = (value) => { - if (value === null || value === undefined) return null; - const formatted = value.toFixed(2); - return value > 0 ? `+${formatted}%` : `${formatted}%`; + if (value === null || value === undefined) return ''; + const formatted = Math.abs(value).toFixed(2); + return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%'; }; /** - * 一级分类卡片组件 + * 将后端层级数据转换为 ECharts tree 格式 */ -const Lv1Card = ({ - item, - isExpanded, - onToggle, - onSelectCategory, - stats -}) => { - const IconComponent = LV1_ICONS[item.name] || FaLayerGroup; - const colorConfig = LV1_COLORS[item.name] || { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' }; - const isMobile = useBreakpointValue({ base: true, md: false }); +const transformToEChartsData = (hierarchy, stats) => { + if (!hierarchy || hierarchy.length === 0) return null; - // 从统计数据中获取涨跌幅 - const avgChange = stats?.avg_change_pct; - const changeColor = getChangeColor(avgChange); + // 创建统计数据映射 + const statsMap = {}; + if (stats?.statistics) { + stats.statistics.forEach(s => { + statsMap[s.lv1] = s; + }); + } - return ( - - - {/* 背景装饰 */} - + // 根节点 + const root = { + name: '概念中心', + itemStyle: { + color: '#8B5CF6', + borderColor: '#7C3AED', + borderWidth: 3, + }, + label: { + fontSize: 16, + fontWeight: 'bold', + }, + children: hierarchy.map(lv1 => { + const lv1Stats = statsMap[lv1.name] || {}; + const lv1Color = LV1_COLORS[lv1.name] || '#8B5CF6'; + const changeColor = getChangeColor(lv1Stats.avg_change_pct); - - - - {item.name} - + return { + name: lv1.name, + value: lv1.concept_count, + // 自定义数据用于点击事件 + data: { + type: 'lv1', + id: lv1.id, + name: lv1.name, + concept_count: lv1.concept_count, + avg_change_pct: lv1Stats.avg_change_pct, + }, + itemStyle: { + color: lv1Color, + borderColor: lv1Color, + borderWidth: 2, + }, + label: { + fontSize: 13, + fontWeight: 'bold', + formatter: (params) => { + const data = params.data?.data || {}; + const change = data.avg_change_pct; + const changeStr = change !== undefined ? ` ${formatChangePercent(change)}` : ''; + return `{name|${params.name}}\n{count|${data.concept_count || 0}个}${changeStr ? `\n{change|${changeStr}}` : ''}`; + }, + rich: { + name: { + fontSize: 13, + fontWeight: 'bold', + color: '#1F2937', + }, + count: { + fontSize: 11, + color: '#6B7280', + }, + change: { + fontSize: 11, + color: changeColor, + fontWeight: 'bold', + }, + }, + }, + children: lv1.children?.map(lv2 => { + const hasLv3 = lv2.children && lv2.children.length > 0; - - - {item.concept_count} 概念 - + return { + name: lv2.name, + value: lv2.concept_count, + data: { + type: 'lv2', + id: lv2.id, + name: lv2.name, + parentLv1: lv1.name, + concept_count: lv2.concept_count, + }, + itemStyle: { + color: lv1Color, + opacity: 0.7, + }, + label: { + fontSize: 11, + formatter: `{b}\n(${lv2.concept_count})`, + }, + children: hasLv3 ? lv2.children.map(lv3 => ({ + name: lv3.name, + value: lv3.concept_count, + data: { + type: 'lv3', + id: lv3.id, + name: lv3.name, + parentLv1: lv1.name, + parentLv2: lv2.name, + concept_count: lv3.concept_count, + concepts: lv3.concepts, + }, + itemStyle: { + color: lv1Color, + opacity: 0.5, + }, + label: { + fontSize: 10, + }, + })) : (lv2.concepts?.slice(0, 5).map(concept => ({ + name: concept, + data: { + type: 'concept', + name: concept, + parentLv1: lv1.name, + parentLv2: lv2.name, + }, + itemStyle: { + color: lv1Color, + opacity: 0.4, + }, + label: { + fontSize: 9, + }, + })) || []), + }; + }) || [], + }; + }), + }; - {avgChange !== null && avgChange !== undefined && ( - - 0 ? FaArrowUp : FaArrowDown} - boxSize={2} - /> - {formatChangePercent(avgChange)} - - )} - - - - - - - ); -}; - -/** - * 二级分类卡片组件 - */ -const Lv2Card = ({ - item, - parentName, - isExpanded, - onToggle, - onSelectCategory, - stats, - colorConfig -}) => { - const hasChildren = item.children && item.children.length > 0; - const avgChange = stats?.avg_change_pct; - const changeColor = getChangeColor(avgChange); - - return ( - - hasChildren ? onToggle() : onSelectCategory(parentName, item.name, null)} - transition="all 0.2s" - _hover={{ - borderColor: `${colorConfig.bg}.400`, - transform: 'translateX(4px)', - boxShadow: '0 6px 25px rgba(0, 0, 0, 0.12)', - }} - > - - - - - - {item.name} - - - - {item.concept_count} 概念 - - {avgChange !== null && avgChange !== undefined && ( - - {formatChangePercent(avgChange)} - - )} - - - - - {hasChildren ? ( - - ) : ( - - )} - - - - {/* 三级分类展开 */} - {hasChildren && ( - - - {item.children.map((lv3Item) => ( - - ))} - - - )} - - ); -}; - -/** - * 三级分类卡片组件 - */ -const Lv3Card = ({ - item, - parentLv1, - parentLv2, - onSelectCategory, - colorConfig -}) => { - return ( - onSelectCategory(parentLv1, parentLv2, item.name)} - transition="all 0.2s" - _hover={{ - bg: `${colorConfig.bg}.100`, - transform: 'translateX(4px)', - }} - > - - - - - {item.name} - - - - {item.concept_count} - - - - {/* 概念标签预览 */} - {item.concepts && item.concepts.length > 0 && ( - - {item.concepts.slice(0, 5).map((concept, idx) => ( - - - {concept} - - - ))} - {item.concepts.length > 5 && ( - - - +{item.concepts.length - 5} - - - )} - - )} - - ); -}; - -/** - * 思维导图连接线 SVG - */ -const ConnectionLine = ({ from, to, isActive }) => { - return ( - - - - ); + return root; }; /** @@ -440,10 +213,12 @@ const HierarchyView = ({ const [hierarchy, setHierarchy] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [expandedLv1, setExpandedLv1] = useState(null); - const [expandedLv2, setExpandedLv2] = useState({}); const [hierarchyStats, setHierarchyStats] = useState(null); + const [layout, setLayout] = useState('radial'); // 'radial' | 'orthogonal' + const [isFullscreen, setIsFullscreen] = useState(false); + const chartRef = useRef(null); + const containerRef = useRef(null); const isMobile = useBreakpointValue({ base: true, md: false }); // 获取层级结构数据 @@ -493,35 +268,148 @@ const HierarchyView = ({ fetchHierarchyStats(); }, [fetchHierarchy, fetchHierarchyStats]); - // 获取某个 lv1 的统计数据 - const getLv1Stats = useCallback((lv1Name) => { - if (!hierarchyStats?.statistics) return null; - return hierarchyStats.statistics.find(s => s.lv1 === lv1Name); - }, [hierarchyStats]); + // ECharts 配置 + const chartOption = useMemo(() => { + const treeData = transformToEChartsData(hierarchy, hierarchyStats); + if (!treeData) return null; - // 切换一级分类展开状态 - const toggleLv1 = useCallback((lv1Id) => { - setExpandedLv1(prev => prev === lv1Id ? null : lv1Id); - setExpandedLv2({}); - }, []); + const isRadial = layout === 'radial'; - // 切换二级分类展开状态 - const toggleLv2 = useCallback((lv2Id) => { - setExpandedLv2(prev => ({ - ...prev, - [lv2Id]: !prev[lv2Id] - })); - }, []); + return { + tooltip: { + trigger: 'item', + triggerOn: 'mousemove', + formatter: (params) => { + const data = params.data?.data || {}; + let content = `${params.name}`; - // 处理分类选择 - const handleSelectCategory = useCallback((lv1, lv2, lv3) => { - logger.info('HierarchyView', '选择分类', { lv1, lv2, lv3 }); - onSelectCategory && onSelectCategory({ lv1, lv2, lv3 }); + if (data.concept_count !== undefined) { + content += `
概念数量: ${data.concept_count}`; + } + if (data.avg_change_pct !== undefined) { + const color = data.avg_change_pct > 0 ? '#EF4444' : data.avg_change_pct < 0 ? '#22C55E' : '#9CA3AF'; + content += `
平均涨跌: ${formatChangePercent(data.avg_change_pct)}`; + } + if (data.type) { + const typeMap = { lv1: '一级分类', lv2: '二级分类', lv3: '三级分类', concept: '概念' }; + content += `
${typeMap[data.type] || ''}`; + } + + return content; + }, + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderColor: '#E5E7EB', + borderWidth: 1, + padding: [8, 12], + textStyle: { + color: '#1F2937', + fontSize: 12, + }, + }, + series: [ + { + type: 'tree', + data: [treeData], + layout: isRadial ? 'radial' : 'orthogonal', + orient: isRadial ? undefined : 'LR', + symbol: 'circle', + symbolSize: (value, params) => { + const data = params.data?.data || {}; + if (data.type === 'lv1') return 24; + if (data.type === 'lv2') return 18; + if (data.type === 'lv3') return 14; + if (data.type === 'concept') return 10; + return 30; // 根节点 + }, + initialTreeDepth: 2, + animationDuration: 550, + animationDurationUpdate: 750, + roam: true, // 启用缩放和平移 + label: { + position: isRadial ? 'radial' : 'right', + verticalAlign: 'middle', + align: isRadial ? undefined : 'left', + fontSize: 12, + distance: 8, + }, + leaves: { + label: { + position: isRadial ? 'radial' : 'right', + verticalAlign: 'middle', + align: isRadial ? undefined : 'left', + }, + }, + emphasis: { + focus: 'descendant', + itemStyle: { + shadowBlur: 10, + shadowColor: 'rgba(0, 0, 0, 0.3)', + }, + }, + expandAndCollapse: true, + lineStyle: { + color: '#CBD5E1', + width: 1.5, + curveness: 0.5, + }, + }, + ], + }; + }, [hierarchy, hierarchyStats, layout]); + + // 处理节点点击 + const handleChartClick = useCallback((params) => { + const data = params.data?.data; + if (!data) return; + + logger.info('HierarchyView', '节点点击', data); + + let filter = { lv1: null, lv2: null, lv3: null }; + + switch (data.type) { + case 'lv1': + filter = { lv1: data.name, lv2: null, lv3: null }; + break; + case 'lv2': + filter = { lv1: data.parentLv1, lv2: data.name, lv3: null }; + break; + case 'lv3': + filter = { lv1: data.parentLv1, lv2: data.parentLv2, lv3: data.name }; + break; + case 'concept': + // 点击具体概念,筛选到其所属的 lv2 + filter = { lv1: data.parentLv1, lv2: data.parentLv2, lv3: null }; + break; + default: + return; + } + + onSelectCategory && onSelectCategory(filter); }, [onSelectCategory]); + // 重置图表视图 + const handleResetView = useCallback(() => { + if (chartRef.current) { + const chart = chartRef.current.getEchartsInstance(); + chart.dispatchAction({ + type: 'restore', + }); + } + }, []); + + // 切换全屏 + const toggleFullscreen = useCallback(() => { + setIsFullscreen(prev => !prev); + }, []); + + // 图表事件 + const chartEvents = useMemo(() => ({ + click: handleChartClick, + }), [handleChartClick]); + if (loading) { return ( -
+
正在加载概念层级... @@ -532,172 +420,172 @@ const HierarchyView = ({ if (error) { return ( -
+
加载失败:{error} + + +
+ ); + } + + if (!chartOption) { + return ( +
+ + + 暂无层级数据
); } return ( - - {/* 标题 */} - - - - - 概念层级导航 - - - - 点击分类展开查看,点击具体类目筛选概念列表 - - - - {/* 思维导图布局 */} - + {/* 工具栏 */} + - {/* 一级分类网格 - 居中展示 */} - - {hierarchy.map((lv1Item) => ( - toggleLv1(lv1Item.id)} - onSelectCategory={handleSelectCategory} - stats={getLv1Stats(lv1Item.name)} + + + + 概念层级导图 + + + 点击节点筛选 + + + + + {/* 布局切换 */} + + + } + onClick={() => setLayout('radial')} + bg={layout === 'radial' ? 'purple.500' : 'transparent'} + color={layout === 'radial' ? 'white' : 'purple.500'} + borderColor="purple.500" + _hover={{ + bg: layout === 'radial' ? 'purple.600' : 'purple.50', + }} + aria-label="径向布局" + /> + + + } + onClick={() => setLayout('orthogonal')} + bg={layout === 'orthogonal' ? 'purple.500' : 'transparent'} + color={layout === 'orthogonal' ? 'white' : 'purple.500'} + borderColor="purple.500" + _hover={{ + bg: layout === 'orthogonal' ? 'purple.600' : 'purple.50', + }} + aria-label="树形布局" + /> + + + + {/* 重置视图 */} + + } + onClick={handleResetView} + variant="outline" + colorScheme="gray" + aria-label="重置视图" /> - ))} - + - {/* 展开的二级分类 */} - {expandedLv1 && ( - - {(() => { - const lv1Item = hierarchy.find(h => h.id === expandedLv1); - if (!lv1Item) return null; + {/* 全屏切换 */} + + : } + onClick={toggleFullscreen} + variant="outline" + colorScheme="gray" + aria-label={isFullscreen ? '退出全屏' : '全屏'} + /> + + + - const colorConfig = LV1_COLORS[lv1Item.name] || { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' }; + {/* 提示 */} + + 🖱️ 滚轮缩放 | 拖拽平移 | 点击节点可展开/收起或筛选概念列表 + - return ( - - {/* 展开分类的标题 */} - - - - {lv1Item.name} - - - {lv1Item.children?.length || 0} 个子分类 - - - {/* 点击筛选该一级分类 */} - handleSelectCategory(lv1Item.name, null, null)} - _hover={{ opacity: 0.8 }} - > - 筛选全部 → - - - - {/* 二级分类列表 */} - - {lv1Item.children?.map((lv2Item) => ( - - toggleLv2(lv2Item.id)} - onSelectCategory={handleSelectCategory} - colorConfig={colorConfig} - /> - - ))} - - - ); - })()} - - )} + {/* ECharts 图表 */} + + {/* 统计信息 */} 共 {hierarchy.length} 个一级分类 {hierarchy.reduce((acc, h) => acc + (h.children?.length || 0), 0)} 个二级分类 {hierarchy.reduce((acc, h) => acc + h.concept_count, 0)} 个概念 diff --git a/src/views/Concept/index.js b/src/views/Concept/index.js index eb8d2c2f..85b10efb 100644 --- a/src/views/Concept/index.js +++ b/src/views/Concept/index.js @@ -363,6 +363,9 @@ const ConceptCenter = () => { // 重新获取数据 fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter); + // 滚动到页面顶部,让用户看到筛选结果 + window.scrollTo({ top: 0, behavior: 'smooth' }); + // 显示提示 toast({ title: '已应用筛选',