feat(web): Web 装到桌面(PWA),带安装引导与发版提示
CI / backend (push) Canceled after 0s

用 vite-plugin-pwa 生成 manifest 与 Service Worker,不自己写缓存逻辑。

- 装到桌面:manifest 名字/主题色/三个图标(any + maskable + apple-touch-icon),
  图标和页头、安卓桌面是同一枚;首页底部给安装引导,能装就弹「安装」,
  iOS 给「分享 → 添加到主屏幕」,关掉记在 localStorage
- 发版提示:registerType=prompt,收到新版本只在底部提示,点「刷新」才换。
  故意不自动刷新——Vditor、图表是懒加载分片,后台换 chunk 会让面板 404
- 缓存边界:只预缓存页面骨架(约 0.8MB),/api/ 一律 NetworkOnly,
  离线宁可报错也不端旧数据;/vditor/ 走 CacheFirst,不拖慢首次安装
- 顺手修:UpdatePrompt 居中改用 flex 包一层,animate-slide-up 的 keyframes
  自带 transform,会和 -translate-x-1/2 抢属性,手机窄屏会把卡片推出屏幕

验证:web/e2e/pwa.mjs 19 项全绿(manifest/SW 激活/预缓存/安装引导/iOS 文案/
发版提示/断网仍能打开);tsc 通过、vite build 通过。

已知限制:Service Worker 只在 https 或 localhost 注册,生产入口还是 http,
所以内网 IP 上装不了——需要域名 + 证书反代或隧道,已写进 DEPLOY.md 第 6 节。
This commit is contained in:
Steven
2026-10-31 04:33:43 +08:00
parent 370d8923c8
commit 483b554c3c
18 changed files with 4798 additions and 26 deletions
+54
View File
@@ -0,0 +1,54 @@
/**
* Service Worker 的注册与升级。
*
* 只做三件事:生产环境注册、把"有新版本"和"可以离线用了"两个事件抛给界面、
* 以及提供统一的一个"现在就换新版本"入口。注册本身交给 vite-plugin-pwa 生成的
* `virtual:pwa-register`,这里不重复实现缓存逻辑。
*/
type ApplyUpdate = (reloadPage?: boolean) => Promise<void>;
let applyUpdate: ApplyUpdate | null = null;
/** 界面用的事件名:有新版可换、首次离线可用。 */
export const PWA_EVENT = {
updateReady: "dadao:pwa-update-ready",
offlineReady: "dadao:pwa-offline-ready",
} as const;
export function startServiceWorker(): void {
// 开发环境没有 SW(devOptions.enabled = false),动态引入免得无谓地打包
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
void import("virtual:pwa-register")
.then(({ registerSW }) => {
applyUpdate = registerSW({
immediate: true,
onNeedRefresh() {
window.dispatchEvent(new Event(PWA_EVENT.updateReady));
},
onOfflineReady() {
window.dispatchEvent(new Event(PWA_EVENT.offlineReady));
},
});
})
.catch(() => {
// 注册失败不影响正常使用:下次打开页面还会再试一次
});
}
/** 用户点了"刷新":让新 SW 立刻接管并重载页面。 */
export async function applyServiceWorkerUpdate(): Promise<void> {
if (applyUpdate) {
await applyUpdate(true);
return;
}
window.location.reload();
}
/** 这个页面是不是从主屏图标启动的(standalone)。 */
export function isStandalone(): boolean {
return (
window.matchMedia?.("(display-mode: standalone)").matches === true ||
(window.navigator as { standalone?: boolean }).standalone === true
);
}