Compare commits
11 Commits
7fd1dc34f4
...
bea4c7fe81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea4c7fe81 | ||
|
|
d3f4a8e02c | ||
|
|
90e2a48d66 | ||
|
|
298ac5a335 | ||
|
|
672e746a26 | ||
|
|
88da7ad1a5 | ||
|
|
8c9cc9845d | ||
|
|
11544909d3 | ||
|
|
08842b9097 | ||
|
|
0ad0287f7b | ||
|
|
d394c25d7e |
@@ -22,6 +22,7 @@
|
|||||||
import React, { useState, useCallback, memo, Suspense } from 'react';
|
import React, { useState, useCallback, memo, Suspense } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Flex,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabList,
|
TabList,
|
||||||
TabPanels,
|
TabPanels,
|
||||||
@@ -30,7 +31,6 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
HStack,
|
HStack,
|
||||||
Text,
|
Text,
|
||||||
Spacer,
|
|
||||||
Center,
|
Center,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
@@ -95,6 +95,16 @@ export interface SubTabTheme {
|
|||||||
tabHoverBg: string;
|
tabHoverBg: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尺寸配置
|
||||||
|
*/
|
||||||
|
const SIZE_CONFIG = {
|
||||||
|
sm: { fontSize: '13px', px: 4, py: 2, gap: 1.5, iconSize: 3.5 },
|
||||||
|
md: { fontSize: '15px', px: 6, py: 3, gap: 2, iconSize: 4 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SubTabSize = keyof typeof SIZE_CONFIG;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预设主题 - 深空 FUI 风格
|
* 预设主题 - 深空 FUI 风格
|
||||||
*/
|
*/
|
||||||
@@ -140,6 +150,8 @@ export interface SubTabContainerProps {
|
|||||||
rightElement?: React.ReactNode;
|
rightElement?: React.ReactNode;
|
||||||
/** 紧凑模式 - 移除 TabList 的外边距 */
|
/** 紧凑模式 - 移除 TabList 的外边距 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** Tab 尺寸: sm=小号(二级导航), md=正常(一级导航) */
|
||||||
|
size?: SubTabSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||||
@@ -154,7 +166,10 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
isLazy = true,
|
isLazy = true,
|
||||||
rightElement,
|
rightElement,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
size = 'md',
|
||||||
}) => {
|
}) => {
|
||||||
|
// 获取尺寸配置
|
||||||
|
const sizeConfig = SIZE_CONFIG[size];
|
||||||
// 内部状态(非受控模式)
|
// 内部状态(非受控模式)
|
||||||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||||||
|
|
||||||
@@ -166,6 +181,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
() => new Set([controlledIndex ?? defaultIndex])
|
() => new Set([controlledIndex ?? defaultIndex])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 记录每个 Tab 的激活次数(用于支持特定 Tab 切换时重新请求)
|
||||||
|
const [activationCounts, setActivationCounts] = useState<Record<number, number>>(
|
||||||
|
() => ({ [controlledIndex ?? defaultIndex]: 1 })
|
||||||
|
);
|
||||||
|
|
||||||
// 合并主题
|
// 合并主题
|
||||||
const theme: SubTabTheme = {
|
const theme: SubTabTheme = {
|
||||||
...THEME_PRESETS[themePreset],
|
...THEME_PRESETS[themePreset],
|
||||||
@@ -177,6 +197,9 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
*/
|
*/
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
(newIndex: number) => {
|
(newIndex: number) => {
|
||||||
|
// 保存当前滚动位置,防止 Tab 切换时页面跳转
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
|
||||||
const tabKey = tabs[newIndex]?.key || '';
|
const tabKey = tabs[newIndex]?.key || '';
|
||||||
onTabChange?.(newIndex, tabKey);
|
onTabChange?.(newIndex, tabKey);
|
||||||
|
|
||||||
@@ -186,9 +209,20 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
return new Set(prev).add(newIndex);
|
return new Set(prev).add(newIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更新激活计数(用于触发特定 Tab 的数据刷新)
|
||||||
|
setActivationCounts(prev => ({
|
||||||
|
...prev,
|
||||||
|
[newIndex]: (prev[newIndex] || 0) + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
if (controlledIndex === undefined) {
|
if (controlledIndex === undefined) {
|
||||||
setInternalIndex(newIndex);
|
setInternalIndex(newIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 恢复滚动位置,阻止浏览器自动滚动
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[tabs, onTabChange, controlledIndex]
|
[tabs, onTabChange, controlledIndex]
|
||||||
);
|
);
|
||||||
@@ -202,8 +236,8 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
index={currentIndex}
|
index={currentIndex}
|
||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
>
|
>
|
||||||
{/* TabList - 玻璃态导航栏 */}
|
{/* 导航栏容器:左侧 Tab 可滚动,右侧元素固定 */}
|
||||||
<TabList
|
<Flex
|
||||||
bg={theme.bg}
|
bg={theme.bg}
|
||||||
backdropFilter="blur(20px)"
|
backdropFilter="blur(20px)"
|
||||||
borderBottom="1px solid"
|
borderBottom="1px solid"
|
||||||
@@ -211,18 +245,9 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
|
borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
|
||||||
mx={compact ? 0 : 2}
|
mx={compact ? 0 : 2}
|
||||||
mb={compact ? 0 : 2}
|
mb={compact ? 0 : 2}
|
||||||
px={3}
|
|
||||||
py={compact ? 2 : 3}
|
|
||||||
flexWrap="nowrap"
|
|
||||||
gap={2}
|
|
||||||
alignItems="center"
|
|
||||||
overflowX="auto"
|
|
||||||
position="relative"
|
position="relative"
|
||||||
boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
|
boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
|
||||||
css={{
|
alignItems="center"
|
||||||
'&::-webkit-scrollbar': { display: 'none' },
|
|
||||||
scrollbarWidth: 'none',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* 顶部金色光条 */}
|
{/* 顶部金色光条 */}
|
||||||
<Box
|
<Box
|
||||||
@@ -235,81 +260,110 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
background={`linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)`}
|
background={`linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{tabs.map((tab, idx) => {
|
{/* 左侧:可滚动的 Tab 区域 */}
|
||||||
const isSelected = idx === currentIndex;
|
<Box
|
||||||
|
flex="1"
|
||||||
|
minW={0}
|
||||||
|
overflowX="auto"
|
||||||
|
css={{
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabList
|
||||||
|
border="none"
|
||||||
|
px={3}
|
||||||
|
py={compact ? 2 : sizeConfig.py}
|
||||||
|
flexWrap="nowrap"
|
||||||
|
gap={sizeConfig.gap}
|
||||||
|
>
|
||||||
|
{tabs.map((tab, idx) => {
|
||||||
|
const isSelected = idx === currentIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
color={theme.tabUnselectedColor}
|
color={theme.tabUnselectedColor}
|
||||||
borderRadius={DEEP_SPACE.radius}
|
borderRadius={DEEP_SPACE.radius}
|
||||||
px={6}
|
px={sizeConfig.px}
|
||||||
py={3}
|
py={sizeConfig.py}
|
||||||
fontSize="15px"
|
fontSize={sizeConfig.fontSize}
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
whiteSpace="nowrap"
|
whiteSpace="nowrap"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
border="1px solid transparent"
|
border="1px solid transparent"
|
||||||
position="relative"
|
position="relative"
|
||||||
letterSpacing="0.03em"
|
letterSpacing="0.03em"
|
||||||
transition={DEEP_SPACE.transition}
|
transition={DEEP_SPACE.transition}
|
||||||
_before={{
|
_before={{
|
||||||
content: '""',
|
content: '""',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '-1px',
|
bottom: '-1px',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: isSelected ? '70%' : '0%',
|
width: isSelected ? '70%' : '0%',
|
||||||
height: '2px',
|
height: '2px',
|
||||||
bg: '#D4AF37',
|
bg: '#D4AF37',
|
||||||
borderRadius: 'full',
|
borderRadius: 'full',
|
||||||
transition: 'width 0.3s ease',
|
transition: 'width 0.3s ease',
|
||||||
boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none',
|
boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none',
|
||||||
}}
|
}}
|
||||||
_selected={{
|
_selected={{
|
||||||
bg: theme.tabSelectedBg,
|
bg: theme.tabSelectedBg,
|
||||||
color: theme.tabSelectedColor,
|
color: theme.tabSelectedColor,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
boxShadow: DEEP_SPACE.glowGold,
|
boxShadow: DEEP_SPACE.glowGold,
|
||||||
border: `1px solid ${DEEP_SPACE.borderGoldHover}`,
|
border: `1px solid ${DEEP_SPACE.borderGoldHover}`,
|
||||||
transform: 'translateY(-2px)',
|
transform: 'translateY(-2px)',
|
||||||
}}
|
}}
|
||||||
_hover={{
|
_hover={{
|
||||||
bg: isSelected ? undefined : theme.tabHoverBg,
|
bg: isSelected ? undefined : theme.tabHoverBg,
|
||||||
border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`,
|
border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`,
|
||||||
transform: 'translateY(-1px)',
|
transform: 'translateY(-1px)',
|
||||||
}}
|
}}
|
||||||
_active={{
|
_active={{
|
||||||
transform: 'translateY(0)',
|
transform: 'translateY(0)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={size === 'sm' ? 1.5 : 2}>
|
||||||
{tab.icon && (
|
{tab.icon && (
|
||||||
<Icon
|
<Icon
|
||||||
as={tab.icon}
|
as={tab.icon}
|
||||||
boxSize={4}
|
boxSize={sizeConfig.iconSize}
|
||||||
opacity={isSelected ? 1 : 0.7}
|
opacity={isSelected ? 1 : 0.7}
|
||||||
transition="opacity 0.2s"
|
transition="opacity 0.2s"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text>{tab.name}</Text>
|
<Text>{tab.name}</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Tab>
|
</Tab>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</TabList>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 右侧:固定的自定义元素(如期数选择器) */}
|
||||||
{rightElement && (
|
{rightElement && (
|
||||||
<>
|
<Box
|
||||||
<Spacer />
|
flexShrink={0}
|
||||||
<Box flexShrink={0}>{rightElement}</Box>
|
pr={3}
|
||||||
</>
|
pl={2}
|
||||||
|
py={compact ? 2 : sizeConfig.py}
|
||||||
|
borderLeft="1px solid"
|
||||||
|
borderColor={DEEP_SPACE.borderGold}
|
||||||
|
>
|
||||||
|
{rightElement}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</TabList>
|
</Flex>
|
||||||
|
|
||||||
<TabPanels p={contentPadding}>
|
<TabPanels p={contentPadding}>
|
||||||
{tabs.map((tab, idx) => {
|
{tabs.map((tab, idx) => {
|
||||||
const Component = tab.component;
|
const Component = tab.component;
|
||||||
// 懒加载:只渲染已访问过的 Tab
|
// 懒加载:只渲染已访问过的 Tab
|
||||||
const shouldRender = !isLazy || visitedTabs.has(idx);
|
const shouldRender = !isLazy || visitedTabs.has(idx);
|
||||||
|
// 判断是否为当前激活的 Tab(用于控制数据加载)
|
||||||
|
const isActive = idx === currentIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanel key={tab.key} p={0}>
|
<TabPanel key={tab.key} p={0}>
|
||||||
@@ -328,7 +382,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Component {...componentProps} />
|
<Component
|
||||||
|
{...componentProps}
|
||||||
|
isActive={isActive}
|
||||||
|
activationKey={activationCounts[idx] || 0}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface TabPanelContainerProps {
|
|||||||
loadingMessage?: string;
|
loadingMessage?: string;
|
||||||
/** 加载状态高度 */
|
/** 加载状态高度 */
|
||||||
loadingHeight?: string;
|
loadingHeight?: string;
|
||||||
|
/** 自定义骨架屏组件,优先于默认 Spinner */
|
||||||
|
skeleton?: React.ReactNode;
|
||||||
/** 子组件间距,默认 6 */
|
/** 子组件间距,默认 6 */
|
||||||
spacing?: number;
|
spacing?: number;
|
||||||
/** 内边距,默认 4 */
|
/** 内边距,默认 4 */
|
||||||
@@ -74,6 +76,7 @@ const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
|
|||||||
loading = false,
|
loading = false,
|
||||||
loadingMessage = '加载中...',
|
loadingMessage = '加载中...',
|
||||||
loadingHeight = '200px',
|
loadingHeight = '200px',
|
||||||
|
skeleton,
|
||||||
spacing = 6,
|
spacing = 6,
|
||||||
padding = 4,
|
padding = 4,
|
||||||
showDisclaimer = false,
|
showDisclaimer = false,
|
||||||
@@ -81,6 +84,10 @@ const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
// 如果提供了自定义骨架屏,使用骨架屏;否则使用默认 Spinner
|
||||||
|
if (skeleton) {
|
||||||
|
return <>{skeleton}</>;
|
||||||
|
}
|
||||||
return <LoadingState message={loadingMessage} height={loadingHeight} />;
|
return <LoadingState message={loadingMessage} height={loadingHeight} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,21 @@
|
|||||||
|
|
||||||
// 生成财务数据
|
// 生成财务数据
|
||||||
export const generateFinancialData = (stockCode) => {
|
export const generateFinancialData = (stockCode) => {
|
||||||
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31'];
|
// 12 期数据 - 用于财务指标表格(7个指标Tab)
|
||||||
|
const metricsPeriods = [
|
||||||
|
'2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31',
|
||||||
|
'2023-09-30', '2023-06-30', '2023-03-31', '2022-12-31',
|
||||||
|
'2022-09-30', '2022-06-30', '2022-03-31', '2021-12-31',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 8 期数据 - 用于财务报表(3个报表Tab)
|
||||||
|
const statementPeriods = [
|
||||||
|
'2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31',
|
||||||
|
'2023-09-30', '2023-06-30', '2023-03-31', '2022-12-31',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 兼容旧代码
|
||||||
|
const periods = statementPeriods.slice(0, 4);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stockCode,
|
stockCode,
|
||||||
@@ -44,8 +58,8 @@ export const generateFinancialData = (stockCode) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 资产负债表 - 嵌套结构
|
// 资产负债表 - 嵌套结构(8期数据)
|
||||||
balanceSheet: periods.map((period, i) => ({
|
balanceSheet: statementPeriods.map((period, i) => ({
|
||||||
period,
|
period,
|
||||||
assets: {
|
assets: {
|
||||||
current_assets: {
|
current_assets: {
|
||||||
@@ -110,8 +124,8 @@ export const generateFinancialData = (stockCode) => {
|
|||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// 利润表 - 嵌套结构
|
// 利润表 - 嵌套结构(8期数据)
|
||||||
incomeStatement: periods.map((period, i) => ({
|
incomeStatement: statementPeriods.map((period, i) => ({
|
||||||
period,
|
period,
|
||||||
revenue: {
|
revenue: {
|
||||||
total_operating_revenue: 162350 - i * 4000,
|
total_operating_revenue: 162350 - i * 4000,
|
||||||
@@ -166,8 +180,8 @@ export const generateFinancialData = (stockCode) => {
|
|||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// 现金流量表 - 嵌套结构
|
// 现金流量表 - 嵌套结构(8期数据)
|
||||||
cashflow: periods.map((period, i) => ({
|
cashflow: statementPeriods.map((period, i) => ({
|
||||||
period,
|
period,
|
||||||
operating_activities: {
|
operating_activities: {
|
||||||
inflow: {
|
inflow: {
|
||||||
@@ -193,8 +207,8 @@ export const generateFinancialData = (stockCode) => {
|
|||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// 财务指标 - 嵌套结构
|
// 财务指标 - 嵌套结构(12期数据)
|
||||||
financialMetrics: periods.map((period, i) => ({
|
financialMetrics: metricsPeriods.map((period, i) => ({
|
||||||
period,
|
period,
|
||||||
profitability: {
|
profitability: {
|
||||||
roe: 16.23 - i * 0.3,
|
roe: 16.23 - i * 0.3,
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
// src/mocks/data/market.js
|
// src/mocks/data/market.js
|
||||||
// 市场行情相关的 Mock 数据
|
// 市场行情相关的 Mock 数据
|
||||||
|
|
||||||
|
// 股票名称映射
|
||||||
|
const STOCK_NAME_MAP = {
|
||||||
|
'000001': { name: '平安银行', basePrice: 13.50 },
|
||||||
|
'600000': { name: '浦发银行', basePrice: 8.20 },
|
||||||
|
'600519': { name: '贵州茅台', basePrice: 1650.00 },
|
||||||
|
'000858': { name: '五粮液', basePrice: 165.00 },
|
||||||
|
'601318': { name: '中国平安', basePrice: 45.00 },
|
||||||
|
'600036': { name: '招商银行', basePrice: 32.00 },
|
||||||
|
'300750': { name: '宁德时代', basePrice: 180.00 },
|
||||||
|
'002594': { name: '比亚迪', basePrice: 260.00 },
|
||||||
|
};
|
||||||
|
|
||||||
// 生成市场数据
|
// 生成市场数据
|
||||||
export const generateMarketData = (stockCode) => {
|
export const generateMarketData = (stockCode) => {
|
||||||
const basePrice = 13.50; // 基准价格(平安银行约13.5元)
|
const stockInfo = STOCK_NAME_MAP[stockCode] || { name: `股票${stockCode}`, basePrice: 20.00 };
|
||||||
|
const basePrice = stockInfo.basePrice;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stockCode,
|
stockCode,
|
||||||
@@ -102,7 +115,7 @@ export const generateMarketData = (stockCode) => {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
stock_code: stockCode,
|
stock_code: stockCode,
|
||||||
stock_name: stockCode === '000001' ? '平安银行' : '示例股票',
|
stock_name: stockInfo.name,
|
||||||
latest_trade: {
|
latest_trade: {
|
||||||
close: basePrice,
|
close: basePrice,
|
||||||
change_percent: 1.89,
|
change_percent: 1.89,
|
||||||
@@ -189,7 +202,7 @@ export const generateMarketData = (stockCode) => {
|
|||||||
return minuteData;
|
return minuteData;
|
||||||
})(),
|
})(),
|
||||||
code: stockCode,
|
code: stockCode,
|
||||||
name: stockCode === '000001' ? '平安银行' : '示例股票',
|
name: stockInfo.name,
|
||||||
trade_date: new Date().toISOString().split('T')[0],
|
trade_date: new Date().toISOString().split('T')[0],
|
||||||
type: '1min'
|
type: '1min'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ import LoadingState from "./LoadingState";
|
|||||||
|
|
||||||
interface AnnouncementsPanelProps {
|
interface AnnouncementsPanelProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
|
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||||
|
isActive?: boolean;
|
||||||
|
/** 激活次数,变化时触发重新请求 */
|
||||||
|
activationKey?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) => {
|
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode, isActive = true, activationKey }) => {
|
||||||
const { announcements, loading } = useAnnouncementsData(stockCode);
|
const { announcements, loading } = useAnnouncementsData({ stockCode, enabled: isActive, refreshKey: activationKey });
|
||||||
|
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
|
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -16,10 +16,12 @@ import { FaSitemap, FaBuilding, FaCheckCircle, FaTimesCircle } from "react-icons
|
|||||||
import { useBranchesData } from "../../hooks/useBranchesData";
|
import { useBranchesData } from "../../hooks/useBranchesData";
|
||||||
import { THEME } from "../config";
|
import { THEME } from "../config";
|
||||||
import { formatDate } from "../utils";
|
import { formatDate } from "../utils";
|
||||||
import LoadingState from "./LoadingState";
|
import { BranchesSkeleton } from "./BasicInfoTabSkeleton";
|
||||||
|
|
||||||
interface BranchesPanelProps {
|
interface BranchesPanelProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
|
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 黑金卡片样式
|
// 黑金卡片样式
|
||||||
@@ -65,11 +67,11 @@ const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label,
|
|||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode }) => {
|
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode, isActive = true }) => {
|
||||||
const { branches, loading } = useBranchesData(stockCode);
|
const { branches, loading } = useBranchesData({ stockCode, enabled: isActive });
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState message="加载分支机构数据..." />;
|
return <BranchesSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (branches.length === 0) {
|
if (branches.length === 0) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Center,
|
Center,
|
||||||
Icon,
|
Icon,
|
||||||
Spinner,
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import {
|
import {
|
||||||
FaBuilding,
|
FaBuilding,
|
||||||
@@ -27,9 +26,12 @@ import {
|
|||||||
import { COLORS, GLASS, glassCardStyle } from "@views/Company/theme";
|
import { COLORS, GLASS, glassCardStyle } from "@views/Company/theme";
|
||||||
import { THEME } from "../config";
|
import { THEME } from "../config";
|
||||||
import { useBasicInfo } from "../../hooks/useBasicInfo";
|
import { useBasicInfo } from "../../hooks/useBasicInfo";
|
||||||
|
import { BusinessInfoSkeleton } from "./BasicInfoTabSkeleton";
|
||||||
|
|
||||||
interface BusinessInfoPanelProps {
|
interface BusinessInfoPanelProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
|
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 区块标题组件
|
// 区块标题组件
|
||||||
@@ -150,15 +152,11 @@ const TextSection: React.FC<{
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
|
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode, isActive = true }) => {
|
||||||
const { basicInfo, loading } = useBasicInfo(stockCode);
|
const { basicInfo, loading } = useBasicInfo({ stockCode, enabled: isActive });
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <BusinessInfoSkeleton />;
|
||||||
<Center h="200px">
|
|
||||||
<Spinner size="lg" color={THEME.gold} />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!basicInfo) {
|
if (!basicInfo) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -19,10 +19,12 @@ import LoadingState from "./LoadingState";
|
|||||||
|
|
||||||
interface DisclosureSchedulePanelProps {
|
interface DisclosureSchedulePanelProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
|
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode }) => {
|
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode, isActive = true }) => {
|
||||||
const { disclosureSchedule, loading } = useDisclosureData(stockCode);
|
const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive });
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState message="加载披露日程..." />;
|
return <LoadingState message="加载披露日程..." />;
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ import {
|
|||||||
ShareholdersTable,
|
ShareholdersTable,
|
||||||
} from "../../components/shareholder";
|
} from "../../components/shareholder";
|
||||||
import TabPanelContainer from "@components/TabPanelContainer";
|
import TabPanelContainer from "@components/TabPanelContainer";
|
||||||
|
import { ShareholderSkeleton } from "./BasicInfoTabSkeleton";
|
||||||
|
|
||||||
interface ShareholderPanelProps {
|
interface ShareholderPanelProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
|
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,17 +26,17 @@ interface ShareholderPanelProps {
|
|||||||
* - ConcentrationCard: 股权集中度卡片
|
* - ConcentrationCard: 股权集中度卡片
|
||||||
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
|
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
|
||||||
*/
|
*/
|
||||||
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
|
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode, isActive = true }) => {
|
||||||
const {
|
const {
|
||||||
actualControl,
|
actualControl,
|
||||||
concentration,
|
concentration,
|
||||||
topShareholders,
|
topShareholders,
|
||||||
topCirculationShareholders,
|
topCirculationShareholders,
|
||||||
loading,
|
loading,
|
||||||
} = useShareholderData(stockCode);
|
} = useShareholderData({ stockCode, enabled: isActive });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanelContainer loading={loading} loadingMessage="加载股权结构数据...">
|
<TabPanelContainer loading={loading} skeleton={<ShareholderSkeleton />}>
|
||||||
{/* 实际控制人 + 股权集中度 左右分布 */}
|
{/* 实际控制人 + 股权集中度 左右分布 */}
|
||||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -9,3 +9,6 @@ export { ManagementPanel } from "./management";
|
|||||||
export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
|
export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
|
||||||
export { default as BranchesPanel } from "./BranchesPanel";
|
export { default as BranchesPanel } from "./BranchesPanel";
|
||||||
export { default as BusinessInfoPanel } from "./BusinessInfoPanel";
|
export { default as BusinessInfoPanel } from "./BusinessInfoPanel";
|
||||||
|
|
||||||
|
// 骨架屏组件
|
||||||
|
export * from "./BasicInfoTabSkeleton";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useManagementData } from "../../../hooks/useManagementData";
|
|||||||
import { THEME } from "../../config";
|
import { THEME } from "../../config";
|
||||||
import TabPanelContainer from "@components/TabPanelContainer";
|
import TabPanelContainer from "@components/TabPanelContainer";
|
||||||
import CategorySection from "./CategorySection";
|
import CategorySection from "./CategorySection";
|
||||||
|
import { ManagementSkeleton } from "../BasicInfoTabSkeleton";
|
||||||
import type {
|
import type {
|
||||||
ManagementPerson,
|
ManagementPerson,
|
||||||
ManagementCategory,
|
ManagementCategory,
|
||||||
@@ -22,6 +23,8 @@ import type {
|
|||||||
|
|
||||||
interface ManagementPanelProps {
|
interface ManagementPanelProps {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
|
/** SubTabContainer 传递的激活状态,控制是否加载数据 */
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,8 +71,8 @@ const categorizeManagement = (management: ManagementPerson[]): CategorizedManage
|
|||||||
return categories;
|
return categories;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
|
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode, isActive = true }) => {
|
||||||
const { management, loading } = useManagementData(stockCode);
|
const { management, loading } = useManagementData({ stockCode, enabled: isActive });
|
||||||
|
|
||||||
// 使用 useMemo 缓存分类计算结果
|
// 使用 useMemo 缓存分类计算结果
|
||||||
const categorizedManagement = useMemo(
|
const categorizedManagement = useMemo(
|
||||||
@@ -78,7 +81,7 @@ const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanelContainer loading={loading} loadingMessage="加载管理团队数据...">
|
<TabPanelContainer loading={loading} skeleton={<ManagementSkeleton />}>
|
||||||
{CATEGORY_ORDER.map((category) => {
|
{CATEGORY_ORDER.map((category) => {
|
||||||
const config = CATEGORY_CONFIG[category];
|
const config = CATEGORY_CONFIG[category];
|
||||||
const people = categorizedManagement[category];
|
const people = categorizedManagement[category];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
|
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
import { Card, CardBody } from "@chakra-ui/react";
|
||||||
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
|
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
|
||||||
|
|
||||||
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
|
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
|
||||||
@@ -65,15 +66,18 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
|||||||
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
|
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubTabContainer
|
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
tabs={tabs}
|
<CardBody p={0}>
|
||||||
componentProps={{ stockCode }}
|
<SubTabContainer
|
||||||
defaultIndex={defaultTabIndex}
|
tabs={tabs}
|
||||||
onTabChange={onTabChange}
|
componentProps={{ stockCode }}
|
||||||
themePreset="blackGold"
|
defaultIndex={defaultTabIndex}
|
||||||
compact
|
onTabChange={onTabChange}
|
||||||
contentPadding={0}
|
themePreset="blackGold"
|
||||||
/>
|
size="sm"
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
|||||||
onTabChange={onTabChange}
|
onTabChange={onTabChange}
|
||||||
componentProps={{}}
|
componentProps={{}}
|
||||||
themePreset="blackGold"
|
themePreset="blackGold"
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<LoadingState message="加载数据中..." height="200px" />
|
<LoadingState message="加载数据中..." height="200px" />
|
||||||
</CardBody>
|
</CardBody>
|
||||||
@@ -99,6 +100,7 @@ const DeepAnalysisTab: React.FC<DeepAnalysisTabProps> = ({
|
|||||||
onToggleSegment,
|
onToggleSegment,
|
||||||
}}
|
}}
|
||||||
themePreset="blackGold"
|
themePreset="blackGold"
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持延迟加载的配置选项
|
||||||
|
interface UseAnnouncementsDataOptions {
|
||||||
|
stockCode?: string;
|
||||||
|
/** 是否启用数据加载,默认 true */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** 刷新标识,变化时触发重新请求 */
|
||||||
|
refreshKey?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseAnnouncementsDataResult {
|
interface UseAnnouncementsDataResult {
|
||||||
announcements: Announcement[];
|
announcements: Announcement[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -18,16 +27,22 @@ interface UseAnnouncementsDataResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公告数据 Hook
|
* 公告数据 Hook(支持延迟加载和刷新)
|
||||||
* @param stockCode - 股票代码
|
* @param options - 配置选项
|
||||||
|
* @param options.stockCode - 股票代码
|
||||||
|
* @param options.enabled - 是否启用数据加载,默认 true
|
||||||
|
* @param options.refreshKey - 刷新标识,变化时触发重新请求
|
||||||
*/
|
*/
|
||||||
export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataResult => {
|
export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseAnnouncementsDataResult => {
|
||||||
|
const { stockCode, enabled = true, refreshKey } = options;
|
||||||
|
|
||||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
|
if (!enabled || !stockCode) return;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -57,7 +72,7 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
|
|||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode]);
|
}, [stockCode, enabled, refreshKey]);
|
||||||
|
|
||||||
return { announcements, loading, error };
|
return { announcements, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持延迟加载的配置选项
|
||||||
|
interface UseBasicInfoOptions {
|
||||||
|
stockCode?: string;
|
||||||
|
/** 是否启用数据加载,默认 true */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseBasicInfoResult {
|
interface UseBasicInfoResult {
|
||||||
basicInfo: BasicInfo | null;
|
basicInfo: BasicInfo | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -18,16 +25,22 @@ interface UseBasicInfoResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公司基本信息 Hook
|
* 公司基本信息 Hook(支持延迟加载)
|
||||||
* @param stockCode - 股票代码
|
* @param options - 配置选项
|
||||||
|
* @param options.stockCode - 股票代码
|
||||||
|
* @param options.enabled - 是否启用数据加载,默认 true
|
||||||
*/
|
*/
|
||||||
export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
|
export const useBasicInfo = (options: UseBasicInfoOptions): UseBasicInfoResult => {
|
||||||
|
const { stockCode, enabled = true } = options;
|
||||||
|
|
||||||
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
|
const [basicInfo, setBasicInfo] = useState<BasicInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
// 智能初始化 loading:当需要加载数据时,初始值为 true,避免首次渲染闪现空状态
|
||||||
|
const [loading, setLoading] = useState(() => enabled && !!stockCode);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
|
if (!enabled || !stockCode) return;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -57,7 +70,7 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
|
|||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode]);
|
}, [stockCode, enabled]);
|
||||||
|
|
||||||
return { basicInfo, loading, error };
|
return { basicInfo, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持延迟加载的配置选项
|
||||||
|
interface UseBranchesDataOptions {
|
||||||
|
stockCode?: string;
|
||||||
|
/** 是否启用数据加载,默认 true */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseBranchesDataResult {
|
interface UseBranchesDataResult {
|
||||||
branches: Branch[];
|
branches: Branch[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -18,16 +25,24 @@ interface UseBranchesDataResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分支机构数据 Hook
|
* 分支机构数据 Hook(支持延迟加载)
|
||||||
* @param stockCode - 股票代码
|
* @param options - 配置选项
|
||||||
|
* @param options.stockCode - 股票代码
|
||||||
|
* @param options.enabled - 是否启用数据加载,默认 true
|
||||||
*/
|
*/
|
||||||
export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
|
export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDataResult => {
|
||||||
|
const { stockCode, enabled = true } = options;
|
||||||
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
if (!enabled || !stockCode) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -46,18 +61,25 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
|
|||||||
} else {
|
} else {
|
||||||
setError("加载分支机构数据失败");
|
setError("加载分支机构数据失败");
|
||||||
}
|
}
|
||||||
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === "CanceledError") return;
|
// 请求被取消时,不更新任何状态
|
||||||
|
if (err.name === "CanceledError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error("useBranchesData", "loadData", err, { stockCode });
|
logger.error("useBranchesData", "loadData", err, { stockCode });
|
||||||
setError("网络请求失败");
|
setError("网络请求失败");
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode]);
|
}, [stockCode, enabled]);
|
||||||
|
|
||||||
return { branches, loading, error };
|
const isLoading = loading || (enabled && !hasLoaded && !error);
|
||||||
|
|
||||||
|
return { branches, loading: isLoading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持延迟加载的配置选项
|
||||||
|
interface UseDisclosureDataOptions {
|
||||||
|
stockCode?: string;
|
||||||
|
/** 是否启用数据加载,默认 true */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseDisclosureDataResult {
|
interface UseDisclosureDataResult {
|
||||||
disclosureSchedule: DisclosureSchedule[];
|
disclosureSchedule: DisclosureSchedule[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -18,16 +25,21 @@ interface UseDisclosureDataResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 披露日程数据 Hook
|
* 披露日程数据 Hook(支持延迟加载)
|
||||||
* @param stockCode - 股票代码
|
* @param options - 配置选项
|
||||||
|
* @param options.stockCode - 股票代码
|
||||||
|
* @param options.enabled - 是否启用数据加载,默认 true
|
||||||
*/
|
*/
|
||||||
export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult => {
|
export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclosureDataResult => {
|
||||||
|
const { stockCode, enabled = true } = options;
|
||||||
|
|
||||||
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
|
const [disclosureSchedule, setDisclosureSchedule] = useState<DisclosureSchedule[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
|
if (!enabled || !stockCode) return;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -57,7 +69,7 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
|
|||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode]);
|
}, [stockCode, enabled]);
|
||||||
|
|
||||||
return { disclosureSchedule, loading, error };
|
return { disclosureSchedule, loading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持延迟加载的配置选项
|
||||||
|
interface UseManagementDataOptions {
|
||||||
|
stockCode?: string;
|
||||||
|
/** 是否启用数据加载,默认 true */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseManagementDataResult {
|
interface UseManagementDataResult {
|
||||||
management: Management[];
|
management: Management[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -18,16 +25,23 @@ interface UseManagementDataResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理团队数据 Hook
|
* 管理团队数据 Hook(支持延迟加载)
|
||||||
* @param stockCode - 股票代码
|
* @param options - 配置选项
|
||||||
|
* @param options.stockCode - 股票代码
|
||||||
|
* @param options.enabled - 是否启用数据加载,默认 true
|
||||||
*/
|
*/
|
||||||
export const useManagementData = (stockCode?: string): UseManagementDataResult => {
|
export const useManagementData = (options: UseManagementDataOptions): UseManagementDataResult => {
|
||||||
|
const { stockCode, enabled = true } = options;
|
||||||
|
|
||||||
const [management, setManagement] = useState<Management[]>([]);
|
const [management, setManagement] = useState<Management[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(() => enabled && !!stockCode);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// 记录是否已完成首次加载,用于派生 loading 状态
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
|
if (!enabled || !stockCode) return;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -52,12 +66,17 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
|
|||||||
setError("网络请求失败");
|
setError("网络请求失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setHasLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode]);
|
}, [stockCode, enabled]);
|
||||||
|
|
||||||
return { management, loading, error };
|
// 派生 loading 状态:enabled 但尚未完成首次加载时,视为 loading
|
||||||
|
// 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁
|
||||||
|
const isLoading = loading || (enabled && !hasLoaded && !error);
|
||||||
|
|
||||||
|
return { management, loading: isLoading, error };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持延迟加载的配置选项
|
||||||
|
interface UseShareholderDataOptions {
|
||||||
|
stockCode?: string;
|
||||||
|
/** 是否启用数据加载,默认 true */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseShareholderDataResult {
|
interface UseShareholderDataResult {
|
||||||
actualControl: ActualControl[];
|
actualControl: ActualControl[];
|
||||||
concentration: Concentration[];
|
concentration: Concentration[];
|
||||||
@@ -21,19 +28,25 @@ interface UseShareholderDataResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股权结构数据 Hook
|
* 股权结构数据 Hook(支持延迟加载)
|
||||||
* @param stockCode - 股票代码
|
* @param options - 配置选项
|
||||||
|
* @param options.stockCode - 股票代码
|
||||||
|
* @param options.enabled - 是否启用数据加载,默认 true
|
||||||
*/
|
*/
|
||||||
export const useShareholderData = (stockCode?: string): UseShareholderDataResult => {
|
export const useShareholderData = (options: UseShareholderDataOptions): UseShareholderDataResult => {
|
||||||
|
const { stockCode, enabled = true } = options;
|
||||||
|
|
||||||
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
|
const [actualControl, setActualControl] = useState<ActualControl[]>([]);
|
||||||
const [concentration, setConcentration] = useState<Concentration[]>([]);
|
const [concentration, setConcentration] = useState<Concentration[]>([]);
|
||||||
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
|
const [topShareholders, setTopShareholders] = useState<Shareholder[]>([]);
|
||||||
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
|
const [topCirculationShareholders, setTopCirculationShareholders] = useState<Shareholder[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
// 智能初始化 loading:当需要加载数据时,初始值为 true,避免首次渲染闪现空状态
|
||||||
|
const [loading, setLoading] = useState(() => enabled && !!stockCode);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stockCode) return;
|
// 只有 enabled 且有 stockCode 时才请求
|
||||||
|
if (!enabled || !stockCode) return;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -69,7 +82,7 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
|
|||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [stockCode]);
|
}, [stockCode, enabled]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actualControl,
|
actualControl,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Flex,
|
Flex,
|
||||||
Text,
|
Text,
|
||||||
Spinner,
|
|
||||||
Center,
|
Center,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
import { ForecastPanelSkeleton } from './DynamicTrackingSkeleton';
|
||||||
import { Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import axios from '@utils/axiosConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
@@ -42,9 +42,17 @@ const getForecastTypeStyle = (type) => {
|
|||||||
return styles[type] || { color: THEME.gold, bg: THEME.goldLight, border: THEME.goldBorder };
|
return styles[type] || { color: THEME.gold, bg: THEME.goldLight, border: THEME.goldBorder };
|
||||||
};
|
};
|
||||||
|
|
||||||
const ForecastPanel = ({ stockCode }) => {
|
/**
|
||||||
|
* 业绩预告面板
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.stockCode - 股票代码
|
||||||
|
* @param {boolean} props.isActive - SubTabContainer 传递的激活状态,控制是否加载数据
|
||||||
|
* @param {number} props.activationKey - 激活次数,变化时触发重新请求
|
||||||
|
*/
|
||||||
|
const ForecastPanel = ({ stockCode, isActive = true, activationKey }) => {
|
||||||
const [forecast, setForecast] = useState(null);
|
const [forecast, setForecast] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
// 智能初始化 loading:当需要加载数据时,初始值为 true,避免首次渲染闪现空状态
|
||||||
|
const [loading, setLoading] = useState(() => isActive && !!stockCode);
|
||||||
|
|
||||||
const loadForecast = useCallback(async () => {
|
const loadForecast = useCallback(async () => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
@@ -65,16 +73,15 @@ const ForecastPanel = ({ stockCode }) => {
|
|||||||
}
|
}
|
||||||
}, [stockCode]);
|
}, [stockCode]);
|
||||||
|
|
||||||
|
// 加载数据 - activationKey 变化时也会触发重新请求(实现切换刷新)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadForecast();
|
if (isActive) {
|
||||||
}, [loadForecast]);
|
loadForecast();
|
||||||
|
}
|
||||||
|
}, [isActive, activationKey, loadForecast]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ForecastPanelSkeleton />;
|
||||||
<Center py={10}>
|
|
||||||
<Spinner size="lg" color={THEME.gold} />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forecast?.forecasts?.length) {
|
if (!forecast?.forecasts?.length) {
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ import { logger } from '@utils/logger';
|
|||||||
import axios from '@utils/axiosConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
import NewsEventsTab from '../NewsEventsTab';
|
import NewsEventsTab from '../NewsEventsTab';
|
||||||
|
|
||||||
const NewsPanel = ({ stockCode }) => {
|
/**
|
||||||
|
* 新闻动态面板
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.stockCode - 股票代码
|
||||||
|
* @param {boolean} props.isActive - SubTabContainer 传递的激活状态,控制是否加载数据
|
||||||
|
* @param {number} props.activationKey - 激活次数,变化时触发重新请求
|
||||||
|
*/
|
||||||
|
const NewsPanel = ({ stockCode, isActive = true, activationKey }) => {
|
||||||
const [newsEvents, setNewsEvents] = useState([]);
|
const [newsEvents, setNewsEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
// 智能初始化 loading:当需要加载数据时,初始值为 true,避免首次渲染闪现空状态
|
||||||
|
const [loading, setLoading] = useState(() => isActive && !!stockCode);
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 10,
|
per_page: 10,
|
||||||
@@ -53,12 +61,12 @@ const NewsPanel = ({ stockCode }) => {
|
|||||||
[stockCode]
|
[stockCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 首次加载 - 直接用股票代码搜索
|
// 加载数据 - activationKey 变化时也会触发重新请求(实现切换刷新)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stockCode) {
|
if (isActive && stockCode) {
|
||||||
loadNewsEvents(null, 1);
|
loadNewsEvents(null, 1);
|
||||||
}
|
}
|
||||||
}, [stockCode, loadNewsEvents]);
|
}, [stockCode, isActive, activationKey, loadNewsEvents]);
|
||||||
|
|
||||||
// 搜索处理
|
// 搜索处理
|
||||||
const handleSearchChange = (value) => {
|
const handleSearchChange = (value) => {
|
||||||
|
|||||||
@@ -2,3 +2,12 @@
|
|||||||
|
|
||||||
export { default as NewsPanel } from './NewsPanel';
|
export { default as NewsPanel } from './NewsPanel';
|
||||||
export { default as ForecastPanel } from './ForecastPanel';
|
export { default as ForecastPanel } from './ForecastPanel';
|
||||||
|
|
||||||
|
// 骨架屏组件
|
||||||
|
export {
|
||||||
|
NewsPanelSkeleton,
|
||||||
|
AnnouncementsSkeleton,
|
||||||
|
DisclosureScheduleSkeleton,
|
||||||
|
ForecastPanelSkeleton,
|
||||||
|
ContentSkeleton,
|
||||||
|
} from './DynamicTrackingSkeleton';
|
||||||
|
|||||||
@@ -1,37 +1,73 @@
|
|||||||
// src/views/Company/components/DynamicTracking/index.js
|
// src/views/Company/components/DynamicTracking/index.js
|
||||||
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab)
|
// 动态跟踪 - 独立一级 Tab 组件(包含新闻动态等二级 Tab)
|
||||||
|
// 优化:子组件懒加载,骨架屏即时反馈
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback, memo, lazy } from 'react';
|
||||||
import { Box } from '@chakra-ui/react';
|
import { Card, CardBody } from '@chakra-ui/react';
|
||||||
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
|
import { FaNewspaper, FaBullhorn, FaCalendarAlt, FaChartBar } from 'react-icons/fa';
|
||||||
|
|
||||||
import SubTabContainer from '@components/SubTabContainer';
|
import SubTabContainer from '@components/SubTabContainer';
|
||||||
import AnnouncementsPanel from '../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel';
|
import {
|
||||||
import DisclosureSchedulePanel from '../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel';
|
NewsPanelSkeleton,
|
||||||
import { NewsPanel, ForecastPanel } from './components';
|
AnnouncementsSkeleton,
|
||||||
|
DisclosureScheduleSkeleton,
|
||||||
|
ForecastPanelSkeleton,
|
||||||
|
} from './components/DynamicTrackingSkeleton';
|
||||||
|
|
||||||
// 二级 Tab 配置
|
// 懒加载子组件
|
||||||
|
const NewsPanel = lazy(() => import('./components/NewsPanel'));
|
||||||
|
const ForecastPanel = lazy(() => import('./components/ForecastPanel'));
|
||||||
|
const AnnouncementsPanel = lazy(() =>
|
||||||
|
import('../CompanyOverview/BasicInfoTab/components/AnnouncementsPanel')
|
||||||
|
);
|
||||||
|
const DisclosureSchedulePanel = lazy(() =>
|
||||||
|
import('../CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel')
|
||||||
|
);
|
||||||
|
|
||||||
|
// 二级 Tab 配置(带骨架屏 fallback)
|
||||||
const TRACKING_TABS = [
|
const TRACKING_TABS = [
|
||||||
{ key: 'news', name: '新闻动态', icon: FaNewspaper, component: NewsPanel },
|
{
|
||||||
{ key: 'announcements', name: '公司公告', icon: FaBullhorn, component: AnnouncementsPanel },
|
key: 'news',
|
||||||
{ key: 'disclosure', name: '财报披露日程', icon: FaCalendarAlt, component: DisclosureSchedulePanel },
|
name: '新闻动态',
|
||||||
{ key: 'forecast', name: '业绩预告', icon: FaChartBar, component: ForecastPanel },
|
icon: FaNewspaper,
|
||||||
|
component: NewsPanel,
|
||||||
|
fallback: <NewsPanelSkeleton />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'announcements',
|
||||||
|
name: '公司公告',
|
||||||
|
icon: FaBullhorn,
|
||||||
|
component: AnnouncementsPanel,
|
||||||
|
fallback: <AnnouncementsSkeleton />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'disclosure',
|
||||||
|
name: '财报披露日程',
|
||||||
|
icon: FaCalendarAlt,
|
||||||
|
component: DisclosureSchedulePanel,
|
||||||
|
fallback: <DisclosureScheduleSkeleton />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'forecast',
|
||||||
|
name: '业绩预告',
|
||||||
|
icon: FaChartBar,
|
||||||
|
component: ForecastPanel,
|
||||||
|
fallback: <ForecastPanelSkeleton />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态跟踪组件
|
* 动态跟踪组件
|
||||||
*
|
*
|
||||||
* 功能:
|
* 功能:
|
||||||
* - 使用 SubTabContainer 实现二级导航
|
* - 使用 SubTabContainer 实现二级导航(同步渲染,无 loading)
|
||||||
* - Tab1: 新闻动态
|
* - 子组件懒加载,减少初始包体积
|
||||||
* - Tab2: 公司公告
|
* - 每个 Tab 有专属骨架屏,提供即时视觉反馈
|
||||||
* - Tab3: 财报披露日程
|
|
||||||
* - Tab4: 业绩预告
|
|
||||||
*
|
*
|
||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {string} props.stockCode - 股票代码
|
* @param {string} props.stockCode - 股票代码
|
||||||
*/
|
*/
|
||||||
const DynamicTracking = ({ stockCode: propStockCode }) => {
|
const DynamicTracking = memo(({ stockCode: propStockCode }) => {
|
||||||
const [stockCode, setStockCode] = useState(propStockCode || '000001');
|
const [stockCode, setStockCode] = useState(propStockCode || '000001');
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
|
||||||
@@ -50,18 +86,28 @@ const DynamicTracking = ({ stockCode: propStockCode }) => {
|
|||||||
[stockCode]
|
[stockCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Tab 切换回调
|
||||||
|
const handleTabChange = useCallback((index) => {
|
||||||
|
setActiveTab(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
<SubTabContainer
|
<CardBody p={0}>
|
||||||
tabs={TRACKING_TABS}
|
<SubTabContainer
|
||||||
componentProps={componentProps}
|
tabs={TRACKING_TABS}
|
||||||
themePreset="blackGold"
|
componentProps={componentProps}
|
||||||
index={activeTab}
|
themePreset="blackGold"
|
||||||
onTabChange={(index) => setActiveTab(index)}
|
index={activeTab}
|
||||||
isLazy
|
onTabChange={handleTabChange}
|
||||||
/>
|
isLazy
|
||||||
</Box>
|
size="sm"
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
DynamicTracking.displayName = 'DynamicTracking';
|
||||||
|
|
||||||
export default DynamicTracking;
|
export default DynamicTracking;
|
||||||
|
|||||||
@@ -108,8 +108,9 @@ const UnifiedFinancialTableInner: React.FC<UnifiedFinancialTableProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制显示列数
|
// 固定显示期数: 财务指标 6 期, 财务报表 8 期
|
||||||
const maxColumns = type === 'metrics' ? 6 : 8;
|
const FIXED_PERIODS = { metrics: 6, statement: 8 };
|
||||||
|
const maxColumns = FIXED_PERIODS[type];
|
||||||
const displayData = data.slice(0, Math.min(data.length, maxColumns));
|
const displayData = data.slice(0, Math.min(data.length, maxColumns));
|
||||||
|
|
||||||
// 构建表格数据
|
// 构建表格数据
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ interface UseFinancialDataReturn {
|
|||||||
setStockCode: (code: string) => void;
|
setStockCode: (code: string) => void;
|
||||||
setSelectedPeriods: (periods: number) => void;
|
setSelectedPeriods: (periods: number) => void;
|
||||||
setActiveTab: (tabKey: DataTypeKey) => void;
|
setActiveTab: (tabKey: DataTypeKey) => void;
|
||||||
|
handleTabChange: (tabKey: DataTypeKey) => void; // Tab 切换时自动检查期数
|
||||||
|
|
||||||
// 当前参数
|
// 当前参数
|
||||||
currentStockCode: string;
|
currentStockCode: string;
|
||||||
@@ -106,6 +107,14 @@ export const useFinancialData = (
|
|||||||
const coreDataControllerRef = useRef<AbortController | null>(null);
|
const coreDataControllerRef = useRef<AbortController | null>(null);
|
||||||
const tabDataControllerRef = useRef<AbortController | null>(null);
|
const tabDataControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// 记录每种数据类型加载时使用的期数(用于 Tab 切换时判断是否需要重新加载)
|
||||||
|
const dataPeriodsRef = useRef<Record<string, number>>({
|
||||||
|
balance: 0,
|
||||||
|
income: 0,
|
||||||
|
cashflow: 0,
|
||||||
|
metrics: 0,
|
||||||
|
});
|
||||||
|
|
||||||
// 判断 Tab key 对应的数据类型
|
// 判断 Tab key 对应的数据类型
|
||||||
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
|
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
|
||||||
switch (tabKey) {
|
switch (tabKey) {
|
||||||
@@ -132,22 +141,34 @@ export const useFinancialData = (
|
|||||||
switch (dataType) {
|
switch (dataType) {
|
||||||
case 'balance': {
|
case 'balance': {
|
||||||
const res = await financialService.getBalanceSheet(stockCode, periods, options);
|
const res = await financialService.getBalanceSheet(stockCode, periods, options);
|
||||||
if (res.success) setBalanceSheet(res.data);
|
if (res.success) {
|
||||||
|
setBalanceSheet(res.data);
|
||||||
|
dataPeriodsRef.current.balance = periods;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'income': {
|
case 'income': {
|
||||||
const res = await financialService.getIncomeStatement(stockCode, periods, options);
|
const res = await financialService.getIncomeStatement(stockCode, periods, options);
|
||||||
if (res.success) setIncomeStatement(res.data);
|
if (res.success) {
|
||||||
|
setIncomeStatement(res.data);
|
||||||
|
dataPeriodsRef.current.income = periods;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'cashflow': {
|
case 'cashflow': {
|
||||||
const res = await financialService.getCashflow(stockCode, periods, options);
|
const res = await financialService.getCashflow(stockCode, periods, options);
|
||||||
if (res.success) setCashflow(res.data);
|
if (res.success) {
|
||||||
|
setCashflow(res.data);
|
||||||
|
dataPeriodsRef.current.cashflow = periods;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'metrics': {
|
case 'metrics': {
|
||||||
const res = await financialService.getFinancialMetrics(stockCode, periods, options);
|
const res = await financialService.getFinancialMetrics(stockCode, periods, options);
|
||||||
if (res.success) setFinancialMetrics(res.data);
|
if (res.success) {
|
||||||
|
setFinancialMetrics(res.data);
|
||||||
|
dataPeriodsRef.current.metrics = periods;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +218,17 @@ export const useFinancialData = (
|
|||||||
setSelectedPeriodsState(periods);
|
setSelectedPeriodsState(periods);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Tab 切换处理:检查期数是否需要重新加载
|
||||||
|
const handleTabChange = useCallback((tabKey: DataTypeKey) => {
|
||||||
|
setActiveTab(tabKey);
|
||||||
|
const dataType = getDataTypeForTab(tabKey);
|
||||||
|
// 如果该 Tab 数据的期数与当前选择的期数不一致,重新加载
|
||||||
|
// 注:refetchByTab 使用传入的 tabKey,不依赖 activeTab 状态,无需延迟
|
||||||
|
if (dataPeriodsRef.current[dataType] !== selectedPeriods) {
|
||||||
|
refetchByTab(tabKey);
|
||||||
|
}
|
||||||
|
}, [selectedPeriods, refetchByTab]);
|
||||||
|
|
||||||
// 加载核心财务数据(初始加载:stockInfo + metrics + comparison)
|
// 加载核心财务数据(初始加载:stockInfo + metrics + comparison)
|
||||||
const loadCoreFinancialData = useCallback(async () => {
|
const loadCoreFinancialData = useCallback(async () => {
|
||||||
if (!stockCode || stockCode.length !== 6) {
|
if (!stockCode || stockCode.length !== 6) {
|
||||||
@@ -235,7 +267,10 @@ export const useFinancialData = (
|
|||||||
|
|
||||||
// 设置数据
|
// 设置数据
|
||||||
if (stockInfoRes.success) setStockInfo(stockInfoRes.data);
|
if (stockInfoRes.success) setStockInfo(stockInfoRes.data);
|
||||||
if (metricsRes.success) setFinancialMetrics(metricsRes.data);
|
if (metricsRes.success) {
|
||||||
|
setFinancialMetrics(metricsRes.data);
|
||||||
|
dataPeriodsRef.current.metrics = selectedPeriods;
|
||||||
|
}
|
||||||
if (comparisonRes.success) setComparison(comparisonRes.data);
|
if (comparisonRes.success) setComparison(comparisonRes.data);
|
||||||
if (businessRes.success) setMainBusiness(businessRes.data);
|
if (businessRes.success) setMainBusiness(businessRes.data);
|
||||||
|
|
||||||
@@ -269,6 +304,23 @@ export const useFinancialData = (
|
|||||||
// 初始加载(仅股票代码变化时全量加载)
|
// 初始加载(仅股票代码变化时全量加载)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stockCode) {
|
if (stockCode) {
|
||||||
|
// 立即清空所有旧数据,触发骨架屏
|
||||||
|
setStockInfo(null);
|
||||||
|
setBalanceSheet([]);
|
||||||
|
setIncomeStatement([]);
|
||||||
|
setCashflow([]);
|
||||||
|
setFinancialMetrics([]);
|
||||||
|
setMainBusiness(null);
|
||||||
|
setComparison([]);
|
||||||
|
|
||||||
|
// 重置期数记录
|
||||||
|
dataPeriodsRef.current = {
|
||||||
|
balance: 0,
|
||||||
|
income: 0,
|
||||||
|
cashflow: 0,
|
||||||
|
metrics: 0,
|
||||||
|
};
|
||||||
|
|
||||||
loadAllFinancialData();
|
loadAllFinancialData();
|
||||||
isInitialLoad.current = false;
|
isInitialLoad.current = false;
|
||||||
}
|
}
|
||||||
@@ -311,6 +363,7 @@ export const useFinancialData = (
|
|||||||
setStockCode,
|
setStockCode,
|
||||||
setSelectedPeriods,
|
setSelectedPeriods,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
|
handleTabChange,
|
||||||
|
|
||||||
// 当前参数
|
// 当前参数
|
||||||
currentStockCode: stockCode,
|
currentStockCode: stockCode,
|
||||||
|
|||||||
@@ -90,15 +90,15 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
|||||||
refetchByTab,
|
refetchByTab,
|
||||||
selectedPeriods,
|
selectedPeriods,
|
||||||
setSelectedPeriods,
|
setSelectedPeriods,
|
||||||
setActiveTab,
|
handleTabChange: handleTabChangeWithPeriodCheck,
|
||||||
activeTab,
|
activeTab,
|
||||||
} = useFinancialData({ stockCode: propStockCode });
|
} = useFinancialData({ stockCode: propStockCode });
|
||||||
|
|
||||||
// 处理 Tab 切换
|
// 处理 Tab 切换(使用 hook 提供的带期数检查的函数)
|
||||||
const handleTabChange = useCallback((index: number, tabKey: string) => {
|
const handleTabChange = useCallback((index: number, tabKey: string) => {
|
||||||
const dataTypeKey = TAB_KEY_MAP[index] || (tabKey as DataTypeKey);
|
const dataTypeKey = TAB_KEY_MAP[index] || (tabKey as DataTypeKey);
|
||||||
setActiveTab(dataTypeKey);
|
handleTabChangeWithPeriodCheck(dataTypeKey);
|
||||||
}, [setActiveTab]);
|
}, [handleTabChangeWithPeriodCheck]);
|
||||||
|
|
||||||
// 处理刷新 - 只刷新当前 Tab
|
// 处理刷新 - 只刷新当前 Tab
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
@@ -213,6 +213,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
|||||||
componentProps={componentProps}
|
componentProps={componentProps}
|
||||||
themePreset="blackGold"
|
themePreset="blackGold"
|
||||||
isLazy
|
isLazy
|
||||||
|
size="sm"
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
rightElement={
|
rightElement={
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
|
|||||||
@@ -216,26 +216,26 @@ const BALANCE_SHEET_SECTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** 资产负债表 Tab */
|
/** 资产负债表 Tab */
|
||||||
export const BalanceSheetTab = memo<BalanceSheetTabProps>(({ balanceSheet, loading, showMetricChart }) => (
|
export const BalanceSheetTab = memo<BalanceSheetTabProps>(({ balanceSheet, loading, loadingTab, showMetricChart }) => (
|
||||||
<UnifiedFinancialTable
|
<UnifiedFinancialTable
|
||||||
type="statement"
|
type="statement"
|
||||||
data={balanceSheet as unknown as FinancialDataItem[]}
|
data={balanceSheet as unknown as FinancialDataItem[]}
|
||||||
sections={BALANCE_SHEET_SECTIONS}
|
sections={BALANCE_SHEET_SECTIONS}
|
||||||
showMetricChart={showMetricChart}
|
showMetricChart={showMetricChart}
|
||||||
loading={loading}
|
loading={loading || loadingTab === 'balance'}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
BalanceSheetTab.displayName = 'BalanceSheetTab';
|
BalanceSheetTab.displayName = 'BalanceSheetTab';
|
||||||
|
|
||||||
/** 利润表 Tab */
|
/** 利润表 Tab */
|
||||||
export const IncomeStatementTab = memo<IncomeStatementTabProps>(({ incomeStatement, loading, showMetricChart }) => (
|
export const IncomeStatementTab = memo<IncomeStatementTabProps>(({ incomeStatement, loading, loadingTab, showMetricChart }) => (
|
||||||
<UnifiedFinancialTable
|
<UnifiedFinancialTable
|
||||||
type="statement"
|
type="statement"
|
||||||
data={incomeStatement as unknown as FinancialDataItem[]}
|
data={incomeStatement as unknown as FinancialDataItem[]}
|
||||||
sections={INCOME_STATEMENT_SECTIONS}
|
sections={INCOME_STATEMENT_SECTIONS}
|
||||||
hideTotalSectionTitle={false}
|
hideTotalSectionTitle={false}
|
||||||
showMetricChart={showMetricChart}
|
showMetricChart={showMetricChart}
|
||||||
loading={loading}
|
loading={loading || loadingTab === 'income'}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
IncomeStatementTab.displayName = 'IncomeStatementTab';
|
IncomeStatementTab.displayName = 'IncomeStatementTab';
|
||||||
@@ -251,14 +251,14 @@ const CASHFLOW_SECTIONS = [{
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
/** 现金流量表 Tab */
|
/** 现金流量表 Tab */
|
||||||
export const CashflowTab = memo<CashflowTabProps>(({ cashflow, loading, showMetricChart }) => (
|
export const CashflowTab = memo<CashflowTabProps>(({ cashflow, loading, loadingTab, showMetricChart }) => (
|
||||||
<UnifiedFinancialTable
|
<UnifiedFinancialTable
|
||||||
type="statement"
|
type="statement"
|
||||||
data={cashflow as unknown as FinancialDataItem[]}
|
data={cashflow as unknown as FinancialDataItem[]}
|
||||||
sections={CASHFLOW_SECTIONS}
|
sections={CASHFLOW_SECTIONS}
|
||||||
hideTotalSectionTitle
|
hideTotalSectionTitle
|
||||||
showMetricChart={showMetricChart}
|
showMetricChart={showMetricChart}
|
||||||
loading={loading}
|
loading={loading || loadingTab === 'cashflow'}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
CashflowTab.displayName = 'CashflowTab';
|
CashflowTab.displayName = 'CashflowTab';
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ export const BLACK_GOLD_TABLE_THEME: ThemeConfig = {
|
|||||||
headerColor: '#D4AF37',
|
headerColor: '#D4AF37',
|
||||||
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
rowHoverBg: 'rgba(212, 175, 55, 0.1)',
|
||||||
borderColor: 'rgba(212, 175, 55, 0.15)',
|
borderColor: 'rgba(212, 175, 55, 0.15)',
|
||||||
cellPaddingBlock: 8,
|
cellPaddingBlock: 6,
|
||||||
cellPaddingInline: 12,
|
cellPaddingInline: 8,
|
||||||
|
// 表头紧凑样式
|
||||||
|
headerSplitColor: 'transparent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -40,6 +42,15 @@ export const getTableStyles = (className: string): string => `
|
|||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
border-bottom: 1px solid rgba(212, 175, 55, 0.3) !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
.${className} .ant-table-thead > tr {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
.${className} .ant-table-header {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
.${className} .ant-table-tbody > tr > td {
|
.${className} .ant-table-tbody > tr > td {
|
||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
border-bottom: 1px solid rgba(212, 175, 55, 0.1) !important;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -5,3 +5,4 @@ export { default as ThemedCard } from './ThemedCard';
|
|||||||
export { default as MarkdownRenderer } from './MarkdownRenderer';
|
export { default as MarkdownRenderer } from './MarkdownRenderer';
|
||||||
export { default as StockSummaryCard } from './StockSummaryCard';
|
export { default as StockSummaryCard } from './StockSummaryCard';
|
||||||
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
|
export { default as AnalysisModal, AnalysisContent } from './AnalysisModal';
|
||||||
|
export { MarketDataSkeleton } from './MarketDataSkeleton';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// K线模块 - 日K线/分时图切换展示(黑金主题 + 专业技术指标 + 商品数据叠加 + 分时盘口)
|
// K线模块 - 日K线/分时图切换展示(黑金主题 + 专业技术指标 + 商品数据叠加 + 分时盘口)
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback, memo } from 'react';
|
import React, { useState, useMemo, useCallback, memo } from 'react';
|
||||||
import { Box } from '@chakra-ui/react';
|
import { Box, HStack, Skeleton, Card, CardBody } from '@chakra-ui/react';
|
||||||
|
|
||||||
import { darkGoldTheme } from '../../../constants';
|
import { darkGoldTheme } from '../../../constants';
|
||||||
import type { IndicatorType, MainIndicatorType, DrawingType } from '../../../utils/chartOptions';
|
import type { IndicatorType, MainIndicatorType, DrawingType } from '../../../utils/chartOptions';
|
||||||
@@ -30,6 +30,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
|||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
onPeriodChange,
|
onPeriodChange,
|
||||||
stockCode,
|
stockCode,
|
||||||
|
loading = false,
|
||||||
}) => {
|
}) => {
|
||||||
// ========== 状态管理 ==========
|
// ========== 状态管理 ==========
|
||||||
const [mode, setMode] = useState<ChartMode>('daily');
|
const [mode, setMode] = useState<ChartMode>('daily');
|
||||||
@@ -127,7 +128,19 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
|||||||
|
|
||||||
{/* 图表内容区域 */}
|
{/* 图表内容区域 */}
|
||||||
<Box pt={4}>
|
<Box pt={4}>
|
||||||
{mode === 'daily' ? (
|
{/* 加载中骨架屏 */}
|
||||||
|
{loading && tradeData.length === 0 ? (
|
||||||
|
<Card bg="gray.900" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
|
<CardBody p={4}>
|
||||||
|
<Skeleton
|
||||||
|
height="600px"
|
||||||
|
borderRadius="md"
|
||||||
|
startColor="rgba(26, 32, 44, 0.6)"
|
||||||
|
endColor="rgba(212, 175, 55, 0.2)"
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : mode === 'daily' ? (
|
||||||
// 日K线图
|
// 日K线图
|
||||||
<DailyKLineChart
|
<DailyKLineChart
|
||||||
tradeData={tradeData}
|
tradeData={tradeData}
|
||||||
|
|||||||
@@ -76,15 +76,21 @@ export const useMarketData = (
|
|||||||
const riseAnalysisRes = await marketService.getRiseAnalysis(stockCode);
|
const riseAnalysisRes = await marketService.getRiseAnalysis(stockCode);
|
||||||
|
|
||||||
if (riseAnalysisRes.success && riseAnalysisRes.data) {
|
if (riseAnalysisRes.success && riseAnalysisRes.data) {
|
||||||
|
// 性能优化:使用 Map 预构建日期索引,将 O(n*m) 降为 O(n+m)
|
||||||
|
const dateToIndexMap = new Map<string, number>();
|
||||||
|
tradeDataForMapping.forEach((item, idx) => {
|
||||||
|
dateToIndexMap.set(item.date.substring(0, 10), idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 Map 查找,O(1) 复杂度
|
||||||
const tempAnalysisMap: Record<number, RiseAnalysis> = {};
|
const tempAnalysisMap: Record<number, RiseAnalysis> = {};
|
||||||
riseAnalysisRes.data.forEach((analysis) => {
|
riseAnalysisRes.data.forEach((analysis) => {
|
||||||
const dateIndex = tradeDataForMapping.findIndex(
|
const dateIndex = dateToIndexMap.get(analysis.trade_date);
|
||||||
(item) => item.date.substring(0, 10) === analysis.trade_date
|
if (dateIndex !== undefined) {
|
||||||
);
|
|
||||||
if (dateIndex !== -1) {
|
|
||||||
tempAnalysisMap[dateIndex] = analysis;
|
tempAnalysisMap[dateIndex] = analysis;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setAnalysisMap(tempAnalysisMap);
|
setAnalysisMap(tempAnalysisMap);
|
||||||
logger.info('useMarketData', '涨幅分析加载成功', { stockCode, count: Object.keys(tempAnalysisMap).length });
|
logger.info('useMarketData', '涨幅分析加载成功', { stockCode, count: Object.keys(tempAnalysisMap).length });
|
||||||
}
|
}
|
||||||
@@ -164,7 +170,7 @@ export const useMarketData = (
|
|||||||
*/
|
*/
|
||||||
const loadDataByType = useCallback(async (dataType: 'funding' | 'bigDeal' | 'unusual' | 'pledge') => {
|
const loadDataByType = useCallback(async (dataType: 'funding' | 'bigDeal' | 'unusual' | 'pledge') => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
if (loadedDataRef.current[dataType]) return; // 已加载则跳过
|
if (loadedDataRef.current[dataType]) return;
|
||||||
|
|
||||||
// 取消之前的 Tab 数据请求
|
// 取消之前的 Tab 数据请求
|
||||||
tabDataControllerRef.current?.abort();
|
tabDataControllerRef.current?.abort();
|
||||||
@@ -345,11 +351,15 @@ export const useMarketData = (
|
|||||||
}, [period, refreshTradeData, stockCode]);
|
}, [period, refreshTradeData, stockCode]);
|
||||||
|
|
||||||
// 组件卸载时取消所有进行中的请求
|
// 组件卸载时取消所有进行中的请求
|
||||||
|
// 注意:在 React StrictMode 下,组件会快速卸载再挂载
|
||||||
|
// 为避免取消正在进行的请求,这里不再自动取消
|
||||||
|
// 请求会在 loadCoreData 开头通过 coreDataControllerRef.current?.abort() 取消旧请求
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
coreDataControllerRef.current?.abort();
|
// 不再在这里取消请求,让请求自然完成
|
||||||
tabDataControllerRef.current?.abort();
|
// coreDataControllerRef.current?.abort();
|
||||||
minuteDataControllerRef.current?.abort();
|
// tabDataControllerRef.current?.abort();
|
||||||
|
// minuteDataControllerRef.current?.abort();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import React, { useState, useEffect, ReactNode, useMemo, useCallback, memo } from 'react';
|
import React, { useState, useEffect, ReactNode, useMemo, useCallback, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
Container,
|
Container,
|
||||||
VStack,
|
VStack,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
@@ -30,7 +32,6 @@ import {
|
|||||||
UnusualPanel,
|
UnusualPanel,
|
||||||
PledgePanel,
|
PledgePanel,
|
||||||
} from './components/panels';
|
} from './components/panels';
|
||||||
import LoadingState from '../LoadingState';
|
|
||||||
import type { MarketDataViewProps } from './types';
|
import type { MarketDataViewProps } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,7 +62,6 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
|||||||
minuteData,
|
minuteData,
|
||||||
minuteLoading,
|
minuteLoading,
|
||||||
analysisMap,
|
analysisMap,
|
||||||
refetch,
|
|
||||||
loadMinuteData,
|
loadMinuteData,
|
||||||
loadDataByType,
|
loadDataByType,
|
||||||
} = useMarketData(stockCode, selectedPeriod);
|
} = useMarketData(stockCode, selectedPeriod);
|
||||||
@@ -89,6 +89,14 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
|||||||
}
|
}
|
||||||
}, [propStockCode, stockCode]);
|
}, [propStockCode, stockCode]);
|
||||||
|
|
||||||
|
// 首次渲染时加载默认 Tab(融资融券)的数据
|
||||||
|
useEffect(() => {
|
||||||
|
// 默认 Tab 是融资融券(index 0)
|
||||||
|
if (activeTab === 0) {
|
||||||
|
loadDataByType('funding');
|
||||||
|
}
|
||||||
|
}, [loadDataByType, activeTab]);
|
||||||
|
|
||||||
// 处理图表点击事件
|
// 处理图表点击事件
|
||||||
const handleChartClick = useCallback(
|
const handleChartClick = useCallback(
|
||||||
(params: { seriesName?: string; data?: [number, number] }) => {
|
(params: { seriesName?: string; data?: [number, number] }) => {
|
||||||
@@ -127,47 +135,40 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
|
<Box bg={'#1A202C'} minH="100vh" color={theme.textPrimary}>
|
||||||
<Container maxW="container.xl" py={6}>
|
<Container maxW="container.xl" py={4}>
|
||||||
<VStack align="stretch" spacing={6}>
|
<VStack align="stretch" spacing={4}>
|
||||||
{/* 股票概览 */}
|
{/* 股票概览 */}
|
||||||
{summary && <StockSummaryCard summary={summary} theme={theme} />}
|
{summary && <StockSummaryCard summary={summary} theme={theme} />}
|
||||||
|
|
||||||
{/* 交易数据 - 日K/分钟K线(独立显示在 Tab 上方) */}
|
{/* 交易数据 - 日K/分钟K线(独立显示在 Tab 上方) */}
|
||||||
{!loading && (
|
<TradeDataPanel
|
||||||
<TradeDataPanel
|
theme={theme}
|
||||||
theme={theme}
|
tradeData={tradeData}
|
||||||
tradeData={tradeData}
|
minuteData={minuteData}
|
||||||
minuteData={minuteData}
|
minuteLoading={minuteLoading}
|
||||||
minuteLoading={minuteLoading}
|
analysisMap={analysisMap}
|
||||||
analysisMap={analysisMap}
|
onLoadMinuteData={loadMinuteData}
|
||||||
onLoadMinuteData={loadMinuteData}
|
onChartClick={handleChartClick}
|
||||||
onChartClick={handleChartClick}
|
selectedPeriod={selectedPeriod}
|
||||||
selectedPeriod={selectedPeriod}
|
onPeriodChange={setSelectedPeriod}
|
||||||
onPeriodChange={setSelectedPeriod}
|
stockCode={stockCode}
|
||||||
stockCode={stockCode}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 主要内容区域 - Tab */}
|
{/* 主要内容区域 - Tab */}
|
||||||
{loading ? (
|
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||||
<Box
|
<CardBody p={0}>
|
||||||
bg="gray.900"
|
<SubTabContainer
|
||||||
border="1px solid"
|
tabs={tabConfigs}
|
||||||
borderColor="rgba(212, 175, 55, 0.3)"
|
componentProps={componentProps}
|
||||||
borderRadius="xl"
|
themePreset="blackGold"
|
||||||
>
|
index={activeTab}
|
||||||
<LoadingState message="数据加载中..." height="400px" />
|
onTabChange={handleTabChange}
|
||||||
</Box>
|
isLazy
|
||||||
) : (
|
size="sm"
|
||||||
<SubTabContainer
|
/>
|
||||||
tabs={tabConfigs}
|
</CardBody>
|
||||||
componentProps={componentProps}
|
</Card>
|
||||||
themePreset="blackGold"
|
|
||||||
index={activeTab}
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
isLazy
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export interface KLineModuleProps {
|
|||||||
selectedPeriod?: number;
|
selectedPeriod?: number;
|
||||||
onPeriodChange?: (period: number) => void;
|
onPeriodChange?: (period: number) => void;
|
||||||
stockCode?: string; // 股票代码,用于获取实时盘口数据
|
stockCode?: string; // 股票代码,用于获取实时盘口数据
|
||||||
|
loading?: boolean; // 日K线数据加载中
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -889,26 +889,60 @@ export const getKLineDarkGoldOption = (
|
|||||||
const highPrices = tradeData.map((item) => item.high);
|
const highPrices = tradeData.map((item) => item.high);
|
||||||
const lowPrices = tradeData.map((item) => item.low);
|
const lowPrices = tradeData.map((item) => item.low);
|
||||||
|
|
||||||
// 计算主图指标
|
// 计算主图指标 - MA 始终计算(主图必需)
|
||||||
const ma5 = calculateMA(closePrices, 5);
|
const ma5 = calculateMA(closePrices, 5);
|
||||||
const ma10 = calculateMA(closePrices, 10);
|
const ma10 = calculateMA(closePrices, 10);
|
||||||
const ma20 = calculateMA(closePrices, 20);
|
const ma20 = calculateMA(closePrices, 20);
|
||||||
|
|
||||||
// 计算布林带(如果选择)
|
// 计算布林带(仅当选择 BOLL 时)
|
||||||
const boll = mainIndicator === 'BOLL' ? calculateBOLL(closePrices) : null;
|
const boll = mainIndicator === 'BOLL' ? calculateBOLL(closePrices) : null;
|
||||||
|
|
||||||
// 计算副图指标
|
// 副图指标 - 按需计算(性能优化:只计算当前显示的指标)
|
||||||
const macdData = calculateMACD(closePrices);
|
type MACDData = { dif: (number | null)[]; dea: (number | null)[]; macd: (number | null)[] };
|
||||||
const kdjData = calculateKDJ(highPrices, lowPrices, closePrices);
|
type KDJData = { k: (number | null)[]; d: (number | null)[]; j: (number | null)[] };
|
||||||
const rsi6 = calculateRSI(closePrices, 6);
|
type RSIData = { rsi6: (number | null)[]; rsi12: (number | null)[]; rsi24: (number | null)[] };
|
||||||
const rsi12 = calculateRSI(closePrices, 12);
|
type WRData = { wr6: (number | null)[]; wr14: (number | null)[] };
|
||||||
const rsi24 = calculateRSI(closePrices, 24);
|
type BIASData = { bias6: (number | null)[]; bias12: (number | null)[]; bias24: (number | null)[] };
|
||||||
const wr14 = calculateWR(highPrices, lowPrices, closePrices, 14);
|
|
||||||
const wr6 = calculateWR(highPrices, lowPrices, closePrices, 6);
|
let macdData: MACDData | null = null;
|
||||||
const cci14 = calculateCCI(highPrices, lowPrices, closePrices, 14);
|
let kdjData: KDJData | null = null;
|
||||||
const bias6 = calculateBIAS(closePrices, 6);
|
let rsiData: RSIData | null = null;
|
||||||
const bias12 = calculateBIAS(closePrices, 12);
|
let wrData: WRData | null = null;
|
||||||
const bias24 = calculateBIAS(closePrices, 24);
|
let cci14: (number | null)[] | null = null;
|
||||||
|
let biasData: BIASData | null = null;
|
||||||
|
|
||||||
|
// 根据 subIndicator 按需计算对应指标
|
||||||
|
switch (subIndicator) {
|
||||||
|
case 'MACD':
|
||||||
|
macdData = calculateMACD(closePrices);
|
||||||
|
break;
|
||||||
|
case 'KDJ':
|
||||||
|
kdjData = calculateKDJ(highPrices, lowPrices, closePrices);
|
||||||
|
break;
|
||||||
|
case 'RSI':
|
||||||
|
rsiData = {
|
||||||
|
rsi6: calculateRSI(closePrices, 6),
|
||||||
|
rsi12: calculateRSI(closePrices, 12),
|
||||||
|
rsi24: calculateRSI(closePrices, 24),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'WR':
|
||||||
|
wrData = {
|
||||||
|
wr6: calculateWR(highPrices, lowPrices, closePrices, 6),
|
||||||
|
wr14: calculateWR(highPrices, lowPrices, closePrices, 14),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'CCI':
|
||||||
|
cci14 = calculateCCI(highPrices, lowPrices, closePrices, 14);
|
||||||
|
break;
|
||||||
|
case 'BIAS':
|
||||||
|
biasData = {
|
||||||
|
bias6: calculateBIAS(closePrices, 6),
|
||||||
|
bias12: calculateBIAS(closePrices, 12),
|
||||||
|
bias24: calculateBIAS(closePrices, 24),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// 创建涨幅分析标记点(仅当 showAnalysis 为 true 时)
|
// 创建涨幅分析标记点(仅当 showAnalysis 为 true 时)
|
||||||
const scatterData: [number, number][] = [];
|
const scatterData: [number, number][] = [];
|
||||||
@@ -1307,8 +1341,8 @@ export const getKLineDarkGoldOption = (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加副图指标
|
// 添加副图指标(使用按需计算的数据)
|
||||||
if (subIndicator === 'MACD') {
|
if (subIndicator === 'MACD' && macdData) {
|
||||||
// MACD柱状图
|
// MACD柱状图
|
||||||
series.push({
|
series.push({
|
||||||
name: 'MACD',
|
name: 'MACD',
|
||||||
@@ -1347,7 +1381,7 @@ export const getKLineDarkGoldOption = (
|
|||||||
lineStyle: { color: cyan, width: 1 },
|
lineStyle: { color: cyan, width: 1 },
|
||||||
});
|
});
|
||||||
legendData.push('MACD', 'DIF', 'DEA');
|
legendData.push('MACD', 'DIF', 'DEA');
|
||||||
} else if (subIndicator === 'KDJ') {
|
} else if (subIndicator === 'KDJ' && kdjData) {
|
||||||
series.push(
|
series.push(
|
||||||
{
|
{
|
||||||
name: 'K',
|
name: 'K',
|
||||||
@@ -1381,14 +1415,14 @@ export const getKLineDarkGoldOption = (
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
legendData.push('K', 'D', 'J');
|
legendData.push('K', 'D', 'J');
|
||||||
} else if (subIndicator === 'RSI') {
|
} else if (subIndicator === 'RSI' && rsiData) {
|
||||||
series.push(
|
series.push(
|
||||||
{
|
{
|
||||||
name: 'RSI6',
|
name: 'RSI6',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
xAxisIndex: 2,
|
xAxisIndex: 2,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: 2,
|
||||||
data: rsi6,
|
data: rsiData.rsi6,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
lineStyle: { color: gold, width: 1 },
|
lineStyle: { color: gold, width: 1 },
|
||||||
@@ -1398,7 +1432,7 @@ export const getKLineDarkGoldOption = (
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
xAxisIndex: 2,
|
xAxisIndex: 2,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: 2,
|
||||||
data: rsi12,
|
data: rsiData.rsi12,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
lineStyle: { color: cyan, width: 1 },
|
lineStyle: { color: cyan, width: 1 },
|
||||||
@@ -1408,21 +1442,21 @@ export const getKLineDarkGoldOption = (
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
xAxisIndex: 2,
|
xAxisIndex: 2,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: 2,
|
||||||
data: rsi24,
|
data: rsiData.rsi24,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
lineStyle: { color: purple, width: 1 },
|
lineStyle: { color: purple, width: 1 },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
legendData.push('RSI6', 'RSI12', 'RSI24');
|
legendData.push('RSI6', 'RSI12', 'RSI24');
|
||||||
} else if (subIndicator === 'WR') {
|
} else if (subIndicator === 'WR' && wrData) {
|
||||||
series.push(
|
series.push(
|
||||||
{
|
{
|
||||||
name: 'WR14',
|
name: 'WR14',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
xAxisIndex: 2,
|
xAxisIndex: 2,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: 2,
|
||||||
data: wr14,
|
data: wrData.wr14,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
lineStyle: { color: gold, width: 1 },
|
lineStyle: { color: gold, width: 1 },
|
||||||
@@ -1432,14 +1466,14 @@ export const getKLineDarkGoldOption = (
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
xAxisIndex: 2,
|
xAxisIndex: 2,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: 2,
|
||||||
data: wr6,
|
data: wrData.wr6,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
lineStyle: { color: cyan, width: 1 },
|
lineStyle: { color: cyan, width: 1 },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
legendData.push('WR14', 'WR6');
|
legendData.push('WR14', 'WR6');
|
||||||
} else if (subIndicator === 'CCI') {
|
} else if (subIndicator === 'CCI' && cci14) {
|
||||||
series.push({
|
series.push({
|
||||||
name: 'CCI',
|
name: 'CCI',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@@ -1474,14 +1508,14 @@ export const getKLineDarkGoldOption = (
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
legendData.push('CCI');
|
legendData.push('CCI');
|
||||||
} else if (subIndicator === 'BIAS') {
|
} else if (subIndicator === 'BIAS' && biasData) {
|
||||||
series.push(
|
series.push(
|
||||||
{
|
{
|
||||||
name: 'BIAS6',
|
name: 'BIAS6',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
xAxisIndex: 2,
|
xAxisIndex: 2,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: 2,
|
||||||
data: bias6,
|
data: biasData.bias6,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
lineStyle: { color: gold, width: 1 },
|
lineStyle: { color: gold, width: 1 },
|
||||||
@@ -1491,7 +1525,7 @@ export const getKLineDarkGoldOption = (
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
xAxisIndex: 2,
|
xAxisIndex: 2,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: 2,
|
||||||
data: bias12,
|
data: biasData.bias12,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
lineStyle: { color: cyan, width: 1 },
|
lineStyle: { color: cyan, width: 1 },
|
||||||
@@ -1501,7 +1535,7 @@ export const getKLineDarkGoldOption = (
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
xAxisIndex: 2,
|
xAxisIndex: 2,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: 2,
|
||||||
data: bias24,
|
data: biasData.bias24,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
lineStyle: { color: purple, width: 1 },
|
lineStyle: { color: purple, width: 1 },
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import type { CompanyTheme, TabConfig } from './types';
|
|||||||
// 骨架屏组件(同步导入,用于 Suspense fallback)
|
// 骨架屏组件(同步导入,用于 Suspense fallback)
|
||||||
import { FinancialPanoramaSkeleton } from './components/FinancialPanorama/components';
|
import { FinancialPanoramaSkeleton } from './components/FinancialPanorama/components';
|
||||||
import { ForecastSkeleton } from './components/ForecastReport/components';
|
import { ForecastSkeleton } from './components/ForecastReport/components';
|
||||||
|
import { MarketDataSkeleton } from './components/MarketDataView/components';
|
||||||
|
import DynamicTrackingNavSkeleton from './components/DynamicTracking/components/DynamicTrackingNavSkeleton';
|
||||||
|
import CompanyOverviewNavSkeleton from './components/CompanyOverview/BasicInfoTab/components/CompanyOverviewNavSkeleton';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 黑金主题配置
|
// 黑金主题配置
|
||||||
@@ -75,6 +78,7 @@ export const TAB_CONFIG: TabConfig[] = [
|
|||||||
name: '公司概览',
|
name: '公司概览',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
component: CompanyOverview,
|
component: CompanyOverview,
|
||||||
|
fallback: React.createElement(CompanyOverviewNavSkeleton),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'analysis',
|
key: 'analysis',
|
||||||
@@ -87,6 +91,7 @@ export const TAB_CONFIG: TabConfig[] = [
|
|||||||
name: '股票行情',
|
name: '股票行情',
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
component: MarketDataView,
|
component: MarketDataView,
|
||||||
|
fallback: React.createElement(MarketDataSkeleton),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'financial',
|
key: 'financial',
|
||||||
@@ -107,6 +112,7 @@ export const TAB_CONFIG: TabConfig[] = [
|
|||||||
name: '动态跟踪',
|
name: '动态跟踪',
|
||||||
icon: Newspaper,
|
icon: Newspaper,
|
||||||
component: DynamicTracking,
|
component: DynamicTracking,
|
||||||
|
fallback: React.createElement(DynamicTrackingNavSkeleton),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user