ForceGraphView: - 优化 API 请求路径兼容性 - 改进数据处理逻辑 HierarchyView: - 优化层级数据获取 - 改进 API 兼容性 DataVisualizationComponents: - 代码优化 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
936 lines
34 KiB
JavaScript
936 lines
34 KiB
JavaScript
/**
|
||
* 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;
|