修复了一些问题
This commit is contained in:
57
scripts/cdp-screenshot.mjs
Normal file
57
scripts/cdp-screenshot.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* CDP 截屏小工具:连接正在运行的 Electron/Chromium 远程调试端口,截取页面渲染结果
|
||||
* 用途:打包后无头验证渲染层 UI(登录页 logo、页面是否白屏等),无需人工肉眼确认
|
||||
* 用法:node scripts/cdp-screenshot.mjs <debug端口> <输出png路径>
|
||||
* 依赖:Node 22+(内置全局 WebSocket),无需安装任何包
|
||||
*/
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
const port = process.argv[2] || '9223'
|
||||
const outFile = process.argv[3] || 'screenshot.png'
|
||||
|
||||
// 从调试端口拿到页面目标的 WebSocket 调试地址
|
||||
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
|
||||
const page = targets.find((t) => t.type === 'page')
|
||||
if (!page) {
|
||||
console.error('未找到 page 类型的调试目标')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
||||
let msgId = 0
|
||||
/** 发送 CDP 命令并等待对应 id 的响应 */
|
||||
function send(method, params = {}) {
|
||||
const id = ++msgId
|
||||
return new Promise((resolve, reject) => {
|
||||
const onMessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.id === id) {
|
||||
ws.removeEventListener('message', onMessage)
|
||||
data.error ? reject(new Error(data.error.message)) : resolve(data.result)
|
||||
}
|
||||
}
|
||||
ws.addEventListener('message', onMessage)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
ws.addEventListener('open', async () => {
|
||||
try {
|
||||
// 等一帧渲染稳定后截屏
|
||||
await send('Page.enable')
|
||||
const { data } = await send('Page.captureScreenshot', { format: 'png' })
|
||||
writeFileSync(outFile, Buffer.from(data, 'base64'))
|
||||
console.log(`截图已保存: ${outFile}`)
|
||||
// 顺带收集页面控制台报错,辅助排查
|
||||
const { result } = await send('Runtime.evaluate', {
|
||||
expression: 'document.title + " | " + location.hash',
|
||||
returnByValue: true,
|
||||
})
|
||||
console.log(`页面状态: ${result.value}`)
|
||||
} catch (err) {
|
||||
console.error('CDP 操作失败:', err.message)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
ws.close()
|
||||
}
|
||||
})
|
||||
327
scripts/generate-icons.mjs
Normal file
327
scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 全套 Logo/图标生成流水线(无第三方依赖,纯 Node 实现 PNG 解码、缩放、编码与 ICO 封装)
|
||||
*
|
||||
* 输入:build/logo-master.png(品牌主视觉源图,奶酪聊天气泡 + 年糕崽崽小猫,正方形大图)
|
||||
*
|
||||
* 输出(按用途划分):
|
||||
* - build/icon.png 256 Electron 打包图标(png 备用)
|
||||
* - build/icon.ico 16/24/32/48/64/128/256 多尺寸 Windows 图标(安装包/exe/快捷方式)
|
||||
* - public/icons/icon.png 256 桌面端窗口图标 / 系统通知图标(主进程运行时读取)
|
||||
* - public/icons/tray.png 32 桌面端系统托盘图标
|
||||
* - public/favicon.png 64 网站浏览器标签页图标
|
||||
* - public/logo.png 256 稳定 URL 的通用 logo(后台/外部页面可直接引用 /logo.png)
|
||||
* - src/assets/logo.png 256 经 Vite 打包的应用内 logo(登录页、Web 通知图标)
|
||||
* - ../nl-im-uniapp/src/static/logo.png 512 移动端 App 图标源图(HBuilderX 生成各尺寸时用,保留直角)
|
||||
*
|
||||
* 说明:除 uniapp 源图保留直角外,其余图标统一加 22% 圆角遮罩(带抗锯齿),
|
||||
* 在任务栏/托盘/标签页中呈现现代应用图标观感。
|
||||
*
|
||||
* 用法:node scripts/generate-icons.mjs(package.json 已配置 npm run icons)
|
||||
* 更换品牌视觉时只需替换 build/logo-master.png 后重跑本脚本。
|
||||
*/
|
||||
import { deflateSync, inflateSync } from 'node:zlib'
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const masterFile = join(projectRoot, 'build', 'logo-master.png')
|
||||
|
||||
// ---------- CRC32(PNG 块校验必需,Node zlib 没有直接暴露,自行实现查表版) ----------
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Int32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[n] = c
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
function crc32(buf) {
|
||||
let c = -1
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
|
||||
}
|
||||
return (c ^ -1) >>> 0
|
||||
}
|
||||
|
||||
// ---------- PNG 解码(支持 8bit RGB/RGBA、非隔行,覆盖常见生成图格式) ----------
|
||||
/** 解码 PNG 为 RGBA 像素数据 */
|
||||
function decodePng(buf) {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (buf[i] !== signature[i]) throw new Error('不是有效的 PNG 文件')
|
||||
}
|
||||
let pos = 8
|
||||
let width = 0
|
||||
let height = 0
|
||||
let bitDepth = 0
|
||||
let colorType = 0
|
||||
let interlace = 0
|
||||
const idatParts = []
|
||||
while (pos < buf.length) {
|
||||
const len = buf.readUInt32BE(pos)
|
||||
const type = buf.toString('ascii', pos + 4, pos + 8)
|
||||
const data = buf.subarray(pos + 8, pos + 8 + len)
|
||||
if (type === 'IHDR') {
|
||||
width = data.readUInt32BE(0)
|
||||
height = data.readUInt32BE(4)
|
||||
bitDepth = data[8]
|
||||
colorType = data[9]
|
||||
interlace = data[12]
|
||||
} else if (type === 'IDAT') {
|
||||
idatParts.push(data)
|
||||
} else if (type === 'IEND') {
|
||||
break
|
||||
}
|
||||
pos += 12 + len
|
||||
}
|
||||
if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6) || interlace !== 0) {
|
||||
throw new Error(`暂不支持的 PNG 格式(bitDepth=${bitDepth} colorType=${colorType} interlace=${interlace}),请提供 8bit RGB/RGBA 非隔行 PNG`)
|
||||
}
|
||||
const bpp = colorType === 6 ? 4 : 3
|
||||
const stride = width * bpp
|
||||
const raw = inflateSync(Buffer.concat(idatParts))
|
||||
const out = Buffer.alloc(width * height * 4)
|
||||
// 每行首字节是滤波器类型,按 PNG 规范逐字节还原(Sub/Up/Average/Paeth)
|
||||
let prevRow = Buffer.alloc(stride)
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[y * (stride + 1)]
|
||||
const row = raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride)
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const a = i >= bpp ? row[i - bpp] : 0 // 左侧同通道
|
||||
const b = prevRow[i] // 上方
|
||||
const c = i >= bpp ? prevRow[i - bpp] : 0 // 左上
|
||||
let v = row[i]
|
||||
switch (filter) {
|
||||
case 0:
|
||||
break
|
||||
case 1:
|
||||
v = (v + a) & 0xff
|
||||
break
|
||||
case 2:
|
||||
v = (v + b) & 0xff
|
||||
break
|
||||
case 3:
|
||||
v = (v + ((a + b) >> 1)) & 0xff
|
||||
break
|
||||
case 4: {
|
||||
// Paeth 预测器:取最接近 p=a+b-c 的邻居
|
||||
const p = a + b - c
|
||||
const pa = Math.abs(p - a)
|
||||
const pb = Math.abs(p - b)
|
||||
const pc = Math.abs(p - c)
|
||||
const pr = pa <= pb && pa <= pc ? a : pb <= pc ? b : c
|
||||
v = (v + pr) & 0xff
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`未知的 PNG 滤波器类型: ${filter}`)
|
||||
}
|
||||
row[i] = v
|
||||
}
|
||||
prevRow = row
|
||||
for (let x = 0; x < width; x++) {
|
||||
const si = x * bpp
|
||||
const di = (y * width + x) * 4
|
||||
out[di] = row[si]
|
||||
out[di + 1] = row[si + 1]
|
||||
out[di + 2] = row[si + 2]
|
||||
out[di + 3] = bpp === 4 ? row[si + 3] : 255
|
||||
}
|
||||
}
|
||||
return { width, height, data: out }
|
||||
}
|
||||
|
||||
// ---------- PNG 编码(8bit RGBA,无隔行扫描) ----------
|
||||
/** 组装一个 PNG 数据块:长度 + 类型 + 数据 + CRC */
|
||||
function pngChunk(type, data) {
|
||||
const typeBuf = Buffer.from(type, 'ascii')
|
||||
const lenBuf = Buffer.alloc(4)
|
||||
lenBuf.writeUInt32BE(data.length)
|
||||
const crcBuf = Buffer.alloc(4)
|
||||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])))
|
||||
return Buffer.concat([lenBuf, typeBuf, data, crcBuf])
|
||||
}
|
||||
|
||||
/** 将 RGBA 像素数据编码为 PNG 文件 Buffer */
|
||||
function encodePng(width, height, rgba) {
|
||||
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(width, 0)
|
||||
ihdr.writeUInt32BE(height, 4)
|
||||
ihdr[8] = 8 // 位深 8bit
|
||||
ihdr[9] = 6 // 颜色类型 6 = RGBA
|
||||
const raw = Buffer.alloc((width * 4 + 1) * height)
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (width * 4 + 1)] = 0 // 滤波器类型 0 = None
|
||||
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4)
|
||||
}
|
||||
return Buffer.concat([
|
||||
signature,
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
])
|
||||
}
|
||||
|
||||
// ---------- 图像处理 ----------
|
||||
/** 中心裁剪为正方形(源图若已是正方形则原样返回) */
|
||||
function cropSquare(img) {
|
||||
const { width, height, data } = img
|
||||
if (width === height) return img
|
||||
const size = Math.min(width, height)
|
||||
const ox = Math.floor((width - size) / 2)
|
||||
const oy = Math.floor((height - size) / 2)
|
||||
const out = Buffer.alloc(size * size * 4)
|
||||
for (let y = 0; y < size; y++) {
|
||||
data.copy(out, y * size * 4, ((y + oy) * width + ox) * 4, ((y + oy) * width + ox + size) * 4)
|
||||
}
|
||||
return { width: size, height: size, data: out }
|
||||
}
|
||||
|
||||
/**
|
||||
* 面积平均法缩放(box filter):
|
||||
* 缩小图标时每个目标像素取源区域的加权平均,比近邻采样平滑得多,小尺寸下依然清晰
|
||||
*/
|
||||
function resizeRgba(src, sw, sh, tw, th) {
|
||||
const dst = Buffer.alloc(tw * th * 4)
|
||||
const xr = sw / tw
|
||||
const yr = sh / th
|
||||
for (let ty = 0; ty < th; ty++) {
|
||||
const y0 = ty * yr
|
||||
const y1 = y0 + yr
|
||||
for (let tx = 0; tx < tw; tx++) {
|
||||
const x0 = tx * xr
|
||||
const x1 = x0 + xr
|
||||
let r = 0
|
||||
let g = 0
|
||||
let b = 0
|
||||
let a = 0
|
||||
let area = 0
|
||||
for (let sy = Math.floor(y0); sy < y1 && sy < sh; sy++) {
|
||||
const wy = Math.min(sy + 1, y1) - Math.max(sy, y0)
|
||||
if (wy <= 0) continue
|
||||
for (let sx = Math.floor(x0); sx < x1 && sx < sw; sx++) {
|
||||
const wx = Math.min(sx + 1, x1) - Math.max(sx, x0)
|
||||
if (wx <= 0) continue
|
||||
const w = wx * wy
|
||||
const i = (sy * sw + sx) * 4
|
||||
r += src[i] * w
|
||||
g += src[i + 1] * w
|
||||
b += src[i + 2] * w
|
||||
a += src[i + 3] * w
|
||||
area += w
|
||||
}
|
||||
}
|
||||
const di = (ty * tw + tx) * 4
|
||||
dst[di] = Math.round(r / area)
|
||||
dst[di + 1] = Math.round(g / area)
|
||||
dst[di + 2] = Math.round(b / area)
|
||||
dst[di + 3] = Math.round(a / area)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
/** 圆角矩形有符号距离场:返回值 <0 表示在内部,用于逐像素计算覆盖率实现抗锯齿 */
|
||||
function roundedRectDist(x, y, cx, cy, halfW, halfH, r) {
|
||||
const dx = Math.abs(x - cx) - (halfW - r)
|
||||
const dy = Math.abs(y - cy) - (halfH - r)
|
||||
const ax = Math.max(dx, 0)
|
||||
const ay = Math.max(dy, 0)
|
||||
return Math.hypot(ax, ay) + Math.min(Math.max(dx, dy), 0) - r
|
||||
}
|
||||
|
||||
/** 应用圆角遮罩(半径 = 尺寸 * ratio,边缘 1px 平滑抗锯齿),现代应用图标观感 */
|
||||
function applyRoundedMask(data, size, ratio = 0.22) {
|
||||
const r = size * ratio
|
||||
const half = size / 2
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dist = roundedRectDist(x + 0.5, y + 0.5, half, half, half, half, r)
|
||||
const coverage = dist <= -0.5 ? 1 : dist >= 0.5 ? 0 : 0.5 - dist
|
||||
if (coverage < 1) {
|
||||
const i = (y * size + x) * 4
|
||||
data[i + 3] = Math.round(data[i + 3] * coverage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ICO 封装 ----------
|
||||
/**
|
||||
* 将多张 PNG 打包为 .ico 容器(ICO 支持直接内嵌 PNG 数据,Windows Vista+ 原生支持)
|
||||
* 为什么自己生成:electron-builder 的 png→ico 原生转换工具在部分环境会崩溃,
|
||||
* 直接提供 icon.ico 可让打包器跳过转换步骤
|
||||
*/
|
||||
function encodeIco(pngBuffers) {
|
||||
const count = pngBuffers.length
|
||||
const header = Buffer.alloc(6)
|
||||
header.writeUInt16LE(0, 0) // 保留位
|
||||
header.writeUInt16LE(1, 2) // 类型:1 = 图标
|
||||
header.writeUInt16LE(count, 4)
|
||||
const entries = []
|
||||
const images = []
|
||||
let offset = 6 + 16 * count
|
||||
for (const { size, data } of pngBuffers) {
|
||||
const entry = Buffer.alloc(16)
|
||||
entry[0] = size >= 256 ? 0 : size // 0 表示 256
|
||||
entry[1] = size >= 256 ? 0 : size
|
||||
entry[2] = 0 // 调色板色数(真彩色为 0)
|
||||
entry[3] = 0 // 保留位
|
||||
entry.writeUInt16LE(1, 4) // 颜色平面数
|
||||
entry.writeUInt16LE(32, 6) // 位深
|
||||
entry.writeUInt32LE(data.length, 8) // 数据字节数
|
||||
entry.writeUInt32LE(offset, 12) // 数据偏移
|
||||
entries.push(entry)
|
||||
images.push(data)
|
||||
offset += data.length
|
||||
}
|
||||
return Buffer.concat([header, ...entries, ...images])
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
if (!existsSync(masterFile)) {
|
||||
console.error(`未找到源图 ${masterFile},请先放置品牌主视觉 PNG(正方形大图,建议 1024x1024)`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const master = cropSquare(decodePng(readFileSync(masterFile)))
|
||||
console.log(`源图 ${masterFile} (${master.width}x${master.height})`)
|
||||
|
||||
/** 生成指定尺寸的 RGBA(可选圆角遮罩) */
|
||||
function renderSize(size, rounded) {
|
||||
const data = resizeRgba(master.data, master.width, master.height, size, size)
|
||||
if (rounded) {
|
||||
applyRoundedMask(data, size)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/** 输出单个 PNG 文件 */
|
||||
function writePng(file, size, rounded) {
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
writeFileSync(file, encodePng(size, size, renderSize(size, rounded)))
|
||||
console.log(`已生成 ${file} (${size}x${size}${rounded ? ' 圆角' : ''})`)
|
||||
}
|
||||
|
||||
// 桌面端 / 网站 / 通用(统一圆角)
|
||||
writePng(join(projectRoot, 'build', 'icon.png'), 256, true)
|
||||
writePng(join(projectRoot, 'public', 'icons', 'icon.png'), 256, true)
|
||||
writePng(join(projectRoot, 'public', 'icons', 'tray.png'), 32, true)
|
||||
writePng(join(projectRoot, 'public', 'favicon.png'), 64, true)
|
||||
writePng(join(projectRoot, 'public', 'logo.png'), 256, true)
|
||||
writePng(join(projectRoot, 'src', 'assets', 'logo.png'), 256, true)
|
||||
|
||||
// 移动端 App 图标源图(保留直角,HBuilderX/应用商店会按平台规范自行裁切圆角)
|
||||
writePng(join(projectRoot, '..', 'nl-im-uniapp', 'src', 'static', 'logo.png'), 512, false)
|
||||
|
||||
// Windows 多尺寸 ICO(exe/安装包/快捷方式共用)
|
||||
const icoSizes = [16, 24, 32, 48, 64, 128, 256]
|
||||
const icoFile = join(projectRoot, 'build', 'icon.ico')
|
||||
writeFileSync(icoFile, encodeIco(icoSizes.map((size) => ({ size, data: encodePng(size, size, renderSize(size, true)) }))))
|
||||
console.log(`已生成 ${icoFile} (${icoSizes.join('/')})`)
|
||||
Reference in New Issue
Block a user