@@ -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
@@ -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 |
@@ -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
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user