update pay ui
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* HierarchyView - 概念层级思维导图视图
|
* HierarchyView - 概念层级热力图视图
|
||||||
*
|
*
|
||||||
* 使用 ECharts Tree 图表实现思维导图效果
|
* 使用 ECharts Treemap 实现热力图效果
|
||||||
* 特性:
|
* 特性:
|
||||||
* 1. 默认只展示前两层(根节点 + lv1),点击节点展开下层
|
* 1. 炫酷的矩形树图/热力图展示
|
||||||
* 2. 集成 /hierarchy/price 接口获取实时涨跌幅
|
* 2. 点击 lv1 进入 lv2,点击 lv2 进入 lv3,层层钻取
|
||||||
* 3. 支持径向布局和正交布局切换
|
* 3. 集成 /hierarchy/price 接口获取实时涨跌幅
|
||||||
|
* 4. 支持面包屑导航返回上级
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Flex,
|
Flex,
|
||||||
ButtonGroup,
|
|
||||||
Button,
|
Button,
|
||||||
useBreakpointValue,
|
useBreakpointValue,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -27,203 +27,63 @@ import {
|
|||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import {
|
import {
|
||||||
FaLayerGroup,
|
FaLayerGroup,
|
||||||
FaSitemap,
|
|
||||||
FaProjectDiagram,
|
|
||||||
FaExpand,
|
FaExpand,
|
||||||
FaCompress,
|
FaCompress,
|
||||||
FaRedo,
|
|
||||||
FaSync,
|
FaSync,
|
||||||
|
FaHome,
|
||||||
|
FaChevronRight,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
// 一级分类颜色映射
|
// 一级分类颜色映射
|
||||||
const LV1_COLORS = {
|
const LV1_COLORS = {
|
||||||
'人工智能': '#8B5CF6',
|
'人工智能': ['#8B5CF6', '#A78BFA', '#C4B5FD'],
|
||||||
'半导体': '#3B82F6',
|
'半导体': ['#3B82F6', '#60A5FA', '#93C5FD'],
|
||||||
'机器人': '#10B981',
|
'机器人': ['#10B981', '#34D399', '#6EE7B7'],
|
||||||
'消费电子': '#EC4899',
|
'消费电子': ['#EC4899', '#F472B6', '#F9A8D4'],
|
||||||
'智能驾驶与汽车': '#F97316',
|
'智能驾驶与汽车': ['#F97316', '#FB923C', '#FDBA74'],
|
||||||
'新能源与电力': '#22C55E',
|
'新能源与电力': ['#22C55E', '#4ADE80', '#86EFAC'],
|
||||||
'空天经济': '#06B6D4',
|
'空天经济': ['#06B6D4', '#22D3EE', '#67E8F9'],
|
||||||
'国防军工': '#EF4444',
|
'国防军工': ['#EF4444', '#F87171', '#FCA5A5'],
|
||||||
'政策与主题': '#F59E0B',
|
'政策与主题': ['#F59E0B', '#FBBF24', '#FCD34D'],
|
||||||
'周期与材料': '#6B7280',
|
'周期与材料': ['#6B7280', '#9CA3AF', '#D1D5DB'],
|
||||||
'大消费': '#F472B6',
|
'大消费': ['#F472B6', '#F9A8D4', '#FBCFE8'],
|
||||||
'数字经济与金融科技': '#6366F1',
|
'数字经济与金融科技': ['#6366F1', '#818CF8', '#A5B4FC'],
|
||||||
'全球宏观与贸易': '#14B8A6',
|
'全球宏观与贸易': ['#14B8A6', '#2DD4BF', '#5EEAD4'],
|
||||||
'医药健康': '#84CC16',
|
'医药健康': ['#84CC16', '#A3E635', '#BEF264'],
|
||||||
'前沿科技': '#A855F7',
|
'前沿科技': ['#A855F7', '#C084FC', '#D8B4FE'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取涨跌幅颜色
|
// 获取涨跌幅颜色(红涨绿跌)
|
||||||
const getChangeColor = (value) => {
|
const getChangeColor = (value) => {
|
||||||
if (value === null || value === undefined) return '#9CA3AF';
|
if (value === null || value === undefined) return '#9CA3AF';
|
||||||
return value > 0 ? '#EF4444' : value < 0 ? '#22C55E' : '#9CA3AF';
|
if (value > 3) return '#DC2626';
|
||||||
|
if (value > 1) return '#EF4444';
|
||||||
|
if (value > 0) return '#F87171';
|
||||||
|
if (value < -3) return '#15803D';
|
||||||
|
if (value < -1) return '#22C55E';
|
||||||
|
if (value < 0) return '#4ADE80';
|
||||||
|
return '#9CA3AF';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 格式化涨跌幅
|
// 格式化涨跌幅
|
||||||
const formatChangePercent = (value) => {
|
const formatChangePercent = (value) => {
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null || value === undefined) return '--';
|
||||||
const formatted = Math.abs(value).toFixed(2);
|
const formatted = Math.abs(value).toFixed(2);
|
||||||
return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%';
|
return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%';
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// 获取 lv1 的颜色
|
||||||
* 将后端层级数据转换为 ECharts tree 格式
|
const getLv1Color = (name, index = 0) => {
|
||||||
* @param {Array} hierarchy - 层级结构数据
|
const colors = LV1_COLORS[name];
|
||||||
* @param {Object} priceData - 涨跌幅数据 { lv1Map, lv2Map, lv3Map }
|
if (colors) return colors[Math.min(index, colors.length - 1)];
|
||||||
* @param {number} initialDepth - 初始展开深度 (1 = 只展示lv1, 2 = 展示到lv2)
|
// 默认颜色
|
||||||
*/
|
const defaultColors = ['#8B5CF6', '#A78BFA', '#C4B5FD'];
|
||||||
const transformToEChartsData = (hierarchy, priceData, initialDepth = 1) => {
|
return defaultColors[Math.min(index, defaultColors.length - 1)];
|
||||||
if (!hierarchy || hierarchy.length === 0) return null;
|
|
||||||
|
|
||||||
const { lv1Map = {}, lv2Map = {}, lv3Map = {} } = priceData || {};
|
|
||||||
|
|
||||||
// 根节点
|
|
||||||
const root = {
|
|
||||||
name: '概念中心',
|
|
||||||
itemStyle: {
|
|
||||||
color: '#8B5CF6',
|
|
||||||
borderColor: '#7C3AED',
|
|
||||||
borderWidth: 3,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#1F2937',
|
|
||||||
},
|
|
||||||
children: hierarchy.map(lv1 => {
|
|
||||||
const lv1Price = lv1Map[lv1.name] || {};
|
|
||||||
const lv1Color = LV1_COLORS[lv1.name] || '#8B5CF6';
|
|
||||||
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: lv1Price.avg_change_pct,
|
|
||||||
stock_count: lv1Price.stock_count,
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color: lv1Color,
|
|
||||||
borderColor: lv1Color,
|
|
||||||
borderWidth: 2,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
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: 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.8,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 11,
|
|
||||||
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 => {
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}) || [],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return root;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主组件:层级视图
|
* 主组件:层级热力图视图
|
||||||
*/
|
*/
|
||||||
const HierarchyView = ({
|
const HierarchyView = ({
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
@@ -236,9 +96,14 @@ const HierarchyView = ({
|
|||||||
const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {} });
|
const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {} });
|
||||||
const [priceLoading, setPriceLoading] = useState(false);
|
const [priceLoading, setPriceLoading] = useState(false);
|
||||||
const [tradeDate, setTradeDate] = useState(null);
|
const [tradeDate, setTradeDate] = useState(null);
|
||||||
const [layout, setLayout] = useState('radial'); // 'radial' | 'orthogonal'
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
// 钻取状态
|
||||||
|
const [currentLevel, setCurrentLevel] = useState('lv1'); // 'lv1' | 'lv2' | 'lv3'
|
||||||
|
const [currentLv1, setCurrentLv1] = useState(null);
|
||||||
|
const [currentLv2, setCurrentLv2] = useState(null);
|
||||||
|
const [breadcrumbs, setBreadcrumbs] = useState([{ label: '全部分类', level: 'root' }]);
|
||||||
|
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||||
@@ -327,39 +192,145 @@ const HierarchyView = ({
|
|||||||
}
|
}
|
||||||
}, [hierarchy, fetchHierarchyPrice]);
|
}, [hierarchy, fetchHierarchyPrice]);
|
||||||
|
|
||||||
|
// 根据当前层级获取显示数据
|
||||||
|
const currentData = useMemo(() => {
|
||||||
|
const { lv1Map, lv2Map, lv3Map } = priceData;
|
||||||
|
|
||||||
|
if (currentLevel === 'lv1') {
|
||||||
|
// 显示所有 lv1
|
||||||
|
return hierarchy.map((lv1, index) => {
|
||||||
|
const price = lv1Map[lv1.name] || {};
|
||||||
|
return {
|
||||||
|
name: lv1.name,
|
||||||
|
value: lv1.concept_count || 10,
|
||||||
|
id: lv1.id,
|
||||||
|
level: 'lv1',
|
||||||
|
concept_count: lv1.concept_count,
|
||||||
|
stock_count: price.stock_count,
|
||||||
|
avg_change_pct: price.avg_change_pct,
|
||||||
|
children: lv1.children,
|
||||||
|
itemStyle: {
|
||||||
|
color: getLv1Color(lv1.name, 0),
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLevel === 'lv2' && currentLv1) {
|
||||||
|
// 显示选中 lv1 下的 lv2
|
||||||
|
const lv1Data = hierarchy.find(h => h.name === currentLv1.name);
|
||||||
|
if (!lv1Data || !lv1Data.children) return [];
|
||||||
|
|
||||||
|
return lv1Data.children.map((lv2, index) => {
|
||||||
|
const price = lv2Map[lv2.name] || {};
|
||||||
|
return {
|
||||||
|
name: lv2.name,
|
||||||
|
value: lv2.concept_count || 5,
|
||||||
|
id: lv2.id,
|
||||||
|
level: 'lv2',
|
||||||
|
parentLv1: currentLv1.name,
|
||||||
|
concept_count: lv2.concept_count,
|
||||||
|
stock_count: price.stock_count,
|
||||||
|
avg_change_pct: price.avg_change_pct,
|
||||||
|
children: lv2.children,
|
||||||
|
concepts: lv2.concepts,
|
||||||
|
itemStyle: {
|
||||||
|
color: getLv1Color(currentLv1.name, index % 3),
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLevel === 'lv3' && currentLv1 && currentLv2) {
|
||||||
|
// 显示选中 lv2 下的 lv3 或概念
|
||||||
|
const lv1Data = hierarchy.find(h => h.name === currentLv1.name);
|
||||||
|
if (!lv1Data || !lv1Data.children) return [];
|
||||||
|
|
||||||
|
const lv2Data = lv1Data.children.find(h => h.name === currentLv2.name);
|
||||||
|
if (!lv2Data) return [];
|
||||||
|
|
||||||
|
// 如果有 lv3 子级
|
||||||
|
if (lv2Data.children && lv2Data.children.length > 0) {
|
||||||
|
return lv2Data.children.map((lv3, index) => {
|
||||||
|
const price = lv3Map[lv3.name] || {};
|
||||||
|
return {
|
||||||
|
name: lv3.name,
|
||||||
|
value: lv3.concept_count || 3,
|
||||||
|
id: lv3.id,
|
||||||
|
level: 'lv3',
|
||||||
|
parentLv1: currentLv1.name,
|
||||||
|
parentLv2: currentLv2.name,
|
||||||
|
concept_count: lv3.concept_count,
|
||||||
|
stock_count: price.stock_count,
|
||||||
|
avg_change_pct: price.avg_change_pct,
|
||||||
|
concepts: lv3.concepts,
|
||||||
|
itemStyle: {
|
||||||
|
color: getLv1Color(currentLv1.name, index % 3),
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果直接是概念列表
|
||||||
|
if (lv2Data.concepts && lv2Data.concepts.length > 0) {
|
||||||
|
return lv2Data.concepts.map((concept, index) => ({
|
||||||
|
name: concept,
|
||||||
|
value: 1,
|
||||||
|
level: 'concept',
|
||||||
|
parentLv1: currentLv1.name,
|
||||||
|
parentLv2: currentLv2.name,
|
||||||
|
itemStyle: {
|
||||||
|
color: getLv1Color(currentLv1.name, index % 3),
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [hierarchy, priceData, currentLevel, currentLv1, currentLv2]);
|
||||||
|
|
||||||
// ECharts 配置
|
// ECharts 配置
|
||||||
const chartOption = useMemo(() => {
|
const chartOption = useMemo(() => {
|
||||||
const treeData = transformToEChartsData(hierarchy, priceData, 1);
|
if (!currentData || currentData.length === 0) return null;
|
||||||
if (!treeData) return null;
|
|
||||||
|
|
||||||
const isRadial = layout === 'radial';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
triggerOn: 'mousemove',
|
|
||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
const data = params.data?.data || {};
|
const data = params.data || {};
|
||||||
let content = `<div style="font-weight:bold;margin-bottom:4px">${params.name}</div>`;
|
let content = `<div style="font-weight:bold;font-size:14px;margin-bottom:8px">${data.name}</div>`;
|
||||||
|
|
||||||
|
if (data.avg_change_pct !== undefined && data.avg_change_pct !== null) {
|
||||||
|
const color = getChangeColor(data.avg_change_pct);
|
||||||
|
content += `<div style="margin-bottom:4px">平均涨跌: <span style="color:${color};font-weight:bold;font-size:16px">${formatChangePercent(data.avg_change_pct)}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.concept_count !== undefined) {
|
if (data.concept_count !== undefined) {
|
||||||
content += `<div>概念数量: ${data.concept_count}</div>`;
|
content += `<div style="margin-bottom:4px">概念数量: <span style="font-weight:bold">${data.concept_count}</span></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.stock_count !== undefined) {
|
if (data.stock_count !== undefined) {
|
||||||
content += `<div>成分股数: ${data.stock_count}</div>`;
|
content += `<div style="margin-bottom:4px">成分股数: <span style="font-weight:bold">${data.stock_count}</span></div>`;
|
||||||
}
|
}
|
||||||
if (data.avg_change_pct !== undefined) {
|
|
||||||
const color = getChangeColor(data.avg_change_pct);
|
const levelMap = { lv1: '一级分类', lv2: '二级分类', lv3: '三级分类', concept: '概念' };
|
||||||
content += `<div>平均涨跌: <span style="color:${color};font-weight:bold">${formatChangePercent(data.avg_change_pct)}</span></div>`;
|
if (data.level) {
|
||||||
}
|
content += `<div style="color:#9CA3AF;font-size:11px;margin-top:8px;padding-top:8px;border-top:1px solid #E5E7EB">${levelMap[data.level] || ''}</div>`;
|
||||||
if (data.type) {
|
|
||||||
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') {
|
if (data.level && data.level !== 'concept' && data.children) {
|
||||||
content += `<div style="color:#8B5CF6;font-size:11px;margin-top:4px">点击筛选该分类</div>`;
|
content += `<div style="color:#8B5CF6;font-size:12px;margin-top:4px">👆 点击查看下级分类</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
@@ -367,100 +338,146 @@ const HierarchyView = ({
|
|||||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||||
borderColor: '#E5E7EB',
|
borderColor: '#E5E7EB',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
padding: [10, 14],
|
padding: [12, 16],
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: '#1F2937',
|
color: '#1F2937',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
extraCssText: 'box-shadow: 0 4px 12px rgba(0,0,0,0.1);',
|
extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,0.15); border-radius: 8px;',
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'tree',
|
type: 'treemap',
|
||||||
data: [treeData],
|
data: currentData,
|
||||||
layout: isRadial ? 'radial' : 'orthogonal',
|
width: '100%',
|
||||||
orient: isRadial ? undefined : 'LR',
|
height: '100%',
|
||||||
symbol: 'circle',
|
roam: false,
|
||||||
symbolSize: (value, params) => {
|
nodeClick: false, // 禁用默认钻取,使用自定义
|
||||||
const data = params.data?.data || {};
|
breadcrumb: {
|
||||||
if (data.type === 'lv1') return 20;
|
show: false, // 使用自定义面包屑
|
||||||
if (data.type === 'lv2') return 14;
|
|
||||||
if (data.type === 'lv3') return 10;
|
|
||||||
return 26; // 根节点
|
|
||||||
},
|
},
|
||||||
initialTreeDepth: 2, // 初始展开到第2层(根 + lv1)
|
|
||||||
animationDuration: 400,
|
|
||||||
animationDurationUpdate: 500,
|
|
||||||
roam: true,
|
|
||||||
zoom: isMobile ? 0.7 : 0.9,
|
|
||||||
center: isRadial ? ['50%', '50%'] : ['40%', '50%'],
|
|
||||||
label: {
|
label: {
|
||||||
position: isRadial ? 'radial' : 'right',
|
show: true,
|
||||||
verticalAlign: 'middle',
|
formatter: (params) => {
|
||||||
align: isRadial ? undefined : 'left',
|
const data = params.data || {};
|
||||||
fontSize: 11,
|
const name = data.name || '';
|
||||||
distance: 8,
|
const changeStr = data.avg_change_pct !== undefined && data.avg_change_pct !== null
|
||||||
},
|
? formatChangePercent(data.avg_change_pct)
|
||||||
leaves: {
|
: '';
|
||||||
label: {
|
|
||||||
position: isRadial ? 'radial' : 'right',
|
// 根据区块大小决定显示内容
|
||||||
verticalAlign: 'middle',
|
if (params.value < 3) {
|
||||||
align: isRadial ? undefined : 'left',
|
return name.length > 4 ? name.slice(0, 4) + '...' : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeStr) {
|
||||||
|
return `{name|${name}}\n{change|${changeStr}}`;
|
||||||
|
}
|
||||||
|
return `{name|${name}}`;
|
||||||
},
|
},
|
||||||
|
rich: {
|
||||||
|
name: {
|
||||||
|
fontSize: isMobile ? 12 : 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#fff',
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.3)',
|
||||||
|
textShadowBlur: 2,
|
||||||
|
lineHeight: isMobile ? 18 : 22,
|
||||||
|
},
|
||||||
|
change: {
|
||||||
|
fontSize: isMobile ? 14 : 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#fff',
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.5)',
|
||||||
|
textShadowBlur: 3,
|
||||||
|
lineHeight: isMobile ? 20 : 26,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
position: 'inside',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
upperLabel: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 3,
|
||||||
|
gapWidth: 3,
|
||||||
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
emphasis: {
|
emphasis: {
|
||||||
focus: 'ancestor',
|
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
shadowBlur: 10,
|
shadowBlur: 20,
|
||||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: isMobile ? 14 : 16,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
expandAndCollapse: true,
|
levels: [
|
||||||
lineStyle: {
|
{
|
||||||
color: '#CBD5E1',
|
itemStyle: {
|
||||||
width: 1.5,
|
borderColor: '#fff',
|
||||||
curveness: 0.5,
|
borderWidth: 4,
|
||||||
},
|
gapWidth: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
animationDuration: 500,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}, [hierarchy, priceData, layout, isMobile]);
|
}, [currentData, isMobile]);
|
||||||
|
|
||||||
// 处理节点点击 - 区分展开/收起和筛选
|
// 处理点击事件 - 钻取
|
||||||
const handleChartClick = useCallback((params) => {
|
const handleChartClick = useCallback((params) => {
|
||||||
const data = params.data?.data;
|
const data = params.data;
|
||||||
if (!data || !data.type) return;
|
if (!data) return;
|
||||||
|
|
||||||
// 只有在节点已展开且点击的情况下才触发筛选
|
logger.info('HierarchyView', '热力图点击', { level: data.level, name: data.name });
|
||||||
// ECharts tree 会自动处理展开/收起
|
|
||||||
logger.info('HierarchyView', '节点点击', data);
|
|
||||||
|
|
||||||
let filter = { lv1: null, lv2: null, lv3: null };
|
if (data.level === 'lv1' && data.children && data.children.length > 0) {
|
||||||
|
// 进入 lv2
|
||||||
switch (data.type) {
|
setCurrentLevel('lv2');
|
||||||
case 'lv1':
|
setCurrentLv1(data);
|
||||||
filter = { lv1: data.name, lv2: null, lv3: null };
|
setBreadcrumbs([
|
||||||
break;
|
{ label: '全部分类', level: 'root' },
|
||||||
case 'lv2':
|
{ label: data.name, level: 'lv1', data },
|
||||||
filter = { lv1: data.parentLv1, lv2: data.name, lv3: null };
|
]);
|
||||||
break;
|
} else if (data.level === 'lv2') {
|
||||||
case 'lv3':
|
// 检查是否有 lv3 或概念
|
||||||
filter = { lv1: data.parentLv1, lv2: data.parentLv2, lv3: data.name };
|
if ((data.children && data.children.length > 0) || (data.concepts && data.concepts.length > 0)) {
|
||||||
break;
|
setCurrentLevel('lv3');
|
||||||
default:
|
setCurrentLv2(data);
|
||||||
return;
|
setBreadcrumbs([
|
||||||
|
{ label: '全部分类', level: 'root' },
|
||||||
|
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
|
||||||
|
{ label: data.name, level: 'lv2', data },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if (data.level === 'lv3' || data.level === 'concept') {
|
||||||
|
// 最底层,可以触发筛选或者其他操作
|
||||||
|
// 这里可以选择不做任何操作,或者提示用户
|
||||||
|
logger.info('HierarchyView', '已到达最底层', { name: data.name });
|
||||||
}
|
}
|
||||||
|
}, [currentLv1]);
|
||||||
|
|
||||||
onSelectCategory && onSelectCategory(filter);
|
// 面包屑导航 - 返回上级
|
||||||
}, [onSelectCategory]);
|
const handleBreadcrumbClick = useCallback((crumb, index) => {
|
||||||
|
if (crumb.level === 'root') {
|
||||||
// 重置图表视图
|
setCurrentLevel('lv1');
|
||||||
const handleResetView = useCallback(() => {
|
setCurrentLv1(null);
|
||||||
if (chartRef.current) {
|
setCurrentLv2(null);
|
||||||
const chart = chartRef.current.getEchartsInstance();
|
setBreadcrumbs([{ label: '全部分类', level: 'root' }]);
|
||||||
chart.dispatchAction({ type: 'restore' });
|
} else if (crumb.level === 'lv1') {
|
||||||
|
setCurrentLevel('lv2');
|
||||||
|
setCurrentLv1(crumb.data);
|
||||||
|
setCurrentLv2(null);
|
||||||
|
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [breadcrumbs]);
|
||||||
|
|
||||||
// 刷新涨跌幅数据
|
// 刷新涨跌幅数据
|
||||||
const handleRefreshPrice = useCallback(() => {
|
const handleRefreshPrice = useCallback(() => {
|
||||||
@@ -477,6 +494,14 @@ const HierarchyView = ({
|
|||||||
click: handleChartClick,
|
click: handleChartClick,
|
||||||
}), [handleChartClick]);
|
}), [handleChartClick]);
|
||||||
|
|
||||||
|
// 获取当前层级标题
|
||||||
|
const getCurrentTitle = () => {
|
||||||
|
if (currentLevel === 'lv1') return '一级分类概览';
|
||||||
|
if (currentLevel === 'lv2' && currentLv1) return `${currentLv1.name} - 二级分类`;
|
||||||
|
if (currentLevel === 'lv3' && currentLv2) return `${currentLv2.name} - 三级分类`;
|
||||||
|
return '概念分类';
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Center h="500px">
|
<Center h="500px">
|
||||||
@@ -536,7 +561,7 @@ const HierarchyView = ({
|
|||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Icon as={FaLayerGroup} color="purple.500" boxSize={5} />
|
<Icon as={FaLayerGroup} color="purple.500" boxSize={5} />
|
||||||
<Text fontSize="lg" fontWeight="bold" color="gray.800">
|
<Text fontSize="lg" fontWeight="bold" color="gray.800">
|
||||||
概念层级导图
|
{getCurrentTitle()}
|
||||||
</Text>
|
</Text>
|
||||||
{tradeDate && (
|
{tradeDate && (
|
||||||
<Badge colorScheme="blue" fontSize="xs">
|
<Badge colorScheme="blue" fontSize="xs">
|
||||||
@@ -562,48 +587,6 @@ const HierarchyView = ({
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* 布局切换 */}
|
|
||||||
<ButtonGroup size="sm" isAttached variant="outline">
|
|
||||||
<Tooltip label="径向布局" placement="top">
|
|
||||||
<IconButton
|
|
||||||
icon={<FaProjectDiagram />}
|
|
||||||
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="径向布局"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label="树形布局" placement="top">
|
|
||||||
<IconButton
|
|
||||||
icon={<FaSitemap />}
|
|
||||||
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="树形布局"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</ButtonGroup>
|
|
||||||
|
|
||||||
{/* 重置视图 */}
|
|
||||||
<Tooltip label="重置视图" placement="top">
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
icon={<FaRedo />}
|
|
||||||
onClick={handleResetView}
|
|
||||||
variant="outline"
|
|
||||||
colorScheme="gray"
|
|
||||||
aria-label="重置视图"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* 全屏切换 */}
|
{/* 全屏切换 */}
|
||||||
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="top">
|
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="top">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -618,12 +601,45 @@ const HierarchyView = ({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{/* 面包屑导航 */}
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
mb={3}
|
||||||
|
p={2}
|
||||||
|
bg="gray.50"
|
||||||
|
borderRadius="lg"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
{breadcrumbs.map((crumb, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{index > 0 && (
|
||||||
|
<Icon as={FaChevronRight} color="gray.400" boxSize={3} mx={1} />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={index === breadcrumbs.length - 1 ? 'solid' : 'ghost'}
|
||||||
|
colorScheme={index === breadcrumbs.length - 1 ? 'purple' : 'gray'}
|
||||||
|
leftIcon={index === 0 ? <FaHome /> : undefined}
|
||||||
|
onClick={() => handleBreadcrumbClick(crumb, index)}
|
||||||
|
isDisabled={index === breadcrumbs.length - 1}
|
||||||
|
fontWeight={index === breadcrumbs.length - 1 ? 'bold' : 'medium'}
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</Button>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{/* 操作提示 */}
|
{/* 操作提示 */}
|
||||||
<Text fontSize="xs" color="gray.500" mb={2} textAlign="center">
|
<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 as="span" color="purple.600" fontWeight="medium">点击色块</Text> 查看下级分类 |
|
||||||
|
区块大小表示概念数量 |
|
||||||
|
<Text as="span" color="red.500">红涨</Text>
|
||||||
|
<Text as="span" color="green.500">绿跌</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* ECharts 图表 */}
|
{/* ECharts 热力图 */}
|
||||||
<Box
|
<Box
|
||||||
border="1px solid"
|
border="1px solid"
|
||||||
borderColor="gray.200"
|
borderColor="gray.200"
|
||||||
@@ -635,7 +651,7 @@ const HierarchyView = ({
|
|||||||
ref={chartRef}
|
ref={chartRef}
|
||||||
option={chartOption}
|
option={chartOption}
|
||||||
style={{
|
style={{
|
||||||
height: isFullscreen ? 'calc(100vh - 140px)' : isMobile ? '450px' : '550px',
|
height: isFullscreen ? 'calc(100vh - 200px)' : isMobile ? '400px' : '500px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
onEvents={chartEvents}
|
onEvents={chartEvents}
|
||||||
@@ -657,26 +673,30 @@ const HierarchyView = ({
|
|||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
>
|
>
|
||||||
{hierarchy.length} 个一级分类
|
当前显示 {currentData.length} 个{currentLevel === 'lv1' ? '一级' : currentLevel === 'lv2' ? '二级' : '三级'}分类
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
colorScheme="blue"
|
|
||||||
px={3}
|
|
||||||
py={1}
|
|
||||||
borderRadius="full"
|
|
||||||
fontSize="xs"
|
|
||||||
>
|
|
||||||
{hierarchy.reduce((acc, h) => acc + (h.children?.length || 0), 0)} 个二级分类
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
colorScheme="cyan"
|
|
||||||
px={3}
|
|
||||||
py={1}
|
|
||||||
borderRadius="full"
|
|
||||||
fontSize="xs"
|
|
||||||
>
|
|
||||||
{hierarchy.reduce((acc, h) => acc + h.concept_count, 0)} 个概念
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{currentLevel === 'lv1' && (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
colorScheme="blue"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
共 {hierarchy.reduce((acc, h) => acc + (h.children?.length || 0), 0)} 个二级分类
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
colorScheme="cyan"
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
共 {hierarchy.reduce((acc, h) => acc + h.concept_count, 0)} 个概念
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -344,13 +344,14 @@ const ConceptCenter = () => {
|
|||||||
fetchConcepts('', 1, selectedDate, 'change_pct', { lv1: null, lv2: null, lv3: null });
|
fetchConcepts('', 1, selectedDate, 'change_pct', { lv1: null, lv2: null, lv3: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理层级筛选选择(从 HierarchyView 点击分类)
|
// 处理层级筛选选择(从 HierarchyView 点击分类 - 仅在需要时使用)
|
||||||
|
// 注意:热力图视图现在是独立的钻取交互,不再自动切换到列表视图
|
||||||
const handleHierarchySelect = useCallback((filter) => {
|
const handleHierarchySelect = useCallback((filter) => {
|
||||||
logger.info('ConceptCenter', '层级筛选选择', filter);
|
logger.info('ConceptCenter', '层级筛选选择', filter);
|
||||||
|
|
||||||
setHierarchyFilter(filter);
|
setHierarchyFilter(filter);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setViewMode('list'); // 切换到列表视图
|
// 不再自动切换视图,热力图内部自己处理钻取
|
||||||
|
|
||||||
// 更新 URL 参数
|
// 更新 URL 参数
|
||||||
updateUrlParams({
|
updateUrlParams({
|
||||||
@@ -360,21 +361,9 @@ const ConceptCenter = () => {
|
|||||||
page: 1
|
page: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
// 重新获取数据
|
// 重新获取数据(用于其他视图)
|
||||||
fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter);
|
fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter);
|
||||||
|
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]);
|
||||||
// 滚动到页面顶部,让用户看到筛选结果
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
|
|
||||||
// 显示提示
|
|
||||||
toast({
|
|
||||||
title: '已应用筛选',
|
|
||||||
description: `正在显示「${[filter.lv1, filter.lv2, filter.lv3].filter(Boolean).join(' > ')}」分类下的概念`,
|
|
||||||
status: 'info',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts, toast]);
|
|
||||||
|
|
||||||
// 清除层级筛选
|
// 清除层级筛选
|
||||||
const handleClearHierarchyFilter = useCallback(() => {
|
const handleClearHierarchyFilter = useCallback(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user