diff --git a/src/mocks/handlers/concept.js b/src/mocks/handlers/concept.js index 26345eda..ac8d1557 100644 --- a/src/mocks/handlers/concept.js +++ b/src/mocks/handlers/concept.js @@ -670,6 +670,87 @@ export const conceptHandlers = [ }); }), + // 获取层级涨跌幅数据(实时价格) + http.get('/concept-api/hierarchy/price', async ({ request }) => { + await delay(200); + + const url = new URL(request.url); + const tradeDate = url.searchParams.get('trade_date'); + + console.log('[Mock Concept] 获取层级涨跌幅数据:', { tradeDate }); + + // 模拟 lv1 层级涨跌幅数据 + const lv1_concepts = [ + { concept_name: '人工智能', avg_change_pct: 3.56, stock_count: 245 }, + { concept_name: '半导体', avg_change_pct: 2.12, stock_count: 156 }, + { concept_name: '机器人', avg_change_pct: 4.28, stock_count: 128 }, + { concept_name: '消费电子', avg_change_pct: 1.45, stock_count: 98 }, + { concept_name: '智能驾驶与汽车', avg_change_pct: 2.89, stock_count: 112 }, + { concept_name: '新能源与电力', avg_change_pct: -0.56, stock_count: 186 }, + { concept_name: '空天经济', avg_change_pct: 3.12, stock_count: 76 }, + { concept_name: '国防军工', avg_change_pct: 1.78, stock_count: 89 } + ]; + + // 模拟 lv2 层级涨跌幅数据 + const lv2_concepts = [ + // 人工智能下的 lv2 + { concept_name: 'AI基础设施', avg_change_pct: 4.12, stock_count: 85 }, + { concept_name: 'AI模型与软件', avg_change_pct: 5.67, stock_count: 42 }, + { concept_name: 'AI应用', avg_change_pct: 2.34, stock_count: 65 }, + // 半导体下的 lv2 + { concept_name: '半导体设备', avg_change_pct: 3.21, stock_count: 38 }, + { concept_name: '半导体材料', avg_change_pct: 1.89, stock_count: 32 }, + { concept_name: '芯片设计与制造', avg_change_pct: 2.45, stock_count: 56 }, + { concept_name: '先进封装', avg_change_pct: 1.23, stock_count: 22 }, + // 机器人下的 lv2 + { concept_name: '人形机器人整机', avg_change_pct: 5.89, stock_count: 45 }, + { concept_name: '机器人核心零部件', avg_change_pct: 3.45, stock_count: 52 }, + { concept_name: '其他类型机器人', avg_change_pct: 2.12, stock_count: 31 }, + // 消费电子下的 lv2 + { concept_name: '智能终端', avg_change_pct: 1.78, stock_count: 28 }, + { concept_name: 'XR与空间计算', avg_change_pct: 2.56, stock_count: 36 }, + { concept_name: '华为产业链', avg_change_pct: 0.89, stock_count: 48 }, + // 智能驾驶下的 lv2 + { concept_name: '自动驾驶解决方案', avg_change_pct: 4.23, stock_count: 35 }, + { concept_name: '智能汽车产业链', avg_change_pct: 2.45, stock_count: 52 }, + { concept_name: '车路协同', avg_change_pct: 1.56, stock_count: 25 }, + // 新能源下的 lv2 + { concept_name: '新型电池技术', avg_change_pct: 0.67, stock_count: 62 }, + { concept_name: '电力设备与电网', avg_change_pct: -1.23, stock_count: 78 }, + { concept_name: '清洁能源', avg_change_pct: -0.45, stock_count: 46 }, + // 空天经济下的 lv2 + { concept_name: '低空经济', avg_change_pct: 4.56, stock_count: 42 }, + { concept_name: '商业航天', avg_change_pct: 1.89, stock_count: 34 }, + // 国防军工下的 lv2 + { concept_name: '无人作战与信息化', avg_change_pct: 2.34, stock_count: 28 }, + { concept_name: '海军装备', avg_change_pct: 1.45, stock_count: 32 }, + { concept_name: '军贸出海', avg_change_pct: 1.12, stock_count: 18 } + ]; + + // 模拟 lv3 层级涨跌幅数据 + const lv3_concepts = [ + // AI基础设施下的 lv3 + { concept_name: 'AI算力硬件', avg_change_pct: 5.23, stock_count: 32 }, + { concept_name: 'AI关键组件', avg_change_pct: 3.89, stock_count: 45 }, + { concept_name: 'AI配套设施', avg_change_pct: 2.67, stock_count: 28 }, + // AI应用下的 lv3 + { concept_name: '智能体与陪伴', avg_change_pct: 3.12, stock_count: 24 }, + { concept_name: '行业应用', avg_change_pct: 1.56, stock_count: 18 } + ]; + + // 计算交易日期(如果没有传入则使用今天) + const today = tradeDate ? new Date(tradeDate) : new Date(); + const tradeDateStr = today.toISOString().split('T')[0]; + + return HttpResponse.json({ + trade_date: tradeDateStr, + lv1_concepts, + lv2_concepts, + lv3_concepts, + update_time: new Date().toISOString() + }); + }), + // 获取指定层级的概念列表 http.get('/concept-api/hierarchy/:lv1Id', async ({ params, request }) => { await delay(300); diff --git a/src/views/Concept/components/HierarchyView.js b/src/views/Concept/components/HierarchyView.js index bcfffde5..cae3bb51 100644 --- a/src/views/Concept/components/HierarchyView.js +++ b/src/views/Concept/components/HierarchyView.js @@ -1,8 +1,11 @@ /** * HierarchyView - 概念层级思维导图视图 * - * 使用 ECharts Tree 图表实现真正的思维导图效果 - * 支持径向布局(radial)和正交布局(orthogonal) + * 使用 ECharts Tree 图表实现思维导图效果 + * 特性: + * 1. 默认只展示前两层(根节点 + lv1),点击节点展开下层 + * 2. 集成 /hierarchy/price 接口获取实时涨跌幅 + * 3. 支持径向布局和正交布局切换 */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { @@ -29,6 +32,7 @@ import { FaExpand, FaCompress, FaRedo, + FaSync, } from 'react-icons/fa'; import { logger } from '../../../utils/logger'; @@ -66,17 +70,14 @@ const formatChangePercent = (value) => { /** * 将后端层级数据转换为 ECharts tree 格式 + * @param {Array} hierarchy - 层级结构数据 + * @param {Object} priceData - 涨跌幅数据 { lv1Map, lv2Map, lv3Map } + * @param {number} initialDepth - 初始展开深度 (1 = 只展示lv1, 2 = 展示到lv2) */ -const transformToEChartsData = (hierarchy, stats) => { +const transformToEChartsData = (hierarchy, priceData, initialDepth = 1) => { if (!hierarchy || hierarchy.length === 0) return null; - // 创建统计数据映射 - const statsMap = {}; - if (stats?.statistics) { - stats.statistics.forEach(s => { - statsMap[s.lv1] = s; - }); - } + const { lv1Map = {}, lv2Map = {}, lv3Map = {} } = priceData || {}; // 根节点 const root = { @@ -87,24 +88,27 @@ const transformToEChartsData = (hierarchy, stats) => { borderWidth: 3, }, label: { - fontSize: 16, + fontSize: 14, fontWeight: 'bold', + color: '#1F2937', }, children: hierarchy.map(lv1 => { - const lv1Stats = statsMap[lv1.name] || {}; + const lv1Price = lv1Map[lv1.name] || {}; const lv1Color = LV1_COLORS[lv1.name] || '#8B5CF6'; - const changeColor = getChangeColor(lv1Stats.avg_change_pct); + const changeColor = getChangeColor(lv1Price.avg_change_pct); + const changeStr = formatChangePercent(lv1Price.avg_change_pct); return { name: lv1.name, value: lv1.concept_count, - // 自定义数据用于点击事件 + collapsed: initialDepth < 2, // 初始是否折叠 data: { type: 'lv1', id: lv1.id, name: lv1.name, concept_count: lv1.concept_count, - avg_change_pct: lv1Stats.avg_change_pct, + avg_change_pct: lv1Price.avg_change_pct, + stock_count: lv1Price.stock_count, }, itemStyle: { color: lv1Color, @@ -112,87 +116,103 @@ const transformToEChartsData = (hierarchy, stats) => { borderWidth: 2, }, label: { - fontSize: 13, + fontSize: 12, 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}}` : ''}`; + color: '#1F2937', + formatter: () => { + let text = `${lv1.name}\n{count|${lv1.concept_count}个}`; + if (changeStr) { + text += `\n{change|${changeStr}}`; + } + return text; }, rich: { - name: { - fontSize: 13, - fontWeight: 'bold', - color: '#1F2937', - }, count: { - fontSize: 11, + fontSize: 10, color: '#6B7280', + lineHeight: 16, }, change: { fontSize: 11, color: changeColor, fontWeight: 'bold', + lineHeight: 16, }, }, }, children: lv1.children?.map(lv2 => { + const lv2Price = lv2Map[lv2.name] || {}; + const lv2ChangeColor = getChangeColor(lv2Price.avg_change_pct); + const lv2ChangeStr = formatChangePercent(lv2Price.avg_change_pct); const hasLv3 = lv2.children && lv2.children.length > 0; return { name: lv2.name, value: lv2.concept_count, + collapsed: true, // lv2 默认折叠 data: { type: 'lv2', id: lv2.id, name: lv2.name, parentLv1: lv1.name, concept_count: lv2.concept_count, + avg_change_pct: lv2Price.avg_change_pct, + stock_count: lv2Price.stock_count, }, itemStyle: { color: lv1Color, - opacity: 0.7, + opacity: 0.8, }, label: { fontSize: 11, - formatter: `{b}\n(${lv2.concept_count})`, + color: '#374151', + formatter: () => { + let text = `${lv2.name}\n{count|(${lv2.concept_count})}`; + if (lv2ChangeStr) { + text += ` {change|${lv2ChangeStr}}`; + } + return text; + }, + rich: { + count: { + fontSize: 9, + color: '#9CA3AF', + }, + change: { + fontSize: 10, + color: lv2ChangeColor, + fontWeight: 'bold', + }, + }, }, - children: hasLv3 ? lv2.children.map(lv3 => ({ - name: lv3.name, - value: lv3.concept_count, - data: { - type: 'lv3', - id: lv3.id, + children: hasLv3 ? lv2.children.map(lv3 => { + const lv3Price = lv3Map[lv3.name] || {}; + const lv3ChangeStr = formatChangePercent(lv3Price.avg_change_pct); + + return { 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, - }, - })) || []), + value: lv3.concept_count, + collapsed: true, + 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.6, + }, + label: { + fontSize: 10, + color: '#4B5563', + formatter: `${lv3.name} (${lv3.concept_count})${lv3ChangeStr ? ' ' + lv3ChangeStr : ''}`, + }, + }; + }) : undefined, }; }) || [], }; @@ -213,7 +233,9 @@ const HierarchyView = ({ const [hierarchy, setHierarchy] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hierarchyStats, setHierarchyStats] = useState(null); + const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {} }); + const [priceLoading, setPriceLoading] = useState(false); + const [tradeDate, setTradeDate] = useState(null); const [layout, setLayout] = useState('radial'); // 'radial' | 'orthogonal' const [isFullscreen, setIsFullscreen] = useState(false); @@ -245,32 +267,69 @@ const HierarchyView = ({ } }, [apiBaseUrl]); - // 获取层级统计数据(包含涨跌幅) - const fetchHierarchyStats = useCallback(async () => { + // 获取层级涨跌幅数据 + const fetchHierarchyPrice = useCallback(async () => { + setPriceLoading(true); + try { - const response = await fetch(`${apiBaseUrl}/statistics/hierarchy`); - if (!response.ok) return; + let url = `${apiBaseUrl}/hierarchy/price`; + if (selectedDate) { + const dateStr = selectedDate.toISOString().split('T')[0]; + url += `?trade_date=${dateStr}`; + } + + const response = await fetch(url); + if (!response.ok) { + logger.warn('HierarchyView', '获取层级涨跌幅失败', { status: response.status }); + return; + } const data = await response.json(); - setHierarchyStats(data); - logger.info('HierarchyView', '层级统计加载完成', { - totalLv1: data.total_lv1, - totalConcepts: data.total_concepts + // 构建映射表 + const lv1Map = {}; + const lv2Map = {}; + const lv3Map = {}; + + (data.lv1_concepts || []).forEach(item => { + lv1Map[item.concept_name] = item; + }); + (data.lv2_concepts || []).forEach(item => { + lv2Map[item.concept_name] = item; + }); + (data.lv3_concepts || []).forEach(item => { + lv3Map[item.concept_name] = item; + }); + + setPriceData({ lv1Map, lv2Map, lv3Map }); + setTradeDate(data.trade_date); + + logger.info('HierarchyView', '层级涨跌幅加载完成', { + lv1Count: Object.keys(lv1Map).length, + lv2Count: Object.keys(lv2Map).length, + lv3Count: Object.keys(lv3Map).length, + tradeDate: data.trade_date }); } catch (err) { - logger.warn('HierarchyView', '获取层级统计失败', { error: err.message }); + logger.warn('HierarchyView', '获取层级涨跌幅失败', { error: err.message }); + } finally { + setPriceLoading(false); } - }, [apiBaseUrl]); + }, [apiBaseUrl, selectedDate]); useEffect(() => { fetchHierarchy(); - fetchHierarchyStats(); - }, [fetchHierarchy, fetchHierarchyStats]); + }, [fetchHierarchy]); + + useEffect(() => { + if (hierarchy.length > 0) { + fetchHierarchyPrice(); + } + }, [hierarchy, fetchHierarchyPrice]); // ECharts 配置 const chartOption = useMemo(() => { - const treeData = transformToEChartsData(hierarchy, hierarchyStats); + const treeData = transformToEChartsData(hierarchy, priceData, 1); if (!treeData) return null; const isRadial = layout === 'radial'; @@ -281,30 +340,39 @@ const HierarchyView = ({ triggerOn: 'mousemove', formatter: (params) => { const data = params.data?.data || {}; - let content = `${params.name}`; + let content = `