feat(Concept): 统一图表容器与共享导航状态

- 使用 ChartContainer 包裹矩形树图和层级图
- 添加 chartDrillPath 共享状态,Tab 切换时导航保持
- 矩形树图/层级图使用不同 contentTopPadding 配置

🤖 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:16 +08:00
parent 0a4f068593
commit d08b6f1725

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { logger } from '../../utils/logger';
import { getConceptHtmlUrl } from '../../utils/textUtils';
@@ -116,6 +116,7 @@ import {
Network,
TrendingUp,
Zap,
RefreshCw,
} from 'lucide-react';
import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal';
@@ -123,6 +124,7 @@ import ConceptStatsPanel from './components/ConceptStatsPanel';
import HierarchyView from './components/HierarchyView';
import ForceGraphView from './components/ForceGraphView';
import BreadcrumbNav from './components/BreadcrumbNav';
import ChartContainer from './components/ChartContainer';
import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker';
// 导航栏已由 MainLayout 提供,无需在此导入
@@ -367,6 +369,9 @@ const ConceptCenter = () => {
const [totalPages, setTotalPages] = useState(1);
const [viewMode, setViewMode] = useState('list'); // 默认列表视图
// 图表共享的钻取路径状态tab 切换时保持)
const [chartDrillPath, setChartDrillPath] = useState(null);
// 层级筛选状态
const [hierarchyFilter, setHierarchyFilter] = useState({ lv1: null, lv2: null, lv3: null });
@@ -590,6 +595,41 @@ const ConceptCenter = () => {
fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter);
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]);
// 图表面包屑导航项(从 chartDrillPath 派生)
const chartBreadcrumbItems = useMemo(() => {
const items = [{ label: '全部分类', path: null }];
if (chartDrillPath?.lv1) {
items.push({ label: chartDrillPath.lv1, path: { lv1: chartDrillPath.lv1 } });
}
if (chartDrillPath?.lv2) {
items.push({ label: chartDrillPath.lv2, path: { lv1: chartDrillPath.lv1, lv2: chartDrillPath.lv2 } });
}
if (chartDrillPath?.lv3) {
items.push({ label: chartDrillPath.lv3, path: { lv1: chartDrillPath.lv1, lv2: chartDrillPath.lv2, lv3: chartDrillPath.lv3 } });
}
return items;
}, [chartDrillPath]);
// 图表面包屑导航回调
const handleChartNavigate = useCallback((path) => {
setChartDrillPath(path);
}, []);
// 图表返回上一层
const handleChartGoBack = useCallback(() => {
if (!chartDrillPath) return;
if (chartDrillPath.lv3) {
setChartDrillPath({ lv1: chartDrillPath.lv1, lv2: chartDrillPath.lv2 });
} else if (chartDrillPath.lv2) {
setChartDrillPath({ lv1: chartDrillPath.lv1 });
} else if (chartDrillPath.lv1) {
setChartDrillPath(null);
}
}, [chartDrillPath]);
// 处理搜索
const handleSearch = () => {
setCurrentPage(1);
@@ -1437,8 +1477,8 @@ const ConceptCenter = () => {
</Card>
);
// 日期选择组件 - 深色主题
const DateSelector = () => (
// 统一控制栏组件 - 日期 + 排序 + 视图切换(单行布局)
const ControlBar = () => (
<Box
p={4}
bg="rgba(15, 23, 42, 0.8)"
@@ -1448,12 +1488,46 @@ const ConceptCenter = () => {
borderColor="whiteAlpha.100"
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
>
<Flex
direction={{ base: 'column', lg: 'row' }}
align={{ base: 'stretch', lg: 'center' }}
gap={4}
<Flex justify="space-between" align="center" wrap="wrap" gap={4}>
{/* 左侧:日期选择区 */}
<HStack spacing={3} flexWrap="wrap">
<ButtonGroup size="sm" spacing={1}>
{[
{ label: '今天', days: 0 },
{ label: '昨天', days: 1 },
{ label: '一周前', days: 7 },
{ label: '一月前', days: 30 },
].map(({ label, days }) => {
// 判断是否选中当前按钮
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() - days);
const isSelected = selectedDate &&
selectedDate.toDateString() === targetDate.toDateString();
return (
<Button
key={days}
onClick={() => handleQuickDateSelect(days)}
bg={isSelected ? 'purple.500' : 'whiteAlpha.100'}
color="white"
borderRadius="full"
border="1px solid"
borderColor={isSelected ? 'purple.500' : 'whiteAlpha.200'}
px={3}
size="sm"
boxShadow={isSelected ? '0 0 12px rgba(139, 92, 246, 0.4)' : 'none'}
_hover={{
bg: 'purple.500',
borderColor: 'purple.500',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
}}
transition="all 0.2s"
>
{/* 使用通用日期选择器组件 - 不显示最新日期提示,由下方单独渲染 */}
{label}
</Button>
);
})}
</ButtonGroup>
<TradeDatePicker
value={selectedDate}
onChange={(date) => {
@@ -1466,101 +1540,135 @@ const ConceptCenter = () => {
fetchConcepts(searchQuery, 1, date, sortBy);
}}
latestTradeDate={latestTradeDate}
label="交易日期"
label="更多"
showIcon={false}
showLatestTradeDateTip={false}
inputWidth="130px"
size="sm"
labelColor="white"
/>
{/* 快捷按钮 - 紧跟日期选择器 */}
<ButtonGroup size="sm" flexWrap="wrap" spacing={2}>
<Button
onClick={() => handleQuickDateSelect(0)}
bg="whiteAlpha.100"
color="white"
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
px={4}
_hover={{
bg: 'purple.500',
borderColor: 'purple.500',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
}}
transition="all 0.2s"
>
今天
</Button>
<Button
onClick={() => handleQuickDateSelect(1)}
bg="whiteAlpha.100"
color="white"
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
px={4}
_hover={{
bg: 'purple.500',
borderColor: 'purple.500',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
}}
transition="all 0.2s"
>
昨天
</Button>
<Button
onClick={() => handleQuickDateSelect(7)}
bg="whiteAlpha.100"
color="white"
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
px={4}
_hover={{
bg: 'purple.500',
borderColor: 'purple.500',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
}}
transition="all 0.2s"
>
一周前
</Button>
<Button
onClick={() => handleQuickDateSelect(30)}
bg="whiteAlpha.100"
color="white"
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
px={4}
_hover={{
bg: 'purple.500',
borderColor: 'purple.500',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)',
}}
transition="all 0.2s"
>
一月前
</Button>
</ButtonGroup>
{/* 最新交易日期提示 - 靠右显示 */}
{/* 数据更新提示 */}
{latestTradeDate && (
<Tooltip label="数据库中最新的交易日期">
<HStack
spacing={1.5}
ml="auto"
px={2}
py={1}
opacity={0.7}
_hover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<Icon as={Info} color="blue.300" boxSize={3} />
<Text fontSize="xs" color="blue.200">
数据更新至 {latestTradeDate.toLocaleDateString('zh-CN')}
<Text fontSize="xs" color="red.400" whiteSpace="nowrap">
更新至 {latestTradeDate.toLocaleDateString('zh-CN')}
</Text>
</HStack>
</Tooltip>
)}
</HStack>
{/* 右侧:刷新 + 排序 + 视图切换 */}
<HStack spacing={4} align="center" flexWrap="wrap">
{/* 刷新按钮 */}
<Tooltip label="刷新数据" placement="top">
<IconButton
size="sm"
icon={<RefreshCw size={16} />}
onClick={() => fetchConcepts(searchQuery, currentPage, selectedDate, sortBy)}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'whiteAlpha.200',
borderColor: 'whiteAlpha.300',
}}
transition="all 0.2s"
aria-label="刷新"
/>
</Tooltip>
{/* 排序下拉框 - 仅列表视图显示 */}
{viewMode === 'list' && (
<HStack spacing={2}>
<Text fontSize="sm" color="whiteAlpha.600" whiteSpace="nowrap">排序</Text>
<Select
value={sortBy}
onChange={(e) => handleSortChange(e.target.value)}
width="120px"
size="sm"
focusBorderColor="purple.400"
borderColor="whiteAlpha.300"
borderRadius="lg"
fontWeight="medium"
color="white"
bg="whiteAlpha.50"
_hover={{ borderColor: 'purple.400' }}
sx={{
option: {
bg: 'gray.800',
color: 'white',
},
}}
>
<option value="change_pct">涨跌幅</option>
<option value="_score">相关度</option>
<option value="stock_count">股票数量</option>
<option value="outbreak_date">爆发日期</option>
</Select>
</HStack>
)}
{/* 视图切换按钮组 */}
<ButtonGroup size="sm" isAttached variant="outline">
<Tooltip label="矩形树图" placement="top">
<IconButton
icon={<BoxIcon size={16} />}
onClick={() => {
if (viewMode !== 'force3d') {
trackViewModeChanged('force3d', viewMode);
setViewMode('force3d');
}
}}
bg={viewMode === 'force3d' ? 'purple.500' : 'transparent'}
color={viewMode === 'force3d' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'force3d' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'force3d' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="矩形树图"
/>
</Tooltip>
<Tooltip label="层级图" placement="top">
<IconButton
icon={<Network />}
onClick={() => {
if (viewMode !== 'hierarchy') {
trackViewModeChanged('hierarchy', viewMode);
setViewMode('hierarchy');
}
}}
bg={viewMode === 'hierarchy' ? 'purple.500' : 'transparent'}
color={viewMode === 'hierarchy' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'hierarchy' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'hierarchy' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="层级图"
/>
</Tooltip>
<Tooltip label="列表视图" placement="top">
<IconButton
icon={<List />}
onClick={() => {
if (viewMode !== 'list') {
trackViewModeChanged('list', viewMode);
setViewMode('list');
}
}}
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
color={viewMode === 'list' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'list' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="列表视图"
/>
</Tooltip>
</ButtonGroup>
</HStack>
</Flex>
</Box>
);
@@ -1626,144 +1734,18 @@ const ConceptCenter = () => {
}
/>
{/* 主内容区域 - padding 由 MainLayout 统一设置 */}
<Box py={10} position="relative" zIndex={1}>
{/* 主内容区域 - 左右两栏布局 */}
<Box pt={0} pb={10} position="relative" zIndex={1}>
<Flex gap={6} direction={{ base: 'column', xl: 'row' }} align="flex-start">
{/* 左侧:控制栏 + 概念内容 */}
<Box flex="1" minW={0}>
{/* 控制栏 */}
<Box mb={6}>
<DateSelector />
<ControlBar />
</Box>
{/* 双栏布局:左侧概念卡片,右侧统计面板 */}
<Flex gap={8} direction={{ base: 'column', xl: 'row' }}>
{/* 左侧概念卡片区域 */}
<Box flex={1}>
<Card
mb={8}
bg="rgba(15, 23, 42, 0.8)"
backdropFilter={GLASS_BLUR.lg}
border="1px solid"
borderColor="whiteAlpha.100"
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
borderRadius="2xl"
>
<CardBody>
<Flex
direction={{ base: 'column', md: 'row' }}
justify="space-between"
align={{ base: 'stretch', md: 'center' }}
gap={4}
>
{/* 排序方式 - 仅在列表视图显示 */}
{viewMode === 'list' && (
<HStack spacing={4} align="center">
<Icon as={Tags} boxSize={4} color="purple.300" />
<Text fontWeight="bold" color="white">排序方式</Text>
<Select
value={sortBy}
onChange={(e) => handleSortChange(e.target.value)}
width="200px"
focusBorderColor="purple.400"
borderColor="whiteAlpha.300"
borderRadius="lg"
fontWeight="medium"
color="white"
bg="whiteAlpha.50"
_hover={{ borderColor: 'purple.400' }}
sx={{
option: {
bg: 'gray.800',
color: 'white',
},
}}
>
<option value="change_pct">涨跌幅</option>
<option value="_score">相关度</option>
<option value="stock_count">股票数量</option>
<option value="outbreak_date">爆发日期</option>
</Select>
{searchQuery && sortBy === '_score' && (
<Tooltip label="搜索时自动切换到相关度排序,以显示最匹配的结果。您也可以手动切换其他排序方式。">
<HStack
spacing={1}
bg="blue.500"
px={3}
py={1}
borderRadius="full"
boxShadow="0 0 10px rgba(59, 130, 246, 0.4)"
>
<Icon as={Info} color="white" boxSize={3} />
<Text fontSize="xs" color="white" fontWeight="medium">
智能排序
</Text>
</HStack>
</Tooltip>
)}
</HStack>
)}
<ButtonGroup size="sm" isAttached variant="outline" ml={viewMode !== 'list' ? 'auto' : undefined}>
<Tooltip label="概念矩形树图" placement="top">
<IconButton
icon={<BoxIcon size={16} />}
onClick={() => {
if (viewMode !== 'force3d') {
trackViewModeChanged('force3d', viewMode);
setViewMode('force3d');
}
}}
bg={viewMode === 'force3d' ? 'purple.500' : 'transparent'}
color={viewMode === 'force3d' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'force3d' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'force3d' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="概念矩形树图"
/>
</Tooltip>
<Tooltip label="层级图" placement="top">
<IconButton
icon={<Network />}
onClick={() => {
if (viewMode !== 'hierarchy') {
trackViewModeChanged('hierarchy', viewMode);
setViewMode('hierarchy');
}
}}
bg={viewMode === 'hierarchy' ? 'purple.500' : 'transparent'}
color={viewMode === 'hierarchy' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'hierarchy' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'hierarchy' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="层级图"
/>
</Tooltip>
<Tooltip label="列表视图" placement="top">
<IconButton
icon={<List />}
onClick={() => {
if (viewMode !== 'list') {
trackViewModeChanged('list', viewMode);
setViewMode('list');
}
}}
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
color={viewMode === 'list' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'list' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="列表视图"
/>
</Tooltip>
</ButtonGroup>
</Flex>
</CardBody>
</Card>
{/* 概念内容区域 */}
<Box>
{/* 面包屑导航 - 显示当前层级筛选 */}
<BreadcrumbNav
filter={hierarchyFilter}
@@ -1772,32 +1754,36 @@ const ConceptCenter = () => {
isDarkMode={true}
/>
{selectedDate && viewMode !== 'hierarchy' && viewMode !== 'force3d' && (
<Box mb={4} p={3} bg="rgba(59, 130, 246, 0.2)" borderRadius="xl" borderLeft="4px solid" borderColor="blue.400">
<HStack>
<Icon as={Info} color="blue.300" />
<Text fontSize="sm" color="whiteAlpha.800">
当前显示 <Text as="strong" color="cyan.300">{selectedDate.toLocaleDateString('zh-CN')}</Text>
{searchQuery && <span>搜索词<Text as="strong" color="cyan.300">"{searchQuery}"</Text></span>}
</Text>
</HStack>
</Box>
)}
{/* 3D 力导向图视图 */}
{/* 矩形树图 / 层级图视图 - 使用统一容器 */}
{(viewMode === 'force3d' || viewMode === 'hierarchy') ? (
<ChartContainer
breadcrumbItems={chartBreadcrumbItems}
onNavigate={handleChartNavigate}
onGoBack={handleChartGoBack}
showBackButton={!!chartDrillPath}
showLegend={viewMode === 'hierarchy'}
contentTopPadding={viewMode === 'force3d' ? 0 : 14}
>
{viewMode === 'force3d' ? (
<ForceGraphView
apiBaseUrl={API_BASE_URL}
onSelectCategory={handleHierarchySelect}
selectedDate={selectedDate}
externalDrillPath={chartDrillPath}
onDrillPathChange={setChartDrillPath}
hideNavigation={true}
/>
) : /* 层级图视图 */
viewMode === 'hierarchy' ? (
) : (
<HierarchyView
apiBaseUrl={API_BASE_URL}
onSelectCategory={handleHierarchySelect}
selectedDate={selectedDate}
externalDrillPath={chartDrillPath}
onDrillPathChange={setChartDrillPath}
hideNavigation={true}
/>
)}
</ChartContainer>
) : loading ? (
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
{[...Array(12)].map((_, i) => (
@@ -1934,10 +1920,10 @@ const ConceptCenter = () => {
</Center>
) : null}
</Box>
</Box>
{/* 右侧统计面板 */}
{/* 右侧:概念统计中心 */}
<Box w={{ base: '100%', xl: '400px' }} flexShrink={0}>
<Box position="sticky" top={6}>
{hasFeatureAccess('concept_stats_panel') ? (
<ConceptStatsPanel
apiBaseUrl={API_BASE_URL}
@@ -1952,35 +1938,17 @@ const ConceptCenter = () => {
borderColor="whiteAlpha.100"
borderRadius="2xl"
>
<CardBody p={6}>
<VStack spacing={4} textAlign="center">
<Icon as={LineChart} boxSize={12} color="whiteAlpha.300" />
<VStack spacing={2}>
<Heading size="md" color="white">
概念统计中心
</Heading>
<CardBody p={4}>
<HStack spacing={3}>
<Icon as={LineChart} boxSize={6} color="whiteAlpha.300" />
<Text fontSize="sm" color="whiteAlpha.600">
此功能需要Pro版订阅才能使用
概念统计中心需要Pro版订阅
</Text>
</VStack>
<Button
bg="blue.500"
color="white"
leftIcon={<Icon as={Rocket} />}
onClick={() => {
setUpgradeFeature('pro');
setUpgradeModalOpen(true);
}}
_hover={{ bg: 'blue.400', boxShadow: '0 0 15px rgba(59, 130, 246, 0.5)' }}
>
升级到Pro版
</Button>
</VStack>
</HStack>
</CardBody>
</Card>
)}
</Box>
</Box>
</Flex>
</Box>