- Tab 切换时检查期数是否一致,不一致则重新加载 - 股票切换时立即清空旧数据,确保显示骨架屏 - 表格右上角显示当前期数 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
/**
|
||
* 财务数据加载 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;
|