perf(DynamicTracking): 子面板支持延迟加载和骨架屏

- ForecastPanel/NewsPanel 接收 isActive 和 activationKey 控制数据加载
- 使用骨架屏替代 Spinner 加载状态
- Tab 切换时自动刷新数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-19 18:54:07 +08:00
parent 90e2a48d66
commit d3f4a8e02c
4 changed files with 202 additions and 27 deletions

View File

@@ -0,0 +1,158 @@
/**
* 动态跟踪 - 导航骨架屏组件
*
* 用于懒加载时显示,让二级导航立即可见
* 导航使用真实 UI内容区域显示骨架屏
*/
import React from 'react';
import {
Box,
Flex,
HStack,
Text,
Icon,
Skeleton,
VStack,
Card,
CardBody,
} from '@chakra-ui/react';
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
// 深空 FUI 主题配置(与 SubTabContainer 保持一致)
const DEEP_SPACE = {
bgGlass: 'rgba(12, 14, 28, 0.6)',
borderGold: 'rgba(212, 175, 55, 0.2)',
borderGoldHover: 'rgba(212, 175, 55, 0.5)',
glowGold: '0 0 30px rgba(212, 175, 55, 0.25), 0 4px 20px rgba(0, 0, 0, 0.3)',
innerGlow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
textWhite: 'rgba(255, 255, 255, 0.95)',
textDark: '#0A0A14',
selectedBg: 'linear-gradient(135deg, rgba(212, 175, 55, 0.95) 0%, rgba(184, 150, 12, 0.95) 100%)',
radius: '12px',
radiusLG: '16px',
};
// 导航配置(与主组件保持同步)
const TRACKING_TABS = [
{ key: 'news', name: '新闻动态', icon: FaNewspaper },
{ key: 'announcements', name: '公司公告', icon: FaBullhorn },
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt },
{ key: 'forecast', name: '业绩预告', icon: FaChartBar },
];
/**
* 新闻动态内容骨架屏
*/
const NewsContentSkeleton: React.FC = () => (
<VStack spacing={3} align="stretch" p={4}>
{[1, 2, 3, 4, 5].map((i) => (
<Card key={i} bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardBody py={3} px={4}>
<HStack spacing={3}>
<Skeleton height="40px" width="40px" borderRadius="md" startColor="gray.700" endColor="gray.600" />
<VStack align="start" flex={1} spacing={2}>
<Skeleton height="16px" width="80%" startColor="gray.700" endColor="gray.600" />
<Skeleton height="12px" width="40%" startColor="gray.700" endColor="gray.600" />
</VStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
);
/**
* DynamicTracking 导航骨架屏
*
* 显示真实的导航 Tab默认选中第一个内容区域显示骨架屏
*/
const DynamicTrackingNavSkeleton: React.FC = () => {
return (
<Box>
{/* 导航栏容器 */}
<Flex
bg={DEEP_SPACE.bgGlass}
backdropFilter="blur(20px)"
borderBottom="1px solid"
borderColor={DEEP_SPACE.borderGold}
borderRadius={DEEP_SPACE.radiusLG}
mx={2}
mb={2}
position="relative"
boxShadow={DEEP_SPACE.innerGlow}
alignItems="center"
>
{/* 顶部金色光条 */}
<Box
position="absolute"
top={0}
left="50%"
transform="translateX(-50%)"
width="50%"
height="1px"
background="linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)"
/>
{/* Tab 列表 */}
<Box
flex="1"
minW={0}
overflowX="auto"
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
<HStack
border="none"
px={3}
py={2}
flexWrap="nowrap"
gap={1.5}
>
{TRACKING_TABS.map((tab, idx) => {
const isSelected = idx === 0;
return (
<Box
key={tab.key}
color={isSelected ? DEEP_SPACE.textDark : DEEP_SPACE.textWhite}
borderRadius={DEEP_SPACE.radius}
px={4}
py={2}
fontSize="13px"
fontWeight={isSelected ? '700' : '500'}
whiteSpace="nowrap"
flexShrink={0}
border="1px solid"
borderColor={isSelected ? DEEP_SPACE.borderGoldHover : 'transparent'}
position="relative"
letterSpacing="0.03em"
bg={isSelected ? DEEP_SPACE.selectedBg : 'transparent'}
boxShadow={isSelected ? DEEP_SPACE.glowGold : 'none'}
transform={isSelected ? 'translateY(-2px)' : 'none'}
cursor="default"
>
<HStack spacing={1.5}>
<Icon
as={tab.icon}
boxSize={3.5}
opacity={isSelected ? 1 : 0.7}
/>
<Text>{tab.name}</Text>
</HStack>
</Box>
);
})}
</HStack>
</Box>
</Flex>
{/* 内容区域骨架屏 */}
<NewsContentSkeleton />
</Box>
);
};
export default DynamicTrackingNavSkeleton;

View File

@@ -7,9 +7,9 @@ import {
Box, Box,
Flex, Flex,
Text, Text,
Spinner,
Center, Center,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ForecastPanelSkeleton } from './DynamicTrackingSkeleton';
import { Tag } from 'antd'; import { Tag } from 'antd';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig'; import axios from '@utils/axiosConfig';
@@ -42,9 +42,17 @@ const getForecastTypeStyle = (type) => {
return styles[type] || { color: THEME.gold, bg: THEME.goldLight, border: THEME.goldBorder }; return styles[type] || { color: THEME.gold, bg: THEME.goldLight, border: THEME.goldBorder };
}; };
const ForecastPanel = ({ stockCode }) => { /**
* 业绩预告面板
* @param {Object} props
* @param {string} props.stockCode - 股票代码
* @param {boolean} props.isActive - SubTabContainer 传递的激活状态,控制是否加载数据
* @param {number} props.activationKey - 激活次数,变化时触发重新请求
*/
const ForecastPanel = ({ stockCode, isActive = true, activationKey }) => {
const [forecast, setForecast] = useState(null); const [forecast, setForecast] = useState(null);
const [loading, setLoading] = useState(false); // 智能初始化 loading当需要加载数据时初始值为 true避免首次渲染闪现空状态
const [loading, setLoading] = useState(() => isActive && !!stockCode);
const loadForecast = useCallback(async () => { const loadForecast = useCallback(async () => {
if (!stockCode) return; if (!stockCode) return;
@@ -65,16 +73,15 @@ const ForecastPanel = ({ stockCode }) => {
} }
}, [stockCode]); }, [stockCode]);
// 加载数据 - activationKey 变化时也会触发重新请求(实现切换刷新)
useEffect(() => { useEffect(() => {
if (isActive) {
loadForecast(); loadForecast();
}, [loadForecast]); }
}, [isActive, activationKey, loadForecast]);
if (loading) { if (loading) {
return ( return <ForecastPanelSkeleton />;
<Center py={10}>
<Spinner size="lg" color={THEME.gold} />
</Center>
);
} }
if (!forecast?.forecasts?.length) { if (!forecast?.forecasts?.length) {

View File

@@ -6,9 +6,17 @@ import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig'; import axios from '@utils/axiosConfig';
import NewsEventsTab from '../NewsEventsTab'; import NewsEventsTab from '../NewsEventsTab';
const NewsPanel = ({ stockCode }) => { /**
* 新闻动态面板
* @param {Object} props
* @param {string} props.stockCode - 股票代码
* @param {boolean} props.isActive - SubTabContainer 传递的激活状态,控制是否加载数据
* @param {number} props.activationKey - 激活次数,变化时触发重新请求
*/
const NewsPanel = ({ stockCode, isActive = true, activationKey }) => {
const [newsEvents, setNewsEvents] = useState([]); const [newsEvents, setNewsEvents] = useState([]);
const [loading, setLoading] = useState(false); // 智能初始化 loading当需要加载数据时初始值为 true避免首次渲染闪现空状态
const [loading, setLoading] = useState(() => isActive && !!stockCode);
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
page: 1, page: 1,
per_page: 10, per_page: 10,
@@ -53,12 +61,12 @@ const NewsPanel = ({ stockCode }) => {
[stockCode] [stockCode]
); );
// 首次加载 - 直接用股票代码搜索 // 加载数据 - activationKey 变化时也会触发重新请求(实现切换刷新)
useEffect(() => { useEffect(() => {
if (stockCode) { if (isActive && stockCode) {
loadNewsEvents(null, 1); loadNewsEvents(null, 1);
} }
}, [stockCode, loadNewsEvents]); }, [stockCode, isActive, activationKey, loadNewsEvents]);
// 搜索处理 // 搜索处理
const handleSearchChange = (value) => { const handleSearchChange = (value) => {

View File

@@ -3,7 +3,7 @@
// 优化:子组件懒加载,骨架屏即时反馈 // 优化:子组件懒加载,骨架屏即时反馈
import React, { useState, useEffect, useMemo, useCallback, memo, lazy } from 'react'; import React, { useState, useEffect, useMemo, useCallback, memo, lazy } from 'react';
import { Box } from '@chakra-ui/react'; import { Card, CardBody } from '@chakra-ui/react';
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa'; import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
import SubTabContainer from '@components/SubTabContainer'; import SubTabContainer from '@components/SubTabContainer';
@@ -92,7 +92,8 @@ const DynamicTracking = memo(({ stockCode: propStockCode }) => {
}, []); }, []);
return ( return (
<Box> <Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={0}>
<SubTabContainer <SubTabContainer
tabs={TRACKING_TABS} tabs={TRACKING_TABS}
componentProps={componentProps} componentProps={componentProps}
@@ -102,7 +103,8 @@ const DynamicTracking = memo(({ stockCode: propStockCode }) => {
isLazy isLazy
size="sm" size="sm"
/> />
</Box> </CardBody>
</Card>
); );
}); });