mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-23 09:19:46 +08:00
Implement PWA state management for improved iOS background persistence
Co-authored-by: jxxghp <jxxghp@163.com>
This commit is contained in:
185
src/components/PWAStateDemo.vue
Normal file
185
src/components/PWAStateDemo.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<VCard class="ma-4" title="PWA状态管理演示">
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="isPWAMode"
|
||||
type="success"
|
||||
class="mb-4"
|
||||
>
|
||||
<VIcon icon="mdi-check-circle" class="me-2" />
|
||||
检测到PWA模式,状态管理功能已启用
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-else
|
||||
type="info"
|
||||
class="mb-4"
|
||||
>
|
||||
<VIcon icon="mdi-information" class="me-2" />
|
||||
当前在浏览器模式,请添加到桌面后体验状态管理功能
|
||||
</VAlert>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VCard variant="outlined">
|
||||
<VCardTitle>状态信息</VCardTitle>
|
||||
<VCardText>
|
||||
<VList density="compact">
|
||||
<VListItem>
|
||||
<VListItemTitle>PWA模式</VListItemTitle>
|
||||
<VListItemSubtitle>{{ isPWAMode ? '是' : '否' }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VListItemTitle>状态管理器可用</VListItemTitle>
|
||||
<VListItemSubtitle>{{ isStateManagerAvailable() ? '是' : '否' }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem>
|
||||
<VListItemTitle>状态恢复次数</VListItemTitle>
|
||||
<VListItemSubtitle>{{ stateRestoreCount }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="isStateRestored">
|
||||
<VListItemTitle>最后恢复时间</VListItemTitle>
|
||||
<VListItemSubtitle>{{ lastRestoredState?.timestamp ? new Date(lastRestoredState.timestamp).toLocaleString() : '无' }}</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VCard variant="outlined">
|
||||
<VCardTitle>操作面板</VCardTitle>
|
||||
<VCardText class="d-flex flex-column ga-3">
|
||||
<VBtn
|
||||
@click="saveCurrentState"
|
||||
:disabled="!isStateManagerAvailable()"
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-save"
|
||||
>
|
||||
手动保存状态
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
@click="checkStateRestore"
|
||||
:disabled="!isStateManagerAvailable()"
|
||||
color="secondary"
|
||||
prepend-icon="mdi-restore"
|
||||
>
|
||||
检查状态恢复
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
@click="clearStoredState"
|
||||
color="warning"
|
||||
prepend-icon="mdi-delete"
|
||||
>
|
||||
清除存储状态
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
@click="resetStateRestored"
|
||||
v-if="isStateRestored"
|
||||
color="info"
|
||||
prepend-icon="mdi-refresh"
|
||||
>
|
||||
重置恢复标志
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<!-- 测试表单 -->
|
||||
<VCard variant="outlined" class="mt-4">
|
||||
<VCardTitle>测试表单(用于验证状态恢复)</VCardTitle>
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="testForm.name"
|
||||
label="姓名"
|
||||
name="test-name"
|
||||
persistent-hint
|
||||
hint="切换应用后再回来,这个值应该被恢复"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="testForm.email"
|
||||
label="邮箱"
|
||||
name="test-email"
|
||||
type="email"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="testForm.message"
|
||||
label="消息"
|
||||
name="test-message"
|
||||
rows="3"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 状态恢复提示 -->
|
||||
<VAlert
|
||||
v-if="isStateRestored"
|
||||
type="success"
|
||||
class="mt-4"
|
||||
closable
|
||||
@click:close="resetStateRestored"
|
||||
>
|
||||
<VIcon icon="mdi-check-circle" class="me-2" />
|
||||
状态已成功恢复!滚动位置和表单数据应该已经恢复到之前的状态。
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePWAState, useGlobalPWAState } from '@/composables/usePWAState'
|
||||
|
||||
// 使用PWA状态管理
|
||||
const {
|
||||
isPWAMode,
|
||||
isStateRestored,
|
||||
stateRestoreCount,
|
||||
lastRestoredState,
|
||||
saveCurrentState,
|
||||
checkStateRestore,
|
||||
resetStateRestored,
|
||||
isStateManagerAvailable
|
||||
} = usePWAState()
|
||||
|
||||
// 使用全局PWA状态管理
|
||||
const { clearStoredState } = useGlobalPWAState()
|
||||
|
||||
// 测试表单数据
|
||||
const testForm = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
message: ''
|
||||
})
|
||||
|
||||
// 监听状态恢复事件,恢复表单数据
|
||||
watch(isStateRestored, (restored) => {
|
||||
if (restored && lastRestoredState.value?.appData?.formState) {
|
||||
console.log('检测到状态恢复,尝试恢复表单数据')
|
||||
// 这里可以添加更复杂的表单数据恢复逻辑
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
console.log('PWA状态演示组件已挂载')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
171
src/composables/usePWAState.ts
Normal file
171
src/composables/usePWAState.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* PWA状态管理的Vue组合式API
|
||||
*/
|
||||
|
||||
import type { PWAState } from '@/utils/pwaStateManager'
|
||||
|
||||
export function usePWAState() {
|
||||
const isStateRestored = ref(false)
|
||||
const stateRestoreCount = ref(0)
|
||||
const lastRestoredState = ref<PWAState | null>(null)
|
||||
|
||||
// 检查是否在PWA模式下运行
|
||||
const isPWAMode = ref(false)
|
||||
|
||||
// 检查PWA模式
|
||||
const checkPWAMode = () => {
|
||||
isPWAMode.value = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone ||
|
||||
document.referrer.includes('android-app://')
|
||||
}
|
||||
|
||||
// 保存当前状态
|
||||
const saveCurrentState = async () => {
|
||||
if (window.pwaStateController) {
|
||||
await window.pwaStateController.saveCurrentState()
|
||||
console.log('手动保存PWA状态')
|
||||
}
|
||||
}
|
||||
|
||||
// 手动触发状态恢复检查
|
||||
const checkStateRestore = async () => {
|
||||
if (window.pwaStateController) {
|
||||
// 这里可以添加手动检查状态恢复的逻辑
|
||||
console.log('检查状态恢复')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听状态恢复事件
|
||||
const handleStateRestored = (event: CustomEvent<{ state: PWAState }>) => {
|
||||
isStateRestored.value = true
|
||||
stateRestoreCount.value++
|
||||
lastRestoredState.value = event.detail.state
|
||||
|
||||
console.log('Vue组件收到状态恢复通知:', event.detail.state)
|
||||
}
|
||||
|
||||
// 重置状态恢复标志
|
||||
const resetStateRestored = () => {
|
||||
isStateRestored.value = false
|
||||
lastRestoredState.value = null
|
||||
}
|
||||
|
||||
// 获取状态管理器实例
|
||||
const getStateController = () => {
|
||||
return window.pwaStateController
|
||||
}
|
||||
|
||||
// 检查状态管理器是否可用
|
||||
const isStateManagerAvailable = () => {
|
||||
return !!window.pwaStateController
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkPWAMode()
|
||||
|
||||
// 监听状态恢复事件
|
||||
window.addEventListener('pwaStateRestored', handleStateRestored)
|
||||
|
||||
// 监听PWA模式变化
|
||||
const mediaQuery = window.matchMedia('(display-mode: standalone)')
|
||||
const handleDisplayModeChange = (e: MediaQueryListEvent) => {
|
||||
isPWAMode.value = e.matches
|
||||
}
|
||||
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleDisplayModeChange)
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
mediaQuery.addListener(handleDisplayModeChange)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener('change', handleDisplayModeChange)
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
mediaQuery.removeListener(handleDisplayModeChange)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pwaStateRestored', handleStateRestored)
|
||||
})
|
||||
|
||||
return {
|
||||
// 响应式状态
|
||||
isPWAMode,
|
||||
isStateRestored,
|
||||
stateRestoreCount,
|
||||
lastRestoredState,
|
||||
|
||||
// 方法
|
||||
saveCurrentState,
|
||||
checkStateRestore,
|
||||
resetStateRestored,
|
||||
getStateController,
|
||||
isStateManagerAvailable,
|
||||
checkPWAMode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局PWA状态管理器
|
||||
*/
|
||||
export function useGlobalPWAState() {
|
||||
// 检查是否在PWA环境中
|
||||
const isPWAEnvironment = () => {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone ||
|
||||
document.referrer.includes('android-app://')
|
||||
}
|
||||
|
||||
// 初始化状态管理器(如果尚未初始化)
|
||||
const initStateManager = async () => {
|
||||
if (!window.pwaStateController && isPWAEnvironment()) {
|
||||
const { PWAStateController } = await import('@/utils/pwaStateManager')
|
||||
window.pwaStateController = new PWAStateController()
|
||||
console.log('延迟初始化PWA状态管理器')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存应用状态
|
||||
const saveAppState = async (customData?: any) => {
|
||||
await initStateManager()
|
||||
|
||||
if (window.pwaStateController) {
|
||||
// 如果有自定义数据,可以通过这种方式传递
|
||||
if (customData) {
|
||||
// 临时存储自定义数据
|
||||
;(window as any).tempCustomState = customData
|
||||
}
|
||||
|
||||
await window.pwaStateController.saveCurrentState()
|
||||
|
||||
// 清除临时数据
|
||||
if (customData) {
|
||||
delete (window as any).tempCustomState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取存储的状态
|
||||
const getStoredState = () => {
|
||||
return localStorage.getItem('mp-pwa-app-state')
|
||||
}
|
||||
|
||||
// 清除存储的状态
|
||||
const clearStoredState = () => {
|
||||
localStorage.removeItem('mp-pwa-app-state')
|
||||
sessionStorage.removeItem('mp-pwa-session-state')
|
||||
}
|
||||
|
||||
return {
|
||||
isPWAEnvironment,
|
||||
initStateManager,
|
||||
saveAppState,
|
||||
getStoredState,
|
||||
clearStoredState
|
||||
}
|
||||
}
|
||||
44
src/main.ts
44
src/main.ts
@@ -43,6 +43,9 @@ import HeaderTab from './layouts/components/HeaderTab.vue'
|
||||
// 7. 样式文件 - 合并为单一导入
|
||||
import '@/styles/main.scss'
|
||||
|
||||
// 8. PWA状态管理
|
||||
import { PWAStateController } from '@/utils/pwaStateManager'
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -89,3 +92,44 @@ app
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
|
||||
// 5. 初始化PWA状态管理器
|
||||
let pwaStateController: PWAStateController | null = null
|
||||
|
||||
// 等待DOM准备就绪后初始化状态管理
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 检查是否在PWA模式下运行
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone ||
|
||||
document.referrer.includes('android-app://')
|
||||
|
||||
if (isPWA) {
|
||||
console.log('检测到PWA模式,初始化状态管理器')
|
||||
pwaStateController = new PWAStateController()
|
||||
|
||||
// 将状态管理器绑定到全局对象,便于调试和手动操作
|
||||
;(window as any).pwaStateController = pwaStateController
|
||||
|
||||
// 监听状态恢复事件
|
||||
window.addEventListener('pwaStateRestored', (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
console.log('PWA状态已恢复:', customEvent.detail.state)
|
||||
|
||||
// 可以在这里添加状态恢复后的处理逻辑
|
||||
// 例如:通知Vue组件状态已恢复
|
||||
app.config.globalProperties.$pwaStateRestored = true
|
||||
})
|
||||
|
||||
// 监听应用即将卸载事件,保存状态
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (pwaStateController) {
|
||||
pwaStateController.saveCurrentState()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('非PWA模式,跳过状态管理器初始化')
|
||||
}
|
||||
})
|
||||
|
||||
// 导出状态管理器供其他模块使用
|
||||
export { pwaStateController }
|
||||
|
||||
@@ -13,6 +13,10 @@ const options = {
|
||||
// 存储未读消息数量的键名
|
||||
const UNREAD_COUNT_KEY = 'mp_unread_count'
|
||||
|
||||
// 状态管理相关的缓存名称和端点
|
||||
const STATE_CACHE_NAME = 'mp-pwa-state-cache'
|
||||
const STATE_ENDPOINT = '/api/pwa-state'
|
||||
|
||||
// 从IndexedDB获取未读消息数量
|
||||
async function getStoredUnreadCount(): Promise<number> {
|
||||
try {
|
||||
@@ -33,6 +37,52 @@ async function setStoredUnreadCount(count: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存PWA状态到缓存
|
||||
async function saveStateToCache(request: Request): Promise<Response> {
|
||||
try {
|
||||
const state = await request.json()
|
||||
const cache = await caches.open(STATE_CACHE_NAME)
|
||||
|
||||
await cache.put(STATE_ENDPOINT, new Response(JSON.stringify({
|
||||
...state,
|
||||
timestamp: Date.now()
|
||||
})))
|
||||
|
||||
return new Response(JSON.stringify({ success: true }))
|
||||
} catch (error) {
|
||||
console.error('Failed to save state to cache:', error)
|
||||
return new Response(JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error) }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 从缓存获取PWA状态
|
||||
async function getStateFromCache(): Promise<Response> {
|
||||
try {
|
||||
const cache = await caches.open(STATE_CACHE_NAME)
|
||||
const response = await cache.match(STATE_ENDPOINT)
|
||||
|
||||
if (response) {
|
||||
const state = await response.json()
|
||||
return new Response(JSON.stringify(state), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get state from cache:', error)
|
||||
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 简单的IndexedDB包装器
|
||||
async function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -123,6 +173,18 @@ self.addEventListener('activate', event => {
|
||||
|
||||
// 处理API请求,当离线时发送消息到客户端
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// 处理PWA状态管理请求
|
||||
if (url.pathname === STATE_ENDPOINT) {
|
||||
if (event.request.method === 'POST') {
|
||||
event.respondWith(saveStateToCache(event.request))
|
||||
} else if (event.request.method === 'GET') {
|
||||
event.respondWith(getStateFromCache())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.request.url.includes('/api/v1/') && event.request.method === 'GET') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
@@ -226,7 +288,7 @@ self.addEventListener('message', function (event) {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to clear badge:', error)
|
||||
event.ports[0]?.postMessage({ success: false, error: error.message })
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'UPDATE_BADGE') {
|
||||
// 更新徽章数量
|
||||
@@ -238,7 +300,7 @@ self.addEventListener('message', function (event) {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to update badge:', error)
|
||||
event.ports[0]?.postMessage({ success: false, error: error.message })
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'GET_UNREAD_COUNT') {
|
||||
// 获取未读消息数量
|
||||
@@ -250,5 +312,32 @@ self.addEventListener('message', function (event) {
|
||||
console.error('Failed to get unread count:', error)
|
||||
event.ports[0]?.postMessage({ count: 0 })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'SAVE_PWA_STATE') {
|
||||
// 保存PWA状态
|
||||
const state = event.data.state || {}
|
||||
saveStateToCache(new Request(STATE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state)
|
||||
}))
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
event.ports[0]?.postMessage({ success: result.success })
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to save PWA state:', error)
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'GET_PWA_STATE') {
|
||||
// 获取PWA状态
|
||||
getStateFromCache()
|
||||
.then(response => response.json())
|
||||
.then(state => {
|
||||
event.ports[0]?.postMessage({ state })
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to get PWA state:', error)
|
||||
event.ports[0]?.postMessage({ state: {} })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
26
src/types/pwa.d.ts
vendored
Normal file
26
src/types/pwa.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* PWA相关的类型声明
|
||||
*/
|
||||
|
||||
// 扩展Window接口
|
||||
declare global {
|
||||
interface Window {
|
||||
pwaStateController?: import('@/utils/pwaStateManager').PWAStateController
|
||||
orientation?: number
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
standalone?: boolean
|
||||
setAppBadge?: (count: number) => Promise<void>
|
||||
clearAppBadge?: () => Promise<void>
|
||||
}
|
||||
|
||||
// 自定义事件类型
|
||||
interface WindowEventMap {
|
||||
'pwaStateRestored': CustomEvent<{
|
||||
state: import('@/utils/pwaStateManager').PWAState
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
630
src/utils/pwaStateManager.ts
Normal file
630
src/utils/pwaStateManager.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* PWA状态管理器
|
||||
* 用于在iOS设备上防止后台被杀时丢失状态,提供状态恢复功能
|
||||
*/
|
||||
|
||||
// 应用状态接口
|
||||
export interface PWAState {
|
||||
url: string
|
||||
scrollPosition: number
|
||||
orientation: number
|
||||
timestamp: number
|
||||
appData?: any
|
||||
formData?: Record<string, any>
|
||||
userSelections?: {
|
||||
selectedItems: string[]
|
||||
activeTab?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 当前上下文接口
|
||||
export interface PWAContext {
|
||||
url: string
|
||||
orientation: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础状态管理器(使用localStorage和sessionStorage)
|
||||
*/
|
||||
export class PWAStateManager {
|
||||
private storageKey = 'mp-pwa-app-state'
|
||||
private sessionKey = 'mp-pwa-session-state'
|
||||
|
||||
// 保存应用状态
|
||||
saveState(state: PWAState): void {
|
||||
try {
|
||||
// 主要状态存储到localStorage
|
||||
localStorage.setItem(this.storageKey, JSON.stringify({
|
||||
...state,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// 临时状态存储到sessionStorage
|
||||
sessionStorage.setItem(this.sessionKey, JSON.stringify({
|
||||
scrollPosition: state.scrollPosition,
|
||||
activeTab: state.appData?.activeTab,
|
||||
formData: state.formData
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('状态保存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复应用状态
|
||||
restoreState(): PWAState | null {
|
||||
try {
|
||||
const savedState = localStorage.getItem(this.storageKey)
|
||||
const sessionState = sessionStorage.getItem(this.sessionKey)
|
||||
|
||||
if (savedState) {
|
||||
const state = JSON.parse(savedState)
|
||||
const sessionData = sessionState ? JSON.parse(sessionState) : {}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...sessionData,
|
||||
isRestored: true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('状态恢复失败:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 清除过期状态
|
||||
clearExpiredState(maxAge = 24 * 60 * 60 * 1000): void { // 24小时
|
||||
try {
|
||||
const savedState = localStorage.getItem(this.storageKey)
|
||||
if (savedState) {
|
||||
const state = JSON.parse(savedState)
|
||||
if (Date.now() - state.timestamp > maxAge) {
|
||||
localStorage.removeItem(this.storageKey)
|
||||
sessionStorage.removeItem(this.sessionKey)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清除过期状态失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IndexedDB状态管理器
|
||||
*/
|
||||
export class PWAIndexedDBManager {
|
||||
private dbName = 'MPPWAStateDB'
|
||||
private dbVersion = 1
|
||||
private storeName = 'appState'
|
||||
|
||||
private async initDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
db.createObjectStore(this.storeName, { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async saveState(state: PWAState): Promise<void> {
|
||||
try {
|
||||
const db = await this.initDB()
|
||||
const transaction = db.transaction([this.storeName], 'readwrite')
|
||||
const store = transaction.objectStore(this.storeName)
|
||||
|
||||
await store.put({
|
||||
id: 'appState',
|
||||
data: state,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('IndexedDB保存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async restoreState(): Promise<PWAState | null> {
|
||||
try {
|
||||
const db = await this.initDB()
|
||||
const transaction = db.transaction([this.storeName], 'readonly')
|
||||
const store = transaction.objectStore(this.storeName)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get('appState')
|
||||
request.onsuccess = () => {
|
||||
const result = request.result
|
||||
resolve(result ? result.data : null)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('IndexedDB恢复失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Worker状态同步
|
||||
*/
|
||||
export class ServiceWorkerStateSync {
|
||||
private stateEndpoint = '/api/pwa-state'
|
||||
|
||||
async saveState(state: PWAState): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(this.stateEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result.success
|
||||
} catch (error) {
|
||||
console.error('Service Worker状态保存失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async loadState(): Promise<PWAState | null> {
|
||||
try {
|
||||
const response = await fetch(this.stateEndpoint)
|
||||
const state = await response.json()
|
||||
return Object.keys(state).length > 0 ? state : null
|
||||
} catch (error) {
|
||||
console.error('Service Worker状态加载失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 使用MessageChannel与Service Worker通信
|
||||
async saveStateViaMessage(state: PWAState): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = (event) => {
|
||||
resolve(event.data.success)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'SAVE_PWA_STATE',
|
||||
state
|
||||
}, [channel.port2])
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async loadStateViaMessage(): Promise<PWAState | null> {
|
||||
return new Promise((resolve) => {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = (event) => {
|
||||
resolve(event.data.state || null)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'GET_PWA_STATE'
|
||||
}, [channel.port2])
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态恢复决策器
|
||||
*/
|
||||
export class StateRestoreDecision {
|
||||
private maxStateAge = 30 * 60 * 1000 // 30分钟
|
||||
|
||||
shouldRestoreState(savedState: PWAState | null, currentContext: PWAContext): boolean {
|
||||
if (!savedState) return false
|
||||
|
||||
// 检查状态年龄
|
||||
if (this.isStateExpired(savedState)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查URL匹配
|
||||
if (!this.isUrlCompatible(savedState.url, currentContext.url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查设备方向
|
||||
if (this.isOrientationChanged(savedState, currentContext)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private isStateExpired(savedState: PWAState): boolean {
|
||||
return Date.now() - savedState.timestamp > this.maxStateAge
|
||||
}
|
||||
|
||||
private isUrlCompatible(savedUrl: string, currentUrl: string): boolean {
|
||||
if (!savedUrl || !currentUrl) return false
|
||||
|
||||
try {
|
||||
const savedPath = new URL(savedUrl).pathname
|
||||
const currentPath = new URL(currentUrl).pathname
|
||||
return savedPath === currentPath
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isOrientationChanged(savedState: PWAState, currentContext: PWAContext): boolean {
|
||||
return savedState.orientation !== currentContext.orientation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面可见性状态管理器
|
||||
*/
|
||||
export class VisibilityStateManager {
|
||||
private stateManager: PWAStateManager
|
||||
private blurTimer: number | null = null
|
||||
|
||||
constructor(stateManager: PWAStateManager) {
|
||||
this.stateManager = stateManager
|
||||
this.setupVisibilityListener()
|
||||
}
|
||||
|
||||
private setupVisibilityListener(): void {
|
||||
// 监听页面可见性变化
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.handlePageHidden()
|
||||
} else {
|
||||
this.handlePageVisible()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听页面卸载
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.handlePageUnload()
|
||||
})
|
||||
|
||||
// 监听页面焦点变化
|
||||
window.addEventListener('blur', () => {
|
||||
this.handlePageBlur()
|
||||
})
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
this.handlePageFocus()
|
||||
})
|
||||
}
|
||||
|
||||
private handlePageHidden(): void {
|
||||
const currentState = this.getCurrentAppState()
|
||||
this.stateManager.saveState(currentState)
|
||||
console.log('页面被隐藏,已保存状态')
|
||||
}
|
||||
|
||||
private handlePageVisible(): void {
|
||||
const restoredState = this.stateManager.restoreState()
|
||||
if (restoredState) {
|
||||
this.restoreAppState(restoredState)
|
||||
console.log('页面显示,已恢复状态')
|
||||
}
|
||||
}
|
||||
|
||||
private handlePageUnload(): void {
|
||||
const currentState = this.getCurrentAppState()
|
||||
this.stateManager.saveState(currentState)
|
||||
}
|
||||
|
||||
private handlePageBlur(): void {
|
||||
if (this.blurTimer) clearTimeout(this.blurTimer)
|
||||
this.blurTimer = window.setTimeout(() => {
|
||||
const currentState = this.getCurrentAppState()
|
||||
this.stateManager.saveState(currentState)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
private handlePageFocus(): void {
|
||||
if (this.blurTimer) {
|
||||
clearTimeout(this.blurTimer)
|
||||
this.blurTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentAppState(): PWAState {
|
||||
return {
|
||||
url: window.location.href,
|
||||
scrollPosition: window.scrollY,
|
||||
orientation: window.orientation || 0,
|
||||
timestamp: Date.now(),
|
||||
appData: this.getAppSpecificState()
|
||||
}
|
||||
}
|
||||
|
||||
private restoreAppState(state: PWAState): void {
|
||||
if (state.scrollPosition) {
|
||||
window.scrollTo(0, state.scrollPosition)
|
||||
}
|
||||
if (state.appData) {
|
||||
this.restoreAppSpecificState(state.appData)
|
||||
}
|
||||
}
|
||||
|
||||
private getAppSpecificState(): any {
|
||||
// 获取应用特定状态
|
||||
return {
|
||||
formData: this.getFormData(),
|
||||
userSelections: this.getUserSelections()
|
||||
}
|
||||
}
|
||||
|
||||
private restoreAppSpecificState(appData: any): void {
|
||||
if (appData.formData) {
|
||||
this.restoreFormData(appData.formData)
|
||||
}
|
||||
if (appData.userSelections) {
|
||||
this.restoreUserSelections(appData.userSelections)
|
||||
}
|
||||
}
|
||||
|
||||
private getFormData(): Record<string, any> {
|
||||
const forms = document.querySelectorAll('form')
|
||||
const formData: Record<string, any> = {}
|
||||
|
||||
forms.forEach((form, index) => {
|
||||
const data = new FormData(form)
|
||||
formData[`form-${index}`] = Object.fromEntries(data)
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
private restoreFormData(formData: Record<string, any>): void {
|
||||
Object.entries(formData).forEach(([formId, data]) => {
|
||||
const formIndex = parseInt(formId.split('-')[1])
|
||||
const form = document.querySelectorAll('form')[formIndex]
|
||||
|
||||
if (form) {
|
||||
Object.entries(data).forEach(([name, value]) => {
|
||||
const input = form.querySelector(`[name="${name}"]`) as HTMLInputElement
|
||||
if (input) {
|
||||
input.value = value as string
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getUserSelections(): any {
|
||||
return {
|
||||
selectedItems: Array.from(document.querySelectorAll('.selected')).map(el => el.id),
|
||||
activeTab: document.querySelector('.tab.active')?.id
|
||||
}
|
||||
}
|
||||
|
||||
private restoreUserSelections(selections: any): void {
|
||||
if (selections.selectedItems) {
|
||||
selections.selectedItems.forEach((id: string) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
element.classList.add('selected')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (selections.activeTab) {
|
||||
const tab = document.getElementById(selections.activeTab)
|
||||
if (tab) {
|
||||
tab.classList.add('active')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的PWA状态管理器
|
||||
*/
|
||||
export class PWAStateController {
|
||||
private stateManager: PWAStateManager
|
||||
private indexedDBManager: PWAIndexedDBManager
|
||||
private swStateSync: ServiceWorkerStateSync
|
||||
private visibilityManager: VisibilityStateManager
|
||||
private restoreDecision: StateRestoreDecision
|
||||
|
||||
constructor() {
|
||||
this.stateManager = new PWAStateManager()
|
||||
this.indexedDBManager = new PWAIndexedDBManager()
|
||||
this.swStateSync = new ServiceWorkerStateSync()
|
||||
this.visibilityManager = new VisibilityStateManager(this.stateManager)
|
||||
this.restoreDecision = new StateRestoreDecision()
|
||||
|
||||
this.init()
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
// 清理过期状态
|
||||
this.stateManager.clearExpiredState()
|
||||
|
||||
// 检查是否需要恢复状态
|
||||
await this.checkAndRestoreState()
|
||||
|
||||
// 设置定期保存
|
||||
this.setupPeriodicSave()
|
||||
}
|
||||
|
||||
private async checkAndRestoreState(): Promise<void> {
|
||||
const currentContext: PWAContext = {
|
||||
url: window.location.href,
|
||||
orientation: window.orientation || 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// 尝试从多个来源恢复状态
|
||||
const sources = [
|
||||
() => this.stateManager.restoreState(),
|
||||
() => this.indexedDBManager.restoreState(),
|
||||
() => this.swStateSync.loadState(),
|
||||
() => this.swStateSync.loadStateViaMessage()
|
||||
]
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const savedState = await source()
|
||||
if (this.restoreDecision.shouldRestoreState(savedState, currentContext)) {
|
||||
await this.restoreState(savedState!)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('状态恢复失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveCurrentState(): Promise<void> {
|
||||
const state: PWAState = {
|
||||
url: window.location.href,
|
||||
scrollPosition: window.scrollY,
|
||||
orientation: window.orientation || 0,
|
||||
timestamp: Date.now(),
|
||||
appData: this.getAppSpecificState()
|
||||
}
|
||||
|
||||
// 多重保存策略
|
||||
await Promise.allSettled([
|
||||
this.stateManager.saveState(state),
|
||||
this.indexedDBManager.saveState(state),
|
||||
this.swStateSync.saveState(state),
|
||||
this.swStateSync.saveStateViaMessage(state)
|
||||
])
|
||||
}
|
||||
|
||||
private async restoreState(state: PWAState): Promise<void> {
|
||||
// 恢复滚动位置
|
||||
if (state.scrollPosition) {
|
||||
window.scrollTo(0, state.scrollPosition)
|
||||
}
|
||||
|
||||
// 恢复应用特定状态
|
||||
if (state.appData) {
|
||||
this.restoreAppSpecificState(state.appData)
|
||||
}
|
||||
|
||||
// 触发状态恢复事件
|
||||
this.dispatchStateRestoreEvent(state)
|
||||
}
|
||||
|
||||
private setupPeriodicSave(): void {
|
||||
// 每30秒保存一次状态
|
||||
setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
this.saveCurrentState()
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
private getAppSpecificState(): any {
|
||||
// 可以在这里添加MoviePilot特定的状态
|
||||
return {
|
||||
// 路由状态
|
||||
routerState: this.getRouterState(),
|
||||
// 用户界面状态
|
||||
uiState: this.getUIState(),
|
||||
// 表单状态
|
||||
formState: this.getFormState()
|
||||
}
|
||||
}
|
||||
|
||||
private getRouterState(): any {
|
||||
// 获取Vue Router状态
|
||||
return {
|
||||
currentRoute: window.location.pathname,
|
||||
query: window.location.search,
|
||||
hash: window.location.hash
|
||||
}
|
||||
}
|
||||
|
||||
private getUIState(): any {
|
||||
// 获取UI状态
|
||||
return {
|
||||
sidebarOpen: document.querySelector('.v-navigation-drawer--active') !== null,
|
||||
darkMode: document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
private getFormState(): any {
|
||||
// 获取表单状态
|
||||
const forms = document.querySelectorAll('form')
|
||||
const formData: Record<string, any> = {}
|
||||
|
||||
forms.forEach((form, index) => {
|
||||
const inputs = form.querySelectorAll('input, select, textarea')
|
||||
const data: Record<string, any> = {}
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const element = input as HTMLInputElement
|
||||
if (element.name) {
|
||||
data[element.name] = element.value
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
formData[`form-${index}`] = data
|
||||
}
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
private restoreAppSpecificState(appData: any): void {
|
||||
if (appData.uiState) {
|
||||
this.restoreUIState(appData.uiState)
|
||||
}
|
||||
if (appData.formState) {
|
||||
this.restoreFormState(appData.formState)
|
||||
}
|
||||
}
|
||||
|
||||
private restoreUIState(uiState: any): void {
|
||||
// 恢复UI状态
|
||||
if (uiState.darkMode !== undefined) {
|
||||
// 这里可以根据实际的主题切换逻辑来恢复
|
||||
console.log('恢复主题状态:', uiState.darkMode)
|
||||
}
|
||||
}
|
||||
|
||||
private restoreFormState(formState: any): void {
|
||||
// 恢复表单状态
|
||||
Object.entries(formState).forEach(([formId, data]) => {
|
||||
const formIndex = parseInt(formId.split('-')[1])
|
||||
const form = document.querySelectorAll('form')[formIndex]
|
||||
|
||||
if (form) {
|
||||
Object.entries(data as Record<string, any>).forEach(([name, value]) => {
|
||||
const input = form.querySelector(`[name="${name}"]`) as HTMLInputElement
|
||||
if (input) {
|
||||
input.value = value as string
|
||||
// 触发change事件,以便Vue能够响应
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private dispatchStateRestoreEvent(state: PWAState): void {
|
||||
const event = new CustomEvent('pwaStateRestored', {
|
||||
detail: { state }
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user