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
@@ -31,10 +31,12 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
appChildren: number;
|
||||
hasLoginPage: boolean;
|
||||
hasInput: boolean;
|
||||
hasLayout: boolean;
|
||||
text: string;
|
||||
} | null = null;
|
||||
for (let attempt = 0; attempt < 56; attempt += 1) {
|
||||
dom = await win.webContents.executeJavaScript(
|
||||
|
||||
const snapshot = async (): Promise<typeof dom> =>
|
||||
win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const app = document.querySelector('#app');
|
||||
return {
|
||||
@@ -42,15 +44,37 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
appChildren: app ? app.children.length : 0,
|
||||
hasLoginPage: Boolean(document.querySelector('.login-page')),
|
||||
hasInput: Boolean(document.querySelector('input')),
|
||||
hasLayout: document.querySelectorAll('.el-menu-item').length > 0,
|
||||
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 80),
|
||||
};
|
||||
})()`,
|
||||
);
|
||||
// 等到登录页真正渲染出来(而不是停在启动过渡页)
|
||||
if (dom?.hasLoginPage && dom?.hasInput) break;
|
||||
|
||||
for (let attempt = 0; attempt < 56; attempt += 1) {
|
||||
dom = await snapshot();
|
||||
// 等到登录页或主布局真正渲染出来(而不是停在启动过渡页)
|
||||
if ((dom?.hasLoginPage && dom?.hasInput) || dom?.hasLayout) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
// 上次运行可能残留登录态(客户端会直接进主界面)——先退出登录,再校验登录页
|
||||
if (dom?.hasLayout && !dom?.hasLoginPage) {
|
||||
console.log('[SMOKE] 检测到已保存的登录态,先退出登录再校验登录页');
|
||||
await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return false;
|
||||
await window.__classhelperSmoke__.sessionCleanup();
|
||||
location.hash = '#/login';
|
||||
return true;
|
||||
})()`,
|
||||
);
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
dom = await snapshot();
|
||||
if (dom?.hasLoginPage && dom?.hasInput) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
|
||||
record(
|
||||
'窗口标题正确',
|
||||
@@ -120,10 +144,27 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
'联网集成(登录 + 四类数据 + Socket.IO + 缓存写入)',
|
||||
'联网集成(登录 + 布局挂载 + 四类数据 + Socket.IO + 缓存写入)',
|
||||
Boolean(online?.ok),
|
||||
String(online?.detail ?? ''),
|
||||
);
|
||||
|
||||
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
|
||||
const navigation = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
return await window.__classhelperSmoke__.layoutNavigationSelfTest();
|
||||
})()`,
|
||||
);
|
||||
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
|
||||
|
||||
const cleanup = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
return await window.__classhelperSmoke__.sessionCleanup();
|
||||
})()`,
|
||||
);
|
||||
record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? ''));
|
||||
}
|
||||
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
|
||||
+97
-7
@@ -58,8 +58,73 @@ export async function offlineScenario(): Promise<SmokeCheckResult> {
|
||||
};
|
||||
}
|
||||
|
||||
/** 轮询等待条件成立(用于验证点击后界面确实发生跳转) */
|
||||
async function waitUntil(predicate: () => boolean, timeoutMs = 3000, intervalMs = 50): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配) */
|
||||
const EXPECTED_MENU: Array<{ label: string; path: string }> = [
|
||||
{ label: '课表', path: '/schedule' },
|
||||
{ label: '作业', path: '/homeworks' },
|
||||
{ label: '通知', path: '/notifications' },
|
||||
{ label: '成绩', path: '/grades' },
|
||||
{ label: '设置', path: '/settings' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 冒烟自检 3(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
||||
* 冒烟自检 3:侧边栏点击导航。
|
||||
* 直接对真实 DOM 触发 click,验证每次点击后路由与视图都发生切换 ——
|
||||
* 这是"点击左侧边栏没反应"这类问题的回归测试。
|
||||
*/
|
||||
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
const { router } = await import('../router/index.js');
|
||||
|
||||
const findMenuItem = (label: string): HTMLElement | undefined =>
|
||||
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
|
||||
(element.textContent ?? '').trim().startsWith(label),
|
||||
);
|
||||
|
||||
if (!findMenuItem('课表')) {
|
||||
return { ok: false, detail: '侧边栏未渲染(当前不在主布局或未登录)' };
|
||||
}
|
||||
|
||||
const visited: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const { label, path } of EXPECTED_MENU) {
|
||||
const element = findMenuItem(label);
|
||||
if (!element) {
|
||||
failures.push(`${label}(菜单项缺失)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
element.click();
|
||||
const navigated = await waitUntil(() => router.currentRoute.value.path === path);
|
||||
const actualPath = router.currentRoute.value.path;
|
||||
|
||||
// 视图内容也要跟着变(标题取自各页 .page-title)
|
||||
const rendered = await waitUntil(() => Boolean(document.querySelector('.page-title')));
|
||||
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
||||
|
||||
visited.push(`${label}→${actualPath}${title ? `(${title})` : ''}`);
|
||||
if (!navigated) failures.push(`${label}(期望 ${path} 实际 ${actualPath})`);
|
||||
else if (!rendered) failures.push(`${label}(视图未渲染)`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: failures.length === 0,
|
||||
detail: `visited=[${visited.join(' ')}]${failures.length > 0 ? ` failures=${failures.join(';')}` : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
||||
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
|
||||
*/
|
||||
export async function onlineScenario(): Promise<SmokeCheckResult> {
|
||||
@@ -82,6 +147,12 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
||||
await auth.login(serverUrl, 'student01', 'student123');
|
||||
detail.push(`login=${auth.user?.name ?? '-'}`);
|
||||
|
||||
// 进入主布局(后续的侧边栏点击测试依赖布局已挂载)
|
||||
const { router } = await import('../router/index.js');
|
||||
await router.push('/schedule');
|
||||
const layoutMounted = await waitUntil(() => Boolean(document.querySelector('.el-menu-item')), 5000);
|
||||
detail.push(`layout=${layoutMounted}`);
|
||||
|
||||
await appStore.init(serverUrl);
|
||||
detail.push(`reachable=${appStore.serverReachable}`);
|
||||
|
||||
@@ -131,14 +202,12 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
||||
homeworks.length > 0 &&
|
||||
notifications.length > 0 &&
|
||||
grades.length > 0 &&
|
||||
realtime.connected;
|
||||
realtime.connected &&
|
||||
layoutMounted;
|
||||
|
||||
realtime.disconnect();
|
||||
await auth.logout(); // 清理登录态,保证下次冒烟从登录页开始
|
||||
// 会话保留给后续的侧边栏点击测试,由 sessionCleanup() 统一清理
|
||||
return { ok, detail: detail.join(' ') };
|
||||
} catch (error) {
|
||||
realtime.disconnect();
|
||||
await auth.logout().catch(() => undefined);
|
||||
return {
|
||||
ok: false,
|
||||
detail: `${detail.join(' ')} error=${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -146,7 +215,28 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
|
||||
export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
||||
import('../stores/auth.js'),
|
||||
import('../stores/realtime.js'),
|
||||
]);
|
||||
try {
|
||||
useRealtimeStore().disconnect();
|
||||
await useAuthStore().logout();
|
||||
return { ok: true, detail: '已断开实时通道并清理登录态' };
|
||||
} catch (error) {
|
||||
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册到 window,供主进程冒烟脚本调用 */
|
||||
export function registerSmokeHooks(): void {
|
||||
window.__classhelperSmoke__ = { cacheSelfTest, offlineScenario, onlineScenario };
|
||||
window.__classhelperSmoke__ = {
|
||||
cacheSelfTest,
|
||||
offlineScenario,
|
||||
layoutNavigationSelfTest,
|
||||
onlineScenario,
|
||||
sessionCleanup,
|
||||
};
|
||||
}
|
||||
@@ -16,14 +16,39 @@ const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'schedule', title: '课表', icon: 'Calendar' },
|
||||
{ name: 'homeworks', title: '作业', icon: 'Notebook' },
|
||||
{ name: 'notifications', title: '通知', icon: 'Bell' },
|
||||
{ name: 'grades', title: '成绩', icon: 'Trophy' },
|
||||
{ name: 'settings', title: '设置', icon: 'Setting' },
|
||||
{ path: '/schedule', title: '课表', icon: 'Calendar' },
|
||||
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
|
||||
{ path: '/notifications', title: '通知', icon: 'Bell' },
|
||||
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
||||
{ path: '/settings', title: '设置', icon: 'Setting' },
|
||||
];
|
||||
|
||||
const activeMenu = computed(() => `/${String(route.name ?? 'schedule')}`);
|
||||
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||
const activeMenu = computed(() => route.path);
|
||||
|
||||
/**
|
||||
* 菜单点击回调。
|
||||
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
|
||||
* 不能当作路由名传给 router.push({ name }),否则会静默失败(点击无反应)。
|
||||
*/
|
||||
function handleMenuSelect(index: string): void {
|
||||
void router.push(index);
|
||||
}
|
||||
|
||||
/** 按路由名跳转(下拉菜单、按钮等程序化调用) */
|
||||
function go(name: string): void {
|
||||
void router.push({ name });
|
||||
}
|
||||
|
||||
/** 开发期自检:菜单路径必须存在于路由表中 */
|
||||
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(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectionType = computed(() => {
|
||||
if (realtime.connected) return 'success';
|
||||
@@ -37,10 +62,6 @@ const connectionText = computed(() => {
|
||||
return '离线模式 · 显示缓存数据';
|
||||
});
|
||||
|
||||
function go(name: string): void {
|
||||
void router.push({ name });
|
||||
}
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' });
|
||||
realtime.disconnect();
|
||||
@@ -81,12 +102,12 @@ onUnmounted(() => {
|
||||
<el-icon :size="20"><School /></el-icon>
|
||||
<span>班级小助手</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="menu" @select="go">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.name" :index="`/${item.name}`">
|
||||
<el-menu :default-active="activeMenu" class="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-badge
|
||||
v-if="item.name === 'notifications' && notifications.unreadCount > 0"
|
||||
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
|
||||
:value="notifications.unreadCount"
|
||||
class="menu-badge"
|
||||
/>
|
||||
|
||||
@@ -41,7 +41,9 @@ declare global {
|
||||
__classhelperSmoke__?: {
|
||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
||||
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
|
||||
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user