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 & { readonly __WB_MANIFEST: Array<{ url: string; revision?: string }> } // 缓存版本控制 const RESOURCE_VERSION = 'V2' const CACHE_VERSION = `${__APP_VERSION__}-${__BUILD_TIME__}` // 开发环境下无法使用此环境变量,生产环境正常 // 启用导航预载 navigationPreload.enable() // 自动清理旧的预缓存 cleanupOutdatedCaches() // 预缓存并路由 precacheAndRoute(self.__WB_MANIFEST) // 监听安装事件 self.addEventListener('install', () => { // 强制等待中的 Service Worker 立即激活 self.skipWaiting() }) // 监听激活事件 self.addEventListener('activate', event => { // 让 Service Worker 立即接管页面 event.waitUntil( (async () => { await self.clients.claim() // 清理旧版本的运行时缓存 await cleanupRuntimeCaches(true) })(), ) }) // 通知选项 const options = { icon: '/logo.png', vibrate: [100, 50, 100], actions: [{ action: 'close', title: '关闭' }], } // 存储未读消息数量的键名 const UNREAD_COUNT_KEY = 'mp_unread_count' // --- 缓存策略配置 --- // 导航请求与 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-${RESOURCE_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-${RESOURCE_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-${RESOURCE_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/system/progress/') && // SSE实时进度流 !url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流 !url.pathname.includes('/api/v1/message/') && // 用户消息接口 !url.pathname.includes('/api/v1/system/global') && // 系统配置接口 !url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口 !url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据 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', ] // 当前版本的缓存全名 const currentCacheNames = [ `app-shell-${CACHE_VERSION}`, `static-resources-${CACHE_VERSION}`, `image-cache-${RESOURCE_VERSION}`, `font-cache-${RESOURCE_VERSION}`, `tmdb-image-cache-${RESOURCE_VERSION}`, `api-cache-${CACHE_VERSION}`, ] await Promise.all( cacheNames.map(cacheName => { const isRuntimeCache = runtimeCachePrefixes.some(prefix => cacheName.startsWith(prefix)) if (isRuntimeCache) { if (!onlyOld || !currentCacheNames.includes(cacheName)) { console.log('[SW] Deleting runtime cache:', cacheName) return caches.delete(cacheName) } } return Promise.resolve() }), ) } // 简单的 IndexedDB 包装器 (用于未读计数) async function openDB(): Promise { 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 if (!db.objectStoreNames.contains('badge')) { db.createObjectStore('badge') } } }) } async function get(key: string, storeName: string = 'badge'): Promise { 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 } } async function set(key: string, value: any, storeName: string = 'badge'): Promise { 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) { console.error(`[SW] Failed to set IndexedDB key "${key}" in store "${storeName}":`, e) } } async function getStoredUnreadCount(): Promise { const count = await get(UNREAD_COUNT_KEY) return typeof count === 'number' ? count : 0 } async function setStoredUnreadCount(count: number): Promise { await set(UNREAD_COUNT_KEY, count) } async function updateBadge(count: number) { if ('setAppBadge' in self.navigator) { try { if (count > 0) { await self.navigator.setAppBadge(count) } else { await self.navigator.clearAppBadge() } } catch (error) { console.error('Failed to update app badge:', error) } } } async function clearBadge() { if ('clearAppBadge' in self.navigator) { try { await self.navigator.clearAppBadge() await setStoredUnreadCount(0) } catch (error) { console.error('Failed to clear app badge:', error) } } } // 监控缓存大小 async function monitorCacheSize() { const cacheSizes: Record = {} let calculatedTotalSize = 0 try { const cacheNames = await caches.keys() // 并行处理所有缓存 await Promise.all( cacheNames.map(async cacheName => { const cache = await caches.open(cacheName) const requests = await cache.keys() let cacheSize = 0 // 遍历请求以获取响应头部,避免 matchAll 一次性加载大量响应对象到内存 for (const request of requests) { const response = await cache.match(request) if (response) { const contentLength = response.headers.get('content-length') if (contentLength) { cacheSize += parseInt(contentLength, 10) } } } cacheSizes[cacheName] = cacheSize }), ) calculatedTotalSize = 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 } // 构造结果:满足 useCacheManager.ts 的需求 const result = { cacheSizes, // 优先使用准确的 usage (真实磁盘占用),如果不可用则退回到计算值 totalSize: usage || calculatedTotalSize, totalSizeMB: ((usage || calculatedTotalSize) / 1024 / 1024).toFixed(2), // 额外信息保留,供未来扩展 quota, usage, quotaMB: (quota / 1024 / 1024).toFixed(2), usageMB: (usage / 1024 / 1024).toFixed(2), calculatedTotalSize, } // 发送缓存统计信息给客户端 const clients = await self.clients.matchAll() clients.forEach(client => { client.postMessage({ type: 'CACHE_SIZE_UPDATE', data: result, }) }) 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', } } } // --- 事件监听 --- // 监听 push 事件 self.addEventListener('push', function (event) { if (!event.data) { return } let payload try { payload = event.data?.json() } catch (err) { payload = { title: event.data?.text(), } } try { const content = { body: payload.body || '', icon: payload.icon || options.icon, vibrate: [100, 50, 100], data: { url: payload.url }, actions: options.actions, } event.waitUntil( (async () => { const currentCount = await getStoredUnreadCount() const newCount = currentCount + 1 await setStoredUnreadCount(newCount) await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)]) })(), ) } catch (e) { // 忽略错误 } }) // 监听通知点击 self.addEventListener('notificationclick', function (event) { const info = event.notification if (event.action === 'close') { info.close() } else if (info.data?.url) { event.waitUntil(self.clients.openWindow(info.data?.url)) } }) // 监听消息 self.addEventListener('message', function (event) { if (event.data && event.data.type === 'CLEAR_BADGE') { clearBadge() .then(() => { event.ports[0]?.postMessage({ success: true }) }) .catch(error => { 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)) .then(() => { event.ports[0]?.postMessage({ success: true }) }) .catch(error => { 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(() => { event.ports[0]?.postMessage({ count: 0 }) }) } else if (event.data && event.data.type === 'CLEANUP_CACHES') { // 手动清理: 清理所有运行时缓存 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 }) }) .catch(error => { event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) }) }) } else if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting() } })