- 修复 web-admin 与 desktop-client 侧边栏点击无反应:el-menu 的 index 是路径,不能当路由名使用 - 修复 Web 产物目录含点号目录(如 .cache)时 res.sendFile 因 dotfiles:ignore 返回 500 - 后端生产模式:静态托管 Web 端 + SPA 回退、helmet、限流(通用/登录)、trust proxy、 /healthz 与 /readyz、请求耗时日志、生产环境变量强校验、首启动自动迁移与创建管理员、PID 文件 - Web 管理端 PWA:manifest + service worker + 图标,可在浏览器安装为应用 - 新增服务端 + Web 管理端 Windows 安装程序(内置 Node 运行时、启停与服务脚本、开机自启、卸载保留数据) - 新增 Docker/Compose/Nginx 部署样例与 docs/production.md - 新增 UI 回归测试:Web 端 Electron 驱动真实点击 7 项、客户端冒烟 11 项(含侧边栏点击与离线回退)
73 lines
2.4 KiB
JavaScript
73 lines
2.4 KiB
JavaScript
/**
|
|
* Service Worker:让 Web 管理端可以被浏览器「安装为应用」,并在断网时打开应用外壳。
|
|
*
|
|
* 策略:
|
|
* - 导航请求(打开页面/刷新/前端路由):network-first,失败时回退缓存的 index.html
|
|
* - 构建产物 /assets/*(带内容哈希):cache-first(文件名变化即自动失效)
|
|
* - /api 与 /socket.io:完全透传,绝不缓存(避免脏数据与鉴权问题)
|
|
*/
|
|
const CACHE_NAME = 'classhelper-admin-v1';
|
|
const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png'];
|
|
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
caches
|
|
.open(CACHE_NAME)
|
|
.then((cache) => cache.addAll(APP_SHELL))
|
|
.then(() => self.skipWaiting())
|
|
.catch(() => undefined),
|
|
);
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches
|
|
.keys()
|
|
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
|
.then(() => self.clients.claim()),
|
|
);
|
|
});
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
if (request.method !== 'GET') return;
|
|
|
|
const url = new URL(request.url);
|
|
// 只接管同源请求
|
|
if (url.origin !== self.location.origin) return;
|
|
// 接口与实时通道透传
|
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/socket.io/')) return;
|
|
|
|
// 页面导航:网络优先,离线时用缓存外壳
|
|
if (request.mode === 'navigate') {
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
const clone = response.clone();
|
|
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', clone));
|
|
return response;
|
|
})
|
|
.catch(() => caches.match('/index.html').then((cached) => cached ?? Response.error())),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 静态资源:缓存优先 + 后台更新
|
|
if (url.pathname.startsWith('/assets/') || /\.(png|svg|ico|webmanifest|woff2?)$/.test(url.pathname)) {
|
|
event.respondWith(
|
|
caches.match(request).then((cached) => {
|
|
const network = fetch(request)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
const clone = response.clone();
|
|
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => cached ?? Response.error());
|
|
return cached ?? network;
|
|
}),
|
|
);
|
|
}
|
|
});
|