fix(hooks): 添加 AbortController 解决竞态条件问题

在以下 Hook 中添加请求取消逻辑,防止快速切换股票时旧数据覆盖新数据:
- useBasicInfo
- useShareholderData
- useManagementData
- useBranchesData
- useAnnouncementsData
- useDisclosureData
- useStockQuoteData

修复前:stockCode 变化时,旧请求可能后返回,覆盖新数据
修复后:cleanup 时取消旧请求,确保数据一致性

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-17 15:20:36 +08:00
parent 7159e510a6
commit c49dee72eb
7 changed files with 224 additions and 165 deletions

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts // src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts
// 公告数据 Hook - 用于公司公告 Tab // 公告数据 Hook - 用于公司公告 Tab
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { Announcement } from "../types"; import type { Announcement } from "../types";
@@ -26,33 +26,38 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => { useEffect(() => {
if (!stockCode) return; if (!stockCode) return;
setLoading(true); const controller = new AbortController();
setError(null);
try { const loadData = async () => {
const { data: result } = await axios.get<ApiResponse<Announcement[]>>( setLoading(true);
`/api/stock/${stockCode}/announcements?limit=20` setError(null);
);
if (result.success) { try {
setAnnouncements(result.data); const { data: result } = await axios.get<ApiResponse<Announcement[]>>(
} else { `/api/stock/${stockCode}/announcements?limit=20`,
setError("加载公告数据失败"); { signal: controller.signal }
);
if (result.success) {
setAnnouncements(result.data);
} else {
setError("加载公告数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
} }
} catch (err) { };
logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData(); loadData();
}, [loadData]); return () => controller.abort();
}, [stockCode]);
return { announcements, loading, error }; return { announcements, loading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts // src/views/Company/components/CompanyOverview/hooks/useBasicInfo.ts
// 公司基本信息 Hook - 用于 CompanyHeaderCard // 公司基本信息 Hook - 用于 CompanyHeaderCard
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { BasicInfo } from "../types"; import type { BasicInfo } from "../types";
@@ -26,33 +26,38 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => { useEffect(() => {
if (!stockCode) return; if (!stockCode) return;
setLoading(true); const controller = new AbortController();
setError(null);
try { const loadData = async () => {
const { data: result } = await axios.get<ApiResponse<BasicInfo>>( setLoading(true);
`/api/stock/${stockCode}/basic-info` setError(null);
);
if (result.success) { try {
setBasicInfo(result.data); const { data: result } = await axios.get<ApiResponse<BasicInfo>>(
} else { `/api/stock/${stockCode}/basic-info`,
setError("加载基本信息失败"); { signal: controller.signal }
);
if (result.success) {
setBasicInfo(result.data);
} else {
setError("加载基本信息失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
} }
} catch (err) { };
logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData(); loadData();
}, [loadData]); return () => controller.abort();
}, [stockCode]);
return { basicInfo, loading, error }; return { basicInfo, loading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts // src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts
// 分支机构数据 Hook - 用于分支机构 Tab // 分支机构数据 Hook - 用于分支机构 Tab
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { Branch } from "../types"; import type { Branch } from "../types";
@@ -26,33 +26,38 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => { useEffect(() => {
if (!stockCode) return; if (!stockCode) return;
setLoading(true); const controller = new AbortController();
setError(null);
try { const loadData = async () => {
const { data: result } = await axios.get<ApiResponse<Branch[]>>( setLoading(true);
`/api/stock/${stockCode}/branches` setError(null);
);
if (result.success) { try {
setBranches(result.data); const { data: result } = await axios.get<ApiResponse<Branch[]>>(
} else { `/api/stock/${stockCode}/branches`,
setError("加载分支机构数据失败"); { signal: controller.signal }
);
if (result.success) {
setBranches(result.data);
} else {
setError("加载分支机构数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
} }
} catch (err) { };
logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData(); loadData();
}, [loadData]); return () => controller.abort();
}, [stockCode]);
return { branches, loading, error }; return { branches, loading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts // src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts
// 披露日程数据 Hook - 用于工商信息 Tab // 披露日程数据 Hook - 用于工商信息 Tab
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { DisclosureSchedule } from "../types"; import type { DisclosureSchedule } from "../types";
@@ -26,33 +26,38 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => { useEffect(() => {
if (!stockCode) return; if (!stockCode) return;
setLoading(true); const controller = new AbortController();
setError(null);
try { const loadData = async () => {
const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>( setLoading(true);
`/api/stock/${stockCode}/disclosure-schedule` setError(null);
);
if (result.success) { try {
setDisclosureSchedule(result.data); const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>(
} else { `/api/stock/${stockCode}/disclosure-schedule`,
setError("加载披露日程数据失败"); { signal: controller.signal }
);
if (result.success) {
setDisclosureSchedule(result.data);
} else {
setError("加载披露日程数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
} }
} catch (err) { };
logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData(); loadData();
}, [loadData]); return () => controller.abort();
}, [stockCode]);
return { disclosureSchedule, loading, error }; return { disclosureSchedule, loading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useManagementData.ts // src/views/Company/components/CompanyOverview/hooks/useManagementData.ts
// 管理团队数据 Hook - 用于管理团队 Tab // 管理团队数据 Hook - 用于管理团队 Tab
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { Management } from "../types"; import type { Management } from "../types";
@@ -26,33 +26,38 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => { useEffect(() => {
if (!stockCode) return; if (!stockCode) return;
setLoading(true); const controller = new AbortController();
setError(null);
try { const loadData = async () => {
const { data: result } = await axios.get<ApiResponse<Management[]>>( setLoading(true);
`/api/stock/${stockCode}/management?active_only=true` setError(null);
);
if (result.success) { try {
setManagement(result.data); const { data: result } = await axios.get<ApiResponse<Management[]>>(
} else { `/api/stock/${stockCode}/management?active_only=true`,
setError("加载管理团队数据失败"); { signal: controller.signal }
);
if (result.success) {
setManagement(result.data);
} else {
setError("加载管理团队数据失败");
}
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
} }
} catch (err) { };
logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败");
} finally {
setLoading(false);
}
}, [stockCode]);
useEffect(() => {
loadData(); loadData();
}, [loadData]); return () => controller.abort();
}, [stockCode]);
return { management, loading, error }; return { management, loading, error };
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts // src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts
// 股权结构数据 Hook - 用于股权结构 Tab // 股权结构数据 Hook - 用于股权结构 Tab
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { logger } from "@utils/logger"; import { logger } from "@utils/logger";
import axios from "@utils/axiosConfig"; import axios from "@utils/axiosConfig";
import type { ActualControl, Concentration, Shareholder } from "../types"; import type { ActualControl, Concentration, Shareholder } from "../types";
@@ -32,40 +32,44 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadData = useCallback(async () => { useEffect(() => {
if (!stockCode) return; if (!stockCode) return;
setLoading(true); const controller = new AbortController();
setError(null);
try { const loadData = async () => {
const [ setLoading(true);
{ data: actualRes }, setError(null);
{ data: concentrationRes },
{ data: shareholdersRes },
{ data: circulationRes },
] = await Promise.all([
axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`),
axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`),
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`),
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`),
]);
if (actualRes.success) setActualControl(actualRes.data); try {
if (concentrationRes.success) setConcentration(concentrationRes.data); const [
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data); { data: actualRes },
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data); { data: concentrationRes },
} catch (err) { { data: shareholdersRes },
logger.error("useShareholderData", "loadData", err, { stockCode }); { data: circulationRes },
setError("加载股权结构数据失败"); ] = await Promise.all([
} finally { axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }),
setLoading(false); axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`, { signal: controller.signal }),
} axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`, { signal: controller.signal }),
}, [stockCode]); axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`, { signal: controller.signal }),
]);
if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
} catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useShareholderData", "loadData", err, { stockCode });
setError("加载股权结构数据失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData(); loadData();
}, [loadData]); return () => controller.abort();
}, [stockCode]);
return { return {
actualControl, actualControl,

View File

@@ -73,24 +73,18 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult =
const [basicLoading, setBasicLoading] = useState(false); const [basicLoading, setBasicLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 获取行情数据 // 用于手动刷新的 ref
const fetchQuote = useCallback(async () => { const refetchRef = useCallback(async () => {
if (!stockCode) { if (!stockCode) return;
setQuoteData(null);
return;
}
// 获取行情数据
setQuoteLoading(true); setQuoteLoading(true);
setError(null); setError(null);
try { try {
logger.debug('useStockQuoteData', '获取股票行情', { stockCode }); logger.debug('useStockQuoteData', '获取股票行情', { stockCode });
const quotes = await stockService.getQuotes([stockCode]); const quotes = await stockService.getQuotes([stockCode]);
// API 返回格式: { [stockCode]: quoteData }
const quoteResult = quotes?.[stockCode] || quotes; const quoteResult = quotes?.[stockCode] || quotes;
const transformedData = transformQuoteData(quoteResult, stockCode); const transformedData = transformQuoteData(quoteResult, stockCode);
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData }); logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
setQuoteData(transformedData); setQuoteData(transformedData);
} catch (err) { } catch (err) {
@@ -100,49 +94,85 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult =
} finally { } finally {
setQuoteLoading(false); setQuoteLoading(false);
} }
}, [stockCode]);
// 获取基本信息
const fetchBasicInfo = useCallback(async () => {
if (!stockCode) {
setBasicInfo(null);
return;
}
// 获取基本信息
setBasicLoading(true); setBasicLoading(true);
try { try {
const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`); const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`);
if (result.success) { if (result.success) {
setBasicInfo(result.data); setBasicInfo(result.data);
} }
} catch (err) { } catch (err) {
logger.error('useStockQuoteData', '获取基本信息失败', err); logger.error('useStockQuoteData', '获取基本信息失败', err);
// 基本信息获取失败不影响主流程,只记录日志
} finally { } finally {
setBasicLoading(false); setBasicLoading(false);
} }
}, [stockCode]); }, [stockCode]);
// stockCode 变化时重新获取数据 // stockCode 变化时重新获取数据(带取消支持)
useEffect(() => { useEffect(() => {
fetchQuote(); if (!stockCode) {
fetchBasicInfo(); setQuoteData(null);
}, [fetchQuote, fetchBasicInfo]); setBasicInfo(null);
return;
}
// 手动刷新 const controller = new AbortController();
const refetch = useCallback(() => { let isCancelled = false;
fetchQuote();
fetchBasicInfo(); const fetchData = async () => {
}, [fetchQuote, fetchBasicInfo]); // 获取行情数据
setQuoteLoading(true);
setError(null);
try {
logger.debug('useStockQuoteData', '获取股票行情', { stockCode });
const quotes = await stockService.getQuotes([stockCode]);
if (isCancelled) return;
const quoteResult = quotes?.[stockCode] || quotes;
const transformedData = transformQuoteData(quoteResult, stockCode);
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
setQuoteData(transformedData);
} catch (err: any) {
if (isCancelled || err.name === 'CanceledError') return;
logger.error('useStockQuoteData', '获取行情失败', err);
setError('获取行情数据失败');
setQuoteData(null);
} finally {
if (!isCancelled) setQuoteLoading(false);
}
// 获取基本信息
setBasicLoading(true);
try {
const { data: result } = await axios.get(`/api/stock/${stockCode}/basic-info`, {
signal: controller.signal,
});
if (isCancelled) return;
if (result.success) {
setBasicInfo(result.data);
}
} catch (err: any) {
if (isCancelled || err.name === 'CanceledError') return;
logger.error('useStockQuoteData', '获取基本信息失败', err);
} finally {
if (!isCancelled) setBasicLoading(false);
}
};
fetchData();
return () => {
isCancelled = true;
controller.abort();
};
}, [stockCode]);
return { return {
quoteData, quoteData,
basicInfo, basicInfo,
isLoading: quoteLoading || basicLoading, isLoading: quoteLoading || basicLoading,
error, error,
refetch, refetch: refetchRef,
}; };
}; };