update pay ui

This commit is contained in:
2025-12-05 13:29:18 +08:00
parent 20994cfb13
commit 48d9c76c5e
1008 changed files with 417880 additions and 486974 deletions

View File

@@ -0,0 +1,170 @@
/**
* BreadcrumbNav - 层级筛选面包屑导航
*
* 功能:
* 1. 显示当前选中的层级路径
* 2. 支持点击返回上级或清除筛选
*/
import React from 'react';
import {
Box,
HStack,
Text,
Icon,
Badge,
IconButton,
Flex,
Tooltip,
} from '@chakra-ui/react';
import { ChevronRightIcon, CloseIcon } from '@chakra-ui/icons';
import { FaLayerGroup, FaFilter, FaTimes, FaHome } from 'react-icons/fa';
const BreadcrumbNav = ({
filter,
onClearFilter,
onNavigate,
}) => {
// 如果没有筛选条件,不显示
if (!filter || (!filter.lv1 && !filter.lv2 && !filter.lv3)) {
return null;
}
const breadcrumbs = [];
// 构建面包屑路径
if (filter.lv1) {
breadcrumbs.push({
label: filter.lv1,
level: 'lv1',
onClick: () => onNavigate({ lv1: filter.lv1, lv2: null, lv3: null }),
});
}
if (filter.lv2) {
breadcrumbs.push({
label: filter.lv2,
level: 'lv2',
onClick: () => onNavigate({ lv1: filter.lv1, lv2: filter.lv2, lv3: null }),
});
}
if (filter.lv3) {
breadcrumbs.push({
label: filter.lv3,
level: 'lv3',
onClick: null, // 最后一级不可点击
});
}
return (
<Box
bg="linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(168, 85, 247, 0.1) 100%)"
borderRadius="xl"
p={{ base: 3, md: 4 }}
mb={4}
border="1px solid"
borderColor="purple.200"
>
<Flex
align="center"
justify="space-between"
flexWrap="wrap"
gap={2}
>
<HStack spacing={2} flexWrap="wrap">
{/* 筛选图标 */}
<HStack
spacing={2}
bg="purple.500"
color="white"
px={3}
py={1}
borderRadius="full"
>
<Icon as={FaFilter} boxSize={3} />
<Text fontSize="sm" fontWeight="bold">
层级筛选
</Text>
</HStack>
{/* 首页入口 */}
<Tooltip label="返回全部概念" placement="top">
<Badge
colorScheme="gray"
px={3}
py={1}
borderRadius="full"
cursor="pointer"
onClick={onClearFilter}
_hover={{
bg: 'gray.200',
}}
display="flex"
alignItems="center"
gap={1}
>
<Icon as={FaHome} boxSize={3} />
全部
</Badge>
</Tooltip>
{/* 面包屑路径 */}
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={crumb.level}>
<Icon as={ChevronRightIcon} color="gray.400" boxSize={4} />
<Badge
colorScheme={
crumb.level === 'lv1' ? 'purple' :
crumb.level === 'lv2' ? 'blue' : 'cyan'
}
px={3}
py={1}
borderRadius="full"
cursor={crumb.onClick ? 'pointer' : 'default'}
onClick={crumb.onClick}
_hover={crumb.onClick ? {
opacity: 0.8,
transform: 'scale(1.05)',
} : {}}
transition="all 0.2s"
fontWeight={index === breadcrumbs.length - 1 ? 'bold' : 'medium'}
>
{crumb.label}
</Badge>
</React.Fragment>
))}
</HStack>
{/* 清除筛选按钮 */}
<Tooltip label="清除筛选" placement="top">
<IconButton
size="sm"
icon={<FaTimes />}
colorScheme="red"
variant="ghost"
borderRadius="full"
onClick={onClearFilter}
aria-label="清除筛选"
/>
</Tooltip>
</Flex>
{/* 筛选提示 */}
<Text fontSize="xs" color="gray.500" mt={2}>
当前显示{breadcrumbs.map(b => b.label).join(' > ')}分类下的概念
<Text
as="span"
color="purple.600"
cursor="pointer"
fontWeight="medium"
onClick={onClearFilter}
_hover={{ textDecoration: 'underline' }}
>
点击清除筛选
</Text>
</Text>
</Box>
);
};
export default BreadcrumbNav;

View File

@@ -0,0 +1,709 @@
/**
* HierarchyView - 概念层级思维导图视图
*
* 功能:
* 1. 思维导图式展示概念层级结构lv1 → lv2 → lv3 → concepts
* 2. 显示各层级的涨跌幅数据和概念数量
* 3. 点击分类后切换到列表视图显示该分类下的概念
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Spinner,
Center,
Flex,
Collapse,
useBreakpointValue,
Tooltip,
Tag,
TagLabel,
Wrap,
WrapItem,
} from '@chakra-ui/react';
import {
ChevronRightIcon,
ChevronDownIcon,
} from '@chakra-ui/icons';
import {
FaLayerGroup,
FaArrowUp,
FaArrowDown,
FaTags,
FaChartLine,
FaBrain,
FaMicrochip,
FaRobot,
FaMobileAlt,
FaCar,
FaBolt,
FaPlane,
FaShieldAlt,
FaLandmark,
FaFlask,
FaShoppingCart,
FaCoins,
FaGlobe,
FaHeartbeat,
FaAtom,
} from 'react-icons/fa';
import { keyframes } from '@emotion/react';
import { logger } from '../../../utils/logger';
// 脉冲动画
const pulseAnimation = keyframes`
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.4); }
70% { transform: scale(1.02); box-shadow: 0 0 0 10px rgba(139, 92, 246, 0); }
100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(139, 92, 246, 0); }
`;
// 连接线动画
const flowAnimation = keyframes`
0% { stroke-dashoffset: 20; }
100% { stroke-dashoffset: 0; }
`;
// 一级分类图标映射
const LV1_ICONS = {
'人工智能': FaBrain,
'半导体': FaMicrochip,
'机器人': FaRobot,
'消费电子': FaMobileAlt,
'智能驾驶与汽车': FaCar,
'新能源与电力': FaBolt,
'空天经济': FaPlane,
'国防军工': FaShieldAlt,
'政策与主题': FaLandmark,
'周期与材料': FaFlask,
'大消费': FaShoppingCart,
'数字经济与金融科技': FaCoins,
'全球宏观与贸易': FaGlobe,
'医药健康': FaHeartbeat,
'前沿科技': FaAtom,
};
// 一级分类颜色映射
const LV1_COLORS = {
'人工智能': { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' },
'半导体': { bg: 'blue', gradient: 'linear(135deg, #4facfe 0%, #00f2fe 100%)' },
'机器人': { bg: 'cyan', gradient: 'linear(135deg, #43e97b 0%, #38f9d7 100%)' },
'消费电子': { bg: 'pink', gradient: 'linear(135deg, #fa709a 0%, #fee140 100%)' },
'智能驾驶与汽车': { bg: 'orange', gradient: 'linear(135deg, #f093fb 0%, #f5576c 100%)' },
'新能源与电力': { bg: 'green', gradient: 'linear(135deg, #11998e 0%, #38ef7d 100%)' },
'空天经济': { bg: 'teal', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' },
'国防军工': { bg: 'red', gradient: 'linear(135deg, #eb3349 0%, #f45c43 100%)' },
'政策与主题': { bg: 'yellow', gradient: 'linear(135deg, #f6d365 0%, #fda085 100%)' },
'周期与材料': { bg: 'gray', gradient: 'linear(135deg, #bdc3c7 0%, #2c3e50 100%)' },
'大消费': { bg: 'pink', gradient: 'linear(135deg, #ff758c 0%, #ff7eb3 100%)' },
'数字经济与金融科技': { bg: 'blue', gradient: 'linear(135deg, #4776e6 0%, #8e54e9 100%)' },
'全球宏观与贸易': { bg: 'teal', gradient: 'linear(135deg, #00cdac 0%, #8ddad5 100%)' },
'医药健康': { bg: 'green', gradient: 'linear(135deg, #56ab2f 0%, #a8e063 100%)' },
'前沿科技': { bg: 'purple', gradient: 'linear(135deg, #a18cd1 0%, #fbc2eb 100%)' },
};
// 获取涨跌幅颜色
const getChangeColor = (value) => {
if (value === null || value === undefined) return 'gray';
return value > 0 ? 'red' : value < 0 ? 'green' : 'gray';
};
// 格式化涨跌幅
const formatChangePercent = (value) => {
if (value === null || value === undefined) return null;
const formatted = value.toFixed(2);
return value > 0 ? `+${formatted}%` : `${formatted}%`;
};
/**
* 一级分类卡片组件
*/
const Lv1Card = ({
item,
isExpanded,
onToggle,
onSelectCategory,
stats
}) => {
const IconComponent = LV1_ICONS[item.name] || FaLayerGroup;
const colorConfig = LV1_COLORS[item.name] || { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' };
const isMobile = useBreakpointValue({ base: true, md: false });
// 从统计数据中获取涨跌幅
const avgChange = stats?.avg_change_pct;
const changeColor = getChangeColor(avgChange);
return (
<Box
position="relative"
cursor="pointer"
onClick={onToggle}
transition="all 0.3s"
_hover={{
transform: 'scale(1.02)',
}}
>
<Box
bgGradient={colorConfig.gradient}
borderRadius="2xl"
p={{ base: 4, md: 6 }}
color="white"
boxShadow="0 10px 40px rgba(0, 0, 0, 0.2)"
position="relative"
overflow="hidden"
minW={{ base: '160px', md: '220px' }}
animation={isExpanded ? `${pulseAnimation} 2s infinite` : 'none'}
>
{/* 背景装饰 */}
<Box
position="absolute"
top="-20%"
right="-20%"
width="60%"
height="60%"
borderRadius="full"
bg="whiteAlpha.200"
filter="blur(20px)"
/>
<VStack spacing={2} align="center">
<Icon as={IconComponent} boxSize={{ base: 8, md: 10 }} />
<Text
fontSize={{ base: 'md', md: 'lg' }}
fontWeight="bold"
textAlign="center"
>
{item.name}
</Text>
<HStack spacing={3}>
<Badge
bg="whiteAlpha.300"
color="white"
px={2}
py={1}
borderRadius="full"
fontSize="xs"
>
{item.concept_count} 概念
</Badge>
{avgChange !== null && avgChange !== undefined && (
<Badge
bg={changeColor === 'red' ? 'red.500' : changeColor === 'green' ? 'green.500' : 'gray.500'}
color="white"
px={2}
py={1}
borderRadius="full"
fontSize="xs"
display="flex"
alignItems="center"
gap={1}
>
<Icon
as={avgChange > 0 ? FaArrowUp : FaArrowDown}
boxSize={2}
/>
{formatChangePercent(avgChange)}
</Badge>
)}
</HStack>
<Icon
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
boxSize={5}
mt={1}
/>
</VStack>
</Box>
</Box>
);
};
/**
* 二级分类卡片组件
*/
const Lv2Card = ({
item,
parentName,
isExpanded,
onToggle,
onSelectCategory,
stats,
colorConfig
}) => {
const hasChildren = item.children && item.children.length > 0;
const avgChange = stats?.avg_change_pct;
const changeColor = getChangeColor(avgChange);
return (
<Box>
<Box
bg="white"
borderRadius="xl"
p={{ base: 3, md: 4 }}
boxShadow="0 4px 20px rgba(0, 0, 0, 0.08)"
border="2px solid"
borderColor={`${colorConfig.bg}.200`}
cursor="pointer"
onClick={() => hasChildren ? onToggle() : onSelectCategory(parentName, item.name, null)}
transition="all 0.2s"
_hover={{
borderColor: `${colorConfig.bg}.400`,
transform: 'translateX(4px)',
boxShadow: '0 6px 25px rgba(0, 0, 0, 0.12)',
}}
>
<Flex align="center" justify="space-between">
<HStack spacing={3}>
<Box
w={2}
h={8}
borderRadius="full"
bgGradient={colorConfig.gradient}
/>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" color="gray.800" fontSize={{ base: 'sm', md: 'md' }}>
{item.name}
</Text>
<HStack spacing={2}>
<Text fontSize="xs" color="gray.500">
{item.concept_count} 概念
</Text>
{avgChange !== null && avgChange !== undefined && (
<Badge
colorScheme={changeColor}
size="sm"
fontSize="xs"
>
{formatChangePercent(avgChange)}
</Badge>
)}
</HStack>
</VStack>
</HStack>
{hasChildren ? (
<Icon
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
color={`${colorConfig.bg}.500`}
boxSize={5}
/>
) : (
<Icon
as={FaTags}
color={`${colorConfig.bg}.400`}
boxSize={4}
/>
)}
</Flex>
</Box>
{/* 三级分类展开 */}
{hasChildren && (
<Collapse in={isExpanded} animateOpacity>
<VStack
spacing={2}
pl={{ base: 4, md: 6 }}
mt={2}
align="stretch"
>
{item.children.map((lv3Item) => (
<Lv3Card
key={lv3Item.id}
item={lv3Item}
parentLv1={parentName}
parentLv2={item.name}
onSelectCategory={onSelectCategory}
colorConfig={colorConfig}
/>
))}
</VStack>
</Collapse>
)}
</Box>
);
};
/**
* 三级分类卡片组件
*/
const Lv3Card = ({
item,
parentLv1,
parentLv2,
onSelectCategory,
colorConfig
}) => {
return (
<Box
bg={`${colorConfig.bg}.50`}
borderRadius="lg"
p={{ base: 2, md: 3 }}
border="1px solid"
borderColor={`${colorConfig.bg}.100`}
cursor="pointer"
onClick={() => onSelectCategory(parentLv1, parentLv2, item.name)}
transition="all 0.2s"
_hover={{
bg: `${colorConfig.bg}.100`,
transform: 'translateX(4px)',
}}
>
<Flex align="center" justify="space-between">
<HStack spacing={2}>
<Icon as={FaChartLine} color={`${colorConfig.bg}.500`} boxSize={3} />
<Text fontSize="sm" fontWeight="medium" color="gray.700">
{item.name}
</Text>
</HStack>
<Badge
colorScheme={colorConfig.bg}
size="sm"
fontSize="xs"
>
{item.concept_count}
</Badge>
</Flex>
{/* 概念标签预览 */}
{item.concepts && item.concepts.length > 0 && (
<Wrap spacing={1} mt={2}>
{item.concepts.slice(0, 5).map((concept, idx) => (
<WrapItem key={idx}>
<Tag
size="sm"
variant="subtle"
colorScheme={colorConfig.bg}
borderRadius="full"
>
<TagLabel fontSize="xs">{concept}</TagLabel>
</Tag>
</WrapItem>
))}
{item.concepts.length > 5 && (
<WrapItem>
<Text fontSize="xs" color={`${colorConfig.bg}.600`}>
+{item.concepts.length - 5}
</Text>
</WrapItem>
)}
</Wrap>
)}
</Box>
);
};
/**
* 思维导图连接线 SVG
*/
const ConnectionLine = ({ from, to, isActive }) => {
return (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
>
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={isActive ? '#8B5CF6' : '#E2E8F0'}
strokeWidth={isActive ? 3 : 2}
strokeDasharray={isActive ? '0' : '5,5'}
style={{
animation: isActive ? `${flowAnimation} 0.5s linear` : 'none',
}}
/>
</svg>
);
};
/**
* 主组件:层级视图
*/
const HierarchyView = ({
apiBaseUrl,
onSelectCategory,
selectedDate,
}) => {
const [hierarchy, setHierarchy] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expandedLv1, setExpandedLv1] = useState(null);
const [expandedLv2, setExpandedLv2] = useState({});
const [hierarchyStats, setHierarchyStats] = useState(null);
const isMobile = useBreakpointValue({ base: true, md: false });
// 获取层级结构数据
const fetchHierarchy = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${apiBaseUrl}/hierarchy`);
if (!response.ok) throw new Error('获取层级结构失败');
const data = await response.json();
setHierarchy(data.hierarchy || []);
logger.info('HierarchyView', '层级结构加载完成', {
totalLv1: data.hierarchy?.length,
totalConcepts: data.total_concepts
});
} catch (err) {
logger.error('HierarchyView', 'fetchHierarchy', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [apiBaseUrl]);
// 获取层级统计数据(包含涨跌幅)
const fetchHierarchyStats = useCallback(async () => {
try {
const response = await fetch(`${apiBaseUrl}/statistics/hierarchy`);
if (!response.ok) return;
const data = await response.json();
setHierarchyStats(data);
logger.info('HierarchyView', '层级统计加载完成', {
totalLv1: data.total_lv1,
totalConcepts: data.total_concepts
});
} catch (err) {
logger.warn('HierarchyView', '获取层级统计失败', { error: err.message });
}
}, [apiBaseUrl]);
useEffect(() => {
fetchHierarchy();
fetchHierarchyStats();
}, [fetchHierarchy, fetchHierarchyStats]);
// 获取某个 lv1 的统计数据
const getLv1Stats = useCallback((lv1Name) => {
if (!hierarchyStats?.statistics) return null;
return hierarchyStats.statistics.find(s => s.lv1 === lv1Name);
}, [hierarchyStats]);
// 切换一级分类展开状态
const toggleLv1 = useCallback((lv1Id) => {
setExpandedLv1(prev => prev === lv1Id ? null : lv1Id);
setExpandedLv2({});
}, []);
// 切换二级分类展开状态
const toggleLv2 = useCallback((lv2Id) => {
setExpandedLv2(prev => ({
...prev,
[lv2Id]: !prev[lv2Id]
}));
}, []);
// 处理分类选择
const handleSelectCategory = useCallback((lv1, lv2, lv3) => {
logger.info('HierarchyView', '选择分类', { lv1, lv2, lv3 });
onSelectCategory && onSelectCategory({ lv1, lv2, lv3 });
}, [onSelectCategory]);
if (loading) {
return (
<Center h="400px">
<VStack spacing={4}>
<Spinner size="xl" color="purple.500" thickness="4px" />
<Text color="gray.600">正在加载概念层级...</Text>
</VStack>
</Center>
);
}
if (error) {
return (
<Center h="400px">
<VStack spacing={4}>
<Icon as={FaLayerGroup} boxSize={16} color="gray.300" />
<Text color="gray.600">加载失败{error}</Text>
</VStack>
</Center>
);
}
return (
<Box position="relative">
{/* 标题 */}
<VStack spacing={2} mb={6} textAlign="center">
<HStack spacing={2}>
<Icon as={FaLayerGroup} color="purple.500" boxSize={6} />
<Text fontSize="xl" fontWeight="bold" color="gray.800">
概念层级导航
</Text>
</HStack>
<Text fontSize="sm" color="gray.500">
点击分类展开查看点击具体类目筛选概念列表
</Text>
</VStack>
{/* 思维导图布局 */}
<Box
position="relative"
overflowX="auto"
pb={4}
css={{
'&::-webkit-scrollbar': {
height: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb': {
background: '#c1c1c1',
borderRadius: '4px',
},
}}
>
{/* 一级分类网格 - 居中展示 */}
<Flex
wrap="wrap"
justify="center"
gap={{ base: 3, md: 4 }}
mb={6}
>
{hierarchy.map((lv1Item) => (
<Lv1Card
key={lv1Item.id}
item={lv1Item}
isExpanded={expandedLv1 === lv1Item.id}
onToggle={() => toggleLv1(lv1Item.id)}
onSelectCategory={handleSelectCategory}
stats={getLv1Stats(lv1Item.name)}
/>
))}
</Flex>
{/* 展开的二级分类 */}
{expandedLv1 && (
<Box
bg="gray.50"
borderRadius="2xl"
p={{ base: 4, md: 6 }}
border="2px dashed"
borderColor="purple.200"
animation={`${pulseAnimation} 0.5s ease-out`}
>
{(() => {
const lv1Item = hierarchy.find(h => h.id === expandedLv1);
if (!lv1Item) return null;
const colorConfig = LV1_COLORS[lv1Item.name] || { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' };
return (
<VStack spacing={4} align="stretch">
{/* 展开分类的标题 */}
<HStack spacing={3} mb={2}>
<Box
w={4}
h={4}
borderRadius="full"
bgGradient={colorConfig.gradient}
/>
<Text fontSize="lg" fontWeight="bold" color="gray.700">
{lv1Item.name}
</Text>
<Badge colorScheme={colorConfig.bg}>
{lv1Item.children?.length || 0} 个子分类
</Badge>
{/* 点击筛选该一级分类 */}
<Badge
colorScheme="purple"
cursor="pointer"
onClick={() => handleSelectCategory(lv1Item.name, null, null)}
_hover={{ opacity: 0.8 }}
>
筛选全部
</Badge>
</HStack>
{/* 二级分类列表 */}
<Flex
wrap="wrap"
gap={3}
>
{lv1Item.children?.map((lv2Item) => (
<Box
key={lv2Item.id}
flex={{ base: '1 1 100%', md: '1 1 calc(50% - 12px)', lg: '1 1 calc(33.333% - 12px)' }}
minW={{ base: '100%', md: '280px' }}
>
<Lv2Card
item={lv2Item}
parentName={lv1Item.name}
isExpanded={expandedLv2[lv2Item.id]}
onToggle={() => toggleLv2(lv2Item.id)}
onSelectCategory={handleSelectCategory}
colorConfig={colorConfig}
/>
</Box>
))}
</Flex>
</VStack>
);
})()}
</Box>
)}
</Box>
{/* 统计信息 */}
<Flex
justify="center"
mt={6}
gap={4}
flexWrap="wrap"
>
<Badge
colorScheme="purple"
px={4}
py={2}
borderRadius="full"
fontSize="sm"
>
{hierarchy.length} 个一级分类
</Badge>
<Badge
colorScheme="blue"
px={4}
py={2}
borderRadius="full"
fontSize="sm"
>
{hierarchy.reduce((acc, h) => acc + (h.children?.length || 0), 0)} 个二级分类
</Badge>
<Badge
colorScheme="cyan"
px={4}
py={2}
borderRadius="full"
fontSize="sm"
>
{hierarchy.reduce((acc, h) => acc + h.concept_count, 0)} 个概念
</Badge>
</Flex>
</Box>
);
};
export default HierarchyView;

View File

@@ -81,11 +81,13 @@ import {
useBreakpointValue,
} from '@chakra-ui/react';
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock, FaSitemap, FaLayerGroup } from 'react-icons/fa';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal';
import ConceptStatsPanel from './components/ConceptStatsPanel';
import HierarchyView from './components/HierarchyView';
import BreadcrumbNav from './components/BreadcrumbNav';
import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker';
// 导航栏已由 MainLayout 提供,无需在此导入
@@ -161,7 +163,10 @@ const ConceptCenter = () => {
const [currentPage, setCurrentPage] = useState(1);
const [totalConcepts, setTotalConcepts] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [viewMode, setViewMode] = useState('grid');
const [viewMode, setViewMode] = useState('list'); // 默认列表视图
// 层级筛选状态
const [hierarchyFilter, setHierarchyFilter] = useState({ lv1: null, lv2: null, lv3: null });
// 日期相关状态
const [selectedDate, setSelectedDate] = useState(null);
@@ -253,7 +258,11 @@ const ConceptCenter = () => {
sort: searchParams.get('sort') || defaultSort,
page: parseInt(searchParams.get('page') || '1', 10),
date: searchParams.get('date') || null,
size: 12
size: 12,
// 层级筛选参数
lv1: searchParams.get('lv1') || null,
lv2: searchParams.get('lv2') || null,
lv3: searchParams.get('lv3') || null,
};
}, [searchParams]);
@@ -271,7 +280,7 @@ const ConceptCenter = () => {
}, [searchParams, setSearchParams]);
// 获取概念数据
const fetchConcepts = useCallback(async (query = '', page = 1, date = selectedDate, customSortBy = null) => {
const fetchConcepts = useCallback(async (query = '', page = 1, date = selectedDate, customSortBy = null, filter = hierarchyFilter) => {
setLoading(true);
try {
const sortToUse = customSortBy !== null ? customSortBy : sortBy;
@@ -287,6 +296,14 @@ const ConceptCenter = () => {
requestBody.trade_date = date.toISOString().split('T')[0];
}
// 添加层级筛选参数
if (filter?.lv1) {
requestBody.filter_lv1 = filter.lv1;
}
if (filter?.lv2) {
requestBody.filter_lv2 = filter.lv2;
}
const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST',
headers: {
@@ -308,24 +325,75 @@ const ConceptCenter = () => {
setSelectedDate(new Date(data.price_date));
}
} catch (error) {
logger.error('ConceptCenter', 'fetchConcepts', error, { query, page, date: date?.toISOString(), sortToUse });
logger.error('ConceptCenter', 'fetchConcepts', error, { query, page, date: date?.toISOString(), sortToUse, filter });
// ❌ 移除获取数据失败toast
// toast({ title: '获取数据失败', description: error.message, status: 'error', duration: 3000, isClosable: true });
} finally {
setLoading(false);
}
}, [pageSize, sortBy]);
}, [pageSize, sortBy, hierarchyFilter]);
// 清除搜索
const handleClearSearch = () => {
setSearchQuery('');
setSortBy('change_pct');
setCurrentPage(1);
updateUrlParams({ q: '', page: 1, sort: 'change_pct' });
fetchConcepts('', 1, selectedDate, 'change_pct');
setHierarchyFilter({ lv1: null, lv2: null, lv3: null });
updateUrlParams({ q: '', page: 1, sort: 'change_pct', lv1: '', lv2: '', lv3: '' });
fetchConcepts('', 1, selectedDate, 'change_pct', { lv1: null, lv2: null, lv3: null });
};
// 处理层级筛选选择(从 HierarchyView 点击分类)
const handleHierarchySelect = useCallback((filter) => {
logger.info('ConceptCenter', '层级筛选选择', filter);
setHierarchyFilter(filter);
setCurrentPage(1);
setViewMode('list'); // 切换到列表视图
// 更新 URL 参数
updateUrlParams({
lv1: filter.lv1 || '',
lv2: filter.lv2 || '',
lv3: filter.lv3 || '',
page: 1
});
// 重新获取数据
fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter);
// 显示提示
toast({
title: '已应用筛选',
description: `正在显示「${[filter.lv1, filter.lv2, filter.lv3].filter(Boolean).join(' > ')}」分类下的概念`,
status: 'info',
duration: 2000,
isClosable: true,
});
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts, toast]);
// 清除层级筛选
const handleClearHierarchyFilter = useCallback(() => {
setHierarchyFilter({ lv1: null, lv2: null, lv3: null });
setCurrentPage(1);
updateUrlParams({ lv1: '', lv2: '', lv3: '', page: 1 });
fetchConcepts(searchQuery, 1, selectedDate, sortBy, { lv1: null, lv2: null, lv3: null });
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]);
// 导航到特定层级
const handleNavigateHierarchy = useCallback((filter) => {
setHierarchyFilter(filter);
setCurrentPage(1);
updateUrlParams({
lv1: filter.lv1 || '',
lv2: filter.lv2 || '',
lv3: filter.lv3 || '',
page: 1
});
fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter);
}, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]);
// 处理搜索
const handleSearch = () => {
setCurrentPage(1);
@@ -556,12 +624,20 @@ const ConceptCenter = () => {
setSortBy(filters.sort);
setCurrentPage(filters.page);
// 恢复层级筛选状态
const hierarchyFilterFromUrl = {
lv1: filters.lv1,
lv2: filters.lv2,
lv3: filters.lv3,
};
setHierarchyFilter(hierarchyFilterFromUrl);
const dateToUse = filters.date ? new Date(filters.date) : latestDate;
if (dateToUse) {
setSelectedDate(dateToUse);
fetchConcepts(filters.q, filters.page, dateToUse, filters.sort);
fetchConcepts(filters.q, filters.page, dateToUse, filters.sort, hierarchyFilterFromUrl);
} else {
fetchConcepts(filters.q, filters.page, null, filters.sort);
fetchConcepts(filters.q, filters.page, null, filters.sort, hierarchyFilterFromUrl);
}
};
init();
@@ -1431,46 +1507,76 @@ const ConceptCenter = () => {
</HStack>
<ButtonGroup size="sm" isAttached variant="outline">
<IconButton
icon={<FaThLarge />}
onClick={() => {
if (viewMode !== 'grid') {
trackViewModeChanged('grid', viewMode);
setViewMode('grid');
}
}}
bg={viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'}
color={viewMode === 'grid' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{
bg: viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50',
boxShadow: viewMode === 'grid' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none',
}}
aria-label="网格视图"
/>
<IconButton
icon={<FaList />}
onClick={() => {
if (viewMode !== 'list') {
trackViewModeChanged('list', viewMode);
setViewMode('list');
}
}}
bg={viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'}
color={viewMode === 'list' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{
bg: viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50',
boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none',
}}
aria-label="列表视图"
/>
<Tooltip label="层级图" placement="top">
<IconButton
icon={<FaSitemap />}
onClick={() => {
if (viewMode !== 'hierarchy') {
trackViewModeChanged('hierarchy', viewMode);
setViewMode('hierarchy');
}
}}
bg={viewMode === 'hierarchy' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'}
color={viewMode === 'hierarchy' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{
bg: viewMode === 'hierarchy' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50',
boxShadow: viewMode === 'hierarchy' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none',
}}
aria-label="层级图"
/>
</Tooltip>
<Tooltip label="网格视图" placement="top">
<IconButton
icon={<FaThLarge />}
onClick={() => {
if (viewMode !== 'grid') {
trackViewModeChanged('grid', viewMode);
setViewMode('grid');
}
}}
bg={viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'}
color={viewMode === 'grid' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{
bg: viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50',
boxShadow: viewMode === 'grid' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none',
}}
aria-label="网格视图"
/>
</Tooltip>
<Tooltip label="列表视图" placement="top">
<IconButton
icon={<FaList />}
onClick={() => {
if (viewMode !== 'list') {
trackViewModeChanged('list', viewMode);
setViewMode('list');
}
}}
bg={viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'}
color={viewMode === 'list' ? 'white' : 'purple.500'}
borderColor="purple.500"
_hover={{
bg: viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50',
boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none',
}}
aria-label="列表视图"
/>
</Tooltip>
</ButtonGroup>
</Flex>
</CardBody>
</Card>
{selectedDate && (
{/* 面包屑导航 - 显示当前层级筛选 */}
<BreadcrumbNav
filter={hierarchyFilter}
onClearFilter={handleClearHierarchyFilter}
onNavigate={handleNavigateHierarchy}
/>
{selectedDate && viewMode !== 'hierarchy' && (
<Box mb={4} p={3} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
<HStack>
<Icon as={InfoIcon} color="blue.500" />
@@ -1482,7 +1588,14 @@ const ConceptCenter = () => {
</Box>
)}
{loading ? (
{/* 层级图视图 */}
{viewMode === 'hierarchy' ? (
<HierarchyView
apiBaseUrl={API_BASE_URL}
onSelectCategory={handleHierarchySelect}
selectedDate={selectedDate}
/>
) : loading ? (
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
{[...Array(12)].map((_, i) => (
<SkeletonCard key={i} />
@@ -1590,17 +1703,32 @@ const ConceptCenter = () => {
</HStack>
</Center>
</>
) : (
) : viewMode !== 'hierarchy' ? (
<Center h="400px">
<VStack spacing={6}>
<Icon as={FaTags} boxSize={20} color="gray.300" />
<VStack spacing={2}>
<Text fontSize="xl" color="gray.600" fontWeight="medium">暂无概念数据</Text>
<Text color="gray.500">请尝试其他搜索关键词或选择其他日期</Text>
<Text color="gray.500">
{hierarchyFilter?.lv1
? `${[hierarchyFilter.lv1, hierarchyFilter.lv2, hierarchyFilter.lv3].filter(Boolean).join(' > ')}」分类下暂无数据`
: '请尝试其他搜索关键词或选择其他日期'
}
</Text>
{hierarchyFilter?.lv1 && (
<Button
size="sm"
colorScheme="purple"
variant="outline"
onClick={handleClearHierarchyFilter}
>
清除筛选
</Button>
)}
</VStack>
</VStack>
</Center>
)}
) : null}
</Box>
{/* 右侧统计面板 */}