feat: 添加 PWA 支持 — 可安装到主屏幕、离线缓存、刘海屏适配

This commit is contained in:
2026-02-26 16:37:40 +08:00
parent 31003110e1
commit 20f63c67cb
10 changed files with 182 additions and 4 deletions
+4 -4
View File
@@ -33,10 +33,10 @@
- 抽中后打卡确认(拍照上传,形成回忆) - 抽中后打卡确认(拍照上传,形成回忆)
- "本周契约执行率" 统计 - "本周契约执行率" 统计
### PWA 支持 ### ~~PWA 支持~~(已完成)
- 添加 Web App Manifest,支持"添加到主屏幕" - ~~添加 Web App Manifest,支持"添加到主屏幕"~~
- Service Worker 离线缓存基础页面 - ~~Service Worker 离线缓存基础页面~~
- `viewport-fit=cover` 适配刘海屏 - ~~`viewport-fit=cover` 适配刘海屏~~
### 盲盒房间删除 / 退出 ### 盲盒房间删除 / 退出
- 房间创建者可删除房间(级联清理成员 & 想法) - 房间创建者可删除房间(级联清理成员 & 想法)
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+68
View File
@@ -0,0 +1,68 @@
const CACHE_NAME = "nowhatever-v1";
const PRECACHE_URLS = ["/", "/offline"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
// API calls: network-only
if (url.pathname.startsWith("/api/")) return;
// Static assets (_next/static, icons, fonts): cache-first
if (
url.pathname.startsWith("/_next/static/") ||
url.pathname.match(/\.(png|jpg|svg|ico|woff2?)$/)
) {
event.respondWith(
caches.match(request).then(
(cached) =>
cached ||
fetch(request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
})
)
);
return;
}
// HTML pages: network-first, fallback to cache, then offline page
event.respondWith(
fetch(request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
})
.catch(() => caches.match(request).then((cached) => cached || caches.match("/offline")))
);
});
+37
View File
@@ -0,0 +1,37 @@
import sharp from "sharp";
import { mkdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const publicDir = join(__dirname, "..", "public");
const ACCENT = "#10b981";
const BG_DARK = "#030712";
function buildSvg(size) {
const fontSize = Math.round(size * 0.32);
const radius = Math.round(size * 0.18);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${radius}" fill="${BG_DARK}"/>
<text x="50%" y="54%" text-anchor="middle" dominant-baseline="central"
font-family="system-ui,-apple-system,sans-serif" font-weight="700"
font-size="${fontSize}" fill="${ACCENT}">NW</text>
</svg>`;
}
const sizes = [192, 512];
for (const size of sizes) {
const svg = Buffer.from(buildSvg(size));
const out = join(publicDir, `icon-${size}x${size}.png`);
await sharp(svg).resize(size, size).png().toFile(out);
console.log(`${out}`);
}
const appleSvg = Buffer.from(buildSvg(180));
const appleOut = join(publicDir, "apple-touch-icon.png");
await sharp(appleSvg).resize(180, 180).png().toFile(appleOut);
console.log(`${appleOut}`);
console.log("\nPWA icons generated.");
+5
View File
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import "./globals.css"; import "./globals.css";
import GlobalUserBadge from "@/components/GlobalUserBadge"; import GlobalUserBadge from "@/components/GlobalUserBadge";
import ServiceWorkerRegistrar from "@/components/ServiceWorkerRegistrar";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -19,6 +20,8 @@ export const viewport: Viewport = {
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
userScalable: false, userScalable: false,
viewportFit: "cover",
themeColor: "#10b981",
}; };
const themeScript = `(function(){try{var t=localStorage.getItem("nowhatever-theme")||"system";var r=t;if(t==="system")r=window.matchMedia("(prefers-color-scheme:light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",r)}catch(e){}})()`; const themeScript = `(function(){try{var t=localStorage.getItem("nowhatever-theme")||"system";var r=t;if(t==="system")r=window.matchMedia("(prefers-color-scheme:light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",r)}catch(e){}})()`;
@@ -32,8 +35,10 @@ export default function RootLayout({
<html lang="zh-CN" suppressHydrationWarning> <html lang="zh-CN" suppressHydrationWarning>
<head> <head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} /> <script dangerouslySetInnerHTML={{ __html: themeScript }} />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head> </head>
<body className={`${geistSans.variable} font-sans antialiased`}> <body className={`${geistSans.variable} font-sans antialiased`}>
<ServiceWorkerRegistrar />
<GlobalUserBadge /> <GlobalUserBadge />
{children} {children}
</body> </body>
+32
View File
@@ -0,0 +1,32 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "NoWhatever — 别说随便",
short_name: "NoWhatever",
description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
start_url: "/",
display: "standalone",
background_color: "#030712",
theme_color: "#10b981",
orientation: "portrait",
icons: [
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { WifiOff } from "lucide-react";
export default function OfflinePage() {
return (
<div className="min-h-dvh flex flex-col items-center justify-center px-6 bg-background text-foreground">
<div className="w-16 h-16 rounded-2xl bg-surface flex items-center justify-center mb-6">
<WifiOff className="w-8 h-8 text-muted" />
</div>
<h1 className="text-xl font-bold text-heading mb-2"></h1>
<p className="text-secondary text-center mb-8">
</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-accent text-white rounded-xl font-medium active:scale-95 transition-transform"
>
</button>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { useEffect } from "react";
export default function ServiceWorkerRegistrar() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
}, []);
return null;
}