fix(types): 修复 ECharts 类型导出和组件类型冲突
- echarts.ts: 将 EChartsOption 改为 EChartsCoreOption 的类型别名 - FuiCorners: 移除 extends BoxProps,position 重命名为 corner - KLineChartModal/TimelineChartModal/ConcentrationCard: 使用导入的 EChartsOption - LoadingState: 新增骨架屏 variant 支持 - FinancialPanorama: 使用骨架屏加载状态 - useFinancialData/financialService: 优化数据获取逻辑 - Company/index: 简化组件结构 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,8 @@ export interface FuiCornersProps {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CornerBoxProps extends BoxProps {
|
interface CornerBoxProps {
|
||||||
position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
size: number;
|
size: number;
|
||||||
borderWidth: number;
|
borderWidth: number;
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
@@ -29,14 +29,14 @@ interface CornerBoxProps extends BoxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CornerBox = memo<CornerBoxProps>(({
|
const CornerBox = memo<CornerBoxProps>(({
|
||||||
position,
|
corner,
|
||||||
size,
|
size,
|
||||||
borderWidth,
|
borderWidth,
|
||||||
borderColor,
|
borderColor,
|
||||||
opacity,
|
opacity,
|
||||||
offset,
|
offset,
|
||||||
}) => {
|
}) => {
|
||||||
const positionStyles: Record<string, BoxProps> = {
|
const cornerStyles: Record<string, BoxProps> = {
|
||||||
'top-left': {
|
'top-left': {
|
||||||
top: `${offset}px`,
|
top: `${offset}px`,
|
||||||
left: `${offset}px`,
|
left: `${offset}px`,
|
||||||
@@ -71,7 +71,7 @@ const CornerBox = memo<CornerBoxProps>(({
|
|||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
opacity={opacity}
|
opacity={opacity}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
{...positionStyles[position]}
|
{...cornerStyles[corner]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -97,7 +97,7 @@ const FuiCorners = memo<FuiCornersProps>(({
|
|||||||
opacity = 0.6,
|
opacity = 0.6,
|
||||||
offset = 12,
|
offset = 12,
|
||||||
}) => {
|
}) => {
|
||||||
const positions: CornerBoxProps['position'][] = [
|
const corners: CornerBoxProps['corner'][] = [
|
||||||
'top-left',
|
'top-left',
|
||||||
'top-right',
|
'top-right',
|
||||||
'bottom-left',
|
'bottom-left',
|
||||||
@@ -106,10 +106,10 @@ const FuiCorners = memo<FuiCornersProps>(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{positions.map((position) => (
|
{corners.map((corner) => (
|
||||||
<CornerBox
|
<CornerBox
|
||||||
key={position}
|
key={corner}
|
||||||
position={position}
|
corner={corner}
|
||||||
size={size}
|
size={size}
|
||||||
borderWidth={borderWidth}
|
borderWidth={borderWidth}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { echarts } from '@lib/echarts';
|
import { echarts } from '@lib/echarts';
|
||||||
import type { ECharts } from '@lib/echarts';
|
import type { ECharts, EChartsOption } from '@lib/echarts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { stockService } from '@services/eventService';
|
import { stockService } from '@services/eventService';
|
||||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||||
@@ -296,7 +296,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 图表配置(H5 响应式)
|
// 图表配置(H5 响应式)
|
||||||
const option: echarts.EChartsOption = {
|
const option: EChartsOption = {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
title: {
|
title: {
|
||||||
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
|
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 图表配置(H5 响应式)
|
// 图表配置(H5 响应式)
|
||||||
const option: echarts.EChartsOption = {
|
const option: EChartsOption = {
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
title: {
|
title: {
|
||||||
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
|
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { CanvasRenderer } from 'echarts/renderers';
|
|||||||
// 类型导出
|
// 类型导出
|
||||||
import type {
|
import type {
|
||||||
ECharts,
|
ECharts,
|
||||||
EChartsOption,
|
EChartsCoreOption,
|
||||||
SetOptionOpts,
|
SetOptionOpts,
|
||||||
ComposeOption,
|
ComposeOption,
|
||||||
} from 'echarts/core';
|
} from 'echarts/core';
|
||||||
@@ -114,7 +114,11 @@ export type ECOption = ComposeOption<
|
|||||||
|
|
||||||
// 导出
|
// 导出
|
||||||
export { echarts };
|
export { echarts };
|
||||||
export type { ECharts, EChartsOption, SetOptionOpts };
|
|
||||||
|
// EChartsOption 类型别名(兼容旧代码)
|
||||||
|
export type EChartsOption = EChartsCoreOption;
|
||||||
|
|
||||||
|
export type { ECharts, SetOptionOpts };
|
||||||
|
|
||||||
// 默认导出(兼容 import * as echarts from 'echarts' 的用法)
|
// 默认导出(兼容 import * as echarts from 'echarts' 的用法)
|
||||||
export default echarts;
|
export default echarts;
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ import axios from '@utils/axiosConfig';
|
|||||||
/**
|
/**
|
||||||
* 统一的 API 请求函数
|
* 统一的 API 请求函数
|
||||||
* axios 拦截器已自动处理日志记录
|
* axios 拦截器已自动处理日志记录
|
||||||
|
* @param {string} url - 请求 URL
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号,用于取消请求
|
||||||
*/
|
*/
|
||||||
const apiRequest = async (url, options = {}) => {
|
const apiRequest = async (url, options = {}) => {
|
||||||
const { method = 'GET', body, ...rest } = options;
|
const { method = 'GET', body, signal, ...rest } = options;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
|
signal,
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,80 +40,98 @@ export const financialService = {
|
|||||||
/**
|
/**
|
||||||
* 获取股票基本信息和最新财务摘要
|
* 获取股票基本信息和最新财务摘要
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getStockInfo: async (seccode) => {
|
getStockInfo: async (seccode, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/stock-info/${seccode}`);
|
return await apiRequest(`/api/financial/stock-info/${seccode}`, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取完整的资产负债表数据
|
* 获取完整的资产负债表数据
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
* @param {number} limit - 获取的报告期数量
|
* @param {number} limit - 获取的报告期数量
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getBalanceSheet: async (seccode, limit = 12) => {
|
getBalanceSheet: async (seccode, limit = 12, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`);
|
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取完整的利润表数据
|
* 获取完整的利润表数据
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
* @param {number} limit - 获取的报告期数量
|
* @param {number} limit - 获取的报告期数量
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getIncomeStatement: async (seccode, limit = 12) => {
|
getIncomeStatement: async (seccode, limit = 12, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`);
|
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取完整的现金流量表数据
|
* 获取完整的现金流量表数据
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
* @param {number} limit - 获取的报告期数量
|
* @param {number} limit - 获取的报告期数量
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getCashflow: async (seccode, limit = 12) => {
|
getCashflow: async (seccode, limit = 12, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`);
|
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取完整的财务指标数据
|
* 获取完整的财务指标数据
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
* @param {number} limit - 获取的报告期数量
|
* @param {number} limit - 获取的报告期数量
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getFinancialMetrics: async (seccode, limit = 12) => {
|
getFinancialMetrics: async (seccode, limit = 12, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`);
|
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取主营业务构成数据
|
* 获取主营业务构成数据
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
* @param {number} periods - 获取的报告期数量
|
* @param {number} periods - 获取的报告期数量
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getMainBusiness: async (seccode, periods = 4) => {
|
getMainBusiness: async (seccode, periods = 4, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`);
|
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取业绩预告和预披露时间
|
* 获取业绩预告和预披露时间
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getForecast: async (seccode) => {
|
getForecast: async (seccode, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/forecast/${seccode}`);
|
return await apiRequest(`/api/financial/forecast/${seccode}`, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取行业排名数据
|
* 获取行业排名数据
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
* @param {number} limit - 获取的报告期数量
|
* @param {number} limit - 获取的报告期数量
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getIndustryRank: async (seccode, limit = 4) => {
|
getIndustryRank: async (seccode, limit = 4, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`);
|
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取不同报告期的对比数据
|
* 获取不同报告期的对比数据
|
||||||
* @param {string} seccode - 股票代码
|
* @param {string} seccode - 股票代码
|
||||||
* @param {number} periods - 对比的报告期数量
|
* @param {number} periods - 对比的报告期数量
|
||||||
|
* @param {object} options - 请求选项
|
||||||
|
* @param {AbortSignal} options.signal - AbortController 信号
|
||||||
*/
|
*/
|
||||||
getPeriodComparison: async (seccode, periods = 8) => {
|
getPeriodComparison: async (seccode, periods = 8, options = {}) => {
|
||||||
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`);
|
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`, options);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [
|
|||||||
chartInstance.current = echarts.init(chartRef.current);
|
chartInstance.current = echarts.init(chartRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
const option: echarts.EChartsOption = {
|
const option: EChartsOption = {
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "item",
|
trigger: "item",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useToast } from '@chakra-ui/react';
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
import axios from 'axios';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { financialService } from '@services/financialService';
|
import { financialService } from '@services/financialService';
|
||||||
import type {
|
import type {
|
||||||
@@ -19,6 +20,11 @@ import type {
|
|||||||
ComparisonData,
|
ComparisonData,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
// 判断是否为取消请求的错误
|
||||||
|
const isCancelError = (error: unknown): boolean => {
|
||||||
|
return axios.isCancel(error) || (error instanceof Error && error.name === 'CanceledError');
|
||||||
|
};
|
||||||
|
|
||||||
// Tab key 到数据类型的映射
|
// Tab key 到数据类型的映射
|
||||||
export type DataTypeKey =
|
export type DataTypeKey =
|
||||||
| 'balance'
|
| 'balance'
|
||||||
@@ -102,6 +108,10 @@ export const useFinancialData = (
|
|||||||
const isInitialLoad = useRef(true);
|
const isInitialLoad = useRef(true);
|
||||||
const prevPeriods = useRef(selectedPeriods);
|
const prevPeriods = useRef(selectedPeriods);
|
||||||
|
|
||||||
|
// AbortController refs - 用于取消请求
|
||||||
|
const coreDataControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const tabDataControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// 判断 Tab key 对应的数据类型
|
// 判断 Tab key 对应的数据类型
|
||||||
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
|
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
|
||||||
switch (tabKey) {
|
switch (tabKey) {
|
||||||
@@ -120,32 +130,36 @@ export const useFinancialData = (
|
|||||||
// 按数据类型加载数据
|
// 按数据类型加载数据
|
||||||
const loadDataByType = useCallback(async (
|
const loadDataByType = useCallback(async (
|
||||||
dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
|
dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
|
||||||
periods: number
|
periods: number,
|
||||||
|
signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
|
const options: { signal?: AbortSignal } = signal ? { signal } : {};
|
||||||
try {
|
try {
|
||||||
switch (dataType) {
|
switch (dataType) {
|
||||||
case 'balance': {
|
case 'balance': {
|
||||||
const res = await financialService.getBalanceSheet(stockCode, periods);
|
const res = await financialService.getBalanceSheet(stockCode, periods, options);
|
||||||
if (res.success) setBalanceSheet(res.data);
|
if (res.success) setBalanceSheet(res.data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'income': {
|
case 'income': {
|
||||||
const res = await financialService.getIncomeStatement(stockCode, periods);
|
const res = await financialService.getIncomeStatement(stockCode, periods, options);
|
||||||
if (res.success) setIncomeStatement(res.data);
|
if (res.success) setIncomeStatement(res.data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'cashflow': {
|
case 'cashflow': {
|
||||||
const res = await financialService.getCashflow(stockCode, periods);
|
const res = await financialService.getCashflow(stockCode, periods, options);
|
||||||
if (res.success) setCashflow(res.data);
|
if (res.success) setCashflow(res.data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'metrics': {
|
case 'metrics': {
|
||||||
const res = await financialService.getFinancialMetrics(stockCode, periods);
|
const res = await financialService.getFinancialMetrics(stockCode, periods, options);
|
||||||
if (res.success) setFinancialMetrics(res.data);
|
if (res.success) setFinancialMetrics(res.data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 取消请求不作为错误处理
|
||||||
|
if (isCancelError(err)) return;
|
||||||
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
|
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -157,6 +171,11 @@ export const useFinancialData = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 取消之前的 Tab 数据请求
|
||||||
|
tabDataControllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
tabDataControllerRef.current = controller;
|
||||||
|
|
||||||
const dataType = getDataTypeForTab(tabKey);
|
const dataType = getDataTypeForTab(tabKey);
|
||||||
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
|
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
|
||||||
|
|
||||||
@@ -164,13 +183,18 @@ export const useFinancialData = (
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadDataByType(dataType, selectedPeriods);
|
await loadDataByType(dataType, selectedPeriods, controller.signal);
|
||||||
logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
|
logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 取消请求不作为错误处理
|
||||||
|
if (isCancelError(err)) return;
|
||||||
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingTab(null);
|
// 只有当前请求没有被取消时才设置 loading 状态
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLoadingTab(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [stockCode, selectedPeriods, loadDataByType]);
|
}, [stockCode, selectedPeriods, loadDataByType]);
|
||||||
|
|
||||||
@@ -191,6 +215,12 @@ export const useFinancialData = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 取消之前的核心数据请求
|
||||||
|
coreDataControllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
coreDataControllerRef.current = controller;
|
||||||
|
const options = { signal: controller.signal };
|
||||||
|
|
||||||
logger.debug('useFinancialData', '开始加载核心财务数据', { stockCode, selectedPeriods });
|
logger.debug('useFinancialData', '开始加载核心财务数据', { stockCode, selectedPeriods });
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -203,10 +233,10 @@ export const useFinancialData = (
|
|||||||
comparisonRes,
|
comparisonRes,
|
||||||
businessRes,
|
businessRes,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
financialService.getStockInfo(stockCode),
|
financialService.getStockInfo(stockCode, options),
|
||||||
financialService.getFinancialMetrics(stockCode, selectedPeriods),
|
financialService.getFinancialMetrics(stockCode, selectedPeriods, options),
|
||||||
financialService.getPeriodComparison(stockCode, selectedPeriods),
|
financialService.getPeriodComparison(stockCode, selectedPeriods, options),
|
||||||
financialService.getMainBusiness(stockCode, 4),
|
financialService.getMainBusiness(stockCode, 4, options),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 设置数据
|
// 设置数据
|
||||||
@@ -217,11 +247,16 @@ export const useFinancialData = (
|
|||||||
|
|
||||||
logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
|
logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 取消请求不作为错误处理
|
||||||
|
if (isCancelError(err)) return;
|
||||||
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
logger.error('useFinancialData', 'loadCoreFinancialData', err, { stockCode, selectedPeriods });
|
logger.error('useFinancialData', 'loadCoreFinancialData', err, { stockCode, selectedPeriods });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
// 只有当前请求没有被取消时才设置 loading 状态
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [stockCode, selectedPeriods, toast]);
|
}, [stockCode, selectedPeriods, toast]);
|
||||||
|
|
||||||
@@ -253,6 +288,14 @@ export const useFinancialData = (
|
|||||||
}
|
}
|
||||||
}, [selectedPeriods, activeTab, refetchByTab]);
|
}, [selectedPeriods, activeTab, refetchByTab]);
|
||||||
|
|
||||||
|
// 组件卸载时取消所有进行中的请求
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
coreDataControllerRef.current?.abort();
|
||||||
|
tabDataControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 数据状态
|
// 数据状态
|
||||||
stockInfo,
|
stockInfo,
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
|||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingState message="加载财务数据中..." height="300px" />
|
<LoadingState message="加载财务数据中..." height="300px" variant="skeleton" skeletonRows={6} />
|
||||||
) : (
|
) : (
|
||||||
<FinancialOverviewPanel
|
<FinancialOverviewPanel
|
||||||
stockInfo={stockInfo}
|
stockInfo={stockInfo}
|
||||||
|
|||||||
@@ -1,29 +1,97 @@
|
|||||||
// src/views/Company/components/LoadingState.tsx
|
// src/views/Company/components/LoadingState.tsx
|
||||||
// 统一的加载状态组件 - 黑金主题
|
// 统一的加载状态组件 - 黑金主题
|
||||||
|
|
||||||
import React from "react";
|
import React, { memo } from "react";
|
||||||
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
|
import { Center, VStack, Spinner, Text, Box, Skeleton, SimpleGrid } from "@chakra-ui/react";
|
||||||
|
|
||||||
// 黑金主题配置
|
// 黑金主题配置
|
||||||
const THEME = {
|
const THEME = {
|
||||||
gold: "#D4AF37",
|
gold: "#D4AF37",
|
||||||
|
goldLight: "rgba(212, 175, 55, 0.3)",
|
||||||
|
bgInset: "rgba(26, 32, 44, 0.6)",
|
||||||
|
borderGlass: "rgba(212, 175, 55, 0.2)",
|
||||||
textSecondary: "gray.400",
|
textSecondary: "gray.400",
|
||||||
|
radiusSM: "md",
|
||||||
|
radiusMD: "lg",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface LoadingStateProps {
|
interface LoadingStateProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
|
/** 使用骨架屏模式(更好的视觉体验) */
|
||||||
|
variant?: "spinner" | "skeleton";
|
||||||
|
/** 骨架屏行数 */
|
||||||
|
skeletonRows?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 骨架屏组件(黑金主题)
|
||||||
|
*/
|
||||||
|
const SkeletonContent: React.FC<{ rows: number }> = memo(({ rows }) => (
|
||||||
|
<VStack align="stretch" spacing={4} w="100%">
|
||||||
|
{/* 头部骨架 */}
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
|
<Skeleton
|
||||||
|
height="28px"
|
||||||
|
width="180px"
|
||||||
|
startColor={THEME.bgInset}
|
||||||
|
endColor={THEME.borderGlass}
|
||||||
|
borderRadius={THEME.radiusSM}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="24px"
|
||||||
|
width="100px"
|
||||||
|
startColor={THEME.bgInset}
|
||||||
|
endColor={THEME.borderGlass}
|
||||||
|
borderRadius={THEME.radiusSM}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 内容骨架行 */}
|
||||||
|
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||||
|
{Array.from({ length: Math.min(rows, 8) }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
height="60px"
|
||||||
|
startColor={THEME.bgInset}
|
||||||
|
endColor={THEME.borderGlass}
|
||||||
|
borderRadius={THEME.radiusMD}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 图表区域骨架 */}
|
||||||
|
<Skeleton
|
||||||
|
height="200px"
|
||||||
|
startColor={THEME.bgInset}
|
||||||
|
endColor={THEME.borderGlass}
|
||||||
|
borderRadius={THEME.radiusMD}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
));
|
||||||
|
|
||||||
|
SkeletonContent.displayName = "SkeletonContent";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一的加载状态组件(黑金主题)
|
* 统一的加载状态组件(黑金主题)
|
||||||
*
|
*
|
||||||
* 用于所有一级 Tab 的 loading 状态展示
|
* 用于所有一级 Tab 的 loading 状态展示
|
||||||
|
* @param variant - "spinner"(默认)或 "skeleton"(骨架屏)
|
||||||
*/
|
*/
|
||||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
const LoadingState: React.FC<LoadingStateProps> = memo(({
|
||||||
message = "加载中...",
|
message = "加载中...",
|
||||||
height = "300px",
|
height = "300px",
|
||||||
|
variant = "spinner",
|
||||||
|
skeletonRows = 4,
|
||||||
}) => {
|
}) => {
|
||||||
|
if (variant === "skeleton") {
|
||||||
|
return (
|
||||||
|
<Box h={height} p={4}>
|
||||||
|
<SkeletonContent rows={skeletonRows} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center h={height}>
|
<Center h={height}>
|
||||||
<VStack spacing={4}>
|
<VStack spacing={4}>
|
||||||
@@ -39,6 +107,8 @@ const LoadingState: React.FC<LoadingStateProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
LoadingState.displayName = "LoadingState";
|
||||||
|
|
||||||
export default LoadingState;
|
export default LoadingState;
|
||||||
|
|||||||
@@ -24,52 +24,6 @@ import CompanyHeader from './components/CompanyHeader';
|
|||||||
import StockQuoteCard from './components/StockQuoteCard';
|
import StockQuoteCard from './components/StockQuoteCard';
|
||||||
import { THEME, TAB_CONFIG } from './config';
|
import { THEME, TAB_CONFIG } from './config';
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 主内容区组件 - FUI 风格
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
interface CompanyContentProps {
|
|
||||||
stockCode: string;
|
|
||||||
isInWatchlist: boolean;
|
|
||||||
watchlistLoading: boolean;
|
|
||||||
onWatchlistToggle: () => void;
|
|
||||||
onTabChange: (index: number, tabKey: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CompanyContent = memo<CompanyContentProps>(({
|
|
||||||
stockCode,
|
|
||||||
isInWatchlist,
|
|
||||||
watchlistLoading,
|
|
||||||
onWatchlistToggle,
|
|
||||||
onTabChange,
|
|
||||||
}) => (
|
|
||||||
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
|
||||||
{/* 股票行情卡片 - 放在 Tab 切换器上方,始终可见 */}
|
|
||||||
<Box mb={6}>
|
|
||||||
<StockQuoteCard
|
|
||||||
stockCode={stockCode}
|
|
||||||
isInWatchlist={isInWatchlist}
|
|
||||||
isWatchlistLoading={watchlistLoading}
|
|
||||||
onWatchlistToggle={onWatchlistToggle}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Tab 内容区 - 使用 FuiContainer */}
|
|
||||||
<FuiContainer variant="default">
|
|
||||||
<SubTabContainer
|
|
||||||
tabs={TAB_CONFIG}
|
|
||||||
componentProps={{ stockCode }}
|
|
||||||
onTabChange={onTabChange}
|
|
||||||
themePreset="blackGold"
|
|
||||||
contentPadding={0}
|
|
||||||
isLazy={true}
|
|
||||||
/>
|
|
||||||
</FuiContainer>
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
|
|
||||||
CompanyContent.displayName = 'CompanyContent';
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 主页面组件
|
// 主页面组件
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -162,13 +116,29 @@ const CompanyIndex: React.FC = () => {
|
|||||||
|
|
||||||
{/* 主内容区 */}
|
{/* 主内容区 */}
|
||||||
<Box position="relative" zIndex={1}>
|
<Box position="relative" zIndex={1}>
|
||||||
<CompanyContent
|
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
||||||
stockCode={stockCode}
|
{/* 股票行情卡片 - 放在 Tab 切换器上方,始终可见 */}
|
||||||
isInWatchlist={isInWatchlist}
|
<Box mb={6}>
|
||||||
watchlistLoading={watchlistLoading}
|
<StockQuoteCard
|
||||||
onWatchlistToggle={handleWatchlistToggle}
|
stockCode={stockCode}
|
||||||
onTabChange={handleTabChange}
|
isInWatchlist={isInWatchlist}
|
||||||
/>
|
isWatchlistLoading={watchlistLoading}
|
||||||
|
onWatchlistToggle={handleWatchlistToggle}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tab 内容区 - 使用 FuiContainer */}
|
||||||
|
<FuiContainer variant="default">
|
||||||
|
<SubTabContainer
|
||||||
|
tabs={TAB_CONFIG}
|
||||||
|
componentProps={{ stockCode }}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
themePreset="blackGold"
|
||||||
|
contentPadding={0}
|
||||||
|
isLazy={true}
|
||||||
|
/>
|
||||||
|
</FuiContainer>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user