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 () => { const probePromise = (async () => {
try { try {
await api.get('system/ping', { await api.get('system/ping', {
feedback: 'silent',
skipNavigationCancellation: true,
skipConnectionTracking: true, skipConnectionTracking: true,
timeout: SERVER_PROBE_TIMEOUT_MS, timeout: SERVER_PROBE_TIMEOUT_MS,
} as ConnectionAwareRequestConfig) } as ConnectionAwareRequestConfig)
+1
View File
@@ -19,6 +19,7 @@ export type ApiFallbackMessageResolver = (key: ApiFallbackMessageKey) => string
declare module 'axios' { declare module 'axios' {
interface AxiosRequestConfig { interface AxiosRequestConfig {
feedback?: ApiFeedbackMode feedback?: ApiFeedbackMode
skipNavigationCancellation?: boolean
skipConnectionTracking?: boolean skipConnectionTracking?: boolean
} }
} }
@@ -261,7 +261,7 @@ describe('MediaCard', () => {
const existsRequest = vi.fn<(url: URL) => void>() const existsRequest = vi.fn<(url: URL) => void>()
server.use( server.use(
querySubscribeByMediaHandler('9102', { id: 72 }, 200, subscribeRequest), querySubscribeByMediaHandler('9102', { id: 72 }, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), mediaExistsHandler({ data: { item: {} }, success: true }, 200, existsRequest),
) )
const Harness = { const Harness = {
@@ -320,7 +320,7 @@ describe('MediaCard', () => {
const subscribeRequest = vi.fn<(url: URL) => void>() const subscribeRequest = vi.fn<(url: URL) => void>()
server.use( server.use(
querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest), querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }), mediaExistsHandler({ data: { item: {} }, success: true }),
) )
await renderCard(media) await renderCard(media)
@@ -342,7 +342,7 @@ describe('MediaCard', () => {
const removeListener = vi.spyOn(document, 'removeEventListener') const removeListener = vi.spyOn(document, 'removeEventListener')
server.use( server.use(
querySubscribeByMediaHandler('9301', {}, 200, subscribeRequest), 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 })) const { unmount } = await renderCard(createMediaInfo({ collection_id: 44, tmdb_id: 9301 }))
@@ -395,7 +395,7 @@ describe('MediaCard', () => {
const existsRequest = vi.fn<(url: URL) => void>() const existsRequest = vi.fn<(url: URL) => void>()
server.use( server.use(
querySubscribeByMediaHandler(musicBrainzRecordingId, {}, 200, subscribeRequest), querySubscribeByMediaHandler(musicBrainzRecordingId, {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), mediaExistsHandler({ data: { item: {} }, success: true }, 200, existsRequest),
) )
const { container } = await renderCard(media) const { container } = await renderCard(media)
@@ -619,7 +619,7 @@ describe('MediaCard', () => {
const subscribeListRequest = vi.fn<(url: URL) => void>() const subscribeListRequest = vi.fn<(url: URL) => void>()
server.use( server.use(
querySubscribeByMediaHandler('9551', { id: 81, season: 2 }), querySubscribeByMediaHandler('9551', { id: 81, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }), mediaExistsHandler({ data: { item: {} }, success: true }),
subscribeListHandler( subscribeListHandler(
[ [
{ best_version: 0, id: 81, media_id: '9551', media_source: 'themoviedb', season: 3, type: '电视剧' }, { best_version: 0, id: 81, media_id: '9551', media_source: 'themoviedb', season: 3, type: '电视剧' },
@@ -681,7 +681,7 @@ describe('MediaCard', () => {
}) })
server.use( server.use(
querySubscribeByMediaHandler('series-9553', { id: 91, season: 2 }), querySubscribeByMediaHandler('series-9553', { id: 91, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }), mediaExistsHandler({ data: { item: {} }, success: true }),
subscribeListHandler([ subscribeListHandler([
{ id: 91, media_id: 'series-9553', media_source: 'bilibili', season: 2, type: '电视剧' }, { id: 91, media_id: 'series-9553', media_source: 'bilibili', season: 2, type: '电视剧' },
{ id: 92, media_id: 'other', media_source: 'bilibili', season: 5, 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) => { ])('matches %s when collecting subscribed TV seasons', async (_label, media, mediaId, subscribes, expected) => {
server.use( server.use(
querySubscribeByMediaHandler(mediaId, { id: 93, season: 2 }), querySubscribeByMediaHandler(mediaId, { id: 93, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }), mediaExistsHandler({ data: { item: {} }, success: true }),
subscribeListHandler(subscribes), subscribeListHandler(subscribes),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () => http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: {} }, success: true }), HttpResponse.json({ data: { value: {} }, success: true }),
+2 -2
View File
@@ -706,11 +706,11 @@ describe('login page orchestration', () => {
mocks.api.post.mockImplementation((url: string) => { mocks.api.post.mockImplementation((url: string) => {
if (url === '/mfa/passkey/authenticate/start') { if (url === '/mfa/passkey/authenticate/start') {
return Promise.resolve({ return Promise.resolve({
options: JSON.stringify({ options: {
allowCredentials: [{ id: 'AwQ', type: 'public-key' }], allowCredentials: [{ id: 'AwQ', type: 'public-key' }],
challenge: 'AQI', challenge: 'AQI',
timeout: 60_000, timeout: 60_000,
}), },
transaction_token: 'transaction-1', transaction_token: 'transaction-1',
}) })
} }
+7 -3
View File
@@ -382,7 +382,7 @@ interface PassKeyAuthOptions {
// PassKey API 响应类型 // PassKey API 响应类型
interface PassKeyStartResponse { interface PassKeyStartResponse {
options: string // JSON 字符串 options: SerializedPublicKeyRequestOptions | string
transaction_token: string transaction_token: string
} }
@@ -411,8 +411,12 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
}, },
) )
const { options: optionsStr, transaction_token: transactionToken } = startResponse const { options: serializedOptions, transaction_token: transactionToken } = startResponse
const publicKeyOptions = JSON.parse(optionsStr) as SerializedPublicKeyRequestOptions // 兼容升级前返回 JSON 字符串的后端,新协议直接返回 WebAuthn 选项对象。
const publicKeyOptions =
typeof serializedOptions === 'string'
? (JSON.parse(serializedOptions) as SerializedPublicKeyRequestOptions)
: serializedOptions
// 2. 调用WebAuthn API // 2. 调用WebAuthn API
const credentialRequestOptions: CredentialRequestOptions = { const credentialRequestOptions: CredentialRequestOptions = {
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
interface RequestConfigFake { interface RequestConfigFake {
signal?: AbortSignal signal?: AbortSignal
skipNavigationCancellation?: boolean
} }
interface ResponseFake { interface ResponseFake {
@@ -119,6 +120,19 @@ describe('requestOptimizer', () => {
expect(getActiveRequestsCount()).toBe(0) 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('导航开始时只取消当前活跃请求,导航结束不主动取消', () => { it('导航开始时只取消当前活跃请求,导航结束不主动取消', () => {
const interceptors = createAxiosInterceptorFake() const interceptors = createAxiosInterceptorFake()
const first = interceptors.request.fulfilled({}) const first = interceptors.request.fulfilled({})
+2 -2
View File
@@ -35,8 +35,8 @@ export function initializeRequestOptimizer(axiosInstance: any) {
// 拦截请求,自动添加 AbortController // 拦截请求,自动添加 AbortController
axiosInstance.interceptors.request.use( axiosInstance.interceptors.request.use(
(config: any) => { (config: any) => {
// 如果请求已经有 signal,跳过(避免覆盖手动设置的) // 心跳与轮询不属于页面生命周期,路由切换时应继续完成。
if (config.signal) { if (config.signal || config.skipNavigationCancellation) {
return config return config
} }
+16 -6
View File
@@ -52,7 +52,9 @@ const animatedCurrentUpload = useAnimatedDashboardNumber(currentUpload, {
const animatedCurrentDownload = useAnimatedDashboardNumber(currentDownload, { const animatedCurrentDownload = useAnimatedDashboardNumber(currentDownload, {
duration: 520, 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( const animatedCurrentDownloadText = computed(
() => `${formatDashboardFileSize(animatedCurrentDownload.value, 2, currentDownload.value)}/s`, () => `${formatDashboardFileSize(animatedCurrentDownload.value, 2, currentDownload.value)}/s`,
) )
@@ -168,7 +170,10 @@ async function getNetworkUsage() {
if (!props.allowRefresh) return if (!props.allowRefresh) return
try { 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 currentUpload.value = Number(data[0]) || 0
currentDownload.value = Number(data[1]) || 0 currentDownload.value = Number(data[1]) || 0
@@ -194,7 +199,7 @@ const { refresh } = useDataRefresh(
'dashboard-network', 'dashboard-network',
getNetworkUsage, getNetworkUsage,
2000, // 2秒间隔 2000, // 2秒间隔
true // 立即执行 true, // 立即执行
) )
useKeepAliveRefresh(refresh) useKeepAliveRefresh(refresh)
@@ -211,8 +216,14 @@ useKeepAliveRefresh(refresh)
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" /> <VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
</div> </div>
<div class="dashboard-chart-footer"> <div class="dashboard-chart-footer">
<span><i class="network-dot network-dot--upload" />{{ t('dashboard.upload') }} {{ animatedCurrentUploadText }}</span> <span
<span><i class="network-dot network-dot--download" />{{ t('dashboard.download') }} {{ animatedCurrentDownloadText }}</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> </div>
</VCardText> </VCardText>
</VCard> </VCard>
@@ -265,5 +276,4 @@ useKeepAliveRefresh(refresh)
.network-dot--download { .network-dot--download {
background: rgb(var(--v-theme-info)); background: rgb(var(--v-theme-info));
} }
</style> </style>
@@ -153,7 +153,7 @@ async function renderDetail(options: RenderDetailOptions = {}) {
server.use( server.use(
mediaDetailsHandler(mediaId, media, options.detailStatus, options.detailRequest), mediaDetailsHandler(mediaId, media, options.detailStatus, options.detailRequest),
mediaExistsHandler( mediaExistsHandler(
options.existsResponse ?? { data: { item: {} }, success: false }, options.existsResponse ?? { data: { item: {} }, success: true },
options.existsStatus, options.existsStatus,
existsRequest, existsRequest,
), ),
@@ -346,7 +346,7 @@ describe('MediaDetailView detail and actions', () => {
const subscribeRequested = vi.fn() const subscribeRequested = vi.fn()
server.use( server.use(
mediaDetailsHandler('8302', createMediaInfo({ title: '重试成功', tmdb_id: 8302 })), 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), querySubscribeByMediaHandler('8302', {}, 200, subscribeRequested),
) )
await fireEvent.click(screen.getByRole('button', { name: '重试' })) await fireEvent.click(screen.getByRole('button', { name: '重试' }))
+12 -4
View File
@@ -1519,13 +1519,17 @@ onUnmounted(() => {
<template #item.src="{ item }"> <template #item.src="{ item }">
<div> <div>
<span> <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> <small>{{ item?.src }}</small>
</span> </span>
<span class="text-high-emphasis text-bold"> => </span> <span class="text-high-emphasis text-bold"> => </span>
<br /> <br />
<span v-if="item?.dest"> <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> <small>{{ item?.dest }}</small>
</span> </span>
</div> </div>
@@ -1621,13 +1625,17 @@ onUnmounted(() => {
<template #item.src="{ item }"> <template #item.src="{ item }">
<div> <div>
<span> <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> <small>{{ item?.src }}</small>
</span> </span>
<span class="text-high-emphasis text-bold"> => </span> <span class="text-high-emphasis text-bold"> => </span>
<br /> <br />
<span v-if="item?.dest"> <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> <small>{{ item?.dest }}</small>
</span> </span>
</div> </div>
@@ -587,6 +587,13 @@ describe('TransferHistoryView', () => {
expect(screen.getByRole('button', { name: '批量选择' })).toBeInTheDocument() 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 () => { it('shows actual audio specs in mobile music history', async () => {
mocks.desktop = false mocks.desktop = false
const item = createHistory(2, '晴天', { 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 () => { 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) setViewport(480)
const recovered = movieSubscribe(3701, '恢复后的电影') const recovered = movieSubscribe(3701, '恢复后的电影')
const onListRequest = vi.fn() const onListRequest = vi.fn()
+1 -1
View File
@@ -21,7 +21,7 @@ export function mediaExistsHandler(
) { ) {
return http.get(mediaApiUrls.exists, async ({ request }) => { return http.get(mediaApiUrls.exists, async ({ request }) => {
await onRequest(new URL(request.url)) await onRequest(new URL(request.url))
return HttpResponse.json(response as JsonBodyType, { status }) return HttpResponse.json({ message: '', ...response } as JsonBodyType, { status })
}) })
} }