添加离线状态管理和网络请求处理

This commit is contained in:
jxxghp
2025-07-05 08:23:06 +08:00
parent 6bd7274c9c
commit 2650bc6068
11 changed files with 472 additions and 347 deletions

View File

@@ -2,6 +2,7 @@ import axios from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores'
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
// 创建axios实例
const api = axios.create({
@@ -32,15 +33,45 @@ api.interceptors.request.use(config => {
return config
})
// 离线状态管理
const globalOfflineStatus = useGlobalOfflineStatus()
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 成功响应时,清除应用离线状态
globalOfflineStatus.setAppOffline(false)
return response.data
},
error => {
if (!error.response) {
// 请求超时
return Promise.reject(new Error(error))
// 网络错误或请求超时 - 通知离线状态管理系统
const isNetworkError =
error.code === 'NETWORK_ERROR' ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED' ||
error.name === 'NetworkError'
if (isNetworkError) {
let reason = 'Network connection failed'
if (error.code === 'ECONNABORTED') {
reason = 'Request timeout'
}
globalOfflineStatus.setAppOffline(true, reason)
}
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') {
// 请求超时
return Promise.reject(new Error('Request timeout, please try again later'))
} else if (error.name === 'AbortError') {
// 请求被中止(路由切换等)
return Promise.reject(new Error('Request cancelled'))
}
// 其他网络错误
return Promise.reject(new Error(error.message || 'Network error'))
} else if (error.response.status === 403) {
// 认证 Store
const authStore = useAuthStore()

View File

@@ -0,0 +1,61 @@
import { ref, computed } from 'vue'
import { useOnline } from '@vueuse/core'
// 全局状态
const isAppOffline = ref(false)
const appOfflineReason = ref('')
// 全局离线状态管理
export function useGlobalOfflineStatus() {
const isOnline = useOnline()
// 综合离线状态(网络离线 或 应用离线)
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 || ''
}
// 获取离线消息
const getOfflineMessage = () => {
if (!isOnline.value) {
return appOfflineReason.value
}
if (isAppOffline.value) {
return appOfflineReason.value
}
return ''
}
return {
isOnline,
isOffline,
canPerformNetworkAction,
setAppOffline,
getOfflineMessage,
}
}
// 单个组件的离线状态
export function useOfflineStatus(initialMessage?: string) {
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
const message = computed(() => {
if (initialMessage) {
return initialMessage
}
return getOfflineMessage()
})
return {
isOnline,
isOffline,
canPerformNetworkAction,
message,
}
}

View File

@@ -1,6 +1,5 @@
import { ref, computed, onMounted, onBeforeUnmount, readonly, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useRoute } from 'vue-router'
import { usePWA } from './usePWA'
// 下拉手势配置类型

View File

@@ -19,6 +19,8 @@ import { onUnreadMessage } from '@/utils/badge'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
import { usePWA } from '@/composables/usePWA'
import OfflinePage from '@/layouts/components/OfflinePage.vue'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
const display = useDisplay()
// PWA模式检测
@@ -59,6 +61,20 @@ const systemMenus = ref<NavMenu[]>([])
// 插件快速访问相关状态
const showPluginQuickAccess = ref(false)
// 离线状态管理
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
// 监听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)
}
}
}
// 使用滚动锁定 composable自动监听showPluginQuickAccess的变化
useScrollLockWithWatch(showPluginQuickAccess)
@@ -70,8 +86,10 @@ const canUsePullGesture = () => {
const isAdmin = superUser.value
// 检查插件快速访问面板是否已显示
const quickAccessOpen = showPluginQuickAccess.value
// 检查是否离线
const offline = isOffline.value
return isDashboard && isAdmin && !quickAccessOpen
return isDashboard && isAdmin && !quickAccessOpen && !offline
}
// 使用下拉手势 composable
@@ -138,14 +156,25 @@ onMounted(() => {
// 监听全局未读消息事件
const unsubscribe = onUnreadMessage(handleUnreadMessage)
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
// 组件卸载时清理监听
onBeforeUnmount(() => {
unsubscribe()
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
})
})
</script>
<template>
<!-- 👉 Offline Page -->
<OfflinePage />
<!-- 👉 Pull Down Indicator -->
<div
v-if="appMode && showPullIndicator"

View File

@@ -0,0 +1,266 @@
<script setup lang="ts">
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
interface Props {
type?: 'offline' | 'online'
}
const props = withDefaults(defineProps<Props>(), {
type: 'offline',
})
const { t } = useI18n()
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
// 重试连接
const retrying = ref(false)
const handleRetry = async () => {
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 shouldShow = computed(() => {
return !canPerformNetworkAction.value
})
// 状态文本
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>
<Transition
enter-active-class="transition-all duration-500"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-300"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="shouldShow" class="offline-page">
<div class="offline-container">
<!-- 状态图标 -->
<div class="status-icon-wrapper">
<div class="status-icon-bg">
<VIcon :icon="statusIcon" size="64" :color="colorTheme" />
</div>
</div>
<!-- 主要信息 -->
<div class="content-section">
<h1 class="offline-title">
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
</h1>
<p class="offline-message">
{{ statusText }}
</p>
<!-- 重试按钮 -->
<div class="action-section">
<VBtn
v-if="props.type === 'offline'"
:loading="retrying"
:color="colorTheme"
size="large"
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"
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"
>
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
</VChip>
</div>
</div>
<!-- 底部信息 -->
<div class="footer-section">
<p class="app-info">{{ t('app.moviepilot') }}</p>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.offline-page {
position: fixed;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(10px);
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgb(var(--v-theme-surface-variant)) 100%);
inset: 0;
}
.offline-container {
padding: 40px;
border-radius: 24px;
background: rgb(var(--v-theme-surface));
box-shadow: 0 20px 40px rgba(0, 0, 0, 10%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
inline-size: 100%;
max-inline-size: 500px;
text-align: center;
}
.status-icon-wrapper {
margin-block-end: 32px;
}
.status-icon-bg {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--v-theme-surface-variant), 0.5);
block-size: 120px;
inline-size: 120px;
margin-block: 0;
margin-inline: auto;
}
.status-icon-bg::before {
position: absolute;
z-index: -1;
border-radius: 50%;
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
content: '';
inset: -4px;
opacity: 0.1;
}
.content-section {
margin-block-end: 32px;
}
.offline-title {
color: rgb(var(--v-theme-on-surface));
font-size: 2rem;
font-weight: 600;
margin-block-end: 16px;
}
.offline-message {
color: rgb(var(--v-theme-on-surface));
font-size: 1.1rem;
line-height: 1.6;
margin-block-end: 32px;
opacity: 0.7;
}
.action-section {
margin-block-end: 32px;
}
.status-indicators {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.help-section {
margin-block-end: 32px;
}
.help-panels {
text-align: start;
}
.footer-section {
opacity: 0.7;
}
.app-info {
color: rgb(var(--v-theme-on-surface));
font-size: 0.875rem;
}
/* 移动端优化 */
@media (width <= 600px) {
.offline-container {
padding: 24px;
margin: 16px;
}
.offline-title {
font-size: 1.5rem;
}
.offline-message {
font-size: 1rem;
}
.status-icon-bg {
block-size: 100px;
inline-size: 100px;
}
.status-indicators {
flex-direction: column;
align-items: center;
}
}
/* 暗黑模式优化 */
.v-theme--dark .offline-page {
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgba(var(--v-theme-surface-variant), 0.8) 100%);
}
.v-theme--dark .offline-container {
box-shadow: 0 20px 40px rgba(0, 0, 0, 30%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

@@ -49,6 +49,17 @@ export default {
pageText: '{0}-{1} of {2}',
noDataText: 'No data',
loadingText: 'Loading...',
networkRequired: 'This feature requires network connection',
networkDisconnected: 'Network connection lost',
featuresLimited: 'Some features may be limited',
serverConnectionFailed: 'Server connection failed',
troubleshooting: 'Troubleshooting',
checking: 'Checking',
retry: 'Retry',
networkOnline: 'Network Online',
networkOffline: 'Network Offline',
serviceAvailable: 'Service Available',
serviceUnavailable: 'Service Unavailable',
},
mediaType: {
movie: 'Movie',
@@ -120,6 +131,7 @@ export default {
},
app: {
moviepilot: 'MoviePilot',
slogan: 'Intelligent Movie & TV Media Library Management Tool',
recommend: 'Recommend',
subscribeMovie: 'Movie Subscription',
subscribeTv: 'TV Subscription',
@@ -131,6 +143,10 @@ export default {
restartTip: 'After restart, you will be logged out and need to log in again.',
restartTimeout: 'Restart timeout, the system may need more time to recover, please refresh the page manually later',
restartFailed: 'Restart failed, please check system status',
offline: 'Offline Mode',
offlineMessage: 'Network connection lost, some features may be limited',
online: 'Online Mode',
onlineMessage: 'Network connection restored',
},
login: {
wallpapers: 'Wallpapers',

View File

@@ -49,6 +49,17 @@ export default {
pageText: '{0}-{1} 共 {2} 条',
noDataText: '没有数据',
loadingText: '加载中...',
networkRequired: '此功能需要网络连接',
networkDisconnected: '网络连接已断开',
featuresLimited: '部分功能可能受限',
serverConnectionFailed: '服务器连接失败',
troubleshooting: '疑难解答',
checking: '检查中',
retry: '重试',
networkOnline: '网络在线',
networkOffline: '网络离线',
serviceAvailable: '服务可用',
serviceUnavailable: '服务不可用',
},
mediaType: {
movie: '电影',
@@ -120,6 +131,7 @@ export default {
},
app: {
moviepilot: 'MoviePilot',
slogan: '智能影视媒体库管理工具',
recommend: '推荐',
subscribeMovie: '电影订阅',
subscribeTv: '电视剧订阅',
@@ -131,6 +143,10 @@ export default {
restartTip: '重启后,您将被注销并需要重新登录。',
restartTimeout: '重启超时,系统可能需要更长时间恢复,请稍后手动刷新页面',
restartFailed: '重启失败,请检查系统状态',
offline: '离线模式',
offlineMessage: '网络连接已断开,部分功能可能受限',
online: '在线模式',
onlineMessage: '网络连接已恢复',
},
login: {
wallpapers: '壁纸',

View File

@@ -49,6 +49,17 @@ export default {
pageText: '{0}-{1} 共 {2} 條',
noDataText: '沒有數據',
loadingText: '加載中...',
networkRequired: '此功能需要網絡連接',
networkDisconnected: '網絡連接已斷開',
featuresLimited: '部分功能可能受限',
serverConnectionFailed: '服務器連接失敗',
troubleshooting: '疑難排解',
checking: '檢查中',
retry: '重試',
networkOnline: '網絡在線',
networkOffline: '網絡離線',
serviceAvailable: '服務可用',
serviceUnavailable: '服務不可用',
},
mediaType: {
movie: '電影',
@@ -120,6 +131,7 @@ export default {
},
app: {
moviepilot: 'MoviePilot',
slogan: '智能影視媒體庫管理工具',
recommend: '推薦',
subscribeMovie: '電影訂閱',
subscribeTv: '電視劇訂閱',
@@ -132,6 +144,10 @@ export default {
restartTip: '重啟後,您將被註銷並需要重新登錄。',
restartTimeout: '重啟超時,系統可能需要更長時間恢復,請稍後手動刷新頁面',
restartFailed: '重啟失敗,請檢查系統狀態',
offline: '離線模式',
offlineMessage: '網絡連接已斷開,部分功能可能受限',
online: '在線模式',
onlineMessage: '網絡連接已恢復',
},
login: {
wallpapers: '壁紙',

View File

@@ -1,17 +1,8 @@
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
// Service Worker 类型声明
declare let self: ServiceWorkerGlobalScope
cleanupOutdatedCaches()
// self.__WB_MANIFEST is default injection point
precacheAndRoute(self.__WB_MANIFEST)
// 离线版本控制 - 递增此版本号将触发install事件并强制更新缓存资源
const OFFLINE_VERSION = 1
const CACHE_NAME = 'mp-offline-cache-v1'
const OFFLINE_URL = '/offline.html'
// 通知选项
const options = {
icon: '/logo.png',
@@ -57,6 +48,7 @@ async function openDB(): Promise<IDBDatabase> {
})
}
// 获取IndexedDB中的数据
async function get(key: string): Promise<any> {
const db = await openDB()
return new Promise((resolve, reject) => {
@@ -68,6 +60,7 @@ async function get(key: string): Promise<any> {
})
}
// 保存数据到IndexedDB
async function set(key: string, value: any): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
@@ -106,17 +99,9 @@ async function clearBadge() {
}
}
// 安装事件 - 缓存离线页面
// 安装事件
self.addEventListener('install', event => {
console.log('Service Worker install, version:', OFFLINE_VERSION)
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME)
// 使用 {cache: 'reload'} 确保从网络获取最新的离线页面
// 而不是从HTTP缓存中获取
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }))
})(),
)
console.log('Service Worker install')
// 强制等待中的Service Worker立即成为活动的Service Worker
self.skipWaiting()
})
@@ -136,36 +121,48 @@ self.addEventListener('activate', event => {
self.clients.claim()
})
// Fetch事件 - 处理网络请求和离线回退
// 处理API请求当离线时发送消息到客户端
self.addEventListener('fetch', event => {
// 只处理HTML页面的导航请求
if (event.request.mode === 'navigate') {
if (event.request.url.includes('/api/v1/') && event.request.method === 'GET') {
event.respondWith(
(async () => {
try {
// 首先尝试使用navigationPreload响应如果支持
const preloadResponse = await event.preloadResponse
if (preloadResponse) {
return preloadResponse
}
// 总是优先尝试网络请求
// 尝试网络请求
const networkResponse = await fetch(event.request)
return networkResponse
} catch (error) {
// 只有在抛出异常时才会触发catch通常是由于网络错误
// 如果fetch()返回4xx或5xx响应码不会触发catch
console.log('网络请求失败,返回离线页面:', error)
// 网络错误时,通知客户端当前处于离线状态
if (self.clients) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'OFFLINE_STATUS',
offline: true,
})
})
})
}
const cache = await caches.open(CACHE_NAME)
const cachedResponse = await cache.match(OFFLINE_URL)
return cachedResponse || new Response('离线页面不可用', { status: 503 })
// 尝试返回缓存的响应
const cache = await caches.open('api-cache')
const cachedResponse = await cache.match(event.request)
if (cachedResponse) {
return cachedResponse
}
// 如果没有缓存,抛出错误
throw error
}
})(),
)
return
}
})
// 初始化 Workbox
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)
// 监听 push 事件,显示通知
self.addEventListener('push', function (event) {
console.log('notification push')