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:
@@ -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"
|
||||
>
|
||||
展开
|
||||
详情 >
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user