Compare commits
3 Commits
385d452f5a
...
3b352be1a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b352be1a8 | ||
|
|
c49dee72eb | ||
|
|
7159e510a6 |
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
59
src/views/Company/hooks/useStockSearch.ts
Normal file
59
src/views/Company/hooks/useStockSearch.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user