fix: 修复接口重复调用 — StrictMode 清理 + useModelList 去重
AppProvider useEffect 增加 aborted 标志,防止 React StrictMode 挂载→卸载→重新挂载时 refreshToken/getUserInfo 各调两次。 useModelList 提升到 LeftPanel 单例调用,通过 props 分发给 ModelSelector 和 TaskHistory,消除 /api/v1/models 重复请求。 DevTools 中显示「已停止」的请求即 StrictMode 第一轮挂载被丢弃的请求。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 → 自动重试
|
### 3. 401 → 刷新 Token → 自动重试
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -3,6 +3,16 @@
|
|||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`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)
|
## 0.0.16(2026-06-09)
|
||||||
|
|
||||||
**模型输入栏重构 + 统一页面调度 + 依赖联动**
|
**模型输入栏重构 + 统一页面调度 + 依赖联动**
|
||||||
|
|||||||
1
TODO.md
1
TODO.md
@@ -48,6 +48,7 @@
|
|||||||
- [ ] 统一 IPC 通道注册/注销模式(避免重复监听)
|
- [ ] 统一 IPC 通道注册/注销模式(避免重复监听)
|
||||||
- [ ] React 组件 lazy loading(React.lazy + Suspense)
|
- [ ] React 组件 lazy loading(React.lazy + Suspense)
|
||||||
- [ ] 错误边界组件(ErrorBoundary)
|
- [ ] 错误边界组件(ErrorBoundary)
|
||||||
|
- [ ] 审计其余 useEffect 缺少清理函数的位置(StrictMode 双重挂载可能导致重复请求,参考 AppProvider 的 aborted 模式)
|
||||||
|
|
||||||
### 文档
|
### 文档
|
||||||
|
|
||||||
|
|||||||
@@ -107,10 +107,17 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
||||||
|
//
|
||||||
|
// 注意:React StrictMode(开发模式)会挂载→卸载→重新挂载以暴露副作用问题。
|
||||||
|
// aborted 标志确保卸载后不再执行异步回调,避免 refreshToken / getUserInfo 重复请求。
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let aborted = false;
|
||||||
|
|
||||||
// 从加密存储恢复 token 到内存缓存
|
// 从加密存储恢复 token 到内存缓存
|
||||||
Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => {
|
Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => {
|
||||||
|
if (aborted) return;
|
||||||
|
|
||||||
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
||||||
initAuthRefresh();
|
initAuthRefresh();
|
||||||
|
|
||||||
@@ -122,12 +129,14 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
|
|
||||||
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
if (aborted) return;
|
||||||
await setToken(res.access_token);
|
await setToken(res.access_token);
|
||||||
await setStoredRefreshToken(res.refresh_token);
|
await setStoredRefreshToken(res.refresh_token);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
logger.info('auth', 'Auto-login succeeded');
|
logger.info('auth', 'Auto-login succeeded');
|
||||||
// 补全用户信息
|
// 补全用户信息
|
||||||
AuthAPI.getUserInfo().then((info) => {
|
AuthAPI.getUserInfo().then((info) => {
|
||||||
|
if (aborted) return;
|
||||||
const u: LoginResponseBody = {
|
const u: LoginResponseBody = {
|
||||||
access_token: res.access_token,
|
access_token: res.access_token,
|
||||||
refresh_token: res.refresh_token,
|
refresh_token: res.refresh_token,
|
||||||
@@ -152,12 +161,15 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
if (aborted) return;
|
||||||
logger.warn('auth', 'Auto-login failed, refresh_token expired');
|
logger.warn('auth', 'Auto-login failed, refresh_token expired');
|
||||||
clearStoredRefreshToken();
|
clearStoredRefreshToken();
|
||||||
// 仅清除自动登录标记,保留用户名供下次表单回填
|
// 仅清除自动登录标记,保留用户名供下次表单回填
|
||||||
clearAutoLoginFlag();
|
clearAutoLoginFlag();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => { aborted = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 监听主进程推送的平台/版本信息 ----------
|
// ---------- 监听主进程推送的平台/版本信息 ----------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
|||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { ModelSelector } from './ModelSelector';
|
import { ModelSelector } from './ModelSelector';
|
||||||
import { TaskHistory } from './TaskHistory';
|
import { TaskHistory } from './TaskHistory';
|
||||||
|
import { useModelList } from '@/hooks/use-api';
|
||||||
|
|
||||||
// ---------- 常量 ----------
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ export function LeftPanel() {
|
|||||||
// 面板容器引用
|
// 面板容器引用
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// ======== 模型数据(提升到父组件,避免两个子组件重复请求) ========
|
||||||
|
const { data: models, loading, error, groupedModels, refetch } = useModelList();
|
||||||
|
|
||||||
// 模型选择区高度(像素)
|
// 模型选择区高度(像素)
|
||||||
const [topHeight, setTopHeight] = useState<number | null>(null);
|
const [topHeight, setTopHeight] = useState<number | null>(null);
|
||||||
// 拖拽状态
|
// 拖拽状态
|
||||||
@@ -132,7 +136,13 @@ export function LeftPanel() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModelSelector />
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
groupedModels={groupedModels}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 纵向拖拽分隔条 */}
|
{/* 纵向拖拽分隔条 */}
|
||||||
@@ -172,7 +182,7 @@ export function LeftPanel() {
|
|||||||
|
|
||||||
{/* 下方:任务记录(剩余高度) */}
|
{/* 下方:任务记录(剩余高度) */}
|
||||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
<TaskHistory />
|
<TaskHistory allModels={models} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表)
|
// ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表)
|
||||||
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
|
// 模型数据由父组件 LeftPanel 通过 props 传入,避免多个兄弟组件各自请求
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
||||||
import { AppstoreOutlined } from '@ant-design/icons';
|
import { AppstoreOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useModelList } from '@/hooks/use-api';
|
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
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<string, ModelsListResponseBody[]>;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({ models, loading, error, groupedModels, refetch }: ModelSelectorProps) {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
const { selectedModel, setSelectedModel } = useHomeContext();
|
const { selectedModel, setSelectedModel } = useHomeContext();
|
||||||
const { data: models, loading, error, groupedModels, refetch } = useModelList();
|
|
||||||
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
|
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
|
||||||
|
|
||||||
// 当前 Tab 对应的模型列表
|
// 当前 Tab 对应的模型列表
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import type { ColumnsType, TableProps } from 'antd/es/table';
|
|||||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||||
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
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 { useAppContext } from '@/components/AppProvider';
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
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 { token } = antTheme.useToken();
|
||||||
const { edition } = useAppContext();
|
const { edition } = useAppContext();
|
||||||
const {
|
const {
|
||||||
@@ -210,10 +216,8 @@ export function TaskHistory() {
|
|||||||
}));
|
}));
|
||||||
}, [taskPage, taskPageSize]);
|
}, [taskPage, taskPageSize]);
|
||||||
|
|
||||||
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)--------
|
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射,由父组件传入)--------
|
||||||
|
|
||||||
|
|
||||||
const { data: allModels } = useModelList();
|
|
||||||
const modelFilters:{text:string, value:string}[] = useMemo(() => {
|
const modelFilters:{text:string, value:string}[] = useMemo(() => {
|
||||||
if (!allModels) return [];
|
if (!allModels) return [];
|
||||||
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
||||||
|
|||||||
Reference in New Issue
Block a user