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

- 修复 web-admin 与 desktop-client 侧边栏点击无反应:el-menu 的 index 是路径,不能当路由名使用
- 修复 Web 产物目录含点号目录(如 .cache)时 res.sendFile 因 dotfiles:ignore 返回 500
- 后端生产模式:静态托管 Web 端 + SPA 回退、helmet、限流(通用/登录)、trust proxy、
  /healthz 与 /readyz、请求耗时日志、生产环境变量强校验、首启动自动迁移与创建管理员、PID 文件
- Web 管理端 PWA:manifest + service worker + 图标,可在浏览器安装为应用
- 新增服务端 + Web 管理端 Windows 安装程序(内置 Node 运行时、启停与服务脚本、开机自启、卸载保留数据)
- 新增 Docker/Compose/Nginx 部署样例与 docs/production.md
- 新增 UI 回归测试:Web 端 Electron 驱动真实点击 7 项、客户端冒烟 11 项(含侧边栏点击与离线回退)
This commit is contained in:
LoveChengke committed 2026-09-12 00:20:21 +08:00
1 parent 6e4c7c1050
commit 5545ef6f55
46 files changed
+3020 -152

No files matched your search

+162
View File
@@ -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);
}
});