Implement PWA state management for improved iOS background persistence

Co-authored-by: jxxghp <jxxghp@163.com>
This commit is contained in:
Cursor Agent
2025-07-06 06:44:06 +00:00
parent a16dd497c4
commit 0e440955c8
7 changed files with 1417 additions and 2 deletions

View 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)
}
}