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 项(含侧边栏点击与离线回退)
No files matched your search
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 264 KiB |
@@ -23,10 +23,31 @@ if (missing.length > 0) {
|
||||
}
|
||||
|
||||
console.log('[smoke] 启动 Electron 冒烟验证...\n');
|
||||
|
||||
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
|
||||
let online = process.env.ELECTRON_SMOKE_ONLINE;
|
||||
if (online === undefined) {
|
||||
online = '0';
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2500);
|
||||
const response = await fetch('http://127.0.0.1:4000/api/health', { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
online = response.ok ? '1' : '0';
|
||||
} catch {
|
||||
online = '0';
|
||||
}
|
||||
console.log(
|
||||
online === '1'
|
||||
? '[smoke] 检测到后端在线,将执行联网集成与侧边栏点击测试'
|
||||
: '[smoke] 后端未在线,仅执行本地检查(离线缓存/界面渲染);如需联网测试请先启动 pnpm dev:server',
|
||||
);
|
||||
}
|
||||
|
||||
const child = spawn(electronPath, ['.'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ELECTRON_SMOKE_TEST: '1' },
|
||||
env: { ...process.env, ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online },
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +37,25 @@ TERM_START_DATE=2026-02-23
|
||||
|
||||
# 日志级别:debug | info | warn | error
|
||||
LOG_LEVEL=info
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 生产部署相关(本地开发保持默认即可)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 监听地址:本地开发用 127.0.0.1;服务器/容器用 0.0.0.0
|
||||
HOST=0.0.0.0
|
||||
|
||||
# 反向代理层数:使用 Nginx / 云负载均衡时填 1,用于正确识别客户端 IP(限流依赖)
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Web 管理端构建产物目录;留空则自动探测 packages/web-admin/dist 或 ./web
|
||||
WEB_DIST_DIR=
|
||||
|
||||
# 接口限流开关(压测时可临时置 false)
|
||||
RATE_LIMIT_ENABLED=true
|
||||
|
||||
# 启动时校验数据库连通性(生产建议 true:连不上直接启动失败,避免带病对外服务)
|
||||
STARTUP_DB_CHECK=true
|
||||
|
||||
# 数据库为空时自动执行迁移(SQLite 场景;安装程序首次启动用)
|
||||
AUTO_MIGRATE=false
|
||||
@@ -26,14 +26,18 @@
|
||||
"@prisma/adapter-libsql": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"compression": "^1.8.2",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"helmet": "^8.3.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "^4.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
||||
@@ -1,36 +1,93 @@
|
||||
import express, { type Express, type Request, type Response } from 'express';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import { API_PREFIX } from '@classhelper/shared';
|
||||
import { env } from './config/env.js';
|
||||
import { pingDatabase } from './lib/db.js';
|
||||
import { sendOk } from './lib/http.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { mountWebAdmin, resolveWebDistDir } from './lib/web-static.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error.js';
|
||||
import { apiRateLimiter, loginRateLimiter, requestLogger, securityHeaders } from './middleware/security.js';
|
||||
import { apiModules } from './modules/registry.js';
|
||||
|
||||
/** 存活探针:进程活着即 200(供容器 livenessProbe / 守护进程使用) */
|
||||
function livenessHandler(_req: Request, res: Response): void {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { status: 'alive', uptimeSeconds: Math.round(process.uptime()) },
|
||||
message: 'ok',
|
||||
});
|
||||
}
|
||||
|
||||
/** 就绪探针:数据库可连才 200,否则 503(供负载均衡摘流 / 安装程序自检) */
|
||||
async function readinessHandler(_req: Request, res: Response): Promise<void> {
|
||||
const connected = await pingDatabase();
|
||||
if (!connected) {
|
||||
res
|
||||
.status(503)
|
||||
.json({ success: false, data: { database: false }, message: '数据库不可用', code: 'NOT_READY' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: { database: true }, message: 'ready' });
|
||||
}
|
||||
|
||||
async function healthHandler(_req: Request, res: Response): Promise<void> {
|
||||
sendOk(
|
||||
res,
|
||||
{
|
||||
status: 'ok',
|
||||
version: '0.1.0',
|
||||
env: env.nodeEnv,
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
database: {
|
||||
provider: env.databaseProvider,
|
||||
connected: await pingDatabase(),
|
||||
},
|
||||
modules: apiModules.map((module) => module.name),
|
||||
webAdmin: env.webDistDir ? 'configured' : 'auto',
|
||||
},
|
||||
'服务运行正常',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根路径落地页。
|
||||
* API 全部挂在 /api 下,根路径本身不是接口;这里给出一个可读的服务首页,
|
||||
* 避免浏览器直接访问 4000 端口时看到 404 JSON 而误以为服务没起来。
|
||||
* 根路径。
|
||||
* - 已托管 Web 管理端且客户端接受 HTML:交给静态资源与 SPA 回退处理(即打开管理端首页)
|
||||
* - 否则返回服务信息页(浏览器)或 JSON(API 客户端)
|
||||
*/
|
||||
function renderLandingPage(): string {
|
||||
function createRootHandler(webDistDir: string | null) {
|
||||
return (req: Request, res: Response, next: express.NextFunction): void => {
|
||||
if (webDistDir && req.accepts(['html', 'json']) === 'html') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.accepts(['html', 'json']) === 'json') {
|
||||
sendOk(
|
||||
res,
|
||||
{
|
||||
service: 'class-helper-server',
|
||||
version: '0.1.0',
|
||||
environment: env.nodeEnv,
|
||||
apiPrefix: API_PREFIX,
|
||||
socket: '/socket.io',
|
||||
webAdmin: webDistDir ? '/' : 'http://127.0.0.1:5173',
|
||||
health: `${API_PREFIX}/health`,
|
||||
liveness: '/healthz',
|
||||
readiness: '/readyz',
|
||||
modules: apiModules.map((module) => `${API_PREFIX}${module.basePath}`),
|
||||
},
|
||||
'服务运行正常,API 前缀为 /api',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.type('html').send(renderLandingPage(Boolean(webDistDir)));
|
||||
};
|
||||
}
|
||||
|
||||
function renderLandingPage(webAdminServed: boolean): string {
|
||||
const moduleRows = apiModules
|
||||
.map((module) => `<li><code>${API_PREFIX}${module.basePath}</code> <span>${module.name}</span></li>`)
|
||||
.join('');
|
||||
@@ -42,9 +99,9 @@ function renderLandingPage(): string {
|
||||
<title>班级小助手 · 后端服务</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.card { max-width: 720px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
|
||||
.card { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
|
||||
h1 { margin: 0 0 6px; font-size: 20px; }
|
||||
p.sub { margin: 0 0 20px; color: #909399; font-size: 13px; }
|
||||
p.sub { margin: 0 0 8px; color: #909399; font-size: 13px; }
|
||||
.ok { display: inline-block; padding: 2px 10px; border-radius: 999px; background: #f0f9eb; color: #67c23a; font-size: 12px; }
|
||||
ul { columns: 2; padding-left: 18px; margin: 12px 0 0; }
|
||||
li { margin: 6px 0; font-size: 13px; }
|
||||
@@ -52,6 +109,7 @@ function renderLandingPage(): string {
|
||||
code { background: #f5f7fa; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
|
||||
a { color: #409eff; text-decoration: none; }
|
||||
.hint { margin-top: 22px; padding-top: 16px; border-top: 1px dashed #e4e7ed; font-size: 13px; color: #606266; line-height: 1.9; }
|
||||
.btn { display: inline-block; margin-top: 4px; padding: 8px 16px; background: #409eff; color: #fff !important; border-radius: 6px; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -59,63 +117,63 @@ function renderLandingPage(): string {
|
||||
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
|
||||
<p class="sub">版本 0.1.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
|
||||
<p class="sub">
|
||||
健康检查:<a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
|
||||
管理端开发地址:<a href="http://127.0.0.1:5173">http://127.0.0.1:5173</a>
|
||||
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
|
||||
存活 /healthz · 就绪 /readyz
|
||||
</p>
|
||||
${webAdminServed ? '<a class="btn" href="/">打开 Web 管理端</a>' : '<p class="sub">Web 管理端未随本服务托管,请单独启动(pnpm dev:web → http://127.0.0.1:5173)。</p>'}
|
||||
<strong>已挂载模块(${apiModules.length} 个)</strong>
|
||||
<ul>${moduleRows}</ul>
|
||||
<div class="hint">
|
||||
提示:本服务只提供 REST API 与 WebSocket,没有网页界面。<br />
|
||||
网页界面请访问 Web 管理端 <code>http://127.0.0.1:5173</code>(pnpm dev:web),
|
||||
WebSocket 端点为 <code>ws://127.0.0.1:${env.port}/socket.io</code>。
|
||||
WebSocket 端点:<code>ws://${env.host === '0.0.0.0' ? '本机地址' : env.host}:${env.port}/socket.io</code><br />
|
||||
本服务只提供 REST API 与 WebSocket;学生端请使用「班级小助手」桌面客户端。
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** 组装 Express 应用:中间件 -> 健康检查 -> 模块路由 -> 404 -> 统一错误处理 */
|
||||
/** 组装 Express 应用:安全中间件 -> 健康检查 -> 模块路由 -> Web 静态托管 -> 404 -> 统一错误处理 */
|
||||
export function createApp(): Express {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// 反向代理场景下正确识别客户端 IP(限流、日志依赖)
|
||||
app.set('trust proxy', env.trustProxy);
|
||||
|
||||
const webDistDir = resolveWebDistDir();
|
||||
|
||||
app.use(securityHeaders());
|
||||
app.use(compression());
|
||||
app.use(cors({ origin: env.corsOrigins, credentials: true }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(requestLogger());
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
logger.debug(`${req.method} ${req.originalUrl}`);
|
||||
next();
|
||||
// 探针(不参与限流)
|
||||
app.get('/healthz', livenessHandler);
|
||||
app.get('/readyz', (req, res) => {
|
||||
void readinessHandler(req, res);
|
||||
});
|
||||
app.get(`${API_PREFIX}/health`, (req, res) => {
|
||||
void healthHandler(req, res);
|
||||
});
|
||||
app.get('/health', (req, res) => {
|
||||
void healthHandler(req, res);
|
||||
});
|
||||
|
||||
// 服务首页(浏览器可读);如果客户端显式要 JSON,则返回结构化信息
|
||||
app.get('/', (req, res) => {
|
||||
if (req.accepts(['html', 'json']) === 'json') {
|
||||
sendOk(
|
||||
res,
|
||||
{
|
||||
service: 'class-helper-server',
|
||||
version: '0.1.0',
|
||||
apiPrefix: API_PREFIX,
|
||||
webAdmin: 'http://127.0.0.1:5173',
|
||||
socket: `/socket.io`,
|
||||
modules: apiModules.map((module) => `${API_PREFIX}${module.basePath}`),
|
||||
},
|
||||
'服务运行正常,API 前缀为 /api',
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.type('html').send(renderLandingPage());
|
||||
});
|
||||
// 根路径信息页(Web 管理端托管时让位给前端首页)
|
||||
app.get('/', createRootHandler(webDistDir));
|
||||
|
||||
// 浏览器会自动请求 favicon,直接返回 204,避免污染日志
|
||||
app.get('/favicon.ico', (_req, res) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
// 浏览器会自动请求 favicon;有前端产物时交给静态托管处理
|
||||
if (!webDistDir) {
|
||||
app.get('/favicon.ico', (_req, res) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
}
|
||||
|
||||
// 健康检查(同时暴露已挂载模块列表,便于确认模块化装配结果)
|
||||
app.get(`${API_PREFIX}/health`, healthHandler);
|
||||
app.get('/health', healthHandler);
|
||||
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
|
||||
app.use(API_PREFIX, apiRateLimiter());
|
||||
app.use(`${API_PREFIX}/auth/login`, loginRateLimiter());
|
||||
|
||||
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
|
||||
for (const module of apiModules) {
|
||||
@@ -124,6 +182,9 @@ export function createApp(): Express {
|
||||
logger.info(`已挂载模块 ${module.name.padEnd(14)} -> ${mountPath}`);
|
||||
}
|
||||
|
||||
// Web 管理端静态资源 + SPA 回退(必须放在 API 之后)
|
||||
mountWebAdmin(app);
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
@@ -10,12 +10,17 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
*/
|
||||
export const serverRoot = path.resolve(currentDir, '..', '..');
|
||||
|
||||
// 显式按包根目录加载 .env,避免受启动时 cwd 影响
|
||||
// 显式按包根目录加载 .env,避免受启动时 cwd 影响。
|
||||
// 打包安装后配置文件放在安装根目录(server 的上一级),因此按顺序尝试两处;
|
||||
// dotenv 默认不覆盖已存在的变量,所以先匹配到的文件优先生效。
|
||||
loadEnv({ path: path.join(serverRoot, '.env') });
|
||||
loadEnv({ path: path.join(serverRoot, '..', '.env') });
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
|
||||
/** 监听地址:生产环境建议 0.0.0.0(容器/内网访问),本地开发可用 127.0.0.1 */
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
DATABASE_PROVIDER: z.enum(['sqlite', 'mysql']).default('sqlite'),
|
||||
DATABASE_URL: z.string().min(1).default('file:./prisma/dev.db'),
|
||||
JWT_SECRET: z.string().min(16),
|
||||
@@ -25,6 +30,30 @@ const envSchema = z.object({
|
||||
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
||||
TERM_START_DATE: z.string().default(''),
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
|
||||
TRUST_PROXY: z.string().default('false'),
|
||||
/** Web 管理端构建产物目录;留空则自动探测 ../web-admin/dist 或 ./web */
|
||||
WEB_DIST_DIR: z.string().default(''),
|
||||
/** 是否启用接口限流(压测时可置 false) */
|
||||
RATE_LIMIT_ENABLED: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((value) => value !== 'false' && value !== '0'),
|
||||
/** 启动时是否校验数据库连通性 */
|
||||
STARTUP_DB_CHECK: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((value) => value !== 'false' && value !== '0'),
|
||||
/** 自动初始化:数据库为空时是否自动执行迁移(安装程序首启动用) */
|
||||
AUTO_MIGRATE: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true' || value === '1'),
|
||||
/** 进程 PID 文件(相对路径以 server 根目录为基准),供安装包的启停脚本使用 */
|
||||
PID_FILE: z.string().default(''),
|
||||
/** 首次初始化(空库)时创建的管理员账号 */
|
||||
INITIAL_ADMIN_USERNAME: z.string().min(3).default('admin'),
|
||||
INITIAL_ADMIN_PASSWORD: z.string().min(6).default('admin123'),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
@@ -46,16 +75,57 @@ function resolveSqliteUrl(url: string): string {
|
||||
return `file:${absolute.replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
/** 从 file: URL 中取出真实的文件系统路径(供启动前建目录等使用) */
|
||||
function sqliteFilePathOf(url: string): string {
|
||||
const withoutPrefix = url.startsWith('file:') ? url.slice('file:'.length) : url;
|
||||
return withoutPrefix.replace(/\\/g, path.sep).replace(/\//g, path.sep);
|
||||
}
|
||||
|
||||
const isSqlite = raw.DATABASE_PROVIDER === 'sqlite';
|
||||
const isProduction = raw.NODE_ENV === 'production';
|
||||
|
||||
const resolvedDatabaseUrl = isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL;
|
||||
/** SQLite 数据文件的绝对路径(MySQL 时为空字符串),供启动前创建目录使用 */
|
||||
const sqliteFilePath = isSqlite ? sqliteFilePathOf(resolvedDatabaseUrl) : '';
|
||||
|
||||
/** 生产环境配置自检:不安全的默认值直接拒绝启动,避免把开发配置带上生产 */
|
||||
const INSECURE_SECRET_MARKERS = ['change-me', 'changeme', 'secret', 'classhelper-dev'];
|
||||
if (isProduction) {
|
||||
const problems: string[] = [];
|
||||
if (raw.JWT_SECRET.length < 32) problems.push('JWT_SECRET 长度必须 ≥ 32 位');
|
||||
if (INSECURE_SECRET_MARKERS.some((marker) => raw.JWT_SECRET.toLowerCase().includes(marker))) {
|
||||
problems.push('JWT_SECRET 仍是示例/开发用值,请重新生成');
|
||||
}
|
||||
if (isSqlite && raw.DATABASE_URL.includes('dev.db')) {
|
||||
problems.push('生产环境仍在使用 dev.db,请指定正式的数据文件或改用 MySQL');
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`生产环境(NODE_ENV=production)配置检查未通过:\n${problems.map((item) => ` - ${item}`).join('\n')}\n` +
|
||||
'请修改 packages/server/.env 后重试。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** trust proxy 支持 false / 数字跳数 / loopback 等字符串形式 */
|
||||
function resolveTrustProxy(value: string): boolean | number | string {
|
||||
if (value === 'false' || value === '') return false;
|
||||
if (value === 'true') return true;
|
||||
if (/^\d+$/.test(value)) return Number(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
nodeEnv: raw.NODE_ENV,
|
||||
isProduction: raw.NODE_ENV === 'production',
|
||||
isProduction,
|
||||
isDevelopment: raw.NODE_ENV === 'development',
|
||||
port: raw.PORT,
|
||||
host: raw.HOST,
|
||||
databaseProvider: raw.DATABASE_PROVIDER,
|
||||
/** 已规范化为绝对路径(SQLite)/ 原始连接串(MySQL) */
|
||||
databaseUrl: isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL,
|
||||
databaseUrl: resolvedDatabaseUrl,
|
||||
/** SQLite 数据文件绝对路径(MySQL 时为空) */
|
||||
sqliteFilePath,
|
||||
rawDatabaseUrl: raw.DATABASE_URL,
|
||||
jwtSecret: raw.JWT_SECRET,
|
||||
jwtExpiresIn: raw.JWT_EXPIRES_IN,
|
||||
@@ -71,6 +141,15 @@ export const env = {
|
||||
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
||||
termStartDate: raw.TERM_START_DATE,
|
||||
logLevel: raw.LOG_LEVEL,
|
||||
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
|
||||
webDistDir: raw.WEB_DIST_DIR,
|
||||
rateLimitEnabled: raw.RATE_LIMIT_ENABLED,
|
||||
startupDbCheck: raw.STARTUP_DB_CHECK,
|
||||
autoMigrate: raw.AUTO_MIGRATE,
|
||||
/** PID 文件的绝对路径(未配置时为空字符串) */
|
||||
pidFile: raw.PID_FILE ? path.resolve(serverRoot, raw.PID_FILE) : '',
|
||||
initialAdminUsername: raw.INITIAL_ADMIN_USERNAME,
|
||||
initialAdminPassword: raw.INITIAL_ADMIN_PASSWORD,
|
||||
serverRoot,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,24 +1,90 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
import { API_PREFIX } from '@classhelper/shared';
|
||||
import { createApp } from './app.js';
|
||||
import { env } from './config/env.js';
|
||||
import { disconnectPrisma, pingDatabase } from './lib/db.js';
|
||||
import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { resolveWebDistDir } from './lib/web-static.js';
|
||||
import { initRealtime } from './realtime/socket.js';
|
||||
|
||||
/** 数据库自检与可选的首次初始化 */
|
||||
async function prepareDatabase(): Promise<void> {
|
||||
// SQLite:确保数据文件所在目录存在,否则 libsql 会以 SQLITE_CANTOPEN(14) 失败
|
||||
if (env.sqliteFilePath) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(env.sqliteFilePath), { recursive: true });
|
||||
} catch (error) {
|
||||
logger.warn(`创建数据目录失败:${path.dirname(env.sqliteFilePath)}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (env.autoMigrate) {
|
||||
try {
|
||||
const result = await ensureSchema();
|
||||
logger.info(`数据库初始化:${result.detail}`);
|
||||
const admin = await ensureInitialAdmin();
|
||||
if (admin.created) logger.info('可使用该账号登录 Web 管理端,并立即修改密码');
|
||||
} catch (error) {
|
||||
logger.error('数据库初始化失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.startupDbCheck) return;
|
||||
|
||||
if (await pingDatabase()) {
|
||||
logger.info('数据库连接正常');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
'数据库连接失败:请检查 DATABASE_URL,并确认已执行迁移(pnpm db:migrate / prisma migrate deploy)',
|
||||
);
|
||||
if (env.isProduction) {
|
||||
logger.error('生产环境启动中止:数据库不可用时不应对外提供服务');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await prepareDatabase();
|
||||
|
||||
const app = createApp();
|
||||
const httpServer = createServer(app);
|
||||
const io = initRealtime(httpServer);
|
||||
|
||||
if (!(await pingDatabase())) {
|
||||
logger.warn('数据库连接失败:请先执行 pnpm db:migrate 与 pnpm db:seed');
|
||||
/** 写入 PID 文件(安装包的 start.cmd / stop.cmd 依赖它精确控制进程) */
|
||||
function writePidFile(): void {
|
||||
if (!env.pidFile) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(env.pidFile), { recursive: true });
|
||||
fs.writeFileSync(env.pidFile, String(process.pid), 'utf8');
|
||||
} catch (error) {
|
||||
logger.warn('写入 PID 文件失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
httpServer.listen(env.port, () => {
|
||||
logger.info(`班级小助手后端已启动:http://127.0.0.1:${env.port}${API_PREFIX}`);
|
||||
logger.info(`健康检查:http://127.0.0.1:${env.port}${API_PREFIX}/health`);
|
||||
logger.info(`WebSocket:ws://127.0.0.1:${env.port}/socket.io`);
|
||||
logger.info(`运行环境:${env.nodeEnv},数据库:${env.databaseProvider}`);
|
||||
function removePidFile(): void {
|
||||
if (!env.pidFile) return;
|
||||
try {
|
||||
if (fs.existsSync(env.pidFile)) fs.unlinkSync(env.pidFile);
|
||||
} catch {
|
||||
// 忽略清理失败
|
||||
}
|
||||
}
|
||||
|
||||
httpServer.listen(env.port, env.host, () => {
|
||||
writePidFile();
|
||||
const displayHost = env.host === '0.0.0.0' ? '127.0.0.1' : env.host;
|
||||
const webDir = resolveWebDistDir();
|
||||
logger.info(`班级小助手后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
|
||||
logger.info(`运行环境:${env.nodeEnv} · 监听:${env.host}:${env.port} · 数据库:${env.databaseProvider}`);
|
||||
if (webDir) {
|
||||
logger.info(`Web 管理端:http://${displayHost}:${env.port}/(静态目录 ${webDir})`);
|
||||
}
|
||||
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
|
||||
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
@@ -28,9 +94,18 @@ async function shutdown(signal: string): Promise<void> {
|
||||
shuttingDown = true;
|
||||
logger.info(`收到 ${signal},正在优雅关闭...`);
|
||||
|
||||
// 停止接收新连接,并给在途请求 5 秒收尾时间
|
||||
const forceExit = setTimeout(() => {
|
||||
logger.warn('关闭超时,强制退出');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
forceExit.unref();
|
||||
|
||||
await new Promise<void>((resolve) => io.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
await disconnectPrisma();
|
||||
removePidFile();
|
||||
clearTimeout(forceExit);
|
||||
logger.info('已关闭所有连接');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -44,3 +119,7 @@ process.on('SIGTERM', () => {
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('未处理的 Promise 拒绝', reason);
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('未捕获异常,进程即将退出', error);
|
||||
void shutdown('uncaughtException');
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createClient } from '@libsql/client';
|
||||
import { env } from '../config/env.js';
|
||||
import { prisma } from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import { hashPassword } from './password.js';
|
||||
|
||||
/**
|
||||
* 首次启动时的数据库初始化(SQLite/libSQL)。
|
||||
*
|
||||
* 场景:安装程序首次运行、Docker 挂载了空数据卷、用户手动删除了数据库文件。
|
||||
* 做法:若目标库中还没有业务表,就把随包发布的 Prisma 迁移 SQL 依次执行一遍。
|
||||
*
|
||||
* 说明:
|
||||
* - 只处理 SQLite(本项目打包安装的默认形态);
|
||||
* MySQL 请在部署流程中执行 `prisma migrate deploy`(Docker 镜像已内置该步骤)。
|
||||
* - 迁移文件按目录名(时间戳)升序执行,与 Prisma 的行为一致。
|
||||
*/
|
||||
|
||||
/** 迁移文件所在目录(打包后随 server 一起发布) */
|
||||
function migrationsRoot(): string {
|
||||
const candidates = [
|
||||
env.isProduction ? path.resolve(env.serverRoot, 'prisma', 'migrations') : '',
|
||||
path.resolve(env.serverRoot, 'prisma', 'migrations'),
|
||||
path.resolve(env.serverRoot, '..', 'prisma', 'migrations'),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return path.resolve(env.serverRoot, 'prisma', 'migrations');
|
||||
}
|
||||
|
||||
/** 收集迁移 SQL 文件(按目录名升序) */
|
||||
export function listMigrationFiles(): string[] {
|
||||
const root = migrationsRoot();
|
||||
if (!fs.existsSync(root)) return [];
|
||||
|
||||
return fs
|
||||
.readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.map((name) => path.join(root, name, 'migration.sql'))
|
||||
.filter((file) => fs.existsSync(file));
|
||||
}
|
||||
|
||||
/** 业务表是否已存在(用 User 表作为探针) */
|
||||
async function schemaExists(): Promise<boolean> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<Array<{ name: string }>>`
|
||||
SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'User'
|
||||
`;
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapResult {
|
||||
applied: number;
|
||||
skipped: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化管理员账号。
|
||||
*
|
||||
* 全新安装的数据库是空的,没有任何账号可登录,因此首次启动(AUTO_MIGRATE=true)
|
||||
* 会创建一个管理员账号;如果使用的是默认密码,会在日志中给出醒目提示。
|
||||
*/
|
||||
export async function ensureInitialAdmin(): Promise<{ created: boolean; username: string }> {
|
||||
const existing = await prisma.user.count({ where: { role: 'ADMIN' } });
|
||||
if (existing > 0) return { created: false, username: env.initialAdminUsername };
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: env.initialAdminUsername,
|
||||
name: '系统管理员',
|
||||
role: 'ADMIN',
|
||||
passwordHash: await hashPassword(env.initialAdminPassword),
|
||||
},
|
||||
});
|
||||
|
||||
if (env.initialAdminPassword === 'admin123') {
|
||||
logger.warn(
|
||||
`已创建初始管理员 ${env.initialAdminUsername} / ${env.initialAdminPassword}(默认密码),请登录后立即在「修改密码」中更换`,
|
||||
);
|
||||
} else {
|
||||
logger.info(`已创建初始管理员账号:${env.initialAdminUsername}`);
|
||||
}
|
||||
|
||||
return { created: true, username: env.initialAdminUsername };
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保数据库结构存在。
|
||||
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。
|
||||
*/
|
||||
export async function ensureSchema(): Promise<BootstrapResult> {
|
||||
if (env.databaseProvider !== 'sqlite') {
|
||||
return {
|
||||
applied: 0,
|
||||
skipped: true,
|
||||
detail: '非 SQLite 数据库:请在部署流程中执行 prisma migrate deploy',
|
||||
};
|
||||
}
|
||||
|
||||
if (await schemaExists()) {
|
||||
return { applied: 0, skipped: true, detail: '数据库结构已存在,跳过初始化' };
|
||||
}
|
||||
|
||||
const files = listMigrationFiles();
|
||||
if (files.length === 0) {
|
||||
return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' };
|
||||
}
|
||||
|
||||
const client = createClient({ url: env.databaseUrl });
|
||||
let applied = 0;
|
||||
try {
|
||||
for (const file of files) {
|
||||
const sql = fs.readFileSync(file, 'utf8');
|
||||
await client.executeMultiple(sql);
|
||||
applied += 1;
|
||||
logger.info(`已应用迁移:${path.basename(path.dirname(file))}`);
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
|
||||
return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import express, { type Express, type RequestHandler } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Web 管理端静态托管(生产模式)。
|
||||
*
|
||||
* 部署形态:后端同时充当 Web 管理端的静态服务器 —— 单端口即可访问
|
||||
* `http://<host>:4000/` 打开管理端,`/api` 与 `/socket.io` 走同一端口,
|
||||
* 因此不再需要额外的 Nginx/Vite 进程(需要 HTTPS/域名时在前面加反向代理)。
|
||||
*
|
||||
* 目录探测顺序:
|
||||
* 1) 环境变量 WEB_DIST_DIR
|
||||
* 2) Monorepo 开发布局:<serverRoot>/../web-admin/dist
|
||||
* 3) 安装包布局:<serverRoot>/web
|
||||
*/
|
||||
export function resolveWebDistDir(): string | null {
|
||||
const candidates = [
|
||||
env.webDistDir,
|
||||
path.resolve(env.serverRoot, '..', 'web-admin', 'dist'),
|
||||
path.resolve(env.serverRoot, 'web'),
|
||||
path.resolve(env.serverRoot, '..', 'web'),
|
||||
].filter((item): item is string => Boolean(item));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(path.join(candidate, 'index.html'))) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂载静态资源与 SPA 回退。
|
||||
* 仅在探测到构建产物时生效;否则保持纯 API 服务(开发模式下 Web 端由 Vite 提供)。
|
||||
*/
|
||||
export function mountWebAdmin(app: Express): string | null {
|
||||
const distDir = resolveWebDistDir();
|
||||
if (!distDir) {
|
||||
logger.info('未检测到 Web 管理端构建产物,跳过静态托管(仅提供 API 与 WebSocket)');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 带内容哈希的构建产物:长缓存
|
||||
// dotfiles: 'allow' —— 安装/部署目录路径里可能含点号目录(如 .cache、/opt/.apps),
|
||||
// 默认的 'ignore' 会让 express.static / res.sendFile 直接 404(进而 500)
|
||||
app.use(
|
||||
'/assets',
|
||||
express.static(path.join(distDir, 'assets'), {
|
||||
immutable: true,
|
||||
maxAge: '1y',
|
||||
fallthrough: true,
|
||||
index: false,
|
||||
dotfiles: 'allow',
|
||||
}),
|
||||
);
|
||||
|
||||
// 其余静态文件(favicon、manifest、service worker 等):短缓存
|
||||
app.use(
|
||||
express.static(distDir, {
|
||||
maxAge: '1h',
|
||||
etag: true,
|
||||
index: false,
|
||||
dotfiles: 'allow',
|
||||
setHeaders: (res, filePath) => {
|
||||
// Service Worker 与 index.html 不能被长缓存,否则更新不生效
|
||||
if (filePath.endsWith('sw.js') || filePath.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const indexHtml = path.join(distDir, 'index.html');
|
||||
|
||||
/**
|
||||
* SPA 回退:非 /api、非 /socket.io、且接受 HTML 的 GET 请求统一返回 index.html,
|
||||
* 保证前端路由(/grades、/classes/xxx 等)刷新后仍可用。
|
||||
*/
|
||||
app.get(/^\/(?!api\/|socket\.io\/|healthz$|readyz$).*/, (req, res, next) => {
|
||||
if (!req.accepts('html')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(indexHtml, { dotfiles: 'allow' }, (error) => {
|
||||
if (!error) return;
|
||||
logger.error(`返回 Web 管理端首页失败:${indexHtml}`, error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
data: null,
|
||||
message: 'Web 管理端资源不可用,请检查构建产物是否完整',
|
||||
code: 'WEB_DIST_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logger.info(`Web 管理端已挂载:${distDir}`);
|
||||
return distDir;
|
||||
}
|
||||
|
||||
/** 静态资源请求判断(用于日志降噪等场景) */
|
||||
export const staticAssetMatcher: RequestHandler = (req, _res, next) => {
|
||||
req.headers['x-classhelper-static'] = req.path.startsWith('/assets/') ? '1' : '0';
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { env } from '../config/env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
|
||||
/**
|
||||
* 安全响应头(helmet)。
|
||||
*
|
||||
* CSP 说明:
|
||||
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
|
||||
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
|
||||
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
|
||||
*/
|
||||
export function securityHeaders(): RequestHandler {
|
||||
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
|
||||
if (Array.isArray(env.corsOrigins)) {
|
||||
for (const origin of env.corsOrigins) connectSrc.add(origin);
|
||||
}
|
||||
|
||||
return helmet({
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'"],
|
||||
'style-src': ["'self'", "'unsafe-inline'"],
|
||||
'img-src': ["'self'", 'data:', 'blob:'],
|
||||
'font-src': ["'self'", 'data:'],
|
||||
'connect-src': [...connectSrc],
|
||||
'object-src': ["'none'"],
|
||||
'base-uri': ["'self'"],
|
||||
'frame-ancestors': ["'none'"],
|
||||
'form-action': ["'self'"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginOpenerPolicy: false,
|
||||
// 允许桌面客户端等跨源场景读取静态资源与接口响应
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
referrerPolicy: { policy: 'no-referrer' },
|
||||
hsts: env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 通用接口限流:防止单 IP 高频刷接口 */
|
||||
export function apiRateLimiter(): RequestHandler {
|
||||
const limiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000,
|
||||
limit: env.isProduction ? 600 : 3000,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
skip: () => !env.rateLimitEnabled,
|
||||
handler: (req, res) => {
|
||||
logger.warn(`触发接口限流:${req.ip ?? '-'} ${req.method} ${req.originalUrl}`);
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
data: null,
|
||||
message: '请求过于频繁,请稍后再试',
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
});
|
||||
},
|
||||
});
|
||||
return limiter;
|
||||
}
|
||||
|
||||
/** 登录接口专用限流:防暴力破解 */
|
||||
export function loginRateLimiter(): RequestHandler {
|
||||
return rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
limit: env.isProduction ? 20 : 200,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true,
|
||||
skip: () => !env.rateLimitEnabled,
|
||||
handler: (req, res) => {
|
||||
logger.warn(`登录尝试过于频繁:${req.ip ?? '-'}`);
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
data: null,
|
||||
message: '登录尝试过于频繁,请 10 分钟后再试',
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 请求耗时日志(生产 info,开发 debug) */
|
||||
export function requestLogger(): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
res.on('finish', () => {
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
||||
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs.toFixed(1)}ms`;
|
||||
if (res.statusCode >= 500) logger.error(message);
|
||||
else if (res.statusCode >= 400) logger.warn(message);
|
||||
else if (env.isProduction) logger.info(message);
|
||||
else logger.debug(message);
|
||||
});
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
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 |
|
After Width: | Height: | Size: 41 KiB |
|
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);
|
||||
});
|
||||
});
|
||||
}
|
||||