mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 16:56:40 +08:00
Merge pull request #356 from jxxghp/cursor/prevent-ios-from-killing-pwa-background-245b
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.6.2",
|
"version": "2.6.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import type { PWAState } from '@/utils/pwaStateManager'
|
||||||
|
|
||||||
|
export function usePWAState() {
|
||||||
|
const isStateRestored = ref(false)
|
||||||
|
const stateRestoreCount = ref(0)
|
||||||
|
const lastRestoredState = ref<PWAState | null>(null)
|
||||||
|
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: Event) => {
|
||||||
|
const customEvent = event as CustomEvent<{ state: PWAState }>
|
||||||
|
isStateRestored.value = true
|
||||||
|
stateRestoreCount.value++
|
||||||
|
lastRestoredState.value = customEvent.detail.state
|
||||||
|
|
||||||
|
console.log('Vue组件收到状态恢复通知:', customEvent.detail.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置状态恢复标志
|
||||||
|
const resetStateRestored = () => {
|
||||||
|
isStateRestored.value = false
|
||||||
|
lastRestoredState.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查状态管理器是否可用
|
||||||
|
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,
|
||||||
|
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 getStoredState = () => {
|
||||||
|
return localStorage.getItem('mp-pwa-app-state')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除存储的状态
|
||||||
|
const clearStoredState = () => {
|
||||||
|
localStorage.removeItem('mp-pwa-app-state')
|
||||||
|
sessionStorage.removeItem('mp-pwa-session-state')
|
||||||
|
console.log('已清除PWA存储状态')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPWAEnvironment,
|
||||||
|
getStoredState,
|
||||||
|
clearStoredState
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -43,6 +43,9 @@ import HeaderTab from './layouts/components/HeaderTab.vue'
|
|||||||
// 7. 样式文件 - 合并为单一导入
|
// 7. 样式文件 - 合并为单一导入
|
||||||
import '@/styles/main.scss'
|
import '@/styles/main.scss'
|
||||||
|
|
||||||
|
// 8. PWA状态管理
|
||||||
|
import { PWAStateController } from '@/utils/pwaStateManager'
|
||||||
|
|
||||||
// 创建Vue实例
|
// 创建Vue实例
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
@@ -89,3 +92,44 @@ app
|
|||||||
.use(ConfirmDialog)
|
.use(ConfirmDialog)
|
||||||
.use(i18n)
|
.use(i18n)
|
||||||
.mount('#app')
|
.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 }
|
||||||
|
|||||||
+91
-2
@@ -13,6 +13,10 @@ const options = {
|
|||||||
// 存储未读消息数量的键名
|
// 存储未读消息数量的键名
|
||||||
const UNREAD_COUNT_KEY = 'mp_unread_count'
|
const UNREAD_COUNT_KEY = 'mp_unread_count'
|
||||||
|
|
||||||
|
// 状态管理相关的缓存名称和端点
|
||||||
|
const STATE_CACHE_NAME = 'mp-pwa-state-cache'
|
||||||
|
const STATE_ENDPOINT = '/api/pwa-state'
|
||||||
|
|
||||||
// 从IndexedDB获取未读消息数量
|
// 从IndexedDB获取未读消息数量
|
||||||
async function getStoredUnreadCount(): Promise<number> {
|
async function getStoredUnreadCount(): Promise<number> {
|
||||||
try {
|
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包装器
|
// 简单的IndexedDB包装器
|
||||||
async function openDB(): Promise<IDBDatabase> {
|
async function openDB(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -123,6 +173,18 @@ self.addEventListener('activate', event => {
|
|||||||
|
|
||||||
// 处理API请求,当离线时发送消息到客户端
|
// 处理API请求,当离线时发送消息到客户端
|
||||||
self.addEventListener('fetch', event => {
|
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') {
|
if (event.request.url.includes('/api/v1/') && event.request.method === 'GET') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -226,7 +288,7 @@ self.addEventListener('message', function (event) {
|
|||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Failed to clear badge:', 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') {
|
} else if (event.data && event.data.type === 'UPDATE_BADGE') {
|
||||||
// 更新徽章数量
|
// 更新徽章数量
|
||||||
@@ -238,7 +300,7 @@ self.addEventListener('message', function (event) {
|
|||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Failed to update badge:', 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') {
|
} 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)
|
console.error('Failed to get unread count:', error)
|
||||||
event.ports[0]?.postMessage({ count: 0 })
|
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: {} })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+26
@@ -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 {}
|
||||||
@@ -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