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:
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
+21
@@ -4,6 +4,15 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>工时薪酬助手</title>
|
||||
<meta name="theme-color" content="#312E81" />
|
||||
<!-- PWA 可安装三件套:manifest / iOS 专属图标 / SW 注册(见 setup 末尾 boot 钩子)。
|
||||
只在 HTTPS(或 localhost)安全上下文生效,HTTP 裸访问自动降级为普通网页,不影响现有行为。 -->
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<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" />
|
||||
<meta name="apple-mobile-web-app-title" content="工时薪酬" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234F46E5'/%3E%3Cpath d='M16 8v8l5 3' stroke='white' stroke-width='2.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E" />
|
||||
<!-- 本地依赖,不走 CDN:
|
||||
原来用 cdn.tailwindcss.com 是运行时 JIT 编译器,它必须先把页面扫一遍再注入样式,
|
||||
@@ -1955,6 +1964,18 @@ createApp({
|
||||
}
|
||||
});
|
||||
|
||||
// PWA:注册 Service Worker。放在 onMounted 里跑:
|
||||
// 1) 不阻塞首次启动;2) 与启动守卫天然隔离 —— SW 注册失败绝不影响页面本身;
|
||||
// 3) verify_payroll.js / app-harness 的 document 桩件没有 serviceWorker 字段,onMounted 里可安全跳过。
|
||||
// HTTPS 才有 navigator.serviceWorker,HTTP 裸访问自动跳过,页面退化为普通网页。
|
||||
onMounted(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch((e) => {
|
||||
console.warn('[pwa] SW 注册失败(不影响使用):', e && e.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(settings, () => {
|
||||
localStorage.setItem('work_settings', JSON.stringify({ ...settings }));
|
||||
if (!suppressSync && window.WHTSync) window.WHTSync.markDirty('settings');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "工时薪酬助手",
|
||||
"short_name": "工时薪酬",
|
||||
"description": "工时排班记账与多周期薪酬核算,本地优先,离线可用。",
|
||||
"lang": "zh-CN",
|
||||
"dir": "ltr",
|
||||
"id": "/",
|
||||
"start_url": "/?source=pwa",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#312E81",
|
||||
"background_color": "#F8FAFC",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -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