mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-19 04:34:26 +08:00
增强离线状态管理,添加服务探测功能和连接状态提示
This commit is contained in:
152
src/App.vue
152
src/App.vue
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api from '@/api'
|
||||
import api, { type ConnectionAwareRequestConfig } from '@/api'
|
||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
@@ -23,6 +23,10 @@ import { usePWA } from '@/composables/usePWA'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import {
|
||||
useGlobalOfflineStatus,
|
||||
type ConnectionFailureReason,
|
||||
} from '@/composables/useOfflineStatus'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
@@ -56,6 +60,7 @@ const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
const { initializePWA } = usePWA()
|
||||
const offlineStatus = useGlobalOfflineStatus()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -154,37 +159,145 @@ function handleWindowFocusRenderThrottle() {
|
||||
}
|
||||
}
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
let connectionRetryTimer: number | null = null
|
||||
let connectionProbePromise: Promise<boolean> | null = null
|
||||
let connectionProbeFailures = 0
|
||||
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
}
|
||||
const SERVER_PROBE_TIMEOUT_MS = 8_000
|
||||
const SERVER_PROBE_FAILURE_THRESHOLD = 2
|
||||
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
|
||||
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
/** 清除等待中的服务重连任务。 */
|
||||
function clearConnectionRetryTimer() {
|
||||
if (!connectionRetryTimer) return
|
||||
|
||||
window.clearTimeout(connectionRetryTimer)
|
||||
connectionRetryTimer = null
|
||||
}
|
||||
|
||||
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
|
||||
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
|
||||
if (!offlineStatus.browserOnline.value) return 'browser-offline'
|
||||
|
||||
const errorCode = (error as { code?: string } | null)?.code
|
||||
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
|
||||
|
||||
return 'server-unreachable'
|
||||
}
|
||||
|
||||
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
|
||||
function scheduleConnectionRetry() {
|
||||
clearConnectionRetryTimer()
|
||||
|
||||
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
|
||||
connectionRetryTimer = window.setTimeout(() => {
|
||||
connectionRetryTimer = null
|
||||
void probeServerConnection()
|
||||
}, SERVER_RETRY_DELAYS_MS[retryIndex])
|
||||
}
|
||||
|
||||
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
|
||||
async function probeServerConnection(showChecking = false): Promise<boolean> {
|
||||
if (!isLogin.value) return false
|
||||
if (connectionProbePromise) return connectionProbePromise
|
||||
|
||||
clearConnectionRetryTimer()
|
||||
if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined)
|
||||
|
||||
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
|
||||
const probePromise = (async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('system/ping')
|
||||
}
|
||||
await api.get(
|
||||
'system/ping',
|
||||
{
|
||||
skipConnectionTracking: true,
|
||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||
} as ConnectionAwareRequestConfig,
|
||||
)
|
||||
connectionProbeFailures = 0
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
if (!isLogin.value) {
|
||||
offlineStatus.markServerOnline()
|
||||
return false
|
||||
}
|
||||
|
||||
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
|
||||
if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
|
||||
connectionProbeFailures = 0
|
||||
return true
|
||||
}
|
||||
|
||||
connectionProbeFailures += 1
|
||||
const failureReason = resolveProbeFailureReason(error)
|
||||
|
||||
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
|
||||
offlineStatus.markServerOffline(failureReason)
|
||||
} else {
|
||||
offlineStatus.markConnectionChecking(failureReason)
|
||||
}
|
||||
|
||||
scheduleConnectionRetry()
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
connectionProbePromise = probePromise
|
||||
try {
|
||||
return await probePromise
|
||||
} finally {
|
||||
if (connectionProbePromise === probePromise) connectionProbePromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动即时服务探测和五分钟在线心跳。 */
|
||||
function startHeartbeat() {
|
||||
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
|
||||
|
||||
void probeServerConnection()
|
||||
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
if (isLogin.value) await probeServerConnection()
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
/** 停止心跳和等待中的自动重连任务。 */
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
|
||||
clearConnectionRetryTimer()
|
||||
connectionProbeFailures = 0
|
||||
}
|
||||
|
||||
watch(
|
||||
() => offlineStatus.connectionCheckRequestId.value,
|
||||
() => {
|
||||
if (isLogin.value) void probeServerConnection(true)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => offlineStatus.connectionStatus.value,
|
||||
status => {
|
||||
if (status !== 'online') return
|
||||
connectionProbeFailures = 0
|
||||
clearConnectionRetryTimer()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => offlineStatus.browserOnline.value,
|
||||
browserIsOnline => {
|
||||
if (!isLogin.value) return
|
||||
offlineStatus.requestConnectionCheck(browserIsOnline ? undefined : 'browser-offline')
|
||||
},
|
||||
)
|
||||
|
||||
// 更新data-theme属性以便CSS选择器能正确匹配
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
@@ -223,20 +336,22 @@ function handleSystemThemeChange() {
|
||||
}
|
||||
}
|
||||
|
||||
// 页面重新可见时同步主题,修复后台期间设置被外部修改后的外观漂移。
|
||||
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
|
||||
function handleVisibilityThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
syncThemePreferenceFromStorage()
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
} else {
|
||||
throttleBackgroundRendering()
|
||||
}
|
||||
}
|
||||
|
||||
// 页面从缓存或重新聚焦恢复时刷新主题偏好。
|
||||
/** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */
|
||||
function handlePageShowThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
}
|
||||
syncThemePreferenceFromStorage()
|
||||
}
|
||||
@@ -509,6 +624,7 @@ onMounted(async () => {
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
offlineStatus.markServerOnline()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
@@ -9,6 +9,10 @@ const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
})
|
||||
|
||||
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -36,36 +40,38 @@ api.interceptors.request.use(config => {
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
|
||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
||||
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||
return 'network-error'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 成功响应时,清除应用离线状态并重置连续错误计数
|
||||
globalOfflineStatus.setAppOffline(false)
|
||||
globalOfflineStatus.resetConsecutiveErrors()
|
||||
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
|
||||
globalOfflineStatus.markServerOnline()
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
(error: AxiosError) => {
|
||||
if (!error.response) {
|
||||
// 网络错误或请求超时 - 通知离线状态管理系统
|
||||
const isNetworkError =
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.code === 'ECONNABORTED' ||
|
||||
error.name === 'NetworkError'
|
||||
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
|
||||
const failureReason = resolveConnectionFailureReason(error)
|
||||
|
||||
if (isNetworkError) {
|
||||
let reason = 'Network connection failed'
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
reason = 'Request timeout'
|
||||
}
|
||||
// 记录网络错误,只有连续三次才会设置为离线模式
|
||||
globalOfflineStatus.recordNetworkError(reason)
|
||||
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
|
||||
if (!requestConfig?.skipConnectionTracking && failureReason) {
|
||||
globalOfflineStatus.reportNetworkError(failureReason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
// 网络连接问题
|
||||
return Promise.reject(new Error('Network connection failed, please check your network status'))
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: 'offline' | 'online'
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
type: 'offline',
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
// 重试连接
|
||||
const retrying = ref(false)
|
||||
|
||||
/** 尝试请求静态资源来触发网络状态重新检测。 */
|
||||
async function handleRetry() {
|
||||
if (retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
await fetch('/favicon.ico?' + new Date().getTime(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
const statusText = computed(() => {
|
||||
if (props.type === 'online') {
|
||||
return t('app.onlineMessage')
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
// 图标
|
||||
const statusIcon = computed(() => {
|
||||
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
|
||||
})
|
||||
|
||||
// 颜色主题
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-icon-wrapper {
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,86 +1,82 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
|
||||
// 全局状态
|
||||
const isAppOffline = ref(false)
|
||||
const appOfflineReason = ref('')
|
||||
const consecutiveNetworkErrors = ref(0)
|
||||
const MAX_CONSECUTIVE_ERRORS = 3
|
||||
export type ConnectionStatus = 'online' | 'checking' | 'offline'
|
||||
export type ConnectionFailureReason = 'browser-offline' | 'network-error' | 'timeout' | 'server-unreachable'
|
||||
|
||||
// 全局离线状态管理
|
||||
const browserOnline = useOnline()
|
||||
const connectionStatus = ref<ConnectionStatus>('online')
|
||||
const connectionReason = ref<ConnectionFailureReason | null>(null)
|
||||
const connectionCheckRequestId = ref(0)
|
||||
const serverSuccessSequence = ref(0)
|
||||
|
||||
/** 管理 MoviePilot 服务的全局连接状态。 */
|
||||
export function useGlobalOfflineStatus() {
|
||||
const isOnline = useOnline()
|
||||
const isOnline = computed(() => connectionStatus.value === 'online')
|
||||
const isChecking = computed(() => connectionStatus.value === 'checking')
|
||||
const isOffline = computed(() => connectionStatus.value === 'offline')
|
||||
const canPerformNetworkAction = computed(() => connectionStatus.value !== 'offline')
|
||||
|
||||
// 综合离线状态(网络离线 或 应用离线)
|
||||
const isOffline = computed(() => !isOnline.value || isAppOffline.value)
|
||||
|
||||
// 是否可以执行网络操作
|
||||
const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value)
|
||||
|
||||
// 设置应用离线状态
|
||||
const setAppOffline = (offline: boolean, reason?: string) => {
|
||||
isAppOffline.value = offline
|
||||
appOfflineReason.value = reason || ''
|
||||
|
||||
// 如果设置为在线状态,重置连续错误计数
|
||||
if (!offline) {
|
||||
consecutiveNetworkErrors.value = 0
|
||||
}
|
||||
/** 记录任意 MoviePilot API 成功响应并恢复在线状态。 */
|
||||
function markServerOnline() {
|
||||
connectionStatus.value = 'online'
|
||||
connectionReason.value = null
|
||||
serverSuccessSequence.value += 1
|
||||
}
|
||||
|
||||
// 记录网络错误
|
||||
const recordNetworkError = (reason?: string) => {
|
||||
consecutiveNetworkErrors.value++
|
||||
|
||||
// 只有连续出现三次网络错误时才设置为离线模式
|
||||
if (consecutiveNetworkErrors.value >= MAX_CONSECUTIVE_ERRORS) {
|
||||
setAppOffline(true, reason || `连续${MAX_CONSECUTIVE_ERRORS}次网络错误`)
|
||||
}
|
||||
/** 将连接状态标记为待确认,但不直接阻断页面操作。 */
|
||||
function markConnectionChecking(reason?: ConnectionFailureReason) {
|
||||
connectionStatus.value = 'checking'
|
||||
if (reason) connectionReason.value = reason
|
||||
}
|
||||
|
||||
// 重置连续错误计数
|
||||
const resetConsecutiveErrors = () => {
|
||||
consecutiveNetworkErrors.value = 0
|
||||
/** 在权威探测失败后标记 MoviePilot 服务不可达。 */
|
||||
function markServerOffline(reason: ConnectionFailureReason = 'server-unreachable') {
|
||||
connectionStatus.value = 'offline'
|
||||
connectionReason.value = reason
|
||||
}
|
||||
|
||||
// 获取离线消息
|
||||
const getOfflineMessage = () => {
|
||||
if (!isOnline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
if (isAppOffline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
return ''
|
||||
/** 将普通请求的网络错误降级为待确认状态,并请求一次去重后的服务探测。 */
|
||||
function reportNetworkError(reason: ConnectionFailureReason = 'network-error') {
|
||||
markConnectionChecking(reason)
|
||||
connectionCheckRequestId.value += 1
|
||||
}
|
||||
|
||||
/** 请求立即检查 MoviePilot 服务连接。 */
|
||||
function requestConnectionCheck(reason?: ConnectionFailureReason) {
|
||||
markConnectionChecking(reason)
|
||||
connectionCheckRequestId.value += 1
|
||||
}
|
||||
|
||||
return {
|
||||
browserOnline,
|
||||
connectionStatus,
|
||||
connectionReason,
|
||||
connectionCheckRequestId,
|
||||
serverSuccessSequence,
|
||||
isOnline,
|
||||
isChecking,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
setAppOffline,
|
||||
recordNetworkError,
|
||||
resetConsecutiveErrors,
|
||||
getOfflineMessage,
|
||||
consecutiveNetworkErrors: computed(() => consecutiveNetworkErrors.value),
|
||||
markServerOnline,
|
||||
markConnectionChecking,
|
||||
markServerOffline,
|
||||
reportNetworkError,
|
||||
requestConnectionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
// 单个组件的离线状态
|
||||
export function useOfflineStatus(initialMessage?: string) {
|
||||
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
const message = computed(() => {
|
||||
if (initialMessage) {
|
||||
return initialMessage
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
/** 为单个组件提供 MoviePilot 服务连接状态。 */
|
||||
export function useOfflineStatus() {
|
||||
const status = useGlobalOfflineStatus()
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
message,
|
||||
browserOnline: status.browserOnline,
|
||||
isOnline: status.isOnline,
|
||||
isChecking: status.isChecking,
|
||||
isOffline: status.isOffline,
|
||||
canPerformNetworkAction: status.canPerformNetworkAction,
|
||||
connectionReason: status.connectionReason,
|
||||
requestConnectionCheck: status.requestConnectionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ const mainContentPaddingTop = computed(() => {
|
||||
const showPluginQuickAccess = ref(false)
|
||||
|
||||
// 离线状态管理
|
||||
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
|
||||
const { isOffline } = useGlobalOfflineStatus()
|
||||
|
||||
// 动态标签页相关
|
||||
// 定义动态标签页类型
|
||||
@@ -227,17 +227,6 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理 Service Worker 推送的离线状态消息。 */
|
||||
const handleServiceWorkerMessage = (event: MessageEvent) => {
|
||||
if (event.data && event.data.type === 'OFFLINE_STATUS') {
|
||||
if (event.data.offline) {
|
||||
setAppOffline(true, t('common.serverConnectionFailed'))
|
||||
} else {
|
||||
setAppOffline(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断当前页面状态是否允许使用主界面下拉快捷入口手势。 */
|
||||
const canUsePullGesture = () => {
|
||||
// 检查是否在dashboard页面
|
||||
@@ -468,25 +457,17 @@ onMounted(async () => {
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
appendPluginSidebarMenus()
|
||||
|
||||
// 监听Service Worker消息
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
|
||||
// 组件卸载时清理监听
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 👉 Offline Page -->
|
||||
<OfflinePage />
|
||||
<OfflinePage :navbar-extra-height="navbarExtraHeight" />
|
||||
|
||||
<!-- 👉 Pull Down Indicator -->
|
||||
<div
|
||||
|
||||
@@ -1,66 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const OfflineStatusDialog = defineAsyncComponent(() => import('@/components/dialog/OfflineStatusDialog.vue'))
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
navbarExtraHeight?: string
|
||||
}>(),
|
||||
{
|
||||
navbarExtraHeight: '0rem',
|
||||
},
|
||||
)
|
||||
|
||||
interface Props {
|
||||
type?: 'offline' | 'online'
|
||||
}
|
||||
const { t } = useI18n()
|
||||
const { connectionStatus, connectionReason, requestConnectionCheck } = useGlobalOfflineStatus()
|
||||
const dismissed = ref(false)
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'offline',
|
||||
const shouldShow = computed(() => connectionStatus.value !== 'online' && !dismissed.value)
|
||||
const isChecking = computed(() => connectionStatus.value === 'checking')
|
||||
const alertType = computed(() => (isChecking.value ? 'warning' : 'error'))
|
||||
const statusTitle = computed(() => (isChecking.value ? t('app.connectionChecking') : t('app.serviceUnavailable')))
|
||||
const statusMessage = computed(() => {
|
||||
if (connectionReason.value === 'browser-offline') return t('app.browserOfflineMessage')
|
||||
if (connectionReason.value === 'timeout') return t('app.serviceTimeoutMessage')
|
||||
if (isChecking.value) return t('app.connectionCheckingMessage')
|
||||
return t('app.serviceUnavailableMessage')
|
||||
})
|
||||
|
||||
const { canPerformNetworkAction } = useGlobalOfflineStatus()
|
||||
let offlineDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
/** 立即请求重新探测 MoviePilot 服务。 */
|
||||
function handleRetry() {
|
||||
requestConnectionCheck()
|
||||
}
|
||||
|
||||
/** 打开离线状态共享弹窗。 */
|
||||
function showOfflineDialog() {
|
||||
if (offlineDialogController) {
|
||||
offlineDialogController.updateProps({ type: props.type })
|
||||
return
|
||||
/** 隐藏本次连接提示并允许用户继续浏览。 */
|
||||
function handleContinueBrowsing() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
watch(connectionStatus, (status, previousStatus) => {
|
||||
if (status === 'online' || (status === 'offline' && previousStatus === 'checking')) {
|
||||
dismissed.value = false
|
||||
}
|
||||
|
||||
offlineDialogController = openSharedDialog(
|
||||
OfflineStatusDialog,
|
||||
{
|
||||
type: props.type,
|
||||
},
|
||||
{},
|
||||
{ closeOn: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** 关闭离线状态共享弹窗。 */
|
||||
function closeOfflineDialog() {
|
||||
offlineDialogController?.close()
|
||||
offlineDialogController = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => canPerformNetworkAction.value,
|
||||
canPerform => {
|
||||
if (canPerform) {
|
||||
closeOfflineDialog()
|
||||
return
|
||||
}
|
||||
|
||||
showOfflineDialog()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
offlineDialogController?.updateProps({ type: props.type })
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
closeOfflineDialog()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
<template>
|
||||
<Transition name="connection-status">
|
||||
<div
|
||||
v-if="shouldShow"
|
||||
class="connection-status-host"
|
||||
:style="{ '--connection-status-navbar-extra-height': props.navbarExtraHeight }"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<VAlert :type="alertType" variant="elevated" density="comfortable" class="connection-status-alert">
|
||||
<div class="connection-status-content">
|
||||
<div class="connection-status-copy">
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
{{ statusTitle }}
|
||||
</div>
|
||||
<div class="text-body-2 mt-1">
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-actions">
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="text"
|
||||
:loading="isChecking"
|
||||
:disabled="isChecking"
|
||||
@click="handleRetry"
|
||||
>
|
||||
{{ isChecking ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
<VBtn size="small" variant="text" @click="handleContinueBrowsing">
|
||||
{{ t('app.continueBrowsing') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VAlert>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.connection-status-host {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
inline-size: min(44rem, calc(100vw - 2rem));
|
||||
inset-block-start: calc(
|
||||
env(safe-area-inset-top, 0px) + 4rem + var(--connection-status-navbar-extra-height, 0rem) + 0.75rem
|
||||
);
|
||||
inset-inline-start: 50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.connection-status-alert {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: var(--app-overlay-radius);
|
||||
box-shadow: var(--app-overlay-shadow);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.connection-status-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.connection-status-copy {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.connection-status-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connection-status-enter-active,
|
||||
.connection-status-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.connection-status-enter-from,
|
||||
.connection-status-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -0.75rem);
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.connection-status-host {
|
||||
inline-size: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.connection-status-content {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.connection-status-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.connection-status-enter-active,
|
||||
.connection-status-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -211,6 +211,13 @@ export default {
|
||||
restartFailed: 'Restart failed, please check system status',
|
||||
offline: 'Application Offline',
|
||||
offlineMessage: 'Network connection lost, some features may be limited',
|
||||
connectionChecking: 'Reconnecting to MoviePilot',
|
||||
connectionCheckingMessage: 'Checking the service status. You can continue browsing this page.',
|
||||
serviceUnavailable: 'MoviePilot is temporarily unavailable',
|
||||
serviceUnavailableMessage: 'The service may be starting up, or the reverse proxy may be temporarily unavailable.',
|
||||
serviceTimeoutMessage: 'The MoviePilot service timed out. It will retry automatically.',
|
||||
browserOfflineMessage: 'The browser reports no network connection. Trying MoviePilot directly.',
|
||||
continueBrowsing: 'Continue browsing',
|
||||
online: 'Application Online',
|
||||
onlineMessage: 'Network connection restored',
|
||||
},
|
||||
|
||||
@@ -207,6 +207,13 @@ export default {
|
||||
restartFailed: '重启失败,请检查系统状态',
|
||||
offline: '应用已离线',
|
||||
offlineMessage: '网络连接已断开,部分功能可能受限',
|
||||
connectionChecking: '正在重新连接 MoviePilot',
|
||||
connectionCheckingMessage: '正在确认服务状态,当前页面仍可继续浏览。',
|
||||
serviceUnavailable: '暂时无法连接 MoviePilot',
|
||||
serviceUnavailableMessage: '服务可能正在启动,或反向代理暂时不可用。',
|
||||
serviceTimeoutMessage: 'MoviePilot 服务响应超时,稍后将自动重试。',
|
||||
browserOfflineMessage: '浏览器报告网络不可用,正在尝试直接连接 MoviePilot。',
|
||||
continueBrowsing: '继续浏览',
|
||||
online: '应用在线',
|
||||
onlineMessage: '网络连接已恢复',
|
||||
},
|
||||
|
||||
@@ -207,6 +207,13 @@ export default {
|
||||
restartFailed: '重啟失敗,請檢查系統狀態',
|
||||
offline: '應用已離線',
|
||||
offlineMessage: '網絡連接已斷開,部分功能可能受限',
|
||||
connectionChecking: '正在重新連接 MoviePilot',
|
||||
connectionCheckingMessage: '正在確認服務狀態,目前頁面仍可繼續瀏覽。',
|
||||
serviceUnavailable: '暫時無法連接 MoviePilot',
|
||||
serviceUnavailableMessage: '服務可能正在啟動,或反向代理暫時不可用。',
|
||||
serviceTimeoutMessage: 'MoviePilot 服務回應逾時,稍後將自動重試。',
|
||||
browserOfflineMessage: '瀏覽器回報網絡不可用,正在嘗試直接連接 MoviePilot。',
|
||||
continueBrowsing: '繼續瀏覽',
|
||||
online: '應用在線',
|
||||
onlineMessage: '網絡連接已恢復',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user