feat: 生产化改造(服务端安装程序/PWA/Docker+HTTPS)并修复侧边栏点击无反应、SPA 静态托管 dotfiles 两个缺陷

- 修复 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 项(含侧边栏点击与离线回退)
This commit is contained in:
LoveChengke committed 2026-09-12 00:20:21 +08:00
1 parent 6e4c7c1050
commit 5545ef6f55
46 files changed
+3020 -152

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4facfe"/>
<stop offset="55%" stop-color="#409eff"/>
<stop offset="100%" stop-color="#1f6feb"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="64" height="64" rx="14" ry="14" fill="url(#g)"/>
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="central"
font-family="'Microsoft YaHei','PingFang SC',sans-serif" font-weight="700"
font-size="33" fill="#ffffff">班</text>
<rect x="16" y="47" width="32" height="3"
rx="1" fill="#ffffff" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

@@ -0,0 +1,45 @@
{
"name": "班级小助手 · 管理端",
"short_name": "班级小助手",
"description": "班级信息管理系统管理端:班级、课表、作业、通知与成绩管理",
"lang": "zh-CN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f5f7fa",
"theme_color": "#409eff",
"categories": ["education", "productivity"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "通知发布",
"url": "/notifications",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
},
{
"name": "成绩录入",
"url": "/grades",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
}
]
}
+72
View File
@@ -0,0 +1,72 @@
/**
* 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;
}),
);
}
});