mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-02 14:07:15 +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 () => {
|
||||
try {
|
||||
await api.get('system/ping', {
|
||||
feedback: 'silent',
|
||||
skipNavigationCancellation: true,
|
||||
skipConnectionTracking: true,
|
||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||
} as ConnectionAwareRequestConfig)
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
@@ -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({})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '重试' }))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user