Files
MoviePilot-Frontend/src/utils/pwaStateManager.ts
2025-07-06 23:34:58 +00:00

907 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PWA状态管理器
* 用于在iOS设备上防止后台被杀时丢失状态提供状态恢复功能
* 只在页面隐藏时收集状态,避免实时监听影响性能
*/
export interface ScrollPosition {
x: number
y: number
element?: string
}
export interface ModalState {
id: string
isOpen: boolean
data?: any
}
export interface FormFieldState {
selector: string
value: string | number | boolean
type: string
checked?: boolean
selectedIndex?: number
}
export interface PWAState {
url: string
scrollPosition: number
scrollPositions: ScrollPosition[]
orientation: number
timestamp: number
appData?: any
formData?: Record<string, any>
formFields?: FormFieldState[]
modalStates?: ModalState[]
userSelections?: {
selectedItems: string[]
activeTab?: string
}
}
export interface PWAContext {
url: string
orientation: number
timestamp: number
}
/**
* 状态收集器
* 只在需要时收集状态,不进行实时监听
*/
export class StateCollector {
static collectScrollPositions(): ScrollPosition[] {
const positions: ScrollPosition[] = []
positions.push({
x: window.scrollX,
y: window.scrollY,
element: 'window'
})
const scrollContainers = [
'.v-main__wrap',
'.v-card-text',
'.v-sheet',
'.perfect-scrollbar',
'[data-simplebar]',
'.overflow-auto',
'.overflow-y-auto'
]
scrollContainers.forEach(selector => {
const elements = document.querySelectorAll(selector)
elements.forEach((element) => {
if (element.scrollTop > 0 || element.scrollLeft > 0) {
positions.push({
x: element.scrollLeft,
y: element.scrollTop,
element: this.generateElementSelector(element)
})
}
})
})
return positions
}
static collectModalStates(): ModalState[] {
const states: ModalState[] = []
const modalSelectors = [
'.v-dialog',
'.v-menu',
'.v-overlay',
'.v-tooltip',
'.v-snackbar',
'.modal',
'.popup',
'.drawer',
'.v-navigation-drawer',
'[role="dialog"]',
'[role="alertdialog"]'
]
modalSelectors.forEach(selector => {
const elements = document.querySelectorAll(selector)
elements.forEach(element => {
if (this.isModalOpen(element)) {
states.push({
id: this.getModalId(element),
isOpen: true,
data: this.extractModalData(element)
})
}
})
})
return states
}
static collectFormFields(): FormFieldState[] {
const fields: FormFieldState[] = []
const formElements = document.querySelectorAll('input, textarea, select')
formElements.forEach(element => {
const inputElement = element as HTMLInputElement
if (inputElement.type === 'password' || inputElement.type === 'hidden') {
return
}
if (inputElement.value || inputElement.checked) {
fields.push({
selector: this.getFieldSelector(inputElement),
value: inputElement.value,
type: inputElement.type,
checked: inputElement.checked,
selectedIndex: inputElement.tagName === 'SELECT' ?
(inputElement as unknown as HTMLSelectElement).selectedIndex : undefined
})
}
})
return fields
}
static restoreScrollPositions(positions: ScrollPosition[]): void {
positions.forEach(pos => {
if (pos.element === 'window') {
window.scrollTo({ top: pos.y, left: pos.x, behavior: 'auto' })
} else {
const elements = document.querySelectorAll(pos.element!)
elements.forEach(element => {
element.scrollTo({ top: pos.y, left: pos.x, behavior: 'auto' })
})
}
})
}
static restoreModalStates(states: ModalState[]): void {
states.forEach(state => {
window.dispatchEvent(new CustomEvent('restoreModalState', {
detail: state
}))
})
}
static restoreFormFields(fields: FormFieldState[]): void {
fields.forEach(field => {
const elements = document.querySelectorAll(field.selector)
elements.forEach(element => {
const inputElement = element as HTMLInputElement
if (field.type === 'checkbox' || field.type === 'radio') {
inputElement.checked = field.checked || false
} else if (field.type === 'select-one' || field.type === 'select-multiple') {
const selectElement = inputElement as unknown as HTMLSelectElement
if (field.selectedIndex !== undefined) {
selectElement.selectedIndex = field.selectedIndex
}
} else {
inputElement.value = field.value as string
}
inputElement.dispatchEvent(new Event('input', { bubbles: true }))
inputElement.dispatchEvent(new Event('change', { bubbles: true }))
})
})
}
private static isModalOpen(element: Element): boolean {
const computedStyle = window.getComputedStyle(element)
return computedStyle.display !== 'none' &&
computedStyle.visibility !== 'hidden' &&
computedStyle.opacity !== '0' &&
!element.hasAttribute('hidden') &&
element.getAttribute('aria-hidden') !== 'true'
}
private static getModalId(element: Element): string {
return element.id ||
element.getAttribute('data-modal-id') ||
element.className.replace(/\s+/g, '-') ||
`modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
private static extractModalData(element: Element): any {
const data: any = {}
const inputs = element.querySelectorAll('input, select, textarea')
if (inputs.length > 0) {
data.formData = {}
inputs.forEach(input => {
const inputElement = input as HTMLInputElement
if (inputElement.name && inputElement.value) {
data.formData[inputElement.name] = inputElement.value
}
})
}
const scrollableElements = element.querySelectorAll('[class*="overflow"], .v-card-text')
if (scrollableElements.length > 0) {
data.scrollPositions = Array.from(scrollableElements).map((el) => ({
selector: this.generateElementSelector(el),
x: el.scrollLeft,
y: el.scrollTop
}))
}
return data
}
private static generateElementSelector(element: Element): string {
if (element.id) {
return `#${element.id}`
}
const path = []
let current = element
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase()
if (current.id) {
selector += `#${current.id}`
path.unshift(selector)
break
}
if (current.className) {
const classes = current.className.split(/\s+/).filter(c => c && !c.includes('v-'))
if (classes.length > 0) {
selector += `.${classes[0]}`
}
}
// Use nth-child instead of nth-of-type, but only when necessary
const parent = current.parentElement
if (parent) {
const siblings = Array.from(parent.children).filter(child =>
child.tagName === current.tagName &&
child.className === current.className
)
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1
selector += `:nth-child(${index})`
}
}
path.unshift(selector)
current = current.parentElement as Element
if (path.length >= 4) break
}
return path.join(' > ')
}
private static getFieldSelector(element: HTMLInputElement): string {
if (element.id) return `#${element.id}`
if (element.name) return `[name="${element.name}"]`
const path = []
let current = element as Element
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase()
if (current.id) {
selector += `#${current.id}`
path.unshift(selector)
break
}
if (current.className) {
const classes = current.className.split(/\s+/).filter(c => c)
if (classes.length > 0) {
selector += `.${classes[0]}`
}
}
path.unshift(selector)
current = current.parentNode as Element
if (path.length >= 3) break
}
return path.join(' > ')
}
}
/**
* 基础状态管理器使用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 = 60 * 60 * 1000 // 60分钟延长有效期
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)) {
// 即使URL不匹配也可以恢复一些基础状态如滚动位置除外
return true
}
// 设备方向变化不阻止状态恢复
if (this.isOrientationChanged(savedState, currentContext)) {
// 继续恢复
}
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
private isRestoring = false
private restorePromise: Promise<void> | 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)
}
private handlePageVisible(): void {
if (this.isRestoring) return
this.isRestoring = true
this.restorePromise = this.performStateRestore()
}
private async performStateRestore(): Promise<void> {
try {
const restoredState = this.stateManager.restoreState()
if (restoredState) {
await this.restoreAppState(restoredState)
}
} catch (error) {
// 静默处理错误
} finally {
this.isRestoring = false
}
}
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,
scrollPositions: [{ x: window.scrollX, y: window.scrollY, element: 'window' }],
orientation: window.orientation || 0,
timestamp: Date.now(),
appData: this.getAppSpecificState()
}
}
private async restoreAppState(state: PWAState): Promise<void> {
// 立即恢复状态,无需延迟
if (state.scrollPosition) {
window.scrollTo(0, state.scrollPosition)
}
if (state.appData) {
this.restoreAppSpecificState(state.appData)
}
// 触发状态恢复完成事件
window.dispatchEvent(new CustomEvent('pwaStateRestored', {
detail: { state }
}))
}
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')
}
}
}
}
export class PWAStateController {
private stateManager: PWAStateManager
private indexedDBManager: PWAIndexedDBManager
private swStateSync: ServiceWorkerStateSync
private visibilityManager: VisibilityStateManager
private restoreDecision: StateRestoreDecision
private stateRestorePromise: Promise<void> | null = null
private stateRestoreResolve: (() => void) | null = null
private isRestoring = false
constructor() {
this.stateManager = new PWAStateManager()
this.indexedDBManager = new PWAIndexedDBManager()
this.swStateSync = new ServiceWorkerStateSync()
this.visibilityManager = new VisibilityStateManager(this.stateManager)
this.restoreDecision = new StateRestoreDecision()
this.stateRestorePromise = new Promise((resolve) => {
this.stateRestoreResolve = resolve
})
this.init()
}
async waitForStateRestore(): Promise<void> {
return this.stateRestorePromise || Promise.resolve()
}
get isRestoringState(): boolean {
return this.isRestoring
}
private async init(): Promise<void> {
this.stateManager.clearExpiredState()
await this.checkAndRestoreState()
}
private async checkAndRestoreState(): Promise<void> {
this.isRestoring = true
try {
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) {
// 静默处理错误
}
}
} finally {
this.isRestoring = false
// 状态恢复完成(无论成功还是失败)
if (this.stateRestoreResolve) {
this.stateRestoreResolve()
this.stateRestoreResolve = null
}
}
}
async saveCurrentState(): Promise<void> {
const scrollPositions = StateCollector.collectScrollPositions()
const modalStates = StateCollector.collectModalStates()
const formFields = StateCollector.collectFormFields()
const state: PWAState = {
url: window.location.href,
scrollPosition: window.scrollY,
scrollPositions: scrollPositions.length > 0 ? scrollPositions : [{ x: window.scrollX, y: window.scrollY, element: 'window' }],
orientation: window.orientation || 0,
timestamp: Date.now(),
appData: this.getAppSpecificState(),
modalStates: modalStates.length > 0 ? modalStates : undefined,
formFields: formFields.length > 0 ? formFields : undefined
}
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> {
const currentUrl = window.location.href
const urlMatches = this.isUrlExactMatch(state.url, currentUrl)
if (state.scrollPositions && urlMatches) {
StateCollector.restoreScrollPositions(state.scrollPositions)
} else if (state.scrollPosition && urlMatches) {
window.scrollTo({
top: state.scrollPosition,
behavior: 'auto'
})
}
if (state.modalStates) {
StateCollector.restoreModalStates(state.modalStates)
}
if (state.formFields && urlMatches) {
StateCollector.restoreFormFields(state.formFields)
}
if (state.appData) {
this.restoreAppSpecificState(state.appData)
}
this.dispatchStateRestoreEvent(state)
}
private isUrlExactMatch(savedUrl: string, currentUrl: string): boolean {
try {
const saved = new URL(savedUrl)
const current = new URL(currentUrl)
return saved.pathname === current.pathname
} catch {
return false
}
}
private getAppSpecificState(): any {
return {
routerState: {
currentRoute: window.location.pathname,
query: window.location.search,
hash: window.location.hash
},
uiState: {
sidebarOpen: document.querySelector('.v-navigation-drawer--active') !== null,
darkMode: document.documentElement.getAttribute('data-theme') === 'dark'
}
}
}
private restoreAppSpecificState(appData: any): void {
// 基础状态恢复,可根据需要扩展
}
private dispatchStateRestoreEvent(state: PWAState): void {
window.dispatchEvent(new CustomEvent('pwaStateRestored', {
detail: { state }
}))
}
destroy(): void {
// 无需清理资源
}
}