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():
|
||||
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
@@ -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.
@@ -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 { 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" : "")}>
|
||||
|
||||
@@ -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 { 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user