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
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { Download, X } from "./icons";
import { isStandalone } from "../lib/pwa";
/** Chrome/Edge/安卓上那个「安装」事件,TS 的 DOM 库里还没有类型。 */
interface InstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const DISMISS_KEY = "dadao.install-hint-dismissed";
function isIos(): boolean {
const ua = window.navigator.userAgent;
return /iPad|iPhone|iPod/.test(ua) || (ua.includes("Macintosh") && "ontouchend" in document);
}
/**
* 把达道Task装到桌面/主屏。
*
* 有安装事件就自己弹一个按钮(浏览器自己那套横幅经常被无视);
* iOS 没有安装事件,只能告诉用户「分享 → 添加到主屏幕」这一步。
* 从主屏启动(standalone)或者用户关过之后就不再出现。
*/
export function InstallHint() {
const [promptEvent, setPromptEvent] = useState<InstallPromptEvent | null>(null);
const [hidden, setHidden] = useState(() => localStorage.getItem(DISMISS_KEY) === "1");
const [installing, setInstalling] = useState(false);
useEffect(() => {
const onPrompt = (event: Event) => {
event.preventDefault(); // 拦住浏览器自带的横幅,换成我们自己的入口
setPromptEvent(event as InstallPromptEvent);
};
const onInstalled = () => setPromptEvent(null);
window.addEventListener("beforeinstallprompt", onPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
if (hidden || isStandalone()) return null;
const ios = isIos();
// 桌面 Safari、Firefox 既不给安装事件,也没有添加主屏,不必打扰
if (!promptEvent && !ios) return null;
function dismiss() {
localStorage.setItem(DISMISS_KEY, "1");
setHidden(true);
}
return (
<div className="mt-4 flex items-start gap-3 rounded-xl border border-primary-300/40 bg-primary-50/50 px-4 py-3">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary-900 text-paper-50">
<Download size={15} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-ink-800">把达道Task装到桌面</p>
<p className="mt-1 text-2xs leading-relaxed text-ink-500">
{promptEvent
? "装好之后是一个独立窗口,不用再敲网址,也不会被浏览器标签页挤掉。"
: "在 Safari 里点底部的「分享」,选「添加到主屏幕」,就会和 App 一样有图标。"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{promptEvent ? (
<button
className="rounded-lg bg-ink-900 px-3 py-1.5 text-xs font-medium text-paper-50 transition hover:bg-ink-800 disabled:opacity-50"
disabled={installing}
onClick={async () => {
setInstalling(true);
try {
await promptEvent.prompt();
await promptEvent.userChoice;
} catch {
// 用户取消或浏览器拒绝:什么都不用说
} finally {
setInstalling(false);
setPromptEvent(null);
}
}}
>
安装
</button>
) : null}
<button
aria-label="不再提示安装"
className="rounded-lg p-1.5 text-ink-400 transition hover:bg-paper-200 hover:text-ink-600"
onClick={dismiss}
>
<X size={14} />
</button>
</div>
</div>
);
}