fix(ui): stabilize authenticated page requests

This commit is contained in:
jxxghp
2026-08-13 07:20:49 +08:00
parent 0ea12f75f7
commit 2b0325e02b
13 changed files with 75 additions and 27 deletions
+2
View File
@@ -418,6 +418,8 @@ async function probeServerConnection(showChecking = false): Promise<boolean> {
const probePromise = (async () => {
try {
await api.get('system/ping', {
feedback: 'silent',
skipNavigationCancellation: true,
skipConnectionTracking: true,
timeout: SERVER_PROBE_TIMEOUT_MS,
} as ConnectionAwareRequestConfig)
+1
View File
@@ -19,6 +19,7 @@ export type ApiFallbackMessageResolver = (key: ApiFallbackMessageKey) => string
declare module 'axios' {
interface AxiosRequestConfig {
feedback?: ApiFeedbackMode
skipNavigationCancellation?: boolean
skipConnectionTracking?: boolean
}
}
@@ -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 }),
+2 -2
View File
@@ -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',
})
}
+7 -3
View File
@@ -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 = {
@@ -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({})
+2 -2
View File
@@ -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
}
+16 -6
View File
@@ -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)
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
</div>
<div class="dashboard-chart-footer">
<span><i class="network-dot network-dot--upload" />{{ t('dashboard.upload') }} {{ animatedCurrentUploadText }}</span>
<span><i class="network-dot network-dot--download" />{{ t('dashboard.download') }} {{ animatedCurrentDownloadText }}</span>
<span
><i class="network-dot network-dot--upload" />{{ t('dashboard.upload') }}
{{ animatedCurrentUploadText }}</span
>
<span
><i class="network-dot network-dot--download" />{{ t('dashboard.download') }}
{{ animatedCurrentDownloadText }}</span
>
</div>
</VCardText>
</VCard>
@@ -265,5 +276,4 @@ useKeepAliveRefresh(refresh)
.network-dot--download {
background: rgb(var(--v-theme-info));
}
</style>
@@ -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: '重试' }))
+12 -4
View File
@@ -1519,13 +1519,17 @@ onUnmounted(() => {
<template #item.src="{ item }">
<div>
<span>
<VChip variant="tonal" size="small" label class="my-1"> {{ storageDict[item?.src_storage || ''] }}</VChip>
<VChip variant="tonal" size="small" label class="my-1">
{{ getHistoryStorageName(item?.src_storage) }}
</VChip>
<small>{{ item?.src }}</small>
</span>
<span class="text-high-emphasis text-bold"> => </span>
<br />
<span v-if="item?.dest">
<VChip variant="tonal" size="small" label class="my-1"> {{ storageDict[item?.dest_storage || ''] }}</VChip>
<VChip variant="tonal" size="small" label class="my-1">
{{ getHistoryStorageName(item?.dest_storage) }}
</VChip>
<small>{{ item?.dest }}</small>
</span>
</div>
@@ -1621,13 +1625,17 @@ onUnmounted(() => {
<template #item.src="{ item }">
<div>
<span>
<VChip variant="tonal" size="small" label class="my-1"> {{ storageDict[item?.src_storage || ''] }}</VChip>
<VChip variant="tonal" size="small" label class="my-1">
{{ getHistoryStorageName(item?.src_storage) }}
</VChip>
<small>{{ item?.src }}</small>
</span>
<span class="text-high-emphasis text-bold"> => </span>
<br />
<span v-if="item?.dest">
<VChip variant="tonal" size="small" label class="my-1"> {{ storageDict[item?.dest_storage || ''] }}</VChip>
<VChip variant="tonal" size="small" label class="my-1">
{{ getHistoryStorageName(item?.dest_storage) }}
</VChip>
<small>{{ item?.dest }}</small>
</span>
</div>
@@ -587,6 +587,13 @@ describe('TransferHistoryView', () => {
expect(screen.getByRole('button', { name: '批量选择' })).toBeInTheDocument()
})
it('uses the storage-name fallback for both grouped and ungrouped desktop paths', () => {
expect(transferHistorySource.match(/getHistoryStorageName\(item\?\.src_storage\)/g)).toHaveLength(3)
expect(transferHistorySource.match(/getHistoryStorageName\(item\?\.dest_storage\)/g)).toHaveLength(3)
expect(transferHistorySource).not.toContain("storageDict[item?.src_storage || '']")
expect(transferHistorySource).not.toContain("storageDict[item?.dest_storage || '']")
})
it('shows actual audio specs in mobile music history', async () => {
mocks.desktop = false
const item = createHistory(2, '晴天', {
@@ -418,6 +418,8 @@ describe('FullCalendarView', () => {
})
it('recovers from a failed list request when the kept-alive view is activated again', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-08-10T12:00:00+08:00'))
setViewport(480)
const recovered = movieSubscribe(3701, '恢复后的电影')
const onListRequest = vi.fn()