修复了一些问题

This commit is contained in:
李琦
2026-08-24 15:24:00 +08:00
parent 0f53edc94c
commit b43bfc4afb
39 changed files with 4282 additions and 142 deletions

View 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
View 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('/')})`)