diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 02a0864..77fc777 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,6 +111,10 @@ App 启动 → AppProvider useEffect → 否 → 跳过 ``` +> **StrictMode 清理**:该 useEffect 持有 `aborted` 标志,cleanup 返回 `() => { aborted = true }`。 +> 所有异步回调(`.then` / `.catch`)在操作 state 或发射事件前检查 `if (aborted) return`。 +> 这防止 React StrictMode(开发模式)挂载→卸载→重新挂载时发起重复的 refreshToken / getUserInfo 请求。 + ### 3. 401 → 刷新 Token → 自动重试 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index d226185..91b2a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 --- +## 0.0.17(2026-06-09) + +**修复接口重复调用 — 请求量减半** + +- AppProvider 自动登录 useEffect 增加 aborted 清理标志,修复 StrictMode 双重挂载导致 refreshToken/getUserInfo 各调两次 +- useModelList 提升到 LeftPanel 单例调用,修复 ModelSelector 和 TaskHistory 各自独立请求全量模型列表 +- 开发模式 DevTools 中"已停止"的请求即为 StrictMode 第一轮挂载被丢弃的请求 + +--- + ## 0.0.16(2026-06-09) **模型输入栏重构 + 统一页面调度 + 依赖联动** diff --git a/TODO.md b/TODO.md index 10aad2d..8222de1 100644 --- a/TODO.md +++ b/TODO.md @@ -48,6 +48,7 @@ - [ ] 统一 IPC 通道注册/注销模式(避免重复监听) - [ ] React 组件 lazy loading(React.lazy + Suspense) - [ ] 错误边界组件(ErrorBoundary) +- [ ] 审计其余 useEffect 缺少清理函数的位置(StrictMode 双重挂载可能导致重复请求,参考 AppProvider 的 aborted 模式) ### 文档 diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index f5ad76c..2d2e5af 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -107,10 +107,17 @@ export function AppProvider({ children }: AppProviderProps) { }, []); // ---------- 启动时初始化 token 存储 + 自动登录 ---------- + // + // 注意:React StrictMode(开发模式)会挂载→卸载→重新挂载以暴露副作用问题。 + // aborted 标志确保卸载后不再执行异步回调,避免 refreshToken / getUserInfo 重复请求。 useEffect(() => { + let aborted = false; + // 从加密存储恢复 token 到内存缓存 Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => { + if (aborted) return; + // 初始化 401 刷新回调(token 缓存就绪后才注册) initAuthRefresh(); @@ -122,12 +129,14 @@ export function AppProvider({ children }: AppProviderProps) { AuthAPI.refreshToken({ refresh_token: refreshToken }) .then(async (res) => { + if (aborted) return; await setToken(res.access_token); await setStoredRefreshToken(res.refresh_token); setIsLoggedIn(true); logger.info('auth', 'Auto-login succeeded'); // 补全用户信息 AuthAPI.getUserInfo().then((info) => { + if (aborted) return; const u: LoginResponseBody = { access_token: res.access_token, refresh_token: res.refresh_token, @@ -152,12 +161,15 @@ export function AppProvider({ children }: AppProviderProps) { }); }) .catch(() => { + if (aborted) return; logger.warn('auth', 'Auto-login failed, refresh_token expired'); clearStoredRefreshToken(); // 仅清除自动登录标记,保留用户名供下次表单回填 clearAutoLoginFlag(); }); }); + + return () => { aborted = true; }; }, []); // ---------- 监听主进程推送的平台/版本信息 ---------- diff --git a/src/pages/home/components/LeftPanel.tsx b/src/pages/home/components/LeftPanel.tsx index a7129f7..af2523d 100644 --- a/src/pages/home/components/LeftPanel.tsx +++ b/src/pages/home/components/LeftPanel.tsx @@ -9,6 +9,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { theme as antTheme } from 'antd'; import { ModelSelector } from './ModelSelector'; import { TaskHistory } from './TaskHistory'; +import { useModelList } from '@/hooks/use-api'; // ---------- 常量 ---------- @@ -29,6 +30,9 @@ export function LeftPanel() { // 面板容器引用 const containerRef = useRef(null); + // ======== 模型数据(提升到父组件,避免两个子组件重复请求) ======== + const { data: models, loading, error, groupedModels, refetch } = useModelList(); + // 模型选择区高度(像素) const [topHeight, setTopHeight] = useState(null); // 拖拽状态 @@ -132,7 +136,13 @@ export function LeftPanel() { flexShrink: 0, }} > - + {/* 纵向拖拽分隔条 */} @@ -172,7 +182,7 @@ export function LeftPanel() { {/* 下方:任务记录(剩余高度) */}
- +
); diff --git a/src/pages/home/components/ModelSelector.tsx b/src/pages/home/components/ModelSelector.tsx index 3f209ef..93deab3 100644 --- a/src/pages/home/components/ModelSelector.tsx +++ b/src/pages/home/components/ModelSelector.tsx @@ -1,13 +1,12 @@ // ============================================================ // ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表) -// 使用 useModelList() Hook 获取数据,组件仅负责渲染 +// 模型数据由父组件 LeftPanel 通过 props 传入,避免多个兄弟组件各自请求 // ============================================================ import React, { useState, useMemo, useCallback } from 'react'; import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd'; import { AppstoreOutlined } from '@ant-design/icons'; -import { useModelList } from '@/hooks/use-api'; import { useHomeContext } from '@/pages/home/HomeContent'; import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models'; @@ -97,10 +96,17 @@ function ModelItem({ model, isSelected, onSelect }: ModelItemProps) { // ---------- 主组件 ---------- -export function ModelSelector() { +interface ModelSelectorProps { + models: ModelsListResponseBody[] | null; + loading: boolean; + error: Error | null; + groupedModels: Map; + refetch: () => void; +} + +export function ModelSelector({ models, loading, error, groupedModels, refetch }: ModelSelectorProps) { const { token } = antTheme.useToken(); const { selectedModel, setSelectedModel } = useHomeContext(); - const { data: models, loading, error, groupedModels, refetch } = useModelList(); const [activeTab, setActiveTab] = useState(ALL_TAB_KEY); // 当前 Tab 对应的模型列表 diff --git a/src/pages/home/components/TaskHistory.tsx b/src/pages/home/components/TaskHistory.tsx index 0dd3476..6bc8ce1 100644 --- a/src/pages/home/components/TaskHistory.tsx +++ b/src/pages/home/components/TaskHistory.tsx @@ -17,7 +17,8 @@ import type { ColumnsType, TableProps } from 'antd/es/table'; import type { FilterValue, SorterResult } from 'antd/es/table/interface'; import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'; -import { useTaskList, useModelList } from '@/hooks/use-api'; +import { useTaskList } from '@/hooks/use-api'; +import type { ModelsListResponseBody } from '@/services/modules/models'; import { useAppContext } from '@/components/AppProvider'; import { useHomeContext } from '@/pages/home/HomeContent'; import { useTheme } from '@/components/ThemeProvider'; @@ -135,7 +136,12 @@ function pickMany(filters: TableFilters, key: string): string[] | undefined { // ---------- 组件 ---------- -export function TaskHistory() { +interface TaskHistoryProps { + /** 模型全量数据,由父组件 LeftPanel 传入(避免兄弟组件各自请求) */ + allModels: ModelsListResponseBody[] | null; +} + +export function TaskHistory({ allModels }: TaskHistoryProps) { const { token } = antTheme.useToken(); const { edition } = useAppContext(); const { @@ -210,10 +216,8 @@ export function TaskHistory() { })); }, [taskPage, taskPageSize]); - // -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)-------- + // -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射,由父组件传入)-------- - - const { data: allModels } = useModelList(); const modelFilters:{text:string, value:string}[] = useMemo(() => { if (!allModels) return []; return allModels.map((m) => ({ text: m.name, value: m.id }));