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
This commit is contained in:
Fam Zheng
2026-05-25 18:44:46 +01:00
parent 1a62ec6658
commit bcf99ec454
47 changed files with 5755 additions and 21 deletions
+6 -1
View File
@@ -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>
+5386 -17
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -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"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 734 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 722 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 714 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 639 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

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')
+2
View File
@@ -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()
+97
View File
@@ -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)
})
})
}
+112
View File
@@ -0,0 +1,112 @@
/// <reference lib="webworker" />
// 自定义 Service WorkerinjectManifest 模式)。
// 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()
}
})(),
)
})
+2 -1
View File
@@ -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"]
} }
+39 -1
View File
@@ -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',
}, },