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,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;