refactor(HierarchyView): 支持外部状态控制

- 重构状态管理,统一使用 drillPath 格式
- 支持外部 drillPath 控制(externalDrillPath, onDrillPathChange)
- 添加 hideNavigation 模式支持
- 面包屑、currentLevel 等从 drillPath 派生

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2026-01-08 19:04:08 +08:00
parent 582cc073a0
commit 0a4f068593

View File

@@ -30,8 +30,7 @@ import {
Layers,
Maximize2,
Minimize2,
RefreshCw,
Home,
ArrowLeft,
ChevronRight,
Brain,
Cpu,
@@ -350,15 +349,6 @@ const GlassCard = ({ item, onClick, size = 'normal' }) => {
</Text>
)}
</VStack>
{/* 外链图标 */}
{isLeafConcept && (
<Icon
as={ExternalLink}
boxSize={3}
color="whiteAlpha.500"
/>
)}
</HStack>
{/* 底部:涨跌幅 */}
@@ -392,23 +382,44 @@ const GlassCard = ({ item, onClick, size = 'normal' }) => {
</Flex>
</Flex>
{/* 展开标识 */}
{/* 展开标识 - 使用箭头图标 */}
{canDrillDown && (
<Box
position="absolute"
top={2}
right={2}
p={1.5}
bg="whiteAlpha.200"
borderRadius="md"
backdropFilter={GLASS_BLUR.sm}
border="1px solid"
borderColor="whiteAlpha.100"
_hover={{ bg: 'whiteAlpha.300' }}
transition="all 0.2s"
>
<Icon as={Maximize2} boxSize={4} color="whiteAlpha.800" />
</Box>
)}
{/* 详情按钮 - 仅在 concept 层显示 */}
{isLeafConcept && (
<Badge
position="absolute"
top={2}
right={2}
bg="whiteAlpha.200"
color="whiteAlpha.800"
bg="rgba(139, 92, 246, 0.3)"
color="purple.200"
fontSize="xs"
borderRadius="full"
borderRadius="md"
px={2}
py={0.5}
py={1}
backdropFilter={GLASS_BLUR.sm}
border="1px solid"
borderColor="whiteAlpha.100"
borderColor="purple.400"
_hover={{ bg: 'rgba(139, 92, 246, 0.5)' }}
transition="all 0.2s"
>
展开
详情 &gt;
</Badge>
)}
</Box>
@@ -422,6 +433,11 @@ const HierarchyView = ({
apiBaseUrl,
onSelectCategory,
selectedDate,
// 外部控制的 drillPath可选用于状态共享
externalDrillPath,
onDrillPathChange,
// 是否隐藏导航和背景(由外部 ChartContainer 提供时设为 true
hideNavigation = false,
}) => {
const [hierarchy, setHierarchy] = useState([]);
const [loading, setLoading] = useState(true);
@@ -431,12 +447,40 @@ const HierarchyView = ({
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 [internalDrillPath, setInternalDrillPath] = useState(null);
// 支持受控和非受控模式
const drillPath = externalDrillPath !== undefined ? externalDrillPath : internalDrillPath;
const setDrillPath = onDrillPathChange || setInternalDrillPath;
// 从 drillPath 派生当前层级和选中项
const currentLevel = useMemo(() => {
if (!drillPath) return 'lv1';
if (drillPath.lv3) return 'concept';
if (drillPath.lv2) return 'lv3';
if (drillPath.lv1) return 'lv2';
return 'lv1';
}, [drillPath]);
const currentLv1 = useMemo(() => drillPath?.lv1 ? { name: drillPath.lv1 } : null, [drillPath]);
const currentLv2 = useMemo(() => drillPath?.lv2 ? { name: drillPath.lv2 } : null, [drillPath]);
const currentLv3 = useMemo(() => drillPath?.lv3 ? { name: drillPath.lv3 } : null, [drillPath]);
// 面包屑从 drillPath 派生
const breadcrumbs = useMemo(() => {
const items = [{ label: '全部分类', level: 'root' }];
if (drillPath?.lv1) {
items.push({ label: drillPath.lv1, level: 'lv1', data: { name: drillPath.lv1 } });
}
if (drillPath?.lv2) {
items.push({ label: drillPath.lv2, level: 'lv2', data: { name: drillPath.lv2 } });
}
if (drillPath?.lv3) {
items.push({ label: drillPath.lv3, level: 'lv3', data: { name: drillPath.lv3 } });
}
return items;
}, [drillPath]);
const isMobile = useBreakpointValue({ base: true, md: false });
@@ -651,67 +695,43 @@ const HierarchyView = ({
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 },
]);
setDrillPath({ lv1: item.name });
} 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 },
]);
if ((item.children && item.children.length > 0) || (item.concepts && item.concepts.length > 0)) {
setDrillPath({ lv1: drillPath?.lv1, lv2: item.name });
}
} 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 },
]);
setDrillPath({ lv1: drillPath?.lv1, lv2: drillPath?.lv2, lv3: item.name });
} else if (item.level === 'concept') {
// 跳转到概念详情页
const htmlPath = getConceptHtmlUrl(item.name);
window.open(htmlPath, '_blank');
}
}, [currentLv1, currentLv2]);
}, [drillPath, setDrillPath]);
// 面包屑导航
const handleBreadcrumbClick = useCallback((crumb, index) => {
if (crumb.level === 'root') {
setCurrentLevel('lv1');
setCurrentLv1(null);
setCurrentLv2(null);
setCurrentLv3(null);
setBreadcrumbs([{ label: '全部分类', level: 'root' }]);
setDrillPath(null);
} else if (crumb.level === 'lv1') {
setCurrentLevel('lv2');
setCurrentLv1(crumb.data);
setCurrentLv2(null);
setCurrentLv3(null);
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
setDrillPath({ lv1: crumb.data.name });
} else if (crumb.level === 'lv2') {
setCurrentLevel('lv3');
setCurrentLv2(crumb.data);
setCurrentLv3(null);
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
setDrillPath({ lv1: drillPath?.lv1, lv2: crumb.data.name });
}
}, [breadcrumbs]);
}, [drillPath, setDrillPath]);
// 返回上一层
const handleGoBack = useCallback(() => {
if (!drillPath) return;
if (drillPath.lv3) {
setDrillPath({ lv1: drillPath.lv1, lv2: drillPath.lv2 });
} else if (drillPath.lv2) {
setDrillPath({ lv1: drillPath.lv1 });
} else if (drillPath.lv1) {
setDrillPath(null);
}
}, [drillPath, setDrillPath]);
// 刷新
const handleRefreshPrice = useCallback(() => {
@@ -796,6 +816,22 @@ const HierarchyView = ({
);
}
// 当 hideNavigation 为 true 时,只渲染纯卡片网格内容
if (hideNavigation) {
return (
<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>
);
}
return (
<Box
position={isFullscreen ? 'fixed' : 'relative'}
@@ -817,105 +853,92 @@ const HierarchyView = ({
{/* 内容层 */}
<Box position="relative" zIndex={1}>
{/* 面包屑导航 + 工具栏(同一行) */}
<Flex
align="center"
justify="space-between"
<HStack
mb={5}
p={3}
bg="whiteAlpha.50"
backdropFilter={GLASS_BLUR.lg}
borderRadius="2xl"
border="1px solid"
borderColor="whiteAlpha.100"
gap={2}
spacing={2}
justify="space-between"
>
{/* 左侧:面包屑导航 */}
<Flex align="center" flexWrap="wrap" gap={1} flex={1}>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={index}>
{index > 0 && (
<Icon as={ChevronRight} color="whiteAlpha.400" boxSize={3} mx={1} />
)}
<Button
{/* 左侧:返回按钮 + 面包屑导航 */}
<HStack spacing={2}>
{drillPath && (
<Tooltip label="返回上一层">
<IconButton
icon={<ArrowLeft size={16} />}
size="sm"
variant="ghost"
bg={index === breadcrumbs.length - 1 ? 'purple.500' : 'transparent'}
color={index === breadcrumbs.length - 1 ? 'white' : 'whiteAlpha.700'}
leftIcon={index === 0 ? <Home /> : 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" />
onClick={handleGoBack}
bg="rgba(255, 255, 255, 0.08)"
backdropFilter={GLASS_BLUR.lg}
border="1px solid"
borderColor="whiteAlpha.100"
color="whiteAlpha.800"
borderRadius="full"
_hover={{
bg: 'rgba(255, 255, 255, 0.15)',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
/>
</Tooltip>
)}
<Tooltip label="刷新涨跌幅" placement="top">
<IconButton
size="sm"
icon={<RefreshCw />}
onClick={handleRefreshPrice}
isLoading={priceLoading}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label="刷新涨跌幅"
/>
</Tooltip>
<HStack
bg="rgba(255, 255, 255, 0.08)"
backdropFilter={GLASS_BLUR.lg}
px={3}
py={1.5}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
spacing={1}
boxShadow="0 4px 16px rgba(0, 0, 0, 0.2)"
>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={index}>
{index > 0 && (
<Icon as={ChevronRight} boxSize={3} color="whiteAlpha.400" />
)}
<Text
fontSize="xs"
color={index === breadcrumbs.length - 1 ? 'purple.300' : 'whiteAlpha.700'}
fontWeight={index === breadcrumbs.length - 1 ? 'bold' : 'normal'}
cursor={index < breadcrumbs.length - 1 ? 'pointer' : 'default'}
_hover={index < breadcrumbs.length - 1 ? { color: 'purple.300' } : {}}
onClick={() => {
if (index < breadcrumbs.length - 1) {
handleBreadcrumbClick(crumb, index);
}
}}
transition="color 0.2s"
>
{crumb.label}
</Text>
</React.Fragment>
))}
</HStack>
</HStack>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="top">
<IconButton
size="sm"
icon={isFullscreen ? <Minimize2 /> : <Maximize2 />}
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>
{/* 右侧:全屏按钮 */}
<Tooltip label={isFullscreen ? '退出全屏' : '全屏查看'}>
<IconButton
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
size="sm"
variant="ghost"
onClick={toggleFullscreen}
bg="rgba(255, 255, 255, 0.08)"
backdropFilter={GLASS_BLUR.lg}
border="1px solid"
borderColor="whiteAlpha.100"
color="whiteAlpha.800"
borderRadius="full"
_hover={{
bg: 'rgba(255, 255, 255, 0.15)',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label={isFullscreen ? '退出全屏' : '全屏'}
/>
</Tooltip>
</HStack>
{/* 热力图网格 */}
<SimpleGrid columns={getGridColumns()} spacing={{ base: 3, md: 4 }}>
@@ -929,6 +952,47 @@ const HierarchyView = ({
))}
</SimpleGrid>
{/* 底部图例 */}
<Flex
mt={5}
justify="center"
gap={4}
>
<HStack
bg="rgba(255, 255, 255, 0.08)"
backdropFilter={GLASS_BLUR.lg}
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
spacing={3}
boxShadow="0 4px 16px rgba(0, 0, 0, 0.2)"
>
<HStack spacing={2}>
<Box
w={3}
h={3}
borderRadius="full"
bg="linear-gradient(135deg, #EF4444, #DC2626)"
boxShadow="0 0 8px rgba(239, 68, 68, 0.5)"
/>
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
<Box w="1px" h={4} bg="whiteAlpha.200" />
<HStack spacing={2}>
<Box
w={3}
h={3}
borderRadius="full"
bg="linear-gradient(135deg, #22C55E, #16A34A)"
boxShadow="0 0 8px rgba(34, 197, 94, 0.5)"
/>
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
</HStack>
</Flex>
</Box>
</Box>
);