diff --git a/README.md b/README.md index 75c5c29..468f414 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - 综合试卷:把纪念试卷做成网页,并保留 Word 原卷下载。 - 留言墙:接入 Twikoo 后可在页面中留言。 - 全站搜索:通过 Typesense 搜索并筛选时间线、照片、文章和同学公开资料。 +- PWA:支持添加到主屏幕,并离线访问站点外壳及已浏览的同源内容。 ## 技术栈 @@ -37,6 +38,8 @@ npm run preview # 预览构建结果 npm run search:sync # 将公开内容同步到 Typesense ``` +`npm run build` 会在 Astro 构建完成后生成带内容版本号的 Service Worker,并预缓存页面、样式、脚本、应用图标和首页主视觉。照片等大体积资源采用访问后缓存,第三方接口始终走网络。 + ## Typesense 全站搜索 复制 `.env.example` 中的配置到本地 `.env`、`.env.local` 或部署平台的环境变量。`npm run search:sync` 会自动读取这两个本地文件,且 `.env.local` 中的同名配置优先: diff --git a/package.json b/package.json index d856641..6d018b3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "astro dev", - "build": "astro build", + "build": "astro build && node scripts/build-pwa.mjs", "preview": "astro preview", "search:key": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/create-typesense-search-key.mjs", "search:sync": "node --env-file-if-exists=.env --env-file-if-exists=.env.local --import tsx scripts/sync-typesense.ts" diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..e6433c2 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/pwa-192.png b/public/icons/pwa-192.png new file mode 100644 index 0000000..f44742f Binary files /dev/null and b/public/icons/pwa-192.png differ diff --git a/public/icons/pwa-512.png b/public/icons/pwa-512.png new file mode 100644 index 0000000..9d2ee92 Binary files /dev/null and b/public/icons/pwa-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..67c21eb --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,44 @@ +{ + "id": "/", + "name": "2024届612班纪念网站", + "short_name": "612班", + "description": "保存2024届612班共同走过的校园时光,也记录毕业后的我们。", + "lang": "zh-CN", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#fffdf7", + "theme_color": "#376d5a", + "categories": ["education", "social"], + "icons": [ + { + "src": "/icons/pwa-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/pwa-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "三年时间线", + "short_name": "时间线", + "url": "/timeline/" + }, + { + "name": "照片墙", + "short_name": "照片", + "url": "/gallery/" + }, + { + "name": "如今的我们", + "short_name": "同学", + "url": "/people/" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..ccdda94 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,103 @@ +const CACHE_PREFIX = "class-612-pwa"; +const CACHE_VERSION = "dev"; // __CACHE_VERSION__ +const PRECACHE_CACHE = `${CACHE_PREFIX}-precache-${CACHE_VERSION}`; +const RUNTIME_CACHE = `${CACHE_PREFIX}-runtime-${CACHE_VERSION}`; + +const PRECACHE_URLS = [ + /* __PRECACHE_URLS_START__ */ + "/", + "/offline/", + "/manifest.webmanifest", + "/icons/pwa-192.png", + "/icons/pwa-512.png", + "/icons/apple-touch-icon.png" + /* __PRECACHE_URLS_END__ */ +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(PRECACHE_CACHE) + .then((cache) => cache.addAll(PRECACHE_URLS)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((cacheNames) => + Promise.all( + cacheNames + .filter( + (cacheName) => + cacheName.startsWith(`${CACHE_PREFIX}-`) && + cacheName !== PRECACHE_CACHE && + cacheName !== RUNTIME_CACHE + ) + .map((cacheName) => caches.delete(cacheName)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +const cacheSuccessfulResponse = async (cacheName, request, response) => { + if (response.ok && response.type === "basic") { + const cache = await caches.open(cacheName); + await cache.put(request, response.clone()); + } + + return response; +}; + +const networkFirstNavigation = async (request) => { + try { + const response = await fetch(request); + return cacheSuccessfulResponse(RUNTIME_CACHE, request, response); + } catch { + return ( + (await caches.match(request, { ignoreSearch: true })) ?? + (await caches.match("/offline/")) ?? + (await caches.match("/")) + ); + } +}; + +const cacheFirst = async (request) => { + const cachedResponse = await caches.match(request, { ignoreSearch: true }); + + if (cachedResponse) { + return cachedResponse; + } + + const response = await fetch(request); + return cacheSuccessfulResponse(RUNTIME_CACHE, request, response); +}; + +self.addEventListener("fetch", (event) => { + const { request } = event; + + if (request.method !== "GET" || request.headers.has("range")) { + return; + } + + const url = new URL(request.url); + + if (url.origin !== self.location.origin) { + return; + } + + if (request.mode === "navigate") { + event.respondWith(networkFirstNavigation(request)); + return; + } + + if ( + ["style", "script", "font", "image"].includes(request.destination) || + url.pathname === "/manifest.webmanifest" + ) { + event.respondWith(cacheFirst(request)); + } +}); diff --git a/scripts/build-pwa.mjs b/scripts/build-pwa.mjs new file mode 100644 index 0000000..8bf610e --- /dev/null +++ b/scripts/build-pwa.mjs @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = fileURLToPath(new URL("../", import.meta.url)); +const distRoot = path.join(projectRoot, "dist"); +const serviceWorkerPath = path.join(distRoot, "sw.js"); +const precacheExtensions = new Set([".html", ".css", ".js", ".webmanifest"]); +const precacheAssets = new Set([ + "assets/campus-hero.png", + "icons/apple-touch-icon.png", + "icons/pwa-192.png", + "icons/pwa-512.png" +]); + +const listFiles = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const entryPath = path.join(directory, entry.name); + return entry.isDirectory() ? listFiles(entryPath) : entryPath; + }) + ); + + return files.flat(); +}; + +const toRelativePath = (filePath) => + path.relative(distRoot, filePath).split(path.sep).join("/"); + +const toPublicUrl = (relativePath) => { + if (relativePath === "index.html") { + return "/"; + } + + if (relativePath.endsWith("/index.html")) { + return `/${relativePath.slice(0, -"index.html".length)}`; + } + + return `/${relativePath}`; +}; + +const replaceBetweenMarkers = (source, startMarker, endMarker, replacement) => { + const startIndex = source.indexOf(startMarker); + const endIndex = source.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + throw new Error(`Service Worker 缺少构建标记:${startMarker} / ${endMarker}`); + } + + const contentStart = startIndex + startMarker.length; + return `${source.slice(0, contentStart)}\n${replacement}\n ${source.slice(endIndex)}`; +}; + +const allFiles = await listFiles(distRoot); +const precacheFiles = allFiles + .map((filePath) => ({ filePath, relativePath: toRelativePath(filePath) })) + .filter( + ({ relativePath }) => + relativePath !== "sw.js" && + (precacheExtensions.has(path.extname(relativePath)) || precacheAssets.has(relativePath)) + ) + .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); + +const precacheUrls = [...new Set(precacheFiles.map(({ relativePath }) => toPublicUrl(relativePath)))].sort( + (left, right) => (left === "/" ? -1 : right === "/" ? 1 : left.localeCompare(right)) +); + +const versionHash = createHash("sha256"); +for (const { filePath, relativePath } of precacheFiles) { + versionHash.update(relativePath); + versionHash.update(await readFile(filePath)); +} + +const cacheVersion = versionHash.digest("hex").slice(0, 12); +const startMarker = "/* __PRECACHE_URLS_START__ */"; +const endMarker = "/* __PRECACHE_URLS_END__ */"; +const urlLines = precacheUrls.map((url, index) => { + const suffix = index === precacheUrls.length - 1 ? "" : ","; + return ` ${JSON.stringify(url)}${suffix}`; +}); + +let serviceWorker = await readFile(serviceWorkerPath, "utf8"); +serviceWorker = serviceWorker.replace( + /const CACHE_VERSION = "dev"; \/\/ __CACHE_VERSION__/, + `const CACHE_VERSION = ${JSON.stringify(cacheVersion)}; // __CACHE_VERSION__` +); +serviceWorker = replaceBetweenMarkers(serviceWorker, startMarker, endMarker, urlLines.join("\n")); + +await writeFile(serviceWorkerPath, serviceWorker, "utf8"); +console.log(`[pwa] 已生成 ${precacheUrls.length} 项预缓存,版本 ${cacheVersion}`); diff --git a/scripts/create-pwa-icons.ps1 b/scripts/create-pwa-icons.ps1 new file mode 100644 index 0000000..d2c593e --- /dev/null +++ b/scripts/create-pwa-icons.ps1 @@ -0,0 +1,77 @@ +param( + [string]$ImportSource +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$projectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path +$sourceDirectory = Join-Path $projectRoot 'src\assets' +$sourcePath = Join-Path $sourceDirectory 'pwa-icon-source.png' +$iconDirectory = Join-Path $projectRoot 'public\icons' + +New-Item -ItemType Directory -Path $sourceDirectory -Force | Out-Null +New-Item -ItemType Directory -Path $iconDirectory -Force | Out-Null + +if ($ImportSource) { + $resolvedImportSource = (Resolve-Path -LiteralPath $ImportSource).Path + Copy-Item -LiteralPath $resolvedImportSource -Destination $sourcePath -Force +} + +if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "找不到图标源文件:$sourcePath" +} + +Add-Type -AssemblyName System.Drawing + +function Export-SquareIcon { + param( + [Parameter(Mandatory)] + [System.Drawing.Image]$SourceImage, + [Parameter(Mandatory)] + [int]$Size, + [Parameter(Mandatory)] + [string]$OutputPath + ) + + $side = [Math]::Min($SourceImage.Width, $SourceImage.Height) + $sourceX = [Math]::Floor(($SourceImage.Width - $side) / 2) + $sourceY = [Math]::Floor(($SourceImage.Height - $side) / 2) + $sourceRectangle = [System.Drawing.Rectangle]::new($sourceX, $sourceY, $side, $side) + $targetRectangle = [System.Drawing.Rectangle]::new(0, 0, $Size, $Size) + $bitmap = [System.Drawing.Bitmap]::new($Size, $Size) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + + try { + $graphics.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy + $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $graphics.DrawImage($SourceImage, $targetRectangle, $sourceRectangle, [System.Drawing.GraphicsUnit]::Pixel) + $bitmap.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $graphics.Dispose() + $bitmap.Dispose() + } +} + +$sourceImage = [System.Drawing.Image]::FromFile($sourcePath) + +try { + $targets = @( + @{ Size = 180; FileName = 'apple-touch-icon.png' } + @{ Size = 192; FileName = 'pwa-192.png' } + @{ Size = 512; FileName = 'pwa-512.png' } + ) + + foreach ($target in $targets) { + $outputPath = Join-Path $iconDirectory $target.FileName + Export-SquareIcon -SourceImage $sourceImage -Size $target.Size -OutputPath $outputPath + Write-Host "已生成 $outputPath" + } +} +finally { + $sourceImage.Dispose() +} diff --git a/src/assets/pwa-icon-source.png b/src/assets/pwa-icon-source.png new file mode 100644 index 0000000..eb299d7 Binary files /dev/null and b/src/assets/pwa-icon-source.png differ diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 5307f48..784d950 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -26,7 +26,17 @@ const buildTime = new Intl.DateTimeFormat("zh-CN", { + + + + + + + {title} + + + + diff --git a/src/pages/offline.astro b/src/pages/offline.astro new file mode 100644 index 0000000..34a708c --- /dev/null +++ b/src/pages/offline.astro @@ -0,0 +1,102 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; +import { site } from "../data/site"; +--- + + +
+
+ +

当前处于离线状态

+

记忆还在,只是网络暂时走远了。

+

+ 已经访问过的页面和照片仍可继续浏览;重新联网后刷新页面,就能看到最新内容。 +

+
+ + 返回首页 +
+
+
+
+ +