mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-07 23:41:28 +08:00
优化PWA状态管理
This commit is contained in:
@@ -42,19 +42,27 @@ async function saveStateToCache(request: Request): Promise<Response> {
|
|||||||
try {
|
try {
|
||||||
const state = await request.json()
|
const state = await request.json()
|
||||||
const cache = await caches.open(STATE_CACHE_NAME)
|
const cache = await caches.open(STATE_CACHE_NAME)
|
||||||
|
|
||||||
await cache.put(STATE_ENDPOINT, new Response(JSON.stringify({
|
await cache.put(
|
||||||
...state,
|
STATE_ENDPOINT,
|
||||||
timestamp: Date.now()
|
new Response(
|
||||||
})))
|
JSON.stringify({
|
||||||
|
...state,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }))
|
return new Response(JSON.stringify({ success: true }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save state to cache:', 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) }), {
|
return new Response(
|
||||||
status: 500,
|
JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error) }),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
{
|
||||||
})
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,22 +71,22 @@ async function getStateFromCache(): Promise<Response> {
|
|||||||
try {
|
try {
|
||||||
const cache = await caches.open(STATE_CACHE_NAME)
|
const cache = await caches.open(STATE_CACHE_NAME)
|
||||||
const response = await cache.match(STATE_ENDPOINT)
|
const response = await cache.match(STATE_ENDPOINT)
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
const state = await response.json()
|
const state = await response.json()
|
||||||
return new Response(JSON.stringify(state), {
|
return new Response(JSON.stringify(state), {
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({}), {
|
return new Response(JSON.stringify({}), {
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get state from cache:', error)
|
console.error('Failed to get state from cache:', error)
|
||||||
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
|
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,9 +135,9 @@ async function updateBadge(count: number) {
|
|||||||
if ('setAppBadge' in navigator) {
|
if ('setAppBadge' in navigator) {
|
||||||
try {
|
try {
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
await navigator.setAppBadge(count)
|
await navigator.setAppBadge!(count)
|
||||||
} else {
|
} else {
|
||||||
await navigator.clearAppBadge()
|
await navigator.clearAppBadge!()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update app badge:', error)
|
console.error('Failed to update app badge:', error)
|
||||||
@@ -141,7 +149,7 @@ async function updateBadge(count: number) {
|
|||||||
async function clearBadge() {
|
async function clearBadge() {
|
||||||
if ('clearAppBadge' in navigator) {
|
if ('clearAppBadge' in navigator) {
|
||||||
try {
|
try {
|
||||||
await navigator.clearAppBadge()
|
await navigator.clearAppBadge!()
|
||||||
await setStoredUnreadCount(0)
|
await setStoredUnreadCount(0)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear app badge:', error)
|
console.error('Failed to clear app badge:', error)
|
||||||
@@ -157,18 +165,17 @@ self.addEventListener('install', event => {
|
|||||||
try {
|
try {
|
||||||
const cache = await caches.open(STATE_CACHE_NAME)
|
const cache = await caches.open(STATE_CACHE_NAME)
|
||||||
const existingState = await cache.match(STATE_ENDPOINT)
|
const existingState = await cache.match(STATE_ENDPOINT)
|
||||||
|
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
// 预热状态数据
|
// 预热状态数据(无需处理,仅确保缓存可用)
|
||||||
const state = await existingState.json()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 静默处理错误
|
// 静默处理错误
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制等待中的Service Worker立即成为活动的Service Worker
|
// 强制等待中的Service Worker立即成为活动的Service Worker
|
||||||
self.skipWaiting()
|
self.skipWaiting()
|
||||||
})()
|
})(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -180,7 +187,7 @@ self.addEventListener('activate', event => {
|
|||||||
if ('navigationPreload' in self.registration) {
|
if ('navigationPreload' in self.registration) {
|
||||||
await self.registration.navigationPreload.enable()
|
await self.registration.navigationPreload.enable()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理旧版本的缓存
|
// 清理旧版本的缓存
|
||||||
const cacheNames = await caches.keys()
|
const cacheNames = await caches.keys()
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -188,7 +195,7 @@ self.addEventListener('activate', event => {
|
|||||||
if (cacheName.includes('old-') || cacheName.includes('deprecated-')) {
|
if (cacheName.includes('old-') || cacheName.includes('deprecated-')) {
|
||||||
return caches.delete(cacheName)
|
return caches.delete(cacheName)
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
})(),
|
})(),
|
||||||
)
|
)
|
||||||
@@ -199,7 +206,7 @@ self.addEventListener('activate', event => {
|
|||||||
// 处理API请求,当离线时发送消息到客户端
|
// 处理API请求,当离线时发送消息到客户端
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
const url = new URL(event.request.url)
|
const url = new URL(event.request.url)
|
||||||
|
|
||||||
// 处理PWA状态管理请求
|
// 处理PWA状态管理请求
|
||||||
if (url.pathname === STATE_ENDPOINT) {
|
if (url.pathname === STATE_ENDPOINT) {
|
||||||
if (event.request.method === 'POST') {
|
if (event.request.method === 'POST') {
|
||||||
@@ -209,7 +216,7 @@ self.addEventListener('fetch', event => {
|
|||||||
}
|
}
|
||||||
return
|
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 () => {
|
||||||
@@ -283,9 +290,9 @@ self.addEventListener('push', function (event) {
|
|||||||
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
|
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
|
||||||
})(),
|
})(),
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 静默处理错误
|
// 静默处理错误
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听通知点击事件
|
// 监听通知点击事件
|
||||||
@@ -300,7 +307,6 @@ self.addEventListener('notificationclick', function (event) {
|
|||||||
|
|
||||||
// 监听来自主应用的消息,用于清除徽章或更新徽章数量
|
// 监听来自主应用的消息,用于清除徽章或更新徽章数量
|
||||||
self.addEventListener('message', function (event) {
|
self.addEventListener('message', function (event) {
|
||||||
|
|
||||||
if (event.data && event.data.type === 'CLEAR_BADGE') {
|
if (event.data && event.data.type === 'CLEAR_BADGE') {
|
||||||
// 清除徽章
|
// 清除徽章
|
||||||
clearBadge()
|
clearBadge()
|
||||||
@@ -333,11 +339,13 @@ self.addEventListener('message', function (event) {
|
|||||||
} else if (event.data && event.data.type === 'SAVE_PWA_STATE') {
|
} else if (event.data && event.data.type === 'SAVE_PWA_STATE') {
|
||||||
// 保存PWA状态
|
// 保存PWA状态
|
||||||
const state = event.data.state || {}
|
const state = event.data.state || {}
|
||||||
saveStateToCache(new Request(STATE_ENDPOINT, {
|
saveStateToCache(
|
||||||
method: 'POST',
|
new Request(STATE_ENDPOINT, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify(state)
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}))
|
body: JSON.stringify(state),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
event.ports[0]?.postMessage({ success: result.success })
|
event.ports[0]?.postMessage({ success: result.success })
|
||||||
|
|||||||
@@ -51,48 +51,47 @@ export interface PWAContext {
|
|||||||
* 只在需要时收集状态,不进行实时监听
|
* 只在需要时收集状态,不进行实时监听
|
||||||
*/
|
*/
|
||||||
export class StateCollector {
|
export class StateCollector {
|
||||||
|
|
||||||
static collectScrollPositions(): ScrollPosition[] {
|
static collectScrollPositions(): ScrollPosition[] {
|
||||||
const positions: ScrollPosition[] = []
|
const positions: ScrollPosition[] = []
|
||||||
|
|
||||||
positions.push({
|
positions.push({
|
||||||
x: window.scrollX,
|
x: window.scrollX,
|
||||||
y: window.scrollY,
|
y: window.scrollY,
|
||||||
element: 'window'
|
element: 'window',
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollContainers = [
|
const scrollContainers = [
|
||||||
'.v-main__wrap',
|
'.v-main__wrap',
|
||||||
'.v-card-text',
|
'.v-card-text',
|
||||||
'.v-sheet',
|
'.v-sheet',
|
||||||
'.perfect-scrollbar',
|
'.perfect-scrollbar',
|
||||||
'[data-simplebar]',
|
'[data-simplebar]',
|
||||||
'.overflow-auto',
|
'.overflow-auto',
|
||||||
'.overflow-y-auto'
|
'.overflow-y-auto',
|
||||||
]
|
]
|
||||||
|
|
||||||
scrollContainers.forEach(selector => {
|
scrollContainers.forEach(selector => {
|
||||||
const elements = document.querySelectorAll(selector)
|
const elements = document.querySelectorAll(selector)
|
||||||
elements.forEach((element) => {
|
elements.forEach(element => {
|
||||||
if (element.scrollTop > 0 || element.scrollLeft > 0) {
|
if (element.scrollTop > 0 || element.scrollLeft > 0) {
|
||||||
positions.push({
|
positions.push({
|
||||||
x: element.scrollLeft,
|
x: element.scrollLeft,
|
||||||
y: element.scrollTop,
|
y: element.scrollTop,
|
||||||
element: this.generateElementSelector(element)
|
element: this.generateElementSelector(element),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return positions
|
return positions
|
||||||
}
|
}
|
||||||
|
|
||||||
static collectModalStates(): ModalState[] {
|
static collectModalStates(): ModalState[] {
|
||||||
const states: ModalState[] = []
|
const states: ModalState[] = []
|
||||||
|
|
||||||
const modalSelectors = [
|
const modalSelectors = [
|
||||||
'.v-dialog',
|
'.v-dialog',
|
||||||
'.v-menu',
|
'.v-menu',
|
||||||
'.v-overlay',
|
'.v-overlay',
|
||||||
'.v-tooltip',
|
'.v-tooltip',
|
||||||
'.v-snackbar',
|
'.v-snackbar',
|
||||||
@@ -101,9 +100,9 @@ export class StateCollector {
|
|||||||
'.drawer',
|
'.drawer',
|
||||||
'.v-navigation-drawer',
|
'.v-navigation-drawer',
|
||||||
'[role="dialog"]',
|
'[role="dialog"]',
|
||||||
'[role="alertdialog"]'
|
'[role="alertdialog"]',
|
||||||
]
|
]
|
||||||
|
|
||||||
modalSelectors.forEach(selector => {
|
modalSelectors.forEach(selector => {
|
||||||
const elements = document.querySelectorAll(selector)
|
const elements = document.querySelectorAll(selector)
|
||||||
elements.forEach(element => {
|
elements.forEach(element => {
|
||||||
@@ -111,41 +110,43 @@ export class StateCollector {
|
|||||||
states.push({
|
states.push({
|
||||||
id: this.getModalId(element),
|
id: this.getModalId(element),
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
data: this.extractModalData(element)
|
data: this.extractModalData(element),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return states
|
return states
|
||||||
}
|
}
|
||||||
|
|
||||||
static collectFormFields(): FormFieldState[] {
|
static collectFormFields(): FormFieldState[] {
|
||||||
const fields: FormFieldState[] = []
|
const fields: FormFieldState[] = []
|
||||||
|
|
||||||
const formElements = document.querySelectorAll('input, textarea, select')
|
const formElements = document.querySelectorAll('input, textarea, select')
|
||||||
formElements.forEach(element => {
|
formElements.forEach(element => {
|
||||||
const inputElement = element as HTMLInputElement
|
const inputElement = element as HTMLInputElement
|
||||||
|
|
||||||
if (inputElement.type === 'password' || inputElement.type === 'hidden') {
|
if (inputElement.type === 'password' || inputElement.type === 'hidden') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inputElement.value || inputElement.checked) {
|
if (inputElement.value || inputElement.checked) {
|
||||||
fields.push({
|
fields.push({
|
||||||
selector: this.getFieldSelector(inputElement),
|
selector: this.getFieldSelector(inputElement),
|
||||||
value: inputElement.value,
|
value: inputElement.value,
|
||||||
type: inputElement.type,
|
type: inputElement.type,
|
||||||
checked: inputElement.checked,
|
checked: inputElement.checked,
|
||||||
selectedIndex: inputElement.tagName === 'SELECT' ?
|
selectedIndex:
|
||||||
(inputElement as unknown as HTMLSelectElement).selectedIndex : undefined
|
inputElement.tagName === 'SELECT'
|
||||||
|
? (inputElement as unknown as HTMLSelectElement).selectedIndex
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
static restoreScrollPositions(positions: ScrollPosition[]): void {
|
static restoreScrollPositions(positions: ScrollPosition[]): void {
|
||||||
positions.forEach(pos => {
|
positions.forEach(pos => {
|
||||||
if (pos.element === 'window') {
|
if (pos.element === 'window') {
|
||||||
@@ -158,21 +159,23 @@ export class StateCollector {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static restoreModalStates(states: ModalState[]): void {
|
static restoreModalStates(states: ModalState[]): void {
|
||||||
states.forEach(state => {
|
states.forEach(state => {
|
||||||
window.dispatchEvent(new CustomEvent('restoreModalState', {
|
window.dispatchEvent(
|
||||||
detail: state
|
new CustomEvent('restoreModalState', {
|
||||||
}))
|
detail: state,
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static restoreFormFields(fields: FormFieldState[]): void {
|
static restoreFormFields(fields: FormFieldState[]): void {
|
||||||
fields.forEach(field => {
|
fields.forEach(field => {
|
||||||
const elements = document.querySelectorAll(field.selector)
|
const elements = document.querySelectorAll(field.selector)
|
||||||
elements.forEach(element => {
|
elements.forEach(element => {
|
||||||
const inputElement = element as HTMLInputElement
|
const inputElement = element as HTMLInputElement
|
||||||
|
|
||||||
if (field.type === 'checkbox' || field.type === 'radio') {
|
if (field.type === 'checkbox' || field.type === 'radio') {
|
||||||
inputElement.checked = field.checked || false
|
inputElement.checked = field.checked || false
|
||||||
} else if (field.type === 'select-one' || field.type === 'select-multiple') {
|
} else if (field.type === 'select-one' || field.type === 'select-multiple') {
|
||||||
@@ -183,32 +186,36 @@ export class StateCollector {
|
|||||||
} else {
|
} else {
|
||||||
inputElement.value = field.value as string
|
inputElement.value = field.value as string
|
||||||
}
|
}
|
||||||
|
|
||||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }))
|
inputElement.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
inputElement.dispatchEvent(new Event('change', { bubbles: true }))
|
inputElement.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isModalOpen(element: Element): boolean {
|
private static isModalOpen(element: Element): boolean {
|
||||||
const computedStyle = window.getComputedStyle(element)
|
const computedStyle = window.getComputedStyle(element)
|
||||||
return computedStyle.display !== 'none' &&
|
return (
|
||||||
computedStyle.visibility !== 'hidden' &&
|
computedStyle.display !== 'none' &&
|
||||||
computedStyle.opacity !== '0' &&
|
computedStyle.visibility !== 'hidden' &&
|
||||||
!element.hasAttribute('hidden') &&
|
computedStyle.opacity !== '0' &&
|
||||||
element.getAttribute('aria-hidden') !== 'true'
|
!element.hasAttribute('hidden') &&
|
||||||
|
element.getAttribute('aria-hidden') !== 'true'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getModalId(element: Element): string {
|
private static getModalId(element: Element): string {
|
||||||
return element.id ||
|
return (
|
||||||
element.getAttribute('data-modal-id') ||
|
element.id ||
|
||||||
element.className.replace(/\s+/g, '-') ||
|
element.getAttribute('data-modal-id') ||
|
||||||
`modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
element.className.replace(/\s+/g, '-') ||
|
||||||
|
`modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static extractModalData(element: Element): any {
|
private static extractModalData(element: Element): any {
|
||||||
const data: any = {}
|
const data: any = {}
|
||||||
|
|
||||||
const inputs = element.querySelectorAll('input, select, textarea')
|
const inputs = element.querySelectorAll('input, select, textarea')
|
||||||
if (inputs.length > 0) {
|
if (inputs.length > 0) {
|
||||||
data.formData = {}
|
data.formData = {}
|
||||||
@@ -219,95 +226,94 @@ export class StateCollector {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollableElements = element.querySelectorAll('[class*="overflow"], .v-card-text')
|
const scrollableElements = element.querySelectorAll('[class*="overflow"], .v-card-text')
|
||||||
if (scrollableElements.length > 0) {
|
if (scrollableElements.length > 0) {
|
||||||
data.scrollPositions = Array.from(scrollableElements).map((el) => ({
|
data.scrollPositions = Array.from(scrollableElements).map(el => ({
|
||||||
selector: this.generateElementSelector(el),
|
selector: this.generateElementSelector(el),
|
||||||
x: el.scrollLeft,
|
x: el.scrollLeft,
|
||||||
y: el.scrollTop
|
y: el.scrollTop,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
private static generateElementSelector(element: Element): string {
|
private static generateElementSelector(element: Element): string {
|
||||||
if (element.id) {
|
if (element.id) {
|
||||||
return `#${element.id}`
|
return `#${element.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = []
|
const path = []
|
||||||
let current = element
|
let current = element
|
||||||
|
|
||||||
while (current && current !== document.body) {
|
while (current && current !== document.body) {
|
||||||
let selector = current.tagName.toLowerCase()
|
let selector = current.tagName.toLowerCase()
|
||||||
|
|
||||||
if (current.id) {
|
if (current.id) {
|
||||||
selector += `#${current.id}`
|
selector += `#${current.id}`
|
||||||
path.unshift(selector)
|
path.unshift(selector)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.className) {
|
if (current.className) {
|
||||||
const classes = current.className.split(/\s+/).filter(c => c && !c.includes('v-'))
|
const classes = current.className.split(/\s+/).filter(c => c && !c.includes('v-'))
|
||||||
if (classes.length > 0) {
|
if (classes.length > 0) {
|
||||||
selector += `.${classes[0]}`
|
selector += `.${classes[0]}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use nth-child instead of nth-of-type, but only when necessary
|
// Use nth-child instead of nth-of-type, but only when necessary
|
||||||
const parent = current.parentElement
|
const parent = current.parentElement
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const siblings = Array.from(parent.children).filter(child =>
|
const siblings = Array.from(parent.children).filter(
|
||||||
child.tagName === current.tagName &&
|
child => child.tagName === current.tagName && child.className === current.className,
|
||||||
child.className === current.className
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (siblings.length > 1) {
|
if (siblings.length > 1) {
|
||||||
const index = siblings.indexOf(current) + 1
|
const index = siblings.indexOf(current) + 1
|
||||||
selector += `:nth-child(${index})`
|
selector += `:nth-child(${index})`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
path.unshift(selector)
|
path.unshift(selector)
|
||||||
current = current.parentElement as Element
|
current = current.parentElement as Element
|
||||||
|
|
||||||
if (path.length >= 4) break
|
if (path.length >= 4) break
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.join(' > ')
|
return path.join(' > ')
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getFieldSelector(element: HTMLInputElement): string {
|
private static getFieldSelector(element: HTMLInputElement): string {
|
||||||
if (element.id) return `#${element.id}`
|
if (element.id) return `#${element.id}`
|
||||||
if (element.name) return `[name="${element.name}"]`
|
if (element.name) return `[name="${element.name}"]`
|
||||||
|
|
||||||
const path = []
|
const path = []
|
||||||
let current = element as Element
|
let current = element as Element
|
||||||
|
|
||||||
while (current && current !== document.body) {
|
while (current && current !== document.body) {
|
||||||
let selector = current.tagName.toLowerCase()
|
let selector = current.tagName.toLowerCase()
|
||||||
|
|
||||||
if (current.id) {
|
if (current.id) {
|
||||||
selector += `#${current.id}`
|
selector += `#${current.id}`
|
||||||
path.unshift(selector)
|
path.unshift(selector)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.className) {
|
if (current.className) {
|
||||||
const classes = current.className.split(/\s+/).filter(c => c)
|
const classes = current.className.split(/\s+/).filter(c => c)
|
||||||
if (classes.length > 0) {
|
if (classes.length > 0) {
|
||||||
selector += `.${classes[0]}`
|
selector += `.${classes[0]}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
path.unshift(selector)
|
path.unshift(selector)
|
||||||
current = current.parentNode as Element
|
current = current.parentNode as Element
|
||||||
|
|
||||||
if (path.length >= 3) break
|
if (path.length >= 3) break
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.join(' > ')
|
return path.join(' > ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,17 +329,23 @@ export class PWAStateManager {
|
|||||||
saveState(state: PWAState): void {
|
saveState(state: PWAState): void {
|
||||||
try {
|
try {
|
||||||
// 主要状态存储到localStorage
|
// 主要状态存储到localStorage
|
||||||
localStorage.setItem(this.storageKey, JSON.stringify({
|
localStorage.setItem(
|
||||||
...state,
|
this.storageKey,
|
||||||
timestamp: Date.now()
|
JSON.stringify({
|
||||||
}))
|
...state,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// 临时状态存储到sessionStorage
|
// 临时状态存储到sessionStorage
|
||||||
sessionStorage.setItem(this.sessionKey, JSON.stringify({
|
sessionStorage.setItem(
|
||||||
scrollPosition: state.scrollPosition,
|
this.sessionKey,
|
||||||
activeTab: state.appData?.activeTab,
|
JSON.stringify({
|
||||||
formData: state.formData
|
scrollPosition: state.scrollPosition,
|
||||||
}))
|
activeTab: state.appData?.activeTab,
|
||||||
|
formData: state.formData,
|
||||||
|
}),
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('状态保存失败:', error)
|
console.error('状态保存失败:', error)
|
||||||
}
|
}
|
||||||
@@ -344,15 +356,15 @@ export class PWAStateManager {
|
|||||||
try {
|
try {
|
||||||
const savedState = localStorage.getItem(this.storageKey)
|
const savedState = localStorage.getItem(this.storageKey)
|
||||||
const sessionState = sessionStorage.getItem(this.sessionKey)
|
const sessionState = sessionStorage.getItem(this.sessionKey)
|
||||||
|
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
const state = JSON.parse(savedState)
|
const state = JSON.parse(savedState)
|
||||||
const sessionData = sessionState ? JSON.parse(sessionState) : {}
|
const sessionData = sessionState ? JSON.parse(sessionState) : {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
...sessionData,
|
...sessionData,
|
||||||
isRestored: true
|
isRestored: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -362,7 +374,8 @@ export class PWAStateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 清除过期状态
|
// 清除过期状态
|
||||||
clearExpiredState(maxAge = 24 * 60 * 60 * 1000): void { // 24小时
|
clearExpiredState(maxAge = 24 * 60 * 60 * 1000): void {
|
||||||
|
// 24小时
|
||||||
try {
|
try {
|
||||||
const savedState = localStorage.getItem(this.storageKey)
|
const savedState = localStorage.getItem(this.storageKey)
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
@@ -389,11 +402,11 @@ export class PWAIndexedDBManager {
|
|||||||
private async initDB(): Promise<IDBDatabase> {
|
private async initDB(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(this.dbName, this.dbVersion)
|
const request = indexedDB.open(this.dbName, this.dbVersion)
|
||||||
|
|
||||||
request.onerror = () => reject(request.error)
|
request.onerror = () => reject(request.error)
|
||||||
request.onsuccess = () => resolve(request.result)
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
request.onupgradeneeded = event => {
|
||||||
const db = (event.target as IDBOpenDBRequest).result
|
const db = (event.target as IDBOpenDBRequest).result
|
||||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||||
db.createObjectStore(this.storeName, { keyPath: 'id' })
|
db.createObjectStore(this.storeName, { keyPath: 'id' })
|
||||||
@@ -407,11 +420,11 @@ export class PWAIndexedDBManager {
|
|||||||
const db = await this.initDB()
|
const db = await this.initDB()
|
||||||
const transaction = db.transaction([this.storeName], 'readwrite')
|
const transaction = db.transaction([this.storeName], 'readwrite')
|
||||||
const store = transaction.objectStore(this.storeName)
|
const store = transaction.objectStore(this.storeName)
|
||||||
|
|
||||||
await store.put({
|
await store.put({
|
||||||
id: 'appState',
|
id: 'appState',
|
||||||
data: state,
|
data: state,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('IndexedDB保存失败:', error)
|
console.error('IndexedDB保存失败:', error)
|
||||||
@@ -423,7 +436,7 @@ export class PWAIndexedDBManager {
|
|||||||
const db = await this.initDB()
|
const db = await this.initDB()
|
||||||
const transaction = db.transaction([this.storeName], 'readonly')
|
const transaction = db.transaction([this.storeName], 'readonly')
|
||||||
const store = transaction.objectStore(this.storeName)
|
const store = transaction.objectStore(this.storeName)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = store.get('appState')
|
const request = store.get('appState')
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
@@ -447,12 +460,13 @@ export class ServiceWorkerStateSync {
|
|||||||
|
|
||||||
async saveState(state: PWAState): Promise<boolean> {
|
async saveState(state: PWAState): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
// 通过Service Worker的fetch拦截器保存状态
|
||||||
const response = await fetch(this.stateEndpoint, {
|
const response = await fetch(this.stateEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(state)
|
body: JSON.stringify(state),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
return result.success
|
return result.success
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -474,17 +488,20 @@ export class ServiceWorkerStateSync {
|
|||||||
|
|
||||||
// 使用MessageChannel与Service Worker通信
|
// 使用MessageChannel与Service Worker通信
|
||||||
async saveStateViaMessage(state: PWAState): Promise<boolean> {
|
async saveStateViaMessage(state: PWAState): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||||
const channel = new MessageChannel()
|
const channel = new MessageChannel()
|
||||||
channel.port1.onmessage = (event) => {
|
channel.port1.onmessage = event => {
|
||||||
resolve(event.data.success)
|
resolve(event.data.success)
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.serviceWorker.controller.postMessage({
|
navigator.serviceWorker.controller.postMessage(
|
||||||
type: 'SAVE_PWA_STATE',
|
{
|
||||||
state
|
type: 'SAVE_PWA_STATE',
|
||||||
}, [channel.port2])
|
state,
|
||||||
|
},
|
||||||
|
[channel.port2],
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
}
|
}
|
||||||
@@ -492,16 +509,19 @@ export class ServiceWorkerStateSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadStateViaMessage(): Promise<PWAState | null> {
|
async loadStateViaMessage(): Promise<PWAState | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => {
|
||||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||||
const channel = new MessageChannel()
|
const channel = new MessageChannel()
|
||||||
channel.port1.onmessage = (event) => {
|
channel.port1.onmessage = event => {
|
||||||
resolve(event.data.state || null)
|
resolve(event.data.state || null)
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.serviceWorker.controller.postMessage({
|
navigator.serviceWorker.controller.postMessage(
|
||||||
type: 'GET_PWA_STATE'
|
{
|
||||||
}, [channel.port2])
|
type: 'GET_PWA_STATE',
|
||||||
|
},
|
||||||
|
[channel.port2],
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
@@ -543,7 +563,7 @@ export class StateRestoreDecision {
|
|||||||
|
|
||||||
private isUrlCompatible(savedUrl: string, currentUrl: string): boolean {
|
private isUrlCompatible(savedUrl: string, currentUrl: string): boolean {
|
||||||
if (!savedUrl || !currentUrl) return false
|
if (!savedUrl || !currentUrl) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedPath = new URL(savedUrl).pathname
|
const savedPath = new URL(savedUrl).pathname
|
||||||
const currentPath = new URL(currentUrl).pathname
|
const currentPath = new URL(currentUrl).pathname
|
||||||
@@ -604,7 +624,7 @@ export class VisibilityStateManager {
|
|||||||
|
|
||||||
private handlePageVisible(): void {
|
private handlePageVisible(): void {
|
||||||
if (this.isRestoring) return
|
if (this.isRestoring) return
|
||||||
|
|
||||||
this.isRestoring = true
|
this.isRestoring = true
|
||||||
this.restorePromise = this.performStateRestore()
|
this.restorePromise = this.performStateRestore()
|
||||||
}
|
}
|
||||||
@@ -649,7 +669,7 @@ export class VisibilityStateManager {
|
|||||||
scrollPositions: [{ x: window.scrollX, y: window.scrollY, element: 'window' }],
|
scrollPositions: [{ x: window.scrollX, y: window.scrollY, element: 'window' }],
|
||||||
orientation: window.orientation || 0,
|
orientation: window.orientation || 0,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
appData: this.getAppSpecificState()
|
appData: this.getAppSpecificState(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,18 +681,20 @@ export class VisibilityStateManager {
|
|||||||
if (state.appData) {
|
if (state.appData) {
|
||||||
this.restoreAppSpecificState(state.appData)
|
this.restoreAppSpecificState(state.appData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发状态恢复完成事件
|
// 触发状态恢复完成事件
|
||||||
window.dispatchEvent(new CustomEvent('pwaStateRestored', {
|
window.dispatchEvent(
|
||||||
detail: { state }
|
new CustomEvent('pwaStateRestored', {
|
||||||
}))
|
detail: { state },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAppSpecificState(): any {
|
private getAppSpecificState(): any {
|
||||||
// 获取应用特定状态
|
// 获取应用特定状态
|
||||||
return {
|
return {
|
||||||
formData: this.getFormData(),
|
formData: this.getFormData(),
|
||||||
userSelections: this.getUserSelections()
|
userSelections: this.getUserSelections(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,12 +710,12 @@ export class VisibilityStateManager {
|
|||||||
private getFormData(): Record<string, any> {
|
private getFormData(): Record<string, any> {
|
||||||
const forms = document.querySelectorAll('form')
|
const forms = document.querySelectorAll('form')
|
||||||
const formData: Record<string, any> = {}
|
const formData: Record<string, any> = {}
|
||||||
|
|
||||||
forms.forEach((form, index) => {
|
forms.forEach((form, index) => {
|
||||||
const data = new FormData(form)
|
const data = new FormData(form)
|
||||||
formData[`form-${index}`] = Object.fromEntries(data)
|
formData[`form-${index}`] = Object.fromEntries(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
return formData
|
return formData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +723,7 @@ export class VisibilityStateManager {
|
|||||||
Object.entries(formData).forEach(([formId, data]) => {
|
Object.entries(formData).forEach(([formId, data]) => {
|
||||||
const formIndex = parseInt(formId.split('-')[1])
|
const formIndex = parseInt(formId.split('-')[1])
|
||||||
const form = document.querySelectorAll('form')[formIndex]
|
const form = document.querySelectorAll('form')[formIndex]
|
||||||
|
|
||||||
if (form) {
|
if (form) {
|
||||||
Object.entries(data).forEach(([name, value]) => {
|
Object.entries(data).forEach(([name, value]) => {
|
||||||
const input = form.querySelector(`[name="${name}"]`) as HTMLInputElement
|
const input = form.querySelector(`[name="${name}"]`) as HTMLInputElement
|
||||||
@@ -716,7 +738,7 @@ export class VisibilityStateManager {
|
|||||||
private getUserSelections(): any {
|
private getUserSelections(): any {
|
||||||
return {
|
return {
|
||||||
selectedItems: Array.from(document.querySelectorAll('.selected')).map(el => el.id),
|
selectedItems: Array.from(document.querySelectorAll('.selected')).map(el => el.id),
|
||||||
activeTab: document.querySelector('.tab.active')?.id
|
activeTab: document.querySelector('.tab.active')?.id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,7 +751,7 @@ export class VisibilityStateManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selections.activeTab) {
|
if (selections.activeTab) {
|
||||||
const tab = document.getElementById(selections.activeTab)
|
const tab = document.getElementById(selections.activeTab)
|
||||||
if (tab) {
|
if (tab) {
|
||||||
@@ -755,11 +777,11 @@ export class PWAStateController {
|
|||||||
this.swStateSync = new ServiceWorkerStateSync()
|
this.swStateSync = new ServiceWorkerStateSync()
|
||||||
this.visibilityManager = new VisibilityStateManager(this.stateManager)
|
this.visibilityManager = new VisibilityStateManager(this.stateManager)
|
||||||
this.restoreDecision = new StateRestoreDecision()
|
this.restoreDecision = new StateRestoreDecision()
|
||||||
|
|
||||||
this.stateRestorePromise = new Promise((resolve) => {
|
this.stateRestorePromise = new Promise(resolve => {
|
||||||
this.stateRestoreResolve = resolve
|
this.stateRestoreResolve = resolve
|
||||||
})
|
})
|
||||||
|
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,12 +800,12 @@ export class PWAStateController {
|
|||||||
|
|
||||||
private async checkAndRestoreState(): Promise<void> {
|
private async checkAndRestoreState(): Promise<void> {
|
||||||
this.isRestoring = true
|
this.isRestoring = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentContext: PWAContext = {
|
const currentContext: PWAContext = {
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
orientation: window.orientation || 0,
|
orientation: window.orientation || 0,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试从多个来源恢复状态
|
// 尝试从多个来源恢复状态
|
||||||
@@ -791,7 +813,7 @@ export class PWAStateController {
|
|||||||
() => this.stateManager.restoreState(),
|
() => this.stateManager.restoreState(),
|
||||||
() => this.indexedDBManager.restoreState(),
|
() => this.indexedDBManager.restoreState(),
|
||||||
() => this.swStateSync.loadState(),
|
() => this.swStateSync.loadState(),
|
||||||
() => this.swStateSync.loadStateViaMessage()
|
() => this.swStateSync.loadStateViaMessage(),
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
@@ -823,19 +845,20 @@ export class PWAStateController {
|
|||||||
const state: PWAState = {
|
const state: PWAState = {
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
scrollPosition: window.scrollY,
|
scrollPosition: window.scrollY,
|
||||||
scrollPositions: scrollPositions.length > 0 ? scrollPositions : [{ x: window.scrollX, y: window.scrollY, element: 'window' }],
|
scrollPositions:
|
||||||
|
scrollPositions.length > 0 ? scrollPositions : [{ x: window.scrollX, y: window.scrollY, element: 'window' }],
|
||||||
orientation: window.orientation || 0,
|
orientation: window.orientation || 0,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
appData: this.getAppSpecificState(),
|
appData: this.getAppSpecificState(),
|
||||||
modalStates: modalStates.length > 0 ? modalStates : undefined,
|
modalStates: modalStates.length > 0 ? modalStates : undefined,
|
||||||
formFields: formFields.length > 0 ? formFields : undefined
|
formFields: formFields.length > 0 ? formFields : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
this.stateManager.saveState(state),
|
this.stateManager.saveState(state),
|
||||||
this.indexedDBManager.saveState(state),
|
this.indexedDBManager.saveState(state),
|
||||||
this.swStateSync.saveState(state),
|
this.swStateSync.saveState(state),
|
||||||
this.swStateSync.saveStateViaMessage(state)
|
this.swStateSync.saveStateViaMessage(state),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,7 +871,7 @@ export class PWAStateController {
|
|||||||
} else if (state.scrollPosition && urlMatches) {
|
} else if (state.scrollPosition && urlMatches) {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: state.scrollPosition,
|
top: state.scrollPosition,
|
||||||
behavior: 'auto'
|
behavior: 'auto',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,12 +905,12 @@ export class PWAStateController {
|
|||||||
routerState: {
|
routerState: {
|
||||||
currentRoute: window.location.pathname,
|
currentRoute: window.location.pathname,
|
||||||
query: window.location.search,
|
query: window.location.search,
|
||||||
hash: window.location.hash
|
hash: window.location.hash,
|
||||||
},
|
},
|
||||||
uiState: {
|
uiState: {
|
||||||
sidebarOpen: document.querySelector('.v-navigation-drawer--active') !== null,
|
sidebarOpen: document.querySelector('.v-navigation-drawer--active') !== null,
|
||||||
darkMode: document.documentElement.getAttribute('data-theme') === 'dark'
|
darkMode: document.documentElement.getAttribute('data-theme') === 'dark',
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,12 +919,14 @@ export class PWAStateController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private dispatchStateRestoreEvent(state: PWAState): void {
|
private dispatchStateRestoreEvent(state: PWAState): void {
|
||||||
window.dispatchEvent(new CustomEvent('pwaStateRestored', {
|
window.dispatchEvent(
|
||||||
detail: { state }
|
new CustomEvent('pwaStateRestored', {
|
||||||
}))
|
detail: { state },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
// 无需清理资源
|
// 无需清理资源
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user