Files
vf_react/src/views/Company/components/FinancialPanorama/hooks/useFinancialData.ts
zdl 0ad0287f7b fix(FinancialPanorama): 优化期数切换和数据加载
- Tab 切换时检查期数是否一致,不一致则重新加载
- 股票切换时立即清空旧数据,确保显示骨架屏
- 表格右上角显示当前期数

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 18:55:03 +08:00

376 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 财务数据加载 Hook
* 封装所有财务数据的加载逻辑,支持按 Tab 独立刷新
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useToast } from '@chakra-ui/react';
import axios from 'axios';
import { logger } from '@utils/logger';
import { financialService } from '@services/financialService';
import type {
StockInfo,
BalanceSheetData,
IncomeStatementData,
CashflowData,
FinancialMetricsData,
MainBusinessData,
ComparisonData,
} from '../types';
// 判断是否为取消请求的错误
const isCancelError = (error: unknown): boolean => {
return axios.isCancel(error) || (error instanceof Error && error.name === 'CanceledError');
};
// Tab key 到数据类型的映射
export type DataTypeKey =
| 'balance'
| 'income'
| 'cashflow'
| 'profitability'
| 'perShare'
| 'growth'
| 'operational'
| 'solvency'
| 'expense'
| 'cashflowMetrics';
interface UseFinancialDataOptions {
stockCode?: string;
periods?: number;
}
interface UseFinancialDataReturn {
// 数据状态
stockInfo: StockInfo | null;
balanceSheet: BalanceSheetData[];
incomeStatement: IncomeStatementData[];
cashflow: CashflowData[];
financialMetrics: FinancialMetricsData[];
mainBusiness: MainBusinessData | null;
comparison: ComparisonData[];
// 加载状态
loading: boolean;
loadingTab: DataTypeKey | null; // 当前正在加载的 Tab
error: string | null;
// 操作方法
refetch: () => Promise<void>;
refetchByTab: (tabKey: DataTypeKey) => Promise<void>;
setStockCode: (code: string) => void;
setSelectedPeriods: (periods: number) => void;
setActiveTab: (tabKey: DataTypeKey) => void;
handleTabChange: (tabKey: DataTypeKey) => void; // Tab 切换时自动检查期数
// 当前参数
currentStockCode: string;
selectedPeriods: number;
activeTab: DataTypeKey;
}
/**
* 财务数据加载 Hook
* @param options - 配置选项
* @returns 财务数据和操作方法
*/
export const useFinancialData = (
options: UseFinancialDataOptions = {}
): UseFinancialDataReturn => {
const { stockCode: initialStockCode = '600000', periods: initialPeriods = 8 } = options;
// 参数状态
const [stockCode, setStockCode] = useState(initialStockCode);
const [selectedPeriods, setSelectedPeriodsState] = useState(initialPeriods);
const [activeTab, setActiveTab] = useState<DataTypeKey>('profitability');
// 加载状态
const [loading, setLoading] = useState(false);
const [loadingTab, setLoadingTab] = useState<DataTypeKey | null>(null);
const [error, setError] = useState<string | null>(null);
// 财务数据状态
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
const [balanceSheet, setBalanceSheet] = useState<BalanceSheetData[]>([]);
const [incomeStatement, setIncomeStatement] = useState<IncomeStatementData[]>([]);
const [cashflow, setCashflow] = useState<CashflowData[]>([]);
const [financialMetrics, setFinancialMetrics] = useState<FinancialMetricsData[]>([]);
const [mainBusiness, setMainBusiness] = useState<MainBusinessData | null>(null);
const [comparison, setComparison] = useState<ComparisonData[]>([]);
const toast = useToast();
const isInitialLoad = useRef(true);
const prevPeriods = useRef(selectedPeriods);
// AbortController refs - 用于取消请求
const coreDataControllerRef = useRef<AbortController | null>(null);
const tabDataControllerRef = useRef<AbortController | null>(null);
// 记录每种数据类型加载时使用的期数(用于 Tab 切换时判断是否需要重新加载)
const dataPeriodsRef = useRef<Record<string, number>>({
balance: 0,
income: 0,
cashflow: 0,
metrics: 0,
});
// 判断 Tab key 对应的数据类型
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
switch (tabKey) {
case 'balance':
return 'balance';
case 'income':
return 'income';
case 'cashflow':
return 'cashflow';
default:
// 所有财务指标类 tab 都使用 metrics 数据
return 'metrics';
}
};
// 按数据类型加载数据
const loadDataByType = useCallback(async (
dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
periods: number,
signal?: AbortSignal
) => {
const options: { signal?: AbortSignal } = signal ? { signal } : {};
try {
switch (dataType) {
case 'balance': {
const res = await financialService.getBalanceSheet(stockCode, periods, options);
if (res.success) {
setBalanceSheet(res.data);
dataPeriodsRef.current.balance = periods;
}
break;
}
case 'income': {
const res = await financialService.getIncomeStatement(stockCode, periods, options);
if (res.success) {
setIncomeStatement(res.data);
dataPeriodsRef.current.income = periods;
}
break;
}
case 'cashflow': {
const res = await financialService.getCashflow(stockCode, periods, options);
if (res.success) {
setCashflow(res.data);
dataPeriodsRef.current.cashflow = periods;
}
break;
}
case 'metrics': {
const res = await financialService.getFinancialMetrics(stockCode, periods, options);
if (res.success) {
setFinancialMetrics(res.data);
dataPeriodsRef.current.metrics = periods;
}
break;
}
}
} catch (err) {
// 取消请求不作为错误处理
if (isCancelError(err)) return;
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
throw err;
}
}, [stockCode]);
// 按 Tab 刷新数据
const refetchByTab = useCallback(async (tabKey: DataTypeKey) => {
if (!stockCode || stockCode.length !== 6) {
return;
}
// 取消之前的 Tab 数据请求
tabDataControllerRef.current?.abort();
const controller = new AbortController();
tabDataControllerRef.current = controller;
const dataType = getDataTypeForTab(tabKey);
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
setLoadingTab(tabKey);
setError(null);
try {
await loadDataByType(dataType, selectedPeriods, controller.signal);
logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
} catch (err) {
// 取消请求不作为错误处理
if (isCancelError(err)) return;
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
} finally {
// 只有当前请求没有被取消时才设置 loading 状态
if (!controller.signal.aborted) {
setLoadingTab(null);
}
}
}, [stockCode, selectedPeriods, loadDataByType]);
// 设置期数(只刷新当前 Tab
const setSelectedPeriods = useCallback((periods: number) => {
setSelectedPeriodsState(periods);
}, []);
// Tab 切换处理:检查期数是否需要重新加载
const handleTabChange = useCallback((tabKey: DataTypeKey) => {
setActiveTab(tabKey);
const dataType = getDataTypeForTab(tabKey);
// 如果该 Tab 数据的期数与当前选择的期数不一致,重新加载
// 注refetchByTab 使用传入的 tabKey不依赖 activeTab 状态,无需延迟
if (dataPeriodsRef.current[dataType] !== selectedPeriods) {
refetchByTab(tabKey);
}
}, [selectedPeriods, refetchByTab]);
// 加载核心财务数据初始加载stockInfo + metrics + comparison
const loadCoreFinancialData = useCallback(async () => {
if (!stockCode || stockCode.length !== 6) {
logger.warn('useFinancialData', '无效的股票代码', { stockCode });
toast({
title: '请输入有效的6位股票代码',
status: 'warning',
duration: 3000,
});
return;
}
// 取消之前的核心数据请求
coreDataControllerRef.current?.abort();
const controller = new AbortController();
coreDataControllerRef.current = controller;
const options = { signal: controller.signal };
logger.debug('useFinancialData', '开始加载核心财务数据', { stockCode, selectedPeriods });
setLoading(true);
setError(null);
try {
// 只加载核心数据(概览面板需要的)
const [
stockInfoRes,
metricsRes,
comparisonRes,
businessRes,
] = await Promise.all([
financialService.getStockInfo(stockCode, options),
financialService.getFinancialMetrics(stockCode, selectedPeriods, options),
financialService.getPeriodComparison(stockCode, selectedPeriods, options),
financialService.getMainBusiness(stockCode, 4, options),
]);
// 设置数据
if (stockInfoRes.success) setStockInfo(stockInfoRes.data);
if (metricsRes.success) {
setFinancialMetrics(metricsRes.data);
dataPeriodsRef.current.metrics = selectedPeriods;
}
if (comparisonRes.success) setComparison(comparisonRes.data);
if (businessRes.success) setMainBusiness(businessRes.data);
logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
} catch (err) {
// 取消请求不作为错误处理
if (isCancelError(err)) return;
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
logger.error('useFinancialData', 'loadCoreFinancialData', err, { stockCode, selectedPeriods });
} finally {
// 只有当前请求没有被取消时才设置 loading 状态
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [stockCode, selectedPeriods, toast]);
// 加载所有财务数据(用于刷新)
const loadAllFinancialData = useCallback(async () => {
await loadCoreFinancialData();
}, [loadCoreFinancialData]);
// 监听 props 中的 stockCode 变化
useEffect(() => {
if (initialStockCode && initialStockCode !== stockCode) {
setStockCode(initialStockCode);
}
}, [initialStockCode]);
// 初始加载(仅股票代码变化时全量加载)
useEffect(() => {
if (stockCode) {
// 立即清空所有旧数据,触发骨架屏
setStockInfo(null);
setBalanceSheet([]);
setIncomeStatement([]);
setCashflow([]);
setFinancialMetrics([]);
setMainBusiness(null);
setComparison([]);
// 重置期数记录
dataPeriodsRef.current = {
balance: 0,
income: 0,
cashflow: 0,
metrics: 0,
};
loadAllFinancialData();
isInitialLoad.current = false;
}
}, [stockCode]); // 注意:这里只依赖 stockCode
// 期数变化时只刷新当前 Tab
useEffect(() => {
if (!isInitialLoad.current && prevPeriods.current !== selectedPeriods) {
prevPeriods.current = selectedPeriods;
refetchByTab(activeTab);
}
}, [selectedPeriods, activeTab, refetchByTab]);
// 组件卸载时取消所有进行中的请求
useEffect(() => {
return () => {
coreDataControllerRef.current?.abort();
tabDataControllerRef.current?.abort();
};
}, []);
return {
// 数据状态
stockInfo,
balanceSheet,
incomeStatement,
cashflow,
financialMetrics,
mainBusiness,
comparison,
// 加载状态
loading,
loadingTab,
error,
// 操作方法
refetch: loadAllFinancialData,
refetchByTab,
setStockCode,
setSelectedPeriods,
setActiveTab,
handleTabChange,
// 当前参数
currentStockCode: stockCode,
selectedPeriods,
activeTab,
};
};
export default useFinancialData;