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:
1 parent
6e4c7c1050
commit
5545ef6f55
46 files changed
+3020
-152
No files matched your search
@@ -3,6 +3,16 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#409eff" />
|
||||
<meta name="description" content="班级小助手管理端:班级、课表、作业、通知与成绩管理" />
|
||||
<!-- PWA:支持在 Chrome/Edge 中「安装为应用」 -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.png" sizes="64x64" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="班级小助手" />
|
||||
<title>班级小助手 · 管理端</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
|
||||
|
||||
/** 页面是否由 http(s) 提供(生产环境由后端托管,浏览器里恒为 true) */
|
||||
const servedOverHttp = typeof window !== 'undefined' && /^https?:$/.test(window.location.protocol);
|
||||
|
||||
/**
|
||||
* 运行时配置。
|
||||
* 默认直连后端(开发/生产都可用);如需走 Vite 代理,把 .env 里的
|
||||
* VITE_API_BASE_URL 设为 /api 即可(vite.config.ts 已配置代理)。
|
||||
*
|
||||
* 默认使用**同源相对路径**:
|
||||
* - 生产:后端同时托管 Web 管理端,`/api` 与 `/socket.io` 同源,无需任何配置;
|
||||
* - 开发:Vite 已把 `/api` 与 `/socket.io` 代理到后端(见 vite.config.ts),同样可用;
|
||||
* - 需要指向其它后端(例如静态托管在 CDN)时,构建期设置 VITE_API_BASE_URL / VITE_SOCKET_URL。
|
||||
*/
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? `${DEFAULT_SERVER_URL}/api`;
|
||||
export const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL ?? (servedOverHttp ? '/api' : `${DEFAULT_SERVER_URL}/api`);
|
||||
|
||||
export const SOCKET_URL = import.meta.env.VITE_SOCKET_URL ?? DEFAULT_SERVER_URL;
|
||||
export const SOCKET_URL =
|
||||
import.meta.env.VITE_SOCKET_URL ?? (servedOverHttp ? window.location.origin : DEFAULT_SERVER_URL);
|
||||
|
||||
export const APP_TITLE = '班级小助手 · 管理端';
|
||||
@@ -13,16 +13,36 @@ const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'dashboard', title: '仪表盘', icon: 'Odometer' },
|
||||
{ name: 'classes', title: '班级管理', icon: 'School' },
|
||||
{ name: 'students', title: '学生管理', icon: 'User' },
|
||||
{ name: 'schedules', title: '课表管理', icon: 'Calendar' },
|
||||
{ name: 'homeworks', title: '作业发布', icon: 'Notebook' },
|
||||
{ name: 'notifications', title: '通知发布', icon: 'Bell' },
|
||||
{ name: 'grades', title: '成绩录入', icon: 'Trophy' },
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
|
||||
{ path: '/classes', title: '班级管理', icon: 'School' },
|
||||
{ path: '/students', title: '学生管理', icon: 'User' },
|
||||
{ path: '/schedules', title: '课表管理', icon: 'Calendar' },
|
||||
{ path: '/homeworks', title: '作业发布', icon: 'Notebook' },
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell' },
|
||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy' },
|
||||
];
|
||||
|
||||
const activeMenu = computed(() => `/${String(route.name ?? 'dashboard')}`);
|
||||
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||
const activeMenu = computed(() => route.path);
|
||||
|
||||
/**
|
||||
* 菜单点击回调。
|
||||
* 注意:el-menu 的 @select 抛出的是 el-menu-item 的 index(这里即路由路径),
|
||||
* 不能当作路由名使用(router.push({ name }) 会因找不到同名路由而静默失败)。
|
||||
*/
|
||||
function handleMenuSelect(index: string): void {
|
||||
void router.push(index);
|
||||
}
|
||||
|
||||
/** 开发期自检:菜单路径必须存在于路由表中,防止再次出现"点了没反应" */
|
||||
if (import.meta.env.DEV) {
|
||||
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
||||
for (const item of menuItems) {
|
||||
if (!knownPaths.has(item.path)) {
|
||||
console.error(`[AdminLayout] 菜单项未注册对应路由:${item.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectionText = computed(() => {
|
||||
if (realtime.connected) return '实时通道已连接';
|
||||
@@ -34,10 +54,6 @@ const connectionType = computed(() =>
|
||||
realtime.connected ? 'success' : realtime.connecting ? 'warning' : 'danger',
|
||||
);
|
||||
|
||||
function go(name: string): void {
|
||||
void router.push({ name });
|
||||
}
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
||||
await auth.logout();
|
||||
@@ -91,8 +107,8 @@ async function submitPassword(): Promise<void> {
|
||||
<el-icon :size="22"><School /></el-icon>
|
||||
<span class="brand-text">班级小助手</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="layout-menu" @select="go">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.name" :index="`/${item.name}`">
|
||||
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
@@ -21,3 +21,16 @@ app.use(router);
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
/**
|
||||
* 注册 Service Worker(仅生产构建):
|
||||
* - 浏览器可「安装为应用」(PWA),断网时仍能打开应用外壳
|
||||
* - 不缓存 /api 与 /socket.io,保证数据实时性
|
||||
*/
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
void navigator.serviceWorker.register('/sw.js').catch((error) => {
|
||||
console.warn('[pwa] Service Worker 注册失败:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user