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
+35
View File
@@ -128,6 +128,41 @@ _DIST = resolve_frontend_dist()
if (_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
_ICONS = _DIST / "icons"
if _ICONS.is_dir():
app.mount("/icons", StaticFiles(directory=str(_ICONS)), name="icons")
def _dist_file(name: str) -> Path:
return _DIST / name
@app.get("/manifest.webmanifest")
async def web_manifest():
path = _dist_file("manifest.webmanifest")
if not path.exists():
raise HTTPException(status_code=404, detail="manifest missing")
return FileResponse(
path,
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)
@app.get("/sw.js")
async def service_worker():
path = _dist_file("sw.js")
if not path.exists():
raise HTTPException(status_code=404, detail="service worker missing")
return FileResponse(
path,
media_type="application/javascript",
headers={
"Cache-Control": "no-cache",
"Service-Worker-Allowed": "/",
},
)
@app.get("/")
async def index_page():
+18 -1
View File
@@ -2,7 +2,24 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="theme-color" content="#0b0e11" />
<meta name="color-scheme" content="dark" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="对冲系统" />
<meta name="application-name" content="对冲系统" />
<meta
name="description"
content="ETH 永续+期权对冲监控与策略控制台,支持电脑与平板安装为独立应用。"
/>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<title>OKX 自动化对冲系统</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "OKX 自动化对冲系统",
"short_name": "对冲系统",
"description": "ETH 永续+期权对冲监控与策略控制台",
"start_url": "/plan",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"orientation": "any",
"background_color": "#0b0e11",
"theme_color": "#0b0e11",
"lang": "zh-CN",
"categories": ["finance", "productivity"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+38
View File
@@ -0,0 +1,38 @@
/* Minimal service worker: makes the app installable on desktop & tablet. */
const CACHE = "eth-hedge-shell-v1";
const PRECACHE = ["/", "/plan", "/manifest.webmanifest", "/icons/icon-192.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))),
).then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Never cache API / health / websocket-ish paths
if (url.pathname.startsWith("/api") || url.pathname === "/health") return;
event.respondWith(
fetch(req)
.then((res) => {
if (res.ok && (url.pathname.startsWith("/assets") || url.pathname === "/")) {
const copy = res.clone();
caches.open(CACHE).then((cache) => cache.put(req, copy));
}
return res;
})
.catch(() => caches.match(req).then((hit) => hit || caches.match("/"))),
);
});
+2
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { clearSession, getToken, getUsername } from "./api/client";
import InstallBadge from "./components/InstallBadge";
import LoginPage from "./pages/Login";
import PlanPage from "./pages/Plan";
import TradesPage from "./pages/Trades";
@@ -14,6 +15,7 @@ function Shell({ children }: { children: ReactNode }) {
<header className="topnav">
<div className="topnav-side left">
<div className="brand">OKX </div>
<InstallBadge />
</div>
<nav className="topnav-center">
<NavLink to="/plan" className={({ isActive }) => (isActive ? "active" : "")}>
+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>
);
}
+3
View File
@@ -2,8 +2,11 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { registerServiceWorker } from "./pwa/install";
import "./styles/app.css";
registerServiceWorker();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
+6 -2
View File
@@ -1,6 +1,7 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { login, setSession } from "../api/client";
import InstallBadge from "../components/InstallBadge";
export default function LoginPage() {
const nav = useNavigate();
@@ -27,8 +28,11 @@ export default function LoginPage() {
return (
<div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}>
<h1>OKX </h1>
<p></p>
<div className="login-title-row">
<h1>OKX </h1>
<InstallBadge />
</div>
<p> · /</p>
{err ? <div className="err">{err}</div> : null}
<div className="field">
<label htmlFor="user"></label>
+34
View File
@@ -0,0 +1,34 @@
/** PWA install helpers for desktop + tablet browsers. */
export type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};
export function isStandaloneDisplay(): boolean {
if (typeof window === "undefined") return false;
const mq = window.matchMedia("(display-mode: standalone)");
if (mq.matches) return true;
// iPad / iOS Safari home-screen launch
const nav = window.navigator as Navigator & { standalone?: boolean };
return Boolean(nav.standalone);
}
export function isAppleTouchDevice(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
const iOS = /iPad|iPhone|iPod/.test(ua);
// iPadOS 13+ may report as Macintosh with touch
const iPadOs =
navigator.platform === "MacIntel" && (navigator.maxTouchPoints || 0) > 1;
return iOS || iPadOs;
}
export function registerServiceWorker(): void {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) return;
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {
/* ignore: HTTP/dev without SW is fine */
});
});
}
+68 -1
View File
@@ -89,6 +89,65 @@ input {
text-overflow: ellipsis;
}
.install-wrap {
position: relative;
flex-shrink: 0;
}
.install-btn {
font-size: 12px;
padding: 4px 10px;
border: 1px solid var(--line);
color: var(--accent);
}
.install-badge {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
}
.install-badge.installed {
color: #0b0e11;
background: var(--accent);
}
.install-tip {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 30;
width: min(320px, calc(100vw - 24px));
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg-elev);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.install-tip p {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.5;
color: var(--text);
}
.install-tip p.muted,
.install-tip .muted {
color: var(--muted);
}
@media (max-width: 900px) {
.topnav-side.left .brand {
max-width: 9em;
}
}
.topnav a {
color: var(--muted);
text-decoration: none;
@@ -340,10 +399,18 @@ input {
}
.login-box h1 {
margin: 0 0 6px;
margin: 0;
font-size: 22px;
}
.login-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.login-box p {
margin: 0 0 20px;
color: var(--muted);