update pay ui

This commit is contained in:
2025-12-05 14:38:47 +08:00
parent 4df5f4dda2
commit bf1e9d3ae6
2 changed files with 361 additions and 165 deletions

View File

@@ -4,7 +4,7 @@
* 使用 CSS Grid + Chakra UI 实现热力图效果
* 特性:
* 1. 炫酷的矩形热力图展示,涨红跌绿背景色
* 2. 点击 lv1 进入 lv2点击 lv2 进入 lv3层层钻取
* 2. 点击 lv1 进入 lv2点击 lv2 进入 lv3点击 lv3 进入具体概念
* 3. 集成 /hierarchy/price 接口获取实时涨跌幅
* 4. 每个分类有独特图标
* 5. 支持面包屑导航返回上级
@@ -60,7 +60,7 @@ import {
FaSatellite,
FaBatteryFull,
FaSolarPanel,
FaWind,
FaTags,
} from 'react-icons/fa';
import { logger } from '../../../utils/logger';
@@ -111,47 +111,27 @@ const LV2_ICONS = {
'军贸出海': FaGlobe,
};
// 一级分类基础颜色(用于渐变
const LV1_BASE_COLORS = {
'人工智能': { from: '#8B5CF6', to: '#A78BFA' },
'半导体': { from: '#3B82F6', to: '#60A5FA' },
'机器人': { from: '#10B981', to: '#34D399' },
'消费电子': { from: '#EC4899', to: '#F472B6' },
'智能驾驶与汽车': { from: '#F97316', to: '#FB923C' },
'新能源与电力': { from: '#22C55E', to: '#4ADE80' },
'空天经济': { from: '#06B6D4', to: '#22D3EE' },
'国防军工': { from: '#EF4444', to: '#F87171' },
'政策与主题': { from: '#F59E0B', to: '#FBBF24' },
'周期与材料': { from: '#6B7280', to: '#9CA3AF' },
'大消费': { from: '#F472B6', to: '#F9A8D4' },
'数字经济与金融科技': { from: '#6366F1', to: '#818CF8' },
'全球宏观与贸易': { from: '#14B8A6', to: '#2DD4BF' },
'医药健康': { from: '#84CC16', to: '#A3E635' },
'前沿科技': { from: '#A855F7', to: '#C084FC' },
};
// 根据涨跌幅获取背景色(涨红跌绿渐变)
const getChangeBgColor = (value, baseColor = null) => {
// 根据涨跌幅获取背景色(统一涨红跌绿
const getChangeBgColor = (value) => {
if (value === null || value === undefined) {
// 无数据时使用基础
if (baseColor) {
return `linear-gradient(135deg, ${baseColor.from} 0%, ${baseColor.to} 100%)`;
}
return 'linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%)';
// 无数据时使用
return 'linear-gradient(135deg, #4B5563 0%, #6B7280 100%)';
}
// 涨跌幅越大,颜色越深
if (value > 5) return 'linear-gradient(135deg, #991B1B 0%, #DC2626 100%)';
if (value > 3) return 'linear-gradient(135deg, #B91C1C 0%, #EF4444 100%)';
if (value > 1) return 'linear-gradient(135deg, #DC2626 0%, #F87171 100%)';
if (value > 0) return 'linear-gradient(135deg, #EF4444 0%, #FCA5A5 100%)';
if (value < -5) return 'linear-gradient(135deg, #14532D 0%, #16A34A 100%)';
if (value < -3) return 'linear-gradient(135deg, #166534 0%, #22C55E 100%)';
if (value < -1) return 'linear-gradient(135deg, #16A34A 0%, #4ADE80 100%)';
if (value < 0) return 'linear-gradient(135deg, #22C55E 0%, #86EFAC 100%)';
// 涨跌幅越大,颜色越深 - 统一涨红跌绿
if (value > 7) return 'linear-gradient(135deg, #7F1D1D 0%, #991B1B 100%)';
if (value > 5) return 'linear-gradient(135deg, #991B1B 0%, #B91C1C 100%)';
if (value > 3) return 'linear-gradient(135deg, #B91C1C 0%, #DC2626 100%)';
if (value > 1) return 'linear-gradient(135deg, #DC2626 0%, #EF4444 100%)';
if (value > 0) return 'linear-gradient(135deg, #EF4444 0%, #F87171 100%)';
if (value < -7) return 'linear-gradient(135deg, #14532D 0%, #166534 100%)';
if (value < -5) return 'linear-gradient(135deg, #166534 0%, #15803D 100%)';
if (value < -3) return 'linear-gradient(135deg, #15803D 0%, #16A34A 100%)';
if (value < -1) return 'linear-gradient(135deg, #16A34A 0%, #22C55E 100%)';
if (value < 0) return 'linear-gradient(135deg, #22C55E 0%, #4ADE80 100%)';
// 平盘
return 'linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%)';
return 'linear-gradient(135deg, #4B5563 0%, #6B7280 100%)';
};
// 格式化涨跌幅
@@ -169,7 +149,16 @@ const getIcon = (name, level) => {
if (level === 'lv2') {
return LV2_ICONS[name] || FaCubes;
}
return FaLightbulb;
if (level === 'lv3') {
return FaCubes;
}
return FaTags; // 具体概念用标签图标
};
// 从 API 返回的名称中提取纯名称(去掉 [一级] [二级] [三级] 前缀)
const extractPureName = (apiName) => {
if (!apiName) return '';
return apiName.replace(/^\[(一级|二级|三级)\]\s*/, '');
};
// 脉冲动画
@@ -182,27 +171,26 @@ const pulseKeyframes = keyframes`
* 单个热力图块组件
*/
const HeatmapBlock = ({ item, onClick, size = 'normal' }) => {
const isMobile = useBreakpointValue({ base: true, md: false });
const hasChange = item.avg_change_pct !== null && item.avg_change_pct !== undefined;
const isPositive = hasChange && item.avg_change_pct > 0;
const isNegative = hasChange && item.avg_change_pct < 0;
const isLargeChange = hasChange && Math.abs(item.avg_change_pct) > 3;
const IconComponent = getIcon(item.name, item.level);
const baseColor = item.parentLv1
? LV1_BASE_COLORS[item.parentLv1]
: LV1_BASE_COLORS[item.name];
// 根据 size 调整高度
const heightMap = {
large: { base: '140px', md: '180px' },
normal: { base: '120px', md: '150px' },
small: { base: '100px', md: '120px' },
large: { base: '140px', md: '160px' },
normal: { base: '110px', md: '130px' },
small: { base: '90px', md: '100px' },
};
// 是否可点击进入下一层
const canDrillDown = item.level !== 'concept' && (item.children?.length > 0 || item.concepts?.length > 0);
return (
<Box
bg={getChangeBgColor(item.avg_change_pct, baseColor)}
bg={getChangeBgColor(item.avg_change_pct)}
borderRadius="xl"
p={{ base: 3, md: 4 }}
cursor="pointer"
@@ -216,26 +204,17 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 12px 30px rgba(0, 0, 0, 0.3)',
}}
animation={isLargeChange ? `${pulseKeyframes} 2s infinite` : 'none'}
css={isLargeChange ? { animation: `${pulseKeyframes} 2s infinite` } : {}}
>
{/* 背景装饰 */}
<Box
position="absolute"
top={-20}
right={-20}
width="100px"
height="100px"
borderRadius="full"
bg="whiteAlpha.100"
/>
<Box
position="absolute"
bottom={-30}
left={-30}
width="80px"
height="80px"
borderRadius="full"
bg="whiteAlpha.50"
bg="whiteAlpha.100"
/>
{/* 内容 */}
@@ -249,14 +228,14 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => {
{/* 顶部:图标和名称 */}
<HStack spacing={2} align="flex-start">
<Box
p={2}
p={1.5}
bg="whiteAlpha.200"
borderRadius="lg"
backdropFilter="blur(10px)"
flexShrink={0}
>
<Icon
as={IconComponent}
boxSize={{ base: 4, md: 5 }}
boxSize={{ base: 3.5, md: 4 }}
color="white"
/>
</Box>
@@ -264,17 +243,15 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => {
<Text
color="white"
fontWeight="bold"
fontSize={{ base: 'sm', md: 'md' }}
fontSize={{ base: 'xs', md: 'sm' }}
noOfLines={2}
textShadow="0 2px 4px rgba(0,0,0,0.3)"
lineHeight="1.3"
>
{item.name}
</Text>
{item.concept_count && (
<Text
color="whiteAlpha.800"
fontSize="xs"
>
{item.concept_count > 0 && (
<Text color="whiteAlpha.800" fontSize="xs">
{item.concept_count} 个概念
</Text>
)}
@@ -284,7 +261,7 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => {
{/* 底部:涨跌幅 */}
<Flex justify="space-between" align="flex-end">
<VStack align="start" spacing={0}>
{item.stock_count && (
{item.stock_count > 0 && (
<Text color="whiteAlpha.700" fontSize="xs">
{item.stock_count} 只股票
</Text>
@@ -292,9 +269,9 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => {
</VStack>
<HStack spacing={1} align="center">
{hasChange && (
{hasChange && (isPositive || isNegative) && (
<Icon
as={isPositive ? FaArrowUp : isNegative ? FaArrowDown : null}
as={isPositive ? FaArrowUp : FaArrowDown}
boxSize={3}
color="white"
/>
@@ -302,7 +279,7 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => {
<Text
color="white"
fontWeight="bold"
fontSize={{ base: 'lg', md: '2xl' }}
fontSize={{ base: 'md', md: 'xl' }}
textShadow="0 2px 8px rgba(0,0,0,0.4)"
>
{formatChangePercent(item.avg_change_pct)}
@@ -312,7 +289,7 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => {
</Flex>
{/* 可点击提示 */}
{(item.children?.length > 0 || item.concepts?.length > 0) && (
{canDrillDown && (
<Badge
position="absolute"
top={2}
@@ -346,10 +323,11 @@ const HierarchyView = ({
const [tradeDate, setTradeDate] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// 钻取状态
// 钻取状态 - 支持4层lv1 -> lv2 -> lv3 -> concept
const [currentLevel, setCurrentLevel] = useState('lv1');
const [currentLv1, setCurrentLv1] = useState(null);
const [currentLv2, setCurrentLv2] = useState(null);
const [currentLv3, setCurrentLv3] = useState(null);
const [breadcrumbs, setBreadcrumbs] = useState([{ label: '全部分类', level: 'root' }]);
const isMobile = useBreakpointValue({ base: true, md: false });
@@ -397,18 +375,22 @@ const HierarchyView = ({
const data = await response.json();
// 构建映射表 - 使用纯名称作为 key去掉 [一级] 等前缀)
const lv1Map = {};
const lv2Map = {};
const lv3Map = {};
(data.lv1_concepts || []).forEach(item => {
lv1Map[item.concept_name] = item;
const pureName = extractPureName(item.concept_name);
lv1Map[pureName] = item;
});
(data.lv2_concepts || []).forEach(item => {
lv2Map[item.concept_name] = item;
const pureName = extractPureName(item.concept_name);
lv2Map[pureName] = item;
});
(data.lv3_concepts || []).forEach(item => {
lv3Map[item.concept_name] = item;
const pureName = extractPureName(item.concept_name);
lv3Map[pureName] = item;
});
setPriceData({ lv1Map, lv2Map, lv3Map });
@@ -418,7 +400,8 @@ const HierarchyView = ({
lv1Count: Object.keys(lv1Map).length,
lv2Count: Object.keys(lv2Map).length,
lv3Count: Object.keys(lv3Map).length,
tradeDate: data.trade_date
tradeDate: data.trade_date,
sampleLv1Keys: Object.keys(lv1Map).slice(0, 3),
});
} catch (err) {
logger.warn('HierarchyView', '获取层级涨跌幅失败', { error: err.message });
@@ -441,6 +424,7 @@ const HierarchyView = ({
const currentData = useMemo(() => {
const { lv1Map, lv2Map, lv3Map } = priceData;
// 第一层:显示所有 lv1
if (currentLevel === 'lv1') {
return hierarchy.map((lv1) => {
const price = lv1Map[lv1.name] || {};
@@ -456,6 +440,7 @@ const HierarchyView = ({
});
}
// 第二层:显示选中 lv1 下的 lv2
if (currentLevel === 'lv2' && currentLv1) {
const lv1Data = hierarchy.find(h => h.name === currentLv1.name);
if (!lv1Data || !lv1Data.children) return [];
@@ -476,6 +461,7 @@ const HierarchyView = ({
});
}
// 第三层:显示选中 lv2 下的 lv3
if (currentLevel === 'lv3' && currentLv1 && currentLv2) {
const lv1Data = hierarchy.find(h => h.name === currentLv1.name);
if (!lv1Data || !lv1Data.children) return [];
@@ -483,6 +469,7 @@ const HierarchyView = ({
const lv2Data = lv1Data.children.find(h => h.name === currentLv2.name);
if (!lv2Data) return [];
// 如果有 lv3 子级
if (lv2Data.children && lv2Data.children.length > 0) {
return lv2Data.children.map((lv3) => {
const price = lv3Map[lv3.name] || {};
@@ -500,6 +487,7 @@ const HierarchyView = ({
});
}
// 如果 lv2 直接包含概念(没有 lv3
if (lv2Data.concepts && lv2Data.concepts.length > 0) {
return lv2Data.concepts.map((concept) => ({
name: concept,
@@ -512,14 +500,35 @@ const HierarchyView = ({
return [];
}
// 第四层:显示选中 lv3 下的具体概念
if (currentLevel === 'concept' && currentLv1 && currentLv2 && currentLv3) {
const lv1Data = hierarchy.find(h => h.name === currentLv1.name);
if (!lv1Data || !lv1Data.children) return [];
const lv2Data = lv1Data.children.find(h => h.name === currentLv2.name);
if (!lv2Data || !lv2Data.children) return [];
const lv3Data = lv2Data.children.find(h => h.name === currentLv3.name);
if (!lv3Data || !lv3Data.concepts) return [];
return lv3Data.concepts.map((concept) => ({
name: concept,
level: 'concept',
parentLv1: currentLv1.name,
parentLv2: currentLv2.name,
parentLv3: currentLv3.name,
}));
}
return [];
}, [hierarchy, priceData, currentLevel, currentLv1, currentLv2]);
}, [hierarchy, priceData, currentLevel, currentLv1, currentLv2, currentLv3]);
// 处理点击事件 - 钻取
const handleBlockClick = useCallback((item) => {
logger.info('HierarchyView', '热力图点击', { level: item.level, name: item.name });
if (item.level === 'lv1' && item.children && item.children.length > 0) {
// 进入 lv2
setCurrentLevel('lv2');
setCurrentLv1(item);
setBreadcrumbs([
@@ -527,7 +536,9 @@ const HierarchyView = ({
{ label: item.name, level: 'lv1', data: item },
]);
} else if (item.level === 'lv2') {
if ((item.children && item.children.length > 0) || (item.concepts && item.concepts.length > 0)) {
// 检查是否有 lv3 或直接是概念
if (item.children && item.children.length > 0) {
// 进入 lv3
setCurrentLevel('lv3');
setCurrentLv2(item);
setBreadcrumbs([
@@ -535,9 +546,32 @@ const HierarchyView = ({
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: item.name, level: 'lv2', data: item },
]);
} else if (item.concepts && item.concepts.length > 0) {
// lv2 直接包含概念,进入概念层
setCurrentLevel('lv3'); // 实际显示概念
setCurrentLv2(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: item.name, level: 'lv2', data: item },
]);
}
} else if (item.level === 'lv3' && item.concepts && item.concepts.length > 0) {
// 进入具体概念层
setCurrentLevel('concept');
setCurrentLv3(item);
setBreadcrumbs([
{ label: '全部分类', level: 'root' },
{ label: currentLv1.name, level: 'lv1', data: currentLv1 },
{ label: currentLv2.name, level: 'lv2', data: currentLv2 },
{ label: item.name, level: 'lv3', data: item },
]);
} else if (item.level === 'concept') {
// 点击具体概念,跳转到概念详情页
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(item.name)}.html`;
window.open(htmlPath, '_blank');
}
}, [currentLv1]);
}, [currentLv1, currentLv2]);
// 面包屑导航
const handleBreadcrumbClick = useCallback((crumb, index) => {
@@ -545,11 +579,18 @@ const HierarchyView = ({
setCurrentLevel('lv1');
setCurrentLv1(null);
setCurrentLv2(null);
setCurrentLv3(null);
setBreadcrumbs([{ label: '全部分类', level: 'root' }]);
} else if (crumb.level === 'lv1') {
setCurrentLevel('lv2');
setCurrentLv1(crumb.data);
setCurrentLv2(null);
setCurrentLv3(null);
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
} else if (crumb.level === 'lv2') {
setCurrentLevel('lv3');
setCurrentLv2(crumb.data);
setCurrentLv3(null);
setBreadcrumbs(breadcrumbs.slice(0, index + 1));
}
}, [breadcrumbs]);
@@ -567,18 +608,27 @@ const HierarchyView = ({
// 获取当前层级标题
const getCurrentTitle = () => {
if (currentLevel === 'lv1') return '概念分类热力图';
if (currentLevel === 'lv2' && currentLv1) return `${currentLv1.name}`;
if (currentLevel === 'lv3' && currentLv2) return `${currentLv2.name}`;
if (currentLevel === 'lv2' && currentLv1) return currentLv1.name;
if (currentLevel === 'lv3' && currentLv2) return currentLv2.name;
if (currentLevel === 'concept' && currentLv3) return currentLv3.name;
return '概念分类';
};
// 获取当前层级描述
const getLevelDesc = () => {
const levelNames = {
lv1: '一级分类',
lv2: '二级分类',
lv3: '三级分类',
concept: '具体概念',
};
return levelNames[currentLevel] || '';
};
// 计算列数
const getGridColumns = () => {
if (currentLevel === 'lv1') {
return { base: 2, md: 3, lg: 4 };
}
if (currentLevel === 'lv2') {
return { base: 2, md: 3, lg: 4 };
if (currentLevel === 'concept') {
return { base: 2, md: 4, lg: 5 }; // 概念层更多列
}
return { base: 2, md: 3, lg: 4 };
};
@@ -641,11 +691,16 @@ const HierarchyView = ({
>
<HStack spacing={3}>
<Icon as={FaLayerGroup} color="purple.500" boxSize={6} />
<Text fontSize="xl" fontWeight="bold" color="gray.800">
{getCurrentTitle()}
</Text>
<VStack align="start" spacing={0}>
<Text fontSize="lg" fontWeight="bold" color="gray.800">
{getCurrentTitle()}
</Text>
<Text fontSize="xs" color="gray.500">
{getLevelDesc()} · {currentData.length}
</Text>
</VStack>
{tradeDate && (
<Badge colorScheme="blue" fontSize="sm" px={3} py={1} borderRadius="full">
<Badge colorScheme="blue" fontSize="xs" px={2} py={1} borderRadius="full">
{tradeDate}
</Badge>
)}
@@ -721,29 +776,39 @@ const HierarchyView = ({
fontSize="sm"
>
<HStack spacing={2}>
<Box w={4} h={4} borderRadius="sm" bg="linear-gradient(135deg, #DC2626 0%, #F87171 100%)" />
<Box w={4} h={4} borderRadius="sm" bg="linear-gradient(135deg, #DC2626 0%, #EF4444 100%)" />
<Text color="gray.600"></Text>
</HStack>
<HStack spacing={2}>
<Box w={4} h={4} borderRadius="sm" bg="linear-gradient(135deg, #22C55E 0%, #4ADE80 100%)" />
<Box w={4} h={4} borderRadius="sm" bg="linear-gradient(135deg, #16A34A 0%, #22C55E 100%)" />
<Text color="gray.600"></Text>
</HStack>
<HStack spacing={2}>
<Box w={4} h={4} borderRadius="sm" bg="linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%)" />
<Text color="gray.600"></Text>
<Box w={4} h={4} borderRadius="sm" bg="linear-gradient(135deg, #4B5563 0%, #6B7280 100%)" />
<Text color="gray.600">/无数据</Text>
</HStack>
<Text color="gray.500">|</Text>
<Text color="gray.500">点击色块查看下级分类</Text>
{currentLevel !== 'concept' && (
<>
<Text color="gray.400">|</Text>
<Text color="gray.500">点击色块查看下级</Text>
</>
)}
{currentLevel === 'concept' && (
<>
<Text color="gray.400">|</Text>
<Text color="gray.500">点击查看概念详情</Text>
</>
)}
</Flex>
{/* 热力图网格 */}
<SimpleGrid columns={getGridColumns()} spacing={{ base: 3, md: 4 }}>
{currentData.map((item, index) => (
{currentData.map((item) => (
<HeatmapBlock
key={item.id || item.name}
item={item}
onClick={handleBlockClick}
size={currentLevel === 'lv1' ? 'large' : 'normal'}
size={currentLevel === 'lv1' ? 'large' : currentLevel === 'concept' ? 'small' : 'normal'}
/>
))}
</SimpleGrid>
@@ -762,8 +827,7 @@ const HierarchyView = ({
borderRadius="full"
fontSize="sm"
>
当前显示 {currentData.length}
{currentLevel === 'lv1' ? '一级' : currentLevel === 'lv2' ? '二级' : '三级'}分类
当前显示 {currentData.length} {getLevelDesc()}
</Badge>
{currentLevel === 'lv1' && (
<Badge