refactor(MarketDataView): 提取共享组件,简化 Panel 结构

- 新增 shared 目录,提取重复组件:
  - DarkGoldCard: 黑金卡片容器
  - DarkGoldBadge: 黑金徽章组件
  - EmptyState: 空状态组件
  - styles.ts: 共享样式定义
- 简化各 Panel 组件,移除重复代码
- 优化 index.tsx componentProps 传递
- 调整 hooks/services 数据获取逻辑

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-19 13:09:20 +08:00
parent afc6d16119
commit 010ed9b5bf
13 changed files with 520 additions and 415 deletions

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx // src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx
// 大宗交易面板 - 黑金主题 // 大宗交易面板 - 黑金主题
import React from 'react'; import React, { memo } from 'react';
import { import {
Box, Box,
Text, Text,
@@ -12,73 +12,23 @@ import {
Th, Th,
Td, Td,
TableContainer, TableContainer,
Center,
VStack, VStack,
HStack, HStack,
Tooltip, Tooltip,
Heading,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { formatNumber } from '../../utils/formatUtils'; import { formatNumber } from '../../utils/formatUtils';
import { darkGoldTheme } from '../../constants'; import { darkGoldTheme } from '../../constants';
import type { Theme, BigDealData } from '../../types'; import { DarkGoldCard, DarkGoldBadge, EmptyState } from '../shared';
import type { BigDealData } from '../../types';
export interface BigDealPanelProps { export interface BigDealPanelProps {
theme: Theme;
bigDealData: BigDealData; bigDealData: BigDealData;
} }
// 黑金卡片样式
const darkGoldCardStyle = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
_hover: {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
},
};
// 黑金徽章样式
const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'gold' | 'orange' | 'green' | 'purple' }> = ({
children,
variant = 'gold',
}) => {
const colors = {
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
orange: { bg: 'rgba(255, 149, 0, 0.15)', color: darkGoldTheme.orange },
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
purple: { bg: 'rgba(160, 120, 220, 0.15)', color: '#A078DC' },
};
const style = colors[variant];
return (
<Box
px={2}
py={1}
bg={style.bg}
color={style.color}
borderRadius="md"
fontSize="xs"
fontWeight="medium"
>
{children}
</Box>
);
};
const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => { const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
return ( return (
<Box {...darkGoldCardStyle} overflow="hidden"> <DarkGoldCard title="大宗交易记录">
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.gold}>
</Heading>
</Box>
<Box p={4}>
{bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? ( {bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? (
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
{bigDealData.daily_stats.map((dayStats, idx) => ( {bigDealData.daily_stats.map((dayStats, idx) => (
@@ -175,13 +125,10 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
))} ))}
</VStack> </VStack>
) : ( ) : (
<Center h="200px"> <EmptyState message="暂无大宗交易数据" />
<Text color={darkGoldTheme.textMuted}></Text>
</Center>
)} )}
</Box> </DarkGoldCard>
</Box>
); );
}; };
export default BigDealPanel; export default memo(BigDealPanel);

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx // src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx
// 融资融券面板 - 黑金主题 // 融资融券面板 - 黑金主题
import React from 'react'; import React, { memo } from 'react';
import { import {
Box, Box,
Text, Text,
@@ -10,41 +10,27 @@ import {
Grid, Grid,
Heading, Heading,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react'; import ECharts from '@components/Charts/ECharts';
import { formatNumber } from '../../utils/formatUtils'; import { formatNumber } from '../../utils/formatUtils';
import { getFundingDarkGoldOption } from '../../utils/chartOptions'; import { getFundingDarkGoldOption } from '../../utils/chartOptions';
import { darkGoldTheme } from '../../constants'; import { darkGoldTheme } from '../../constants';
import type { Theme, FundingDayData } from '../../types'; import { DarkGoldCard } from '../shared';
import { darkGoldCardFullStyle } from '../shared/styles';
import type { FundingDayData } from '../../types';
export interface FundingPanelProps { export interface FundingPanelProps {
theme: Theme;
fundingData: FundingDayData[]; fundingData: FundingDayData[];
} }
// 黑金卡片样式
const darkGoldCardStyle = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
_hover: {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
transform: 'translateY(-2px)',
},
};
const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => { const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
return ( return (
<VStack spacing={6} align="stretch"> <VStack spacing={6} align="stretch">
{/* 图表卡片 */} {/* 图表卡片 */}
<Box {...darkGoldCardStyle} p={6}> <Box sx={darkGoldCardFullStyle} p={6}>
{fundingData.length > 0 && ( {fundingData.length > 0 && (
<Box h="400px"> <Box h="400px">
<ReactECharts <ECharts
option={getFundingDarkGoldOption(fundingData)} option={getFundingDarkGoldOption(fundingData)}
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
theme="dark" theme="dark"
@@ -55,13 +41,7 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
<Grid templateColumns="repeat(2, 1fr)" gap={6}> <Grid templateColumns="repeat(2, 1fr)" gap={6}>
{/* 融资数据 */} {/* 融资数据 */}
<Box {...darkGoldCardStyle} overflow="hidden"> <DarkGoldCard title="融资数据" titleColor={darkGoldTheme.gold}>
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.gold}>
</Heading>
</Box>
<Box p={4}>
<VStack spacing={3} align="stretch"> <VStack spacing={3} align="stretch">
{fundingData {fundingData
.slice(-5) .slice(-5)
@@ -97,17 +77,10 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
</Box> </Box>
))} ))}
</VStack> </VStack>
</Box> </DarkGoldCard>
</Box>
{/* 融券数据 */} {/* 融券数据 */}
<Box {...darkGoldCardStyle} overflow="hidden"> <DarkGoldCard title="融券数据" titleColor={darkGoldTheme.orange}>
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.orange}>
</Heading>
</Box>
<Box p={4}>
<VStack spacing={3} align="stretch"> <VStack spacing={3} align="stretch">
{fundingData {fundingData
.slice(-5) .slice(-5)
@@ -143,11 +116,10 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
</Box> </Box>
))} ))}
</VStack> </VStack>
</Box> </DarkGoldCard>
</Box>
</Grid> </Grid>
</VStack> </VStack>
); );
}; };
export default FundingPanel; export default memo(FundingPanel);

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx // src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx
// 股权质押面板 - 黑金主题 // 股权质押面板 - 黑金主题
import React from 'react'; import React, { memo } from 'react';
import { import {
Box, Box,
Text, Text,
@@ -13,42 +13,28 @@ import {
Td, Td,
TableContainer, TableContainer,
VStack, VStack,
Heading,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react'; import ECharts from '@components/Charts/ECharts';
import { formatNumber, formatPercent } from '../../utils/formatUtils'; import { formatNumber, formatPercent } from '../../utils/formatUtils';
import { getPledgeDarkGoldOption } from '../../utils/chartOptions'; import { getPledgeDarkGoldOption } from '../../utils/chartOptions';
import { darkGoldTheme } from '../../constants'; import { darkGoldTheme } from '../../constants';
import type { Theme, PledgeData } from '../../types'; import { DarkGoldCard } from '../shared';
import { darkGoldCardFullStyle } from '../shared/styles';
import type { PledgeData } from '../../types';
export interface PledgePanelProps { export interface PledgePanelProps {
theme: Theme;
pledgeData: PledgeData[]; pledgeData: PledgeData[];
} }
// 黑金卡片样式
const darkGoldCardStyle = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
_hover: {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
},
};
const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => { const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
return ( return (
<VStack spacing={6} align="stretch"> <VStack spacing={6} align="stretch">
{/* 图表卡片 */} {/* 图表卡片 */}
<Box {...darkGoldCardStyle} p={6}> <Box sx={darkGoldCardFullStyle} p={6}>
{pledgeData.length > 0 && ( {pledgeData.length > 0 && (
<Box h="400px"> <Box h="400px">
<ReactECharts <ECharts
option={getPledgeDarkGoldOption(pledgeData)} option={getPledgeDarkGoldOption(pledgeData)}
style={{ height: '100%', width: '100%' }} style={{ height: '100%', width: '100%' }}
theme="dark" theme="dark"
@@ -58,13 +44,7 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
</Box> </Box>
{/* 质押明细表格 */} {/* 质押明细表格 */}
<Box {...darkGoldCardStyle} overflow="hidden"> <DarkGoldCard title="质押明细">
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.gold}>
</Heading>
</Box>
<Box p={4}>
<TableContainer> <TableContainer>
<Table variant="unstyled" size="sm"> <Table variant="unstyled" size="sm">
<Thead> <Thead>
@@ -132,10 +112,9 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
</Tbody> </Tbody>
</Table> </Table>
</TableContainer> </TableContainer>
</Box> </DarkGoldCard>
</Box>
</VStack> </VStack>
); );
}; };
export default PledgePanel; export default memo(PledgePanel);

View File

@@ -1,76 +1,27 @@
// src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx // src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx
// 龙虎榜面板 - 黑金主题 // 龙虎榜面板 - 黑金主题
import React from 'react'; import React, { memo } from 'react';
import { import {
Box, Box,
Text, Text,
Center,
VStack, VStack,
HStack, HStack,
Grid, Grid,
Heading,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { formatNumber } from '../../utils/formatUtils'; import { formatNumber } from '../../utils/formatUtils';
import { darkGoldTheme } from '../../constants'; import { darkGoldTheme } from '../../constants';
import type { Theme, UnusualData } from '../../types'; import { DarkGoldCard, DarkGoldBadge, EmptyState } from '../shared';
import type { UnusualData } from '../../types';
export interface UnusualPanelProps { export interface UnusualPanelProps {
theme: Theme;
unusualData: UnusualData; unusualData: UnusualData;
} }
// 黑金卡片样式
const darkGoldCardStyle = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
_hover: {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
},
};
// 黑金徽章样式
const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'red' | 'green' | 'gold' }> = ({
children,
variant = 'gold',
}) => {
const colors = {
red: { bg: 'rgba(255, 68, 68, 0.15)', color: darkGoldTheme.red },
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
};
const style = colors[variant];
return (
<Box
px={2}
py={1}
bg={style.bg}
color={style.color}
borderRadius="md"
fontSize="xs"
fontWeight="medium"
>
{children}
</Box>
);
};
const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => { const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
return ( return (
<Box {...darkGoldCardStyle} overflow="hidden"> <DarkGoldCard title="龙虎榜数据">
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={darkGoldTheme.gold}>
</Heading>
</Box>
<Box p={4}>
{unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? ( {unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? (
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
{unusualData.grouped_data.map((dayData, idx) => ( {unusualData.grouped_data.map((dayData, idx) => (
@@ -208,13 +159,10 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
))} ))}
</VStack> </VStack>
) : ( ) : (
<Center h="200px"> <EmptyState message="暂无龙虎榜数据" />
<Text color={darkGoldTheme.textMuted}></Text>
</Center>
)} )}
</Box> </DarkGoldCard>
</Box>
); );
}; };
export default UnusualPanel; export default memo(UnusualPanel);

View File

@@ -0,0 +1,51 @@
// src/views/Company/components/MarketDataView/components/shared/DarkGoldBadge.tsx
// 黑金主题徽章组件
import React, { memo } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
import { darkGoldTheme } from '../../constants';
export type DarkGoldBadgeVariant = 'gold' | 'orange' | 'green' | 'red' | 'purple';
export interface DarkGoldBadgeProps extends Omit<BoxProps, 'children'> {
children: React.ReactNode;
variant?: DarkGoldBadgeVariant;
}
// 徽章颜色配置
const BADGE_COLORS: Record<DarkGoldBadgeVariant, { bg: string; color: string }> = {
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
orange: { bg: 'rgba(255, 149, 0, 0.15)', color: darkGoldTheme.orange },
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
red: { bg: 'rgba(255, 68, 68, 0.15)', color: darkGoldTheme.red },
purple: { bg: 'rgba(160, 120, 220, 0.15)', color: '#A078DC' },
};
/**
* 黑金主题徽章组件
* 用于显示标签、状态等信息
*/
const DarkGoldBadge: React.FC<DarkGoldBadgeProps> = ({
children,
variant = 'gold',
...boxProps
}) => {
const colors = BADGE_COLORS[variant];
return (
<Box
px={2}
py={1}
bg={colors.bg}
color={colors.color}
borderRadius="md"
fontSize="xs"
fontWeight="medium"
{...boxProps}
>
{children}
</Box>
);
};
export default memo(DarkGoldBadge);

View File

@@ -0,0 +1,47 @@
// src/views/Company/components/MarketDataView/components/shared/DarkGoldCard.tsx
// 黑金主题卡片组件
import React, { memo } from 'react';
import { Box, Heading, BoxProps } from '@chakra-ui/react';
import { darkGoldTheme } from '../../constants';
import { darkGoldCardFullStyle } from './styles';
export interface DarkGoldCardProps extends Omit<BoxProps, 'title'> {
/** 卡片标题 */
title?: string;
/** 标题颜色 */
titleColor?: string;
/** 是否显示标题区域 */
showHeader?: boolean;
/** 内容区 padding */
contentPadding?: number | string;
children: React.ReactNode;
}
/**
* 黑金主题卡片组件
* 统一的卡片样式,包含标题区和内容区
*/
const DarkGoldCard: React.FC<DarkGoldCardProps> = ({
title,
titleColor = darkGoldTheme.gold,
showHeader = true,
contentPadding = 4,
children,
...boxProps
}) => {
return (
<Box sx={darkGoldCardFullStyle} overflow="hidden" {...boxProps}>
{showHeader && title && (
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
<Heading size="md" color={titleColor}>
{title}
</Heading>
</Box>
)}
<Box p={contentPadding}>{children}</Box>
</Box>
);
};
export default memo(DarkGoldCard);

View File

@@ -0,0 +1,28 @@
// src/views/Company/components/MarketDataView/components/shared/EmptyState.tsx
// 空状态组件
import React, { memo } from 'react';
import { Center, Text, CenterProps } from '@chakra-ui/react';
import { darkGoldTheme } from '../../constants';
export interface EmptyStateProps extends Omit<CenterProps, 'children'> {
message?: string;
}
/**
* 空状态组件
* 数据为空时显示的占位组件
*/
const EmptyState: React.FC<EmptyStateProps> = ({
message = '暂无数据',
h = '200px',
...centerProps
}) => {
return (
<Center h={h} {...centerProps}>
<Text color={darkGoldTheme.textMuted}>{message}</Text>
</Center>
);
};
export default memo(EmptyState);

View File

@@ -0,0 +1,8 @@
// src/views/Company/components/MarketDataView/components/shared/index.ts
// 共享组件和样式导出
export { default as DarkGoldCard } from './DarkGoldCard';
export { default as DarkGoldBadge } from './DarkGoldBadge';
export { default as EmptyState } from './EmptyState';
export { darkGoldCardStyle, darkGoldCardHoverStyle } from './styles';
export type { DarkGoldBadgeVariant } from './DarkGoldBadge';

View File

@@ -0,0 +1,59 @@
// src/views/Company/components/MarketDataView/components/shared/styles.ts
// 共享样式常量
import { darkGoldTheme } from '../../constants';
import type { SystemStyleObject } from '@chakra-ui/react';
/**
* 黑金卡片基础样式
*/
export const darkGoldCardStyle: SystemStyleObject = {
bg: darkGoldTheme.bgCard,
border: '1px solid',
borderColor: darkGoldTheme.border,
borderRadius: 'xl',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
transition: 'all 0.3s ease',
};
/**
* 黑金卡片悬停样式
*/
export const darkGoldCardHoverStyle: SystemStyleObject = {
borderColor: darkGoldTheme.borderHover,
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
transform: 'translateY(-2px)',
};
/**
* 黑金卡片完整样式(包含 hover
*/
export const darkGoldCardFullStyle: SystemStyleObject = {
...darkGoldCardStyle,
_hover: darkGoldCardHoverStyle,
};
/**
* 数据项行样式
*/
export const dataRowStyle: SystemStyleObject = {
p: 3,
borderRadius: 'md',
border: '1px solid',
transition: 'all 0.2s',
};
/**
* 表格行悬停样式
*/
export const tableRowHoverStyle: SystemStyleObject = {
bg: 'rgba(212, 175, 55, 0.08)',
};
/**
* 表格边框样式
*/
export const tableBorderStyle: SystemStyleObject = {
borderBottom: '1px solid',
borderColor: 'rgba(212, 175, 55, 0.1)',
};

View File

@@ -2,6 +2,7 @@
// MarketDataView 数据获取 Hook // MarketDataView 数据获取 Hook
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import { marketService } from '../services/marketService'; import { marketService } from '../services/marketService';
import { DEFAULT_PERIOD } from '../constants'; import { DEFAULT_PERIOD } from '../constants';
@@ -17,6 +18,11 @@ import type {
UseMarketDataReturn, UseMarketDataReturn,
} from '../types'; } from '../types';
// 判断是否为取消请求的错误
const isCancelError = (error: unknown): boolean => {
return axios.isCancel(error) || (error instanceof Error && error.name === 'CanceledError');
};
/** /**
* 市场数据获取 Hook * 市场数据获取 Hook
* @param stockCode 股票代码 * @param stockCode 股票代码
@@ -51,6 +57,11 @@ export const useMarketData = (
// 记录上一次的 period用于判断是否需要刷新交易数据 // 记录上一次的 period用于判断是否需要刷新交易数据
const prevPeriodRef = useRef(period); const prevPeriodRef = useRef(period);
// AbortController refs - 用于取消请求
const coreDataControllerRef = useRef<AbortController | null>(null);
const tabDataControllerRef = useRef<AbortController | null>(null);
const minuteDataControllerRef = useRef<AbortController | null>(null);
/** /**
* 加载涨幅分析数据(懒加载) * 加载涨幅分析数据(懒加载)
* 需要 tradeData 来建立日期索引映射 * 需要 tradeData 来建立日期索引映射
@@ -100,14 +111,20 @@ export const useMarketData = (
const loadCoreData = useCallback(async () => { const loadCoreData = useCallback(async () => {
if (!stockCode) return; if (!stockCode) return;
// 取消之前的核心数据请求
coreDataControllerRef.current?.abort();
const controller = new AbortController();
coreDataControllerRef.current = controller;
const options = { signal: controller.signal };
logger.debug('useMarketData', '开始加载核心市场数据', { stockCode, period }); logger.debug('useMarketData', '开始加载核心市场数据', { stockCode, period });
setLoading(true); setLoading(true);
setAnalysisMap({}); // 清空旧的分析数据 setAnalysisMap({}); // 清空旧的分析数据
try { try {
const [summaryRes, tradeRes] = await Promise.all([ const [summaryRes, tradeRes] = await Promise.all([
marketService.getMarketSummary(stockCode), marketService.getMarketSummary(stockCode, options),
marketService.getTradeData(stockCode, period), marketService.getTradeData(stockCode, period, options),
]); ]);
// 设置概览数据 // 设置概览数据
@@ -131,10 +148,15 @@ export const useMarketData = (
loadRiseAnalysis(loadedTradeData); loadRiseAnalysis(loadedTradeData);
} }
} catch (error) { } catch (error) {
// 取消请求不作为错误处理
if (isCancelError(error)) return;
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period }); logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
} finally { } finally {
// 只有当前请求没有被取消时才设置 loading 状态
if (!controller.signal.aborted) {
setLoading(false); setLoading(false);
} }
}
}, [stockCode, period, loadRiseAnalysis]); }, [stockCode, period, loadRiseAnalysis]);
/** /**
@@ -144,12 +166,18 @@ export const useMarketData = (
if (!stockCode) return; if (!stockCode) return;
if (loadedDataRef.current[dataType]) return; // 已加载则跳过 if (loadedDataRef.current[dataType]) return; // 已加载则跳过
// 取消之前的 Tab 数据请求
tabDataControllerRef.current?.abort();
const controller = new AbortController();
tabDataControllerRef.current = controller;
const options = { signal: controller.signal };
logger.debug('useMarketData', `按需加载 ${dataType} 数据`, { stockCode }); logger.debug('useMarketData', `按需加载 ${dataType} 数据`, { stockCode });
try { try {
switch (dataType) { switch (dataType) {
case 'funding': { case 'funding': {
const res = await marketService.getFundingData(stockCode, 30); const res = await marketService.getFundingData(stockCode, 30, options);
if (res.success) { if (res.success) {
setFundingData(res.data); setFundingData(res.data);
loadedDataRef.current.funding = true; loadedDataRef.current.funding = true;
@@ -157,7 +185,7 @@ export const useMarketData = (
break; break;
} }
case 'bigDeal': { case 'bigDeal': {
const res = await marketService.getBigDealData(stockCode, 30); const res = await marketService.getBigDealData(stockCode, 30, options);
if (res.success) { if (res.success) {
setBigDealData(res); setBigDealData(res);
loadedDataRef.current.bigDeal = true; loadedDataRef.current.bigDeal = true;
@@ -165,7 +193,7 @@ export const useMarketData = (
break; break;
} }
case 'unusual': { case 'unusual': {
const res = await marketService.getUnusualData(stockCode, 30); const res = await marketService.getUnusualData(stockCode, 30, options);
if (res.success) { if (res.success) {
setUnusualData(res); setUnusualData(res);
loadedDataRef.current.unusual = true; loadedDataRef.current.unusual = true;
@@ -173,7 +201,7 @@ export const useMarketData = (
break; break;
} }
case 'pledge': { case 'pledge': {
const res = await marketService.getPledgeData(stockCode); const res = await marketService.getPledgeData(stockCode, options);
if (res.success) { if (res.success) {
setPledgeData(res.data); setPledgeData(res.data);
loadedDataRef.current.pledge = true; loadedDataRef.current.pledge = true;
@@ -183,6 +211,8 @@ export const useMarketData = (
} }
logger.info('useMarketData', `${dataType} 数据加载成功`, { stockCode }); logger.info('useMarketData', `${dataType} 数据加载成功`, { stockCode });
} catch (error) { } catch (error) {
// 取消请求不作为错误处理
if (isCancelError(error)) return;
logger.error('useMarketData', `loadDataByType:${dataType}`, error, { stockCode }); logger.error('useMarketData', `loadDataByType:${dataType}`, error, { stockCode });
} }
}, [stockCode]); }, [stockCode]);
@@ -200,11 +230,17 @@ export const useMarketData = (
const loadMinuteData = useCallback(async () => { const loadMinuteData = useCallback(async () => {
if (!stockCode) return; if (!stockCode) return;
// 取消之前的分钟数据请求
minuteDataControllerRef.current?.abort();
const controller = new AbortController();
minuteDataControllerRef.current = controller;
const options = { signal: controller.signal };
logger.debug('useMarketData', '开始加载分钟频数据', { stockCode }); logger.debug('useMarketData', '开始加载分钟频数据', { stockCode });
setMinuteLoading(true); setMinuteLoading(true);
try { try {
const data = await marketService.getMinuteData(stockCode); const data = await marketService.getMinuteData(stockCode, options);
setMinuteData(data); setMinuteData(data);
if (data.data && data.data.length > 0) { if (data.data && data.data.length > 0) {
@@ -216,6 +252,8 @@ export const useMarketData = (
logger.warn('useMarketData', '分钟频数据为空', { stockCode }); logger.warn('useMarketData', '分钟频数据为空', { stockCode });
} }
} catch (error) { } catch (error) {
// 取消请求不作为错误处理
if (isCancelError(error)) return;
logger.error('useMarketData', 'loadMinuteData', error, { stockCode }); logger.error('useMarketData', 'loadMinuteData', error, { stockCode });
setMinuteData({ setMinuteData({
data: [], data: [],
@@ -225,8 +263,11 @@ export const useMarketData = (
type: 'minute', type: 'minute',
}); });
} finally { } finally {
// 只有当前请求没有被取消时才设置 loading 状态
if (!controller.signal.aborted) {
setMinuteLoading(false); setMinuteLoading(false);
} }
}
}, [stockCode]); }, [stockCode]);
/** /**
@@ -303,6 +344,15 @@ export const useMarketData = (
} }
}, [period, refreshTradeData, stockCode]); }, [period, refreshTradeData, stockCode]);
// 组件卸载时取消所有进行中的请求
useEffect(() => {
return () => {
coreDataControllerRef.current?.abort();
tabDataControllerRef.current?.abort();
minuteDataControllerRef.current?.abort();
};
}, []);
return { return {
loading, loading,
tradeLoading, tradeLoading,

View File

@@ -1,19 +1,14 @@
// src/views/Company/components/MarketDataView/index.tsx // src/views/Company/components/MarketDataView/index.tsx
// MarketDataView 主组件 - 股票市场数据综合展示 // MarketDataView 主组件 - 股票市场数据综合展示
import React, { useState, useEffect, ReactNode, useMemo, useCallback } from 'react'; import React, { useState, useEffect, ReactNode, useMemo, useCallback, memo } from 'react';
import { import {
Box, Box,
Container, Container,
VStack, VStack,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { import { Unlock, ArrowUp, Star, Lock } from 'lucide-react';
Unlock,
ArrowUp,
Star,
Lock,
} from 'lucide-react';
// 通用组件 // 通用组件
import SubTabContainer from '@components/SubTabContainer'; import SubTabContainer from '@components/SubTabContainer';
@@ -36,7 +31,7 @@ import {
PledgePanel, PledgePanel,
} from './components/panels'; } from './components/panels';
import LoadingState from '../LoadingState'; import LoadingState from '../LoadingState';
import type { MarketDataViewProps, RiseAnalysis } from './types'; import type { MarketDataViewProps } from './types';
/** /**
* MarketDataView 主组件 * MarketDataView 主组件
@@ -118,37 +113,16 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
{ key: 'pledge', name: '股权质押', icon: Lock, component: PledgePanel }, { key: 'pledge', name: '股权质押', icon: Lock, component: PledgePanel },
]; ];
// 传递给 Tab 组件的 props // 传递给 Tab 组件的 props - 只传递各 Tab 需要的数据
const componentProps = useMemo( const componentProps = useMemo(
() => ({ () => ({
theme, // 各 Tab 只使用自己需要的数据
tradeData,
minuteData,
minuteLoading,
analysisMap,
onLoadMinuteData: loadMinuteData,
onChartClick: handleChartClick,
selectedPeriod,
onPeriodChange: setSelectedPeriod,
fundingData, fundingData,
bigDealData, bigDealData,
unusualData, unusualData,
pledgeData, pledgeData,
}), }),
[ [fundingData, bigDealData, unusualData, pledgeData]
theme,
tradeData,
minuteData,
minuteLoading,
analysisMap,
loadMinuteData,
handleChartClick,
selectedPeriod,
fundingData,
bigDealData,
unusualData,
pledgeData,
]
); );
return ( return (
@@ -203,4 +177,4 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
); );
}; };
export default MarketDataView; export default memo(MarketDataView);

View File

@@ -23,6 +23,13 @@ interface ApiResponse<T> {
message?: string; message?: string;
} }
/**
* 请求选项
*/
interface RequestOptions {
signal?: AbortSignal;
}
/** /**
* 市场数据服务 * 市场数据服务
*/ */
@@ -30,9 +37,13 @@ export const marketService = {
/** /**
* 获取市场概览数据 * 获取市场概览数据
* @param stockCode 股票代码 * @param stockCode 股票代码
* @param options 请求选项
*/ */
async getMarketSummary(stockCode: string): Promise<ApiResponse<MarketSummary>> { async getMarketSummary(stockCode: string, options?: RequestOptions): Promise<ApiResponse<MarketSummary>> {
const { data } = await axios.get<ApiResponse<MarketSummary>>(`/api/market/summary/${stockCode}`); const { data } = await axios.get<ApiResponse<MarketSummary>>(
`/api/market/summary/${stockCode}`,
{ signal: options?.signal }
);
return data; return data;
}, },
@@ -40,9 +51,13 @@ export const marketService = {
* 获取交易日数据 * 获取交易日数据
* @param stockCode 股票代码 * @param stockCode 股票代码
* @param days 天数,默认 60 天 * @param days 天数,默认 60 天
* @param options 请求选项
*/ */
async getTradeData(stockCode: string, days: number = 60): Promise<ApiResponse<TradeDayData[]>> { async getTradeData(stockCode: string, days: number = 60, options?: RequestOptions): Promise<ApiResponse<TradeDayData[]>> {
const { data } = await axios.get<ApiResponse<TradeDayData[]>>(`/api/market/trade/${stockCode}?days=${days}`); const { data } = await axios.get<ApiResponse<TradeDayData[]>>(
`/api/market/trade/${stockCode}?days=${days}`,
{ signal: options?.signal }
);
return data; return data;
}, },
@@ -50,9 +65,13 @@ export const marketService = {
* 获取融资融券数据 * 获取融资融券数据
* @param stockCode 股票代码 * @param stockCode 股票代码
* @param days 天数,默认 30 天 * @param days 天数,默认 30 天
* @param options 请求选项
*/ */
async getFundingData(stockCode: string, days: number = 30): Promise<ApiResponse<FundingDayData[]>> { async getFundingData(stockCode: string, days: number = 30, options?: RequestOptions): Promise<ApiResponse<FundingDayData[]>> {
const { data } = await axios.get<ApiResponse<FundingDayData[]>>(`/api/market/funding/${stockCode}?days=${days}`); const { data } = await axios.get<ApiResponse<FundingDayData[]>>(
`/api/market/funding/${stockCode}?days=${days}`,
{ signal: options?.signal }
);
return data; return data;
}, },
@@ -60,9 +79,13 @@ export const marketService = {
* 获取大宗交易数据 * 获取大宗交易数据
* @param stockCode 股票代码 * @param stockCode 股票代码
* @param days 天数,默认 30 天 * @param days 天数,默认 30 天
* @param options 请求选项
*/ */
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> { async getBigDealData(stockCode: string, days: number = 30, options?: RequestOptions): Promise<BigDealData> {
const { data } = await axios.get<BigDealData>(`/api/market/bigdeal/${stockCode}?days=${days}`); const { data } = await axios.get<BigDealData>(
`/api/market/bigdeal/${stockCode}?days=${days}`,
{ signal: options?.signal }
);
return data; return data;
}, },
@@ -70,18 +93,26 @@ export const marketService = {
* 获取龙虎榜数据 * 获取龙虎榜数据
* @param stockCode 股票代码 * @param stockCode 股票代码
* @param days 天数,默认 30 天 * @param days 天数,默认 30 天
* @param options 请求选项
*/ */
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> { async getUnusualData(stockCode: string, days: number = 30, options?: RequestOptions): Promise<UnusualData> {
const { data } = await axios.get<UnusualData>(`/api/market/unusual/${stockCode}?days=${days}`); const { data } = await axios.get<UnusualData>(
`/api/market/unusual/${stockCode}?days=${days}`,
{ signal: options?.signal }
);
return data; return data;
}, },
/** /**
* 获取股权质押数据 * 获取股权质押数据
* @param stockCode 股票代码 * @param stockCode 股票代码
* @param options 请求选项
*/ */
async getPledgeData(stockCode: string): Promise<ApiResponse<PledgeData[]>> { async getPledgeData(stockCode: string, options?: RequestOptions): Promise<ApiResponse<PledgeData[]>> {
const { data } = await axios.get<ApiResponse<PledgeData[]>>(`/api/market/pledge/${stockCode}`); const { data } = await axios.get<ApiResponse<PledgeData[]>>(
`/api/market/pledge/${stockCode}`,
{ signal: options?.signal }
);
return data; return data;
}, },
@@ -90,27 +121,33 @@ export const marketService = {
* @param stockCode 股票代码 * @param stockCode 股票代码
* @param startDate 开始日期(可选) * @param startDate 开始日期(可选)
* @param endDate 结束日期(可选) * @param endDate 结束日期(可选)
* @param options 请求选项
*/ */
async getRiseAnalysis( async getRiseAnalysis(
stockCode: string, stockCode: string,
startDate?: string, startDate?: string,
endDate?: string endDate?: string,
options?: RequestOptions
): Promise<ApiResponse<RiseAnalysis[]>> { ): Promise<ApiResponse<RiseAnalysis[]>> {
let url = `/api/market/rise-analysis/${stockCode}`; let url = `/api/market/rise-analysis/${stockCode}`;
if (startDate && endDate) { if (startDate && endDate) {
url += `?start_date=${startDate}&end_date=${endDate}`; url += `?start_date=${startDate}&end_date=${endDate}`;
} }
const { data } = await axios.get<ApiResponse<RiseAnalysis[]>>(url); const { data } = await axios.get<ApiResponse<RiseAnalysis[]>>(url, { signal: options?.signal });
return data; return data;
}, },
/** /**
* 获取分钟K线数据 * 获取分钟K线数据
* @param stockCode 股票代码 * @param stockCode 股票代码
* @param options 请求选项
*/ */
async getMinuteData(stockCode: string): Promise<MinuteData> { async getMinuteData(stockCode: string, options?: RequestOptions): Promise<MinuteData> {
try { try {
const { data } = await axios.get<MinuteData>(`/api/stock/${stockCode}/latest-minute`); const { data } = await axios.get<MinuteData>(
`/api/stock/${stockCode}/latest-minute`,
{ signal: options?.signal }
);
if (data.data && Array.isArray(data.data)) { if (data.data && Array.isArray(data.data)) {
return data; return data;
@@ -125,6 +162,10 @@ export const marketService = {
type: 'minute', type: 'minute',
}; };
} catch (error) { } catch (error) {
// 取消请求不作为错误处理
if (axios.isCancel(error)) {
throw error;
}
logger.error('marketService', 'getMinuteData', error, { stockCode }); logger.error('marketService', 'getMinuteData', error, { stockCode });
// 返回空数据结构 // 返回空数据结构
return { return {

View File

@@ -927,7 +927,8 @@ export const getKLineDarkGoldOption = (
// 布局配置(优化比例) // 布局配置(优化比例)
// 主图: 55%, 成交量: 12%, 副图指标: 18%(如有) // 主图: 55%, 成交量: 12%, 副图指标: 18%(如有)
const grids: EChartsOption['grid'] = [ // 注意:使用 object[] 而非 EChartsOption['grid'],因为后者可能是单个对象或数组
const grids: object[] = [
{ {
left: '3%', left: '3%',
right: '3%', right: '3%',
@@ -956,7 +957,7 @@ export const getKLineDarkGoldOption = (
} }
// X轴配置使用 boundaryGap: true 确保柱状图对齐) // X轴配置使用 boundaryGap: true 确保柱状图对齐)
const xAxes: EChartsOption['xAxis'] = [ const xAxes: object[] = [
{ {
type: 'category', type: 'category',
data: dates, data: dates,
@@ -978,7 +979,7 @@ export const getKLineDarkGoldOption = (
]; ];
// Y轴配置 // Y轴配置
const yAxes: EChartsOption['yAxis'] = [ const yAxes: object[] = [
{ {
scale: true, scale: true,
splitLine: { splitLine: {
@@ -1144,7 +1145,7 @@ export const getKLineDarkGoldOption = (
} }
// 构建系列数据 // 构建系列数据
const series: EChartsOption['series'] = [ const series: object[] = [
{ {
name: 'K线', name: 'K线',
type: 'candlestick', type: 'candlestick',