diff --git a/src/App.vue b/src/App.vue index bae25403..394d2f50 100644 --- a/src/App.vue +++ b/src/App.vue @@ -418,6 +418,8 @@ async function probeServerConnection(showChecking = false): Promise { const probePromise = (async () => { try { await api.get('system/ping', { + feedback: 'silent', + skipNavigationCancellation: true, skipConnectionTracking: true, timeout: SERVER_PROBE_TIMEOUT_MS, } as ConnectionAwareRequestConfig) diff --git a/src/api/client.ts b/src/api/client.ts index 8ab62f8a..ded9cb21 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -19,6 +19,7 @@ export type ApiFallbackMessageResolver = (key: ApiFallbackMessageKey) => string declare module 'axios' { interface AxiosRequestConfig { feedback?: ApiFeedbackMode + skipNavigationCancellation?: boolean skipConnectionTracking?: boolean } } diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index 99cc5005..8884a5e4 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -261,7 +261,7 @@ describe('MediaCard', () => { const existsRequest = vi.fn<(url: URL) => void>() server.use( querySubscribeByMediaHandler('9102', { id: 72 }, 200, subscribeRequest), - mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), + mediaExistsHandler({ data: { item: {} }, success: true }, 200, existsRequest), ) const Harness = { @@ -320,7 +320,7 @@ describe('MediaCard', () => { const subscribeRequest = vi.fn<(url: URL) => void>() server.use( querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest), - mediaExistsHandler({ data: { item: {} }, success: false }), + mediaExistsHandler({ data: { item: {} }, success: true }), ) await renderCard(media) @@ -342,7 +342,7 @@ describe('MediaCard', () => { const removeListener = vi.spyOn(document, 'removeEventListener') server.use( querySubscribeByMediaHandler('9301', {}, 200, subscribeRequest), - mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), + mediaExistsHandler({ data: { item: {} }, success: true }, 200, existsRequest), ) const { unmount } = await renderCard(createMediaInfo({ collection_id: 44, tmdb_id: 9301 })) @@ -395,7 +395,7 @@ describe('MediaCard', () => { const existsRequest = vi.fn<(url: URL) => void>() server.use( querySubscribeByMediaHandler(musicBrainzRecordingId, {}, 200, subscribeRequest), - mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), + mediaExistsHandler({ data: { item: {} }, success: true }, 200, existsRequest), ) const { container } = await renderCard(media) @@ -619,7 +619,7 @@ describe('MediaCard', () => { const subscribeListRequest = vi.fn<(url: URL) => void>() server.use( querySubscribeByMediaHandler('9551', { id: 81, season: 2 }), - mediaExistsHandler({ data: { item: {} }, success: false }), + mediaExistsHandler({ data: { item: {} }, success: true }), subscribeListHandler( [ { best_version: 0, id: 81, media_id: '9551', media_source: 'themoviedb', season: 3, type: '电视剧' }, @@ -681,7 +681,7 @@ describe('MediaCard', () => { }) server.use( querySubscribeByMediaHandler('series-9553', { id: 91, season: 2 }), - mediaExistsHandler({ data: { item: {} }, success: false }), + mediaExistsHandler({ data: { item: {} }, success: true }), subscribeListHandler([ { id: 91, media_id: 'series-9553', media_source: 'bilibili', season: 2, type: '电视剧' }, { id: 92, media_id: 'other', media_source: 'bilibili', season: 5, type: '电视剧' }, @@ -739,7 +739,7 @@ describe('MediaCard', () => { ])('matches %s when collecting subscribed TV seasons', async (_label, media, mediaId, subscribes, expected) => { server.use( querySubscribeByMediaHandler(mediaId, { id: 93, season: 2 }), - mediaExistsHandler({ data: { item: {} }, success: false }), + mediaExistsHandler({ data: { item: {} }, success: true }), subscribeListHandler(subscribes), http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () => HttpResponse.json({ data: { value: {} }, success: true }), diff --git a/src/pages/__tests__/login.spec.ts b/src/pages/__tests__/login.spec.ts index a54761ff..44e89182 100644 --- a/src/pages/__tests__/login.spec.ts +++ b/src/pages/__tests__/login.spec.ts @@ -706,11 +706,11 @@ describe('login page orchestration', () => { mocks.api.post.mockImplementation((url: string) => { if (url === '/mfa/passkey/authenticate/start') { return Promise.resolve({ - options: JSON.stringify({ + options: { allowCredentials: [{ id: 'AwQ', type: 'public-key' }], challenge: 'AQI', timeout: 60_000, - }), + }, transaction_token: 'transaction-1', }) } diff --git a/src/pages/login.vue b/src/pages/login.vue index 21f748fc..298dde9e 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -382,7 +382,7 @@ interface PassKeyAuthOptions { // PassKey API 响应类型 interface PassKeyStartResponse { - options: string // JSON 字符串 + options: SerializedPublicKeyRequestOptions | string transaction_token: string } @@ -411,8 +411,12 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis }, ) - const { options: optionsStr, transaction_token: transactionToken } = startResponse - const publicKeyOptions = JSON.parse(optionsStr) as SerializedPublicKeyRequestOptions + const { options: serializedOptions, transaction_token: transactionToken } = startResponse + // 兼容升级前返回 JSON 字符串的后端,新协议直接返回 WebAuthn 选项对象。 + const publicKeyOptions = + typeof serializedOptions === 'string' + ? (JSON.parse(serializedOptions) as SerializedPublicKeyRequestOptions) + : serializedOptions // 2. 调用WebAuthn API const credentialRequestOptions: CredentialRequestOptions = { diff --git a/src/utils/__tests__/requestOptimizer.spec.ts b/src/utils/__tests__/requestOptimizer.spec.ts index f47e0f4e..ed376e02 100644 --- a/src/utils/__tests__/requestOptimizer.spec.ts +++ b/src/utils/__tests__/requestOptimizer.spec.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' interface RequestConfigFake { signal?: AbortSignal + skipNavigationCancellation?: boolean } interface ResponseFake { @@ -119,6 +120,19 @@ describe('requestOptimizer', () => { expect(getActiveRequestsCount()).toBe(0) }) + it('不把跨路由心跳和轮询纳入导航取消', () => { + const interceptors = createAxiosInterceptorFake() + const config = interceptors.request.fulfilled({ skipNavigationCancellation: true }) + + expect(config.signal).toBeUndefined() + expect(getActiveRequestsCount()).toBe(0) + + setNavigatingState(true) + + expect(config.signal).toBeUndefined() + expect(getActiveRequestsCount()).toBe(0) + }) + it('导航开始时只取消当前活跃请求,导航结束不主动取消', () => { const interceptors = createAxiosInterceptorFake() const first = interceptors.request.fulfilled({}) diff --git a/src/utils/requestOptimizer.ts b/src/utils/requestOptimizer.ts index 0571712c..8ae498b5 100644 --- a/src/utils/requestOptimizer.ts +++ b/src/utils/requestOptimizer.ts @@ -35,8 +35,8 @@ export function initializeRequestOptimizer(axiosInstance: any) { // 拦截请求,自动添加 AbortController axiosInstance.interceptors.request.use( (config: any) => { - // 如果请求已经有 signal,跳过(避免覆盖手动设置的) - if (config.signal) { + // 心跳与轮询不属于页面生命周期,路由切换时应继续完成。 + if (config.signal || config.skipNavigationCancellation) { return config } diff --git a/src/views/dashboard/AnalyticsNetwork.vue b/src/views/dashboard/AnalyticsNetwork.vue index b2a97358..3ab44be8 100644 --- a/src/views/dashboard/AnalyticsNetwork.vue +++ b/src/views/dashboard/AnalyticsNetwork.vue @@ -52,7 +52,9 @@ const animatedCurrentUpload = useAnimatedDashboardNumber(currentUpload, { const animatedCurrentDownload = useAnimatedDashboardNumber(currentDownload, { duration: 520, }) -const animatedCurrentUploadText = computed(() => `${formatDashboardFileSize(animatedCurrentUpload.value, 2, currentUpload.value)}/s`) +const animatedCurrentUploadText = computed( + () => `${formatDashboardFileSize(animatedCurrentUpload.value, 2, currentUpload.value)}/s`, +) const animatedCurrentDownloadText = computed( () => `${formatDashboardFileSize(animatedCurrentDownload.value, 2, currentDownload.value)}/s`, ) @@ -168,7 +170,10 @@ async function getNetworkUsage() { if (!props.allowRefresh) return try { // 请求数据 - 接口返回 [上行流量, 下行流量] - const data: [number, number] = (await api.get('dashboard/network')) ?? [0, 0] + const data: [number, number] = (await api.get('dashboard/network', { + feedback: 'silent', + skipNavigationCancellation: true, + })) ?? [0, 0] currentUpload.value = Number(data[0]) || 0 currentDownload.value = Number(data[1]) || 0 @@ -194,7 +199,7 @@ const { refresh } = useDataRefresh( 'dashboard-network', getNetworkUsage, 2000, // 2秒间隔 - true // 立即执行 + true, // 立即执行 ) useKeepAliveRefresh(refresh) @@ -211,8 +216,14 @@ useKeepAliveRefresh(refresh) @@ -265,5 +276,4 @@ useKeepAliveRefresh(refresh) .network-dot--download { background: rgb(var(--v-theme-info)); } - diff --git a/src/views/discover/__tests__/MediaDetailView.spec.ts b/src/views/discover/__tests__/MediaDetailView.spec.ts index ad50203b..e255612a 100644 --- a/src/views/discover/__tests__/MediaDetailView.spec.ts +++ b/src/views/discover/__tests__/MediaDetailView.spec.ts @@ -153,7 +153,7 @@ async function renderDetail(options: RenderDetailOptions = {}) { server.use( mediaDetailsHandler(mediaId, media, options.detailStatus, options.detailRequest), mediaExistsHandler( - options.existsResponse ?? { data: { item: {} }, success: false }, + options.existsResponse ?? { data: { item: {} }, success: true }, options.existsStatus, existsRequest, ), @@ -346,7 +346,7 @@ describe('MediaDetailView detail and actions', () => { const subscribeRequested = vi.fn() server.use( mediaDetailsHandler('8302', createMediaInfo({ title: '重试成功', tmdb_id: 8302 })), - mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequested), + mediaExistsHandler({ data: { item: {} }, success: true }, 200, existsRequested), querySubscribeByMediaHandler('8302', {}, 200, subscribeRequested), ) await fireEvent.click(screen.getByRole('button', { name: '重试' })) diff --git a/src/views/reorganize/TransferHistoryView.vue b/src/views/reorganize/TransferHistoryView.vue index b7826ed6..fae39a2e 100644 --- a/src/views/reorganize/TransferHistoryView.vue +++ b/src/views/reorganize/TransferHistoryView.vue @@ -1519,13 +1519,17 @@ onUnmounted(() => {