mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
Implement PWA state management for improved iOS background persistence
Co-authored-by: jxxghp <jxxghp@163.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
|||||||
|
# PWA状态管理功能实现说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本次实现为您的MoviePilot项目添加了完整的PWA状态管理功能,专门解决iOS设备上PWA后台被杀导致状态丢失的问题。
|
||||||
|
|
||||||
|
## 已实现的功能
|
||||||
|
|
||||||
|
### 1. 核心状态管理器 (`src/utils/pwaStateManager.ts`)
|
||||||
|
|
||||||
|
- ✅ **多层存储策略**:localStorage + sessionStorage + IndexedDB + Service Worker缓存
|
||||||
|
- ✅ **智能状态恢复**:基于时间、URL、设备方向的智能决策
|
||||||
|
- ✅ **生命周期监听**:自动监听页面可见性、焦点变化、卸载事件
|
||||||
|
- ✅ **表单状态保存**:自动保存和恢复表单数据
|
||||||
|
- ✅ **滚动位置恢复**:精确恢复页面滚动位置
|
||||||
|
- ✅ **UI状态管理**:保存侧边栏、主题等界面状态
|
||||||
|
|
||||||
|
### 2. Service Worker增强 (`src/service-worker.ts`)
|
||||||
|
|
||||||
|
- ✅ **状态缓存端点**:虚拟的`/api/pwa-state`端点用于状态存储
|
||||||
|
- ✅ **消息通信**:支持与主应用的双向状态同步
|
||||||
|
- ✅ **缓存管理**:专用的状态缓存空间
|
||||||
|
- ✅ **错误处理**:完善的错误处理和降级策略
|
||||||
|
|
||||||
|
### 3. Vue集成 (`src/main.ts`)
|
||||||
|
|
||||||
|
- ✅ **自动初始化**:PWA模式下自动启动状态管理
|
||||||
|
- ✅ **环境检测**:智能检测PWA运行环境
|
||||||
|
- ✅ **全局可用**:状态管理器绑定到全局对象
|
||||||
|
- ✅ **事件监听**:监听状态恢复事件并处理
|
||||||
|
|
||||||
|
### 4. Vue组合式API (`src/composables/usePWAState.ts`)
|
||||||
|
|
||||||
|
- ✅ **响应式状态**:提供响应式的状态管理接口
|
||||||
|
- ✅ **便捷方法**:封装常用的状态操作方法
|
||||||
|
- ✅ **类型安全**:完整的TypeScript类型支持
|
||||||
|
- ✅ **组件友好**:易于在Vue组件中使用
|
||||||
|
|
||||||
|
### 5. 类型声明 (`src/types/pwa.d.ts`)
|
||||||
|
|
||||||
|
- ✅ **类型扩展**:扩展Window和Navigator接口
|
||||||
|
- ✅ **自定义事件**:定义状态恢复事件类型
|
||||||
|
- ✅ **TypeScript支持**:完整的类型安全保障
|
||||||
|
|
||||||
|
### 6. 演示组件 (`src/components/PWAStateDemo.vue`)
|
||||||
|
|
||||||
|
- ✅ **功能演示**:展示所有状态管理功能
|
||||||
|
- ✅ **测试界面**:提供测试表单和操作按钮
|
||||||
|
- ✅ **状态监控**:实时显示状态管理器状态
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 在Vue组件中使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { usePWAState } from '@/composables/usePWAState'
|
||||||
|
|
||||||
|
const {
|
||||||
|
isPWAMode,
|
||||||
|
isStateRestored,
|
||||||
|
saveCurrentState,
|
||||||
|
resetStateRestored
|
||||||
|
} = usePWAState()
|
||||||
|
|
||||||
|
// 手动保存状态
|
||||||
|
const handleImportantAction = async () => {
|
||||||
|
await saveCurrentState()
|
||||||
|
// 执行重要操作
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<VAlert v-if="isStateRestored" type="success">
|
||||||
|
状态已恢复!
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VBtn @click="handleImportantAction">
|
||||||
|
执行重要操作
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动操作状态
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 在浏览器控制台中
|
||||||
|
window.pwaStateController.saveCurrentState() // 保存当前状态
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
### 1. 状态保存时机
|
||||||
|
|
||||||
|
- 🔄 **页面隐藏时**:`visibilitychange`事件触发
|
||||||
|
- 🔄 **失去焦点时**:`blur`事件延迟1秒触发
|
||||||
|
- 🔄 **页面卸载时**:`beforeunload`事件触发
|
||||||
|
- 🔄 **定期保存**:每30秒自动保存一次
|
||||||
|
- 🔄 **手动触发**:调用API手动保存
|
||||||
|
|
||||||
|
### 2. 状态恢复时机
|
||||||
|
|
||||||
|
- 🔄 **应用启动时**:自动检查并恢复状态
|
||||||
|
- 🔄 **页面显示时**:`visibilitychange`事件触发
|
||||||
|
- 🔄 **获得焦点时**:清除延迟保存定时器
|
||||||
|
|
||||||
|
### 3. 存储策略
|
||||||
|
|
||||||
|
```
|
||||||
|
localStorage (主要状态)
|
||||||
|
↓
|
||||||
|
sessionStorage (临时状态)
|
||||||
|
↓
|
||||||
|
IndexedDB (大量数据)
|
||||||
|
↓
|
||||||
|
Service Worker缓存 (跨页面共享)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 恢复决策
|
||||||
|
|
||||||
|
状态恢复需要同时满足:
|
||||||
|
- ✅ 状态未过期(默认30分钟内)
|
||||||
|
- ✅ URL路径匹配
|
||||||
|
- ✅ 设备方向未显著变化
|
||||||
|
|
||||||
|
## 配置选项
|
||||||
|
|
||||||
|
### 修改状态保存间隔
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 PWAStateController 中修改
|
||||||
|
private setupPeriodicSave(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
this.saveCurrentState()
|
||||||
|
}
|
||||||
|
}, 60000) // 改为60秒保存一次
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改状态过期时间
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 StateRestoreDecision 中修改
|
||||||
|
export class StateRestoreDecision {
|
||||||
|
private maxStateAge = 60 * 60 * 1000 // 改为60分钟
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义状态内容
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 PWAStateController 中添加自定义状态
|
||||||
|
private getAppSpecificState(): any {
|
||||||
|
return {
|
||||||
|
// 现有状态...
|
||||||
|
|
||||||
|
// 添加自定义状态
|
||||||
|
customData: {
|
||||||
|
userPreferences: this.getUserPreferences(),
|
||||||
|
currentMedia: this.getCurrentMedia(),
|
||||||
|
searchHistory: this.getSearchHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试和监控
|
||||||
|
|
||||||
|
### 控制台调试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 检查状态管理器是否可用
|
||||||
|
console.log('状态管理器:', window.pwaStateController)
|
||||||
|
|
||||||
|
// 查看当前保存的状态
|
||||||
|
console.log('本地状态:', localStorage.getItem('mp-pwa-app-state'))
|
||||||
|
|
||||||
|
// 手动保存状态
|
||||||
|
window.pwaStateController?.saveCurrentState()
|
||||||
|
|
||||||
|
// 清除所有状态
|
||||||
|
localStorage.removeItem('mp-pwa-app-state')
|
||||||
|
sessionStorage.removeItem('mp-pwa-session-state')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 监听状态事件
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 监听状态恢复事件
|
||||||
|
window.addEventListener('pwaStateRestored', (event) => {
|
||||||
|
console.log('状态已恢复:', event.detail.state)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
### iOS特殊性
|
||||||
|
- PWA不与Safari共享存储空间
|
||||||
|
- 后台执行时间有限(约30秒-5分钟)
|
||||||
|
- 内存压力时会被强制清理
|
||||||
|
- Service Worker可能会被暂停
|
||||||
|
|
||||||
|
### 存储限制
|
||||||
|
- **localStorage**: 约5-10MB
|
||||||
|
- **sessionStorage**: 约5-10MB
|
||||||
|
- **IndexedDB**: 较大但可能被清理
|
||||||
|
- **Service Worker缓存**: 约50MB
|
||||||
|
|
||||||
|
### 性能考虑
|
||||||
|
- 避免保存过大的状态对象
|
||||||
|
- 使用防抖技术避免频繁保存
|
||||||
|
- 异步处理状态操作
|
||||||
|
- 定期清理过期状态
|
||||||
|
|
||||||
|
## 测试方法
|
||||||
|
|
||||||
|
### 1. 基本功能测试
|
||||||
|
1. 将应用添加到iOS桌面
|
||||||
|
2. 打开PWA,填写测试表单
|
||||||
|
3. 切换到其他应用
|
||||||
|
4. 等待几分钟后重新打开PWA
|
||||||
|
5. 检查表单数据和滚动位置是否恢复
|
||||||
|
|
||||||
|
### 2. 状态管理测试
|
||||||
|
1. 在PWA中访问演示页面:`/pwa-state-demo`
|
||||||
|
2. 观察状态管理器状态
|
||||||
|
3. 测试手动保存和恢复功能
|
||||||
|
4. 验证状态恢复通知
|
||||||
|
|
||||||
|
### 3. 长时间测试
|
||||||
|
1. 保持PWA在后台运行几小时
|
||||||
|
2. 使用其他应用增加内存压力
|
||||||
|
3. 重新打开PWA检查状态恢复
|
||||||
|
4. 重启设备后测试状态持久性
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 状态未恢复
|
||||||
|
1. 检查是否在PWA模式运行
|
||||||
|
2. 确认状态管理器已初始化
|
||||||
|
3. 检查控制台错误信息
|
||||||
|
4. 验证存储权限和配额
|
||||||
|
|
||||||
|
### 性能问题
|
||||||
|
1. 减少状态保存频率
|
||||||
|
2. 优化状态对象大小
|
||||||
|
3. 检查内存使用情况
|
||||||
|
4. 考虑延迟初始化
|
||||||
|
|
||||||
|
### 兼容性问题
|
||||||
|
1. 检查iOS版本支持
|
||||||
|
2. 验证Service Worker注册
|
||||||
|
3. 测试不同设备和浏览器
|
||||||
|
4. 检查网络连接状态
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. **智能压缩**:对大型状态对象进行压缩
|
||||||
|
2. **增量保存**:只保存变化的状态部分
|
||||||
|
3. **云端同步**:结合服务器实现跨设备状态同步
|
||||||
|
4. **用户偏好**:允许用户自定义状态保存策略
|
||||||
|
5. **性能监控**:添加状态管理性能指标
|
||||||
|
6. **A/B测试**:测试不同的状态管理策略效果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
通过这套完整的解决方案,您的MoviePilot PWA应该能够在iOS设备上提供更好的用户体验,显著减少因后台被杀而导致的状态丢失问题。
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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