From 88b836e75a60e44cfe9023ea41e2f1fc572b727d Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 22 Dec 2025 12:15:21 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(mock):=20=E5=AE=8C=E5=96=84=E5=A4=A7?= =?UTF-8?q?=E5=AE=97=E4=BA=A4=E6=98=93=E5=92=8C=E9=BE=99=E8=99=8E=E6=A6=9C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 融券余额增加 balance_amount 字段 - 大宗交易:新增 deals 明细、买卖营业部、成交均价 - 龙虎榜:新增 buyers/sellers 营业部列表、净买入金额 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/mocks/data/market.js | 78 +++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/src/mocks/data/market.js b/src/mocks/data/market.js index c6e79a5c..64158873 100644 --- a/src/mocks/data/market.js +++ b/src/mocks/data/market.js @@ -55,41 +55,77 @@ export const generateMarketData = (stockCode) => { repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还 }, securities: { - balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额 + balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额(股数) + balance_amount: Math.floor(Math.random() * 2000000000) + 1000000000, // 融券余额(金额) sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出 repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还 } })) }, - // 大单统计 - 包含 daily_stats 数组 + // 大宗交易 - 包含 daily_stats 数组,符合 BigDealDayStats 类型 bigDealData: { success: true, data: [], - daily_stats: Array(10).fill(null).map((_, i) => ({ - date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - big_buy: Math.floor(Math.random() * 300000000) + 100000000, - big_sell: Math.floor(Math.random() * 300000000) + 80000000, - medium_buy: Math.floor(Math.random() * 200000000) + 60000000, - medium_sell: Math.floor(Math.random() * 200000000) + 50000000, - small_buy: Math.floor(Math.random() * 100000000) + 30000000, - small_sell: Math.floor(Math.random() * 100000000) + 25000000 - })) + daily_stats: Array(10).fill(null).map((_, i) => { + const count = Math.floor(Math.random() * 5) + 1; // 1-5 笔交易 + const avgPrice = parseFloat((basePrice * (0.95 + Math.random() * 0.1)).toFixed(2)); // 折价/溢价 -5%~+5% + const deals = Array(count).fill(null).map(() => { + const volume = parseFloat((Math.random() * 500 + 100).toFixed(2)); // 100-600 万股 + const price = parseFloat((avgPrice * (0.98 + Math.random() * 0.04)).toFixed(2)); + return { + buyer_dept: ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司'][Math.floor(Math.random() * 4)], + seller_dept: ['中金公司北京营业部', '海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司'][Math.floor(Math.random() * 4)], + price, + volume, + amount: parseFloat((price * volume).toFixed(2)) + }; + }); + const totalVolume = deals.reduce((sum, d) => sum + d.volume, 0); + const totalAmount = deals.reduce((sum, d) => sum + d.amount, 0); + return { + date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + count, + total_volume: parseFloat(totalVolume.toFixed(2)), + total_amount: parseFloat(totalAmount.toFixed(2)), + avg_price: avgPrice, + deals + }; + }) }, - // 异动分析 - 包含 grouped_data 数组 + // 龙虎榜数据 - 包含 grouped_data 数组,符合 UnusualDayData 类型 unusualData: { success: true, data: [], - grouped_data: Array(5).fill(null).map((_, i) => ({ - date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - events: [ - { time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' }, - { time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' }, - { time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' } - ], - count: 3 - })) + grouped_data: Array(5).fill(null).map((_, i) => { + const buyerDepts = ['中信证券北京总部', '国泰君安上海分公司', '华泰证券深圳营业部', '招商证券广州分公司', '中金公司北京营业部']; + const sellerDepts = ['海通证券上海分公司', '广发证券深圳营业部', '平安证券广州分公司', '东方证券上海营业部', '兴业证券福州营业部']; + const infoTypes = ['日涨幅偏离值达7%', '日振幅达15%', '连续三日涨幅偏离20%', '换手率达20%']; + + const buyers = buyerDepts.map(dept => ({ + dept_name: dept, + buy_amount: Math.floor(Math.random() * 50000000) + 10000000 // 1000万-6000万 + })).sort((a, b) => b.buy_amount - a.buy_amount); + + const sellers = sellerDepts.map(dept => ({ + dept_name: dept, + sell_amount: Math.floor(Math.random() * 40000000) + 8000000 // 800万-4800万 + })).sort((a, b) => b.sell_amount - a.sell_amount); + + const totalBuy = buyers.reduce((sum, b) => sum + b.buy_amount, 0); + const totalSell = sellers.reduce((sum, s) => sum + s.sell_amount, 0); + + return { + date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + total_buy: totalBuy, + total_sell: totalSell, + net_amount: totalBuy - totalSell, + buyers, + sellers, + info_types: infoTypes.slice(0, Math.floor(Math.random() * 3) + 1) // 随机选1-3个类型 + }; + }) }, // 股权质押 - 匹配 PledgeData[] 类型 From 9e271747da6ba3c4ad6917e1513e24441b7d0c1c Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 22 Dec 2025 12:15:39 +0800 Subject: [PATCH 2/4] =?UTF-8?q?perf(MarketDataView):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81=EF=BC=8C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6=E5=B1=8F=E9=81=BF=E5=85=8D=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E8=B7=B3=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMarketData: 新增 hasLoaded 状态,优化首次加载 loading 逻辑 - 导出 SummaryCardSkeleton 组件用于概览卡片占位 - MarketDataView: 使用骨架屏替代空白占位 - DeepAnalysisTab: 使用 skeleton 变体替代 spinner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../CompanyOverview/DeepAnalysisTab/index.tsx | 2 +- .../components/MarketDataSkeleton.tsx | 2 +- .../MarketDataView/components/index.ts | 2 +- .../MarketDataView/hooks/useMarketData.ts | 24 ++++++++++++------- .../components/MarketDataView/index.tsx | 16 ++++++------- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/index.tsx b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/index.tsx index fa2642d4..a9dd6181 100644 --- a/src/views/Company/components/CompanyOverview/DeepAnalysisTab/index.tsx +++ b/src/views/Company/components/CompanyOverview/DeepAnalysisTab/index.tsx @@ -77,7 +77,7 @@ const DeepAnalysisTab: React.FC = ({ themePreset="blackGold" size="sm" /> - + ); diff --git a/src/views/Company/components/MarketDataView/components/MarketDataSkeleton.tsx b/src/views/Company/components/MarketDataView/components/MarketDataSkeleton.tsx index 67ff83c4..a2c5daae 100644 --- a/src/views/Company/components/MarketDataView/components/MarketDataSkeleton.tsx +++ b/src/views/Company/components/MarketDataView/components/MarketDataSkeleton.tsx @@ -136,5 +136,5 @@ const MarketDataSkeleton: React.FC = memo(() => ( MarketDataSkeleton.displayName = 'MarketDataSkeleton'; -export { MarketDataSkeleton }; +export { MarketDataSkeleton, SummaryCardSkeleton }; export default MarketDataSkeleton; diff --git a/src/views/Company/components/MarketDataView/components/index.ts b/src/views/Company/components/MarketDataView/components/index.ts index 1a854d60..91b880e7 100644 --- a/src/views/Company/components/MarketDataView/components/index.ts +++ b/src/views/Company/components/MarketDataView/components/index.ts @@ -5,4 +5,4 @@ export { default as ThemedCard } from './ThemedCard'; export { default as MarkdownRenderer } from './MarkdownRenderer'; export { default as StockSummaryCard } from './StockSummaryCard'; export { default as AnalysisModal, AnalysisContent } from './AnalysisModal'; -export { MarketDataSkeleton } from './MarketDataSkeleton'; +export { MarketDataSkeleton, SummaryCardSkeleton } from './MarketDataSkeleton'; diff --git a/src/views/Company/components/MarketDataView/hooks/useMarketData.ts b/src/views/Company/components/MarketDataView/hooks/useMarketData.ts index 6231b8b3..eaf6b751 100644 --- a/src/views/Company/components/MarketDataView/hooks/useMarketData.ts +++ b/src/views/Company/components/MarketDataView/hooks/useMarketData.ts @@ -33,8 +33,9 @@ export const useMarketData = ( period: number = DEFAULT_PERIOD ): UseMarketDataReturn => { // 主数据状态 - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [tradeLoading, setTradeLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); const [summary, setSummary] = useState(null); const [tradeData, setTradeData] = useState([]); const [fundingData, setFundingData] = useState([]); @@ -153,15 +154,17 @@ export const useMarketData = ( if (loadedTradeData.length > 0) { loadRiseAnalysis(loadedTradeData); } + + setLoading(false); + setHasLoaded(true); } catch (error) { - // 取消请求不作为错误处理 - if (isCancelError(error)) return; - logger.error('useMarketData', 'loadCoreData', error, { stockCode, period }); - } finally { - // 只有当前请求没有被取消时才设置 loading 状态 - if (!controller.signal.aborted) { - setLoading(false); + // 请求被取消时,不更新任何状态 + if (isCancelError(error)) { + return; } + logger.error('useMarketData', 'loadCoreData', error, { stockCode, period }); + setLoading(false); + setHasLoaded(true); } }, [stockCode, period, loadRiseAnalysis]); @@ -363,8 +366,11 @@ export const useMarketData = ( }; }, []); + // 派生 loading 状态:stockCode 存在但尚未完成首次加载时,视为 loading + const isLoading = loading || (!!stockCode && !hasLoaded); + return { - loading, + loading: isLoading, tradeLoading, summary, tradeData, diff --git a/src/views/Company/components/MarketDataView/index.tsx b/src/views/Company/components/MarketDataView/index.tsx index 51030aaf..e4140a54 100644 --- a/src/views/Company/components/MarketDataView/index.tsx +++ b/src/views/Company/components/MarketDataView/index.tsx @@ -22,6 +22,7 @@ import { useMarketData } from './hooks/useMarketData'; import { ThemedCard, StockSummaryCard, + SummaryCardSkeleton, AnalysisModal, AnalysisContent, } from './components'; @@ -89,13 +90,12 @@ const MarketDataView: React.FC = ({ stockCode: propStockCod } }, [propStockCode, stockCode]); - // 首次渲染时加载默认 Tab(融资融券)的数据 + // 首次挂载时加载默认 Tab(融资融券)的数据 + // 注意:SubTabContainer 的 onChange 只在切换时触发,首次渲染不会触发 useEffect(() => { - // 默认 Tab 是融资融券(index 0) - if (activeTab === 0) { - loadDataByType('funding'); - } - }, [loadDataByType, activeTab]); + loadDataByType('funding'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 只在首次挂载时执行 // 处理图表点击事件 const handleChartClick = useCallback( @@ -137,8 +137,8 @@ const MarketDataView: React.FC = ({ stockCode: propStockCod - {/* 股票概览 */} - {summary && } + {/* 股票概览 - 未加载时显示骨架屏占位,避免布局跳动 */} + {summary ? : } {/* 交易数据 - 日K/分钟K线(独立显示在 Tab 上方) */} Date: Mon, 22 Dec 2025 13:02:29 +0800 Subject: [PATCH 3/4] =?UTF-8?q?perf(hooks):=20=E4=BD=BF=E7=94=A8=20useRef?= =?UTF-8?q?=20=E7=BC=93=E5=AD=98=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=20Tab=20=E5=88=87=E6=8D=A2=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 useRef 替代 useState 跟踪 hasLoaded 状态 - Tab 切换回来时保持数据缓存,不重新发起请求 - stockCode 变化时重置加载状态,确保新股票正常加载 - useAnnouncementsData 支持 refreshKey 强制刷新 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../hooks/useAnnouncementsData.ts | 34 ++++++++++++++++--- .../CompanyOverview/hooks/useBranchesData.ts | 25 +++++++++++--- .../hooks/useDisclosureData.ts | 25 +++++++++++--- .../hooks/useManagementData.ts | 25 +++++++++++--- .../hooks/useShareholderData.ts | 25 +++++++++++--- 5 files changed, 109 insertions(+), 25 deletions(-) diff --git a/src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts b/src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts index e6ff2db8..473a9674 100644 --- a/src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts +++ b/src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts @@ -1,7 +1,7 @@ // src/views/Company/components/CompanyOverview/hooks/useAnnouncementsData.ts // 公告数据 Hook - 用于公司公告 Tab -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { logger } from "@utils/logger"; import axios from "@utils/axiosConfig"; import type { Announcement } from "../types"; @@ -39,7 +39,11 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA const [announcements, setAnnouncements] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hasLoaded, setHasLoaded] = useState(false); + // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求 + const hasLoadedRef = useRef(false); + // 记录上次加载的 stockCode 和 refreshKey + const lastStockCodeRef = useRef(undefined); + const lastRefreshKeyRef = useRef(undefined); useEffect(() => { // 只有 enabled 且有 stockCode 时才请求 @@ -48,6 +52,26 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA return; } + // stockCode 或 refreshKey 变化时重置加载状态 + if (lastStockCodeRef.current !== stockCode || lastRefreshKeyRef.current !== refreshKey) { + // refreshKey 变化时强制重新加载 + if (lastRefreshKeyRef.current !== refreshKey && lastRefreshKeyRef.current !== undefined) { + hasLoadedRef.current = false; + } + // stockCode 变化时重置 + if (lastStockCodeRef.current !== stockCode) { + hasLoadedRef.current = false; + } + lastStockCodeRef.current = stockCode; + lastRefreshKeyRef.current = refreshKey; + } + + // 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存) + if (hasLoadedRef.current) { + setLoading(false); + return; + } + const controller = new AbortController(); const loadData = async () => { @@ -66,7 +90,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA setError("加载公告数据失败"); } setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } catch (err: any) { // 请求被取消时,不更新任何状态 if (err.name === "CanceledError") { @@ -75,7 +99,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA logger.error("useAnnouncementsData", "loadData", err, { stockCode }); setError("网络请求失败"); setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } }; @@ -83,7 +107,7 @@ export const useAnnouncementsData = (options: UseAnnouncementsDataOptions): UseA return () => controller.abort(); }, [stockCode, enabled, refreshKey]); - const isLoading = loading || (enabled && !hasLoaded && !error); + const isLoading = loading || (enabled && !hasLoadedRef.current && !error); return { announcements, loading: isLoading, error }; }; diff --git a/src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts b/src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts index 65299387..5ed23bd6 100644 --- a/src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts +++ b/src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts @@ -1,7 +1,7 @@ // src/views/Company/components/CompanyOverview/hooks/useBranchesData.ts // 分支机构数据 Hook - 用于分支机构 Tab -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { logger } from "@utils/logger"; import axios from "@utils/axiosConfig"; import type { Branch } from "../types"; @@ -36,7 +36,10 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat const [branches, setBranches] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hasLoaded, setHasLoaded] = useState(false); + // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求 + const hasLoadedRef = useRef(false); + // 记录上次加载的 stockCode,stockCode 变化时需要重新加载 + const lastStockCodeRef = useRef(undefined); useEffect(() => { if (!enabled || !stockCode) { @@ -44,6 +47,18 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat return; } + // stockCode 变化时重置加载状态 + if (lastStockCodeRef.current !== stockCode) { + hasLoadedRef.current = false; + lastStockCodeRef.current = stockCode; + } + + // 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存) + if (hasLoadedRef.current) { + setLoading(false); + return; + } + const controller = new AbortController(); const loadData = async () => { @@ -62,7 +77,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat setError("加载分支机构数据失败"); } setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } catch (err: any) { // 请求被取消时,不更新任何状态 if (err.name === "CanceledError") { @@ -71,7 +86,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat logger.error("useBranchesData", "loadData", err, { stockCode }); setError("网络请求失败"); setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } }; @@ -79,7 +94,7 @@ export const useBranchesData = (options: UseBranchesDataOptions): UseBranchesDat return () => controller.abort(); }, [stockCode, enabled]); - const isLoading = loading || (enabled && !hasLoaded && !error); + const isLoading = loading || (enabled && !hasLoadedRef.current && !error); return { branches, loading: isLoading, error }; }; diff --git a/src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts b/src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts index 38e4f229..4cf9f0ea 100644 --- a/src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts +++ b/src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts @@ -1,7 +1,7 @@ // src/views/Company/components/CompanyOverview/hooks/useDisclosureData.ts // 披露日程数据 Hook - 用于工商信息 Tab -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { logger } from "@utils/logger"; import axios from "@utils/axiosConfig"; import type { DisclosureSchedule } from "../types"; @@ -36,7 +36,10 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos const [disclosureSchedule, setDisclosureSchedule] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hasLoaded, setHasLoaded] = useState(false); + // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求 + const hasLoadedRef = useRef(false); + // 记录上次加载的 stockCode,stockCode 变化时需要重新加载 + const lastStockCodeRef = useRef(undefined); useEffect(() => { // 只有 enabled 且有 stockCode 时才请求 @@ -45,6 +48,18 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos return; } + // stockCode 变化时重置加载状态 + if (lastStockCodeRef.current !== stockCode) { + hasLoadedRef.current = false; + lastStockCodeRef.current = stockCode; + } + + // 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存) + if (hasLoadedRef.current) { + setLoading(false); + return; + } + const controller = new AbortController(); const loadData = async () => { @@ -63,7 +78,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos setError("加载披露日程数据失败"); } setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } catch (err: any) { // 请求被取消时,不更新任何状态 if (err.name === "CanceledError") { @@ -72,7 +87,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos logger.error("useDisclosureData", "loadData", err, { stockCode }); setError("网络请求失败"); setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } }; @@ -80,7 +95,7 @@ export const useDisclosureData = (options: UseDisclosureDataOptions): UseDisclos return () => controller.abort(); }, [stockCode, enabled]); - const isLoading = loading || (enabled && !hasLoaded && !error); + const isLoading = loading || (enabled && !hasLoadedRef.current && !error); return { disclosureSchedule, loading: isLoading, error }; }; diff --git a/src/views/Company/components/CompanyOverview/hooks/useManagementData.ts b/src/views/Company/components/CompanyOverview/hooks/useManagementData.ts index 62590e77..ca3d3f72 100644 --- a/src/views/Company/components/CompanyOverview/hooks/useManagementData.ts +++ b/src/views/Company/components/CompanyOverview/hooks/useManagementData.ts @@ -1,7 +1,7 @@ // src/views/Company/components/CompanyOverview/hooks/useManagementData.ts // 管理团队数据 Hook - 用于管理团队 Tab -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { logger } from "@utils/logger"; import axios from "@utils/axiosConfig"; import type { Management } from "../types"; @@ -36,7 +36,10 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem const [management, setManagement] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hasLoaded, setHasLoaded] = useState(false); + // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求 + const hasLoadedRef = useRef(false); + // 记录上次加载的 stockCode,stockCode 变化时需要重新加载 + const lastStockCodeRef = useRef(undefined); useEffect(() => { // 只有 enabled 且有 stockCode 时才请求 @@ -45,6 +48,18 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem return; } + // stockCode 变化时重置加载状态 + if (lastStockCodeRef.current !== stockCode) { + hasLoadedRef.current = false; + lastStockCodeRef.current = stockCode; + } + + // 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存) + if (hasLoadedRef.current) { + setLoading(false); + return; + } + const controller = new AbortController(); const loadData = async () => { @@ -63,7 +78,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem setError("加载管理团队数据失败"); } setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } catch (err: any) { // 请求被取消时,不更新任何状态 if (err.name === "CanceledError") { @@ -72,7 +87,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem logger.error("useManagementData", "loadData", err, { stockCode }); setError("网络请求失败"); setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } }; @@ -82,7 +97,7 @@ export const useManagementData = (options: UseManagementDataOptions): UseManagem // 派生 loading 状态:enabled 但尚未完成首次加载时,视为 loading // 这样可以在渲染时同步判断,避免 useEffect 异步导致的空状态闪烁 - const isLoading = loading || (enabled && !hasLoaded && !error); + const isLoading = loading || (enabled && !hasLoadedRef.current && !error); return { management, loading: isLoading, error }; }; diff --git a/src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts b/src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts index 6c47b62d..b9867adb 100644 --- a/src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts +++ b/src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts @@ -1,7 +1,7 @@ // src/views/Company/components/CompanyOverview/hooks/useShareholderData.ts // 股权结构数据 Hook - 用于股权结构 Tab -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { logger } from "@utils/logger"; import axios from "@utils/axiosConfig"; import type { ActualControl, Concentration, Shareholder } from "../types"; @@ -42,7 +42,10 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare const [topCirculationShareholders, setTopCirculationShareholders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hasLoaded, setHasLoaded] = useState(false); + // 使用 ref 跟踪是否已加载,避免 Tab 切换时重复请求 + const hasLoadedRef = useRef(false); + // 记录上次加载的 stockCode,stockCode 变化时需要重新加载 + const lastStockCodeRef = useRef(undefined); useEffect(() => { // 只有 enabled 且有 stockCode 时才请求 @@ -51,6 +54,18 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare return; } + // stockCode 变化时重置加载状态 + if (lastStockCodeRef.current !== stockCode) { + hasLoadedRef.current = false; + lastStockCodeRef.current = stockCode; + } + + // 如果已经加载过数据,不再重新请求(Tab 切换回来时保持缓存) + if (hasLoadedRef.current) { + setLoading(false); + return; + } + const controller = new AbortController(); const loadData = async () => { @@ -75,7 +90,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare if (shareholdersRes.success) setTopShareholders(shareholdersRes.data); if (circulationRes.success) setTopCirculationShareholders(circulationRes.data); setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } catch (err: any) { // 请求被取消时,不更新任何状态 if (err.name === "CanceledError") { @@ -84,7 +99,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare logger.error("useShareholderData", "loadData", err, { stockCode }); setError("加载股权结构数据失败"); setLoading(false); - setHasLoaded(true); + hasLoadedRef.current = true; } }; @@ -92,7 +107,7 @@ export const useShareholderData = (options: UseShareholderDataOptions): UseShare return () => controller.abort(); }, [stockCode, enabled]); - const isLoading = loading || (enabled && !hasLoaded && !error); + const isLoading = loading || (enabled && !hasLoadedRef.current && !error); return { actualControl, From 174fe32850c59dca0314a1de075a0d449fed5b6b Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Mon, 22 Dec 2025 13:02:45 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(LoadingState):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6=E5=B1=8F=E5=8F=98=E4=BD=93=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=8A=A0=E8=BD=BD=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadingState: 新增 variant 参数支持 spinner/skeleton 模式 - LoadingState: 新增 skeletonType 参数支持 grid/list 布局 - AnnouncementsPanel: 使用 list 骨架屏替代 spinner - DisclosureSchedulePanel: 使用 grid 骨架屏替代 spinner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/AnnouncementsPanel.tsx | 2 +- .../components/DisclosureSchedulePanel.tsx | 2 +- .../BasicInfoTab/components/LoadingState.tsx | 102 ++++++++++++++++-- 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx index 89536abf..f24dce1b 100644 --- a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx @@ -49,7 +49,7 @@ const AnnouncementsPanel: React.FC = ({ stockCode, isAc }; if (loading) { - return ; + return ; } return ( diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx index 6b8ef55a..54e2b34c 100644 --- a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx @@ -27,7 +27,7 @@ const DisclosureSchedulePanel: React.FC = ({ stock const { disclosureSchedule, loading } = useDisclosureData({ stockCode, enabled: isActive }); if (loading) { - return ; + return ; } if (disclosureSchedule.length === 0) { diff --git a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx index 450cefef..042db0fa 100644 --- a/src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx +++ b/src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx @@ -1,22 +1,110 @@ // src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx -// 复用的加载状态组件 +// 复用的加载状态组件 - 支持骨架屏 -import React from "react"; -import { Center, VStack, Spinner, Text } from "@chakra-ui/react"; +import React, { memo } from "react"; +import { + Center, + VStack, + Spinner, + Text, + Box, + Skeleton, + SimpleGrid, + HStack, +} from "@chakra-ui/react"; import { THEME } from "../config"; +// 骨架屏颜色配置 +const SKELETON_COLORS = { + startColor: "rgba(26, 32, 44, 0.6)", + endColor: "rgba(212, 175, 55, 0.2)", +}; + interface LoadingStateProps { message?: string; height?: string; + /** 使用骨架屏模式(更好的视觉体验) */ + variant?: "spinner" | "skeleton"; + /** 骨架屏类型:grid(网格布局)或 list(列表布局) */ + skeletonType?: "grid" | "list"; + /** 骨架屏项目数量 */ + skeletonCount?: number; } /** - * 加载状态组件(黑金主题) + * 网格骨架屏(用于披露日程等) */ -const LoadingState: React.FC = ({ +const GridSkeleton: React.FC<{ count: number }> = memo(({ count }) => ( + + {Array.from({ length: count }).map((_, i) => ( + + ))} + +)); + +GridSkeleton.displayName = "GridSkeleton"; + +/** + * 列表骨架屏(用于公告列表等) + */ +const ListSkeleton: React.FC<{ count: number }> = memo(({ count }) => ( + + {Array.from({ length: count }).map((_, i) => ( + + + + + + + + + + + + + ))} + +)); + +ListSkeleton.displayName = "ListSkeleton"; + +/** + * 加载状态组件(黑金主题) + * + * @param variant - "spinner"(默认)或 "skeleton"(骨架屏) + * @param skeletonType - 骨架屏类型:"grid" 或 "list" + */ +const LoadingState: React.FC = memo(({ message = "加载中...", height = "200px", + variant = "spinner", + skeletonType = "list", + skeletonCount = 4, }) => { + if (variant === "skeleton") { + return ( + + {skeletonType === "grid" ? ( + + ) : ( + + )} + + ); + } + return (
@@ -27,6 +115,8 @@ const LoadingState: React.FC = ({
); -}; +}); + +LoadingState.displayName = "LoadingState"; export default LoadingState;