Compare commits

...

3 Commits

Author SHA1 Message Date
zdl
3b352be1a8 refactor(Company): 提取共享的 useStockSearch Hook
- 新增 useStockSearch.ts:统一股票模糊搜索逻辑
  - 支持按代码或名称搜索
  - 支持排除指定股票(用于对比场景)
  - 使用 useMemo 优化性能
- 重构 SearchBar.js:使用共享 Hook,减少 15 行代码
- 重构 CompareStockInput.tsx:使用共享 Hook,减少 20 行代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:34:36 +08:00
zdl
c49dee72eb 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>
2025-12-17 15:20:36 +08:00
zdl
7159e510a6 fix(SubTabContainer): 修复 Tab 懒加载失效问题
- 添加 visitedTabs 状态记录已访问的 Tab 索引
- Tab 切换时更新已访问集合
- TabPanels 中实现条件渲染:只渲染当前或已访问过的 Tab

修复前:tabs.map() 会创建所有组件实例,导致 Hook 立即执行
修复后:仅首次访问 Tab 时才渲染组件,真正实现懒加载

效果:初始加载从 N 个请求减少到 1 个请求

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 14:44:46 +08:00
11 changed files with 318 additions and 206 deletions

View File

@@ -118,6 +118,11 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
// 当前索引 // 当前索引
const currentIndex = controlledIndex ?? internalIndex; const currentIndex = controlledIndex ?? internalIndex;
// 记录已访问的 Tab 索引(用于真正的懒加载)
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
() => new Set([controlledIndex ?? defaultIndex])
);
// 合并主题 // 合并主题
const theme: SubTabTheme = { const theme: SubTabTheme = {
...THEME_PRESETS[themePreset], ...THEME_PRESETS[themePreset],
@@ -132,6 +137,12 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
const tabKey = tabs[newIndex]?.key || ''; const tabKey = tabs[newIndex]?.key || '';
onTabChange?.(newIndex, tabKey); onTabChange?.(newIndex, tabKey);
// 记录已访问的 Tab用于懒加载
setVisitedTabs(prev => {
if (prev.has(newIndex)) return prev;
return new Set(prev).add(newIndex);
});
if (controlledIndex === undefined) { if (controlledIndex === undefined) {
setInternalIndex(newIndex); setInternalIndex(newIndex);
} }
@@ -197,11 +208,16 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
</TabList> </TabList>
<TabPanels p={contentPadding}> <TabPanels p={contentPadding}>
{tabs.map((tab) => { {tabs.map((tab, idx) => {
const Component = tab.component; const Component = tab.component;
// 懒加载:只渲染已访问过的 Tab
const shouldRender = !isLazy || visitedTabs.has(idx);
return ( return (
<TabPanel key={tab.key} p={0}> <TabPanel key={tab.key} p={0}>
{Component ? <Component {...componentProps} /> : null} {shouldRender && Component ? (
<Component {...componentProps} />
) : null}
</TabPanel> </TabPanel>
); );
})} })}

View File

@@ -13,6 +13,7 @@ import {
VStack, VStack,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons'; import { SearchIcon } from '@chakra-ui/icons';
import { useStockSearch } from '../../hooks/useStockSearch';
/** /**
* 股票搜索栏组件(带模糊搜索下拉) * 股票搜索栏组件(带模糊搜索下拉)
@@ -31,27 +32,18 @@ const SearchBar = ({
}) => { }) => {
// 下拉状态 // 下拉状态
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [filteredStocks, setFilteredStocks] = useState([]);
const containerRef = useRef(null); const containerRef = useRef(null);
// 从 Redux 获取全部股票列表 // 从 Redux 获取全部股票列表
const allStocks = useSelector(state => state.stock.allStocks); const allStocks = useSelector(state => state.stock.allStocks);
// 模糊搜索过滤 // 使用共享的搜索 Hook
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
// 根据搜索结果更新下拉显示状态
useEffect(() => { useEffect(() => {
if (inputCode && inputCode.trim()) { setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
const searchTerm = inputCode.trim().toLowerCase(); }, [filteredStocks, inputCode]);
const filtered = allStocks.filter(stock =>
stock.code.toLowerCase().includes(searchTerm) ||
stock.name.includes(inputCode.trim())
).slice(0, 10); // 限制显示10条
setFilteredStocks(filtered);
setShowDropdown(filtered.length > 0);
} else {
setFilteredStocks([]);
setShowDropdown(false);
}
}, [inputCode, allStocks]);
// 点击外部关闭下拉 // 点击外部关闭下拉
useEffect(() => { useEffect(() => {

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,15 +26,19 @@ 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;
const controller = new AbortController();
const loadData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const { data: result } = await axios.get<ApiResponse<Announcement[]>>( const { data: result } = await axios.get<ApiResponse<Announcement[]>>(
`/api/stock/${stockCode}/announcements?limit=20` `/api/stock/${stockCode}/announcements?limit=20`,
{ signal: controller.signal }
); );
if (result.success) { if (result.success) {
@@ -42,17 +46,18 @@ export const useAnnouncementsData = (stockCode?: string): UseAnnouncementsDataRe
} else { } else {
setError("加载公告数据失败"); setError("加载公告数据失败");
} }
} catch (err) { } catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useAnnouncementsData", "loadData", err, { stockCode }); logger.error("useAnnouncementsData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
} finally { } finally {
setLoading(false); 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,15 +26,19 @@ 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;
const controller = new AbortController();
const loadData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const { data: result } = await axios.get<ApiResponse<BasicInfo>>( const { data: result } = await axios.get<ApiResponse<BasicInfo>>(
`/api/stock/${stockCode}/basic-info` `/api/stock/${stockCode}/basic-info`,
{ signal: controller.signal }
); );
if (result.success) { if (result.success) {
@@ -42,17 +46,18 @@ export const useBasicInfo = (stockCode?: string): UseBasicInfoResult => {
} else { } else {
setError("加载基本信息失败"); setError("加载基本信息失败");
} }
} catch (err) { } catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useBasicInfo", "loadData", err, { stockCode }); logger.error("useBasicInfo", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
} finally { } finally {
setLoading(false); 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,15 +26,19 @@ 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;
const controller = new AbortController();
const loadData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const { data: result } = await axios.get<ApiResponse<Branch[]>>( const { data: result } = await axios.get<ApiResponse<Branch[]>>(
`/api/stock/${stockCode}/branches` `/api/stock/${stockCode}/branches`,
{ signal: controller.signal }
); );
if (result.success) { if (result.success) {
@@ -42,17 +46,18 @@ export const useBranchesData = (stockCode?: string): UseBranchesDataResult => {
} else { } else {
setError("加载分支机构数据失败"); setError("加载分支机构数据失败");
} }
} catch (err) { } catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useBranchesData", "loadData", err, { stockCode }); logger.error("useBranchesData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
} finally { } finally {
setLoading(false); 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,15 +26,19 @@ 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;
const controller = new AbortController();
const loadData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>( const { data: result } = await axios.get<ApiResponse<DisclosureSchedule[]>>(
`/api/stock/${stockCode}/disclosure-schedule` `/api/stock/${stockCode}/disclosure-schedule`,
{ signal: controller.signal }
); );
if (result.success) { if (result.success) {
@@ -42,17 +46,18 @@ export const useDisclosureData = (stockCode?: string): UseDisclosureDataResult =
} else { } else {
setError("加载披露日程数据失败"); setError("加载披露日程数据失败");
} }
} catch (err) { } catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useDisclosureData", "loadData", err, { stockCode }); logger.error("useDisclosureData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
} finally { } finally {
setLoading(false); 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,15 +26,19 @@ 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;
const controller = new AbortController();
const loadData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const { data: result } = await axios.get<ApiResponse<Management[]>>( const { data: result } = await axios.get<ApiResponse<Management[]>>(
`/api/stock/${stockCode}/management?active_only=true` `/api/stock/${stockCode}/management?active_only=true`,
{ signal: controller.signal }
); );
if (result.success) { if (result.success) {
@@ -42,17 +46,18 @@ export const useManagementData = (stockCode?: string): UseManagementDataResult =
} else { } else {
setError("加载管理团队数据失败"); setError("加载管理团队数据失败");
} }
} catch (err) { } catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useManagementData", "loadData", err, { stockCode }); logger.error("useManagementData", "loadData", err, { stockCode });
setError("网络请求失败"); setError("网络请求失败");
} finally { } finally {
setLoading(false); 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,9 +32,12 @@ 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;
const controller = new AbortController();
const loadData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -45,27 +48,28 @@ export const useShareholderData = (stockCode?: string): UseShareholderDataResult
{ data: shareholdersRes }, { data: shareholdersRes },
{ data: circulationRes }, { data: circulationRes },
] = await Promise.all([ ] = await Promise.all([
axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`), axios.get<ApiResponse<ActualControl[]>>(`/api/stock/${stockCode}/actual-control`, { signal: controller.signal }),
axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`), axios.get<ApiResponse<Concentration[]>>(`/api/stock/${stockCode}/concentration`, { signal: controller.signal }),
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`), axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-shareholders?limit=10`, { signal: controller.signal }),
axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`), axios.get<ApiResponse<Shareholder[]>>(`/api/stock/${stockCode}/top-circulation-shareholders?limit=10`, { signal: controller.signal }),
]); ]);
if (actualRes.success) setActualControl(actualRes.data); if (actualRes.success) setActualControl(actualRes.data);
if (concentrationRes.success) setConcentration(concentrationRes.data); if (concentrationRes.success) setConcentration(concentrationRes.data);
if (shareholdersRes.success) setTopShareholders(shareholdersRes.data); if (shareholdersRes.success) setTopShareholders(shareholdersRes.data);
if (circulationRes.success) setTopCirculationShareholders(circulationRes.data); if (circulationRes.success) setTopCirculationShareholders(circulationRes.data);
} catch (err) { } catch (err: any) {
if (err.name === "CanceledError") return;
logger.error("useShareholderData", "loadData", err, { stockCode }); logger.error("useShareholderData", "loadData", err, { stockCode });
setError("加载股权结构数据失败"); setError("加载股权结构数据失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [stockCode]); };
useEffect(() => {
loadData(); loadData();
}, [loadData]); return () => controller.abort();
}, [stockCode]);
return { return {
actualControl, actualControl,

View File

@@ -18,6 +18,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons'; import { SearchIcon } from '@chakra-ui/icons';
import { BarChart2 } from 'lucide-react'; import { BarChart2 } from 'lucide-react';
import { useStockSearch, type Stock } from '../../../hooks/useStockSearch';
interface CompareStockInputProps { interface CompareStockInputProps {
onCompare: (stockCode: string) => void; onCompare: (stockCode: string) => void;
@@ -25,11 +26,6 @@ interface CompareStockInputProps {
currentStockCode?: string; currentStockCode?: string;
} }
interface Stock {
code: string;
name: string;
}
interface RootState { interface RootState {
stock: { stock: {
allStocks: Stock[]; allStocks: Stock[];
@@ -43,7 +39,6 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
}) => { }) => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [filteredStocks, setFilteredStocks] = useState<Stock[]>([]);
const [selectedStock, setSelectedStock] = useState<Stock | null>(null); const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -55,25 +50,16 @@ const CompareStockInput: React.FC<CompareStockInputProps> = ({
const goldColor = '#F4D03F'; const goldColor = '#F4D03F';
const bgColor = '#1A202C'; const bgColor = '#1A202C';
// 模糊搜索过滤 // 使用共享的搜索 Hook排除当前股票
const filteredStocks = useStockSearch(allStocks, inputValue, {
excludeCode: currentStockCode,
limit: 8,
});
// 根据搜索结果更新下拉显示状态
useEffect(() => { useEffect(() => {
if (inputValue && inputValue.trim()) { setShowDropdown(filteredStocks.length > 0 && !!inputValue?.trim());
const searchTerm = inputValue.trim().toLowerCase(); }, [filteredStocks, inputValue]);
const filtered = allStocks
.filter(
(stock) =>
stock.code !== currentStockCode && // 排除当前股票
(stock.code.toLowerCase().includes(searchTerm) ||
stock.name.includes(inputValue.trim()))
)
.slice(0, 8); // 限制显示8条
setFilteredStocks(filtered);
setShowDropdown(filtered.length > 0);
} else {
setFilteredStocks([]);
setShowDropdown(false);
}
}, [inputValue, allStocks, currentStockCode]);
// 点击外部关闭下拉 // 点击外部关闭下拉
useEffect(() => { useEffect(() => {

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,
}; };
}; };

View File

@@ -0,0 +1,59 @@
/**
* useStockSearch - 股票模糊搜索 Hook
*
* 提取自 SearchBar.js 和 CompareStockInput.tsx 的共享搜索逻辑
* 支持按代码或名称搜索,可选排除指定股票
*/
import { useMemo } from 'react';
export interface Stock {
code: string;
name: string;
}
interface UseStockSearchOptions {
excludeCode?: string;
limit?: number;
}
/**
* 股票模糊搜索 Hook
*
* @param allStocks - 全部股票列表
* @param searchTerm - 搜索关键词
* @param options - 可选配置
* @param options.excludeCode - 排除的股票代码(用于对比场景)
* @param options.limit - 返回结果数量限制,默认 10
* @returns 过滤后的股票列表
*/
export const useStockSearch = (
allStocks: Stock[],
searchTerm: string,
options: UseStockSearchOptions = {}
): Stock[] => {
const { excludeCode, limit = 10 } = options;
return useMemo(() => {
const trimmed = searchTerm?.trim();
if (!trimmed) return [];
const term = trimmed.toLowerCase();
return allStocks
.filter((stock) => {
// 排除指定股票
if (excludeCode && stock.code === excludeCode) {
return false;
}
// 按代码或名称匹配
return (
stock.code.toLowerCase().includes(term) ||
stock.name.includes(trimmed)
);
})
.slice(0, limit);
}, [allStocks, searchTerm, excludeCode, limit]);
};
export default useStockSearch;