Files
vf_react/src/views/Concept/components/HierarchyView.js
zdl 72aef087ea refactor(Concept): 优化 3D 力导向图和层级图组件
ForceGraphView:
- 优化 API 请求路径兼容性
- 改进数据处理逻辑

HierarchyView:
- 优化层级数据获取
- 改进 API 兼容性

DataVisualizationComponents:
- 代码优化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 13:14:16 +08:00

936 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* HierarchyView - 概念层级热力图视图
*
* Modern Spatial & Glassmorphism 设计风格
* 特性:
* 1. 毛玻璃卡片 + 极光背景
* 2. 涨红跌绿渐变色
* 3. 支持 lv1 → lv2 → lv3 → leaf 四层钻取
* 4. 集成 /hierarchy/price 接口(含 leaf_concepts
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Spinner,
Center,
Flex,
Button,
useBreakpointValue,
Tooltip,
IconButton,
SimpleGrid,
} from '@chakra-ui/react';
import { keyframes } from '@emotion/react';
import {
FaLayerGroup,
FaExpand,
FaCompress,
FaSync,
FaHome,
FaChevronRight,
FaBrain,
FaMicrochip,
FaRobot,
FaMobileAlt,
FaCar,
FaBolt,
FaRocket,
FaShieldAlt,
FaGlobe,
FaIndustry,
FaShoppingCart,
FaCoins,
FaHeartbeat,
FaAtom,
FaArrowUp,
FaArrowDown,
FaCubes,
FaServer,
FaCode,
FaMagic,
FaEye,
FaPlane,
FaSatellite,
FaBatteryFull,
FaSolarPanel,
FaTags,
FaExternalLinkAlt,
} from 'react-icons/fa';
import { logger } from '../../../utils/logger';
// 一级分类图标映射
const LV1_ICONS = {
'人工智能': FaBrain,
'半导体': FaMicrochip,
'机器人': FaRobot,
'消费电子': FaMobileAlt,
'智能驾驶与汽车': FaCar,
'新能源与电力': FaBolt,
'空天经济': FaRocket,
'国防军工': FaShieldAlt,
'政策与主题': FaGlobe,
'周期与材料': FaIndustry,
'大消费': FaShoppingCart,
'数字经济与金融科技': FaCoins,
'全球宏观与贸易': FaGlobe,
'医药健康': FaHeartbeat,
'前沿科技': FaAtom,
};
// 二级分类图标映射
const LV2_ICONS = {
'AI基础设施': FaServer,
'AI模型与软件': FaCode,
'AI应用': FaMagic,
'半导体设备': FaCubes,
'半导体材料': FaAtom,
'芯片设计与制造': FaMicrochip,
'先进封装': FaCubes,
'人形机器人整机': FaRobot,
'机器人核心零部件': FaCubes,
'其他类型机器人': FaRobot,
'智能终端': FaMobileAlt,
'XR与空间计算': FaEye,
'华为产业链': FaMobileAlt,
'自动驾驶解决方案': FaCar,
'智能汽车产业链': FaCar,
'车路协同': FaCar,
'新型电池技术': FaBatteryFull,
'电力设备与电网': FaBolt,
'清洁能源': FaSolarPanel,
'低空经济': FaPlane,
'商业航天': FaSatellite,
'无人作战与信息化': FaShieldAlt,
'海军装备': FaShieldAlt,
'军贸出海': FaGlobe,
};
// 根据涨跌幅获取背景渐变色(涨红跌绿)
const getChangeGradient = (value) => {
if (value === null || value === undefined) {
return 'linear-gradient(135deg, rgba(71, 85, 105, 0.6) 0%, rgba(100, 116, 139, 0.4) 100%)';
}
// 涨 - 红色系
if (value > 7) return 'linear-gradient(135deg, rgba(153, 27, 27, 0.8) 0%, rgba(220, 38, 38, 0.6) 100%)';
if (value > 5) return 'linear-gradient(135deg, rgba(185, 28, 28, 0.75) 0%, rgba(239, 68, 68, 0.55) 100%)';
if (value > 3) return 'linear-gradient(135deg, rgba(220, 38, 38, 0.7) 0%, rgba(248, 113, 113, 0.5) 100%)';
if (value > 1) return 'linear-gradient(135deg, rgba(239, 68, 68, 0.65) 0%, rgba(252, 165, 165, 0.45) 100%)';
if (value > 0) return 'linear-gradient(135deg, rgba(248, 113, 113, 0.6) 0%, rgba(254, 202, 202, 0.4) 100%)';
// 跌 - 绿色系
if (value < -7) return 'linear-gradient(135deg, rgba(20, 83, 45, 0.8) 0%, rgba(22, 101, 52, 0.6) 100%)';
if (value < -5) return 'linear-gradient(135deg, rgba(22, 101, 52, 0.75) 0%, rgba(21, 128, 61, 0.55) 100%)';
if (value < -3) return 'linear-gradient(135deg, rgba(21, 128, 61, 0.7) 0%, rgba(22, 163, 74, 0.5) 100%)';
if (value < -1) return 'linear-gradient(135deg, rgba(22, 163, 74, 0.65) 0%, rgba(74, 222, 128, 0.45) 100%)';
if (value < 0) return 'linear-gradient(135deg, rgba(34, 197, 94, 0.6) 0%, rgba(134, 239, 172, 0.4) 100%)';
// 平盘
return 'linear-gradient(135deg, rgba(71, 85, 105, 0.6) 0%, rgba(100, 116, 139, 0.4) 100%)';
};
// 获取涨跌幅文字颜色
const getChangeTextColor = (value) => {
if (value === null || value === undefined) return 'gray.300';
if (value > 0) return '#FCA5A5';
if (value < 0) return '#86EFAC';
return 'gray.300';
};
// 格式化涨跌幅
const formatChangePercent = (value) => {
if (value === null || value === undefined) return '--';
const formatted = Math.abs(value).toFixed(2);
return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%';
};
// 获取图标
const getIcon = (name, level) => {
if (level === 'lv1') return LV1_ICONS[name] || FaLayerGroup;
if (level === 'lv2') return LV2_ICONS[name] || FaCubes;
if (level === 'lv3') return FaCubes;
return FaTags;
};
// 从 API 返回的名称中提取纯名称
const extractPureName = (apiName) => {
if (!apiName) return '';
return apiName.replace(/^\[(一级|二级|三级)\]\s*/, '');
};
// 呼吸动画
const breatheKeyframes = keyframes`
0%, 100% { opacity: 0.3; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.05); }
`;
// 浮动动画
const floatKeyframes = keyframes`
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
`;
/**
* 极光背景组件
*/
const AuroraBackground = () => (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
overflow="hidden"
pointerEvents="none"
zIndex={0}
>
{/* 紫色光斑 - 左上 */}
<Box
position="absolute"
top="-20%"
left="-10%"
width="500px"
height="500px"
bg="purple.600"
opacity={0.15}
borderRadius="full"
filter="blur(120px)"
css={{ animation: `${breatheKeyframes} 8s ease-in-out infinite` }}
/>
{/* 蓝色光斑 - 右下 */}
<Box
position="absolute"
bottom="-20%"
right="-10%"
width="450px"
height="450px"
bg="blue.500"
opacity={0.12}
borderRadius="full"
filter="blur(100px)"
css={{ animation: `${breatheKeyframes} 10s ease-in-out infinite 2s` }}
/>
{/* 青色光斑 - 中间 */}
<Box
position="absolute"
top="40%"
left="30%"
width="300px"
height="300px"
bg="cyan.400"
opacity={0.08}
borderRadius="full"
filter="blur(80px)"
css={{ animation: `${breatheKeyframes} 12s ease-in-out infinite 4s` }}
/>
</Box>
);
/**
* 毛玻璃卡片组件
*/
const GlassCard = ({ item, onClick, size = 'normal' }) => {
const hasChange = item.avg_change_pct !== null && item.avg_change_pct !== undefined;
const isPositive = hasChange && item.avg_change_pct > 0;
const isNegative = hasChange && item.avg_change_pct < 0;
const isLargeChange = hasChange && Math.abs(item.avg_change_pct) > 3;
const IconComponent = getIcon(item.name, item.level);
// 根据 size 调整高度
const heightMap = {
large: { base: '150px', md: '170px' },
normal: { base: '120px', md: '140px' },
small: { base: '100px', md: '110px' },
};
// 是否可点击进入下一层
const canDrillDown = item.level !== 'concept' && (item.children?.length > 0 || item.concepts?.length > 0);
const isLeafConcept = item.level === 'concept';
return (
<Box
position="relative"
minH={heightMap[size]}
cursor="pointer"
onClick={() => onClick(item)}
transition="all 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
transform="translateZ(0)"
_hover={{
transform: 'translateY(-6px) scale(1.02)',
}}
css={isLargeChange ? { animation: `${floatKeyframes} 3s ease-in-out infinite` } : {}}
>
{/* 毛玻璃主体 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg={getChangeGradient(item.avg_change_pct)}
backdropFilter="blur(20px)"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="2xl"
overflow="hidden"
boxShadow={isLargeChange
? `0 8px 32px rgba(${isPositive ? '239, 68, 68' : '34, 197, 94'}, 0.3)`
: '0 8px 32px rgba(0, 0, 0, 0.2)'
}
_hover={{
borderColor: 'whiteAlpha.300',
boxShadow: isLargeChange
? `0 16px 48px rgba(${isPositive ? '239, 68, 68' : '34, 197, 94'}, 0.4)`
: '0 16px 48px rgba(0, 0, 0, 0.3)',
}}
transition="all 0.4s"
/>
{/* 高光效果 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="50%"
bg="linear-gradient(180deg, rgba(255,255,255,0.1) 0%, transparent 100%)"
borderRadius="2xl 2xl 0 0"
pointerEvents="none"
/>
{/* 内容 */}
<Flex
direction="column"
justify="space-between"
h="100%"
p={{ base: 3, md: 4 }}
position="relative"
zIndex={1}
>
{/* 顶部:图标和名称 */}
<HStack spacing={2} align="flex-start">
<Box
p={2}
bg="whiteAlpha.150"
borderRadius="xl"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="whiteAlpha.100"
flexShrink={0}
>
<Icon
as={IconComponent}
boxSize={{ base: 4, md: 5 }}
color="white"
opacity={0.9}
/>
</Box>
<VStack align="start" spacing={0} flex={1}>
<Text
color="white"
fontWeight="semibold"
fontSize={{ base: 'sm', md: 'md' }}
noOfLines={2}
lineHeight="1.3"
letterSpacing="0.02em"
>
{item.name}
</Text>
{item.concept_count > 0 && (
<Text color="whiteAlpha.600" fontSize="xs" mt={0.5}>
{item.concept_count} 个概念
</Text>
)}
</VStack>
{/* 外链图标 */}
{isLeafConcept && (
<Icon
as={FaExternalLinkAlt}
boxSize={3}
color="whiteAlpha.500"
/>
)}
</HStack>
{/* 底部:涨跌幅 */}
<Flex justify="space-between" align="flex-end">
<VStack align="start" spacing={0}>
{item.stock_count > 0 && (
<Text color="whiteAlpha.500" fontSize="xs">
{item.stock_count} 只股票
</Text>
)}
</VStack>
<HStack spacing={1} align="center">
{hasChange && (isPositive || isNegative) && (
<Icon
as={isPositive ? FaArrowUp : FaArrowDown}
boxSize={3}
color={getChangeTextColor(item.avg_change_pct)}
/>
)}
<Text
color={getChangeTextColor(item.avg_change_pct)}
fontWeight="bold"
fontSize={{ base: 'lg', md: '2xl' }}
fontFamily="mono"
letterSpacing="-0.02em"
>
{formatChangePercent(item.avg_change_pct)}
</Text>
</HStack>
</Flex>
</Flex>
{/* 展开标识 */}
{canDrillDown && (
<Badge
position="absolute"
top={2}
right={2}
bg="whiteAlpha.200"
color="whiteAlpha.800"
fontSize="xs"
borderRadius="full"
px={2}
py={0.5}
backdropFilter="blur(10px)"
border="1px solid"
borderColor="whiteAlpha.100"
>
展开
</Badge>
)}
</Box>
);
};
/**
* 主组件:层级热力图视图
*/
const HierarchyView = ({
apiBaseUrl,
onSelectCategory,
selectedDate,
}) => {
const [hierarchy, setHierarchy] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} });
const [priceLoading, setPriceLoading] = useState(false);
const [tradeDate, setTradeDate] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// 钻取状态
const [currentLevel, setCurrentLevel] = useState('lv1');
const [currentLv1, setCurrentLv1] = useState(null);
const [currentLv2, setCurrentLv2] = useState(null);
const [currentLv3, setCurrentLv3] = useState(null);
const [breadcrumbs, setBreadcrumbs] = useState([{ label: '全部分类', level: 'root' }]);
const isMobile = useBreakpointValue({ base: true, md: false });
// 获取层级结构数据
const fetchHierarchy = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${apiBaseUrl}/hierarchy`);
if (!response.ok) throw new Error('获取层级结构失败');
const data = await response.json();
setHierarchy(data.hierarchy || []);
logger.info('HierarchyView', '层级结构加载完成', {
totalLv1: data.hierarchy?.length,
totalConcepts: data.total_concepts
});
} catch (err) {
logger.error('HierarchyView', 'fetchHierarchy', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [apiBaseUrl]);
// 获取层级涨跌幅数据
const fetchHierarchyPrice = useCallback(async () => {
setPriceLoading(true);
try {
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();
// 构建映射表
const lv1Map = {};
const lv2Map = {};
const lv3Map = {};
const leafMap = {};
(data.lv1_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv1Map[pureName] = item;
});
(data.lv2_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv2Map[pureName] = item;
});
(data.lv3_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv3Map[pureName] = item;
});
// 叶子概念 - 直接用名称
(data.leaf_concepts || []).forEach(item => {
leafMap[item.concept_name] = item;
});
setPriceData({ lv1Map, lv2Map, lv3Map, leafMap });
setTradeDate(data.trade_date);
logger.info('HierarchyView', '层级涨跌幅加载完成', {
lv1Count: Object.keys(lv1Map).length,
lv2Count: Object.keys(lv2Map).length,
lv3Count: Object.keys(lv3Map).length,
leafCount: Object.keys(leafMap).length,
tradeDate: data.trade_date,
});
} catch (err) {
logger.warn('HierarchyView', '获取层级涨跌幅失败', { error: err.message });
} finally {
setPriceLoading(false);
}
}, [apiBaseUrl, selectedDate]);
useEffect(() => {
fetchHierarchy();
}, [fetchHierarchy]);
useEffect(() => {
if (hierarchy.length > 0) {
fetchHierarchyPrice();
}
}, [hierarchy, fetchHierarchyPrice]);
// 根据当前层级获取显示数据
const currentData = useMemo(() => {
const { lv1Map, lv2Map, lv3Map, leafMap } = priceData;
// 第一层:显示所有 lv1
if (currentLevel === 'lv1') {
return hierarchy.map((lv1) => {
const price = lv1Map[lv1.name] || {};
return {
name: lv1.name,
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,
};
});
}
// 第二层:显示选中 lv1 下的 lv2
if (currentLevel === 'lv2' && currentLv1) {
const lv1Data = hierarchy.find(h => h.name === currentLv1.name);
if (!lv1Data || !lv1Data.children) return [];
return lv1Data.children.map((lv2) => {
const price = lv2Map[lv2.name] || {};
return {
name: lv2.name,
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,
};
});
}
// 第三层:显示选中 lv2 下的 lv3
if (currentLevel === 'lv3' && currentLv1 && currentLv2) {
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) => {
const price = lv3Map[lv3.name] || {};
return {
name: lv3.name,
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,
};
});
}
// 如果 lv2 直接包含概念(没有 lv3
if (lv2Data.concepts && lv2Data.concepts.length > 0) {
return lv2Data.concepts.map((conceptName) => {
const price = leafMap[conceptName] || {};
return {
name: conceptName,
level: 'concept',
parentLv1: currentLv1.name,
parentLv2: currentLv2.name,
stock_count: price.stock_count,
avg_change_pct: price.avg_change_pct,
};
});
}
return [];
}
// 第四层:显示选中 lv3 下的具体概念
if (currentLevel === 'concept' && currentLv1 && currentLv2 && currentLv3) {
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 || !lv2Data.children) return [];
const lv3Data = lv2Data.children.find(h => h.name === currentLv3.name);
if (!lv3Data || !lv3Data.concepts) return [];
return lv3Data.concepts.map((conceptName) => {
const price = leafMap[conceptName] || {};
return {
name: conceptName,
level: 'concept',
parentLv1: currentLv1.name,
parentLv2: currentLv2.name,
parentLv3: currentLv3.name,
stock_count: price.stock_count,
avg_change_pct: price.avg_change_pct,
};
});
}
return [];
}, [hierarchy, priceData, currentLevel, currentLv1, currentLv2, currentLv3]);
// 处理点击事件 - 钻取
const handleBlockClick = useCallback((item) => {
logger.info('HierarchyView', '热力图点击', { level: item.level, name: item.name });
if (item.level === 'lv1' && item.children && item.children.length > 0) {
setCurrentLevel('lv2');
setCurrentLv1(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: item.name, level: 'lv1', data: item },
]);
} else if (item.level === 'lv2') {
if (item.children && item.children.length > 0) {
setCurrentLevel('lv3');
setCurrentLv2(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: item.name, level: 'lv2', data: item },
]);
} else if (item.concepts && item.concepts.length > 0) {
setCurrentLevel('lv3');
setCurrentLv2(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: item.name, level: 'lv2', data: item },
]);
}
} else if (item.level === 'lv3' && item.concepts && item.concepts.length > 0) {
setCurrentLevel('concept');
setCurrentLv3(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: currentLv2.name, level: 'lv2', data: currentLv2 },
{ label: item.name, level: 'lv3', data: item },
]);
} else if (item.level === 'concept') {
// 跳转到概念详情页
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(item.name)}.html`;
window.open(htmlPath, '_blank');
}
}, [currentLv1, currentLv2]);
// 面包屑导航
const handleBreadcrumbClick = useCallback((crumb, index) => {
if (crumb.level === 'root') {
setCurrentLevel('lv1');
setCurrentLv1(null);
setCurrentLv2(null);
setCurrentLv3(null);
setBreadcrumbs([{ label: '全部分类', level: 'root' }]);
} else if (crumb.level === 'lv1') {
setCurrentLevel('lv2');
setCurrentLv1(crumb.data);
setCurrentLv2(null);
setCurrentLv3(null);
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
} else if (crumb.level === 'lv2') {
setCurrentLevel('lv3');
setCurrentLv2(crumb.data);
setCurrentLv3(null);
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
}
}, [breadcrumbs]);
// 刷新
const handleRefreshPrice = useCallback(() => {
fetchHierarchyPrice();
}, [fetchHierarchyPrice]);
// 全屏切换
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
// 获取当前层级标题
const getCurrentTitle = () => {
if (currentLevel === 'lv1') return '概念分类热力图';
if (currentLevel === 'lv2' && currentLv1) return currentLv1.name;
if (currentLevel === 'lv3' && currentLv2) return currentLv2.name;
if (currentLevel === 'concept' && currentLv3) return currentLv3.name;
return '概念分类';
};
// 获取当前层级描述
const getLevelDesc = () => {
const levelNames = {
lv1: '一级分类',
lv2: '二级分类',
lv3: '三级分类',
concept: '具体概念',
};
return levelNames[currentLevel] || '';
};
// 计算列数
const getGridColumns = () => {
if (currentLevel === 'concept') {
return { base: 2, md: 3, lg: 4 };
}
return { base: 2, md: 3, lg: 4 };
};
if (loading) {
return (
<Center h="400px" position="relative">
<AuroraBackground />
<VStack spacing={4} position="relative" zIndex={1}>
<Spinner size="xl" color="purple.400" thickness="4px" />
<Text color="gray.400">正在加载概念层级...</Text>
</VStack>
</Center>
);
}
if (error) {
return (
<Center h="400px" position="relative">
<AuroraBackground />
<VStack spacing={4} position="relative" zIndex={1}>
<Icon as={FaLayerGroup} boxSize={16} color="gray.600" />
<Text color="gray.400">加载失败{error}</Text>
<Button
colorScheme="purple"
size="sm"
onClick={fetchHierarchy}
bg="purple.500"
_hover={{ bg: 'purple.400' }}
>
重试
</Button>
</VStack>
</Center>
);
}
if (currentData.length === 0) {
return (
<Center h="400px" position="relative">
<AuroraBackground />
<VStack spacing={4} position="relative" zIndex={1}>
<Icon as={FaLayerGroup} boxSize={16} color="gray.600" />
<Text color="gray.400">暂无层级数据</Text>
</VStack>
</Center>
);
}
return (
<Box
position={isFullscreen ? 'fixed' : 'relative'}
top={isFullscreen ? 0 : 'auto'}
left={isFullscreen ? 0 : 'auto'}
right={isFullscreen ? 0 : 'auto'}
bottom={isFullscreen ? 0 : 'auto'}
zIndex={isFullscreen ? 1000 : 'auto'}
bg={isFullscreen ? 'slate.950' : 'transparent'}
bgGradient={isFullscreen ? 'linear(to-br, gray.900, slate.900, gray.900)' : undefined}
p={{ base: 0, md: 0 }}
borderRadius={isFullscreen ? '0' : '0'}
overflow={isFullscreen ? 'auto' : 'visible'}
minH="auto"
>
{/* 极光背景 - 仅全屏时显示 */}
{isFullscreen && <AuroraBackground />}
{/* 内容层 */}
<Box position="relative" zIndex={1}>
{/* 面包屑导航 + 工具栏(同一行) */}
<Flex
align="center"
justify="space-between"
mb={5}
p={3}
bg="whiteAlpha.50"
backdropFilter="blur(20px)"
borderRadius="2xl"
border="1px solid"
borderColor="whiteAlpha.100"
gap={2}
>
{/* 左侧:面包屑导航 */}
<Flex align="center" flexWrap="wrap" gap={1} flex={1}>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={index}>
{index > 0 && (
<Icon as={FaChevronRight} color="whiteAlpha.400" boxSize={3} mx={1} />
)}
<Button
size="sm"
variant="ghost"
bg={index === breadcrumbs.length - 1 ? 'purple.500' : 'transparent'}
color={index === breadcrumbs.length - 1 ? 'white' : 'whiteAlpha.700'}
leftIcon={index === 0 ? <FaHome /> : undefined}
onClick={() => handleBreadcrumbClick(crumb, index)}
isDisabled={index === breadcrumbs.length - 1}
fontWeight={index === breadcrumbs.length - 1 ? 'bold' : 'medium'}
borderRadius="xl"
_hover={index !== breadcrumbs.length - 1 ? { bg: 'whiteAlpha.100' } : {}}
boxShadow={index === breadcrumbs.length - 1 ? '0 0 20px rgba(139, 92, 246, 0.5)' : 'none'}
>
{crumb.label}
</Button>
</React.Fragment>
))}
</Flex>
{/* 右侧:工具栏按钮 */}
<HStack spacing={2} flexShrink={0}>
{priceLoading && (
<Spinner size="sm" color="purple.300" />
)}
<Tooltip label="刷新涨跌幅" placement="top">
<IconButton
size="sm"
icon={<FaSync />}
onClick={handleRefreshPrice}
isLoading={priceLoading}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label="刷新涨跌幅"
/>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="top">
<IconButton
size="sm"
icon={isFullscreen ? <FaCompress /> : <FaExpand />}
onClick={toggleFullscreen}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
/>
</Tooltip>
</HStack>
</Flex>
{/* 图例说明 */}
<Flex
justify="center"
mb={5}
gap={4}
flexWrap="wrap"
fontSize="xs"
>
<HStack spacing={2}>
<Box w={3} h={3} borderRadius="sm" bg="linear-gradient(135deg, rgba(239, 68, 68, 0.7) 0%, rgba(248, 113, 113, 0.5) 100%)" />
<Text color="whiteAlpha.600"></Text>
</HStack>
<HStack spacing={2}>
<Box w={3} h={3} borderRadius="sm" bg="linear-gradient(135deg, rgba(22, 163, 74, 0.7) 0%, rgba(74, 222, 128, 0.5) 100%)" />
<Text color="whiteAlpha.600"></Text>
</HStack>
<HStack spacing={2}>
<Box w={3} h={3} borderRadius="sm" bg="linear-gradient(135deg, rgba(71, 85, 105, 0.6) 0%, rgba(100, 116, 139, 0.4) 100%)" />
<Text color="whiteAlpha.600">/无数据</Text>
</HStack>
<Text color="whiteAlpha.300">|</Text>
<Text color="whiteAlpha.500">
{currentLevel !== 'concept' ? '点击色块查看下级' : '点击查看概念详情'}
</Text>
</Flex>
{/* 热力图网格 */}
<SimpleGrid columns={getGridColumns()} spacing={{ base: 3, md: 4 }}>
{currentData.map((item) => (
<GlassCard
key={item.id || item.name}
item={item}
onClick={handleBlockClick}
size={currentLevel === 'lv1' ? 'large' : currentLevel === 'concept' ? 'small' : 'normal'}
/>
))}
</SimpleGrid>
</Box>
</Box>
);
};
export default HierarchyView;