Files
YschcmsFront/mock/theme-favicons.cjs
2026-09-07 09:11:37 +08:00

129 lines
5.9 KiB
JavaScript
Raw Permalink 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.
/**
* 门户主题 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); });