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:
OpenClaw Agent
2026-09-13 08:56:49 +08:00
parent 7c77323561
commit 6ef5922430
8 changed files with 134 additions and 0 deletions
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
View File
@@ -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');
+20
View File
@@ -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" }
]
}
+93
View File
@@ -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));
});