fix(offline): reset connection prompts after recovery (#681)

This commit is contained in:
InfinityPacer
2026-08-19 05:55:33 +08:00
committed by GitHub
parent a4d253e103
commit 68ad3ec77c
4 changed files with 212 additions and 3 deletions
@@ -0,0 +1,78 @@
import { useGlobalOfflineStatus, useOfflineStatus } from '@/composables/useOfflineStatus'
import { beforeEach, describe, expect, it } from 'vitest'
describe('useOfflineStatus', () => {
const status = useGlobalOfflineStatus()
beforeEach(() => {
status.markServerOnline()
})
it('在所有消费者之间共享连接状态,并为组件暴露只读操作边界', () => {
const anotherGlobalStatus = useGlobalOfflineStatus()
const componentStatus = useOfflineStatus()
expect(anotherGlobalStatus.connectionStatus).toBe(status.connectionStatus)
expect(anotherGlobalStatus.connectionReason).toBe(status.connectionReason)
expect(componentStatus.connectionReason).toBe(status.connectionReason)
expect(componentStatus).not.toHaveProperty('markServerOffline')
status.markServerOffline('timeout')
expect(componentStatus.isOnline.value).toBe(false)
expect(componentStatus.isChecking.value).toBe(false)
expect(componentStatus.isOffline.value).toBe(true)
expect(componentStatus.canPerformNetworkAction.value).toBe(false)
const initialCheckRequestId = status.connectionCheckRequestId.value
componentStatus.requestConnectionCheck('network-error')
expect(status.connectionStatus.value).toBe('checking')
expect(status.connectionReason.value).toBe('network-error')
expect(status.connectionCheckRequestId.value).toBe(initialCheckRequestId + 1)
})
it('区分待确认与离线状态,并只在确认离线后阻断网络操作', () => {
status.markConnectionChecking('timeout')
expect(status.connectionStatus.value).toBe('checking')
expect(status.connectionReason.value).toBe('timeout')
expect(status.isOnline.value).toBe(false)
expect(status.isChecking.value).toBe(true)
expect(status.isOffline.value).toBe(false)
expect(status.canPerformNetworkAction.value).toBe(true)
status.markServerOffline()
expect(status.connectionStatus.value).toBe('offline')
expect(status.connectionReason.value).toBe('server-unreachable')
expect(status.isOffline.value).toBe(true)
expect(status.canPerformNetworkAction.value).toBe(false)
status.markServerOnline()
expect(status.connectionStatus.value).toBe('online')
expect(status.connectionReason.value).toBeNull()
expect(status.isOnline.value).toBe(true)
expect(status.canPerformNetworkAction.value).toBe(true)
})
it('为网络错误和主动检查递增探测序列,并为成功响应递增恢复序列', () => {
const initialCheckRequestId = status.connectionCheckRequestId.value
const initialSuccessSequence = status.serverSuccessSequence.value
status.reportNetworkError()
expect(status.connectionStatus.value).toBe('checking')
expect(status.connectionReason.value).toBe('network-error')
expect(status.connectionCheckRequestId.value).toBe(initialCheckRequestId + 1)
status.requestConnectionCheck('browser-offline')
expect(status.connectionReason.value).toBe('browser-offline')
expect(status.connectionCheckRequestId.value).toBe(initialCheckRequestId + 2)
status.requestConnectionCheck()
expect(status.connectionReason.value).toBe('browser-offline')
expect(status.connectionCheckRequestId.value).toBe(initialCheckRequestId + 3)
status.markServerOnline()
expect(status.serverSuccessSequence.value).toBe(initialSuccessSequence + 1)
})
})
@@ -40,14 +40,14 @@ function showConnectionPrompt() {
/** 在同一轮连接异常内按状态去重提示,并在恢复在线后允许下一轮提示重新出现。 */
function handleConnectionStatusChange() {
// 重启期间由重启进度弹窗承载反馈,避免离线提示与进度提示叠加。
if (isRestarting.value) return
if (connectionStatus.value === 'online') {
shownConnectionPromptKeys.clear()
return
}
// 重启期间由重启进度弹窗承载反馈,避免离线提示与进度提示叠加。
if (isRestarting.value) return
const promptKey =
connectionStatus.value === 'checking'
? connectionStatus.value
@@ -0,0 +1,117 @@
import OfflinePage from '@/layouts/default/components/OfflinePage.vue'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
error: vi.fn(),
isRestarting: { value: false },
warning: vi.fn(),
}))
vi.mock('@/composables/useSystemRestart', () => ({
useSystemRestartStatus: () => ({ isRestarting: mocks.isRestarting }),
}))
vi.mock('vue-i18n', async importOriginal => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.error, warning: mocks.warning }),
}))
describe('OfflinePage', () => {
const status = useGlobalOfflineStatus()
let wrapper: VueWrapper | undefined
beforeEach(() => {
mocks.error.mockReset()
mocks.warning.mockReset()
mocks.isRestarting.value = false
status.markServerOnline()
wrapper = mount(OfflinePage)
})
afterEach(() => {
wrapper?.unmount()
status.markServerOnline()
})
it('同一轮 checking 状态只提示一次,并使用非阻断警告', async () => {
status.markConnectionChecking('network-error')
await nextTick()
expect(mocks.warning).toHaveBeenCalledWith('app.connectionCheckingapp.connectionCheckingMessage', {
timeout: 5000,
})
status.markConnectionChecking('timeout')
await nextTick()
expect(mocks.warning).toHaveBeenCalledTimes(1)
expect(mocks.error).not.toHaveBeenCalled()
})
it('按离线原因显示错误,并允许不同离线状态分别提示', async () => {
status.markServerOffline('timeout')
await nextTick()
expect(mocks.error).toHaveBeenNthCalledWith(1, 'app.serviceUnavailableapp.serviceTimeoutMessage', {
timeout: 7000,
})
status.markServerOffline('browser-offline')
await nextTick()
expect(mocks.error).toHaveBeenNthCalledWith(2, 'app.serviceUnavailableapp.browserOfflineMessage', {
timeout: 7000,
})
status.markServerOffline('server-unreachable')
await nextTick()
expect(mocks.error).toHaveBeenNthCalledWith(3, 'app.serviceUnavailableapp.serviceUnavailableMessage', {
timeout: 7000,
})
expect(mocks.warning).not.toHaveBeenCalled()
})
it('在线恢复后允许下一轮 checking 再次提示', async () => {
status.markConnectionChecking()
await nextTick()
expect(mocks.warning).toHaveBeenCalledTimes(1)
status.markServerOnline()
await nextTick()
status.markConnectionChecking()
await nextTick()
expect(mocks.warning).toHaveBeenCalledTimes(2)
})
it('重启期间不提示,但在线恢复仍清空上一轮去重状态', async () => {
status.markConnectionChecking()
await nextTick()
expect(mocks.warning).toHaveBeenCalledTimes(1)
mocks.isRestarting.value = true
status.markServerOffline('timeout')
await nextTick()
expect(mocks.error).not.toHaveBeenCalled()
expect(mocks.warning).toHaveBeenCalledTimes(1)
status.markServerOnline()
await nextTick()
expect(mocks.error).not.toHaveBeenCalled()
expect(mocks.warning).toHaveBeenCalledTimes(1)
mocks.isRestarting.value = false
status.markConnectionChecking()
await nextTick()
expect(mocks.warning).toHaveBeenCalledTimes(2)
})
})
+14
View File
@@ -336,7 +336,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/views/subscribe/SubscribeShareView.vue',
'src/composables/useMediaSubscribe.ts',
'src/composables/useLlmProviderDirectory.ts',
'src/composables/useOfflineStatus.ts',
'src/composables/useTorrentFilter.ts',
'src/layouts/default/components/OfflinePage.vue',
'src/components/cards/SubscribeCard.vue',
'src/components/cards/UserCard.vue',
'src/components/filter/TorrentFilterBar.vue',
@@ -425,6 +427,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 90,
statements: 90,
},
'src/composables/useOfflineStatus.ts': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/layouts/default/components/OfflinePage.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/components/cards/SubscribeCard.vue': {
branches: 75,
functions: 80,