feat(BasicInfoTab): 添加骨架屏并适配延迟加载

- 各 Panel 组件适配新的 hooks 参数格式
- 新增 BasicInfoTabSkeleton 骨架屏组件
- 新增 CompanyOverviewNavSkeleton 导航骨架屏组件

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-19 18:53:50 +08:00
parent 298ac5a335
commit 90e2a48d66
10 changed files with 518 additions and 32 deletions

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,16 +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
size="sm"
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>
);
};