update pay ui

This commit is contained in:
2025-12-05 13:29:18 +08:00
parent 0db5af1acd
commit 98e975e755
8 changed files with 4177 additions and 49 deletions

1667
concept_api_v2.py Normal file

File diff suppressed because it is too large Load Diff

1275
concept_hierarchy_v3.json Normal file

File diff suppressed because it is too large Load Diff

BIN
max.docx Normal file

Binary file not shown.

BIN
pro.docx Normal file

Binary file not shown.

View File

@@ -521,5 +521,184 @@ export const conceptHandlers = [
query: query, query: query,
mode: mode mode: mode
}); });
}),
// ============ 层级结构 API ============
// 获取完整层级结构
http.get('/concept-api/hierarchy', async () => {
await delay(300);
console.log('[Mock Concept] 获取层级结构');
// 模拟层级结构数据
const hierarchy = [
{
id: 'lv1_1',
name: '人工智能',
concept_count: 98,
children: [
{
id: 'lv2_1_1',
name: 'AI基础设施',
concept_count: 52,
children: [
{ id: 'lv3_1_1_1', name: 'AI算力硬件', concept_count: 16, concepts: ['AI芯片', 'GPU概念股', '服务器', 'AI一体机'] },
{ id: 'lv3_1_1_2', name: 'AI关键组件', concept_count: 24, concepts: ['HBM', 'PCB', '光通信', '存储芯片'] },
{ id: 'lv3_1_1_3', name: 'AI配套设施', concept_count: 12, concepts: ['数据中心', '液冷', '电力设备'] }
]
},
{
id: 'lv2_1_2',
name: 'AI模型与软件',
concept_count: 13,
concepts: ['DeepSeek', 'KIMI', 'SORA概念', '国产大模型']
},
{
id: 'lv2_1_3',
name: 'AI应用',
concept_count: 17,
children: [
{ id: 'lv3_1_3_1', name: '智能体与陪伴', concept_count: 11, concepts: ['AI伴侣', 'AI智能体', 'AI陪伴'] },
{ id: 'lv3_1_3_2', name: '行业应用', concept_count: 6, concepts: ['AI编程', '低代码'] }
]
}
]
},
{
id: 'lv1_2',
name: '半导体',
concept_count: 45,
children: [
{ id: 'lv2_2_1', name: '半导体设备', concept_count: 10, concepts: ['光刻机', 'EDA', '半导体设备'] },
{ id: 'lv2_2_2', name: '半导体材料', concept_count: 8, concepts: ['光刻胶', '半导体材料', '石英砂'] },
{ id: 'lv2_2_3', name: '芯片设计与制造', concept_count: 10, concepts: ['第三代半导体', '碳化硅', '功率半导体'] },
{ id: 'lv2_2_4', name: '先进封装', concept_count: 5, concepts: ['玻璃基板', '半导体封测'] }
]
},
{
id: 'lv1_3',
name: '机器人',
concept_count: 42,
children: [
{ id: 'lv2_3_1', name: '人形机器人整机', concept_count: 20, concepts: ['特斯拉机器人', '人形机器人', '智元机器人'] },
{ id: 'lv2_3_2', name: '机器人核心零部件', concept_count: 12, concepts: ['滚柱丝杆', '电子皮肤', '轴向磁通电机'] },
{ id: 'lv2_3_3', name: '其他类型机器人', concept_count: 10, concepts: ['工业机器人', '机器狗', '外骨骼机器人'] }
]
},
{
id: 'lv1_4',
name: '消费电子',
concept_count: 38,
children: [
{ id: 'lv2_4_1', name: '智能终端', concept_count: 8, concepts: ['AI PC', 'AI手机'] },
{ id: 'lv2_4_2', name: 'XR与空间计算', concept_count: 14, concepts: ['AR眼镜', 'MR', '智能眼镜'] },
{ id: 'lv2_4_3', name: '华为产业链', concept_count: 16, concepts: ['华为Mate70', '鸿蒙', '华为昇腾'] }
]
},
{
id: 'lv1_5',
name: '智能驾驶与汽车',
concept_count: 35,
children: [
{ id: 'lv2_5_1', name: '自动驾驶解决方案', concept_count: 12, concepts: ['Robotaxi', '无人驾驶', '特斯拉FSD'] },
{ id: 'lv2_5_2', name: '智能汽车产业链', concept_count: 15, concepts: ['比亚迪产业链', '小米汽车产业链'] },
{ id: 'lv2_5_3', name: '车路协同', concept_count: 8, concepts: ['车路云一体化', '车路协同'] }
]
},
{
id: 'lv1_6',
name: '新能源与电力',
concept_count: 52,
children: [
{ id: 'lv2_6_1', name: '新型电池技术', concept_count: 18, concepts: ['固态电池', '钠离子电池', '硅基负极'] },
{ id: 'lv2_6_2', name: '电力设备与电网', concept_count: 20, concepts: ['电力', '变压器出海', '燃料电池'] },
{ id: 'lv2_6_3', name: '清洁能源', concept_count: 14, concepts: ['光伏', '核电', '可控核聚变'] }
]
},
{
id: 'lv1_7',
name: '空天经济',
concept_count: 28,
children: [
{ id: 'lv2_7_1', name: '低空经济', concept_count: 14, concepts: ['低空经济', 'eVTOL', '飞行汽车'] },
{ id: 'lv2_7_2', name: '商业航天', concept_count: 14, concepts: ['卫星互联网', '商业航天', '北斗导航'] }
]
},
{
id: 'lv1_8',
name: '国防军工',
concept_count: 25,
children: [
{ id: 'lv2_8_1', name: '无人作战与信息化', concept_count: 10, concepts: ['AI军工', '无人机蜂群', '军工信息化'] },
{ id: 'lv2_8_2', name: '海军装备', concept_count: 8, concepts: ['国产航母', '电磁弹射'] },
{ id: 'lv2_8_3', name: '军贸出海', concept_count: 7, concepts: ['军贸', '巴黎航展'] }
]
}
];
return HttpResponse.json({
hierarchy,
total_lv1: hierarchy.length,
total_concepts: hierarchy.reduce((acc, h) => acc + h.concept_count, 0)
});
}),
// 获取层级统计数据(包含涨跌幅)
http.get('/concept-api/statistics/hierarchy', async () => {
await delay(300);
console.log('[Mock Concept] 获取层级统计数据');
const statistics = [
{ lv1: '人工智能', concept_count: 98, avg_change_pct: 3.56, top_gainer: 'DeepSeek', top_gainer_change: 15.23 },
{ lv1: '半导体', concept_count: 45, avg_change_pct: 2.12, top_gainer: '光刻机', top_gainer_change: 8.76 },
{ lv1: '机器人', concept_count: 42, avg_change_pct: 4.28, top_gainer: '人形机器人', top_gainer_change: 12.45 },
{ lv1: '消费电子', concept_count: 38, avg_change_pct: 1.45, top_gainer: 'AR眼镜', top_gainer_change: 6.78 },
{ lv1: '智能驾驶与汽车', concept_count: 35, avg_change_pct: 2.89, top_gainer: 'Robotaxi', top_gainer_change: 9.32 },
{ lv1: '新能源与电力', concept_count: 52, avg_change_pct: -0.56, top_gainer: '固态电池', top_gainer_change: 5.67 },
{ lv1: '空天经济', concept_count: 28, avg_change_pct: 3.12, top_gainer: '低空经济', top_gainer_change: 11.23 },
{ lv1: '国防军工', concept_count: 25, avg_change_pct: 1.78, top_gainer: 'AI军工', top_gainer_change: 7.89 }
];
return HttpResponse.json({
statistics,
total_lv1: statistics.length,
total_concepts: statistics.reduce((acc, s) => acc + s.concept_count, 0),
market_avg_change: 2.34,
update_time: new Date().toISOString()
});
}),
// 获取指定层级的概念列表
http.get('/concept-api/hierarchy/:lv1Id', async ({ params, request }) => {
await delay(300);
const { lv1Id } = params;
const url = new URL(request.url);
const lv2Id = url.searchParams.get('lv2_id');
console.log('[Mock Concept] 获取层级概念列表:', { lv1Id, lv2Id });
// 返回该层级下的概念列表
let concepts = generatePopularConcepts(20);
// 添加层级信息
concepts = concepts.map(c => ({
...c,
hierarchy: {
lv1: '人工智能',
lv1_id: lv1Id,
lv2: lv2Id ? 'AI基础设施' : null,
lv2_id: lv2Id
}
}));
return HttpResponse.json({
concepts,
total: concepts.length,
lv1_id: lv1Id,
lv2_id: lv2Id
});
}) })
]; ];

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, useBreakpointValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons'; 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 { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react'; import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal'; import ConceptTimelineModal from './ConceptTimelineModal';
import ConceptStatsPanel from './components/ConceptStatsPanel'; import ConceptStatsPanel from './components/ConceptStatsPanel';
import HierarchyView from './components/HierarchyView';
import BreadcrumbNav from './components/BreadcrumbNav';
import ConceptStocksModal from '@components/ConceptStocksModal'; import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker'; import TradeDatePicker from '@components/TradeDatePicker';
// 导航栏已由 MainLayout 提供,无需在此导入 // 导航栏已由 MainLayout 提供,无需在此导入
@@ -161,7 +163,10 @@ const ConceptCenter = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalConcepts, setTotalConcepts] = useState(0); const [totalConcepts, setTotalConcepts] = useState(0);
const [totalPages, setTotalPages] = useState(1); 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); const [selectedDate, setSelectedDate] = useState(null);
@@ -253,7 +258,11 @@ const ConceptCenter = () => {
sort: searchParams.get('sort') || defaultSort, sort: searchParams.get('sort') || defaultSort,
page: parseInt(searchParams.get('page') || '1', 10), page: parseInt(searchParams.get('page') || '1', 10),
date: searchParams.get('date') || null, 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]); }, [searchParams]);
@@ -271,7 +280,7 @@ const ConceptCenter = () => {
}, [searchParams, setSearchParams]); }, [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); setLoading(true);
try { try {
const sortToUse = customSortBy !== null ? customSortBy : sortBy; const sortToUse = customSortBy !== null ? customSortBy : sortBy;
@@ -287,6 +296,14 @@ const ConceptCenter = () => {
requestBody.trade_date = date.toISOString().split('T')[0]; 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`, { const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -308,24 +325,75 @@ const ConceptCenter = () => {
setSelectedDate(new Date(data.price_date)); setSelectedDate(new Date(data.price_date));
} }
} catch (error) { } 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
// toast({ title: '获取数据失败', description: error.message, status: 'error', duration: 3000, isClosable: true }); // toast({ title: '获取数据失败', description: error.message, status: 'error', duration: 3000, isClosable: true });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [pageSize, sortBy]); }, [pageSize, sortBy, hierarchyFilter]);
// 清除搜索 // 清除搜索
const handleClearSearch = () => { const handleClearSearch = () => {
setSearchQuery(''); setSearchQuery('');
setSortBy('change_pct'); setSortBy('change_pct');
setCurrentPage(1); setCurrentPage(1);
updateUrlParams({ q: '', page: 1, sort: 'change_pct' }); setHierarchyFilter({ lv1: null, lv2: null, lv3: null });
fetchConcepts('', 1, selectedDate, 'change_pct'); 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 = () => { const handleSearch = () => {
setCurrentPage(1); setCurrentPage(1);
@@ -556,12 +624,20 @@ const ConceptCenter = () => {
setSortBy(filters.sort); setSortBy(filters.sort);
setCurrentPage(filters.page); 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; const dateToUse = filters.date ? new Date(filters.date) : latestDate;
if (dateToUse) { if (dateToUse) {
setSelectedDate(dateToUse); setSelectedDate(dateToUse);
fetchConcepts(filters.q, filters.page, dateToUse, filters.sort); fetchConcepts(filters.q, filters.page, dateToUse, filters.sort, hierarchyFilterFromUrl);
} else { } else {
fetchConcepts(filters.q, filters.page, null, filters.sort); fetchConcepts(filters.q, filters.page, null, filters.sort, hierarchyFilterFromUrl);
} }
}; };
init(); init();
@@ -1431,46 +1507,76 @@ const ConceptCenter = () => {
</HStack> </HStack>
<ButtonGroup size="sm" isAttached variant="outline"> <ButtonGroup size="sm" isAttached variant="outline">
<IconButton <Tooltip label="层级图" placement="top">
icon={<FaThLarge />} <IconButton
onClick={() => { icon={<FaSitemap />}
if (viewMode !== 'grid') { onClick={() => {
trackViewModeChanged('grid', viewMode); if (viewMode !== 'hierarchy') {
setViewMode('grid'); trackViewModeChanged('hierarchy', viewMode);
} setViewMode('hierarchy');
}} }
bg={viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} }}
color={viewMode === 'grid' ? 'white' : 'purple.500'} bg={viewMode === 'hierarchy' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'}
borderColor="purple.500" color={viewMode === 'hierarchy' ? 'white' : 'purple.500'}
_hover={{ borderColor="purple.500"
bg: viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', _hover={{
boxShadow: viewMode === 'grid' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', 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="网格视图" }}
/> aria-label="层级图"
<IconButton />
icon={<FaList />} </Tooltip>
onClick={() => { <Tooltip label="网格视图" placement="top">
if (viewMode !== 'list') { <IconButton
trackViewModeChanged('list', viewMode); icon={<FaThLarge />}
setViewMode('list'); onClick={() => {
} if (viewMode !== 'grid') {
}} trackViewModeChanged('grid', viewMode);
bg={viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} setViewMode('grid');
color={viewMode === 'list' ? 'white' : 'purple.500'} }
borderColor="purple.500" }}
_hover={{ bg={viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'}
bg: viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', color={viewMode === 'grid' ? 'white' : 'purple.500'}
boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', borderColor="purple.500"
}} _hover={{
aria-label="列表视图" 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> </ButtonGroup>
</Flex> </Flex>
</CardBody> </CardBody>
</Card> </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"> <Box mb={4} p={3} bg="blue.50" borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
<HStack> <HStack>
<Icon as={InfoIcon} color="blue.500" /> <Icon as={InfoIcon} color="blue.500" />
@@ -1482,7 +1588,14 @@ const ConceptCenter = () => {
</Box> </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 }}> <SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
{[...Array(12)].map((_, i) => ( {[...Array(12)].map((_, i) => (
<SkeletonCard key={i} /> <SkeletonCard key={i} />
@@ -1590,17 +1703,32 @@ const ConceptCenter = () => {
</HStack> </HStack>
</Center> </Center>
</> </>
) : ( ) : viewMode !== 'hierarchy' ? (
<Center h="400px"> <Center h="400px">
<VStack spacing={6}> <VStack spacing={6}>
<Icon as={FaTags} boxSize={20} color="gray.300" /> <Icon as={FaTags} boxSize={20} color="gray.300" />
<VStack spacing={2}> <VStack spacing={2}>
<Text fontSize="xl" color="gray.600" fontWeight="medium">暂无概念数据</Text> <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>
</VStack> </VStack>
</Center> </Center>
)} ) : null}
</Box> </Box>
{/* 右侧统计面板 */} {/* 右侧统计面板 */}