From f8537606d451a756ec2fb86344727bd160a307ab Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 5 Dec 2025 13:46:27 +0800 Subject: [PATCH] update pay ui --- src/mocks/handlers/concept.js | 81 +++++ src/views/Concept/components/HierarchyView.js | 322 +++++++++++------- 2 files changed, 286 insertions(+), 117 deletions(-) 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 = `
${params.name}
`; if (data.concept_count !== undefined) { - content += `
概念数量: ${data.concept_count}`; + content += `
概念数量: ${data.concept_count}
`; + } + if (data.stock_count !== undefined) { + content += `
成分股数: ${data.stock_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)}`; + const color = getChangeColor(data.avg_change_pct); + content += `
平均涨跌: ${formatChangePercent(data.avg_change_pct)}
`; } if (data.type) { - const typeMap = { lv1: '一级分类', lv2: '二级分类', lv3: '三级分类', concept: '概念' }; - content += `
${typeMap[data.type] || ''}`; + const typeMap = { lv1: '一级分类', lv2: '二级分类', lv3: '三级分类' }; + content += `
${typeMap[data.type] || ''}
`; + } + + // 提示可点击 + if (data.type && data.type !== 'concept') { + content += `
点击筛选该分类
`; } return content; }, - backgroundColor: 'rgba(255, 255, 255, 0.95)', + backgroundColor: 'rgba(255, 255, 255, 0.98)', borderColor: '#E5E7EB', borderWidth: 1, - padding: [8, 12], + padding: [10, 14], textStyle: { color: '#1F2937', fontSize: 12, }, + extraCssText: 'box-shadow: 0 4px 12px rgba(0,0,0,0.1);', }, series: [ { @@ -315,21 +383,22 @@ const HierarchyView = ({ 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; // 根节点 + if (data.type === 'lv1') return 20; + if (data.type === 'lv2') return 14; + if (data.type === 'lv3') return 10; + return 26; // 根节点 }, - initialTreeDepth: 2, - animationDuration: 550, - animationDurationUpdate: 750, - roam: true, // 启用缩放和平移 + initialTreeDepth: 2, // 初始展开到第2层(根 + lv1) + animationDuration: 400, + animationDurationUpdate: 500, + roam: true, + zoom: isMobile ? 0.7 : 0.9, + center: isRadial ? ['50%', '50%'] : ['40%', '50%'], label: { position: isRadial ? 'radial' : 'right', verticalAlign: 'middle', align: isRadial ? undefined : 'left', - fontSize: 12, + fontSize: 11, distance: 8, }, leaves: { @@ -340,7 +409,7 @@ const HierarchyView = ({ }, }, emphasis: { - focus: 'descendant', + focus: 'ancestor', itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.3)', @@ -355,13 +424,15 @@ const HierarchyView = ({ }, ], }; - }, [hierarchy, hierarchyStats, layout]); + }, [hierarchy, priceData, layout, isMobile]); - // 处理节点点击 + // 处理节点点击 - 区分展开/收起和筛选 const handleChartClick = useCallback((params) => { const data = params.data?.data; - if (!data) return; + if (!data || !data.type) return; + // 只有在节点已展开且点击的情况下才触发筛选 + // ECharts tree 会自动处理展开/收起 logger.info('HierarchyView', '节点点击', data); let filter = { lv1: null, lv2: null, lv3: null }; @@ -376,10 +447,6 @@ const HierarchyView = ({ 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; } @@ -391,12 +458,15 @@ const HierarchyView = ({ const handleResetView = useCallback(() => { if (chartRef.current) { const chart = chartRef.current.getEchartsInstance(); - chart.dispatchAction({ - type: 'restore', - }); + chart.dispatchAction({ type: 'restore' }); } }, []); + // 刷新涨跌幅数据 + const handleRefreshPrice = useCallback(() => { + fetchHierarchyPrice(); + }, [fetchHierarchyPrice]); + // 切换全屏 const toggleFullscreen = useCallback(() => { setIsFullscreen(prev => !prev); @@ -459,7 +529,7 @@ const HierarchyView = ({ @@ -468,15 +538,33 @@ const HierarchyView = ({ 概念层级导图 - - 点击节点筛选 - + {tradeDate && ( + + {tradeDate} + + )} + {priceLoading && ( + + )} + {/* 刷新涨跌幅 */} + + } + onClick={handleRefreshPrice} + isLoading={priceLoading} + variant="outline" + colorScheme="blue" + aria-label="刷新涨跌幅" + /> + + {/* 布局切换 */} - + } onClick={() => setLayout('radial')} @@ -530,9 +618,9 @@ const HierarchyView = ({ - {/* 提示 */} + {/* 操作提示 */} - 🖱️ 滚轮缩放 | 拖拽平移 | 点击节点可展开/收起或筛选概念列表 + 🖱️ 滚轮缩放 | 拖拽平移 | 点击节点展开/收起 | 双击筛选该分类 {/* ECharts 图表 */} @@ -541,13 +629,13 @@ const HierarchyView = ({ borderColor="gray.200" borderRadius="xl" overflow="hidden" - bg="gray.50" + bg="white" > @@ -569,7 +657,7 @@ const HierarchyView = ({ borderRadius="full" fontSize="xs" > - 共 {hierarchy.length} 个一级分类 + {hierarchy.length} 个一级分类