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
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
|
||||
*
|
||||
* 产出:
|
||||
* release-server/classhelper-server/ 可直接运行的免安装目录
|
||||
* release-server/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
|
||||
*
|
||||
* 打包策略(面向"双击即可用、目标机无需安装 Node"):
|
||||
* 1. 内置 Node 运行时(复制本机 node.exe)—— 目标机无需安装 Node.js
|
||||
* 2. 依赖用 npm 安装为真实目录(非 pnpm 软链),保证可整体拷贝到别的机器
|
||||
* 3. 后端使用 tsc 构建产物(ESM),Web 管理端使用 Vite 构建产物
|
||||
* 4. 首次启动自动执行迁移(AUTO_MIGRATE=true)+ 自动生成 JWT 密钥 + PID 文件
|
||||
* 5. 用 electron-builder 自带的 NSIS(makensis)生成安装程序
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const serverDir = path.join(root, 'packages', 'server');
|
||||
const webDir = path.join(root, 'packages', 'web-admin');
|
||||
const outRoot = path.join(root, 'release-server');
|
||||
const staging = path.join(outRoot, 'classhelper-server');
|
||||
|
||||
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||||
const PRODUCT = '班级小助手服务端';
|
||||
const PORT = '4000';
|
||||
|
||||
const log = (message) => console.log(`[dist:server] ${message}`);
|
||||
|
||||
const run = (command, args, options = {}) => {
|
||||
// Windows 下 .cmd 必须通过 cmd.exe 启动(Node 20+ 不再允许直接 spawn .cmd)
|
||||
const needsShell = process.platform === 'win32' && command.toLowerCase().endsWith('.cmd');
|
||||
const result = spawnSync(command, args, { stdio: 'inherit', shell: needsShell, ...options });
|
||||
if (result.error) {
|
||||
throw new Error(`命令启动失败:${command} ${args.join(' ')} -> ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`命令失败(${result.status}):${command} ${args.join(' ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------ 1. 前置构建 */
|
||||
|
||||
function ensureBuilds() {
|
||||
const serverEntry = path.join(serverDir, 'dist', 'index.js');
|
||||
const webEntry = path.join(webDir, 'dist', 'index.html');
|
||||
const missing = [];
|
||||
if (!fs.existsSync(serverEntry))
|
||||
missing.push('packages/server/dist(请先 pnpm --filter @classhelper/server build)');
|
||||
if (!fs.existsSync(webEntry))
|
||||
missing.push('packages/web-admin/dist(请先 pnpm --filter @classhelper/web-admin build)');
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`缺少构建产物:\n - ${missing.join('\n - ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 2. 依赖安装 */
|
||||
|
||||
function serverProdDependencies() {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(serverDir, 'package.json'), 'utf8'));
|
||||
const deps = { ...pkg.dependencies };
|
||||
// 工作区内部包用拷贝的方式提供,不走 npm
|
||||
delete deps['@classhelper/shared'];
|
||||
return deps;
|
||||
}
|
||||
|
||||
/** 运行时依赖清单(同时供安装包与 Docker 运行时镜像使用,单一事实来源) */
|
||||
function writeRuntimeManifest() {
|
||||
const manifest = {
|
||||
name: 'classhelper-server-runtime',
|
||||
version: VERSION,
|
||||
private: true,
|
||||
type: 'module',
|
||||
description: '班级小助手服务端运行时(安装包 / 容器内置)',
|
||||
dependencies: serverProdDependencies(),
|
||||
};
|
||||
fs.writeFileSync(path.join(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
|
||||
const deployDir = path.join(root, 'deploy');
|
||||
fs.mkdirSync(deployDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(deployDir, 'package.runtime.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function installDependencies() {
|
||||
writeRuntimeManifest();
|
||||
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
log('安装生产依赖(npm install --omit=dev,真实目录,便于整体拷贝)...');
|
||||
run(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {
|
||||
cwd: staging,
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_cache: path.join(root, '.cache', 'npm-cache'),
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS ?? '--use-system-ca',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 3. 组装运行时 */
|
||||
|
||||
function copyRecursive(from, to) {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.cpSync(from, to, { recursive: true, dereference: true });
|
||||
}
|
||||
|
||||
function buildRuntime() {
|
||||
// 后端代码:保留 dist 层级,保证代码里 serverRoot 的计算(../..)与开发环境一致
|
||||
copyRecursive(path.join(serverDir, 'dist'), path.join(staging, 'server', 'dist'));
|
||||
// 迁移文件(首次启动 AUTO_MIGRATE 使用)
|
||||
copyRecursive(
|
||||
path.join(serverDir, 'prisma', 'migrations'),
|
||||
path.join(staging, 'server', 'prisma', 'migrations'),
|
||||
);
|
||||
// 工作区共享包(tsc 产物)
|
||||
const sharedTarget = path.join(staging, 'node_modules', '@classhelper', 'shared');
|
||||
fs.mkdirSync(sharedTarget, { recursive: true });
|
||||
copyRecursive(path.join(root, 'packages', 'shared', 'dist'), path.join(sharedTarget, 'dist'));
|
||||
fs.writeFileSync(
|
||||
path.join(sharedTarget, 'package.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: '@classhelper/shared',
|
||||
version: VERSION,
|
||||
private: true,
|
||||
type: 'module',
|
||||
main: './dist/index.js',
|
||||
types: './dist/index.d.ts',
|
||||
exports: { '.': { types: './dist/index.d.ts', default: './dist/index.js' } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Web 管理端
|
||||
copyRecursive(path.join(webDir, 'dist'), path.join(staging, 'web'));
|
||||
|
||||
// Node 运行时
|
||||
const nodeExe = process.execPath;
|
||||
copyRecursive(nodeExe, path.join(staging, 'node.exe'));
|
||||
log(`内置 Node 运行时:${nodeExe}`);
|
||||
|
||||
// 数据目录:强制清空,避免把打包机上测试用的数据库带进安装包
|
||||
fs.rmSync(path.join(staging, 'data'), { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(staging, 'data'), { recursive: true });
|
||||
fs.mkdirSync(path.join(staging, 'logs'), { recursive: true });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 4. 配置与脚本 */
|
||||
|
||||
function writeEnvironmentFile() {
|
||||
const jwtSecret = crypto.randomBytes(48).toString('hex');
|
||||
const content = `# 班级小助手服务端(安装程序生成,请妥善保管)
|
||||
# 修改后需重启服务生效(开始菜单 → 班级小助手服务端 → 重启服务)
|
||||
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=${PORT}
|
||||
|
||||
# 数据库:SQLite 文件位于安装目录的 data 子目录
|
||||
DATABASE_PROVIDER=sqlite
|
||||
DATABASE_URL="file:../data/classhelper.db"
|
||||
# 首次启动时自动建表(数据库为空时执行随包的迁移 SQL)
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
# 登录令牌签名密钥(首次安装随机生成,请勿泄露/更换,更换后所有用户需重新登录)
|
||||
JWT_SECRET=${jwtSecret}
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 允许跨域来源:* 表示全部允许;若通过域名访问建议改成具体来源,例如 https://class.example.com
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# 反代场景请设为 1,保证限流按真实客户端 IP 统计
|
||||
TRUST_PROXY=0
|
||||
# 安全与运维
|
||||
RATE_LIMIT_ENABLED=true
|
||||
STARTUP_DB_CHECK=true
|
||||
PID_FILE=../data/server.pid
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 可选项
|
||||
BCRYPT_ROUNDS=10
|
||||
DEFAULT_STUDENT_PASSWORD=123456
|
||||
TERM_START_DATE=
|
||||
`;
|
||||
fs.writeFileSync(path.join(staging, '.env'), content, 'utf8');
|
||||
}
|
||||
|
||||
function writeRuntimeScripts() {
|
||||
// 注意:.cmd 内容保持 ASCII,避免 cmd.exe 按 OEM 代码页(GBK)解析 UTF-8 中文出错;
|
||||
// 中文说明放在 README.txt 与安装向导里。
|
||||
// 启动采用 PowerShell Start-Process 分离进程 + 输出重定向到日志文件,
|
||||
// 避免 `start` 在管道/非交互环境下让子进程继承句柄而卡住调用方。
|
||||
const startCmd = `@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "PID="
|
||||
if exist "data\\server.pid" set /p PID=<"data\\server.pid"
|
||||
|
||||
if defined PID (
|
||||
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
|
||||
if not errorlevel 1 (
|
||||
echo [ClassHelper] Server is already running ^(PID %PID%^).
|
||||
echo [ClassHelper] Admin UI: http://127.0.0.1:${PORT}/
|
||||
exit /b 0
|
||||
)
|
||||
)
|
||||
|
||||
if not exist "logs" mkdir "logs"
|
||||
echo [ClassHelper] Starting server...
|
||||
|
||||
powershell -NoLogo -NoProfile -NonInteractive -Command ^
|
||||
"Start-Process -FilePath '%~dp0node.exe' -ArgumentList '%~dp0server\\dist\\index.js' -WorkingDirectory '%~dp0' -WindowStyle Hidden -RedirectStandardOutput '%~dp0logs\\server.log' -RedirectStandardError '%~dp0logs\\server.error.log'"
|
||||
|
||||
echo [ClassHelper] Waiting for service to become ready...
|
||||
for /l %%i in (1,1,30) do (
|
||||
powershell -NoLogo -NoProfile -NonInteractive -Command "try { $null = Invoke-WebRequest -Uri 'http://127.0.0.1:${PORT}/healthz' -UseBasicParsing -TimeoutSec 2; exit 0 } catch { exit 1 }" >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo [ClassHelper] Ready: http://127.0.0.1:${PORT}/
|
||||
exit /b 0
|
||||
)
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
)
|
||||
|
||||
echo [ClassHelper] Start timed out. Check logs\\server.error.log for details.
|
||||
exit /b 1
|
||||
`;
|
||||
|
||||
const stopCmd = `@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
if not exist "data\\server.pid" (
|
||||
echo [ClassHelper] No PID file found - server is not running.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
set /p PID=<"data\\server.pid"
|
||||
echo [ClassHelper] Stopping server ^(PID %PID%^)...
|
||||
powershell -NoLogo -NoProfile -NonInteractive -Command "try { Stop-Process -Id %PID% -Force -ErrorAction Stop; exit 0 } catch { exit 1 }" >nul 2>&1
|
||||
if errorlevel 1 taskkill /PID %PID% /T /F >nul 2>&1
|
||||
del /q "data\\server.pid" >nul 2>&1
|
||||
echo [ClassHelper] Stopped.
|
||||
`;
|
||||
|
||||
const restartCmd = `@echo off
|
||||
call "%~dp0stop.cmd"
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
call "%~dp0start.cmd"
|
||||
`;
|
||||
|
||||
const statusCmd = `@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
cd /d "%~dp0"
|
||||
|
||||
if exist "data\\server.pid" (
|
||||
set /p PID=<"data\\server.pid"
|
||||
powershell -NoLogo -NoProfile -NonInteractive -Command "if (Get-Process -Id !PID! -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo [ClassHelper] Running ^(PID !PID!^)
|
||||
powershell -NoLogo -NoProfile -NonInteractive -Command "try { $r = Invoke-WebRequest -Uri 'http://127.0.0.1:${PORT}/api/health' -UseBasicParsing -TimeoutSec 3; Write-Host ' HTTP:' $r.StatusCode } catch { Write-Host ' HTTP: unreachable' }"
|
||||
echo [ClassHelper] Admin UI: http://127.0.0.1:${PORT}/
|
||||
exit /b 0
|
||||
)
|
||||
)
|
||||
echo [ClassHelper] Not running.
|
||||
exit /b 1
|
||||
`;
|
||||
|
||||
const serviceCmd = `@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
net session >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ClassHelper] Administrator privileges are required. Right-click and run as administrator.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [ClassHelper] Registering scheduled task (auto start at boot)...
|
||||
schtasks /Create /TN "ClassHelperServer" /TR "\\"%~dp0node.exe\\" \\"%~dp0server\\dist\\index.js\\"" /SC ONSTART /RL HIGHEST /F
|
||||
schtasks /Run /TN "ClassHelperServer"
|
||||
echo [ClassHelper] Done.
|
||||
echo start : schtasks /Run /TN ClassHelperServer
|
||||
echo stop : schtasks /End /TN ClassHelperServer
|
||||
echo remove: schtasks /Delete /TN ClassHelperServer /F
|
||||
pause
|
||||
`;
|
||||
|
||||
const unserviceCmd = `@echo off
|
||||
setlocal
|
||||
net session >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ClassHelper] Administrator privileges are required.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
schtasks /End /TN "ClassHelperServer" >nul 2>&1
|
||||
schtasks /Delete /TN "ClassHelperServer" /F
|
||||
echo [ClassHelper] Scheduled task removed.
|
||||
pause
|
||||
`;
|
||||
|
||||
const files = {
|
||||
'start.cmd': startCmd,
|
||||
'stop.cmd': stopCmd,
|
||||
'restart.cmd': restartCmd,
|
||||
'status.cmd': statusCmd,
|
||||
'install-service.cmd': serviceCmd,
|
||||
'uninstall-service.cmd': unserviceCmd,
|
||||
};
|
||||
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(staging, name), content.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function writeReadme() {
|
||||
const content = `${PRODUCT} v${VERSION}
|
||||
========================================
|
||||
|
||||
一、启动与访问
|
||||
1. 双击「start.cmd」(或开始菜单 → 班级小助手服务端 → 启动服务)
|
||||
2. 浏览器打开 http://127.0.0.1:${PORT}/ 即可进入 Web 管理端
|
||||
3. 安装程序已把本服务加入当前用户的开机自启项,重启电脑后无需手动启动
|
||||
|
||||
二、默认账号(首次安装后请立即修改密码)
|
||||
管理员 admin / admin123
|
||||
教师 teacher1 / teacher123、teacher2 / teacher123
|
||||
学生 student01 ~ student15 / student123(桌面客户端使用)
|
||||
|
||||
三、目录说明
|
||||
server\\ 后端程序(server\\dist\\index.js 为入口)
|
||||
web\\ Web 管理端(由后端直接托管)
|
||||
data\\ 数据库文件与 PID 文件(备份时复制此目录即可)
|
||||
logs\\ 预留日志目录
|
||||
.env 全部配置项(端口、数据库、密钥、跨域等)
|
||||
node.exe 内置 Node 运行时(无需在系统中安装 Node.js)
|
||||
|
||||
四、常用操作
|
||||
start.cmd 启动服务并等待就绪
|
||||
stop.cmd 停止服务
|
||||
restart.cmd 重启服务
|
||||
status.cmd 查看运行状态
|
||||
install-service.cmd 注册为开机启动的计划任务(需管理员身份运行)
|
||||
uninstall-service.cmd 移除计划任务(需管理员身份运行)
|
||||
|
||||
五、升级
|
||||
1. 先执行 stop.cmd 停止服务
|
||||
2. 备份 data 目录
|
||||
3. 运行新版本安装程序覆盖安装(.env 会被保留,不会重置密钥与数据库)
|
||||
4. 执行 start.cmd
|
||||
|
||||
六、安全建议
|
||||
- 修改 admin / teacher 的默认密码
|
||||
- 如需通过公网访问,请在前面加 Nginx/Caddy 反向代理并启用 HTTPS(见 docs/production.md)
|
||||
- 定期备份 data 目录;更换 JWT_SECRET 会导致所有用户需要重新登录
|
||||
`;
|
||||
fs.writeFileSync(path.join(staging, 'README.txt'), content.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||||
|
||||
// 许可证/说明文件(安装向导的许可页读取它)
|
||||
const license = `${PRODUCT} v${VERSION} 使用许可与说明
|
||||
|
||||
1. 用途
|
||||
本程序用于班级信息管理(班级、课表、作业、通知、成绩),由后端服务与 Web 管理端组成。
|
||||
|
||||
2. 运行方式
|
||||
安装后将在本机启动一个 HTTP 服务(默认端口 ${PORT}),并把 Web 管理端一并托管。
|
||||
该服务默认监听 0.0.0.0,同一局域网内的设备可通过本机 IP 访问;
|
||||
若需公网访问,请务必在反向代理上启用 HTTPS 并修改默认密码。
|
||||
|
||||
3. 数据与备份
|
||||
全部数据保存在安装目录的 data 目录(SQLite 文件)。定期复制该目录即为完整备份。
|
||||
|
||||
4. 卸载
|
||||
卸载程序会停止服务并删除程序文件,但会把 data 目录重命名为 data.backup 保留,
|
||||
如需彻底删除请手动删除该目录。
|
||||
|
||||
5. 免责
|
||||
本程序按"现状"提供,请在使用前自行评估并做好数据备份。
|
||||
`;
|
||||
fs.writeFileSync(path.join(staging, 'LICENSE.txt'), license.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 5. NSIS 安装程序 */
|
||||
|
||||
function findMakensis() {
|
||||
const cacheRoot = path.join(root, '.cache', 'electron-builder');
|
||||
const candidates = [];
|
||||
const walk = (dir, depth = 0) => {
|
||||
if (depth > 3 || !fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, depth + 1);
|
||||
else if (entry.name.toLowerCase() === 'makensis.exe') candidates.push(full);
|
||||
}
|
||||
};
|
||||
walk(cacheRoot);
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function buildInstaller() {
|
||||
const makensis = findMakensis();
|
||||
if (!makensis) {
|
||||
log('未找到 makensis(NSIS),跳过安装程序生成。');
|
||||
log('可执行一次 pnpm dist:win 让 electron-builder 下载 NSIS,或手动安装 NSIS 后重试。');
|
||||
return null;
|
||||
}
|
||||
|
||||
const outFile = path.join(outRoot, `${PRODUCT}-${VERSION}-x64-setup.exe`);
|
||||
const templatePath = path.join(root, 'scripts', 'nsis', 'server-installer.nsi');
|
||||
const script = fs
|
||||
.readFileSync(templatePath, 'utf8')
|
||||
.replaceAll('@PRODUCT@', PRODUCT)
|
||||
.replaceAll('@VERSION@', VERSION)
|
||||
.replaceAll('@STAGING@', staging)
|
||||
.replaceAll('@OUTFILE@', outFile)
|
||||
.replaceAll('@ICON@', path.join(root, 'build', 'icon.ico'))
|
||||
.replaceAll('@PORT@', PORT);
|
||||
|
||||
const generatedScript = path.join(outRoot, 'server-installer.generated.nsi');
|
||||
// NSIS 通过 BOM 识别 UTF-8,脚本含中文必须写入 BOM,否则报 "Bad text encoding"
|
||||
fs.writeFileSync(generatedScript, `\uFEFF${script}`, 'utf8');
|
||||
|
||||
// 防御:若上一次的产物仍被占用(例如同时还开着安装程序),先尝试删除并给出明确提示
|
||||
if (fs.existsSync(outFile)) {
|
||||
try {
|
||||
fs.rmSync(outFile, { force: true });
|
||||
} catch {
|
||||
throw new Error(
|
||||
`无法覆盖已有安装程序(可能仍在运行/被杀毒软件占用):${outFile}\n请关闭该安装程序后重试。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log(`生成安装程序:${path.relative(root, outFile)}`);
|
||||
run(makensis, ['/V2', generatedScript], { cwd: outRoot });
|
||||
return outFile;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 主流程 */
|
||||
|
||||
function main() {
|
||||
log(`版本 ${VERSION}`);
|
||||
ensureBuilds();
|
||||
|
||||
// 迭代打包时可加 --reuse-deps 复用已安装的 node_modules,跳过 2 分钟的 npm install
|
||||
const reuseDeps = process.argv.includes('--reuse-deps');
|
||||
const hasDeps = fs.existsSync(path.join(staging, 'node_modules'));
|
||||
|
||||
if (!(reuseDeps && hasDeps)) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
} else {
|
||||
log('复用已安装的 node_modules(--reuse-deps)');
|
||||
// 只保留依赖安装结果,其余目录重新生成,避免残留上一次打包的文件
|
||||
for (const dir of ['server', 'web', 'logs']) {
|
||||
fs.rmSync(path.join(staging, dir), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
|
||||
log('组装运行时...');
|
||||
writeRuntimeManifest();
|
||||
if (reuseDeps && hasDeps) {
|
||||
log('跳过依赖安装');
|
||||
} else {
|
||||
installDependencies();
|
||||
}
|
||||
buildRuntime();
|
||||
writeEnvironmentFile();
|
||||
writeRuntimeScripts();
|
||||
writeReadme();
|
||||
|
||||
const installer = buildInstaller();
|
||||
|
||||
const sizeMb = (target) => {
|
||||
let total = 0;
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else total += fs.statSync(full).size;
|
||||
}
|
||||
};
|
||||
walk(target);
|
||||
return Math.round(total / 1024 / 1024);
|
||||
};
|
||||
|
||||
log(`免安装目录:${path.relative(root, staging)}(${sizeMb(staging)} MB)`);
|
||||
if (installer)
|
||||
log(
|
||||
`安装程序:${path.relative(root, installer)}(${Math.round(fs.statSync(installer).size / 1024 / 1024)} MB)`,
|
||||
);
|
||||
log('完成。');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 图标生成启动器:pnpm icons
|
||||
* 复用 desktop-client 的 Electron 运行时渲染 SVG 并导出 PNG/ICO。
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
||||
|
||||
if (!fs.existsSync(electronDir)) {
|
||||
console.error('[icons] 未找到 Electron,请先执行 pnpm install');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const pathFile = path.join(electronDir, 'path.txt');
|
||||
const executable = fs.existsSync(pathFile)
|
||||
? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim())
|
||||
: path.join(electronDir, 'dist', 'electron.exe');
|
||||
|
||||
const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs')], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on('exit', (code) => process.exit(code ?? 1));
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 用 Electron 渲染 SVG 并导出应用图标(PNG + ICO)。
|
||||
*
|
||||
* 为什么用 Electron:可以复用系统字体渲染中文标识,且无需任何图形库/ImageMagick。
|
||||
*
|
||||
* 产物:
|
||||
* packages/web-admin/public/icon-{192,512}.png PWA 图标
|
||||
* packages/web-admin/public/apple-touch-icon.png iOS 图标
|
||||
* packages/web-admin/public/favicon.svg 浏览器标签页图标
|
||||
* packages/desktop-client/build/icon.png 512×512(electron-builder 用)
|
||||
* packages/desktop-client/build/icon.ico 多尺寸 ICO(安装包/EXE 图标)
|
||||
* build/icon.ico 服务端安装程序图标
|
||||
*/
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const SIZES = [16, 24, 32, 48, 64, 128, 180, 192, 256, 512];
|
||||
|
||||
/** 品牌图形:蓝色渐变圆角方块 + 白色"班"字 + 书本底纹 */
|
||||
function logoSvg(size) {
|
||||
const radius = Math.round(size * 0.22);
|
||||
const fontSize = Math.round(size * 0.52);
|
||||
const bookY = Math.round(size * 0.74);
|
||||
const bookWidth = Math.round(size * 0.5);
|
||||
const bookX = Math.round((size - bookWidth) / 2);
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<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="${size}" height="${size}" rx="${radius}" ry="${radius}" 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="${fontSize}" fill="#ffffff">班</text>
|
||||
<rect x="${bookX}" y="${bookY}" width="${bookWidth}" height="${Math.max(2, Math.round(size * 0.045))}"
|
||||
rx="${Math.round(size * 0.02)}" fill="#ffffff" opacity="0.9"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function htmlFor(size) {
|
||||
return `<!doctype html><html><head><meta charset="utf-8" /><style>
|
||||
html,body{margin:0;padding:0;background:transparent;width:${size}px;height:${size}px;overflow:hidden}
|
||||
svg{display:block}
|
||||
</style></head><body>${logoSvg(size)}</body></html>`;
|
||||
}
|
||||
|
||||
/** 把若干 PNG 打包成 ICO 容器(ICO 允许直接内嵌 PNG 数据) */
|
||||
function buildIco(entries) {
|
||||
const count = entries.length;
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0); // reserved
|
||||
header.writeUInt16LE(1, 2); // type: icon
|
||||
header.writeUInt16LE(count, 4);
|
||||
|
||||
const directory = Buffer.alloc(16 * count);
|
||||
let offset = 6 + 16 * count;
|
||||
const payloads = [];
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const base = index * 16;
|
||||
directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, base + 0); // width
|
||||
directory.writeUInt8(entry.size >= 256 ? 0 : entry.size, base + 1); // height
|
||||
directory.writeUInt8(0, base + 2); // palette
|
||||
directory.writeUInt8(0, base + 3); // reserved
|
||||
directory.writeUInt16LE(1, base + 4); // color planes
|
||||
directory.writeUInt16LE(32, base + 6); // bits per pixel
|
||||
directory.writeUInt32LE(entry.data.length, base + 8);
|
||||
directory.writeUInt32LE(offset, base + 12);
|
||||
offset += entry.data.length;
|
||||
payloads.push(entry.data);
|
||||
});
|
||||
|
||||
return Buffer.concat([header, directory, ...payloads]);
|
||||
}
|
||||
|
||||
async function renderSizes() {
|
||||
const window = new BrowserWindow({
|
||||
width: 512,
|
||||
height: 512,
|
||||
show: false,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
webPreferences: { offscreen: false, backgroundThrottling: false },
|
||||
});
|
||||
|
||||
const tempDir = path.join(ROOT, '.cache', 'icons');
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const pngBySize = new Map();
|
||||
|
||||
for (const size of SIZES) {
|
||||
const file = path.join(tempDir, `logo-${size}.html`);
|
||||
fs.writeFileSync(file, htmlFor(size), 'utf8');
|
||||
window.setContentSize(size, size);
|
||||
await window.loadFile(file);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
let buffer = (await window.webContents.capturePage({ x: 0, y: 0, width: size, height: size })).toPNG();
|
||||
|
||||
// 隐藏窗口在部分环境下会截到空图,短暂显示后重试
|
||||
if (!buffer || buffer.length === 0) {
|
||||
window.showInactive();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
buffer = (await window.webContents.capturePage({ x: 0, y: 0, width: size, height: size })).toPNG();
|
||||
window.hide();
|
||||
}
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error(`渲染 ${size}×${size} 图标失败:截图为空`);
|
||||
}
|
||||
console.log(`[icons] ${size}×${size} -> ${buffer.length} 字节`);
|
||||
pngBySize.set(size, buffer);
|
||||
}
|
||||
|
||||
window.destroy();
|
||||
return pngBySize;
|
||||
}
|
||||
|
||||
function writeIfChanged(file, data) {
|
||||
if (!data || (Buffer.isBuffer(data) && data.length === 0)) {
|
||||
throw new Error(`拒绝写入空内容:${path.relative(ROOT, file)}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, data);
|
||||
console.log(`[icons] 写入 ${path.relative(ROOT, file)} (${data.length} 字节)`);
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
const pngBySize = await renderSizes();
|
||||
|
||||
// Web 管理端(PWA)
|
||||
const webPublic = path.join(ROOT, 'packages', 'web-admin', 'public');
|
||||
writeIfChanged(path.join(webPublic, 'icon-192.png'), pngBySize.get(192));
|
||||
writeIfChanged(path.join(webPublic, 'icon-512.png'), pngBySize.get(512));
|
||||
writeIfChanged(path.join(webPublic, 'apple-touch-icon.png'), pngBySize.get(180));
|
||||
writeIfChanged(path.join(webPublic, 'favicon.svg'), Buffer.from(logoSvg(64), 'utf8'));
|
||||
writeIfChanged(path.join(webPublic, 'favicon.png'), pngBySize.get(64));
|
||||
|
||||
// 桌面客户端(electron-builder)
|
||||
const desktopBuild = path.join(ROOT, 'packages', 'desktop-client', 'build');
|
||||
writeIfChanged(path.join(desktopBuild, 'icon.png'), pngBySize.get(512));
|
||||
|
||||
// ICO(安装包 / EXE 图标):包含常用尺寸
|
||||
const icoSizes = [16, 24, 32, 48, 64, 128, 256];
|
||||
const ico = buildIco(icoSizes.map((size) => ({ size, data: pngBySize.get(size) })));
|
||||
writeIfChanged(path.join(desktopBuild, 'icon.ico'), ico);
|
||||
writeIfChanged(path.join(ROOT, 'build', 'icon.ico'), ico);
|
||||
writeIfChanged(path.join(ROOT, 'build', 'icon.png'), pngBySize.get(512));
|
||||
|
||||
console.log('[icons] 全部图标生成完成');
|
||||
app.exit(0);
|
||||
} catch (error) {
|
||||
console.error('[icons] 生成失败', error);
|
||||
app.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
; 班级小助手服务端 + Web 管理端 Windows 安装程序
|
||||
; 由 scripts/dist-server.mjs 生成(占位符在打包时替换)
|
||||
;
|
||||
; 设计要点:
|
||||
; - 当前用户级安装($LOCALAPPDATA\Programs),无需管理员权限即可完成安装
|
||||
; - 内置 Node 运行时,目标机无需安装 Node.js
|
||||
; - 快捷方式:启动/停止/重启服务、打开管理端、卸载
|
||||
; - 默认加入当前用户开机自启(注册表 HKCU\...\Run),随时可用
|
||||
; - 卸载时保留 data 目录(数据库)以避免误删数据
|
||||
|
||||
Unicode true
|
||||
SetCompressor /SOLID lzma
|
||||
|
||||
!include "MUI2.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!define PRODUCT "@PRODUCT@"
|
||||
!define VERSION "@VERSION@"
|
||||
!define STAGING "@STAGING@"
|
||||
!define OUT_FILE "@OUTFILE@"
|
||||
!define ICON_FILE "@ICON@"
|
||||
!define APP_PORT "@PORT@"
|
||||
|
||||
Name "${PRODUCT} ${VERSION}"
|
||||
OutFile "${OUT_FILE}"
|
||||
InstallDir "$LOCALAPPDATA\Programs\ClassHelperServer"
|
||||
InstallDirRegKey HKCU "Software\ClassHelperServer" "InstallDir"
|
||||
RequestExecutionLevel user
|
||||
ShowInstDetails show
|
||||
ShowUninstDetails show
|
||||
|
||||
!define MUI_ICON "${ICON_FILE}"
|
||||
!define MUI_UNICON "${ICON_FILE}"
|
||||
!define MUI_ABORTWARNING
|
||||
!define MUI_FINISHPAGE_TITLE "安装完成"
|
||||
!define MUI_FINISHPAGE_TEXT "班级小助手服务端已安装完成。$\r$\n$\r$\n安装程序已将服务加入当前用户的开机自启项,浏览器打开 http://127.0.0.1:${APP_PORT}/ 即可使用管理端。$\r$\n$\r$\n默认账号:admin / admin123(首次登录后请立即修改密码)"
|
||||
!define MUI_FINISHPAGE_RUN
|
||||
!define MUI_FINISHPAGE_RUN_TEXT "立即启动服务并打开管理端"
|
||||
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchService
|
||||
!define MUI_LICENSEPAGE_TEXT_TOP "请阅读以下说明后继续安装。"
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_LICENSE "${STAGING}\LICENSE.txt"
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
|
||||
!insertmacro MUI_LANGUAGE "SimpChinese"
|
||||
|
||||
Function LaunchService
|
||||
ExecShell "open" "$INSTDIR\start.cmd" "" SW_SHOWMINIMIZED
|
||||
Sleep 3000
|
||||
ExecShell "open" "http://127.0.0.1:${APP_PORT}/"
|
||||
FunctionEnd
|
||||
|
||||
; ------------------------------------------------------------------ 安装
|
||||
|
||||
Section "主程序" SecMain
|
||||
SectionIn RO
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; 若目标目录已有旧版本,先停止正在运行的服务,避免文件占用
|
||||
IfFileExists "$INSTDIR\data\server.pid" 0 +3
|
||||
nsExec::ExecToLog '"$INSTDIR\stop.cmd"'
|
||||
Sleep 1500
|
||||
|
||||
; 升级安装时保留原有配置(含 JWT 密钥与端口设置),避免用户被强制重新登录
|
||||
IfFileExists "$INSTDIR\.env" 0 +2
|
||||
Rename "$INSTDIR\.env" "$INSTDIR\.env.previous"
|
||||
|
||||
File /r "${STAGING}\*.*"
|
||||
|
||||
IfFileExists "$INSTDIR\.env.previous" 0 +3
|
||||
Delete "$INSTDIR\.env"
|
||||
Rename "$INSTDIR\.env.previous" "$INSTDIR\.env"
|
||||
|
||||
WriteRegStr HKCU "Software\ClassHelperServer" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKCU "Software\ClassHelperServer" "Version" "${VERSION}"
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
; 开始菜单
|
||||
CreateDirectory "$SMPROGRAMS\${PRODUCT}"
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT}\启动服务.lnk" "$INSTDIR\start.cmd" "" "$INSTDIR\node.exe" 0 SW_SHOWMINIMIZED
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT}\停止服务.lnk" "$INSTDIR\stop.cmd" "" "$INSTDIR\node.exe" 0 SW_SHOWMINIMIZED
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT}\重启服务.lnk" "$INSTDIR\restart.cmd" "" "$INSTDIR\node.exe" 0 SW_SHOWMINIMIZED
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT}\打开管理端.lnk" "http://127.0.0.1:${APP_PORT}/"
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT}\使用说明.lnk" "$INSTDIR\README.txt"
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT}\卸载.lnk" "$INSTDIR\uninstall.exe"
|
||||
|
||||
; 桌面快捷方式(管理端进浏览器)
|
||||
CreateShortCut "$DESKTOP\${PRODUCT}.lnk" "http://127.0.0.1:${APP_PORT}/"
|
||||
|
||||
; 开机自启(当前用户)
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "ClassHelperServer" '"$INSTDIR\start.cmd"'
|
||||
|
||||
; 注册到"应用和功能"列表,便于通过系统设置卸载
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "DisplayName" "${PRODUCT} ${VERSION}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "DisplayIcon" "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "DisplayVersion" "${VERSION}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "Publisher" "Class Helper"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer" "EstimatedSize" "$0"
|
||||
|
||||
; 首次启动即初始化数据库并开始提供服务
|
||||
ExecShell "open" "$INSTDIR\start.cmd" "" SW_SHOWMINIMIZED
|
||||
SectionEnd
|
||||
|
||||
; ------------------------------------------------------------------ 卸载
|
||||
|
||||
Section "Uninstall"
|
||||
; 停止服务
|
||||
IfFileExists "$INSTDIR\data\server.pid" 0 +3
|
||||
nsExec::ExecToLog '"$INSTDIR\stop.cmd"'
|
||||
Sleep 1200
|
||||
|
||||
; 兜底:按命令行特征结束残留进程
|
||||
nsExec::ExecToLog 'taskkill /F /FI "WINDOWTITLE eq ClassHelperServer*"'
|
||||
|
||||
DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "ClassHelperServer"
|
||||
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ClassHelperServer"
|
||||
DeleteRegKey HKCU "Software\ClassHelperServer"
|
||||
|
||||
Delete "$DESKTOP\${PRODUCT}.lnk"
|
||||
RMDir /r "$SMPROGRAMS\${PRODUCT}"
|
||||
|
||||
; 保留数据目录,避免误删数据库:重命名为 data.backup
|
||||
IfFileExists "$INSTDIR\data\classhelper.db" 0 +3
|
||||
RMDir /r "$INSTDIR\data.backup"
|
||||
Rename "$INSTDIR\data" "$INSTDIR\data.backup"
|
||||
|
||||
RMDir /r "$INSTDIR\server"
|
||||
RMDir /r "$INSTDIR\web"
|
||||
RMDir /r "$INSTDIR\node_modules"
|
||||
RMDir /r "$INSTDIR\logs"
|
||||
Delete "$INSTDIR\node.exe"
|
||||
Delete "$INSTDIR\package.json"
|
||||
Delete "$INSTDIR\package-lock.json"
|
||||
Delete "$INSTDIR\LICENSE.txt"
|
||||
Delete "$INSTDIR\start.cmd"
|
||||
Delete "$INSTDIR\stop.cmd"
|
||||
Delete "$INSTDIR\restart.cmd"
|
||||
Delete "$INSTDIR\status.cmd"
|
||||
Delete "$INSTDIR\install-service.cmd"
|
||||
Delete "$INSTDIR\uninstall-service.cmd"
|
||||
Delete "$INSTDIR\README.txt"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
; 若用户未保留 .env,则一并清理目录
|
||||
Delete "$INSTDIR\.env"
|
||||
RMDir "$INSTDIR"
|
||||
SectionEnd
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Web 管理端 UI 冒烟测试(Electron 驱动真实浏览器内核)。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. 首页加载(生产构建产物)
|
||||
* 2. 登录页渲染与真实表单登录
|
||||
* 3. 进入主布局
|
||||
* 4. **侧边栏 7 个菜单逐一点击**:断言路由路径变化 + 页面标题渲染
|
||||
* —— 这是"点击左侧边栏没反应"的回归测试
|
||||
*
|
||||
* 用法(需要后端已启动,且 Web 管理端由其托管):
|
||||
* UI_SMOKE_URL=http://127.0.0.1:4000/ node scripts/ui-smoke/run.mjs
|
||||
*/
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
|
||||
// 使用独立(每次清空)的 userData,避免 Service Worker 缓存影响验证结果
|
||||
if (process.env.UI_SMOKE_PROFILE) {
|
||||
app.setPath('userData', process.env.UI_SMOKE_PROFILE);
|
||||
}
|
||||
|
||||
const TARGET_URL = process.env.UI_SMOKE_URL ?? 'http://127.0.0.1:4000/';
|
||||
const USERNAME = process.env.UI_SMOKE_USER ?? 'teacher1';
|
||||
const PASSWORD = process.env.UI_SMOKE_PASS ?? 'teacher123';
|
||||
const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
|
||||
|
||||
/** 侧边栏菜单:显示文案 -> 期望路由 */
|
||||
const MENU_ITEMS = [
|
||||
{ label: '仪表盘', path: '/dashboard' },
|
||||
{ label: '班级管理', path: '/classes' },
|
||||
{ label: '学生管理', path: '/students' },
|
||||
{ label: '课表管理', path: '/schedules' },
|
||||
{ label: '作业发布', path: '/homeworks' },
|
||||
{ label: '通知发布', path: '/notifications' },
|
||||
{ label: '成绩录入', path: '/grades' },
|
||||
];
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail = '') {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 在渲染进程里轮询等待条件成立 */
|
||||
async function waitFor(win, expression, timeoutMs = 15000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const ok = await win.webContents.executeJavaScript(
|
||||
`(() => { try { return Boolean(${expression}); } catch { return false; } })()`,
|
||||
);
|
||||
if (ok) return true;
|
||||
await sleep(150);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
show: false,
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
||||
});
|
||||
|
||||
// 把渲染进程的报错转发到控制台(兼容 Electron 新旧事件签名)
|
||||
win.webContents.on('console-message', (...args) => {
|
||||
const first = args[0];
|
||||
const isEventObject = first && typeof first === 'object' && 'message' in first;
|
||||
const level = isEventObject ? first.level : args[1];
|
||||
const message = isEventObject ? first.message : args[2];
|
||||
// level: 旧版为 0..3 数字,新版为 'info' | 'warning' | 'error' | 'debug'
|
||||
const isProblem = level === 'error' || level === 'warning' || level === 2 || level === 3;
|
||||
if (isProblem) console.log(`[renderer] ${String(message)}`);
|
||||
});
|
||||
|
||||
console.log(`[ui-smoke] 打开 ${TARGET_URL}`);
|
||||
await win.loadURL(TARGET_URL);
|
||||
|
||||
// 1. 首页
|
||||
const title = await win.webContents.executeJavaScript('document.title');
|
||||
record('Web 管理端首页加载', typeof title === 'string' && title.includes('班级小助手'), `title=${title}`);
|
||||
|
||||
// 2. 登录页
|
||||
const loginReady = await waitFor(win, `document.querySelectorAll('input').length >= 2`, 15000);
|
||||
record('登录页渲染(用户名/密码输入框)', loginReady);
|
||||
|
||||
// 3. 填写表单并点击登录(用原生 setter + input 事件,保证 Vue 双向绑定生效)
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
const inputs = Array.from(document.querySelectorAll('input'));
|
||||
const setValue = (element, value) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(element, value);
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
setValue(inputs[0], ${JSON.stringify(USERNAME)});
|
||||
const passwordInput = inputs.find((item) => item.type === 'password') ?? inputs[1];
|
||||
setValue(passwordInput, ${JSON.stringify(PASSWORD)});
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
const button = Array.from(document.querySelectorAll('button')).find((item) => (item.textContent ?? '').includes('登录'));
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
})()`);
|
||||
|
||||
const layoutReady = await waitFor(
|
||||
win,
|
||||
`document.querySelectorAll('.el-menu-item').length >= ${MENU_ITEMS.length}`,
|
||||
20000,
|
||||
);
|
||||
record(
|
||||
'真实表单登录并进入主布局',
|
||||
layoutReady,
|
||||
`菜单项=${await win.webContents.executeJavaScript(`document.querySelectorAll('.el-menu-item').length`)}`,
|
||||
);
|
||||
|
||||
if (!layoutReady) {
|
||||
await finish(win);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 逐一点击侧边栏菜单
|
||||
const visited = [];
|
||||
const failures = [];
|
||||
|
||||
for (const item of MENU_ITEMS) {
|
||||
const outcome = await win.webContents.executeJavaScript(`(async () => {
|
||||
const label = ${JSON.stringify(item.label)};
|
||||
const expected = ${JSON.stringify(item.path)};
|
||||
const items = Array.from(document.querySelectorAll('.el-menu-item'));
|
||||
const element = items.find((node) => (node.textContent ?? '').trim().startsWith(label));
|
||||
if (!element) return { ok: false, path: location.pathname, title: '', reason: '菜单项未找到' };
|
||||
|
||||
element.click();
|
||||
|
||||
const deadline = Date.now() + 4000;
|
||||
while (Date.now() < deadline) {
|
||||
if (location.pathname === expected) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
}
|
||||
// 等视图渲染
|
||||
const viewDeadline = Date.now() + 3000;
|
||||
while (Date.now() < viewDeadline) {
|
||||
if (document.querySelector('.page-title')) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
}
|
||||
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
||||
return { ok: location.pathname === expected && Boolean(title), path: location.pathname, title, expected };
|
||||
})()`);
|
||||
|
||||
visited.push(`${item.label}→${outcome.path}${outcome.title ? `(${outcome.title})` : ''}`);
|
||||
if (!outcome.ok)
|
||||
failures.push(
|
||||
`${item.label}(期望 ${item.path} 实际 ${outcome.path}${outcome.reason ? ` ${outcome.reason}` : ''})`,
|
||||
);
|
||||
}
|
||||
|
||||
record(
|
||||
`侧边栏点击导航(逐一点击 ${MENU_ITEMS.length} 个菜单)`,
|
||||
failures.length === 0,
|
||||
`visited=[${visited.join(' ')}]${failures.length > 0 ? ` failures=${failures.join(';')}` : ''}`,
|
||||
);
|
||||
|
||||
// 5. SPA 深链刷新(生产环境静态托管必须支持)
|
||||
await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/grades`);
|
||||
const deepLinkOk = await waitFor(
|
||||
win,
|
||||
`document.querySelectorAll('.el-menu-item').length >= ${MENU_ITEMS.length}`,
|
||||
15000,
|
||||
);
|
||||
record(
|
||||
'前端路由深链直接访问(/grades 刷新可用)',
|
||||
deepLinkOk,
|
||||
`path=${await win.webContents.executeJavaScript('location.pathname')}`,
|
||||
);
|
||||
|
||||
// 6. PWA 可安装性:manifest 可获取 + Service Worker 已注册
|
||||
// 注意:注入代码里不要使用 ${} 插值,避免与宿主文件的模板字符串冲突
|
||||
const manifestInfo = await win.webContents.executeJavaScript(`(async () => {
|
||||
const link = document.querySelector('link[rel="manifest"]');
|
||||
if (!link) return { ok: false, detail: '缺少 manifest 链接' };
|
||||
const response = await fetch(link.getAttribute('href'));
|
||||
const json = await response.json().catch(() => null);
|
||||
const ok = response.ok && Boolean(json && json.name);
|
||||
return { ok, detail: String(response.status) + ' name=' + (json && json.name ? json.name : '-') + ' icons=' + (json && json.icons ? json.icons.length : 0) };
|
||||
})()`);
|
||||
record('PWA manifest 可获取', Boolean(manifestInfo?.ok), String(manifestInfo?.detail ?? ''));
|
||||
|
||||
const swInfo = await win.webContents.executeJavaScript(`(async () => {
|
||||
if (!('serviceWorker' in navigator)) return { ok: false, detail: '不支持 Service Worker' };
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return { ok: registrations.length > 0, detail: 'registrations=' + registrations.length };
|
||||
})()`);
|
||||
record('Service Worker 已注册(浏览器可安装为应用)', Boolean(swInfo?.ok), String(swInfo?.detail ?? ''));
|
||||
|
||||
await finish(win);
|
||||
}
|
||||
|
||||
async function finish(win) {
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
const passed = results.length - failed.length;
|
||||
console.log(`\n[ui-smoke] ${passed}/${results.length} 项通过`);
|
||||
|
||||
if (RESULT_FILE) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
RESULT_FILE,
|
||||
JSON.stringify(
|
||||
{ passed, total: results.length, ok: failed.length === 0, results, target: TARGET_URL },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
console.log(`[ui-smoke] 结果已写入 ${RESULT_FILE}`);
|
||||
} catch (error) {
|
||||
console.error('[ui-smoke] 写入结果失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
win.destroy();
|
||||
app.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
app.whenReady().then(() =>
|
||||
main().catch(async (error) => {
|
||||
record('UI 冒烟执行异常', false, String(error));
|
||||
console.error(error);
|
||||
app.exit(1);
|
||||
}),
|
||||
);
|
||||
|
||||
app.on('window-all-closed', () => app.quit());
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Web 管理端 UI 冒烟测试启动器。
|
||||
*
|
||||
* 复用 desktop-client 里已安装的 Electron 运行时(无需额外依赖),
|
||||
* 以「真实浏览器内核 + 真实点击」的方式验证 Web 管理端。
|
||||
*
|
||||
* 用法:
|
||||
* 1) 启动后端(同时托管 Web 管理端):pnpm dev:server 或生产模式 node dist/index.js
|
||||
* 2) pnpm verify:web
|
||||
*
|
||||
* 环境变量:
|
||||
* UI_SMOKE_URL 默认 http://127.0.0.1:4000/
|
||||
* UI_SMOKE_USER 默认 teacher1
|
||||
* UI_SMOKE_PASS 默认 teacher123
|
||||
* UI_SMOKE_RESULT 结果 JSON 输出路径(可选)
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const electronDir = path.join(root, 'packages', 'desktop-client', 'node_modules', 'electron');
|
||||
|
||||
if (!fs.existsSync(electronDir)) {
|
||||
console.error('[ui-smoke] 未找到 Electron(packages/desktop-client/node_modules/electron)');
|
||||
console.error(' 请先执行:pnpm install');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const pathFile = path.join(electronDir, 'path.txt');
|
||||
const executable = fs.existsSync(pathFile)
|
||||
? path.join(electronDir, 'dist', fs.readFileSync(pathFile, 'utf8').trim())
|
||||
: path.join(electronDir, 'dist', 'electron.exe');
|
||||
|
||||
if (!fs.existsSync(executable)) {
|
||||
console.error(`[ui-smoke] Electron 可执行文件不存在:${executable}`);
|
||||
console.error(' 请执行:node packages/desktop-client/node_modules/electron/install.js');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const entry = path.join(root, 'scripts', 'ui-smoke', 'main.cjs');
|
||||
const target = process.env.UI_SMOKE_URL ?? 'http://127.0.0.1:4000/';
|
||||
const resultFile = process.env.UI_SMOKE_RESULT ?? path.join(root, '.cache', 'ui-smoke-result.json');
|
||||
const profileDir = path.join(root, '.cache', 'ui-smoke-profile');
|
||||
|
||||
// 每次运行前清空浏览器配置目录,避免 Service Worker / 缓存影响结果
|
||||
fs.rmSync(profileDir, { recursive: true, force: true });
|
||||
|
||||
console.log(`[ui-smoke] 目标:${target}`);
|
||||
console.log(`[ui-smoke] Electron:${executable}`);
|
||||
|
||||
const child = spawn(executable, [entry], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
UI_SMOKE_URL: target,
|
||||
UI_SMOKE_RESULT: resultFile,
|
||||
UI_SMOKE_PROFILE: profileDir,
|
||||
},
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log(`[ui-smoke] 全部通过,结果文件:${resultFile}`);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
Reference in new issue
Block a user