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
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { Button } from "./ui";
import { applyServiceWorkerUpdate, PWA_EVENT } from "../lib/pwa";
/**
* 装到手机/桌面以后,发版就不是"让用户重新输网址"了:
* 有新版本时在这里提一句,点"刷新"才换,不点就继续用手上的这版。
*
* 为什么不用自动刷新(registerType: "autoUpdate"):Vditor、图表这些是懒加载分片,
* 后台悄悄换掉之后,用户下一次点开某个面板就是 404 —— 编辑器直接白屏。
*/
export function UpdatePrompt() {
const [ready, setReady] = useState(false);
const [applying, setApplying] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const onReady = () => setReady(true);
window.addEventListener(PWA_EVENT.updateReady, onReady);
return () => window.removeEventListener(PWA_EVENT.updateReady, onReady);
}, []);
if (!ready || dismissed) return null;
return (
// 居中用 flex 包一层:animate-slide-up 的 keyframes 自带 transform,
// 会和 -translate-x-1/2 抢同一个属性,手机窄屏上卡片会被推出屏幕右侧。
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[70] flex justify-center px-4">
<div
role="status"
className="animate-slide-up pointer-events-auto w-full max-w-[26rem] rounded-xl border border-line-200 bg-ink-900 px-4 py-3 text-paper-50 shadow-pop"
>
<p className="text-sm">有新版了。刷新一下就用上,手头的输入会丢,先存了再点。</p>
<div className="mt-2 flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-paper-300 hover:text-paper-50"
onClick={() => setDismissed(true)}
>
稍后
</Button>
<Button
variant="primary"
size="sm"
disabled={applying}
onClick={() => {
setApplying(true);
void applyServiceWorkerUpdate();
}}
>
{applying ? "正在刷新…" : "刷新"}
</Button>
</div>
</div>
</div>
);
}