diff --git a/public/images/apple-touch-icon.png b/public/images/apple-touch-icon.png new file mode 100644 index 0000000..54af06e Binary files /dev/null and b/public/images/apple-touch-icon.png differ diff --git a/public/images/pwa-192x192.png b/public/images/pwa-192x192.png new file mode 100644 index 0000000..213c484 Binary files /dev/null and b/public/images/pwa-192x192.png differ diff --git a/public/images/pwa-512x512.png b/public/images/pwa-512x512.png new file mode 100644 index 0000000..e0ed3c3 Binary files /dev/null and b/public/images/pwa-512x512.png differ diff --git a/public/images/pwa-maskable-512x512.png b/public/images/pwa-maskable-512x512.png new file mode 100644 index 0000000..dfdbcb9 Binary files /dev/null and b/public/images/pwa-maskable-512x512.png differ diff --git a/public/js/pwa.js b/public/js/pwa.js new file mode 100644 index 0000000..465f9cc --- /dev/null +++ b/public/js/pwa.js @@ -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); + } +})(); diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..40337ed --- /dev/null +++ b/public/manifest.webmanifest @@ -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" }] + } + ] +} diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..0fac7f4 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,29 @@ + + + + + + + 暂时离线 | Bi's Blog + + + +
+ +

网络暂时走丢了

+

已经读过的文章仍可从浏览器历史或返回按钮打开。网络恢复后,再来继续探索吧。

+ + 返回首页 +
+ + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..091d1c1 --- /dev/null +++ b/public/sw.js @@ -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)); + } +}); diff --git a/src/components/PwaPrompt.astro b/src/components/PwaPrompt.astro new file mode 100644 index 0000000..90e2a38 --- /dev/null +++ b/src/components/PwaPrompt.astro @@ -0,0 +1,11 @@ + diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 6034080..4df9f5c 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -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) => )} {jsonLd && } + + + + +