mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-10 17:42:50 +08:00
- feat(login): 优化通行密钥(Passkey)登录逻辑,支持 Conditional UI 自动填充,并改进错误提示。 - feat(userProfile): 优化双重验证弹窗样式。 - feat(qrcode): 优化二维码生成逻辑与显示。 - feat(passkey): 优化通行密钥错误提示,添加最后使用时间显示。
474 lines
13 KiB
TypeScript
474 lines
13 KiB
TypeScript
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<IDBDatabase> {
|
|
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<any> {
|
|
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<void> {
|
|
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<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 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<string, number> = {}
|
|
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()
|
|
}
|
|
})
|