This commit is contained in:
2026-07-17 13:02:21 +08:00 Unverified
parent 2fd8f3dc14
commit 8e6efd1c51
12 changed files with 355 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+104
View File
@@ -0,0 +1,104 @@
(() => {
if (!('serviceWorker' in navigator)) return;
const toast = document.querySelector('[data-pwa-toast]');
const title = toast?.querySelector('[data-pwa-title]');
const message = toast?.querySelector('[data-pwa-message]');
const primary = toast?.querySelector('[data-pwa-primary]');
const close = toast?.querySelector('[data-pwa-close]');
let installPrompt = null;
let waitingWorker = null;
let toastMode = '';
let refreshing = false;
const showToast = (mode, heading, detail, actionLabel) => {
if (!toast || !title || !message || !primary) return;
toastMode = mode;
title.textContent = heading;
message.textContent = detail;
primary.textContent = actionLabel;
primary.hidden = !actionLabel;
toast.hidden = false;
};
const hideToast = () => {
if (toast) toast.hidden = true;
};
const installDismissedRecently = () => {
try {
const dismissedAt = Number(localStorage.getItem('pwa-install-dismissed-at'));
return dismissedAt > 0 && Date.now() - dismissedAt < 30 * 86400000;
} catch {
return false;
}
};
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
installPrompt = event;
if (!installDismissedRecently()) {
window.setTimeout(() => {
if (installPrompt) showToast('install', '安装 Bi\'s Blog', '添加到桌面,像应用一样打开并阅读缓存过的文章。', '立即安装');
}, 2500);
}
});
window.addEventListener('appinstalled', () => {
installPrompt = null;
showToast('ready', '安装完成', '博客已经添加到你的设备。', '知道了');
});
primary?.addEventListener('click', async () => {
if (toastMode === 'install' && installPrompt) {
await installPrompt.prompt();
await installPrompt.userChoice;
installPrompt = null;
hideToast();
return;
}
if (toastMode === 'update' && waitingWorker) {
refreshing = true;
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
return;
}
hideToast();
});
close?.addEventListener('click', () => {
if (toastMode === 'install') {
try { localStorage.setItem('pwa-install-dismissed-at', String(Date.now())); } catch {}
}
hideToast();
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) window.location.reload();
});
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then((registration) => {
if (registration.waiting && navigator.serviceWorker.controller) {
waitingWorker = registration.waiting;
showToast('update', '发现新版本', '刷新后即可使用博客的最新内容和功能。', '立即更新');
}
registration.addEventListener('updatefound', () => {
const worker = registration.installing;
worker?.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
waitingWorker = worker;
showToast('update', '发现新版本', '刷新后即可使用博客的最新内容和功能。', '立即更新');
}
});
});
}).catch((error) => {
console.warn('PWA registration failed:', error);
});
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'search') {
window.setTimeout(() => document.querySelector('.search-typesense-trigger')?.click(), 350);
}
})();
+46
View File
@@ -0,0 +1,46 @@
{
"id": "/",
"name": "Bi's Blog",
"short_name": "Bi's Blog",
"description": "好奇心转化为代码的空间。",
"lang": "zh-CN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"theme_color": "#0f766e",
"background_color": "#f7f5ef",
"categories": ["blog", "education", "technology"],
"icons": [
{
"src": "/images/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/images/pwa-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "搜索文章",
"short_name": "搜索",
"url": "/?action=search",
"icons": [{ "src": "/images/pwa-192x192.png", "sizes": "192x192" }]
},
{
"name": "随机文章",
"short_name": "随机文章",
"url": "/go/",
"icons": [{ "src": "/images/pwa-192x192.png", "sizes": "192x192" }]
}
]
}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f766e" />
<title>暂时离线 | Bi's Blog</title>
<style>
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; color: #27313a; font-family: system-ui, "Microsoft YaHei", sans-serif; background: linear-gradient(145deg, #f7f5ef, #e5f5ef); }
main { width: min(520px, 100%); border: 1px solid rgba(255,255,255,.8); border-radius: 18px; padding: 40px; text-align: center; background: rgba(255,253,248,.82); box-shadow: 0 24px 70px rgba(44,55,63,.16); }
.icon { display: grid; width: 72px; height: 72px; margin: 0 auto 22px; place-items: center; border-radius: 20px; color: white; font-size: 30px; font-weight: 700; background: #0f766e; }
h1 { margin: 0; font-size: clamp(1.8rem, 6vw, 2.6rem); }
p { margin: 14px 0 26px; color: #686f78; line-height: 1.8; }
a, button { display: inline-flex; min-height: 42px; align-items: center; justify-content: center; border: 0; border-radius: 999px; padding: 9px 18px; color: white; font: inherit; font-weight: 700; text-decoration: none; background: #0f766e; cursor: pointer; }
a { margin-left: 8px; color: #0f766e; background: #dff5ed; }
@media (prefers-color-scheme: dark) { body { color: #eef4f6; background: #0b1014; } main { border-color: rgba(181,198,207,.2); background: #141b21; } p { color: #b6c3c9; } }
</style>
</head>
<body>
<main>
<div class="icon" aria-hidden="true">Bi</div>
<h1>网络暂时走丢了</h1>
<p>已经读过的文章仍可从浏览器历史或返回按钮打开。网络恢复后,再来继续探索吧。</p>
<button type="button" onclick="location.reload()">重新连接</button>
<a href="/">返回首页</a>
</main>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
const CACHE_VERSION = '2026-07-17-v1';
const CACHE_PREFIX = 'bis-blog-';
const CORE_CACHE = `${CACHE_PREFIX}core-${CACHE_VERSION}`;
const PAGE_CACHE = `${CACHE_PREFIX}pages-${CACHE_VERSION}`;
const ASSET_CACHE = `${CACHE_PREFIX}assets-${CACHE_VERSION}`;
const IMAGE_CACHE = `${CACHE_PREFIX}images-${CACHE_VERSION}`;
const CORE_ASSETS = [
'/',
'/offline.html',
'/manifest.webmanifest',
'/images/Bi.ico',
'/images/pwa-192x192.png',
'/images/pwa-512x512.png',
'/images/pwa-maskable-512x512.png',
'/images/apple-touch-icon.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CORE_CACHE)
.then((cache) => cache.addAll(CORE_ASSETS))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter((name) => name.startsWith(CACHE_PREFIX) && ![CORE_CACHE, PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE].includes(name))
.map((name) => caches.delete(name))
);
await self.clients.claim();
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach((client) => client.postMessage({ type: 'OFFLINE_READY' }));
})());
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(PAGE_CACHE);
await cache.put(request, response.clone());
void trimCache(PAGE_CACHE, 40);
}
return response;
} catch {
return (await caches.match(request)) || (await caches.match('/offline.html'));
}
}
async function cacheFirst(request, cacheName, maxEntries) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok || response.type === 'opaque') {
const cache = await caches.open(cacheName);
await cache.put(request, response.clone());
void trimCache(cacheName, maxEntries);
}
return response;
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(ASSET_CACHE);
const cached = await cache.match(request);
const network = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
}).catch(() => cached);
return cached || network;
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
if (request.destination === 'image') {
event.respondWith(cacheFirst(request, IMAGE_CACHE, 100));
return;
}
if (
url.origin === self.location.origin &&
['style', 'script', 'font', 'worker'].includes(request.destination)
) {
event.respondWith(staleWhileRevalidate(request));
}
});
+11
View File
@@ -0,0 +1,11 @@
<aside class="pwa-toast" data-pwa-toast hidden role="status" aria-live="polite">
<div class="pwa-toast-icon" aria-hidden="true"><i class="fa-solid fa-download"></i></div>
<div class="pwa-toast-copy">
<strong data-pwa-title>PWA 提示</strong>
<p data-pwa-message></p>
</div>
<div class="pwa-toast-actions">
<button class="pwa-toast-primary" type="button" data-pwa-primary>确定</button>
<button class="pwa-toast-close" type="button" data-pwa-close aria-label="关闭提示">稍后</button>
</div>
</aside>
+7
View File
@@ -2,6 +2,7 @@
import '../styles/site.css';
import CustomContextMenu from '@/components/CustomContextMenu.astro';
import HitokotoCard from '@/components/HitokotoCard.astro';
import PwaPrompt from '@/components/PwaPrompt.astro';
import { publicSiteConfig, siteConfig } from '../../site.config.mjs';
interface Props {
@@ -90,6 +91,10 @@ const consoleSites = [
{keywords.map((keyword) => <meta property="article:tag" content={keyword} />)}
{jsonLd && <script type="application/ld+json" is:inline set:html={jsonLdHtml}></script>}
<link rel="icon" href={siteConfig.assets.icon} />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png" />
<meta name="theme-color" content="#f7f5ef" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0b1014" media="(prefers-color-scheme: dark)" />
<script defer src={siteConfig.analytics.umami.script} data-website-id={siteConfig.analytics.umami.websiteId}></script>
<script is:inline set:html={`window.SITE_CONFIG = ${publicConfigJson};`}></script>
<script is:inline>
@@ -243,12 +248,14 @@ const consoleSites = [
</footer>
<HitokotoCard />
<CustomContextMenu />
<PwaPrompt />
<script is:inline src={siteConfig.cdn.instantsearchJs}></script>
<script is:inline src={siteConfig.cdn.typesenseAdapterJs}></script>
<script is:inline src={siteConfig.cdn.fancyboxJs}></script>
<script is:inline src="/js/random.js"></script>
<script is:inline src="/js/search/typesense-search.js"></script>
<script is:inline src="/js/external-links.js"></script>
<script is:inline src="/js/pwa.js"></script>
<script is:inline>
(() => {
const nav = document.querySelector('.nav');
+1
View File
@@ -13,3 +13,4 @@
@import "./site/13-responsive.css";
@import "./site/14-animations.css";
@import "./site/15-reading-series.css";
@import "./site/16-pwa.css";
+47
View File
@@ -0,0 +1,47 @@
.pwa-toast {
position: fixed;
right: max(18px, var(--page-gutter));
bottom: 24px;
z-index: 34;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
width: min(560px, calc(100vw - 32px));
border: 1px solid rgba(255, 255, 255, 0.68);
border-radius: 14px;
padding: 14px;
color: #27313a;
background: rgba(255, 253, 248, 0.94);
box-shadow: 0 22px 60px rgba(44, 55, 63, 0.24);
backdrop-filter: blur(20px) saturate(165%);
-webkit-backdrop-filter: blur(20px) saturate(165%);
animation: pwa-toast-in 0.3s ease both;
}
.pwa-toast[hidden] { display: none; }
.pwa-toast-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; color: #fff; background: #3eb8be; }
.pwa-toast-copy { min-width: 0; }
.pwa-toast-copy strong, .pwa-toast-copy p { margin: 0; }
.pwa-toast-copy p { color: var(--muted); font-size: 0.88rem; line-height: 1.5; }
.pwa-toast-actions { display: flex; gap: 7px; }
.pwa-toast button { border: 1px solid rgba(15, 118, 110, 0.18); border-radius: 999px; padding: 7px 12px; color: var(--accent); font: inherit; font-size: 0.84rem; font-weight: 800; background: rgba(236, 253, 245, 0.72); cursor: pointer; }
.pwa-toast .pwa-toast-primary { color: #fff; background: #3eb8be; }
:root[data-theme='dark'] .pwa-toast { border-color: rgba(139, 224, 213, 0.22); color: #edf7f5; background: rgba(13, 23, 29, 0.94); }
@keyframes pwa-toast-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 720px) {
.pwa-toast { right: 16px; bottom: 16px; grid-template-columns: 40px minmax(0, 1fr); }
.pwa-toast-icon { width: 40px; height: 40px; }
.pwa-toast-actions { grid-column: 1 / -1; }
.pwa-toast button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {
.pwa-toast { animation: none; }
}