pwa: 可安装+离线可用(manifest/sw/图标/接线)
- manifest.webmanifest: standalone, start_url /?source=pwa, theme #312E81, 4 icons - sw.js v1: 预缓存骨架(11项), 导航+静态 network-first, /api/* 与 /mcp/* 不缓存 - icons: 192/512 any + maskable + apple-touch-icon - 实测: SW activated+controlling, installabilityErrors=[], 离线重载骨架正常渲染
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 工时薪酬助手 — Service Worker
|
||||
*
|
||||
* 职责(刻意收得很窄):
|
||||
* 1. 预缓存页面骨架(index.html / sync.js / payroll.js / vendor 两件 / manifest / 图标),
|
||||
* 保证离线或服务器彻底挂掉时页面能完整启动。
|
||||
* 2. 导航请求走 network-first + 离线兜底 index.html:在线时永远拿最新版,
|
||||
* 离线时用预缓存的骨架(本地记账照常,数据在 localStorage,与 SW 无关)。
|
||||
* 3. 静态资源 network-first:在线优先最新(no-cache 本来就要求「用前先问」),
|
||||
* 网络失败才回缓存 —— SW 不改变现有缓存策略,只补「彻底断网」这一层。
|
||||
* 4. /api/* 与 /mcp/sse 一律不碰:同步引擎自己有超时与 offline 处理,
|
||||
* SW 绝不缓存 API 响应,避免「读到旧数据以为同步成功」的假象。
|
||||
*
|
||||
* 版本升级:静态资源 no-cache+ETag,导航每次都回源,新 index.html 引用的
|
||||
* vendor ?v= 指纹变化会触发重下,PRECACHE 键值同步更新即可。旧缓存条目
|
||||
* activate 时统一清理,不做遗留兜底。
|
||||
*/
|
||||
const VERSION = 'v1';
|
||||
const PRECACHE = `wht-precache-${VERSION}`;
|
||||
const RUNTIME = `wht-runtime-${VERSION}`;
|
||||
|
||||
// 页面骨架:缺一个离线就是白屏,全部预缓存。
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/sync.js',
|
||||
'/shared/payroll.js',
|
||||
'/vendor/vue.global.prod.js',
|
||||
'/vendor/tailwind.css',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-maskable-192.png',
|
||||
'/icons/icon-maskable-512.png'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(PRECACHE)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
// 骨架里任何一个取不到就放弃安装:宁可没有 SW 也不能装出半个能用的
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(
|
||||
keys.filter((k) => k !== PRECACHE && k !== RUNTIME)
|
||||
.map((k) => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data === 'SKIP_WAITING') self.skipWaiting();
|
||||
});
|
||||
|
||||
/** network-first:先在线取,失败回运行时缓存,再失败回预缓存。 */
|
||||
async function networkFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
try {
|
||||
const res = await fetch(request);
|
||||
if (res && res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
} catch (e) {
|
||||
const cached = await cache.match(request, { ignoreSearch: request.mode === 'navigate' });
|
||||
if (cached) return cached;
|
||||
// 导航请求最终兜底:预缓存的 index.html(ignoreSearch 吞掉 ?source=pwa 等查询)
|
||||
const pre = await caches.open(PRECACHE);
|
||||
const fallback = await pre.match('/index.html', { ignoreSearch: true });
|
||||
if (fallback) return fallback;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// 只管同源 GET;跨域与 /api/*、/mcp/* 直接放行给网络
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (event.request.method !== 'GET') return;
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/mcp/')) return;
|
||||
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(event.request, RUNTIME));
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(networkFirst(event.request, RUNTIME));
|
||||
});
|
||||
Reference in New Issue
Block a user