diff --git a/src/App.vue b/src/App.vue index 394d2f50..a68e85a1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -31,6 +31,7 @@ import { getDisplayImageUrl } from '@/utils/imageUtils' import { normalizeThemeMaterialAccent } from '@/utils/glassColor' import { configureApexChartsTheme } from '@/utils/apexCharts' import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' +import { useSystemRestartStatus } from '@/composables/useSystemRestart' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction' import { @@ -156,6 +157,7 @@ const route = useRoute() const router = useRouter() const { initializePWA } = usePWA() const offlineStatus = useGlobalOfflineStatus() +const { isRestarting: isSystemRestarting } = useSystemRestartStatus() // 全局设置store const globalSettingsStore = useGlobalSettingsStore() @@ -440,6 +442,9 @@ async function probeServerConnection(showChecking = false): Promise { connectionProbeFailures += 1 const failureReason = resolveProbeFailureReason(error) + // 重启期间服务不可达属预期行为,由重启进度弹窗承载反馈,不累计离线阈值。 + if (isSystemRestarting.value) return false + if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) { offlineStatus.markServerOffline(failureReason) } else { diff --git a/src/api/__tests__/client.spec.ts b/src/api/__tests__/client.spec.ts index a3684a34..abe0750e 100644 --- a/src/api/__tests__/client.spec.ts +++ b/src/api/__tests__/client.spec.ts @@ -195,6 +195,49 @@ describe('MoviePilot API client', () => { expect(reportConnectionFailure).not.toHaveBeenCalled() }) + it.each([502, 503, 504])('网关错误 %d 上报连接失败且不弹请求层 Toast', async status => { + const reportConnectionFailure = vi.fn() + const { api } = createApiClients({ + adapter: rejectWith({ message: 'Gateway unavailable' }, status), + hooks: { reportConnectionFailure }, + notifier, + }) + + const error = requireApiRequestError(await api.get('/resource').catch(reason => reason)) + + expect(error.status).toBe(status) + expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable') + expect(notifier.error).not.toHaveBeenCalled() + }) + + it('网络错误仅上报离线状态,不弹请求层 Toast', async () => { + const reportConnectionFailure = vi.fn() + const adapter: AxiosAdapter = async () => { + throw new AxiosError('Network Error', AxiosError.ERR_NETWORK) + } + const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier }) + + await api.get('/resource').catch(reason => reason) + + expect(reportConnectionFailure).toHaveBeenCalledWith('network-error') + expect(notifier.error).not.toHaveBeenCalled() + }) + + it('网关错误不算服务在线证据,仅成功响应恢复在线状态', async () => { + const markServerOnline = vi.fn() + const reportConnectionFailure = vi.fn() + const { api } = createApiClients({ + adapter: rejectWith({ message: 'Bad Gateway' }, 502), + hooks: { markServerOnline, reportConnectionFailure }, + notifier, + }) + + await api.get('/resource').catch(reason => reason) + + expect(markServerOnline).not.toHaveBeenCalled() + expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable') + }) + it('拒绝缺少标准字段的普通 JSON 响应', async () => { const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier }) diff --git a/src/api/client.ts b/src/api/client.ts index ded9cb21..427f7a30 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -78,7 +78,7 @@ export interface ApiFeedbackNotifier { export interface ApiClientHooks { markServerOnline?(): void onForbidden?(error: ApiRequestError): void - reportConnectionFailure?(reason: 'network-error' | 'timeout'): void + reportConnectionFailure?(reason: 'network-error' | 'timeout' | 'server-unreachable'): void } /** 创建内部数据客户端与插件原始协议客户端时所需的配置。 */ @@ -158,12 +158,21 @@ export function isApiResponse(value: unknown): value is ApiResponse return typeof record.success === 'boolean' && typeof record.message === 'string' && 'data' in record } -/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */ -export function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null { +/** + * 将 Axios 连接错误归类为全局服务探测可识别的原因。 + * + * 网关不可用状态码(502/503/504)同样视为服务不可达:后端重启或崩溃时网关 + * 会返回这类响应,若只按“无响应”判断会漏掉重启场景的离线检测。 + */ +export function resolveConnectionFailureReason( + error: AxiosError, +): 'network-error' | 'timeout' | 'server-unreachable' | null { if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout' if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') { return 'network-error' } + const status = error.response?.status + if (status === 502 || status === 503 || status === 504) return 'server-unreachable' return null } @@ -241,7 +250,8 @@ function installResponseInterceptors( const original = reason instanceof AxiosError ? reason : undefined const response = original?.response ? await normalizeErrorResponse(original.response) : undefined - if (response) hooks?.markServerOnline?.() + // 只有成功响应才算服务在线证据;网关错误响应说明后端当前不可达,不能恢复在线状态。 + if (response && response.status >= 200 && response.status < 300) hooks?.markServerOnline?.() const payload = response?.data const error = new ApiRequestError(resolveErrorMessage(payload, original, resolveFallbackMessage), { @@ -255,12 +265,14 @@ function installResponseInterceptors( const requestConfig = original?.config const failureReason = original ? resolveConnectionFailureReason(original) : null - if (!response && !requestConfig?.skipConnectionTracking && failureReason) { + if (!requestConfig?.skipConnectionTracking && failureReason) { hooks?.reportConnectionFailure?.(failureReason) } if (response?.status === 403) hooks?.onForbidden?.(error) - notifyFailure(requestConfig?.feedback, notifier, error.message) + // 连接类失败(无响应、超时、网关不可用)统一交给离线状态系统按阈值提示, + // 不在请求层逐个弹出,避免后端重启时刷屏。 + if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message) return Promise.reject(error) }, ) diff --git a/src/composables/__tests__/useSystemRestart.spec.ts b/src/composables/__tests__/useSystemRestart.spec.ts new file mode 100644 index 00000000..a4009152 --- /dev/null +++ b/src/composables/__tests__/useSystemRestart.spec.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useSystemRestartStatus } from '@/composables/useSystemRestart' + +describe('useSystemRestartStatus', () => { + beforeEach(() => { + // 每个用例从非重启状态开始,避免模块级状态在用例间残留。 + useSystemRestartStatus().finishSystemRestart() + }) + + it('重启状态在多个调用方之间共享', () => { + const first = useSystemRestartStatus() + const second = useSystemRestartStatus() + + first.startSystemRestart() + expect(second.isRestarting.value).toBe(true) + + second.finishSystemRestart() + expect(first.isRestarting.value).toBe(false) + }) + + it('默认处于非重启状态', () => { + expect(useSystemRestartStatus().isRestarting.value).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/composables/useSystemRestart.ts b/src/composables/useSystemRestart.ts new file mode 100644 index 00000000..8736f47b --- /dev/null +++ b/src/composables/useSystemRestart.ts @@ -0,0 +1,23 @@ +import { ref } from 'vue' + +/** 全局系统重启状态,重启入口写入,离线探测与连接提示按此状态抑制。 */ +const isRestarting = ref(false) + +/** 管理 MoviePilot 系统重启的全局状态。 */ +export function useSystemRestartStatus() { + /** 标记系统进入重启流程(此后服务不可达属预期行为)。 */ + function startSystemRestart() { + isRestarting.value = true + } + + /** 结束重启流程,恢复常规连接状态提示。 */ + function finishSystemRestart() { + isRestarting.value = false + } + + return { + isRestarting, + startSystemRestart, + finishSystemRestart, + } +} \ No newline at end of file diff --git a/src/layouts/default/components/OfflinePage.vue b/src/layouts/default/components/OfflinePage.vue index 66878bef..02585699 100644 --- a/src/layouts/default/components/OfflinePage.vue +++ b/src/layouts/default/components/OfflinePage.vue @@ -1,10 +1,12 @@