PWA:可安装 + 离线可用(manifest/SW/图标)

- manifest.webmanifest:standalone、theme #312E81、start_url /?source=pwa、4 图标(any+maskable)
- sw.js v2:预缓存页面骨架 11 项;导航/静态 network-first,离线回缓存(ignoreSearch 兜 ?v= 指纹);/api/* 与 /mcp/* 一律放行不缓存
- index.html:manifest link、theme-color、apple-touch-icon、iOS meta、onMounted 注册 SW(HTTP 裸访问自动降级)
- icons/:Chromium 渲染 SVG 生成 192/512 any + maskable + apple-touch-icon(png/gen_icons.js 可重跑)
- 验证:npm test 72/72;png/verify_pwa.js 11 项全 PASS(SW 注册/预缓存/离线启动/manifest 解析)
This commit is contained in:
OpenClaw Agent
2026-09-13 09:05:47 +08:00
parent dc9c744df1
commit 9702d1949a
2 changed files with 99 additions and 2 deletions
+95
View File
@@ -0,0 +1,95 @@
// PWA 验证:SW 注册、预缓存、离线启动、可安装性要素,一次跑完。
// 跑法:node png/verify_pwa.js (真实 Chromium,验证后不清理产物,截图留证据)
const puppeteer = require('/root/.openclaw/workspace/web_tasks/node_modules/puppeteer');
const URL_BASE = 'https://daka.kory.kdns.fr';
const PROFILE = '/tmp/wht-pwa-verify';
(async () => {
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium',
headless: 'new',
userDataDir: PROFILE,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844, isMobile: true, deviceScaleFactor: 2 });
const results = [];
const ok = (name, cond, detail = '') => {
results.push(`${cond ? '✅' : '❌'} ${name}${detail ? ' — ' + detail : ''}`);
};
// ---- 1. 首次在线加载 + SW 注册 ----
await page.goto(URL_BASE + '/', { waitUntil: 'networkidle2', timeout: 45000 });
await new Promise(r => setTimeout(r, 3500)); // 给 SW install 留时间
const swState = await page.evaluate(async () => {
if (!('serviceWorker' in navigator)) return { supported: false };
const reg = await navigator.serviceWorker.ready;
const keys = await caches.keys();
let precacheCount = 0;
for (const k of keys) {
if (k.startsWith('wht-precache')) {
const c = await caches.open(k);
precacheCount = (await c.keys()).length;
}
}
return {
supported: true,
script: reg.active ? reg.active.scriptURL : null,
scope: reg.scope,
controlled: !!navigator.serviceWorker.controller,
cacheKeys: keys,
precacheCount
};
});
ok('SW 支持(安全上下文)', swState.supported === true);
ok('SW 注册成功', !!swState.script, swState.script || '');
ok('SW 作用域 = /', swState.scope === URL_BASE + '/', swState.scope || '');
ok('页面已被 SW 控制', swState.controlled === true);
ok('预缓存条目=11', swState.precacheCount === 11, '实际 ' + swState.precacheCount);
// ---- 2. manifest 可解析 ----
const manifest = await page.evaluate(async () => {
const res = await fetch('/manifest.webmanifest');
const j = await res.json();
return { ok: res.ok, name: j.name, icons: (j.icons || []).length, display: j.display };
});
ok('manifest 200 且可解析', manifest.ok === true && manifest.icons >= 4, JSON.stringify(manifest));
// ---- 3. 离线重启:骨架必须能起来 ----
await page.setOfflineMode(true);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 20000 });
await new Promise(r => setTimeout(r, 3500));
const offline = await page.evaluate(() => ({
vue: typeof globalThis.Vue !== 'undefined',
payroll: typeof globalThis.WHTPayroll !== 'undefined',
bootHealVisible: (() => {
const el = document.getElementById('boot-heal');
return !!el && el.style.display !== 'none' && getComputedStyle(el).display !== 'none';
})(),
appText: (document.querySelector('#app') || document.body).innerText.slice(0, 60)
}));
ok('离线:Vue 可用', offline.vue === true);
ok('离线:薪酬模块可用', offline.payroll === true);
ok('离线:无 boot 恢复卡', offline.bootHealVisible === false);
ok('离线:页面有实际内容', offline.appText.trim().length > 20, JSON.stringify(offline.appText.slice(0, 40)));
await page.screenshot({ path: '/root/.openclaw/workspace/work-hours-tracker/png/pwa_offline_boot.png' });
// ---- 4. 恢复在线,确认导航回源 ----
await page.setOfflineMode(false);
await page.reload({ waitUntil: 'networkidle2', timeout: 45000 });
const backOnline = await page.evaluate(() => typeof globalThis.Vue !== 'undefined');
ok('恢复在线:页面正常', backOnline === true);
await page.screenshot({ path: '/root/.openclaw/workspace/work-hours-tracker/png/pwa_online.png' });
await browser.close();
console.log(results.join('\n'));
const failed = results.filter(r => r.startsWith('❌')).length;
console.log(failed === 0 ? 'PWA-VERIFY-ALL-PASS' : `PWA-VERIFY-FAILED x${failed}`);
process.exit(failed === 0 ? 0 : 1);
})().catch(e => { console.error('FAILED:', e.message); process.exit(1); });
+4 -2
View File
@@ -15,7 +15,7 @@
* vendor ?v= 指纹变化会触发重下,PRECACHE 键值同步更新即可。旧缓存条目
* activate 时统一清理,不做遗留兜底。
*/
const VERSION = 'v1';
const VERSION = 'v2';
const PRECACHE = `wht-precache-${VERSION}`;
const RUNTIME = `wht-runtime-${VERSION}`;
@@ -66,7 +66,9 @@ async function networkFirst(request, cacheName) {
if (res && res.ok) cache.put(request, res.clone());
return res;
} catch (e) {
const cached = await cache.match(request, { ignoreSearch: request.mode === 'navigate' });
// ignoreSearch:离线时 vendor 实际请求带 ?v= 指纹,预缓存里是不带指纹的裸路径;
// 不吞掉查询串就永远匹配不上,冷离线直接白屏。指纹内容不一致的风险可接受(离线本来就拿不到新版)。
const cached = await cache.match(request, { ignoreSearch: true });
if (cached) return cached;
// 导航请求最终兜底:预缓存的 index.html(ignoreSearch 吞掉 ?source=pwa 等查询)
const pre = await caches.open(PRECACHE);