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

View File

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

View File

@@ -3,7 +3,21 @@
// 生成财务数据
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 {
stockCode,
@@ -44,8 +58,8 @@ export const generateFinancialData = (stockCode) => {
}
},
// 资产负债表 - 嵌套结构
balanceSheet: periods.map((period, i) => ({
// 资产负债表 - 嵌套结构8期数据
balanceSheet: statementPeriods.map((period, i) => ({
period,
assets: {
current_assets: {
@@ -110,8 +124,8 @@ export const generateFinancialData = (stockCode) => {
}
})),
// 利润表 - 嵌套结构
incomeStatement: periods.map((period, i) => ({
// 利润表 - 嵌套结构8期数据
incomeStatement: statementPeriods.map((period, i) => ({
period,
revenue: {
total_operating_revenue: 162350 - i * 4000,
@@ -166,8 +180,8 @@ export const generateFinancialData = (stockCode) => {
}
})),
// 现金流量表 - 嵌套结构
cashflow: periods.map((period, i) => ({
// 现金流量表 - 嵌套结构8期数据
cashflow: statementPeriods.map((period, i) => ({
period,
operating_activities: {
inflow: {
@@ -193,8 +207,8 @@ export const generateFinancialData = (stockCode) => {
}
})),
// 财务指标 - 嵌套结构
financialMetrics: periods.map((period, i) => ({
// 财务指标 - 嵌套结构12期数据
financialMetrics: metricsPeriods.map((period, i) => ({
period,
profitability: {
roe: 16.23 - i * 0.3,

View File

@@ -1,9 +1,22 @@
// src/mocks/data/market.js
// 市场行情相关的 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) => {
const basePrice = 13.50; // 基准价格平安银行约13.5元)
const stockInfo = STOCK_NAME_MAP[stockCode] || { name: `股票${stockCode}`, basePrice: 20.00 };
const basePrice = stockInfo.basePrice;
return {
stockCode,
@@ -102,7 +115,7 @@ export const generateMarketData = (stockCode) => {
success: true,
data: {
stock_code: stockCode,
stock_name: stockCode === '000001' ? '平安银行' : '示例股票',
stock_name: stockInfo.name,
latest_trade: {
close: basePrice,
change_percent: 1.89,
@@ -189,7 +202,7 @@ export const generateMarketData = (stockCode) => {
return minuteData;
})(),
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例股票',
name: stockInfo.name,
trade_date: new Date().toISOString().split('T')[0],
type: '1min'
}

View File

@@ -31,10 +31,14 @@ import LoadingState from "./LoadingState";
interface AnnouncementsPanelProps {
stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
/** 激活次数,变化时触发重新请求 */
activationKey?: number;
}
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) => {
const { announcements, loading } = useAnnouncementsData(stockCode);
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode, isActive = true, activationKey }) => {
const { announcements, loading } = useAnnouncementsData({ stockCode, enabled: isActive, refreshKey: activationKey });
const { isOpen, onOpen, onClose } = useDisclosure();
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 { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
import { BranchesSkeleton } from "./BasicInfoTabSkeleton";
interface BranchesPanelProps {
stockCode: string;
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
isActive?: boolean;
}
// 黑金卡片样式
@@ -65,11 +67,11 @@ const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label,
</VStack>
);
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode }) => {
const { branches, loading } = useBranchesData(stockCode);
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode, isActive = true }) => {
const { branches, loading } = useBranchesData({ stockCode, enabled: isActive });
if (loading) {
return <LoadingState message="加载分支机构数据..." />;
return <BranchesSkeleton />;
}
if (branches.length === 0) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,13 @@ interface ApiResponse<T> {
data: T;
}
// 支持延迟加载的配置选项
interface UseManagementDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseManagementDataResult {
management: Management[];
loading: boolean;
@@ -18,16 +25,23 @@ interface UseManagementDataResult {
}
/**
* 管理团队数据 Hook
* @param stockCode - 股票代码
* 管理团队数据 Hook(支持延迟加载)
* @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 [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(() => enabled && !!stockCode);
const [error, setError] = useState<string | null>(null);
// 记录是否已完成首次加载,用于派生 loading 状态
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
if (!stockCode) return;
// 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) return;
const controller = new AbortController();
@@ -52,12 +66,17 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
setError("网络请求失败");
} finally {
setLoading(false);
setHasLoaded(true);
}
};
loadData();
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;
}
// 支持延迟加载的配置选项
interface UseShareholderDataOptions {
stockCode?: string;
/** 是否启用数据加载,默认 true */
enabled?: boolean;
}
interface UseShareholderDataResult {
actualControl: ActualControl[];
concentration: Concentration[];
@@ -21,19 +28,25 @@ interface UseShareholderDataResult {
}
/**
* 股权结构数据 Hook
* @param stockCode - 股票代码
* 股权结构数据 Hook(支持延迟加载)
* @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 [concentration, setConcentration] = useState<Concentration[]>([]);
const [topShareholders, setTopShareholders] = 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);
useEffect(() => {
if (!stockCode) return;
// 只有 enabled 且有 stockCode 时才请求
if (!enabled || !stockCode) return;
const controller = new AbortController();
@@ -69,7 +82,7 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
loadData();
return () => controller.abort();
}, [stockCode]);
}, [stockCode, enabled]);
return {
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,
Flex,
Text,
Spinner,
Center,
} from '@chakra-ui/react';
import { ForecastPanelSkeleton } from './DynamicTrackingSkeleton';
import { Tag } from 'antd';
import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig';
@@ -42,9 +42,17 @@ const getForecastTypeStyle = (type) => {
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 [loading, setLoading] = useState(false);
// 智能初始化 loading当需要加载数据时初始值为 true避免首次渲染闪现空状态
const [loading, setLoading] = useState(() => isActive && !!stockCode);
const loadForecast = useCallback(async () => {
if (!stockCode) return;
@@ -65,16 +73,15 @@ const ForecastPanel = ({ stockCode }) => {
}
}, [stockCode]);
// 加载数据 - activationKey 变化时也会触发重新请求(实现切换刷新)
useEffect(() => {
loadForecast();
}, [loadForecast]);
if (isActive) {
loadForecast();
}
}, [isActive, activationKey, loadForecast]);
if (loading) {
return (
<Center py={10}>
<Spinner size="lg" color={THEME.gold} />
</Center>
);
return <ForecastPanelSkeleton />;
}
if (!forecast?.forecasts?.length) {

View File

@@ -6,9 +6,17 @@ import { logger } from '@utils/logger';
import axios from '@utils/axiosConfig';
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 [loading, setLoading] = useState(false);
// 智能初始化 loading当需要加载数据时初始值为 true避免首次渲染闪现空状态
const [loading, setLoading] = useState(() => isActive && !!stockCode);
const [pagination, setPagination] = useState({
page: 1,
per_page: 10,
@@ -53,12 +61,12 @@ const NewsPanel = ({ stockCode }) => {
[stockCode]
);
// 首次加载 - 直接用股票代码搜索
// 加载数据 - activationKey 变化时也会触发重新请求(实现切换刷新)
useEffect(() => {
if (stockCode) {
if (isActive && stockCode) {
loadNewsEvents(null, 1);
}
}, [stockCode, loadNewsEvents]);
}, [stockCode, isActive, activationKey, loadNewsEvents]);
// 搜索处理
const handleSearchChange = (value) => {

View File

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

View File

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

View File

@@ -62,6 +62,7 @@ interface UseFinancialDataReturn {
setStockCode: (code: string) => void;
setSelectedPeriods: (periods: number) => void;
setActiveTab: (tabKey: DataTypeKey) => void;
handleTabChange: (tabKey: DataTypeKey) => void; // Tab 切换时自动检查期数
// 当前参数
currentStockCode: string;
@@ -106,6 +107,14 @@ export const useFinancialData = (
const coreDataControllerRef = 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 对应的数据类型
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
switch (tabKey) {
@@ -132,22 +141,34 @@ export const useFinancialData = (
switch (dataType) {
case 'balance': {
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;
}
case 'income': {
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;
}
case 'cashflow': {
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;
}
case 'metrics': {
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;
}
}
@@ -197,6 +218,17 @@ export const useFinancialData = (
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
const loadCoreFinancialData = useCallback(async () => {
if (!stockCode || stockCode.length !== 6) {
@@ -235,7 +267,10 @@ export const useFinancialData = (
// 设置数据
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 (businessRes.success) setMainBusiness(businessRes.data);
@@ -269,6 +304,23 @@ export const useFinancialData = (
// 初始加载(仅股票代码变化时全量加载)
useEffect(() => {
if (stockCode) {
// 立即清空所有旧数据,触发骨架屏
setStockInfo(null);
setBalanceSheet([]);
setIncomeStatement([]);
setCashflow([]);
setFinancialMetrics([]);
setMainBusiness(null);
setComparison([]);
// 重置期数记录
dataPeriodsRef.current = {
balance: 0,
income: 0,
cashflow: 0,
metrics: 0,
};
loadAllFinancialData();
isInitialLoad.current = false;
}
@@ -311,6 +363,7 @@ export const useFinancialData = (
setStockCode,
setSelectedPeriods,
setActiveTab,
handleTabChange,
// 当前参数
currentStockCode: stockCode,

View File

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

View File

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

View File

@@ -20,8 +20,10 @@ export const BLACK_GOLD_TABLE_THEME: ThemeConfig = {
headerColor: '#D4AF37',
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.15)',
cellPaddingBlock: 8,
cellPaddingInline: 12,
cellPaddingBlock: 6,
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;
font-weight: 600;
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 {
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 StockSummaryCard } from './StockSummaryCard';
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
export { MarketDataSkeleton } from './MarketDataSkeleton';

View File

@@ -2,7 +2,7 @@
// K线模块 - 日K线/分时图切换展示(黑金主题 + 专业技术指标 + 商品数据叠加 + 分时盘口)
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 type { IndicatorType, MainIndicatorType, DrawingType } from '../../../utils/chartOptions';
@@ -30,6 +30,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
selectedPeriod,
onPeriodChange,
stockCode,
loading = false,
}) => {
// ========== 状态管理 ==========
const [mode, setMode] = useState<ChartMode>('daily');
@@ -127,7 +128,19 @@ const KLineModule: React.FC<KLineModuleProps> = ({
{/* 图表内容区域 */}
<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线图
<DailyKLineChart
tradeData={tradeData}

View File

@@ -76,15 +76,21 @@ export const useMarketData = (
const riseAnalysisRes = await marketService.getRiseAnalysis(stockCode);
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> = {};
riseAnalysisRes.data.forEach((analysis) => {
const dateIndex = tradeDataForMapping.findIndex(
(item) => item.date.substring(0, 10) === analysis.trade_date
);
if (dateIndex !== -1) {
const dateIndex = dateToIndexMap.get(analysis.trade_date);
if (dateIndex !== undefined) {
tempAnalysisMap[dateIndex] = analysis;
}
});
setAnalysisMap(tempAnalysisMap);
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') => {
if (!stockCode) return;
if (loadedDataRef.current[dataType]) return; // 已加载则跳过
if (loadedDataRef.current[dataType]) return;
// 取消之前的 Tab 数据请求
tabDataControllerRef.current?.abort();
@@ -345,11 +351,15 @@ export const useMarketData = (
}, [period, refreshTradeData, stockCode]);
// 组件卸载时取消所有进行中的请求
// 注意:在 React StrictMode 下,组件会快速卸载再挂载
// 为避免取消正在进行的请求,这里不再自动取消
// 请求会在 loadCoreData 开头通过 coreDataControllerRef.current?.abort() 取消旧请求
useEffect(() => {
return () => {
coreDataControllerRef.current?.abort();
tabDataControllerRef.current?.abort();
minuteDataControllerRef.current?.abort();
// 不再在这里取消请求,让请求自然完成
// coreDataControllerRef.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 {
Box,
Card,
CardBody,
Container,
VStack,
useDisclosure,
@@ -30,7 +32,6 @@ import {
UnusualPanel,
PledgePanel,
} from './components/panels';
import LoadingState from '../LoadingState';
import type { MarketDataViewProps } from './types';
/**
@@ -61,7 +62,6 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
minuteData,
minuteLoading,
analysisMap,
refetch,
loadMinuteData,
loadDataByType,
} = useMarketData(stockCode, selectedPeriod);
@@ -89,6 +89,14 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
}
}, [propStockCode, stockCode]);
// 首次渲染时加载默认 Tab融资融券的数据
useEffect(() => {
// 默认 Tab 是融资融券index 0
if (activeTab === 0) {
loadDataByType('funding');
}
}, [loadDataByType, activeTab]);
// 处理图表点击事件
const handleChartClick = useCallback(
(params: { seriesName?: string; data?: [number, number] }) => {
@@ -127,47 +135,40 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
return (
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
<Container maxW="container.xl" py={6}>
<VStack align="stretch" spacing={6}>
<Container maxW="container.xl" py={4}>
<VStack align="stretch" spacing={4}>
{/* 股票概览 */}
{summary && <StockSummaryCard summary={summary} theme={theme} />}
{/* 交易数据 - 日K/分钟K线独立显示在 Tab 上方) */}
{!loading && (
<TradeDataPanel
theme={theme}
tradeData={tradeData}
minuteData={minuteData}
minuteLoading={minuteLoading}
analysisMap={analysisMap}
onLoadMinuteData={loadMinuteData}
onChartClick={handleChartClick}
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
stockCode={stockCode}
/>
)}
<TradeDataPanel
theme={theme}
tradeData={tradeData}
minuteData={minuteData}
minuteLoading={minuteLoading}
analysisMap={analysisMap}
onLoadMinuteData={loadMinuteData}
onChartClick={handleChartClick}
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
stockCode={stockCode}
loading={loading}
/>
{/* 主要内容区域 - Tab */}
{loading ? (
<Box
bg="gray.900"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
borderRadius="xl"
>
<LoadingState message="数据加载中..." height="400px" />
</Box>
) : (
<SubTabContainer
tabs={tabConfigs}
componentProps={componentProps}
themePreset="blackGold"
index={activeTab}
onTabChange={handleTabChange}
isLazy
/>
)}
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={0}>
<SubTabContainer
tabs={tabConfigs}
componentProps={componentProps}
themePreset="blackGold"
index={activeTab}
onTabChange={handleTabChange}
isLazy
size="sm"
/>
</CardBody>
</Card>
</VStack>
</Container>

View File

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

View File

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

View File

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