Merge pull request #23 from bishshi/codex/pwa

ADD PWA
This commit is contained in:
2026-07-18 15:15:05 +08:00
committed by GitHub
Unverified
12 changed files with 441 additions and 1 deletions
+3
View File
@@ -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` 中的同名配置优先:
+1 -1
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

+44
View File
@@ -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/"
}
]
}
+103
View File
@@ -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));
}
});
+92
View File
@@ -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}`);
+77
View File
@@ -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()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+19
View File
@@ -26,7 +26,17 @@ const buildTime = new Intl.DateTimeFormat("zh-CN", {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="description" content={site.subtitle} />
<meta name="theme-color" content="#376d5a" />
<meta name="application-name" content={site.className} />
<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="default" />
<meta name="apple-mobile-web-app-title" content={site.className} />
<title>{title}</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fancyapps/ui@5.0/dist/fancybox/fancybox.css"
@@ -127,5 +137,14 @@ const buildTime = new Intl.DateTimeFormat("zh-CN", {
dragToClose: true
});
</script>
<script is:inline>
if ("serviceWorker" in navigator && window.isSecureContext) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((error) => {
console.warn("Service Worker 注册失败", error);
});
});
}
</script>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { site } from "../data/site";
---
<BaseLayout title={`暂时离线 · ${site.className}`}>
<main class="offline-page">
<section class="offline-card" aria-labelledby="offline-title">
<p class="offline-mark" aria-hidden="true">612</p>
<p class="eyebrow">当前处于离线状态</p>
<h1 id="offline-title">记忆还在,只是网络暂时走远了。</h1>
<p>
已经访问过的页面和照片仍可继续浏览;重新联网后刷新页面,就能看到最新内容。
</p>
<div class="offline-actions">
<button type="button" onclick="window.location.reload()">重新连接</button>
<a href="/">返回首页</a>
</div>
</section>
</main>
</BaseLayout>
<style>
.offline-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 96px 20px 48px;
background:
radial-gradient(circle at 15% 18%, rgba(232, 168, 76, 0.2), transparent 24%),
linear-gradient(145deg, #eef4e9, #fffdf7 55%, #f6f0df);
}
.offline-card {
width: min(680px, 100%);
padding: clamp(30px, 7vw, 64px);
border: 1px solid rgba(31, 43, 42, 0.14);
border-radius: 24px;
background: rgba(255, 253, 247, 0.92);
box-shadow: 0 24px 70px rgba(39, 55, 52, 0.14);
}
.offline-mark {
width: 76px;
height: 76px;
display: grid;
place-items: center;
margin: 0 0 28px;
border-radius: 20px;
background: #376d5a;
color: #fffdf7;
font-size: 26px;
font-weight: 900;
}
.offline-card .eyebrow {
color: #c96452;
}
.offline-card h1 {
max-width: 580px;
color: #1f2b2a;
font-size: clamp(34px, 7vw, 58px);
}
.offline-card > p:not(.offline-mark, .eyebrow) {
margin: 24px 0 0;
color: #62706f;
font-size: 17px;
}
.offline-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 32px;
}
.offline-actions button,
.offline-actions a {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 18px;
border: 1px solid #376d5a;
border-radius: 8px;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.offline-actions button {
background: #376d5a;
color: #fffdf7;
}
.offline-actions a {
background: transparent;
color: #376d5a;
}
</style>