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