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:
@@ -128,6 +128,41 @@ _DIST = resolve_frontend_dist()
|
|||||||
if (_DIST / "assets").is_dir():
|
if (_DIST / "assets").is_dir():
|
||||||
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
|
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("/")
|
@app.get("/")
|
||||||
async def index_page():
|
async def index_page():
|
||||||
|
|||||||
+18
-1
@@ -2,7 +2,24 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<title>OKX 自动化对冲系统</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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.
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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("/"))),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { clearSession, getToken, getUsername } from "./api/client";
|
import { clearSession, getToken, getUsername } from "./api/client";
|
||||||
|
import InstallBadge from "./components/InstallBadge";
|
||||||
import LoginPage from "./pages/Login";
|
import LoginPage from "./pages/Login";
|
||||||
import PlanPage from "./pages/Plan";
|
import PlanPage from "./pages/Plan";
|
||||||
import TradesPage from "./pages/Trades";
|
import TradesPage from "./pages/Trades";
|
||||||
@@ -14,6 +15,7 @@ function Shell({ children }: { children: ReactNode }) {
|
|||||||
<header className="topnav">
|
<header className="topnav">
|
||||||
<div className="topnav-side left">
|
<div className="topnav-side left">
|
||||||
<div className="brand">OKX 自动化对冲系统</div>
|
<div className="brand">OKX 自动化对冲系统</div>
|
||||||
|
<InstallBadge />
|
||||||
</div>
|
</div>
|
||||||
<nav className="topnav-center">
|
<nav className="topnav-center">
|
||||||
<NavLink to="/plan" className={({ isActive }) => (isActive ? "active" : "")}>
|
<NavLink to="/plan" className={({ isActive }) => (isActive ? "active" : "")}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { registerServiceWorker } from "./pwa/install";
|
||||||
import "./styles/app.css";
|
import "./styles/app.css";
|
||||||
|
|
||||||
|
registerServiceWorker();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { login, setSession } from "../api/client";
|
import { login, setSession } from "../api/client";
|
||||||
|
import InstallBadge from "../components/InstallBadge";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
@@ -27,8 +28,11 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="login-wrap">
|
<div className="login-wrap">
|
||||||
<form className="login-box" onSubmit={onSubmit}>
|
<form className="login-box" onSubmit={onSubmit}>
|
||||||
|
<div className="login-title-row">
|
||||||
<h1>OKX 自动化对冲系统</h1>
|
<h1>OKX 自动化对冲系统</h1>
|
||||||
<p>模拟盘登录</p>
|
<InstallBadge />
|
||||||
|
</div>
|
||||||
|
<p>模拟盘登录 · 支持电脑/平板安装为应用</p>
|
||||||
{err ? <div className="err">{err}</div> : null}
|
{err ? <div className="err">{err}</div> : null}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="user">用户名</label>
|
<label htmlFor="user">用户名</label>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -89,6 +89,65 @@ input {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.topnav a {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -340,10 +399,18 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-box h1 {
|
.login-box h1 {
|
||||||
margin: 0 0 6px;
|
margin: 0;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.login-box p {
|
.login-box p {
|
||||||
margin: 0 0 20px;
|
margin: 0 0 20px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user