增强离线状态管理,添加服务探测功能和连接状态提示

This commit is contained in:
jxxghp
2026-07-02 11:56:34 +08:00
parent fa39c3750c
commit af604d0c5c
9 changed files with 385 additions and 345 deletions

View File

@@ -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()
}
})
})

View File

@@ -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') {

View File

@@ -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>

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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',
},

View File

@@ -207,6 +207,13 @@ export default {
restartFailed: '重启失败,请检查系统状态',
offline: '应用已离线',
offlineMessage: '网络连接已断开,部分功能可能受限',
connectionChecking: '正在重新连接 MoviePilot',
connectionCheckingMessage: '正在确认服务状态,当前页面仍可继续浏览。',
serviceUnavailable: '暂时无法连接 MoviePilot',
serviceUnavailableMessage: '服务可能正在启动,或反向代理暂时不可用。',
serviceTimeoutMessage: 'MoviePilot 服务响应超时,稍后将自动重试。',
browserOfflineMessage: '浏览器报告网络不可用,正在尝试直接连接 MoviePilot。',
continueBrowsing: '继续浏览',
online: '应用在线',
onlineMessage: '网络连接已恢复',
},

View File

@@ -207,6 +207,13 @@ export default {
restartFailed: '重啟失敗,請檢查系統狀態',
offline: '應用已離線',
offlineMessage: '網絡連接已斷開,部分功能可能受限',
connectionChecking: '正在重新連接 MoviePilot',
connectionCheckingMessage: '正在確認服務狀態,目前頁面仍可繼續瀏覽。',
serviceUnavailable: '暫時無法連接 MoviePilot',
serviceUnavailableMessage: '服務可能正在啟動,或反向代理暫時不可用。',
serviceTimeoutMessage: 'MoviePilot 服務回應逾時,稍後將自動重試。',
browserOfflineMessage: '瀏覽器回報網絡不可用,正在嘗試直接連接 MoviePilot。',
continueBrowsing: '繼續瀏覽',
online: '應用在線',
onlineMessage: '網絡連接已恢復',
},