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">
|
||||
<head>
|
||||
<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" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"gen:icons": "node scripts/gen-icons.mjs",
|
||||
"compress:images": "node scripts/compress-images.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^2.1.8",
|
||||
"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 App from './App.vue'
|
||||
import './style.css'
|
||||
import { setupPWA } from './pwa'
|
||||
|
||||
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,
|
||||
"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 vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
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: {
|
||||
target: 'es2020',
|
||||
},
|
||||
|
||||