Add PWA install badge for desktop and tablet (manifest, SW, Add to Home Screen).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 21:57:39 +08:00
parent aca9d80e64
commit cb97a45051
14 changed files with 356 additions and 4 deletions
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import {
BeforeInstallPromptEvent,
isAppleTouchDevice,
isStandaloneDisplay,
} from "../pwa/install";
/**
* Desktop/tablet install affordance:
* - Chrome/Edge/Android: native install via beforeinstallprompt
* - iPad/iOS: tip to Add to Home Screen
* - Already installed: show 已安装标识
*/
export default function InstallBadge() {
const [standalone, setStandalone] = useState(isStandaloneDisplay);
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(
null,
);
const [tipOpen, setTipOpen] = useState(false);
const [busy, setBusy] = useState(false);
const apple = isAppleTouchDevice();
useEffect(() => {
const mq = window.matchMedia("(display-mode: standalone)");
const sync = () => setStandalone(isStandaloneDisplay());
mq.addEventListener?.("change", sync);
sync();
const onBip = (e: Event) => {
e.preventDefault();
setDeferred(e as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setDeferred(null);
setStandalone(true);
setTipOpen(false);
};
window.addEventListener("beforeinstallprompt", onBip);
window.addEventListener("appinstalled", onInstalled);
return () => {
mq.removeEventListener?.("change", sync);
window.removeEventListener("beforeinstallprompt", onBip);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
if (standalone) {
return (
<span className="install-badge installed" title="已安装为独立应用">
</span>
);
}
const canNativeInstall = Boolean(deferred);
async function onInstall() {
if (!deferred) {
setTipOpen((v) => !v);
return;
}
setBusy(true);
try {
await deferred.prompt();
await deferred.userChoice;
setDeferred(null);
} finally {
setBusy(false);
}
}
return (
<div className="install-wrap">
<button
type="button"
className="btn ghost install-btn"
disabled={busy}
onClick={() => void onInstall()}
title={
canNativeInstall
? "安装到电脑或平板"
: apple
? "添加到主屏幕(平板/手机)"
: "安装说明"
}
>
{canNativeInstall ? (busy ? "安装中…" : "安装应用") : "安装应用"}
</button>
{tipOpen && !canNativeInstall ? (
<div className="install-tip" role="dialog" aria-label="安装说明">
{apple ? (
<>
<p>iPad / iPhone Safari </p>
<p className="muted"></p>
</>
) : (
<>
<p>
Chrome / Edge
</p>
<p>
/
</p>
<p className="muted"> HTTPS localhost</p>
</>
)}
<button
type="button"
className="btn ghost"
onClick={() => setTipOpen(false)}
>
</button>
</div>
) : null}
</div>
);
}