PWA
This commit is contained in:
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 |
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user