129 lines
5.9 KiB
JavaScript
129 lines
5.9 KiB
JavaScript
/**
|
||
* 门户主题 favicon 兜底资产生成器(4 套皮肤各一份)
|
||
* - 输出 templates/themes/<theme>/assets/favicon.svg + favicon.png(128×128)
|
||
* - head.html 在 site.config['favicon'] 未配置时输出 url.asset('favicon.png')(见 fragments/head.html)
|
||
* 图形语言对齐种子 logo-8 系(圆点+书脊),主色取各主题 theme.json 的 primary 参数默认值;
|
||
* 小尺寸下高对比扁平化:无边框、无环形留白。
|
||
* 用法:node theme-favicons.cjs (在 front/ 下执行,依赖 ./node_modules/ws 与系统 Edge)
|
||
*/
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const { spawn } = require("child_process");
|
||
const http = require("http");
|
||
const WebSocket = require("ws");
|
||
|
||
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||
const ROOT = path.join(__dirname, "..", "..", "service", "src", "main", "resources", "templates", "themes");
|
||
const THEMES = [
|
||
{ dir: "default", primary: "#1a5cb0" },
|
||
{ dir: "classic-red", primary: "#b01a2e" },
|
||
{ dir: "blue-white", primary: "#2f6bd8" },
|
||
{ dir: "warm-kinder", primary: "#e8722a" },
|
||
];
|
||
|
||
/** #rrggbb -> [r,g,b] */
|
||
function rgb(hex) {
|
||
return [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
|
||
}
|
||
function mix(hex, target, t) {
|
||
const c = rgb(hex).map((v, i) => Math.round(v + (target[i] - v) * t));
|
||
return "#" + c.map(v => v.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
const light = hex => mix(hex, [255, 255, 255], 0.18);
|
||
const dark = hex => mix(hex, [0, 0, 0], 0.30);
|
||
|
||
function faviconSvg(primary) {
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||
<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0" stop-color="${light(primary)}"/><stop offset="1" stop-color="${dark(primary)}"/>
|
||
</linearGradient></defs>
|
||
<rect width="64" height="64" fill="url(#bg)"/>
|
||
<circle cx="32" cy="25" r="10" fill="#ffffff"/>
|
||
<path d="M 16 49.3 Q 32 30.7 48 49.3 Z" fill="#ffffff" opacity="0.92"/>
|
||
</svg>`;
|
||
}
|
||
|
||
/* ---- CDP 栅格化(Edge headless,128×128 视图内 img 渲染 svg 矢量,天然高清) ---- */
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||
function getJson(port, p) {
|
||
return new Promise((resolve, reject) => {
|
||
http.get({ host: "127.0.0.1", port, path: p }, res => {
|
||
let d = "";
|
||
res.on("data", c => d += c);
|
||
res.on("end", () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
|
||
}).on("error", reject);
|
||
});
|
||
}
|
||
async function waitFor(fn, timeout, label) {
|
||
const t0 = Date.now();
|
||
for (;;) {
|
||
try { if (await fn()) return; } catch (e) { /* retry */ }
|
||
if (Date.now() - t0 > timeout) throw new Error("timeout: " + label);
|
||
await sleep(200);
|
||
}
|
||
}
|
||
async function rasterizeSvg(svgPath, pngPath) {
|
||
const PORT = 9400 + Math.floor(Math.random() * 300);
|
||
const profile = path.join(require("os").tmpdir(), "fav-" + process.pid + "-" + Math.floor(Math.random() * 1e6));
|
||
const htmlPath = path.join(profile, "icon.html");
|
||
fs.mkdirSync(profile, { recursive: true });
|
||
fs.writeFileSync(htmlPath,
|
||
`<html><head><style>html,body{margin:0;padding:0;background:#fff}</style></head>
|
||
<body><img src="file:///${svgPath.replace(/\\/g, "/")}" width="128" height="128"></body></html>`);
|
||
const proc = spawn(EDGE, [
|
||
"--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
|
||
`--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
|
||
"--window-size=128,128", "--no-first-run", "--no-default-browser-check", "about:blank",
|
||
], { stdio: "ignore" });
|
||
try {
|
||
await waitFor(async () => { try { await getJson(PORT, "/json/version"); return true; } catch (e) { return false; } }, 20000, "devtools");
|
||
const pages = await getJson(PORT, "/json");
|
||
const page = pages.find(p => p.type === "page");
|
||
const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
|
||
await new Promise(r => ws.on("open", r));
|
||
const cdp = {
|
||
id: 0,
|
||
pending: new Map(),
|
||
send(method, params = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const id = ++this.id;
|
||
this.pending.set(id, { resolve, reject });
|
||
ws.send(JSON.stringify({ id, method, params }));
|
||
});
|
||
},
|
||
};
|
||
ws.on("message", m => {
|
||
const msg = JSON.parse(m);
|
||
if (msg.id && cdp.pending.has(msg.id)) {
|
||
const { resolve, reject } = cdp.pending.get(msg.id);
|
||
cdp.pending.delete(msg.id);
|
||
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
|
||
}
|
||
});
|
||
await cdp.send("Page.enable");
|
||
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 128, height: 128, deviceScaleFactor: 1, mobile: false });
|
||
const loaded = new Promise(r => {
|
||
const on = m => { const d = JSON.parse(m); if (d.method === "Page.loadEventFired") { ws.off("message", on); r(); } };
|
||
ws.on("message", on);
|
||
});
|
||
await cdp.send("Page.navigate", { url: "file:///" + htmlPath.replace(/\\/g, "/") });
|
||
await loaded;
|
||
await sleep(500);
|
||
const shot = await cdp.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
|
||
fs.writeFileSync(pngPath, Buffer.from(shot.data, "base64"));
|
||
ws.close();
|
||
} finally {
|
||
proc.kill();
|
||
}
|
||
}
|
||
|
||
(async () => {
|
||
for (const t of THEMES) {
|
||
const dir = path.join(ROOT, t.dir, "assets");
|
||
fs.writeFileSync(path.join(dir, "favicon.svg"), faviconSvg(t.primary));
|
||
await rasterizeSvg(path.join(dir, "favicon.svg"), path.join(dir, "favicon.png"));
|
||
console.log("wrote", t.dir, "favicon.svg + favicon.png");
|
||
}
|
||
console.log("done");
|
||
})().catch(e => { console.error(e); process.exit(1); });
|