Files
ClassHelper/scripts/icons/render.cjs
T
LoveChengke 5545ef6f55 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 项(含侧边栏点击与离线回退)
2026-09-12 00:20:21 +08:00

163 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 用 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);
}
});