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 { useSearchParams, useNavigate } from 'react-router-dom';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getConceptHtmlUrl } from '../../utils/textUtils'; import { getConceptHtmlUrl } from '../../utils/textUtils';
@@ -116,6 +116,7 @@ import {
Network, Network,
TrendingUp, TrendingUp,
Zap, Zap,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { keyframes } from '@emotion/react'; import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal'; import ConceptTimelineModal from './ConceptTimelineModal';
@@ -123,6 +124,7 @@ import ConceptStatsPanel from './components/ConceptStatsPanel';
import HierarchyView from './components/HierarchyView'; import HierarchyView from './components/HierarchyView';
import ForceGraphView from './components/ForceGraphView'; import ForceGraphView from './components/ForceGraphView';
import BreadcrumbNav from './components/BreadcrumbNav'; import BreadcrumbNav from './components/BreadcrumbNav';
import ChartContainer from './components/ChartContainer';
import ConceptStocksModal from '@components/ConceptStocksModal'; import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker'; import TradeDatePicker from '@components/TradeDatePicker';
// 导航栏已由 MainLayout 提供,无需在此导入 // 导航栏已由 MainLayout 提供,无需在此导入
@@ -367,6 +369,9 @@ const ConceptCenter = () => {
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [viewMode, setViewMode] = useState('list'); // 默认列表视图 const [viewMode, setViewMode] = useState('list'); // 默认列表视图
// 图表共享的钻取路径状态tab 切换时保持)
const [chartDrillPath, setChartDrillPath] = useState(null);
// 层级筛选状态 // 层级筛选状态
const [hierarchyFilter, setHierarchyFilter] = useState({ lv1: null, lv2: null, lv3: null }); const [hierarchyFilter, setHierarchyFilter] = useState({ lv1: null, lv2: null, lv3: null });
@@ -590,6 +595,41 @@ const ConceptCenter = () => {
fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter); fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter);
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]); }, [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 = () => { const handleSearch = () => {
setCurrentPage(1); setCurrentPage(1);
@@ -1437,8 +1477,8 @@ const ConceptCenter = () => {
</Card> </Card>
); );
// 日期选择组件 - 深色主题 // 统一控制栏组件 - 日期 + 排序 + 视图切换(单行布局)
const DateSelector = () => ( const ControlBar = () => (
<Box <Box
p={4} p={4}
bg="rgba(15, 23, 42, 0.8)" bg="rgba(15, 23, 42, 0.8)"
@@ -1448,12 +1488,46 @@ const ConceptCenter = () => {
borderColor="whiteAlpha.100" borderColor="whiteAlpha.100"
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)" boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
> >
<Flex <Flex justify="space-between" align="center" wrap="wrap" gap={4}>
direction={{ base: 'column', lg: 'row' }} {/* 左侧:日期选择区 */}
align={{ base: 'stretch', lg: 'center' }} <HStack spacing={3} flexWrap="wrap">
gap={4} <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 <TradeDatePicker
value={selectedDate} value={selectedDate}
onChange={(date) => { onChange={(date) => {
@@ -1466,101 +1540,135 @@ const ConceptCenter = () => {
fetchConcepts(searchQuery, 1, date, sortBy); fetchConcepts(searchQuery, 1, date, sortBy);
}} }}
latestTradeDate={latestTradeDate} latestTradeDate={latestTradeDate}
label="交易日期" label="更多"
showIcon={false}
showLatestTradeDateTip={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 && ( {latestTradeDate && (
<Tooltip label="数据库中最新的交易日期"> <Text fontSize="xs" color="red.400" whiteSpace="nowrap">
<HStack 更新至 {latestTradeDate.toLocaleDateString('zh-CN')}
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> </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> </Flex>
</Box> </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}> <Box mb={6}>
<DateSelector /> <ControlBar />
</Box> </Box>
{/* 双栏布局:左侧概念卡片,右侧统计面板 */} {/* 概念内容区域 */}
<Flex gap={8} direction={{ base: 'column', xl: 'row' }}> <Box>
{/* 左侧概念卡片区域 */}
<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>
{/* 面包屑导航 - 显示当前层级筛选 */} {/* 面包屑导航 - 显示当前层级筛选 */}
<BreadcrumbNav <BreadcrumbNav
filter={hierarchyFilter} filter={hierarchyFilter}
@@ -1772,32 +1754,36 @@ const ConceptCenter = () => {
isDarkMode={true} 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"> {(viewMode === 'force3d' || viewMode === 'hierarchy') ? (
<HStack> <ChartContainer
<Icon as={Info} color="blue.300" /> breadcrumbItems={chartBreadcrumbItems}
<Text fontSize="sm" color="whiteAlpha.800"> onNavigate={handleChartNavigate}
当前显示 <Text as="strong" color="cyan.300">{selectedDate.toLocaleDateString('zh-CN')}</Text> onGoBack={handleChartGoBack}
{searchQuery && <span>搜索词<Text as="strong" color="cyan.300">"{searchQuery}"</Text></span>} showBackButton={!!chartDrillPath}
</Text> showLegend={viewMode === 'hierarchy'}
</HStack> contentTopPadding={viewMode === 'force3d' ? 0 : 14}
</Box> >
)}
{/* 3D 力导向图视图 */}
{viewMode === 'force3d' ? ( {viewMode === 'force3d' ? (
<ForceGraphView <ForceGraphView
apiBaseUrl={API_BASE_URL} apiBaseUrl={API_BASE_URL}
onSelectCategory={handleHierarchySelect} onSelectCategory={handleHierarchySelect}
selectedDate={selectedDate} selectedDate={selectedDate}
externalDrillPath={chartDrillPath}
onDrillPathChange={setChartDrillPath}
hideNavigation={true}
/> />
) : /* 层级图视图 */ ) : (
viewMode === 'hierarchy' ? (
<HierarchyView <HierarchyView
apiBaseUrl={API_BASE_URL} apiBaseUrl={API_BASE_URL}
onSelectCategory={handleHierarchySelect} onSelectCategory={handleHierarchySelect}
selectedDate={selectedDate} selectedDate={selectedDate}
externalDrillPath={chartDrillPath}
onDrillPathChange={setChartDrillPath}
hideNavigation={true}
/> />
)}
</ChartContainer>
) : loading ? ( ) : loading ? (
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}> <SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
{[...Array(12)].map((_, i) => ( {[...Array(12)].map((_, i) => (
@@ -1934,10 +1920,10 @@ const ConceptCenter = () => {
</Center> </Center>
) : null} ) : null}
</Box> </Box>
</Box>
{/* 右侧统计面板 */} {/* 右侧:概念统计中心 */}
<Box w={{ base: '100%', xl: '400px' }} flexShrink={0}> <Box w={{ base: '100%', xl: '400px' }} flexShrink={0}>
<Box position="sticky" top={6}>
{hasFeatureAccess('concept_stats_panel') ? ( {hasFeatureAccess('concept_stats_panel') ? (
<ConceptStatsPanel <ConceptStatsPanel
apiBaseUrl={API_BASE_URL} apiBaseUrl={API_BASE_URL}
@@ -1952,35 +1938,17 @@ const ConceptCenter = () => {
borderColor="whiteAlpha.100" borderColor="whiteAlpha.100"
borderRadius="2xl" borderRadius="2xl"
> >
<CardBody p={6}> <CardBody p={4}>
<VStack spacing={4} textAlign="center"> <HStack spacing={3}>
<Icon as={LineChart} boxSize={12} color="whiteAlpha.300" /> <Icon as={LineChart} boxSize={6} color="whiteAlpha.300" />
<VStack spacing={2}>
<Heading size="md" color="white">
概念统计中心
</Heading>
<Text fontSize="sm" color="whiteAlpha.600"> <Text fontSize="sm" color="whiteAlpha.600">
此功能需要Pro版订阅才能使用 概念统计中心需要Pro版订阅
</Text> </Text>
</VStack> </HStack>
<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>
</CardBody> </CardBody>
</Card> </Card>
)} )}
</Box> </Box>
</Box>
</Flex> </Flex>
</Box> </Box>