From 53249daf8d3917cf0f020d2c268dbd17ef126145 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 17 Jun 2026 17:22:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B6=E6=A0=8F=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E4=B8=8E=E7=99=BB=E5=BD=95=E8=AF=B7=E6=B1=82=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/axios.test.ts | 70 + frontend/src/api/axios.ts | 49 +- frontend/src/components/Layout.vue | 1274 +++++++++++++++-- .../src/components/LayoutBreadcrumb.test.ts | 167 ++- frontend/src/locales/zh-CN.ts | 15 + frontend/src/views/Login.test.ts | 23 +- frontend/src/views/Login.vue | 59 +- .../views/admin/SystemMonitoringPage.test.ts | 17 + .../src/views/admin/SystemMonitoringPage.vue | 8 +- 9 files changed, 1479 insertions(+), 203 deletions(-) create mode 100644 frontend/src/api/axios.test.ts create mode 100644 frontend/src/views/admin/SystemMonitoringPage.test.ts diff --git a/frontend/src/api/axios.test.ts b/frontend/src/api/axios.test.ts new file mode 100644 index 00000000..417b2060 --- /dev/null +++ b/frontend/src/api/axios.test.ts @@ -0,0 +1,70 @@ +import type { AxiosAdapter, AxiosError, InternalAxiosRequestConfig } from "axios"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const errorMessage = vi.fn(); + +vi.mock("element-plus", () => ({ + ElMessage: { + error: errorMessage, + }, +})); + +vi.mock("../router", () => ({ + default: { + push: vi.fn(), + }, +})); + +vi.mock("../utils/auth", () => ({ + getToken: vi.fn(() => undefined), +})); + +vi.mock("../store/study", () => ({ + useStudyStore: vi.fn(() => ({ + clearCurrentStudy: vi.fn(), + })), +})); + +vi.mock("../session/sessionManager", () => ({ + extendAccessToken: vi.fn(), + forceLogout: vi.fn(), + LOGOUT_REASON_AUTH_EXPIRED: "auth-expired", +})); + +describe("api axios retry", () => { + beforeEach(() => { + vi.useFakeTimers(); + errorMessage.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("retries network failures 10 times at 30 second intervals before showing one error", async () => { + const api = (await import("./axios")).default; + const adapter = vi.fn(async (config) => { + const error = new Error("Network Error") as AxiosError; + error.config = config as InternalAxiosRequestConfig; + error.isAxiosError = true; + return Promise.reject(error); + }); + + const request = api.get("/api/v1/projects", { adapter }).catch((error) => error); + await vi.dynamicImportSettled(); + + expect(adapter).toHaveBeenCalledTimes(1); + expect(errorMessage).not.toHaveBeenCalled(); + + for (let retry = 1; retry < 10; retry += 1) { + await vi.advanceTimersByTimeAsync(30000); + expect(adapter).toHaveBeenCalledTimes(retry + 1); + expect(errorMessage).not.toHaveBeenCalled(); + } + + await vi.advanceTimersByTimeAsync(30000); + expect(adapter).toHaveBeenCalledTimes(11); + await expect(request).resolves.toMatchObject({ message: "Network Error" }); + expect(errorMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 41258d0a..43af1750 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -1,20 +1,41 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios"; import { ElMessage } from "element-plus"; -import router from "../router"; import { getToken } from "../utils/auth"; import type { ApiError } from "../types/api"; -import { useStudyStore } from "../store/study"; import { TEXT } from "../locales"; -import { extendAccessToken, forceLogout, LOGOUT_REASON_AUTH_EXPIRED } from "../session/sessionManager"; const instance: AxiosInstance = axios.create({ baseURL: "/", timeout: 15000, }); +const NETWORK_RETRY_LIMIT = 10; +const NETWORK_RETRY_DELAY_MS = 30000; + export type ApiRequestConfig = AxiosRequestConfig & { suppressErrorMessage?: boolean; _retry?: boolean; + _networkRetryCount?: number; +}; + +const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +const clearInvalidStudyAndNavigate = async () => { + try { + const [{ useStudyStore }, { default: router }] = await Promise.all([ + import("../store/study"), + import("../router"), + ]); + useStudyStore().clearCurrentStudy(); + router.push("/admin/projects"); + } catch { + /* ignore */ + } +}; + +const forceAuthExpiredLogout = async () => { + const { forceLogout, LOGOUT_REASON_AUTH_EXPIRED } = await import("../session/sessionManager"); + forceLogout(LOGOUT_REASON_AUTH_EXPIRED); }; instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => { @@ -40,19 +61,23 @@ instance.interceptors.response.use( // 认证相关的错误由具体页面自行处理,避免重复提示 return Promise.reject(error); } + if (!status && error.config) { + const config = error.config as ApiRequestConfig; + const retryCount = config._networkRetryCount || 0; + if (retryCount < NETWORK_RETRY_LIMIT) { + config._networkRetryCount = retryCount + 1; + await wait(NETWORK_RETRY_DELAY_MS); + return instance(config); + } + } // 如果当前项目不存在或已失效,清理项目并跳到项目管理页 if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) { - try { - const studyStore = useStudyStore(); - studyStore.clearCurrentStudy(); - } catch { - /* ignore */ - } - router.push("/admin/projects"); + await clearInvalidStudyAndNavigate(); } if (status === 401) { const config = error.config as ApiRequestConfig; if (config && !config._retry) { + const { extendAccessToken } = await import("../session/sessionManager"); const result = await extendAccessToken("response-401"); if (result.token) { config._retry = true; @@ -61,11 +86,11 @@ instance.interceptors.response.use( return instance(config); } if (result.authFailed) { - forceLogout(LOGOUT_REASON_AUTH_EXPIRED); + await forceAuthExpiredLogout(); } } } else if (status === 403 && (data as any)?.detail === "账号已停用") { - forceLogout(LOGOUT_REASON_AUTH_EXPIRED); + await forceAuthExpiredLogout(); } else { const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage; if (!suppressErrorMessage) { diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 6545a018..9e9d67c3 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -126,7 +126,7 @@ - + + + + + + + + - - - / + + +
+ +
+
+ + + + +