feat: integrate PWA support with vite-plugin-pwa and remove legacy service worker files

- Added vite-plugin-pwa for enhanced PWA capabilities, including automatic service worker registration and caching strategies.
- Updated manifest configuration directly in vite.config.ts, removing the need for a separate manifest.json file.
- Removed legacy service worker and associated versioning plugin, streamlining the PWA setup.
- Adjusted UI components to reflect the new update handling mechanism for service workers.
This commit is contained in:
AdingApkgg
2026-01-09 21:10:47 +08:00
parent d61c45a640
commit 04c802a84f
15 changed files with 3333 additions and 467 deletions

1
env.d.ts vendored
View File

@@ -1,4 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'

View File

@@ -112,8 +112,7 @@
<link rel="alternate icon" type="image/png" sizes="16x16" href="/logo.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/logo.svg" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- PWA Meta Tags (manifest 由 vite-plugin-pwa 自动注入) -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

View File

@@ -23,17 +23,19 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"vite": "^7.3.0",
"vue-tsc": "^3.2.1"
"vite-plugin-pwa": "^1.2.0",
"vue-tsc": "^3.2.1",
"workbox-window": "^7.4.0"
},
"dependencies": {
"@fontsource/noto-sans-sc": "^5.2.8",
"@tanstack/vue-virtual": "^3.13.13",
"artalk": "^2.9.1",
"lucide-vue-next": "^0.562.0",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"vue": "^3.5.26",
"vue-prism-editor": "2.0.0-alpha.2",
"@tanstack/vue-virtual": "^3.13.13"
"vue-prism-editor": "2.0.0-alpha.2"
},
"packageManager": "pnpm@10.26.2"
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
}

3144
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,84 +0,0 @@
{
"name": "SearchGal - Galgame 聚合搜索",
"short_name": "SearchGal",
"description": "多平台 Galgame 资源聚合搜索引擎",
"start_url": "/",
"display": "standalone",
"background_color": "#fff5fa",
"theme_color": "#ff1493",
"orientation": "portrait-primary",
"scope": "/",
"lang": "zh-CN",
"dir": "ltr",
"icons": [
{
"src": "/logo.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/logo.svg",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo.svg",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"categories": ["entertainment", "games", "utilities"],
"screenshots": [
{
"src": "/logo.svg",
"sizes": "1920x1080",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/logo.svg",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "游戏模式搜索",
"short_name": "游戏搜索",
"description": "快速搜索游戏资源",
"url": "/?mode=game",
"icons": [
{
"src": "/logo.svg",
"sizes": "any"
}
]
},
{
"name": "高级模式搜索",
"short_name": "高级搜索",
"description": "高级工具和资源搜索",
"url": "/?mode=advanced",
"icons": [
{
"src": "/logo.svg",
"sizes": "any"
}
]
}
],
"share_target": {
"action": "/",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"title": "title",
"text": "text"
}
}
}

View File

@@ -1,157 +0,0 @@
// Service Worker - SearchGal PWA
// 版本由构建工具注入
const VERSION = self.__SW_VERSION__ || Date.now().toString(36)
const CACHE = `searchgal-${VERSION}`
// 缓存规则
const STATIC_EXT = /\.(js|css|mjs|woff2?|ttf|png|jpg|jpeg|gif|webp|svg|ico|avif|wasm)$/i
const SKIP = /\/(api|sw\.js|__vite|hot-update)|\.map$/
// ============================================
// 生命周期
// ============================================
self.addEventListener('install', (e) => {
console.log(`[SW] Install v${VERSION}`)
e.waitUntil(self.skipWaiting())
})
self.addEventListener('activate', (e) => {
console.log(`[SW] Activate v${VERSION}`)
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((k) => k.startsWith('searchgal-') && k !== CACHE)
.map((k) => caches.delete(k))
))
.then(() => self.clients.claim())
)
})
// ============================================
// 消息
// ============================================
self.addEventListener('message', (e) => {
const { type } = e.data || {}
if (type === 'GET_VERSION') {
e.ports[0]?.postMessage({ version: VERSION })
} else if (type === 'SKIP_WAITING') {
self.skipWaiting()
} else if (type === 'CLEAR_CACHE') {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then(() => e.ports[0]?.postMessage({ success: true }))
)
}
})
// ============================================
// 请求拦截
// ============================================
self.addEventListener('fetch', (e) => {
const { request } = e
const url = new URL(request.url)
// 跳过:非 GET、非 HTTP、跨域、特殊路径
if (
request.method !== 'GET' ||
!url.protocol.startsWith('http') ||
url.origin !== location.origin ||
SKIP.test(url.href)
) return
const isDocument = request.destination === 'document'
const isStatic = STATIC_EXT.test(url.pathname)
if (isDocument) {
// HTML网络优先离线显示提示
e.respondWith(networkFirst(request, true))
} else if (isStatic) {
// 静态资源:缓存优先
e.respondWith(cacheFirst(request))
} else {
// 其他:网络优先
e.respondWith(networkFirst(request, false))
}
})
// ============================================
// 缓存策略
// ============================================
async function cacheFirst(req) {
const cached = await caches.match(req)
if (cached) return cached
try {
const res = await fetch(req)
if (res.ok) {
const cache = await caches.open(CACHE)
cache.put(req, res.clone())
}
return res
} catch {
return new Response('', { status: 503 })
}
}
async function networkFirst(req, showOffline) {
try {
const res = await fetch(req)
if (res.ok) {
const cache = await caches.open(CACHE)
cache.put(req, res.clone())
}
return res
} catch {
const cached = await caches.match(req)
if (cached) return cached
return showOffline ? offlinePage() : new Response('', { status: 503 })
}
}
// ============================================
// 离线页面
// ============================================
function offlinePage() {
return new Response(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>离线 - SearchGal</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,#fff5fa,#ffe4f0);padding:1.5rem}
.c{text-align:center;max-width:360px}
.i{font-size:4rem;margin-bottom:1rem;animation:f 2s ease-in-out infinite}
@keyframes f{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
h1{font-size:1.5rem;margin-bottom:.5rem;background:linear-gradient(135deg,#ff1493,#d946ef);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
p{color:#666;line-height:1.5;margin-bottom:1.5rem}
button{padding:.875rem 2rem;background:linear-gradient(135deg,#ff1493,#d946ef);color:#fff;border:none;border-radius:9999px;font-size:.9rem;font-weight:500;cursor:pointer;box-shadow:0 4px 12px rgba(255,20,147,.3);transition:transform .2s,box-shadow .2s}
button:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(255,20,147,.4)}
button:active{transform:translateY(0)}
.h{margin-top:1.5rem;font-size:.8rem;color:#999}
</style>
</head>
<body>
<div class="c">
<div class="i">🌐</div>
<h1>需要网络连接</h1>
<p>SearchGal 是在线搜索服务,请连接网络后使用</p>
<button onclick="location.reload()">重新连接</button>
<p class="h">检查网络是否正常</p>
</div>
</body>
</html>`, {
status: 503,
headers: { 'Content-Type': 'text/html;charset=utf-8' }
})
}
console.log(`[SW] Ready v${VERSION}`)

View File

@@ -1,70 +0,0 @@
/**
* Vite 插件Service Worker 版本注入
* 构建时自动将版本号注入到 sw.js
*/
import type { Plugin } from 'vite'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { resolve } from 'path'
import { execSync } from 'child_process'
interface Options {
/** sw.js 路径(相对于输出目录) */
swPath?: string
/** 是否包含 git commit hash */
includeGitHash?: boolean
}
/** 获取 git 短 hash失败返回空 */
function getGitHash(): string {
try {
return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim()
} catch {
return ''
}
}
/** 生成版本号时间戳base36[-gitHash] */
function generateVersion(includeGitHash: boolean): string {
const timestamp = Date.now().toString(36)
const gitHash = includeGitHash ? getGitHash() : ''
return gitHash ? `${timestamp}-${gitHash}` : timestamp
}
export function swVersionPlugin(options: Options = {}): Plugin {
const { swPath = 'sw.js', includeGitHash = true } = options
let version = ''
let outDir = 'dist'
return {
name: 'sw-version',
configResolved(config) {
outDir = config.build.outDir
},
buildStart() {
version = generateVersion(includeGitHash)
console.info(`\n📦 SW Version: ${version}\n`)
},
closeBundle() {
const filePath = resolve(outDir, swPath)
if (!existsSync(filePath)) {
console.warn(`[sw-version] ${swPath} not found`)
return
}
// 替换版本占位符
const content = readFileSync(filePath, 'utf-8')
.replace(/self\.__SW_VERSION__/g, `'${version}'`)
writeFileSync(filePath, content)
console.info(`✅ SW version: ${version}`)
},
}
}
export default swVersionPlugin

View File

@@ -56,10 +56,7 @@
<ImageViewer />
<!-- SW 更新提示 -->
<UpdateToast
:is-visible="uiStore.showUpdateToast"
:on-update="handleSwUpdate"
/>
<UpdateToast />
</main>
</div>
</template>
@@ -121,14 +118,6 @@ function openSettings() {
uiStore.toggleSettingsModal()
}
// 处理 SW 更新
function handleSwUpdate() {
// SW 更新逻辑由 main.ts 的 controllerchange 事件处理
// 这里只需要关闭提示并发送消息给 SW
uiStore.setShowUpdateToast(false)
navigator.serviceWorker.controller?.postMessage({ type: 'SKIP_WAITING' })
}
// 处理历史记录选择
function handleHistorySelect(item: { query: string; mode: 'game' | 'patch' }) {
// 同步设置 store用于其他地方读取

View File

@@ -61,6 +61,7 @@
<!-- 输入框 -->
<input
ref="searchInputRef"
v-model="searchQuery"
type="search"
:placeholder="searchMode === 'game' ? '搜索游戏...' : '搜索补丁...'"
@@ -502,6 +503,7 @@ const historyStore = useHistoryStore()
const searchQuery = ref('')
const customApi = ref('')
const searchMode = ref<'game' | 'patch'>('game')
const searchInputRef = ref<HTMLInputElement | null>(null)
let cleanupURLListener: (() => void) | null = null
let searchStartTime = 0
@@ -951,6 +953,11 @@ function searchWithParams(query: string, mode: 'game' | 'patch') {
mode: mode,
api: customApi.value,
})
// 自动对焦到输入框
setTimeout(() => {
searchInputRef.value?.focus()
}, 50)
}
defineExpose({

View File

@@ -8,79 +8,111 @@
leave-to-class="opacity-0 translate-y-4 scale-95"
>
<div
v-if="isVisible"
v-if="needRefresh"
class="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] px-4 py-3 rounded-2xl bg-gradient-to-r from-[#ff1493] to-[#d946ef] text-white shadow-xl shadow-pink-500/30 flex items-center gap-3 max-w-[90vw] sm:max-w-md"
>
<!-- 图标 -->
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
<RefreshCw :size="18" class="animate-spin" />
<RefreshCw :size="18" :class="{ 'animate-spin': isUpdating }" />
</div>
<!-- 文字内容 -->
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm">发现新版本</p>
<p class="text-xs text-white/80 truncate">
{{ countdown > 0 ? `${countdown} 秒后自动更新...` : '正在更新...' }}
<template v-if="isUpdating">
正在更新...
</template>
<template v-else-if="countdown > 0">
{{ countdown }} 秒后自动更新...
</template>
<template v-else>
准备更新...
</template>
</p>
</div>
<!-- 立即更新按钮 -->
<button
v-if="!isUpdating && countdown > 0"
class="flex-shrink-0 px-3 py-1.5 rounded-full bg-white/20 hover:bg-white/30 text-xs font-medium transition-colors"
@click="handleUpdate"
>
立即更新
</button>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import { useRegisterSW } from 'virtual:pwa-register/vue'
import { playNotification } from '@/composables/useSound'
const props = defineProps<{
isVisible: boolean
onUpdate: () => void
}>()
const UPDATE_COUNTDOWN = 5 // 秒
const countdown = ref(5)
let timer: number | null = null
const countdown = ref(0)
const isUpdating = ref(false)
let countdownTimer: number | null = null
const {
needRefresh,
updateServiceWorker,
} = useRegisterSW({
immediate: true,
onRegisteredSW(swUrl, r) {
console.info(`[SW] Registered: ${swUrl}`)
// 定期检查更新
if (r) {
setInterval(() => {
void r.update()
}, 5 * 60 * 1000) // 每 5 分钟检查一次
}
},
onRegisterError(error) {
console.error('[SW] Registration error:', error)
},
})
// 当有更新时启动倒计时
watch(needRefresh, (need) => {
if (need) {
playNotification()
startCountdown()
}
})
function startCountdown() {
// 先清除可能存在的旧定时器,避免创建多个并发定时器
if (timer) {
clearInterval(timer)
timer = null
countdown.value = UPDATE_COUNTDOWN
if (countdownTimer) {
clearInterval(countdownTimer)
}
playNotification()
countdown.value = 5
timer = window.setInterval(() => {
countdownTimer = window.setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
if (timer) {
clearInterval(timer)
timer = null
}
props.onUpdate()
clearInterval(countdownTimer!)
countdownTimer = null
handleUpdate()
}
}, 1000)
}
onMounted(() => {
if (props.isVisible) {
startCountdown()
function handleUpdate() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
isUpdating.value = true
// 更新 SW 并刷新页面
void updateServiceWorker(true)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
// 监听 isVisible 变化
watch(() => props.isVisible, (visible) => {
if (visible) {
startCountdown()
} else if (timer) {
clearInterval(timer)
timer = null
if (countdownTimer) {
clearInterval(countdownTimer)
}
})
</script>

View File

@@ -75,7 +75,7 @@ app.mount('#app')
// Pinia Stores 初始化
// ============================================
// 获取 UI Store 用于 SW 更新通知
// 获取 UI Store
const uiStore = useUIStore()
const statsStore = useStatsStore()
@@ -86,61 +86,7 @@ uiStore.init()
statsStore.incrementPageView()
// ============================================
// Service Worker 注册与更新
// Service Worker (vite-plugin-pwa)
// ============================================
// 显示更新提示 - 使用 UIStore 管理(由 UpdateToast 组件渲染)
function showUpdateToast() {
uiStore.setShowUpdateToast(true)
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void (async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js')
console.info('[SW] Registered')
// 新版本检测
reg.addEventListener('updatefound', () => {
const worker = reg.installing
if (!worker) {
return
}
worker.addEventListener('statechange', () => {
// 新 SW 安装完成且有旧 SW 控制页面 = 有更新
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
console.info('[SW] Update available')
// 显示更新提示UpdateToast 组件处理倒计时和更新)
showUpdateToast()
}
})
})
// 新 SW 激活后刷新页面
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) {
return
}
refreshing = true
console.info('[SW] New version activated, reloading...')
window.location.reload()
})
// 定期检查更新5 分钟)
setInterval(() => { void reg.update().catch(() => { /* 静默处理 */ }) }, 5 * 60 * 1000)
// 页面可见时检查更新
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
void reg.update().catch(() => { /* 静默处理 */ })
}
})
} catch {
// 静默处理
}
})()
})
}
// SW 注册由 vite-plugin-pwa 自动处理
// 更新提示由 UpdateToast 组件处理

View File

@@ -4,13 +4,13 @@ import type { SearchHistory } from '@/utils/persistence'
import {
loadSearchHistory,
saveSearchHistory as persistSearchHistory,
saveAllSearchHistory,
clearSearchHistory as clearPersistedSearchHistory,
} from '@/utils/persistence'
export const useHistoryStore = defineStore('history', () => {
// 状态
const searchHistory = ref<SearchHistory[]>([])
const maxHistoryItems = ref(50) // 最多保存 50 条历史
// 计算属性
const recentHistory = computed(() =>
@@ -57,11 +57,6 @@ export const useHistoryStore = defineStore('history', () => {
// 添加到开头
searchHistory.value.unshift(newItem)
// 限制数量
if (searchHistory.value.length > maxHistoryItems.value) {
searchHistory.value = searchHistory.value.slice(0, maxHistoryItems.value)
}
// 持久化
persistSearchHistory(newItem)
}
@@ -87,10 +82,8 @@ export const useHistoryStore = defineStore('history', () => {
}
function saveHistory() {
// 由于持久化工具只能添加单条,这里重新保存所有历史
searchHistory.value.forEach(item => {
persistSearchHistory(item)
})
// 覆盖保存整个历史列表
saveAllSearchHistory(searchHistory.value)
}
function getHistoryStats() {
@@ -128,7 +121,6 @@ export const useHistoryStore = defineStore('history', () => {
return {
// 状态
searchHistory,
maxHistoryItems,
// 计算属性
recentHistory,

View File

@@ -79,9 +79,6 @@ export const useUIStore = defineStore('ui', () => {
// 加载状态
const isLoading = ref(false)
const loadingMessage = ref('')
// SW 更新状态
const showUpdateToast = ref(false)
// 滚动位置(用于恢复)
const scrollPosition = ref(0)
@@ -466,11 +463,6 @@ export const useUIStore = defineStore('ui', () => {
}, { passive: true })
}
// 显示 SW 更新提示
function setShowUpdateToast(show: boolean) {
showUpdateToast.value = show
}
// 清除会话状态(用于完全重置)
function clearSessionState() {
sessionStorage.removeItem(SESSION_KEY)
@@ -493,7 +485,6 @@ export const useUIStore = defineStore('ui', () => {
backgroundImageLoaded,
isLoading,
loadingMessage,
showUpdateToast,
toasts,
isKeyboardHelpOpen,
scrollPosition,
@@ -526,7 +517,6 @@ export const useUIStore = defineStore('ui', () => {
setBackgroundImage,
setBackgroundImageLoaded,
setLoading,
setShowUpdateToast,
showToast,
removeToast,
clearToasts,

View File

@@ -7,7 +7,6 @@ import type { PlatformData, VndbInfo } from '@/stores/search'
const STORAGE_KEY = 'searchgal_state'
const STORAGE_VERSION = '1.0'
const MAX_HISTORY_SIZE = 10 // 最多保存 10 条搜索历史
export interface SearchState {
version: string
@@ -106,11 +105,6 @@ export function saveSearchHistory(history: SearchHistory): void {
// 添加新搜索到开头
historyList.unshift(history)
// 限制历史记录数量
if (historyList.length > MAX_HISTORY_SIZE) {
historyList = historyList.slice(0, MAX_HISTORY_SIZE)
}
localStorage.setItem(HISTORY_KEY, JSON.stringify(historyList))
} catch (error) {
// 静默处理
@@ -149,6 +143,18 @@ export function clearSearchHistory(): void {
}
}
/**
* 覆盖保存整个搜索历史列表
*/
export function saveAllSearchHistory(historyList: SearchHistory[]): void {
try {
const HISTORY_KEY = 'searchgal_history'
localStorage.setItem(HISTORY_KEY, JSON.stringify(historyList))
} catch (error) {
// 静默处理
}
}
/**
* 获取 localStorage 使用情况
*/

View File

@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath, URL } from 'node:url';
import { swVersionPlugin } from './scripts/sw-version-plugin';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
server: {
@@ -26,10 +26,97 @@ export default defineConfig({
},
}),
tailwindcss(),
// 构建时自动注入 SW 版本号
swVersionPlugin({
swPath: 'sw.js',
includeGitHash: true,
// PWA 配置
VitePWA({
registerType: 'prompt', // 提示用户更新
includeAssets: ['logo.svg', 'robots.txt'],
manifest: {
name: 'SearchGal - Galgame 聚合搜索',
short_name: 'SearchGal',
description: '多平台 Galgame 资源聚合搜索引擎',
start_url: '/',
display: 'standalone',
background_color: '#fff5fa',
theme_color: '#ff1493',
orientation: 'portrait-primary',
scope: '/',
lang: 'zh-CN',
dir: 'ltr',
icons: [
{
src: '/logo.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
categories: ['entertainment', 'games', 'utilities'],
shortcuts: [
{
name: '游戏模式搜索',
short_name: '游戏搜索',
description: '快速搜索游戏资源',
url: '/?mode=game',
icons: [{ src: '/logo.svg', sizes: 'any' }],
},
{
name: '补丁模式搜索',
short_name: '补丁搜索',
description: '搜索游戏补丁资源',
url: '/?mode=patch',
icons: [{ src: '/logo.svg', sizes: 'any' }],
},
],
},
workbox: {
// 预缓存所有构建产物
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
// 运行时缓存策略
runtimeCaching: [
{
// 字体文件 - 缓存优先
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 年
},
},
},
{
// 图片 API - 网络优先
urlPattern: /^https:\/\/api\.illlights\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'background-images',
expiration: {
maxEntries: 20,
maxAgeSeconds: 60 * 60 * 24, // 1 天
},
},
},
{
// VNDB API - 网络优先
urlPattern: /^https:\/\/api\.vndb\.org\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'vndb-api',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 30, // 30 分钟
},
},
},
],
// 离线时的导航回退
navigateFallback: null, // 不使用默认回退,由 offlineFallback 处理
},
// 开发环境启用 SW便于测试
devOptions: {
enabled: false, // 开发时禁用,避免干扰
},
}),
],