feat(image): 图片直接显示 + 右键复制图片; 修未读角标不清

## 图片直显 + 右键复制

以前图片消息基本只能看到 "[图片] xxx.png" 的文件卡片, 而且右键没有"复制"。

根因有两层:

1. `asImage` 从来没被设成 true —— 用"发送文件"选中的图片是当**普通文件**发的,
   对方只能看到文件卡片。
2. 拖拽/粘贴的图走 `sendBuffer`, 只在 <200KB 时才带 `thumbDataUrl`,
   大图没有缩略图 -> 渲染层 fallback 成文件卡片。

改法:

- 缩略图随消息体走 (`body.thumbDataUrl`): **发送方生成一次, 对方直接拿来显示**,
  对端不用再解码, 老版本对端也看得到图。
- 缩略图用 Electron 的 `nativeImage` 生成 (`src/main/image.ts`), 不引 sharp/jimp:
  宽度上限 480, **PNG 优先 (保透明 —— 截图转 JPEG 会变黑底)**, PNG 超 60KB 才退 JPEG,
  然后按"降质量 -> 缩尺寸"的阶梯找一档够小的。
  阈值/编码选择/选图源抽到 `src/main/image-core.ts` (纯逻辑, 不 import electron, 可单测)。
  源文件 >20MB 不生成 —— nativeImage 解码会把整张图铺成 RGBA 内存。
- 拖拽/选中的图片改走 `sendFile(asImage: true)` (有真实路径, 流式上传),
  只有粘贴的图片才走 `sendBuffer` (整图 base64 进 WS 帧, 大图撑爆带宽)。
- 右键"复制图片" -> `image:copy` IPC -> `clipboard.writeImage`, 写的是**原图**
  (本地文件优先, 退回 dataBase64), 不是气泡里那个缩略图。
- 缩略图缺失时 (老消息/老版本对端) 渲染层退到 `image:thumb` 现场生成,
  失败才显示文件卡片。点开看大图用 `image:dataUrl` 读本地原图。

## 修未读角标不清

用户正看着某个会话, dock/任务栏角标的数字却不消。

根因: `App.vue` 的 `message:received` 里先判 `if (!savedPath) return` (等文件落地再算未读),
而**文字消息没有 savedPath** —— 正好在"清未读"之前就 return 了。
文件/图片消息因为第二帧带 savedPath 才碰巧能清掉, 所以只有文字消息暴露这个 bug。

改法:

- 决策抽成纯函数 `src/renderer/src/utils/unread.ts` 的 `unreadActionForIncoming()`
  (`clear` / `wait` / `count`): **先判 isActive -> 清未读 + 回已读回执, 再判 savedPath**。
- 侧边栏**重复点同一个设备**时 activePeerId 不变, watch 不触发 -> 显式清一次。
- 主进程 `device:delete` 清了 unread 但忘了 `updateBadge()` -> 角标残留, 补上。

## 验证

- npm run typecheck        ✅
- npm run build            ✅
- npm run selftest         ✅ 76 通过 / 0 失败 (新增 I1-I8 图片 + U1-U4 角标)
- npm run selftest:sync    ✅ 全部通过
- npm run selftest:image   ✅ 真 Electron 下验 nativeImage (新增命令)
- npm run package:linux    ✅ 新 deb
- npm run selftest:package ✅ 4/4

真机 (CDP 驱动真实窗口) 验证过: 图片气泡内联显示 (640x420, fallback 0 个),
右键菜单含"复制图片", 点下去系统剪贴板里是 **640x420 原图** (不是缩略图)。
This commit is contained in:
2026-10-21 14:52:37 +08:00
parent d854c95ee6
commit 2b63bf7d0b
19 changed files with 1344 additions and 45 deletions
+149
View File
@@ -0,0 +1,149 @@
// 用 CDP (Chrome DevTools Protocol) 驱动真实 Electron 窗口:
// 点开设备 -> 截图 -> 右键图片 -> 再截图。
//
// 用途: 肉眼验证"图片直接显示 + 右键能复制图片"。环境里没有 xdotool,
// 所以走 DevTools 协议自己点。
//
// 用法: node scripts/selftest/cdp-shot.cjs <outDir> [port]
const fs = require('node:fs')
const path = require('node:path')
const WebSocket = require('ws')
const outDir = process.argv[2] || '/tmp/lnm-shots'
const port = process.argv[3] || '9222'
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function findPage() {
for (let i = 0; i < 40; i++) {
try {
const res = await fetch(`http://127.0.0.1:${port}/json/list`)
const list = await res.json()
const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl)
if (page) return page
} catch { /* 还没起来 */ }
await sleep(500)
}
throw new Error('连不上 CDP (应用没起来?)')
}
function connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 })
ws.once('open', () => resolve(ws))
ws.once('error', reject)
})
}
async function main() {
fs.mkdirSync(outDir, { recursive: true })
const page = await findPage()
const ws = await connect(page.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString())
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id)
pending.delete(msg.id)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result)
}
})
const send = (method, params) => new Promise((resolve, reject) => {
const mid = ++id
pending.set(mid, { resolve, reject })
ws.send(JSON.stringify({ id: mid, method, params: params || {} }))
})
const evaluate = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ' ' + JSON.stringify(r.exceptionDetails.exception?.description || ''))
return r.result?.value
}
const shot = async (name) => {
const r = await send('Page.captureScreenshot', { format: 'png' })
const p = path.join(outDir, name)
fs.writeFileSync(p, Buffer.from(r.data, 'base64'))
console.log('截图: ' + p)
return p
}
await send('Page.enable')
await send('Runtime.enable')
// 等 Vue 挂载
for (let i = 0; i < 30; i++) {
const ok = await evaluate(`!!document.querySelector('.device-item')`)
if (ok) break
await sleep(500)
}
// 1) 点开设备 (侧边栏第一项)
const clicked = await evaluate(`(() => {
const el = document.querySelector('.device-item')
if (!el) return false
el.click()
return true
})()`)
console.log('点击设备项: ' + clicked)
await sleep(1500)
// 2) 看看聊天区里有没有图片气泡
const info = await evaluate(`(() => {
const imgs = [...document.querySelectorAll('.bubble.image img')]
return {
imageCount: imgs.length,
srcPrefix: imgs[0] ? imgs[0].src.slice(0, 30) : '',
naturalW: imgs[0] ? imgs[0].naturalWidth : 0,
naturalH: imgs[0] ? imgs[0].naturalHeight : 0,
fallbackCount: document.querySelectorAll('.image-fallback').length,
bubbleText: [...document.querySelectorAll('.bubble')].map(b => b.innerText.trim().slice(0, 40)),
}
})()`)
console.log('页面状态: ' + JSON.stringify(info, null, 2))
await shot('01-chat-image.png')
// 3) 右键点图片 -> 看右键菜单
const ctx = await evaluate(`(() => {
const img = document.querySelector('.bubble.image img')
if (!img) return null
const r = img.getBoundingClientRect()
const x = Math.round(r.left + r.width / 2)
const y = Math.round(r.top + r.height / 2)
img.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y }))
return { x, y }
})()`)
console.log('右键坐标: ' + JSON.stringify(ctx))
await sleep(800)
const menu = await evaluate(`(() => {
const items = [...document.querySelectorAll('.ctx-menu .ctx-item')].map(e => e.innerText.trim())
return { items, hasCopyImage: items.includes('复制图片') }
})()`)
console.log('右键菜单: ' + JSON.stringify(menu, null, 2))
await shot('02-context-menu.png')
// 4) 点"复制图片" -> 看提示条 (提示只在 imageCopy 返回 ok 时才会出现)
const clickedCopy = await evaluate(`(() => {
const items = [...document.querySelectorAll('.ctx-menu .ctx-item')]
const it = items.find(e => e.innerText.trim() === '复制图片')
if (!it) return false
it.click()
return true
})()`)
console.log('点击"复制图片": ' + clickedCopy)
await sleep(1500)
const toast = await evaluate(`(() => {
const t = document.body.innerText
return { copied: t.includes('图片已复制到剪贴板'), failed: t.includes('复制失败') || t.includes('还没有可复制') }
})()`)
console.log('提示条: ' + JSON.stringify(toast))
await shot('03-after-copy.png')
ws.close()
console.log('done')
}
main().catch(e => { console.error('CDP 失败:', e.message); process.exit(1) })