werewolf(pwa): 离线 PWA — 自定义 SW 预缓存 + 全屏进度条,牌图 21M→2.8M
deploy werewolf / build-and-deploy (push) Successful in 1m1s
- vite-plugin-pwa(injectManifest)自定义 SW:install 逐个抓取并向页面广播进度, cache-first 服务,导航离线回退 index.html,缓存版本随清单哈希自动淘汰旧缓存 - 全屏 modal 进度条(src/pwa.ts),反映首屏预缓存真实下载进度 - 牌图 mozjpeg 压缩 + 限长边 900px,每张 ≤200K(21.2MB→2.8MB) - 生成 PWA 图标 + manifest + apple-touch meta,index.html 接入 - 新增脚本:npm run gen:icons / compress:images
@@ -2,8 +2,13 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#1a1a2e" />
|
<meta name="theme-color" content="#1a1a2e" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-48x48.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="狼人杀" />
|
||||||
<title>狼人杀发牌器</title>
|
<title>狼人杀发牌器</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"gen:icons": "node scripts/gen-icons.mjs",
|
||||||
|
"compress:images": "node scripts/compress-images.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 648 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 697 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 784 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 716 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 721 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 712 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 638 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 701 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 586 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 734 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 728 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 746 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 717 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 708 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 741 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 731 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 714 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 639 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 689 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 644 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 657 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 692 KiB After Width: | Height: | Size: 93 KiB |
@@ -0,0 +1,60 @@
|
|||||||
|
// 把 public/werewolf 下的牌图压到每张 <= 200KB(原地覆盖)。
|
||||||
|
// 策略:最长边限 900px,mozjpeg 质量从高到低递减,直到达标。
|
||||||
|
// 已经 <= 目标的文件跳过,避免重复编码反复掉质量。
|
||||||
|
// 运行: npm run compress:images
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { readdir, stat, writeFile } from 'node:fs/promises'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve, join } from 'node:path'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = resolve(here, '../public/werewolf')
|
||||||
|
|
||||||
|
const TARGET = 200 * 1024
|
||||||
|
const MAX_EDGE = 900
|
||||||
|
const QUALITIES = [82, 76, 70, 64, 58, 52, 46]
|
||||||
|
|
||||||
|
async function listJpgs(dir) {
|
||||||
|
const out = []
|
||||||
|
for (const name of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const p = join(dir, name.name)
|
||||||
|
if (name.isDirectory()) out.push(...(await listJpgs(p)))
|
||||||
|
else if (/\.jpe?g$/i.test(name.name)) out.push(p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compress(file) {
|
||||||
|
const before = (await stat(file)).size
|
||||||
|
if (before <= TARGET) return { file, skipped: true, before }
|
||||||
|
let chosen = null
|
||||||
|
for (const quality of QUALITIES) {
|
||||||
|
const buf = await sharp(file)
|
||||||
|
.rotate() // 按 EXIF 摆正
|
||||||
|
.resize({ width: MAX_EDGE, height: MAX_EDGE, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality, mozjpeg: true })
|
||||||
|
.toBuffer()
|
||||||
|
chosen = { buf, quality }
|
||||||
|
if (buf.length <= TARGET) break
|
||||||
|
}
|
||||||
|
await writeFile(file, chosen.buf)
|
||||||
|
return { file, before, after: chosen.buf.length, quality: chosen.quality }
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await listJpgs(root)
|
||||||
|
let totalBefore = 0
|
||||||
|
let totalAfter = 0
|
||||||
|
for (const f of files) {
|
||||||
|
const r = await compress(f)
|
||||||
|
const rel = f.slice(root.length + 1)
|
||||||
|
if (r.skipped) {
|
||||||
|
totalBefore += r.before
|
||||||
|
totalAfter += r.before
|
||||||
|
console.log(`skip ${rel} (${(r.before / 1024) | 0}K)`)
|
||||||
|
} else {
|
||||||
|
totalBefore += r.before
|
||||||
|
totalAfter += r.after
|
||||||
|
console.log(`ok ${rel} ${(r.before / 1024) | 0}K -> ${(r.after / 1024) | 0}K q${r.quality}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${files.length} files: ${(totalBefore / 1024 / 1024).toFixed(1)}MB -> ${(totalAfter / 1024 / 1024).toFixed(1)}MB`)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// 从 public/werewolf/back.jpg 生成 PWA 图标。
|
||||||
|
// 运行: npm run gen:icons
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { mkdir } from 'node:fs/promises'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pub = resolve(here, '../public')
|
||||||
|
const src = resolve(pub, 'werewolf/back.jpg')
|
||||||
|
|
||||||
|
// 卡背是竖图,狼头菱形大致在水平居中、垂直 ~46% 处。
|
||||||
|
// 裁一个聚焦狼头 logo 的方形区域作为图标基底(按实际尺寸夹紧,避免越界)。
|
||||||
|
const meta = await sharp(src).metadata()
|
||||||
|
const SIDE = Math.min(meta.width, Math.round(meta.width * 0.82), meta.height)
|
||||||
|
const left = Math.round(Math.max(0, Math.min(meta.width - SIDE, meta.width / 2 - SIDE / 2)))
|
||||||
|
const top = Math.round(Math.max(0, Math.min(meta.height - SIDE, meta.height * 0.46 - SIDE / 2)))
|
||||||
|
const square = await sharp(src)
|
||||||
|
.extract({ left, top, width: SIDE, height: SIDE })
|
||||||
|
.toBuffer()
|
||||||
|
|
||||||
|
const RED = { r: 0xc0, g: 0x39, b: 0x2b, alpha: 1 } // 贴近卡面红,用于 maskable 安全区留白
|
||||||
|
|
||||||
|
async function out(name, size, { maskable = false } = {}) {
|
||||||
|
await mkdir(pub, { recursive: true })
|
||||||
|
const target = resolve(pub, name)
|
||||||
|
if (maskable) {
|
||||||
|
// maskable: 内容缩到 ~80%,四周用卡红留白,保证安全区不被裁切
|
||||||
|
const inner = Math.round(size * 0.8)
|
||||||
|
const fg = await sharp(square).resize(inner, inner).toBuffer()
|
||||||
|
await sharp({ create: { width: size, height: size, channels: 4, background: RED } })
|
||||||
|
.composite([{ input: fg, gravity: 'center' }])
|
||||||
|
.png()
|
||||||
|
.toFile(target)
|
||||||
|
} else {
|
||||||
|
await sharp(square).resize(size, size).png().toFile(target)
|
||||||
|
}
|
||||||
|
console.log('wrote', name)
|
||||||
|
}
|
||||||
|
|
||||||
|
await out('pwa-192x192.png', 192)
|
||||||
|
await out('pwa-512x512.png', 512)
|
||||||
|
await out('maskable-icon-512x512.png', 512, { maskable: true })
|
||||||
|
await out('apple-touch-icon-180x180.png', 180)
|
||||||
|
await out('favicon-48x48.png', 48)
|
||||||
|
console.log('done')
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import { setupPWA } from './pwa'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).mount('#app')
|
||||||
|
setupPWA()
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// PWA 客户端:注册 Service Worker,并在首次预缓存(或版本更新)时
|
||||||
|
// 显示一个全屏 modal 进度条,反映离线资源的真实下载进度。
|
||||||
|
|
||||||
|
type SWMessage =
|
||||||
|
| { type: 'precache-start'; total: number }
|
||||||
|
| { type: 'precache-progress'; loaded: number; total: number }
|
||||||
|
| { type: 'precache-done'; total: number }
|
||||||
|
|
||||||
|
let overlay: HTMLDivElement | null = null
|
||||||
|
let barFill: HTMLDivElement | null = null
|
||||||
|
let label: HTMLDivElement | null = null
|
||||||
|
|
||||||
|
function injectStyle(): void {
|
||||||
|
if (document.getElementById('pwa-precache-style')) return
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = 'pwa-precache-style'
|
||||||
|
style.textContent = `
|
||||||
|
.pwa-precache {
|
||||||
|
position: fixed; inset: 0; z-index: 9999;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 22px; padding: 32px;
|
||||||
|
background: var(--bg, #1a1a2e);
|
||||||
|
color: var(--fg, rgba(255,255,255,0.92));
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||||
|
transition: opacity .4s ease;
|
||||||
|
}
|
||||||
|
.pwa-precache.hide { opacity: 0; pointer-events: none; }
|
||||||
|
.pwa-precache__icon { width: 96px; height: 96px; border-radius: 22px; box-shadow: 0 8px 30px rgba(0,0,0,.4); }
|
||||||
|
.pwa-precache__title { font-size: 17px; font-weight: 600; }
|
||||||
|
.pwa-precache__sub { font-size: 13px; color: var(--fg-dim, rgba(255,255,255,0.6)); margin-top: -10px; }
|
||||||
|
.pwa-precache__track {
|
||||||
|
width: min(78vw, 320px); height: 8px; border-radius: 999px;
|
||||||
|
background: var(--bg-soft, rgba(255,255,255,0.06));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pwa-precache__fill {
|
||||||
|
height: 100%; width: 0%;
|
||||||
|
background: linear-gradient(90deg, var(--accent, #7c3aed), var(--accent-2, #06b6d4));
|
||||||
|
border-radius: 999px; transition: width .25s ease;
|
||||||
|
}
|
||||||
|
.pwa-precache__pct { font-size: 13px; color: var(--fg-dim, rgba(255,255,255,0.6)); }
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(): void {
|
||||||
|
if (overlay) return
|
||||||
|
injectStyle()
|
||||||
|
overlay = document.createElement('div')
|
||||||
|
overlay.className = 'pwa-precache'
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<img class="pwa-precache__icon" src="/pwa-192x192.png" alt="" />
|
||||||
|
<div class="pwa-precache__title">正在缓存离线资源…</div>
|
||||||
|
<div class="pwa-precache__sub">完成后断网也能发牌</div>
|
||||||
|
<div class="pwa-precache__track"><div class="pwa-precache__fill"></div></div>
|
||||||
|
<div class="pwa-precache__pct">0%</div>
|
||||||
|
`
|
||||||
|
document.body.appendChild(overlay)
|
||||||
|
barFill = overlay.querySelector('.pwa-precache__fill')
|
||||||
|
label = overlay.querySelector('.pwa-precache__pct')
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(loaded: number, total: number): void {
|
||||||
|
if (!overlay) show()
|
||||||
|
const pct = total > 0 ? Math.round((loaded / total) * 100) : 0
|
||||||
|
if (barFill) barFill.style.width = `${pct}%`
|
||||||
|
if (label) label.textContent = `${pct}% · ${loaded}/${total}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function done(): void {
|
||||||
|
if (!overlay) return
|
||||||
|
if (barFill) barFill.style.width = '100%'
|
||||||
|
overlay.classList.add('hide')
|
||||||
|
const el = overlay
|
||||||
|
overlay = null
|
||||||
|
barFill = null
|
||||||
|
label = null
|
||||||
|
setTimeout(() => el.remove(), 450)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupPWA(): void {
|
||||||
|
if (!('serviceWorker' in navigator)) return
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
const msg = event.data as SWMessage
|
||||||
|
if (!msg || typeof msg !== 'object') return
|
||||||
|
if (msg.type === 'precache-start') show()
|
||||||
|
else if (msg.type === 'precache-progress') update(msg.loaded, msg.total)
|
||||||
|
else if (msg.type === 'precache-done') done()
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch((err) => {
|
||||||
|
console.error('[pwa] SW 注册失败', err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
// 自定义 Service Worker(injectManifest 模式)。
|
||||||
|
// install 时逐个抓取预缓存清单中的资源,并向所有页面广播进度,
|
||||||
|
// 用于驱动首屏的全屏加载进度条;之后断网也能完整发牌。
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope & typeof globalThis
|
||||||
|
|
||||||
|
interface PrecacheEntry {
|
||||||
|
url: string
|
||||||
|
revision: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectManifest 会把构建产物清单注入到这里
|
||||||
|
const MANIFEST = self.__WB_MANIFEST as PrecacheEntry[]
|
||||||
|
|
||||||
|
// 由清单内容派生缓存版本号:任一资源变化版本即变,旧缓存自动淘汰
|
||||||
|
function hashStr(s: string): string {
|
||||||
|
let h = 5381
|
||||||
|
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
|
||||||
|
return h.toString(36)
|
||||||
|
}
|
||||||
|
const VERSION = hashStr(MANIFEST.map((e) => `${e.url}@${e.revision ?? ''}`).join('|'))
|
||||||
|
const CACHE = `werewolf-precache-${VERSION}`
|
||||||
|
|
||||||
|
const INDEX = new URL('index.html', self.location.href).href
|
||||||
|
const URLS = Array.from(new Set([INDEX, ...MANIFEST.map((e) => new URL(e.url, self.location.href).href)]))
|
||||||
|
|
||||||
|
async function broadcast(message: unknown): Promise<void> {
|
||||||
|
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
|
||||||
|
for (const client of clients) client.postMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE)
|
||||||
|
const total = URLS.length
|
||||||
|
let loaded = 0
|
||||||
|
await broadcast({ type: 'precache-start', total })
|
||||||
|
|
||||||
|
const queue = [...URLS]
|
||||||
|
const CONCURRENCY = 6
|
||||||
|
const worker = async () => {
|
||||||
|
for (let url = queue.shift(); url !== undefined; url = queue.shift()) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { cache: 'no-cache' })
|
||||||
|
if (res.ok) await cache.put(url, res.clone())
|
||||||
|
} catch {
|
||||||
|
// 单个资源失败不阻塞安装,下次启动或运行时再补
|
||||||
|
}
|
||||||
|
loaded++
|
||||||
|
await broadcast({ type: 'precache-progress', loaded, total })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker))
|
||||||
|
|
||||||
|
await broadcast({ type: 'precache-done', total })
|
||||||
|
await self.skipWaiting()
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys()
|
||||||
|
await Promise.all(
|
||||||
|
keys.filter((k) => k.startsWith('werewolf-precache-') && k !== CACHE).map((k) => caches.delete(k)),
|
||||||
|
)
|
||||||
|
await self.clients.claim()
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const req = event.request
|
||||||
|
if (req.method !== 'GET') return
|
||||||
|
const url = new URL(req.url)
|
||||||
|
if (url.origin !== self.location.origin) return
|
||||||
|
|
||||||
|
// 导航请求:优先网络(便于上线后拿到新版),断网回退缓存的 index.html
|
||||||
|
if (req.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
return await fetch(req)
|
||||||
|
} catch {
|
||||||
|
const cache = await caches.open(CACHE)
|
||||||
|
return (await cache.match(INDEX)) ?? Response.error()
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其它同源资源:cache-first,未命中再走网络并回填
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE)
|
||||||
|
const hit = await cache.match(req.url)
|
||||||
|
if (hit) return hit
|
||||||
|
try {
|
||||||
|
const res = await fetch(req)
|
||||||
|
if (res.ok && res.type !== 'opaque') cache.put(req.url, res.clone())
|
||||||
|
return res
|
||||||
|
} catch {
|
||||||
|
return hit ?? Response.error()
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -18,5 +18,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"]
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"],
|
||||||
|
"exclude": ["src/sw.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,46 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
// 自定义 SW:install 时逐个抓取资源并向页面广播进度(驱动首屏进度条)
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.ts',
|
||||||
|
injectRegister: false, // 注册由 src/pwa.ts 手动处理,避免插件的自动重载
|
||||||
|
includeAssets: ['favicon-48x48.png', 'apple-touch-icon-180x180.png'],
|
||||||
|
manifest: {
|
||||||
|
name: '狼人杀发牌器',
|
||||||
|
short_name: '狼人杀',
|
||||||
|
description: '离线可用的狼人杀发牌器',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
theme_color: '#1a1a2e',
|
||||||
|
background_color: '#1a1a2e',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
icons: [
|
||||||
|
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{
|
||||||
|
src: 'maskable-icon-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectManifest: {
|
||||||
|
// 预缓存应用外壳 + 全部牌图,保证彻底断网也能发牌
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,JPG,webmanifest}'],
|
||||||
|
maximumFileSizeToCacheInBytes: 1024 * 1024,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
},
|
},
|
||||||
|
|||||||