Compare commits

...

11 Commits

Author SHA1 Message Date
zdl
bea4c7fe81 perf(MarketDataView): 优化数据映射性能和请求管理
- useMarketData: 使用 Map 替代 findIndex,O(n*m) → O(n+m) 性能优化
- useMarketData: 修复 React StrictMode 下请求被意外取消的问题
- config.ts: 添加 CompanyOverview 和 DynamicTracking 的骨架屏 fallback

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:06 +08:00
zdl
d3f4a8e02c perf(DynamicTracking): 子面板支持延迟加载和骨架屏
- ForecastPanel/NewsPanel 接收 isActive 和 activationKey 控制数据加载
- 使用骨架屏替代 Spinner 加载状态
- Tab 切换时自动刷新数据

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:05 +08:00
zdl
90e2a48d66 feat(BasicInfoTab): 添加骨架屏并适配延迟加载
- 各 Panel 组件适配新的 hooks 参数格式
- 新增 BasicInfoTabSkeleton 骨架屏组件
- 新增 CompanyOverviewNavSkeleton 导航骨架屏组件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:05 +08:00
zdl
298ac5a335 perf(CompanyOverview): hooks 支持 enabled 延迟加载和刷新
- 所有 hooks 参数改为 options 对象形式
- 新增 enabled 参数支持延迟加载
- 新增 refreshKey 参数支持手动刷新
- 智能初始化 loading 状态,避免首次渲染闪现空状态

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
672e746a26 feat(SubTabContainer): 支持 Tab 激活状态和刷新机制
- SubTabContainer: 新增 isActive 和 activationKey props 传递给子组件
- SubTabContainer: 修复 Tab 切换时页面滚动位置跳转问题
- TabPanelContainer: 新增 skeleton prop 支持自定义骨架屏

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
88da7ad1a5 fix(mock): 完善股票名称映射,支持多只股票
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
8c9cc9845d perf(DynamicTracking): 优化组件加载体验,子组件懒加载
- 使用 React.lazy() 懒加载所有子面板组件
- 为每个 Tab 添加专属骨架屏 fallback
- SubTabContainer 同步渲染,点击立即显示二级导航
- 添加 memo、useCallback、useMemo 性能优化
- 新增 DynamicTrackingSkeleton.tsx 骨架屏组件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
11544909d3 style(MarketDataView): 缩小页面间距
- Container py: 6 → 4
- VStack spacing: 6 → 4

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
08842b9097 fix: 优化加载状态和布局
MarketDataView:
- 移除重复的 LoadingState,改用 KLineModule 内部骨架屏
- 修复点击股票行情后数据不显示的问题

FinancialPanorama:
- 移除表格右上角"显示 6 期"标签
- 优化 loadingTab 状态处理

SubTabContainer:
- 重构布局:Tab 区域可滚动,右侧元素固定

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:04 +08:00
zdl
0ad0287f7b fix(FinancialPanorama): 优化期数切换和数据加载
- Tab 切换时检查期数是否一致,不一致则重新加载
- 股票切换时立即清空旧数据,确保显示骨架屏
- 表格右上角显示当前期数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:03 +08:00
zdl
d394c25d7e feat(MarketDataView): 添加股票行情骨架屏
- 创建 MarketDataSkeleton 组件(摘要卡片 + K线图表 + Tab)
- 配置 Suspense fallback,点击时直接显示骨架屏

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:03 +08:00
40 changed files with 1574 additions and 298 deletions

View File

@@ -22,6 +22,7 @@
import React, { useState, useCallback, memo, Suspense } from 'react'; import React, { useState, useCallback, memo, Suspense } from 'react';
import { import {
Box, Box,
Flex,
Tabs, Tabs,
TabList, TabList,
TabPanels, TabPanels,
@@ -30,7 +31,6 @@ import {
Icon, Icon,
HStack, HStack,
Text, Text,
Spacer,
Center, Center,
Spinner, Spinner,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
@@ -95,6 +95,16 @@ export interface SubTabTheme {
tabHoverBg: string; tabHoverBg: string;
} }
/**
* 尺寸配置
*/
const SIZE_CONFIG = {
sm: { fontSize: '13px', px: 4, py: 2, gap: 1.5, iconSize: 3.5 },
md: { fontSize: '15px', px: 6, py: 3, gap: 2, iconSize: 4 },
} as const;
export type SubTabSize = keyof typeof SIZE_CONFIG;
/** /**
* 预设主题 - 深空 FUI 风格 * 预设主题 - 深空 FUI 风格
*/ */
@@ -140,6 +150,8 @@ export interface SubTabContainerProps {
rightElement?: React.ReactNode; rightElement?: React.ReactNode;
/** 紧凑模式 - 移除 TabList 的外边距 */ /** 紧凑模式 - 移除 TabList 的外边距 */
compact?: boolean; compact?: boolean;
/** Tab 尺寸: sm=小号(二级导航), md=正常(一级导航) */
size?: SubTabSize;
} }
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
@@ -154,7 +166,10 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
isLazy = true, isLazy = true,
rightElement, rightElement,
compact = false, compact = false,
size = 'md',
}) => { }) => {
// 获取尺寸配置
const sizeConfig = SIZE_CONFIG[size];
// 内部状态(非受控模式) // 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex); const [internalIndex, setInternalIndex] = useState(defaultIndex);
@@ -166,6 +181,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
() => new Set([controlledIndex ?? defaultIndex]) () => new Set([controlledIndex ?? defaultIndex])
); );
// 记录每个 Tab 的激活次数(用于支持特定 Tab 切换时重新请求)
const [activationCounts, setActivationCounts] = useState<Record<number, number>>(
() => ({ [controlledIndex ?? defaultIndex]: 1 })
);
// 合并主题 // 合并主题
const theme: SubTabTheme = { const theme: SubTabTheme = {
...THEME_PRESETS[themePreset], ...THEME_PRESETS[themePreset],
@@ -177,6 +197,9 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
*/ */
const handleTabChange = useCallback( const handleTabChange = useCallback(
(newIndex: number) => { (newIndex: number) => {
// 保存当前滚动位置,防止 Tab 切换时页面跳转
const scrollY = window.scrollY;
const tabKey = tabs[newIndex]?.key || ''; const tabKey = tabs[newIndex]?.key || '';
onTabChange?.(newIndex, tabKey); onTabChange?.(newIndex, tabKey);
@@ -186,9 +209,20 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
return new Set(prev).add(newIndex); return new Set(prev).add(newIndex);
}); });
// 更新激活计数(用于触发特定 Tab 的数据刷新)
setActivationCounts(prev => ({
...prev,
[newIndex]: (prev[newIndex] || 0) + 1,
}));
if (controlledIndex === undefined) { if (controlledIndex === undefined) {
setInternalIndex(newIndex); setInternalIndex(newIndex);
} }
// 恢复滚动位置,阻止浏览器自动滚动
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
}, },
[tabs, onTabChange, controlledIndex] [tabs, onTabChange, controlledIndex]
); );
@@ -202,8 +236,8 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
index={currentIndex} index={currentIndex}
onChange={handleTabChange} onChange={handleTabChange}
> >
{/* TabList - 玻璃态导航栏 */} {/* 导航栏容器:左侧 Tab 可滚动,右侧元素固定 */}
<TabList <Flex
bg={theme.bg} bg={theme.bg}
backdropFilter="blur(20px)" backdropFilter="blur(20px)"
borderBottom="1px solid" borderBottom="1px solid"
@@ -211,18 +245,9 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
borderRadius={compact ? 0 : DEEP_SPACE.radiusLG} borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
mx={compact ? 0 : 2} mx={compact ? 0 : 2}
mb={compact ? 0 : 2} mb={compact ? 0 : 2}
px={3}
py={compact ? 2 : 3}
flexWrap="nowrap"
gap={2}
alignItems="center"
overflowX="auto"
position="relative" position="relative"
boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow} boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
css={{ alignItems="center"
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
> >
{/* 顶部金色光条 */} {/* 顶部金色光条 */}
<Box <Box
@@ -235,81 +260,110 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
background={`linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)`} background={`linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)`}
/> />
{tabs.map((tab, idx) => { {/* 左侧:可滚动的 Tab 区域 */}
const isSelected = idx === currentIndex; <Box
flex="1"
minW={0}
overflowX="auto"
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
<TabList
border="none"
px={3}
py={compact ? 2 : sizeConfig.py}
flexWrap="nowrap"
gap={sizeConfig.gap}
>
{tabs.map((tab, idx) => {
const isSelected = idx === currentIndex;
return ( return (
<Tab <Tab
key={tab.key} key={tab.key}
color={theme.tabUnselectedColor} color={theme.tabUnselectedColor}
borderRadius={DEEP_SPACE.radius} borderRadius={DEEP_SPACE.radius}
px={6} px={sizeConfig.px}
py={3} py={sizeConfig.py}
fontSize="15px" fontSize={sizeConfig.fontSize}
fontWeight="500" fontWeight="500"
whiteSpace="nowrap" whiteSpace="nowrap"
flexShrink={0} flexShrink={0}
border="1px solid transparent" border="1px solid transparent"
position="relative" position="relative"
letterSpacing="0.03em" letterSpacing="0.03em"
transition={DEEP_SPACE.transition} transition={DEEP_SPACE.transition}
_before={{ _before={{
content: '""', content: '""',
position: 'absolute', position: 'absolute',
bottom: '-1px', bottom: '-1px',
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
width: isSelected ? '70%' : '0%', width: isSelected ? '70%' : '0%',
height: '2px', height: '2px',
bg: '#D4AF37', bg: '#D4AF37',
borderRadius: 'full', borderRadius: 'full',
transition: 'width 0.3s ease', transition: 'width 0.3s ease',
boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none', boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none',
}} }}
_selected={{ _selected={{
bg: theme.tabSelectedBg, bg: theme.tabSelectedBg,
color: theme.tabSelectedColor, color: theme.tabSelectedColor,
fontWeight: '700', fontWeight: '700',
boxShadow: DEEP_SPACE.glowGold, boxShadow: DEEP_SPACE.glowGold,
border: `1px solid ${DEEP_SPACE.borderGoldHover}`, border: `1px solid ${DEEP_SPACE.borderGoldHover}`,
transform: 'translateY(-2px)', transform: 'translateY(-2px)',
}} }}
_hover={{ _hover={{
bg: isSelected ? undefined : theme.tabHoverBg, bg: isSelected ? undefined : theme.tabHoverBg,
border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`, border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`,
transform: 'translateY(-1px)', transform: 'translateY(-1px)',
}} }}
_active={{ _active={{
transform: 'translateY(0)', transform: 'translateY(0)',
}} }}
> >
<HStack spacing={2}> <HStack spacing={size === 'sm' ? 1.5 : 2}>
{tab.icon && ( {tab.icon && (
<Icon <Icon
as={tab.icon} as={tab.icon}
boxSize={4} boxSize={sizeConfig.iconSize}
opacity={isSelected ? 1 : 0.7} opacity={isSelected ? 1 : 0.7}
transition="opacity 0.2s" transition="opacity 0.2s"
/> />
)} )}
<Text>{tab.name}</Text> <Text>{tab.name}</Text>
</HStack> </HStack>
</Tab> </Tab>
); );
})} })}
</TabList>
</Box>
{/* 右侧:固定的自定义元素(如期数选择器) */}
{rightElement && ( {rightElement && (
<> <Box
<Spacer /> flexShrink={0}
<Box flexShrink={0}>{rightElement}</Box> pr={3}
</> pl={2}
py={compact ? 2 : sizeConfig.py}
borderLeft="1px solid"
borderColor={DEEP_SPACE.borderGold}
>
{rightElement}
</Box>
)} )}
</TabList> </Flex>
<TabPanels p={contentPadding}> <TabPanels p={contentPadding}>
{tabs.map((tab, idx) => { {tabs.map((tab, idx) => {
const Component = tab.component; const Component = tab.component;
// 懒加载:只渲染已访问过的 Tab // 懒加载:只渲染已访问过的 Tab
const shouldRender = !isLazy || visitedTabs.has(idx); const shouldRender = !isLazy || visitedTabs.has(idx);
// 判断是否为当前激活的 Tab用于控制数据加载
const isActive = idx === currentIndex;
return ( return (
<TabPanel key={tab.key} p={0}> <TabPanel key={tab.key} p={0}>
@@ -328,7 +382,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
) )
} }
> >
<Component {...componentProps} /> <Component
{...componentProps}
isActive={isActive}
activationKey={activationCounts[idx] || 0}
/>
</Suspense> </Suspense>
) : null} ) : null}
</TabPanel> </TabPanel>

View File

@@ -28,6 +28,8 @@ export interface TabPanelContainerProps {
loadingMessage?: string; loadingMessage?: string;
/** 加载状态高度 */ /** 加载状态高度 */
loadingHeight?: string; loadingHeight?: string;
/** 自定义骨架屏组件,优先于默认 Spinner */
skeleton?: React.ReactNode;
/** 子组件间距,默认 6 */ /** 子组件间距,默认 6 */
spacing?: number; spacing?: number;
/** 内边距,默认 4 */ /** 内边距,默认 4 */
@@ -74,6 +76,7 @@ const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
loading = false, loading = false,
loadingMessage = '加载中...', loadingMessage = '加载中...',
loadingHeight = '200px', loadingHeight = '200px',
skeleton,
spacing = 6, spacing = 6,
padding = 4, padding = 4,
showDisclaimer = false, showDisclaimer = false,
@@ -81,6 +84,10 @@ const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
children, children,
}) => { }) => {
if (loading) { if (loading) {
// 如果提供了自定义骨架屏,使用骨架屏;否则使用默认 Spinner
if (skeleton) {
return <>{skeleton}</>;
}
return <LoadingState message={loadingMessage} height={loadingHeight} />; return <LoadingState message={loadingMessage} height={loadingHeight} />;
} }

View File

@@ -3,7 +3,21 @@
// 生成财务数据 // 生成财务数据
export const generateFinancialData = (stockCode) => { export const generateFinancialData = (stockCode) => {
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31']; // 12 期数据 - 用于财务指标表格7个指标Tab
const metricsPeriods = [
'2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31',
'2023-09-30', '2023-06-30', '2023-03-31', '2022-12-31',
'2022-09-30', '2022-06-30', '2022-03-31', '2021-12-31',
];
// 8 期数据 - 用于财务报表3个报表Tab
const statementPeriods = [
'2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31',
'2023-09-30', '2023-06-30', '2023-03-31', '2022-12-31',
];
// 兼容旧代码
const periods = statementPeriods.slice(0, 4);
return { return {
stockCode, stockCode,
@@ -44,8 +58,8 @@ export const generateFinancialData = (stockCode) => {
} }
}, },
// 资产负债表 - 嵌套结构 // 资产负债表 - 嵌套结构8期数据
balanceSheet: periods.map((period, i) => ({ balanceSheet: statementPeriods.map((period, i) => ({
period, period,
assets: { assets: {
current_assets: { current_assets: {
@@ -110,8 +124,8 @@ export const generateFinancialData = (stockCode) => {
} }
})), })),
// 利润表 - 嵌套结构 // 利润表 - 嵌套结构8期数据
incomeStatement: periods.map((period, i) => ({ incomeStatement: statementPeriods.map((period, i) => ({
period, period,
revenue: { revenue: {
total_operating_revenue: 162350 - i * 4000, total_operating_revenue: 162350 - i * 4000,
@@ -166,8 +180,8 @@ export const generateFinancialData = (stockCode) => {
} }
})), })),
// 现金流量表 - 嵌套结构 // 现金流量表 - 嵌套结构8期数据
cashflow: periods.map((period, i) => ({ cashflow: statementPeriods.map((period, i) => ({
period, period,
operating_activities: { operating_activities: {
inflow: { inflow: {
@@ -193,8 +207,8 @@ export const generateFinancialData = (stockCode) => {
} }
})), })),
// 财务指标 - 嵌套结构 // 财务指标 - 嵌套结构12期数据
financialMetrics: periods.map((period, i) => ({ financialMetrics: metricsPeriods.map((period, i) => ({
period, period,
profitability: { profitability: {
roe: 16.23 - i * 0.3, roe: 16.23 - i * 0.3,

View File

@@ -1,9 +1,22 @@
// src/mocks/data/market.js // src/mocks/data/market.js
// 市场行情相关的 Mock 数据 // 市场行情相关的 Mock 数据
// 股票名称映射
const STOCK_NAME_MAP = {
'000001': { name: '平安银行', basePrice: 13.50 },
'600000': { name: '浦发银行', basePrice: 8.20 },
'600519': { name: '贵州茅台', basePrice: 1650.00 },
'000858': { name: '五粮液', basePrice: 165.00 },
'601318': { name: '中国平安', basePrice: 45.00 },
'600036': { name: '招商银行', basePrice: 32.00 },
'300750': { name: '宁德时代', basePrice: 180.00 },
'002594': { name: '比亚迪', basePrice: 260.00 },
};
// 生成市场数据 // 生成市场数据
export const generateMarketData = (stockCode) => { export const generateMarketData = (stockCode) => {
const basePrice = 13.50; // 基准价格平安银行约13.5元) const stockInfo = STOCK_NAME_MAP[stockCode] || { name: `股票${stockCode}`, basePrice: 20.00 };
const basePrice = stockInfo.basePrice;
return { return {
stockCode, stockCode,
@@ -102,7 +115,7 @@ export const generateMarketData = (stockCode) => {
success: true, success: true,
data: { data: {
stock_code: stockCode, stock_code: stockCode,
stock_name: stockCode === '000001' ? '平安银行' : '示例股票', stock_name: stockInfo.name,
latest_trade: { latest_trade: {
close: basePrice, close: basePrice,
change_percent: 1.89, change_percent: 1.89,
@@ -189,7 +202,7 @@ export const generateMarketData = (stockCode) => {
return minuteData; return minuteData;
})(), })(),
code: stockCode, code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例股票', name: stockInfo.name,
trade_date: new Date().toISOString().split('T')[0], trade_date: new Date().toISOString().split('T')[0],
type: '1min' type: '1min'
} }

View File

@@ -31,10 +31,14 @@ import LoadingState from "./LoadingState";
interface AnnouncementsPanelProps { interface AnnouncementsPanelProps {
stockCode: string; stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
/** 激活次数,变化时触发重新请求 */
activationKey?: number;
} }
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) => { const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode, isActive = true, activationKey }) => {
const { announcements, loading } = useAnnouncementsData(stockCode); const { announcements, loading } = useAnnouncementsData({ stockCode, enabled: isActive, refreshKey: activationKey });
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null); const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);

View File

@@ -0,0 +1,271 @@
/**
* BasicInfoTab 骨架屏组件
* 用于各个 Tab 面板的加载状态显示
*/
import React from 'react';
import {
Box,
VStack,
HStack,
SimpleGrid,
Skeleton,
SkeletonText,
SkeletonCircle,
} from '@chakra-ui/react';
// 黑金主题骨架屏样式
const skeletonStyles = {
startColor: 'rgba(212, 175, 55, 0.1)',
endColor: 'rgba(212, 175, 55, 0.2)',
};
// 卡片骨架屏样式
const cardStyle = {
bg: 'linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))',
border: '1px solid',
borderColor: 'rgba(212, 175, 55, 0.2)',
borderRadius: '12px',
p: 4,
};
/**
* 分支机构骨架屏
*/
export const BranchesSkeleton: React.FC = () => (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{[1, 2, 3, 4].map((i) => (
<Box key={i} sx={cardStyle}>
{/* 顶部金色装饰线 */}
<Box
h="2px"
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.3), transparent)"
mb={4}
/>
<VStack align="start" spacing={4}>
{/* 标题行 */}
<HStack justify="space-between" w="full">
<HStack spacing={2} flex={1}>
<Skeleton
{...skeletonStyles}
height="28px"
width="28px"
borderRadius="md"
/>
<Skeleton
{...skeletonStyles}
height="16px"
width="60%"
/>
</HStack>
<Skeleton
{...skeletonStyles}
height="22px"
width="60px"
borderRadius="full"
/>
</HStack>
{/* 分隔线 */}
<Box
w="full"
h="1px"
bgGradient="linear(to-r, rgba(212, 175, 55, 0.2), transparent)"
/>
{/* 信息网格 */}
<SimpleGrid columns={2} spacing={3} w="full">
{[1, 2, 3, 4].map((j) => (
<VStack key={j} align="start" spacing={1}>
<Skeleton {...skeletonStyles} height="12px" width="50px" />
<Skeleton {...skeletonStyles} height="14px" width="80px" />
</VStack>
))}
</SimpleGrid>
</VStack>
</Box>
))}
</SimpleGrid>
);
/**
* 工商信息骨架屏
*/
export const BusinessInfoSkeleton: React.FC = () => (
<VStack spacing={4} align="stretch">
{/* 上半部分:工商信息 + 服务机构 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
{/* 工商信息卡片 */}
<Box sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="16px" width="16px" />
<Skeleton {...skeletonStyles} height="16px" width="80px" />
</HStack>
<VStack spacing={3} align="stretch">
{[1, 2, 3, 4].map((i) => (
<HStack key={i} spacing={3} p={2}>
<Skeleton {...skeletonStyles} height="14px" width="14px" />
<Skeleton {...skeletonStyles} height="14px" width="60px" />
<Skeleton {...skeletonStyles} height="14px" flex={1} />
</HStack>
))}
</VStack>
</Box>
{/* 服务机构卡片 */}
<Box sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="16px" width="16px" />
<Skeleton {...skeletonStyles} height="16px" width="80px" />
</HStack>
<VStack spacing={3} align="stretch">
{[1, 2].map((i) => (
<Box key={i} p={4} borderRadius="10px" bg="rgba(255,255,255,0.02)">
<HStack spacing={2} mb={2}>
<Skeleton {...skeletonStyles} height="14px" width="14px" />
<Skeleton {...skeletonStyles} height="12px" width="80px" />
</HStack>
<Skeleton {...skeletonStyles} height="14px" width="70%" />
</Box>
))}
</VStack>
</Box>
</SimpleGrid>
{/* 下半部分:主营业务 + 经营范围 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
{[1, 2].map((i) => (
<Box key={i} sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="16px" width="16px" />
<Skeleton {...skeletonStyles} height="16px" width="80px" />
</HStack>
<SkeletonText
{...skeletonStyles}
noOfLines={4}
spacing={3}
/>
</Box>
))}
</SimpleGrid>
</VStack>
);
/**
* 股权结构骨架屏
*/
export const ShareholderSkeleton: React.FC = () => (
<Box p={4}>
<VStack spacing={6} align="stretch">
{/* 实际控制人 + 股权集中度 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
{[1, 2].map((i) => (
<Box key={i} sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="18px" width="18px" />
<Skeleton {...skeletonStyles} height="18px" width="100px" />
</HStack>
<VStack spacing={3} align="stretch">
{[1, 2, 3].map((j) => (
<HStack key={j} justify="space-between">
<Skeleton {...skeletonStyles} height="14px" width="80px" />
<Skeleton {...skeletonStyles} height="14px" width="60px" />
</HStack>
))}
</VStack>
</Box>
))}
</SimpleGrid>
{/* 十大股东表格 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
{[1, 2].map((i) => (
<Box key={i} sx={cardStyle}>
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="18px" width="18px" />
<Skeleton {...skeletonStyles} height="18px" width="100px" />
</HStack>
<VStack spacing={2} align="stretch">
{/* 表头 */}
<HStack spacing={4} pb={2} borderBottom="1px solid" borderColor="rgba(212, 175, 55, 0.1)">
<Skeleton {...skeletonStyles} height="12px" width="30px" />
<Skeleton {...skeletonStyles} height="12px" flex={1} />
<Skeleton {...skeletonStyles} height="12px" width="60px" />
<Skeleton {...skeletonStyles} height="12px" width="60px" />
</HStack>
{/* 表格行 */}
{[1, 2, 3, 4, 5].map((j) => (
<HStack key={j} spacing={4} py={2}>
<SkeletonCircle {...skeletonStyles} size="6" />
<Skeleton {...skeletonStyles} height="14px" flex={1} />
<Skeleton {...skeletonStyles} height="14px" width="60px" />
<Skeleton {...skeletonStyles} height="14px" width="60px" />
</HStack>
))}
</VStack>
</Box>
))}
</SimpleGrid>
</VStack>
</Box>
);
/**
* 管理团队骨架屏
*/
export const ManagementSkeleton: React.FC = () => (
<Box p={4}>
<VStack spacing={6} align="stretch">
{/* 每个分类 */}
{[1, 2, 3].map((i) => (
<Box key={i}>
{/* 分类标题 */}
<HStack spacing={2} mb={4}>
<Skeleton {...skeletonStyles} height="20px" width="20px" />
<Skeleton {...skeletonStyles} height="18px" width="80px" />
<Skeleton
{...skeletonStyles}
height="20px"
width="30px"
borderRadius="full"
/>
</HStack>
{/* 人员卡片网格 */}
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{[1, 2, 3, 4].map((j) => (
<Box key={j} sx={cardStyle}>
<VStack spacing={3}>
<SkeletonCircle {...skeletonStyles} size="12" />
<Skeleton {...skeletonStyles} height="16px" width="60px" />
<Skeleton {...skeletonStyles} height="12px" width="80px" />
<HStack spacing={2}>
<Skeleton {...skeletonStyles} height="10px" width="40px" />
<Skeleton {...skeletonStyles} height="10px" width="40px" />
</HStack>
</VStack>
</Box>
))}
</SimpleGrid>
</Box>
))}
</VStack>
</Box>
);
/**
* 通用内容骨架屏
*/
export const ContentSkeleton: React.FC = () => (
<Box p={4}>
<SkeletonText {...skeletonStyles} noOfLines={6} spacing={4} />
</Box>
);
export default {
BranchesSkeleton,
BusinessInfoSkeleton,
ShareholderSkeleton,
ManagementSkeleton,
ContentSkeleton,
};

View File

@@ -16,10 +16,12 @@ import { FaSitemap, FaBuilding, FaCheckCircle, FaTimesCircle } from "react-icons
import { useBranchesData } from "../../hooks/useBranchesData"; import { useBranchesData } from "../../hooks/useBranchesData";
import { THEME } from "../config"; import { THEME } from "../config";
import { formatDate } from "../utils"; import { formatDate } from "../utils";
import LoadingState from "./LoadingState"; import { BranchesSkeleton } from "./BasicInfoTabSkeleton";
interface BranchesPanelProps { interface BranchesPanelProps {
stockCode: string; stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
} }
// 黑金卡片样式 // 黑金卡片样式
@@ -65,11 +67,11 @@ const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label,
</VStack> </VStack>
); );
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode }) => { const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode, isActive = true }) => {
const { branches, loading } = useBranchesData(stockCode); const { branches, loading } = useBranchesData({ stockCode, enabled: isActive });
if (loading) { if (loading) {
return <LoadingState message="加载分支机构数据..." />; return <BranchesSkeleton />;
} }
if (branches.length === 0) { if (branches.length === 0) {

View File

@@ -10,7 +10,6 @@ import {
SimpleGrid, SimpleGrid,
Center, Center,
Icon, Icon,
Spinner,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import {
FaBuilding, FaBuilding,
@@ -27,9 +26,12 @@ import {
import { COLORS, GLASS, glassCardStyle } from "@views/Company/theme"; import { COLORS, GLASS, glassCardStyle } from "@views/Company/theme";
import { THEME } from "../config"; import { THEME } from "../config";
import { useBasicInfo } from "../../hooks/useBasicInfo"; import { useBasicInfo } from "../../hooks/useBasicInfo";
import { BusinessInfoSkeleton } from "./BasicInfoTabSkeleton";
interface BusinessInfoPanelProps { interface BusinessInfoPanelProps {
stockCode: string; stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
} }
// 区块标题组件 // 区块标题组件
@@ -150,15 +152,11 @@ const TextSection: React.FC<{
</Box> </Box>
); );
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => { const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode, isActive = true }) => {
const { basicInfo, loading } = useBasicInfo(stockCode); const { basicInfo, loading } = useBasicInfo({ stockCode, enabled: isActive });
if (loading) { if (loading) {
return ( return <BusinessInfoSkeleton />;
<Center h="200px">
<Spinner size="lg" color={THEME.gold} />
</Center>
);
} }
if (!basicInfo) { if (!basicInfo) {

View File

@@ -0,0 +1,197 @@
/**
* 公司概览 - 导航骨架屏组件
*
* 用于懒加载时显示,让二级导航立即可见
* 导航使用真实 UI内容区域显示骨架屏
*/
import React from 'react';
import {
Box,
Flex,
HStack,
Text,
Icon,
Skeleton,
VStack,
Card,
CardBody,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
} from '@chakra-ui/react';
import {
FaShareAlt,
FaUserTie,
FaSitemap,
FaInfoCircle,
} 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',
};
// 导航配置(与主组件 config.ts 保持同步)
const OVERVIEW_TABS = [
{ key: 'shareholder', name: '股权结构', icon: FaShareAlt },
{ key: 'management', name: '管理团队', icon: FaUserTie },
{ key: 'branches', name: '分支机构', icon: FaSitemap },
{ key: 'business', name: '工商信息', icon: FaInfoCircle },
];
/**
* 股权结构内容骨架屏
*/
const ShareholderContentSkeleton: React.FC = () => (
<Box p={4}>
{/* 表格骨架屏 */}
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardBody p={0}>
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
</Th>
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
</Th>
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
</Th>
<Th borderColor="rgba(212, 175, 55, 0.2)" color="gray.400">
<Skeleton height="14px" width="70px" startColor="gray.700" endColor="gray.600" />
</Th>
</Tr>
</Thead>
<Tbody>
{[1, 2, 3, 4, 5].map((i) => (
<Tr key={i}>
<Td borderColor="rgba(212, 175, 55, 0.2)">
<Skeleton height="14px" width="120px" startColor="gray.700" endColor="gray.600" />
</Td>
<Td borderColor="rgba(212, 175, 55, 0.2)">
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
</Td>
<Td borderColor="rgba(212, 175, 55, 0.2)">
<Skeleton height="14px" width="60px" startColor="gray.700" endColor="gray.600" />
</Td>
<Td borderColor="rgba(212, 175, 55, 0.2)">
<Skeleton height="14px" width="80px" startColor="gray.700" endColor="gray.600" />
</Td>
</Tr>
))}
</Tbody>
</Table>
</CardBody>
</Card>
</Box>
);
/**
* CompanyOverview 导航骨架屏
*
* 显示真实的导航 Tab默认选中第一个内容区域显示骨架屏
*/
const CompanyOverviewNavSkeleton: React.FC = () => {
return (
<Box>
{/* 导航栏容器 - compact 模式(无外边距) */}
<Flex
bg={DEEP_SPACE.bgGlass}
backdropFilter="blur(20px)"
borderBottom="1px solid"
borderColor={DEEP_SPACE.borderGold}
borderRadius={0}
mx={0}
mb={0}
position="relative"
boxShadow="none"
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}
>
{OVERVIEW_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>
{/* 内容区域骨架屏 */}
<ShareholderContentSkeleton />
</Box>
);
};
export default CompanyOverviewNavSkeleton;

View File

@@ -19,10 +19,12 @@ import LoadingState from "./LoadingState";
interface DisclosureSchedulePanelProps { interface DisclosureSchedulePanelProps {
stockCode: string; stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
} }
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode }) => { const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode, isActive = true }) => {
const { disclosureSchedule, loading } = useDisclosureData(stockCode); const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive });
if (loading) { if (loading) {
return <LoadingState message="加载披露日程..." />; return <LoadingState message="加载披露日程..." />;

View File

@@ -11,9 +11,12 @@ import {
ShareholdersTable, ShareholdersTable,
} from "../../components/shareholder"; } from "../../components/shareholder";
import TabPanelContainer from "@components/TabPanelContainer"; import TabPanelContainer from "@components/TabPanelContainer";
import { ShareholderSkeleton } from "./BasicInfoTabSkeleton";
interface ShareholderPanelProps { interface ShareholderPanelProps {
stockCode: string; stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
} }
/** /**
@@ -23,17 +26,17 @@ interface ShareholderPanelProps {
* - ConcentrationCard: 股权集中度卡片 * - ConcentrationCard: 股权集中度卡片
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东) * - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
*/ */
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => { const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode, isActive = true }) => {
const { const {
actualControl, actualControl,
concentration, concentration,
topShareholders, topShareholders,
topCirculationShareholders, topCirculationShareholders,
loading, loading,
} = useShareholderData(stockCode); } = useShareholderData({ stockCode, enabled: isActive });
return ( return (
<TabPanelContainer loading={loading} loadingMessage="加载股权结构数据..."> <TabPanelContainer loading={loading} skeleton={<ShareholderSkeleton />}>
{/* 实际控制人 + 股权集中度 左右分布 */} {/* 实际控制人 + 股权集中度 左右分布 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}> <SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
<Box> <Box>

View File

@@ -9,3 +9,6 @@ export { ManagementPanel } from "./management";
export { default as AnnouncementsPanel } from "./AnnouncementsPanel"; export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
export { default as BranchesPanel } from "./BranchesPanel"; export { default as BranchesPanel } from "./BranchesPanel";
export { default as BusinessInfoPanel } from "./BusinessInfoPanel"; export { default as BusinessInfoPanel } from "./BusinessInfoPanel";
// 骨架屏组件
export * from "./BasicInfoTabSkeleton";

View File

@@ -13,6 +13,7 @@ import { useManagementData } from "../../../hooks/useManagementData";
import { THEME } from "../../config"; import { THEME } from "../../config";
import TabPanelContainer from "@components/TabPanelContainer"; import TabPanelContainer from "@components/TabPanelContainer";
import CategorySection from "./CategorySection"; import CategorySection from "./CategorySection";
import { ManagementSkeleton } from "../BasicInfoTabSkeleton";
import type { import type {
ManagementPerson, ManagementPerson,
ManagementCategory, ManagementCategory,
@@ -22,6 +23,8 @@ import type {
interface ManagementPanelProps { interface ManagementPanelProps {
stockCode: string; stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
} }
/** /**
@@ -68,8 +71,8 @@ const categorizeManagement = (management: ManagementPerson[]): CategorizedManage
return categories; return categories;
}; };
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => { const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode, isActive = true }) => {
const { management, loading } = useManagementData(stockCode); const { management, loading } = useManagementData({ stockCode, enabled: isActive });
// 使用 useMemo 缓存分类计算结果 // 使用 useMemo 缓存分类计算结果
const categorizedManagement = useMemo( const categorizedManagement = useMemo(
@@ -78,7 +81,7 @@ const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
); );
return ( return (
<TabPanelContainer loading={loading} loadingMessage="加载管理团队数据..."> <TabPanelContainer loading={loading} skeleton={<ManagementSkeleton />}>
{CATEGORY_ORDER.map((category) => { {CATEGORY_ORDER.map((category) => {
const config = CATEGORY_CONFIG[category]; const config = CATEGORY_CONFIG[category];
const people = categorizedManagement[category]; const people = categorizedManagement[category];

View File

@@ -2,6 +2,7 @@
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件 // 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Card, CardBody } from "@chakra-ui/react";
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer"; import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config"; import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
@@ -65,15 +66,18 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]); const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
return ( return (
<SubTabContainer <Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
tabs={tabs} <CardBody p={0}>
componentProps={{ stockCode }} <SubTabContainer
defaultIndex={defaultTabIndex} tabs={tabs}
onTabChange={onTabChange} componentProps={{ stockCode }}
themePreset="blackGold" defaultIndex={defaultTabIndex}
compact onTabChange={onTabChange}
contentPadding={0} themePreset="blackGold"
/> size="sm"
/>
</CardBody>
</Card>
); );
}; };

View File

@@ -75,6 +75,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
onTabChange={onTabChange} onTabChange={onTabChange}
componentProps={{}} componentProps={{}}
themePreset="blackGold" themePreset="blackGold"
size="sm"
/> />
<LoadingState message="加载数据中..." height="200px" /> <LoadingState message="加载数据中..." height="200px" />
</CardBody> </CardBody>
@@ -99,6 +100,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
onToggleSegment, onToggleSegment,
}} }}
themePreset="blackGold" themePreset="blackGold"
size="sm"
/> />
</CardBody> </CardBody>
</Card> </Card>

View File

@@ -11,6 +11,15 @@ interface ApiResponse<T> {
data: T; data: T;
} }
// 支持延迟加载的配置选项
interface UseAnnouncementsDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
/** 刷新标识,变化时触发重新请求 */
refreshKey?: number;
}
interface UseAnnouncementsDataResult { interface UseAnnouncementsDataResult {
announcements: Announcement[]; announcements: Announcement[];
loading: boolean; loading: boolean;
@@ -18,16 +27,22 @@ interface UseAnnouncementsDataResult {
} }
/** /**
* 公告数据 Hook * 公告数据 Hook(支持延迟加载和刷新)
* @param stockCode - 股票代码 * @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
* @param options.refreshKey - 刷新标识,变化时触发重新请求
*/ */
export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataResult => { export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseAnnouncementsDataResult => {
const { stockCode, enabled = true, refreshKey } = options;
const [announcements, setAnnouncements] = useState<Announcement[]>([]); const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!stockCode) return; // 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) return;
const controller = new AbortController(); const controller = new AbortController();
@@ -57,7 +72,7 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
loadData(); loadData();
return () => controller.abort(); return () => controller.abort();
}, [stockCode]); }, [stockCode, enabled, refreshKey]);
return { announcements, loading, error }; return { announcements, loading, error };
}; };

View File

@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T; data: T;
} }
// 支持延迟加载的配置选项
interface UseBasicInfoOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseBasicInfoResult { interface UseBasicInfoResult {
basicInfo: BasicInfo | null; basicInfo: BasicInfo | null;
loading: boolean; loading: boolean;
@@ -18,16 +25,22 @@ interface UseBasicInfoResult {
} }
/** /**
* 公司基本信息 Hook * 公司基本信息 Hook(支持延迟加载)
* @param stockCode - 股票代码 * @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/ */
export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => { export const useBasicInfo = (options: UseBasicInfoOptions): UseBasicInfoResult => {
const { stockCode, enabled = true } = options;
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null); const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
const [loading, setLoading] = useState(false); // 智能初始化 loading当需要加载数据时初始值为 true避免首次渲染闪现空状态
const [loading, setLoading] = useState(() => enabled && !!stockCode);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!stockCode) return; // 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) return;
const controller = new AbortController(); const controller = new AbortController();
@@ -57,7 +70,7 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
loadData(); loadData();
return () => controller.abort(); return () => controller.abort();
}, [stockCode]); }, [stockCode, enabled]);
return { basicInfo, loading, error }; return { basicInfo, loading, error };
}; };

View File

@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T; data: T;
} }
// 支持延迟加载的配置选项
interface UseBranchesDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseBranchesDataResult { interface UseBranchesDataResult {
branches: Branch[]; branches: Branch[];
loading: boolean; loading: boolean;
@@ -18,16 +25,24 @@ interface UseBranchesDataResult {
} }
/** /**
* 分支机构数据 Hook * 分支机构数据 Hook(支持延迟加载)
* @param stockCode - 股票代码 * @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/ */
export const useBranchesData = (stockCode?: string): UseBranchesDataResult => { export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDataResult => {
const { stockCode, enabled = true } = options;
const [branches, setBranches] = useState<Branch[]>([]); const [branches, setBranches] = useState<Branch[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (!stockCode) return; if (!enabled || !stockCode) {
setLoading(false);
return;
}
const controller = new AbortController(); const controller = new AbortController();
@@ -46,18 +61,25 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
} else { } else {
setError("加载分支机构数据失败"); setError("加载分支机构数据失败");
} }
setLoading(false);
setHasLoaded(true);
} catch (err: any) { } catch (err: any) {
if (err.name === "CanceledError") return; // 请求被取消时,不更新任何状态
if (err.name === "CanceledError") {
return;
}
logger.error("useBranchesData", "loadData", err, { stockCode }); logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
} finally {
setLoading(false); setLoading(false);
setHasLoaded(true);
} }
}; };
loadData(); loadData();
return () => controller.abort(); return () => controller.abort();
}, [stockCode]); }, [stockCode, enabled]);
return { branches, loading, error }; const isLoading = loading || (enabled && !hasLoaded && !error);
return { branches, loading: isLoading, error };
}; };

View File

@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T; data: T;
} }
// 支持延迟加载的配置选项
interface UseDisclosureDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseDisclosureDataResult { interface UseDisclosureDataResult {
disclosureSchedule: DisclosureSchedule[]; disclosureSchedule: DisclosureSchedule[];
loading: boolean; loading: boolean;
@@ -18,16 +25,21 @@ interface UseDisclosureDataResult {
} }
/** /**
* 披露日程数据 Hook * 披露日程数据 Hook(支持延迟加载)
* @param stockCode - 股票代码 * @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/ */
export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult => { export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclosureDataResult => {
const { stockCode, enabled = true } = options;
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]); const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!stockCode) return; // 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) return;
const controller = new AbortController(); const controller = new AbortController();
@@ -57,7 +69,7 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
loadData(); loadData();
return () => controller.abort(); return () => controller.abort();
}, [stockCode]); }, [stockCode, enabled]);
return { disclosureSchedule, loading, error }; return { disclosureSchedule, loading, error };
}; };

View File

@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T; data: T;
} }
// 支持延迟加载的配置选项
interface UseManagementDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseManagementDataResult { interface UseManagementDataResult {
management: Management[]; management: Management[];
loading: boolean; loading: boolean;
@@ -18,16 +25,23 @@ interface UseManagementDataResult {
} }
/** /**
* 管理团队数据 Hook * 管理团队数据 Hook(支持延迟加载)
* @param stockCode - 股票代码 * @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/ */
export const useManagementData = (stockCode?: string): UseManagementDataResult => { export const useManagementData = (options: UseManagementDataOptions): UseManagementDataResult => {
const { stockCode, enabled = true } = options;
const [management, setManagement] = useState<Management[]>([]); const [management, setManagement] = useState<Management[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(() => enabled && !!stockCode);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 记录是否已完成首次加载,用于派生 loading 状态
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (!stockCode) return; // 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) return;
const controller = new AbortController(); const controller = new AbortController();
@@ -52,12 +66,17 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
setError("网络请求失败"); setError("网络请求失败");
} finally { } finally {
setLoading(false); setLoading(false);
setHasLoaded(true);
} }
}; };
loadData(); loadData();
return () => controller.abort(); return () => controller.abort();
}, [stockCode]); }, [stockCode, enabled]);
return { management, loading, error }; // 派生 loading 状态enabled 但尚未完成首次加载时,视为 loading
// 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁
const isLoading = loading || (enabled && !hasLoaded && !error);
return { management, loading: isLoading, error };
}; };

View File

@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T; data: T;
} }
// 支持延迟加载的配置选项
interface UseShareholderDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseShareholderDataResult { interface UseShareholderDataResult {
actualControl: ActualControl[]; actualControl: ActualControl[];
concentration: Concentration[]; concentration: Concentration[];
@@ -21,19 +28,25 @@ interface UseShareholderDataResult {
} }
/** /**
* 股权结构数据 Hook * 股权结构数据 Hook(支持延迟加载)
* @param stockCode - 股票代码 * @param options - 配置选项
* @param options.stockCode - 股票代码
* @param options.enabled - 是否启用数据加载,默认 true
*/ */
export const useShareholderData = (stockCode?: string): UseShareholderDataResult => { export const useShareholderData = (options: UseShareholderDataOptions): UseShareholderDataResult => {
const { stockCode, enabled = true } = options;
const [actualControl, setActualControl] = useState<ActualControl[]>([]); const [actualControl, setActualControl] = useState<ActualControl[]>([]);
const [concentration, setConcentration] = useState<Concentration[]>([]); const [concentration, setConcentration] = useState<Concentration[]>([]);
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]); const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]); const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
const [loading, setLoading] = useState(false); // 智能初始化 loading当需要加载数据时初始值为 true避免首次渲染闪现空状态
const [loading, setLoading] = useState(() => enabled && !!stockCode);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!stockCode) return; // 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) return;
const controller = new AbortController(); const controller = new AbortController();
@@ -69,7 +82,7 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
loadData(); loadData();
return () => controller.abort(); return () => controller.abort();
}, [stockCode]); }, [stockCode, enabled]);
return { return {
actualControl, actualControl,

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

@@ -0,0 +1,101 @@
/**
* 动态跟踪 Tab 骨架屏组件
* 用于懒加载时显示,提供即时反馈
*/
import React from 'react';
import { Box, VStack, HStack, Skeleton, SkeletonText, Card, CardBody } from '@chakra-ui/react';
/**
* 新闻动态骨架屏
*/
export const NewsPanelSkeleton: React.FC = () => (
<VStack spacing={3} align="stretch">
{[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" />
<VStack align="start" flex={1} spacing={2}>
<Skeleton height="16px" width="80%" />
<Skeleton height="12px" width="40%" />
</VStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
);
/**
* 公告列表骨架屏
*/
export const AnnouncementsSkeleton: React.FC = () => (
<VStack spacing={3} align="stretch">
{[1, 2, 3, 4].map((i) => (
<Card key={i} bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardBody py={3} px={4}>
<VStack align="start" spacing={2}>
<Skeleton height="16px" width="70%" />
<HStack spacing={4}>
<Skeleton height="12px" width="80px" />
<Skeleton height="12px" width="60px" />
</HStack>
</VStack>
</CardBody>
</Card>
))}
</VStack>
);
/**
* 财报披露日程骨架屏
*/
export const DisclosureScheduleSkeleton: React.FC = () => (
<Box>
<Skeleton height="200px" borderRadius="md" mb={4} />
<VStack spacing={2} align="stretch">
{[1, 2, 3].map((i) => (
<HStack key={i} justify="space-between" p={2}>
<Skeleton height="14px" width="100px" />
<Skeleton height="14px" width="60px" />
</HStack>
))}
</VStack>
</Box>
);
/**
* 业绩预告骨架屏
*/
export const ForecastPanelSkeleton: React.FC = () => (
<VStack spacing={4} align="stretch">
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardBody>
<SkeletonText noOfLines={4} spacing={3} />
</CardBody>
</Card>
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.2)">
<CardBody>
<Skeleton height="120px" borderRadius="md" />
</CardBody>
</Card>
</VStack>
);
/**
* 通用内容骨架屏(默认 fallback
*/
export const ContentSkeleton: React.FC = () => (
<Box p={4}>
<SkeletonText noOfLines={6} spacing={4} />
</Box>
);
export default {
NewsPanelSkeleton,
AnnouncementsSkeleton,
DisclosureScheduleSkeleton,
ForecastPanelSkeleton,
ContentSkeleton,
};

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(() => {
loadForecast(); if (isActive) {
}, [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

@@ -2,3 +2,12 @@
export { default as NewsPanel } from './NewsPanel'; export { default as NewsPanel } from './NewsPanel';
export { default as ForecastPanel } from './ForecastPanel'; export { default as ForecastPanel } from './ForecastPanel';
// 骨架屏组件
export {
NewsPanelSkeleton,
AnnouncementsSkeleton,
DisclosureScheduleSkeleton,
ForecastPanelSkeleton,
ContentSkeleton,
} from './DynamicTrackingSkeleton';

View File

@@ -1,37 +1,73 @@
// src/views/Company/components/DynamicTracking/index.js // src/views/Company/components/DynamicTracking/index.js
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab // 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab
// 优化:子组件懒加载,骨架屏即时反馈
import React, { useState, useEffect, useMemo } 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';
import AnnouncementsPanel from '../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel'; import {
import DisclosureSchedulePanel from '../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel'; NewsPanelSkeleton,
import { NewsPanel, ForecastPanel } from './components'; AnnouncementsSkeleton,
DisclosureScheduleSkeleton,
ForecastPanelSkeleton,
} from './components/DynamicTrackingSkeleton';
// 二级 Tab 配置 // 懒加载子组件
const NewsPanel = lazy(() => import('./components/NewsPanel'));
const ForecastPanel = lazy(() => import('./components/ForecastPanel'));
const AnnouncementsPanel = lazy(() =>
import('../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel')
);
const DisclosureSchedulePanel = lazy(() =>
import('../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel')
);
// 二级 Tab 配置(带骨架屏 fallback
const TRACKING_TABS = [ const TRACKING_TABS = [
{ key: 'news', name: '新闻动态', icon: FaNewspaper, component: NewsPanel }, {
{ key: 'announcements', name: '公司公告', icon: FaBullhorn, component: AnnouncementsPanel }, key: 'news',
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt, component: DisclosureSchedulePanel }, name: '新闻动态',
{ key: 'forecast', name: '业绩预告', icon: FaChartBar, component: ForecastPanel }, icon: FaNewspaper,
component: NewsPanel,
fallback: <NewsPanelSkeleton />,
},
{
key: 'announcements',
name: '公司公告',
icon: FaBullhorn,
component: AnnouncementsPanel,
fallback: <AnnouncementsSkeleton />,
},
{
key: 'disclosure',
name: '财报披露日程',
icon: FaCalendarAlt,
component: DisclosureSchedulePanel,
fallback: <DisclosureScheduleSkeleton />,
},
{
key: 'forecast',
name: '业绩预告',
icon: FaChartBar,
component: ForecastPanel,
fallback: <ForecastPanelSkeleton />,
},
]; ];
/** /**
* 动态跟踪组件 * 动态跟踪组件
* *
* 功能: * 功能:
* - 使用 SubTabContainer 实现二级导航 * - 使用 SubTabContainer 实现二级导航(同步渲染,无 loading
* - Tab1: 新闻动态 * - 子组件懒加载,减少初始包体积
* - Tab2: 公司公告 * - 每个 Tab 有专属骨架屏,提供即时视觉反馈
* - Tab3: 财报披露日程
* - Tab4: 业绩预告
* *
* @param {Object} props * @param {Object} props
* @param {string} props.stockCode - 股票代码 * @param {string} props.stockCode - 股票代码
*/ */
const DynamicTracking = ({ stockCode: propStockCode }) => { const DynamicTracking = memo(({ stockCode: propStockCode }) => {
const [stockCode, setStockCode] = useState(propStockCode || '000001'); const [stockCode, setStockCode] = useState(propStockCode || '000001');
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
@@ -50,18 +86,28 @@ const DynamicTracking = ({ stockCode: propStockCode }) => {
[stockCode] [stockCode]
); );
// Tab 切换回调
const handleTabChange = useCallback((index) => {
setActiveTab(index);
}, []);
return ( return (
<Box> <Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<SubTabContainer <CardBody p={0}>
tabs={TRACKING_TABS} <SubTabContainer
componentProps={componentProps} tabs={TRACKING_TABS}
themePreset="blackGold" componentProps={componentProps}
index={activeTab} themePreset="blackGold"
onTabChange={(index) => setActiveTab(index)} index={activeTab}
isLazy onTabChange={handleTabChange}
/> isLazy
</Box> size="sm"
/>
</CardBody>
</Card>
); );
}; });
DynamicTracking.displayName = 'DynamicTracking';
export default DynamicTracking; export default DynamicTracking;

View File

@@ -108,8 +108,9 @@ const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
); );
} }
// 限制显示列数 // 固定显示期数: 财务指标 6 期, 财务报表 8 期
const maxColumns = type === 'metrics' ? 6 : 8; const FIXED_PERIODS = { metrics: 6, statement: 8 };
const maxColumns = FIXED_PERIODS[type];
const displayData = data.slice(0, Math.min(data.length, maxColumns)); const displayData = data.slice(0, Math.min(data.length, maxColumns));
// 构建表格数据 // 构建表格数据

View File

@@ -62,6 +62,7 @@ interface UseFinancialDataReturn {
setStockCode: (code: string) => void; setStockCode: (code: string) => void;
setSelectedPeriods: (periods: number) => void; setSelectedPeriods: (periods: number) => void;
setActiveTab: (tabKey: DataTypeKey) => void; setActiveTab: (tabKey: DataTypeKey) => void;
handleTabChange: (tabKey: DataTypeKey) => void; // Tab 切换时自动检查期数
// 当前参数 // 当前参数
currentStockCode: string; currentStockCode: string;
@@ -106,6 +107,14 @@ export const useFinancialData = (
const coreDataControllerRef = useRef<AbortController | null>(null); const coreDataControllerRef = useRef<AbortController | null>(null);
const tabDataControllerRef = useRef<AbortController | null>(null); const tabDataControllerRef = useRef<AbortController | null>(null);
// 记录每种数据类型加载时使用的期数(用于 Tab 切换时判断是否需要重新加载)
const dataPeriodsRef = useRef<Record<string, number>>({
balance: 0,
income: 0,
cashflow: 0,
metrics: 0,
});
// 判断 Tab key 对应的数据类型 // 判断 Tab key 对应的数据类型
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => { const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
switch (tabKey) { switch (tabKey) {
@@ -132,22 +141,34 @@ export const useFinancialData = (
switch (dataType) { switch (dataType) {
case 'balance': { case 'balance': {
const res = await financialService.getBalanceSheet(stockCode, periods, options); const res = await financialService.getBalanceSheet(stockCode, periods, options);
if (res.success) setBalanceSheet(res.data); if (res.success) {
setBalanceSheet(res.data);
dataPeriodsRef.current.balance = periods;
}
break; break;
} }
case 'income': { case 'income': {
const res = await financialService.getIncomeStatement(stockCode, periods, options); const res = await financialService.getIncomeStatement(stockCode, periods, options);
if (res.success) setIncomeStatement(res.data); if (res.success) {
setIncomeStatement(res.data);
dataPeriodsRef.current.income = periods;
}
break; break;
} }
case 'cashflow': { case 'cashflow': {
const res = await financialService.getCashflow(stockCode, periods, options); const res = await financialService.getCashflow(stockCode, periods, options);
if (res.success) setCashflow(res.data); if (res.success) {
setCashflow(res.data);
dataPeriodsRef.current.cashflow = periods;
}
break; break;
} }
case 'metrics': { case 'metrics': {
const res = await financialService.getFinancialMetrics(stockCode, periods, options); const res = await financialService.getFinancialMetrics(stockCode, periods, options);
if (res.success) setFinancialMetrics(res.data); if (res.success) {
setFinancialMetrics(res.data);
dataPeriodsRef.current.metrics = periods;
}
break; break;
} }
} }
@@ -197,6 +218,17 @@ export const useFinancialData = (
setSelectedPeriodsState(periods); setSelectedPeriodsState(periods);
}, []); }, []);
// Tab 切换处理:检查期数是否需要重新加载
const handleTabChange = useCallback((tabKey: DataTypeKey) => {
setActiveTab(tabKey);
const dataType = getDataTypeForTab(tabKey);
// 如果该 Tab 数据的期数与当前选择的期数不一致,重新加载
// 注refetchByTab 使用传入的 tabKey不依赖 activeTab 状态,无需延迟
if (dataPeriodsRef.current[dataType] !== selectedPeriods) {
refetchByTab(tabKey);
}
}, [selectedPeriods, refetchByTab]);
// 加载核心财务数据初始加载stockInfo + metrics + comparison // 加载核心财务数据初始加载stockInfo + metrics + comparison
const loadCoreFinancialData = useCallback(async () => { const loadCoreFinancialData = useCallback(async () => {
if (!stockCode || stockCode.length !== 6) { if (!stockCode || stockCode.length !== 6) {
@@ -235,7 +267,10 @@ export const useFinancialData = (
// 设置数据 // 设置数据
if (stockInfoRes.success) setStockInfo(stockInfoRes.data); if (stockInfoRes.success) setStockInfo(stockInfoRes.data);
if (metricsRes.success) setFinancialMetrics(metricsRes.data); if (metricsRes.success) {
setFinancialMetrics(metricsRes.data);
dataPeriodsRef.current.metrics = selectedPeriods;
}
if (comparisonRes.success) setComparison(comparisonRes.data); if (comparisonRes.success) setComparison(comparisonRes.data);
if (businessRes.success) setMainBusiness(businessRes.data); if (businessRes.success) setMainBusiness(businessRes.data);
@@ -269,6 +304,23 @@ export const useFinancialData = (
// 初始加载(仅股票代码变化时全量加载) // 初始加载(仅股票代码变化时全量加载)
useEffect(() => { useEffect(() => {
if (stockCode) { if (stockCode) {
// 立即清空所有旧数据,触发骨架屏
setStockInfo(null);
setBalanceSheet([]);
setIncomeStatement([]);
setCashflow([]);
setFinancialMetrics([]);
setMainBusiness(null);
setComparison([]);
// 重置期数记录
dataPeriodsRef.current = {
balance: 0,
income: 0,
cashflow: 0,
metrics: 0,
};
loadAllFinancialData(); loadAllFinancialData();
isInitialLoad.current = false; isInitialLoad.current = false;
} }
@@ -311,6 +363,7 @@ export const useFinancialData = (
setStockCode, setStockCode,
setSelectedPeriods, setSelectedPeriods,
setActiveTab, setActiveTab,
handleTabChange,
// 当前参数 // 当前参数
currentStockCode: stockCode, currentStockCode: stockCode,

View File

@@ -90,15 +90,15 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
refetchByTab, refetchByTab,
selectedPeriods, selectedPeriods,
setSelectedPeriods, setSelectedPeriods,
setActiveTab, handleTabChange: handleTabChangeWithPeriodCheck,
activeTab, activeTab,
} = useFinancialData({ stockCode: propStockCode }); } = useFinancialData({ stockCode: propStockCode });
// 处理 Tab 切换 // 处理 Tab 切换(使用 hook 提供的带期数检查的函数)
const handleTabChange = useCallback((index: number, tabKey: string) => { const handleTabChange = useCallback((index: number, tabKey: string) => {
const dataTypeKey = TAB_KEY_MAP[index] || (tabKey as DataTypeKey); const dataTypeKey = TAB_KEY_MAP[index] || (tabKey as DataTypeKey);
setActiveTab(dataTypeKey); handleTabChangeWithPeriodCheck(dataTypeKey);
}, [setActiveTab]); }, [handleTabChangeWithPeriodCheck]);
// 处理刷新 - 只刷新当前 Tab // 处理刷新 - 只刷新当前 Tab
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
@@ -213,6 +213,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
componentProps={componentProps} componentProps={componentProps}
themePreset="blackGold" themePreset="blackGold"
isLazy isLazy
size="sm"
onTabChange={handleTabChange} onTabChange={handleTabChange}
rightElement={ rightElement={
<PeriodSelector <PeriodSelector

View File

@@ -216,26 +216,26 @@ const BALANCE_SHEET_SECTIONS = [
]; ];
/** 资产负债表 Tab */ /** 资产负债表 Tab */
export const BalanceSheetTab = memo<BalanceSheetTabProps>(({ balanceSheet, loading, showMetricChart }) => ( export const BalanceSheetTab = memo<BalanceSheetTabProps>(({ balanceSheet, loading, loadingTab, showMetricChart }) => (
<UnifiedFinancialTable <UnifiedFinancialTable
type="statement" type="statement"
data={balanceSheet as unknown as FinancialDataItem[]} data={balanceSheet as unknown as FinancialDataItem[]}
sections={BALANCE_SHEET_SECTIONS} sections={BALANCE_SHEET_SECTIONS}
showMetricChart={showMetricChart} showMetricChart={showMetricChart}
loading={loading} loading={loading || loadingTab === 'balance'}
/> />
)); ));
BalanceSheetTab.displayName = 'BalanceSheetTab'; BalanceSheetTab.displayName = 'BalanceSheetTab';
/** 利润表 Tab */ /** 利润表 Tab */
export const IncomeStatementTab = memo<IncomeStatementTabProps>(({ incomeStatement, loading, showMetricChart }) => ( export const IncomeStatementTab = memo<IncomeStatementTabProps>(({ incomeStatement, loading, loadingTab, showMetricChart }) => (
<UnifiedFinancialTable <UnifiedFinancialTable
type="statement" type="statement"
data={incomeStatement as unknown as FinancialDataItem[]} data={incomeStatement as unknown as FinancialDataItem[]}
sections={INCOME_STATEMENT_SECTIONS} sections={INCOME_STATEMENT_SECTIONS}
hideTotalSectionTitle={false} hideTotalSectionTitle={false}
showMetricChart={showMetricChart} showMetricChart={showMetricChart}
loading={loading} loading={loading || loadingTab === 'income'}
/> />
)); ));
IncomeStatementTab.displayName = 'IncomeStatementTab'; IncomeStatementTab.displayName = 'IncomeStatementTab';
@@ -251,14 +251,14 @@ const CASHFLOW_SECTIONS = [{
}]; }];
/** 现金流量表 Tab */ /** 现金流量表 Tab */
export const CashflowTab = memo<CashflowTabProps>(({ cashflow, loading, showMetricChart }) => ( export const CashflowTab = memo<CashflowTabProps>(({ cashflow, loading, loadingTab, showMetricChart }) => (
<UnifiedFinancialTable <UnifiedFinancialTable
type="statement" type="statement"
data={cashflow as unknown as FinancialDataItem[]} data={cashflow as unknown as FinancialDataItem[]}
sections={CASHFLOW_SECTIONS} sections={CASHFLOW_SECTIONS}
hideTotalSectionTitle hideTotalSectionTitle
showMetricChart={showMetricChart} showMetricChart={showMetricChart}
loading={loading} loading={loading || loadingTab === 'cashflow'}
/> />
)); ));
CashflowTab.displayName = 'CashflowTab'; CashflowTab.displayName = 'CashflowTab';

View File

@@ -20,8 +20,10 @@ export const BLACK_GOLD_TABLE_THEME: ThemeConfig = {
headerColor: '#D4AF37', headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)', rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)', borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8, cellPaddingBlock: 6,
cellPaddingInline: 12, cellPaddingInline: 8,
// 表头紧凑样式
headerSplitColor: 'transparent',
}, },
}, },
}; };
@@ -40,6 +42,15 @@ export const getTableStyles = (className: string): string => `
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important; border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
padding: 4px 8px !important;
margin: 0 !important;
}
.${className} .ant-table-thead > tr {
margin: 0 !important;
}
.${className} .ant-table-header {
margin: 0 !important;
padding: 0 !important;
} }
.${className} .ant-table-tbody > tr > td { .${className} .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important; border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;

View File

@@ -0,0 +1,140 @@
/**
* 股票行情骨架屏组件
*/
import React, { memo } from 'react';
import {
Box,
Container,
VStack,
HStack,
Skeleton,
SkeletonText,
SimpleGrid,
Card,
CardBody,
} from '@chakra-ui/react';
// 黑金主题配色
const SKELETON_COLORS = {
startColor: 'rgba(26, 32, 44, 0.6)',
endColor: 'rgba(212, 175, 55, 0.2)',
};
/**
* 股票摘要卡片骨架屏
*/
const SummaryCardSkeleton: React.FC = memo(() => (
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody>
<HStack spacing={8} align="flex-start">
{/* 左侧:股票名称和价格 */}
<VStack align="flex-start" spacing={2} minW="200px">
<Skeleton height="28px" width="150px" {...SKELETON_COLORS} />
<Skeleton height="40px" width="120px" {...SKELETON_COLORS} />
<HStack spacing={4}>
<Skeleton height="20px" width="80px" {...SKELETON_COLORS} />
<Skeleton height="20px" width="60px" {...SKELETON_COLORS} />
</HStack>
</VStack>
{/* 右侧:指标网格 */}
<SimpleGrid columns={4} spacing={4} flex={1}>
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<VStack key={i} align="flex-start" spacing={1}>
<Skeleton height="14px" width="50px" {...SKELETON_COLORS} />
<Skeleton height="20px" width="70px" {...SKELETON_COLORS} />
</VStack>
))}
</SimpleGrid>
</HStack>
</CardBody>
</Card>
));
SummaryCardSkeleton.displayName = 'SummaryCardSkeleton';
/**
* K线图表骨架屏
*/
const ChartSkeleton: React.FC = memo(() => (
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody>
{/* 工具栏 */}
<HStack justify="space-between" mb={4}>
<HStack spacing={2}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height="32px" width="60px" borderRadius="md" {...SKELETON_COLORS} />
))}
</HStack>
<HStack spacing={2}>
<Skeleton height="32px" width="100px" borderRadius="md" {...SKELETON_COLORS} />
<Skeleton height="32px" width="32px" borderRadius="md" {...SKELETON_COLORS} />
</HStack>
</HStack>
{/* 图表区域 */}
<Skeleton height="400px" borderRadius="md" {...SKELETON_COLORS} />
</CardBody>
</Card>
));
ChartSkeleton.displayName = 'ChartSkeleton';
/**
* Tab 区域骨架屏
*/
const TabSkeleton: React.FC = memo(() => (
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={0}>
{/* Tab 栏 */}
<HStack
spacing={2}
p={3}
borderBottom="1px solid"
borderColor="rgba(212, 175, 55, 0.2)"
>
{[1, 2, 3, 4].map((i) => (
<Skeleton
key={i}
height="36px"
width="100px"
borderRadius="md"
{...SKELETON_COLORS}
/>
))}
</HStack>
{/* Tab 内容 */}
<Box p={4}>
<SkeletonText
noOfLines={6}
spacing={4}
skeletonHeight={4}
{...SKELETON_COLORS}
/>
</Box>
</CardBody>
</Card>
));
TabSkeleton.displayName = 'TabSkeleton';
/**
* 股票行情完整骨架屏
*/
const MarketDataSkeleton: React.FC = memo(() => (
<Box bg="#1A202C" minH="100vh">
<Container maxW="container.xl" py={6}>
<VStack align="stretch" spacing={6}>
<SummaryCardSkeleton />
<ChartSkeleton />
<TabSkeleton />
</VStack>
</Container>
</Box>
));
MarketDataSkeleton.displayName = 'MarketDataSkeleton';
export { MarketDataSkeleton };
export default MarketDataSkeleton;

View File

@@ -5,3 +5,4 @@ export { default as ThemedCard } from './ThemedCard';
export { default as MarkdownRenderer } from './MarkdownRenderer'; export { default as MarkdownRenderer } from './MarkdownRenderer';
export { default as StockSummaryCard } from './StockSummaryCard'; export { default as StockSummaryCard } from './StockSummaryCard';
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal'; export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
export { MarketDataSkeleton } from './MarketDataSkeleton';

View File

@@ -2,7 +2,7 @@
// K线模块 - 日K线/分时图切换展示(黑金主题 + 专业技术指标 + 商品数据叠加 + 分时盘口) // K线模块 - 日K线/分时图切换展示(黑金主题 + 专业技术指标 + 商品数据叠加 + 分时盘口)
import React, { useState, useMemo, useCallback, memo } from 'react'; import React, { useState, useMemo, useCallback, memo } from 'react';
import { Box } from '@chakra-ui/react'; import { Box, HStack, Skeleton, Card, CardBody } from '@chakra-ui/react';
import { darkGoldTheme } from '../../../constants'; import { darkGoldTheme } from '../../../constants';
import type { IndicatorType, MainIndicatorType, DrawingType } from '../../../utils/chartOptions'; import type { IndicatorType, MainIndicatorType, DrawingType } from '../../../utils/chartOptions';
@@ -30,6 +30,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
selectedPeriod, selectedPeriod,
onPeriodChange, onPeriodChange,
stockCode, stockCode,
loading = false,
}) => { }) => {
// ========== 状态管理 ========== // ========== 状态管理 ==========
const [mode, setMode] = useState<ChartMode>('daily'); const [mode, setMode] = useState<ChartMode>('daily');
@@ -127,7 +128,19 @@ const KLineModule: React.FC<KLineModuleProps> = ({
{/* 图表内容区域 */} {/* 图表内容区域 */}
<Box pt={4}> <Box pt={4}>
{mode === 'daily' ? ( {/* 加载中骨架屏 */}
{loading && tradeData.length === 0 ? (
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={4}>
<Skeleton
height="600px"
borderRadius="md"
startColor="rgba(26, 32, 44, 0.6)"
endColor="rgba(212, 175, 55, 0.2)"
/>
</CardBody>
</Card>
) : mode === 'daily' ? (
// 日K线图 // 日K线图
<DailyKLineChart <DailyKLineChart
tradeData={tradeData} tradeData={tradeData}

View File

@@ -76,15 +76,21 @@ export const useMarketData = (
const riseAnalysisRes = await marketService.getRiseAnalysis(stockCode); const riseAnalysisRes = await marketService.getRiseAnalysis(stockCode);
if (riseAnalysisRes.success && riseAnalysisRes.data) { if (riseAnalysisRes.success && riseAnalysisRes.data) {
// 性能优化:使用 Map 预构建日期索引,将 O(n*m) 降为 O(n+m)
const dateToIndexMap = new Map<string, number>();
tradeDataForMapping.forEach((item, idx) => {
dateToIndexMap.set(item.date.substring(0, 10), idx);
});
// 使用 Map 查找O(1) 复杂度
const tempAnalysisMap: Record<number, RiseAnalysis> = {}; const tempAnalysisMap: Record<number, RiseAnalysis> = {};
riseAnalysisRes.data.forEach((analysis) => { riseAnalysisRes.data.forEach((analysis) => {
const dateIndex = tradeDataForMapping.findIndex( const dateIndex = dateToIndexMap.get(analysis.trade_date);
(item) => item.date.substring(0, 10) === analysis.trade_date if (dateIndex !== undefined) {
);
if (dateIndex !== -1) {
tempAnalysisMap[dateIndex] = analysis; tempAnalysisMap[dateIndex] = analysis;
} }
}); });
setAnalysisMap(tempAnalysisMap); setAnalysisMap(tempAnalysisMap);
logger.info('useMarketData', '涨幅分析加载成功', { stockCode, count: Object.keys(tempAnalysisMap).length }); logger.info('useMarketData', '涨幅分析加载成功', { stockCode, count: Object.keys(tempAnalysisMap).length });
} }
@@ -164,7 +170,7 @@ export const useMarketData = (
*/ */
const loadDataByType = useCallback(async (dataType: 'funding' | 'bigDeal' | 'unusual' | 'pledge') => { const loadDataByType = useCallback(async (dataType: 'funding' | 'bigDeal' | 'unusual' | 'pledge') => {
if (!stockCode) return; if (!stockCode) return;
if (loadedDataRef.current[dataType]) return; // 已加载则跳过 if (loadedDataRef.current[dataType]) return;
// 取消之前的 Tab 数据请求 // 取消之前的 Tab 数据请求
tabDataControllerRef.current?.abort(); tabDataControllerRef.current?.abort();
@@ -345,11 +351,15 @@ export const useMarketData = (
}, [period, refreshTradeData, stockCode]); }, [period, refreshTradeData, stockCode]);
// 组件卸载时取消所有进行中的请求 // 组件卸载时取消所有进行中的请求
// 注意:在 React StrictMode 下,组件会快速卸载再挂载
// 为避免取消正在进行的请求,这里不再自动取消
// 请求会在 loadCoreData 开头通过 coreDataControllerRef.current?.abort() 取消旧请求
useEffect(() => { useEffect(() => {
return () => { return () => {
coreDataControllerRef.current?.abort(); // 不再在这里取消请求,让请求自然完成
tabDataControllerRef.current?.abort(); // coreDataControllerRef.current?.abort();
minuteDataControllerRef.current?.abort(); // tabDataControllerRef.current?.abort();
// minuteDataControllerRef.current?.abort();
}; };
}, []); }, []);

View File

@@ -4,6 +4,8 @@
import React, { useState, useEffect, ReactNode, useMemo, useCallback, memo } from 'react'; import React, { useState, useEffect, ReactNode, useMemo, useCallback, memo } from 'react';
import { import {
Box, Box,
Card,
CardBody,
Container, Container,
VStack, VStack,
useDisclosure, useDisclosure,
@@ -30,7 +32,6 @@ import {
UnusualPanel, UnusualPanel,
PledgePanel, PledgePanel,
} from './components/panels'; } from './components/panels';
import LoadingState from '../LoadingState';
import type { MarketDataViewProps } from './types'; import type { MarketDataViewProps } from './types';
/** /**
@@ -61,7 +62,6 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
minuteData, minuteData,
minuteLoading, minuteLoading,
analysisMap, analysisMap,
refetch,
loadMinuteData, loadMinuteData,
loadDataByType, loadDataByType,
} = useMarketData(stockCode, selectedPeriod); } = useMarketData(stockCode, selectedPeriod);
@@ -89,6 +89,14 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
} }
}, [propStockCode, stockCode]); }, [propStockCode, stockCode]);
// 首次渲染时加载默认 Tab融资融券的数据
useEffect(() => {
// 默认 Tab 是融资融券index 0
if (activeTab === 0) {
loadDataByType('funding');
}
}, [loadDataByType, activeTab]);
// 处理图表点击事件 // 处理图表点击事件
const handleChartClick = useCallback( const handleChartClick = useCallback(
(params: { seriesName?: string; data?: [number, number] }) => { (params: { seriesName?: string; data?: [number, number] }) => {
@@ -127,47 +135,40 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
return ( return (
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}> <Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
<Container maxW="container.xl" py={6}> <Container maxW="container.xl" py={4}>
<VStack align="stretch" spacing={6}> <VStack align="stretch" spacing={4}>
{/* 股票概览 */} {/* 股票概览 */}
{summary && <StockSummaryCard summary={summary} theme={theme} />} {summary && <StockSummaryCard summary={summary} theme={theme} />}
{/* 交易数据 - 日K/分钟K线独立显示在 Tab 上方) */} {/* 交易数据 - 日K/分钟K线独立显示在 Tab 上方) */}
{!loading && ( <TradeDataPanel
<TradeDataPanel theme={theme}
theme={theme} tradeData={tradeData}
tradeData={tradeData} minuteData={minuteData}
minuteData={minuteData} minuteLoading={minuteLoading}
minuteLoading={minuteLoading} analysisMap={analysisMap}
analysisMap={analysisMap} onLoadMinuteData={loadMinuteData}
onLoadMinuteData={loadMinuteData} onChartClick={handleChartClick}
onChartClick={handleChartClick} selectedPeriod={selectedPeriod}
selectedPeriod={selectedPeriod} onPeriodChange={setSelectedPeriod}
onPeriodChange={setSelectedPeriod} stockCode={stockCode}
stockCode={stockCode} loading={loading}
/> />
)}
{/* 主要内容区域 - Tab */} {/* 主要内容区域 - Tab */}
{loading ? ( <Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<Box <CardBody p={0}>
bg="gray.900" <SubTabContainer
border="1px solid" tabs={tabConfigs}
borderColor="rgba(212, 175, 55, 0.3)" componentProps={componentProps}
borderRadius="xl" themePreset="blackGold"
> index={activeTab}
<LoadingState message="数据加载中..." height="400px" /> onTabChange={handleTabChange}
</Box> isLazy
) : ( size="sm"
<SubTabContainer />
tabs={tabConfigs} </CardBody>
componentProps={componentProps} </Card>
themePreset="blackGold"
index={activeTab}
onTabChange={handleTabChange}
isLazy
/>
)}
</VStack> </VStack>
</Container> </Container>

View File

@@ -300,6 +300,7 @@ export interface KLineModuleProps {
selectedPeriod?: number; selectedPeriod?: number;
onPeriodChange?: (period: number) => void; onPeriodChange?: (period: number) => void;
stockCode?: string; // 股票代码,用于获取实时盘口数据 stockCode?: string; // 股票代码,用于获取实时盘口数据
loading?: boolean; // 日K线数据加载中
} }
/** /**

View File

@@ -889,26 +889,60 @@ export const getKLineDarkGoldOption = (
const highPrices = tradeData.map((item) => item.high); const highPrices = tradeData.map((item) => item.high);
const lowPrices = tradeData.map((item) => item.low); const lowPrices = tradeData.map((item) => item.low);
// 计算主图指标 // 计算主图指标 - MA 始终计算(主图必需)
const ma5 = calculateMA(closePrices, 5); const ma5 = calculateMA(closePrices, 5);
const ma10 = calculateMA(closePrices, 10); const ma10 = calculateMA(closePrices, 10);
const ma20 = calculateMA(closePrices, 20); const ma20 = calculateMA(closePrices, 20);
// 计算布林带(如果选择 // 计算布林带(仅当选择 BOLL 时
const boll = mainIndicator === 'BOLL' ? calculateBOLL(closePrices) : null; const boll = mainIndicator === 'BOLL' ? calculateBOLL(closePrices) : null;
// 计算副图指标 // 副图指标 - 按需计算(性能优化:只计算当前显示的指标)
const macdData = calculateMACD(closePrices); type MACDData = { dif: (number | null)[]; dea: (number | null)[]; macd: (number | null)[] };
const kdjData = calculateKDJ(highPrices, lowPrices, closePrices); type KDJData = { k: (number | null)[]; d: (number | null)[]; j: (number | null)[] };
const rsi6 = calculateRSI(closePrices, 6); type RSIData = { rsi6: (number | null)[]; rsi12: (number | null)[]; rsi24: (number | null)[] };
const rsi12 = calculateRSI(closePrices, 12); type WRData = { wr6: (number | null)[]; wr14: (number | null)[] };
const rsi24 = calculateRSI(closePrices, 24); type BIASData = { bias6: (number | null)[]; bias12: (number | null)[]; bias24: (number | null)[] };
const wr14 = calculateWR(highPrices, lowPrices, closePrices, 14);
const wr6 = calculateWR(highPrices, lowPrices, closePrices, 6); let macdData: MACDData | null = null;
const cci14 = calculateCCI(highPrices, lowPrices, closePrices, 14); let kdjData: KDJData | null = null;
const bias6 = calculateBIAS(closePrices, 6); let rsiData: RSIData | null = null;
const bias12 = calculateBIAS(closePrices, 12); let wrData: WRData | null = null;
const bias24 = calculateBIAS(closePrices, 24); let cci14: (number | null)[] | null = null;
let biasData: BIASData | null = null;
// 根据 subIndicator 按需计算对应指标
switch (subIndicator) {
case 'MACD':
macdData = calculateMACD(closePrices);
break;
case 'KDJ':
kdjData = calculateKDJ(highPrices, lowPrices, closePrices);
break;
case 'RSI':
rsiData = {
rsi6: calculateRSI(closePrices, 6),
rsi12: calculateRSI(closePrices, 12),
rsi24: calculateRSI(closePrices, 24),
};
break;
case 'WR':
wrData = {
wr6: calculateWR(highPrices, lowPrices, closePrices, 6),
wr14: calculateWR(highPrices, lowPrices, closePrices, 14),
};
break;
case 'CCI':
cci14 = calculateCCI(highPrices, lowPrices, closePrices, 14);
break;
case 'BIAS':
biasData = {
bias6: calculateBIAS(closePrices, 6),
bias12: calculateBIAS(closePrices, 12),
bias24: calculateBIAS(closePrices, 24),
};
break;
}
// 创建涨幅分析标记点(仅当 showAnalysis 为 true 时) // 创建涨幅分析标记点(仅当 showAnalysis 为 true 时)
const scatterData: [number, number][] = []; const scatterData: [number, number][] = [];
@@ -1307,8 +1341,8 @@ export const getKLineDarkGoldOption = (
}, },
}); });
// 添加副图指标 // 添加副图指标(使用按需计算的数据)
if (subIndicator === 'MACD') { if (subIndicator === 'MACD' && macdData) {
// MACD柱状图 // MACD柱状图
series.push({ series.push({
name: 'MACD', name: 'MACD',
@@ -1347,7 +1381,7 @@ export const getKLineDarkGoldOption = (
lineStyle: { color: cyan, width: 1 }, lineStyle: { color: cyan, width: 1 },
}); });
legendData.push('MACD', 'DIF', 'DEA'); legendData.push('MACD', 'DIF', 'DEA');
} else if (subIndicator === 'KDJ') { } else if (subIndicator === 'KDJ' && kdjData) {
series.push( series.push(
{ {
name: 'K', name: 'K',
@@ -1381,14 +1415,14 @@ export const getKLineDarkGoldOption = (
} }
); );
legendData.push('K', 'D', 'J'); legendData.push('K', 'D', 'J');
} else if (subIndicator === 'RSI') { } else if (subIndicator === 'RSI' && rsiData) {
series.push( series.push(
{ {
name: 'RSI6', name: 'RSI6',
type: 'line', type: 'line',
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 2, yAxisIndex: 2,
data: rsi6, data: rsiData.rsi6,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: gold, width: 1 }, lineStyle: { color: gold, width: 1 },
@@ -1398,7 +1432,7 @@ export const getKLineDarkGoldOption = (
type: 'line', type: 'line',
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 2, yAxisIndex: 2,
data: rsi12, data: rsiData.rsi12,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: cyan, width: 1 }, lineStyle: { color: cyan, width: 1 },
@@ -1408,21 +1442,21 @@ export const getKLineDarkGoldOption = (
type: 'line', type: 'line',
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 2, yAxisIndex: 2,
data: rsi24, data: rsiData.rsi24,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: purple, width: 1 }, lineStyle: { color: purple, width: 1 },
} }
); );
legendData.push('RSI6', 'RSI12', 'RSI24'); legendData.push('RSI6', 'RSI12', 'RSI24');
} else if (subIndicator === 'WR') { } else if (subIndicator === 'WR' && wrData) {
series.push( series.push(
{ {
name: 'WR14', name: 'WR14',
type: 'line', type: 'line',
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 2, yAxisIndex: 2,
data: wr14, data: wrData.wr14,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: gold, width: 1 }, lineStyle: { color: gold, width: 1 },
@@ -1432,14 +1466,14 @@ export const getKLineDarkGoldOption = (
type: 'line', type: 'line',
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 2, yAxisIndex: 2,
data: wr6, data: wrData.wr6,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: cyan, width: 1 }, lineStyle: { color: cyan, width: 1 },
} }
); );
legendData.push('WR14', 'WR6'); legendData.push('WR14', 'WR6');
} else if (subIndicator === 'CCI') { } else if (subIndicator === 'CCI' && cci14) {
series.push({ series.push({
name: 'CCI', name: 'CCI',
type: 'line', type: 'line',
@@ -1474,14 +1508,14 @@ export const getKLineDarkGoldOption = (
} }
); );
legendData.push('CCI'); legendData.push('CCI');
} else if (subIndicator === 'BIAS') { } else if (subIndicator === 'BIAS' && biasData) {
series.push( series.push(
{ {
name: 'BIAS6', name: 'BIAS6',
type: 'line', type: 'line',
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 2, yAxisIndex: 2,
data: bias6, data: biasData.bias6,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: gold, width: 1 }, lineStyle: { color: gold, width: 1 },
@@ -1491,7 +1525,7 @@ export const getKLineDarkGoldOption = (
type: 'line', type: 'line',
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 2, yAxisIndex: 2,
data: bias12, data: biasData.bias12,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: cyan, width: 1 }, lineStyle: { color: cyan, width: 1 },
@@ -1501,7 +1535,7 @@ export const getKLineDarkGoldOption = (
type: 'line', type: 'line',
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 2, yAxisIndex: 2,
data: bias24, data: biasData.bias24,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: purple, width: 1 }, lineStyle: { color: purple, width: 1 },

View File

@@ -11,6 +11,9 @@ import type { CompanyTheme, TabConfig } from './types';
// 骨架屏组件(同步导入,用于 Suspense fallback // 骨架屏组件(同步导入,用于 Suspense fallback
import { FinancialPanoramaSkeleton } from './components/FinancialPanorama/components'; import { FinancialPanoramaSkeleton } from './components/FinancialPanorama/components';
import { ForecastSkeleton } from './components/ForecastReport/components'; import { ForecastSkeleton } from './components/ForecastReport/components';
import { MarketDataSkeleton } from './components/MarketDataView/components';
import DynamicTrackingNavSkeleton from './components/DynamicTracking/components/DynamicTrackingNavSkeleton';
import CompanyOverviewNavSkeleton from './components/CompanyOverview/BasicInfoTab/components/CompanyOverviewNavSkeleton';
// ============================================ // ============================================
// 黑金主题配置 // 黑金主题配置
@@ -75,6 +78,7 @@ export const TAB_CONFIG: TabConfig[] = [
name: '公司概览', name: '公司概览',
icon: Building2, icon: Building2,
component: CompanyOverview, component: CompanyOverview,
fallback: React.createElement(CompanyOverviewNavSkeleton),
}, },
{ {
key: 'analysis', key: 'analysis',
@@ -87,6 +91,7 @@ export const TAB_CONFIG: TabConfig[] = [
name: '股票行情', name: '股票行情',
icon: TrendingUp, icon: TrendingUp,
component: MarketDataView, component: MarketDataView,
fallback: React.createElement(MarketDataSkeleton),
}, },
{ {
key: 'financial', key: 'financial',
@@ -107,6 +112,7 @@ export const TAB_CONFIG: TabConfig[] = [
name: '动态跟踪', name: '动态跟踪',
icon: Newspaper, icon: Newspaper,
component: DynamicTracking, component: DynamicTracking,
fallback: React.createElement(DynamicTrackingNavSkeleton),
}, },
]; ];