feat(pwa): 重构 Service Worker 及版本更新机制

This commit is contained in:
PKC278
2026-01-02 20:36:33 +08:00
parent 2281e4224b
commit 6d2916dc9f
10 changed files with 432 additions and 544 deletions

View File

@@ -237,6 +237,13 @@ async function loadBackgroundImages(retryCount = 0) {
}
onMounted(async () => {
// 移除URL中的时间戳参数
const url = new URL(window.location.href)
if (url.searchParams.has('t')) {
url.searchParams.delete('t')
window.history.replaceState({}, '', url.toString())
}
// 配置 ApexCharts
configureApexCharts()

View File

@@ -1,12 +1,10 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { clearCachesAndServiceWorker } from '@/composables/useVersionChecker'
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
declare const __APP_VERSION__: string
// 国际化
const { t } = useI18n()
@@ -124,8 +122,8 @@ function releaseTime(releaseDate: string) {
// 强制清除缓存
async function cleanCache() {
await clearCachesAndServiceWorker()
// 刷新页面
window.location.reload()
// 刷新页面,添加时间戳参数以强制更新
reloadWithTimestamp()
}
onMounted(() => {

View File

@@ -12,12 +12,17 @@
interface Props {
message: string
refreshText: string
onRefresh?: () => void
}
const props = defineProps<Props>()
const handleRefresh = () => {
window.location.reload()
if (props.onRefresh) {
props.onRefresh()
} else {
window.location.reload()
}
}
</script>

View File

@@ -3,17 +3,25 @@ import { useToast } from 'vue-toastification'
import i18n from '@/plugins/i18n'
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
// 声明全局变量类型
declare const __APP_VERSION__: string
// 全局状态
const currentVersion = ref(__APP_VERSION__)
let isListenerAdded = false
let notificationShowTime = 0
const serverVersion = ref<string | null>(null)
const versionChecked = ref(false)
const needsUpdate = computed(() => {
return serverVersion.value !== null && serverVersion.value !== currentVersion.value
})
/**
* 刷新页面并添加时间戳
*/
export const reloadWithTimestamp = (): void => {
const url = new URL(window.location.href)
url.searchParams.set('t', Date.now().toString())
window.location.href = url.toString()
}
/**
* 清除所有缓存和 Service Worker
*/
@@ -56,6 +64,7 @@ export function useVersionChecker() {
const component = h(VersionUpdateToast, {
message: i18n.global.t('common.newVersionAvailable'),
refreshText: i18n.global.t('common.refresh'),
onRefresh: reloadWithTimestamp,
})
toast.info(component, {
@@ -79,16 +88,46 @@ export function useVersionChecker() {
// 更新服务端版本
serverVersion.value = latestVersion
// 版本不同,且尚未显示通知
if (needsUpdate.value) {
versionChecked.value = true
console.log(`[VersionChecker] 检测到版本更新: ${currentVersion.value} -> ${latestVersion}`)
// 执行版本不一致时的处理逻辑
const handleVersionMismatch = async () => {
if (needsUpdate.value) {
versionChecked.value = true
console.log(`[VersionChecker] 检测到版本更新: ${currentVersion.value} -> ${latestVersion}`)
// 清除缓存和 Service Worker
await clearCachesAndServiceWorker()
// 清除缓存和 Service Worker
await clearCachesAndServiceWorker()
// 显示持久化通知
showUpdateNotification()
// 显示持久化通知
showUpdateNotification()
}
}
// 优先尝试通过 Service Worker 检查更新
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
console.log('[VersionChecker] Requesting Service Worker update check...')
const registration = await navigator.serviceWorker.getRegistration()
// 如果已经有等待中的更新,直接处理
if (registration?.waiting) {
console.log('[VersionChecker] New worker waiting, skipping manual check.')
handleVersionMismatch()
return
}
const messageChannel = new MessageChannel()
messageChannel.port1.onmessage = event => {
if (event.data && event.data.type === 'SW_NO_UPDATE_DETECTED') {
console.log('[VersionChecker] Service Worker reported no update, checking version manually...')
handleVersionMismatch()
}
}
navigator.serviceWorker.controller.postMessage({ type: 'CHECK_SW_UPDATE' }, [messageChannel.port2])
} else {
// 如果没有 Service Worker 控制,直接进行版本比较
await handleVersionMismatch()
}
}
@@ -100,6 +139,32 @@ export function useVersionChecker() {
serverVersion.value = null
}
// 监听 Service Worker 版本更新消息
if (!isListenerAdded && 'serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', event => {
// 1. 发现新版本 -> 弹出通知
if (event.data && event.data.type === 'SW_VERSION_DETECTED') {
console.log('[VersionChecker] Detected new version:', event.data.version)
notificationShowTime = Date.now()
toast.info(i18n.global.t('common.newVersionFound'), {
timeout: false,
hideProgressBar: true,
closeButton: false,
})
}
// 2. 安装完成 -> 刷新页面
else if (event.data && event.data.type === 'SW_RELOAD_PAGE') {
const elapsed = Date.now() - notificationShowTime
const delay = Math.max(0, 1000 - elapsed)
console.log(`[VersionChecker] Update installed, reloading in ${delay}ms...`)
setTimeout(() => {
reloadWithTimestamp()
}, delay)
}
})
isListenerAdded = true
}
return {
// 状态
currentVersion: computed(() => currentVersion.value),

View File

@@ -69,6 +69,7 @@ export default {
preset: 'Preset',
refresh: 'Refresh',
newVersionAvailable: 'New version detected, please refresh the page to get the latest features',
newVersionFound: 'New version found, updating...',
},
mediaType: {
movie: 'Movie',

View File

@@ -69,6 +69,7 @@ export default {
preset: '预设',
refresh: '刷新',
newVersionAvailable: '检测到新版本,请刷新页面以获取最新功能',
newVersionFound: '发现新版本,正在更新...',
},
mediaType: {
movie: '电影',

View File

@@ -69,6 +69,7 @@ export default {
preset: '預設',
refresh: '刷新',
newVersionAvailable: '檢測到新版本,請刷新頁面以獲取最新功能',
newVersionFound: '發現新版本,正在更新...',
},
mediaType: {
movie: '電影',

View File

@@ -1,4 +1,9 @@
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { registerRoute, setCatchHandler } from 'workbox-routing'
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import * as navigationPreload from 'workbox-navigation-preload'
// Service Worker 类型声明
declare let self: ServiceWorkerGlobalScope & {
@@ -6,27 +11,62 @@ declare let self: ServiceWorkerGlobalScope & {
}
// 缓存版本控制
const CACHE_VERSION = 'v13'
const CACHE_NAMES = {
appShell: `app-shell-${CACHE_VERSION}`,
static: `static-resources-${CACHE_VERSION}`,
images: `image-cache-${CACHE_VERSION}`,
fonts: `font-cache-${CACHE_VERSION}`,
api: `api-cache-${CACHE_VERSION}`,
tmdb: `tmdb-image-cache-${CACHE_VERSION}`,
pages: `pages-cache-${CACHE_VERSION}`,
}
const CACHE_VERSION = `${__APP_VERSION__}-${__BUILD_TIME__}`
// 缓存大小限制
const CACHE_SIZE_LIMITS = {
appShell: { maxEntries: 10, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
static: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天
images: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天
fonts: { maxEntries: 50, maxAgeSeconds: 365 * 24 * 60 * 60 }, // 1年
api: { maxEntries: 500, maxAgeSeconds: 24 * 60 * 60 }, // 24小时
tmdb: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
pages: { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
}
// 启用导航预载
navigationPreload.enable()
// 自动清理旧的预缓存
cleanupOutdatedCaches()
// 预缓存并路由
precacheAndRoute(self.__WB_MANIFEST)
// 变量记录是否为更新安装
let isUpdate = false
// 监听安装事件以检测更新
self.addEventListener('install', () => {
// 强制等待中的 Service Worker 立即激活
self.skipWaiting()
// 检查是否是更新(即是否已经有激活的 Service Worker
if (self.registration.active) {
isUpdate = true
// 通知客户端发现新版本
self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'SW_VERSION_DETECTED',
version: CACHE_VERSION,
})
})
})
}
})
// 监听激活事件
self.addEventListener('activate', event => {
// 让 Service Worker 立即接管页面
event.waitUntil(
(async () => {
await self.clients.claim()
// 清理旧版本的运行时缓存
await cleanupRuntimeCaches(true)
// 如果是更新,则通知客户端刷新页面
if (isUpdate) {
const clients = await self.clients.matchAll({ type: 'window' })
clients.forEach(client => {
client.postMessage({
type: 'SW_RELOAD_PAGE',
})
})
}
})(),
)
})
// 通知选项
const options = {
@@ -38,100 +78,216 @@ const options = {
// 存储未读消息数量的键名
const UNREAD_COUNT_KEY = 'mp_unread_count'
// 从IndexedDB获取未读消息数量
async function getStoredUnreadCount(): Promise<number> {
try {
const count = await get(UNREAD_COUNT_KEY)
return count || 0
} catch (error) {
console.error('Failed to get stored unread count:', error)
return 0
// --- 缓存策略配置 ---
// 导航请求与 App Shell - 优先网络
registerRoute(
({ request, url }) => request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/index.html',
new NetworkFirst({
cacheName: `app-shell-${CACHE_VERSION}`,
plugins: [
new ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
}),
],
}),
)
// 静态资源 (JS, CSS, HTML) - 优先缓存
registerRoute(
({ request }) => ['style', 'script', 'worker'].includes(request.destination),
new StaleWhileRevalidate({
cacheName: `static-resources-${CACHE_VERSION}`,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
)
// 图片资源 - 优先缓存
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: `image-cache-${CACHE_VERSION}`,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
}),
],
}),
)
// 字体资源 - 优先缓存
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: `font-cache-${CACHE_VERSION}`,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1年
}),
],
}),
)
// TMDB 图片 - 优先缓存
registerRoute(
({ url }) => url.hostname === 'image.tmdb.org',
new CacheFirst({
cacheName: `tmdb-image-cache-${CACHE_VERSION}`,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 300,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
}),
],
}),
)
// API GET 请求 - 优先网络
registerRoute(
({ url, request }) =>
url.pathname.includes('/api/v1/') &&
request.method === 'GET' &&
!url.pathname.includes('/api/v1/system/message') && // 排除 SSE 长连接
!url.pathname.includes('/api/v1/common/message') && // 排除通用消息
!url.pathname.includes('/api/v1/message/'), // 排除所有消息类接口
new NetworkFirst({
cacheName: `api-cache-${CACHE_VERSION}`,
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 24 * 60 * 60, // 24小时
}),
],
}),
)
// 设置默认离线页面
setCatchHandler(async ({ request }) => {
if (request?.destination === 'document') {
return (await caches.match('/offline.html')) || Response.error()
}
return Response.error()
})
// --- 辅助函数 (通知与徽章) ---
// 清理运行时缓存
async function cleanupRuntimeCaches(onlyOld: boolean = false) {
const cacheNames = await caches.keys()
const runtimeCachePrefixes = [
'app-shell',
'static-resources',
'image-cache',
'font-cache',
'api-cache',
'tmdb-image-cache',
'pages-cache',
]
await Promise.all(
cacheNames.map(cacheName => {
const isRuntimeCache = runtimeCachePrefixes.some(prefix => cacheName.startsWith(prefix))
if (isRuntimeCache) {
if (!onlyOld || !cacheName.includes(CACHE_VERSION)) {
console.log('[SW] Deleting runtime cache:', cacheName)
return caches.delete(cacheName)
}
}
return Promise.resolve()
}),
)
}
// 保存未读消息数量到IndexedDB
async function setStoredUnreadCount(count: number): Promise<void> {
try {
await set(UNREAD_COUNT_KEY, count)
} catch (error) {
console.error('Failed to set stored unread count:', error)
}
}
// 简单的IndexedDB包装器
// 简单的 IndexedDB 包装器 (用于未读计数)
async function openDB(): Promise<IDBDatabase> {
// Bump the version to add the new "sync" store while keeping existing data intact
return new Promise((resolve, reject) => {
const request = indexedDB.open('mp_badge_db', 2)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = event => {
const db = (event.target as IDBOpenDBRequest).result
// Badge store (existing)
if (!db.objectStoreNames.contains('badge')) {
db.createObjectStore('badge')
}
// Dedicated store for offline-sync items
if (!db.objectStoreNames.contains('sync')) {
db.createObjectStore('sync')
}
}
})
}
// 获取IndexedDB中的数据
async function get(key: string, storeName: string = 'badge'): Promise<any> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readonly')
const store = tx.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
try {
const db = await openDB()
return new Promise((resolve, reject) => {
if (!db.objectStoreNames.contains(storeName)) {
resolve(null)
return
}
const tx = db.transaction([storeName], 'readonly')
const store = tx.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
} catch (e) {
return null
}
}
// 保存数据到IndexedDB
async function set(key: string, value: any, storeName: string = 'badge'): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readwrite')
const store = tx.objectStore(storeName)
store.put(value, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
try {
const db = await openDB()
return new Promise((resolve, reject) => {
if (!db.objectStoreNames.contains(storeName)) {
console.warn(`Store ${storeName} not found`)
resolve()
return
}
const tx = db.transaction([storeName], 'readwrite')
const store = tx.objectStore(storeName)
store.put(value, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
} catch (e) {
// 忽略错误
}
}
// 删除IndexedDB中的数据确保事务完成
async function del(key: string, storeName: string = 'badge'): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readwrite')
const store = tx.objectStore(storeName)
store.delete(key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
async function getStoredUnreadCount(): Promise<number> {
const count = await get(UNREAD_COUNT_KEY)
return typeof count === 'number' ? count : 0
}
async function setStoredUnreadCount(count: number): Promise<void> {
await set(UNREAD_COUNT_KEY, count)
}
// 更新桌面图标徽章
async function updateBadge(count: number) {
if ('setAppBadge' in navigator) {
if ('setAppBadge' in self.navigator) {
try {
if (count > 0) {
await navigator.setAppBadge!(count)
await (self.navigator as any).setAppBadge(count)
} else {
await navigator.clearAppBadge!()
await (self.navigator as any).clearAppBadge()
}
} catch (error) {
console.error('Failed to update app badge:', error)
@@ -139,11 +295,10 @@ async function updateBadge(count: number) {
}
}
// 清除桌面图标徽章
async function clearBadge() {
if ('clearAppBadge' in navigator) {
if ('clearAppBadge' in self.navigator) {
try {
await navigator.clearAppBadge!()
await (self.navigator as any).clearAppBadge()
await setStoredUnreadCount(0)
} catch (error) {
console.error('Failed to clear app badge:', error)
@@ -151,352 +306,87 @@ async function clearBadge() {
}
}
// 清理旧版本缓存
async function deleteOldCaches() {
const cacheWhitelist = Object.values(CACHE_NAMES)
const cacheNames = await caches.keys()
await Promise.all(
cacheNames.map(async cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
console.log('Deleting old cache:', cacheName)
return caches.delete(cacheName)
}
}),
)
}
// 获取缓存大小
async function getCacheSize(cacheName: string): Promise<number> {
if (!('estimate' in navigator.storage)) {
return 0
}
try {
const cache = await caches.open(cacheName)
const keys = await cache.keys()
let totalSize = 0
for (const request of keys) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
totalSize += blob.size
}
}
return totalSize
} catch (error) {
console.error('Failed to get cache size:', error)
return 0
}
}
// 监控缓存大小
async function monitorCacheSize() {
const cacheSizes: Record<string, number> = {}
let totalSize = 0
for (const [key, cacheName] of Object.entries(CACHE_NAMES)) {
const size = await getCacheSize(cacheName)
cacheSizes[key] = size
totalSize += size
}
try {
const cacheNames = await caches.keys()
// 发送缓存统计信息给客户端
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'CACHE_SIZE_UPDATE',
data: {
cacheSizes,
totalSize,
totalSizeMB: (totalSize / 1024 / 1024).toFixed(2),
},
})
})
// 并行处理所有缓存以提高性能
await Promise.all(
cacheNames.map(async cacheName => {
const cache = await caches.open(cacheName)
// 使用 matchAll 一次性获取所有响应,避免循环中多次 match
const responses = await cache.matchAll()
let cacheSize = 0
return { cacheSizes, totalSize }
}
// 清理过期缓存条目
async function cleanupExpiredCaches() {
for (const [key, cacheName] of Object.entries(CACHE_NAMES)) {
const limit = CACHE_SIZE_LIMITS[key as keyof typeof CACHE_SIZE_LIMITS]
if (!limit) continue
try {
const cache = await caches.open(cacheName)
const keys = await cache.keys()
// 如果缓存条目超过限制,删除最老的条目
if (keys.length > limit.maxEntries) {
const deleteCount = keys.length - limit.maxEntries
console.log(`Cleaning up ${deleteCount} entries from ${cacheName}`)
// 删除最老的条目(假设数组开头是最老的)
for (let i = 0; i < deleteCount; i++) {
await cache.delete(keys[i])
for (const response of responses) {
// 仅通过 Content-Length 获取大小
const contentLength = response.headers.get('content-length')
if (contentLength) {
cacheSize += parseInt(contentLength, 10)
}
}
}
} catch (error) {
console.error(`Failed to cleanup cache ${cacheName}:`, error)
cacheSizes[cacheName] = cacheSize
}),
)
// 计算总大小
totalSize = Object.values(cacheSizes).reduce((acc, size) => acc + size, 0)
// 获取存储估算
let quota = 0
let usage = 0
if (self.navigator.storage && self.navigator.storage.estimate) {
const estimate = await self.navigator.storage.estimate()
quota = estimate.quota || 0
usage = estimate.usage || 0
}
}
}
// 安装事件
self.addEventListener('install', () => {
// 强制等待中的Service Worker立即成为活动的Service Worker
self.skipWaiting()
})
// 激活事件
self.addEventListener('activate', event => {
event.waitUntil(
(async () => {
// 启用导航预载功能以提高性能
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable()
}
// 清理旧版本的缓存
await deleteOldCaches()
// 清理过期的缓存条目
await cleanupExpiredCaches()
// 监控缓存大小
await monitorCacheSize()
})(),
)
// 告诉活动的Service Worker立即控制页面
self.clients.claim()
})
// 处理API请求当离线时发送消息到客户端
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
// 处理API请求
if (event.request.url.includes('/api/v1/')) {
// GET请求尝试从缓存返回
if (event.request.method === 'GET') {
event.respondWith(
(async () => {
try {
// 尝试网络请求
const networkResponse = await fetch(event.request)
return networkResponse
} catch (error) {
// 网络错误时,通知客户端当前处于离线状态
if (self.clients) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'OFFLINE_STATUS',
offline: true,
})
})
})
}
// 尝试返回缓存的响应
const cache = await caches.open(CACHE_NAMES.api)
const cachedResponse = await cache.match(event.request)
if (cachedResponse) {
return cachedResponse
}
// 如果没有缓存,抛出错误
throw error
}
})(),
)
const result = {
cacheSizes,
totalSize,
totalSizeMB: (totalSize / 1024 / 1024).toFixed(2),
quota,
usage,
quotaMB: (quota / 1024 / 1024).toFixed(2),
usageMB: (usage / 1024 / 1024).toFixed(2),
}
// POST/PUT/DELETE请求离线时加入同步队列
else if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(event.request.method)) {
event.respondWith(
(async () => {
try {
// 尝试网络请求
const networkResponse = await fetch(event.request)
return networkResponse
} catch (error) {
// 网络错误时,加入同步队列
await addToSyncQueue(event.request)
// 通知客户端请求已加入队列
if (self.clients) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'REQUEST_QUEUED',
url: event.request.url,
method: event.request.method,
})
})
})
}
// 返回一个假的成功响应
return new Response(
JSON.stringify({
success: true,
queued: true,
message: '请求已加入离线队列,将在网络恢复后自动同步',
}),
{
status: 202,
headers: { 'Content-Type': 'application/json' },
},
)
}
})(),
)
}
return
}
})
// 后台同步队列
const syncQueue: Array<{
id: string
url: string
method: string
data?: any
timestamp: number
}> = []
// 添加请求到同步队列
async function addToSyncQueue(request: Request) {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const url = request.url
const method = request.method
let data: any = null
if (method !== 'GET' && method !== 'HEAD') {
try {
data = await request.clone().text()
} catch (e) {
console.error('Failed to read request body:', e)
}
}
const syncItem = {
id,
url,
method,
data,
timestamp: Date.now(),
}
// 保存到IndexedDB (使用专用的 "sync" store)
await set(id, syncItem, 'sync')
syncQueue.push(syncItem)
// 注册后台同步
if ('sync' in self.registration) {
await self.registration.sync.register('sync-data')
}
}
// 执行同步队列中的请求
async function processSyncQueue() {
const db = await openDB()
// 先用只读事务获取所有同步项
const items: Array<any> = await new Promise((resolve, reject) => {
const tx = db.transaction(['sync'], 'readonly')
const store = tx.objectStore('sync')
const req = store.getAll()
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
// 收集需要删除的项目ID
const itemsToDelete: string[] = []
const itemsToDeleteExpired: string[] = []
for (const syncItem of items) {
const key = syncItem.id
try {
// 构建请求
const init: RequestInit = {
method: syncItem.method,
headers: {
'Content-Type': 'application/json',
},
}
if (syncItem.data) {
init.body = syncItem.data
}
// 发送请求
const response = await fetch(syncItem.url, init)
if (response.ok) {
// 成功后标记为需要删除
itemsToDelete.push(key)
// 通知客户端同步成功
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'SYNC_SUCCESS',
syncId: syncItem.id,
url: syncItem.url,
})
})
} else {
throw new Error(`HTTP ${response.status}`)
}
} catch (error) {
console.error('Sync failed for item:', key, error)
// 如果该同步项已存在超过 24 小时,则标记为需要删除
if (Date.now() - syncItem.timestamp > 24 * 60 * 60 * 1000) {
itemsToDeleteExpired.push(key)
}
}
}
// 批量删除所有成功处理的项目和过期项目
const allItemsToDelete = [...itemsToDelete, ...itemsToDeleteExpired]
if (allItemsToDelete.length > 0) {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(['sync'], 'readwrite')
const store = tx.objectStore('sync')
// 批量删除所有标记的项目
allItemsToDelete.forEach(id => {
store.delete(id)
// 发送缓存统计信息给客户端
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'CACHE_SIZE_UPDATE',
data: result,
})
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
return result
} catch (error) {
console.error('Failed to monitor cache size:', error)
return {
cacheSizes: {},
totalSize: 0,
totalSizeMB: '0.00',
quota: 0,
usage: 0,
quotaMB: '0.00',
usageMB: '0.00',
}
}
}
// 初始化 Workbox
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)
// --- 事件监听 ---
// 监听 sync 事件,处理后台同步
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'sync-data') {
event.waitUntil(processSyncQueue())
}
})
// 监听 push 事件,显示通知
// 监听 push 事件
self.addEventListener('push', function (event) {
if (!event.data) {
return
}
// 解析获取推送消息
let payload
try {
payload = event.data?.json()
@@ -505,7 +395,7 @@ self.addEventListener('push', function (event) {
title: event.data?.text(),
}
}
// 根据推送消息生成桌面通知并展现出来
try {
const content = {
body: payload.body || '',
@@ -515,7 +405,6 @@ self.addEventListener('push', function (event) {
actions: options.actions,
}
// 增加未读消息计数并持久化存储
event.waitUntil(
(async () => {
const currentCount = await getStoredUnreadCount()
@@ -525,11 +414,11 @@ self.addEventListener('push', function (event) {
})(),
)
} catch (e) {
// 静默处理错误
// 忽略错误
}
})
// 监听通知点击事件
// 监听通知点击
self.addEventListener('notificationclick', function (event) {
const info = event.notification
if (event.action === 'close') {
@@ -539,10 +428,9 @@ self.addEventListener('notificationclick', function (event) {
}
})
// 监听来自主应用的消息,用于清除徽章或更新徽章数量
// 监听消息
self.addEventListener('message', function (event) {
if (event.data && event.data.type === 'CLEAR_BADGE') {
// 清除徽章
clearBadge()
.then(() => {
event.ports[0]?.postMessage({ success: true })
@@ -551,7 +439,6 @@ self.addEventListener('message', function (event) {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'UPDATE_BADGE') {
// 更新徽章数量
const count = event.data.count || 0
setStoredUnreadCount(count)
.then(() => updateBadge(count))
@@ -562,25 +449,27 @@ self.addEventListener('message', function (event) {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'GET_UNREAD_COUNT') {
// 获取未读消息数量
getStoredUnreadCount()
.then(count => {
event.ports[0]?.postMessage({ count })
})
.catch(error => {
.catch(() => {
event.ports[0]?.postMessage({ count: 0 })
})
} else if (event.data && event.data.type === 'CLEANUP_CACHES') {
// 手动触发缓存清理
Promise.all([deleteOldCaches(), cleanupExpiredCaches(), monitorCacheSize()])
.then(([, , cacheInfo]) => {
// 手动清理: 清理所有运行时缓存
const performCleanup = async () => {
await cleanupRuntimeCaches(false)
return await monitorCacheSize()
}
performCleanup()
.then(cacheInfo => {
event.ports[0]?.postMessage({ success: true, cacheInfo })
})
.catch(error => {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'GET_CACHE_INFO') {
// 获取缓存信息
monitorCacheSize()
.then(cacheInfo => {
event.ports[0]?.postMessage({ success: true, cacheInfo })
@@ -588,5 +477,21 @@ self.addEventListener('message', function (event) {
.catch(error => {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'CHECK_SW_UPDATE') {
// 检查 Service Worker 更新
self.registration
.update()
.then(() => {
// 如果没有正在安装或等待的 worker说明没有检测到更新
if (!self.registration.installing && !self.registration.waiting) {
event.ports[0]?.postMessage({ type: 'SW_NO_UPDATE_DETECTED' })
}
})
.catch(error => {
console.error('Failed to check for SW update:', error)
event.ports[0]?.postMessage({ type: 'SW_NO_UPDATE_DETECTED' })
})
} else if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})

View File

@@ -1,5 +1,8 @@
// PWA Badge API 类型定义
declare global {
const __APP_VERSION__: string
const __BUILD_TIME__: string
interface Navigator {
/**
* 设置应用徽章数量

View File

@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs'
// 读取 package.json 获取版本号
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
const buildTime = new Date().getTime().toString()
// https://vitejs.dev/config/
export default defineConfig({
@@ -56,104 +57,10 @@ export default defineConfig({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'service-worker.ts',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
// 确保关键资源被预缓存
additionalManifestEntries: [
{
url: '/offline.html',
revision: null,
},
// 预缓存App Shell关键资源
{
url: '/logo.png',
revision: null,
},
],
// 启用导航预加载
navigationPreload: true,
runtimeCaching: [
// App Shell缓存 - 优先缓存
{
urlPattern: /^\/$|\/index\.html$/,
handler: 'CacheFirst',
options: {
cacheName: 'app-shell-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
},
},
},
{
urlPattern: /\.(?:js|css|html)$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources',
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|ico|webp|avif|gif|bmp|tiff)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 200,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
},
},
},
{
urlPattern: /\.(?:woff|woff2|ttf|otf|eot)$/,
handler: 'CacheFirst',
options: {
cacheName: 'font-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1年
},
},
},
{
urlPattern: /\/api\/v1\/.*$/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 500,
maxAgeSeconds: 24 * 60 * 60, // 24小时
},
},
},
{
urlPattern: /^https:\/\/image\.tmdb\.org\/.*$/,
handler: 'CacheFirst',
options: {
cacheName: 'tmdb-image-cache',
expiration: {
maxEntries: 300,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
},
},
},
{
urlPattern: ({ request }) => request.destination === 'document',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'pages-cache',
},
},
],
navigateFallback: '/offline.html',
navigateFallbackDenylist: [/.*\/api\/.*/, /\/offline\.html$/],
ignoreURLParametersMatching: [/^utm_/, /^fbclid$/, /^gclid$/],
skipWaiting: true,
clientsClaim: true,
},
injectManifest: {
rollupFormat: 'iife',
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
},
devOptions: {
enabled: true,
@@ -283,7 +190,8 @@ export default defineConfig({
],
define: {
'process.env': {},
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`)
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`),
'__BUILD_TIME__': JSON.stringify(buildTime),
},
resolve: {
alias: {
@@ -307,12 +215,6 @@ export default defineConfig({
},
chunkSizeWarningLimit: 5000,
cssCodeSplit: false,
rollupOptions: {
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
},
},
},
optimizeDeps: {
exclude: ['vuetify'],