update pay ui

This commit is contained in:
2025-12-05 13:46:27 +08:00
parent 306cbfa9ab
commit f8537606d4
2 changed files with 286 additions and 117 deletions

View File

@@ -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);

View File

@@ -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,55 +116,83 @@ const transformToEChartsData = (hierarchy, stats) => {
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,
fontSize: 12,
fontWeight: 'bold',
color: '#1F2937',
formatter: () => {
let text = `${lv1.name}\n{count|${lv1.concept_count}个}`;
if (changeStr) {
text += `\n{change|${changeStr}}`;
}
return text;
},
rich: {
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;
},
children: hasLv3 ? lv2.children.map(lv3 => ({
rich: {
count: {
fontSize: 9,
color: '#9CA3AF',
},
change: {
fontSize: 10,
color: lv2ChangeColor,
fontWeight: 'bold',
},
},
},
children: hasLv3 ? lv2.children.map(lv3 => {
const lv3Price = lv3Map[lv3.name] || {};
const lv3ChangeStr = formatChangePercent(lv3Price.avg_change_pct);
return {
name: lv3.name,
value: lv3.concept_count,
collapsed: true,
data: {
type: 'lv3',
id: lv3.id,
@@ -172,27 +204,15 @@ const transformToEChartsData = (hierarchy, stats) => {
},
itemStyle: {
color: lv1Color,
opacity: 0.5,
opacity: 0.6,
},
label: {
fontSize: 10,
color: '#4B5563',
formatter: `${lv3.name} (${lv3.concept_count})${lv3ChangeStr ? ' ' + lv3ChangeStr : ''}`,
},
})) : (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,
},
})) || []),
};
}) : 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 = `<strong>${params.name}</strong>`;
let content = `<div style="font-weight:bold;margin-bottom:4px">${params.name}</div>`;
if (data.concept_count !== undefined) {
content += `<br/>概念数量: ${data.concept_count}`;
content += `<div>概念数量: ${data.concept_count}</div>`;
}
if (data.stock_count !== undefined) {
content += `<div>成分股数: ${data.stock_count}</div>`;
}
if (data.avg_change_pct !== undefined) {
const color = data.avg_change_pct > 0 ? '#EF4444' : data.avg_change_pct < 0 ? '#22C55E' : '#9CA3AF';
content += `<br/>平均涨跌: <span style="color:${color};font-weight:bold">${formatChangePercent(data.avg_change_pct)}</span>`;
const color = getChangeColor(data.avg_change_pct);
content += `<div>平均涨跌: <span style="color:${color};font-weight:bold">${formatChangePercent(data.avg_change_pct)}</span></div>`;
}
if (data.type) {
const typeMap = { lv1: '一级分类', lv2: '二级分类', lv3: '三级分类', concept: '概念' };
content += `<br/><span style="color:#9CA3AF">${typeMap[data.type] || ''}</span>`;
const typeMap = { lv1: '一级分类', lv2: '二级分类', lv3: '三级分类' };
content += `<div style="color:#9CA3AF;font-size:11px;margin-top:4px">${typeMap[data.type] || ''}</div>`;
}
// 提示可点击
if (data.type && data.type !== 'concept') {
content += `<div style="color:#8B5CF6;font-size:11px;margin-top:4px">点击筛选该分类</div>`;
}
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 = ({
<Flex
justify="space-between"
align="center"
mb={4}
mb={3}
flexWrap="wrap"
gap={2}
>
@@ -468,15 +538,33 @@ const HierarchyView = ({
<Text fontSize="lg" fontWeight="bold" color="gray.800">
概念层级导图
</Text>
<Badge colorScheme="purple" fontSize="xs">
点击节点筛选
{tradeDate && (
<Badge colorScheme="blue" fontSize="xs">
{tradeDate}
</Badge>
)}
{priceLoading && (
<Spinner size="xs" color="purple.500" />
)}
</HStack>
<HStack spacing={2}>
{/* 刷新涨跌幅 */}
<Tooltip label="刷新涨跌幅" placement="top">
<IconButton
size="sm"
icon={<FaSync />}
onClick={handleRefreshPrice}
isLoading={priceLoading}
variant="outline"
colorScheme="blue"
aria-label="刷新涨跌幅"
/>
</Tooltip>
{/* 布局切换 */}
<ButtonGroup size="sm" isAttached variant="outline">
<Tooltip label="径向布局(思维导图)" placement="top">
<Tooltip label="径向布局" placement="top">
<IconButton
icon={<FaProjectDiagram />}
onClick={() => setLayout('radial')}
@@ -530,9 +618,9 @@ const HierarchyView = ({
</HStack>
</Flex>
{/* 提示 */}
{/* 操作提示 */}
<Text fontSize="xs" color="gray.500" mb={2} textAlign="center">
🖱 滚轮缩放 | 拖拽平移 | 点击节点展开/收起或筛选概念列表
🖱 滚轮缩放 | 拖拽平移 | <Text as="span" color="purple.600" fontWeight="medium">点击节点展开/收起</Text> | <Text as="span" color="blue.600" fontWeight="medium"></Text>
</Text>
{/* ECharts 图表 */}
@@ -541,13 +629,13 @@ const HierarchyView = ({
borderColor="gray.200"
borderRadius="xl"
overflow="hidden"
bg="gray.50"
bg="white"
>
<ReactECharts
ref={chartRef}
option={chartOption}
style={{
height: isFullscreen ? 'calc(100vh - 120px)' : isMobile ? '400px' : '600px',
height: isFullscreen ? 'calc(100vh - 140px)' : isMobile ? '450px' : '550px',
width: '100%',
}}
onEvents={chartEvents}
@@ -558,7 +646,7 @@ const HierarchyView = ({
{/* 统计信息 */}
<Flex
justify="center"
mt={4}
mt={3}
gap={3}
flexWrap="wrap"
>
@@ -569,7 +657,7 @@ const HierarchyView = ({
borderRadius="full"
fontSize="xs"
>
{hierarchy.length} 个一级分类
{hierarchy.length} 个一级分类
</Badge>
<Badge
colorScheme="blue"