说明 localStorage 缓存机制确保大多数情况下立即显示正确状态 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
/**
|
||
* Company 页面数据加载 Hook
|
||
* - 使用 axios 请求
|
||
* - 懒加载策略
|
||
* - 自动取消请求
|
||
* - 自选股状态与 Redux 全局状态同步
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||
import { useToast } from '@chakra-ui/react';
|
||
import { useSelector, useDispatch } from 'react-redux';
|
||
import axios from '@utils/axiosConfig';
|
||
import { logger } from '@utils/logger';
|
||
import { useAuth } from '@contexts/AuthContext';
|
||
import { toggleWatchlist as reduxToggleWatchlist, loadWatchlist } from '@store/slices/stockSlice';
|
||
import type {
|
||
StockInfo,
|
||
UseCompanyDataReturn,
|
||
ApiResponse,
|
||
} from '../types';
|
||
|
||
// Store 类型(因为 store 是 JS 文件,这里内联定义)
|
||
interface WatchlistItem {
|
||
stock_code: string;
|
||
stock_name: string;
|
||
}
|
||
|
||
interface StockState {
|
||
watchlist: WatchlistItem[];
|
||
loading: {
|
||
watchlist: boolean;
|
||
};
|
||
}
|
||
|
||
interface RootState {
|
||
stock: StockState;
|
||
}
|
||
|
||
interface UseCompanyDataOptions {
|
||
stockCode: string;
|
||
/** 是否自动加载股票信息 */
|
||
autoLoadStockInfo?: boolean;
|
||
/** 是否自动加载自选股状态 */
|
||
autoLoadWatchlist?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Company 页面数据管理 Hook
|
||
*
|
||
* 自选股状态现在从 Redux 全局状态读取,确保与导航栏等其他组件同步
|
||
*/
|
||
export const useCompanyData = ({
|
||
stockCode,
|
||
autoLoadStockInfo = true,
|
||
autoLoadWatchlist = true,
|
||
}: UseCompanyDataOptions): UseCompanyDataReturn => {
|
||
// 本地状态(仅股票信息)
|
||
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
|
||
const [stockInfoLoading, setStockInfoLoading] = useState(false);
|
||
|
||
// Redux 状态(自选股)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const dispatch = useDispatch<any>();
|
||
const watchlist = useSelector((state: RootState) => state.stock.watchlist);
|
||
const watchlistLoading = useSelector((state: RootState) => state.stock.loading.watchlist);
|
||
|
||
// 从 Redux watchlist 中派生当前股票的自选状态
|
||
// 注意:当 watchlist 正在加载时,保持之前的状态(避免闪烁)
|
||
const isInWatchlist = useMemo(() => {
|
||
// 如果正在加载且 watchlist 为空,暂时返回 false
|
||
// localStorage 缓存会很快返回,所以大多数情况下不会看到错误状态
|
||
return watchlist.some((item) => item.stock_code === stockCode);
|
||
}, [watchlist, stockCode]);
|
||
|
||
// Hooks
|
||
const toast = useToast();
|
||
const { isAuthenticated } = useAuth();
|
||
|
||
// AbortController 用于取消请求
|
||
const abortControllerRef = useRef<AbortController | null>(null);
|
||
|
||
/**
|
||
* 加载股票基本信息
|
||
*/
|
||
const loadStockInfo = useCallback(async () => {
|
||
if (!stockCode || stockCode.length !== 6) return;
|
||
|
||
// 取消之前的请求
|
||
abortControllerRef.current?.abort();
|
||
abortControllerRef.current = new AbortController();
|
||
|
||
setStockInfoLoading(true);
|
||
|
||
try {
|
||
const { data } = await axios.get<ApiResponse<StockInfo>>(
|
||
`/api/financial/stock-info/${stockCode}`,
|
||
{ signal: abortControllerRef.current.signal }
|
||
);
|
||
|
||
if (data.success && data.data) {
|
||
setStockInfo(data.data);
|
||
}
|
||
} catch (error: any) {
|
||
if (error.name === 'CanceledError') return;
|
||
logger.error('useCompanyData', 'loadStockInfo', error, { stockCode });
|
||
} finally {
|
||
setStockInfoLoading(false);
|
||
}
|
||
}, [stockCode]);
|
||
|
||
/**
|
||
* 切换自选股状态(使用 Redux action,自动同步全局状态)
|
||
*/
|
||
const toggleWatchlist = useCallback(async () => {
|
||
if (!stockCode) {
|
||
toast({ title: '无效的股票代码', status: 'error', duration: 2000 });
|
||
return;
|
||
}
|
||
|
||
if (!isAuthenticated) {
|
||
toast({ title: '请先登录后再加入自选', status: 'warning', duration: 2000 });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 使用 Redux action,状态会自动同步到全局
|
||
// @ts-expect-error stockSlice 是 JS 文件,TypeScript 无法推断 thunk 参数类型
|
||
const result = await dispatch(reduxToggleWatchlist({
|
||
stockCode,
|
||
stockName: stockInfo?.stock_name || '',
|
||
isInWatchlist,
|
||
}));
|
||
|
||
// 检查是否成功(rejected action 会有 error 属性)
|
||
if (result.error) {
|
||
throw new Error(result.error.message || '操作失败');
|
||
}
|
||
|
||
// 显示提示
|
||
toast({
|
||
title: isInWatchlist ? '已从自选移除' : '已加入自选',
|
||
status: isInWatchlist ? 'info' : 'success',
|
||
duration: 1500,
|
||
});
|
||
} catch (error: any) {
|
||
logger.error('useCompanyData', 'toggleWatchlist', error, { stockCode });
|
||
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
|
||
}
|
||
}, [stockCode, stockInfo?.stock_name, isAuthenticated, isInWatchlist, toast, dispatch]);
|
||
|
||
/**
|
||
* 刷新股票信息
|
||
*/
|
||
const refreshStockInfo = useCallback(async () => {
|
||
await loadStockInfo();
|
||
}, [loadStockInfo]);
|
||
|
||
// 自动加载股票信息
|
||
useEffect(() => {
|
||
if (autoLoadStockInfo) {
|
||
loadStockInfo();
|
||
}
|
||
|
||
return () => {
|
||
abortControllerRef.current?.abort();
|
||
};
|
||
}, [autoLoadStockInfo, loadStockInfo]);
|
||
|
||
// 自动加载自选股列表(从 Redux)
|
||
useEffect(() => {
|
||
if (autoLoadWatchlist && isAuthenticated && watchlist.length === 0) {
|
||
// 只有当 Redux 中没有数据时才加载
|
||
dispatch(loadWatchlist());
|
||
}
|
||
}, [autoLoadWatchlist, isAuthenticated, watchlist.length, dispatch]);
|
||
|
||
return {
|
||
stockInfo,
|
||
stockInfoLoading,
|
||
isInWatchlist,
|
||
watchlistLoading,
|
||
toggleWatchlist,
|
||
refreshStockInfo,
|
||
};
|
||
};
|
||
|
||
export default useCompanyData;
|