mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
fix(ui): stabilize authenticated page requests
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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
@@ -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({})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '重试' }))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user