diff --git a/app/globals.css b/app/globals.css index 2b5246e..20a1360 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,8 @@ @import "tw-animate-css"; @import "../vendor/shadcn-tailwind-4.13.0.css"; +.pwa-install{position:fixed;z-index:60;right:1rem;bottom:1rem;display:flex;align-items:center;gap:.75rem;max-width:min(28rem,calc(100vw - 2rem));padding:.8rem .9rem;color:#eff6ff;background:rgba(15,23,42,.94);border:1px solid rgba(147,197,253,.45);border-radius:.85rem;box-shadow:0 12px 30px rgba(15,23,42,.28);font-size:.875rem}.pwa-install button{flex:none;padding:.45rem .7rem;border-radius:.55rem;border:0;background:#60a5fa;color:#0f172a;font-weight:700;cursor:pointer}.pwa-ios{color:#bfdbfe;white-space:nowrap}@media(max-width:520px){.pwa-install{left:.75rem;right:.75rem;bottom:.75rem;flex-wrap:wrap}.pwa-ios{white-space:normal}} + @utility scrollbar-thin { scrollbar-width: thin; } diff --git a/app/layout.tsx b/app/layout.tsx index 34ad7ea..72091f4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ export const metadata: Metadata = { icons: { icon: "/favicon.svg", shortcut: "/favicon.svg", + apple: "/icon-192.png", }, manifest: "/manifest.webmanifest", applicationName: "大学英语六级 · 90天打卡", diff --git a/app/pwa-register.tsx b/app/pwa-register.tsx index efda7f1..38bc645 100644 --- a/app/pwa-register.tsx +++ b/app/pwa-register.tsx @@ -1,3 +1,23 @@ "use client"; -import { useEffect } from "react"; -export default function PwaRegister() { useEffect(() => { if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => undefined); }, []); return null; } +import { useEffect, useState } from "react"; + +type InstallPrompt = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }> }; + +export default function PwaRegister() { + const [prompt, setPrompt] = useState(null); + const [isIos, setIsIos] = useState(false); + + useEffect(() => { + if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" }).catch(() => undefined); + setIsIos(/iphone|ipad|ipod/i.test(navigator.userAgent)); + const receivePrompt = (event: Event) => { event.preventDefault(); setPrompt(event as InstallPrompt); }; + window.addEventListener("beforeinstallprompt", receivePrompt); + return () => window.removeEventListener("beforeinstallprompt", receivePrompt); + }, []); + + if (!prompt && !isIos) return null; + return
+ 把打卡站点安装到桌面,打开更快捷。 + {prompt ? : Safari 点“分享”→“添加到主屏幕”} +
; +} diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..7273a8d Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..1dc1a9c Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index aadb0e7..0fff5ca 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -8,5 +8,9 @@ "display": "standalone", "background_color": "#eef4ff", "theme_color": "#1d4ed8", - "icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }] + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }, + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] } diff --git a/public/sw.js b/public/sw.js index 5da3fc5..6c1499f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,10 +1,17 @@ -const CACHE = "cet6-shell-v1"; -const APP_SHELL = ["/", "/tasks", "/stats", "/review", "/manifest.webmanifest", "/favicon.svg"]; +const CACHE = "cet6-shell-v2"; +const APP_SHELL = ["/", "/manifest.webmanifest", "/favicon.svg", "/icon-192.png", "/icon-512.png"]; self.addEventListener("install", event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(APP_SHELL)).then(() => self.skipWaiting()))); -self.addEventListener("activate", event => event.waitUntil(self.clients.claim())); +self.addEventListener("activate", event => event.waitUntil( + caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()) +)); self.addEventListener("fetch", event => { const request = event.request; if (request.method !== "GET" || new URL(request.url).origin !== location.origin) return; if (request.url.includes("/api/")) return; - event.respondWith(fetch(request).then(response => { const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(request, copy)); return response; }).catch(() => caches.match(request).then(found => found || caches.match("/")))); + event.respondWith(fetch(request).then(response => { + if (response.ok && new URL(request.url).pathname.startsWith("/_next/")) { + const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(request, copy)); + } + return response; + }).catch(() => caches.match(request).then(found => found || caches.match("/")))); });