feat: add message management and delivery states

This commit is contained in:
2026-07-16 17:23:24 +08:00
parent 07c7260a3c
commit 565d044f18
17 changed files with 881 additions and 97 deletions
+42 -5
View File
@@ -14,6 +14,7 @@ interface Entry {
peer: DeviceInfo peer: DeviceInfo
ws: WebSocket | null ws: WebSocket | null
retryTimer: NodeJS.Timeout | null retryTimer: NodeJS.Timeout | null
pingTimer: NodeJS.Timeout | null
backoff: number backoff: number
alive: boolean alive: boolean
} }
@@ -51,7 +52,7 @@ export class ChatClient extends EventEmitter {
} }
this.scheduleConnect(e, e.backoff) this.scheduleConnect(e, e.backoff)
} else { } else {
e = { peer, ws: null, retryTimer: null, backoff: 1000, alive: true } e = { peer, ws: null, retryTimer: null, pingTimer: null, backoff: 1000, alive: true }
this.entries.set(peer.deviceId, e) this.entries.set(peer.deviceId, e)
this.scheduleConnect(e, 0) this.scheduleConnect(e, 0)
} }
@@ -73,6 +74,7 @@ export class ChatClient extends EventEmitter {
if (!e) return if (!e) return
e.alive = false e.alive = false
if (e.retryTimer) clearTimeout(e.retryTimer) if (e.retryTimer) clearTimeout(e.retryTimer)
this.stopPing(e)
try { e.ws?.close() } catch {} try { e.ws?.close() } catch {}
this.entries.delete(deviceId) this.entries.delete(deviceId)
} }
@@ -90,13 +92,33 @@ export class ChatClient extends EventEmitter {
const ws = new WebSocket(url, { handshakeTimeout: 5000 }) const ws = new WebSocket(url, { handshakeTimeout: 5000 })
e.ws = ws e.ws = ws
ws.on('open', () => { ws.on('open', () => {
try {
ws.send(JSON.stringify({ type: 'hello', from: this.self })) ws.send(JSON.stringify({ type: 'hello', from: this.self }))
} catch {}
e.backoff = 2000 e.backoff = 2000
this.emit('open', e.peer) this.emit('open', e.peer)
// NAT keepalive: 每 25s 发一次 WS 控制层 ping frame (比应用层 ping 更省, NAT 路由/防火墙
// 能识别 ping 控制帧, 不会因为"空闲太久"砍掉连接 — 这是 WS 抖动最常见的隐形原因)
// 顺便: 应用层 ping (JSON) 也发一份, 让对方知道我们活着 (双保险)
this.stopPing(e)
e.pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try { ws.ping() } catch {}
try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })) } catch {}
}
}, 25_000)
})
ws.on('pong', () => {
// 对端回了 pong, 标记连接健康 (留个 hook, 后续可加 ws-down 检测)
}) })
ws.on('message', (raw) => { ws.on('message', (raw) => {
try { try {
const frame = JSON.parse(raw.toString('utf8')) as WsFrame const frame = JSON.parse(raw.toString('utf8')) as WsFrame
if (frame.type === 'ping') {
// 应用层 ping: 回 pong
try { ws.send(JSON.stringify({ type: 'pong', ts: frame.ts })) } catch {}
return
}
// 更新对端地址 (可能 ip 变了) // 更新对端地址 (可能 ip 变了)
if (frame.type === 'hello' && frame.from) { if (frame.type === 'hello' && frame.from) {
e.peer = { ...peer, ...frame.from, address: peer.address } e.peer = { ...peer, ...frame.from, address: peer.address }
@@ -105,10 +127,11 @@ export class ChatClient extends EventEmitter {
} catch {} } catch {}
}) })
ws.on('close', () => { ws.on('close', () => {
this.stopPing(e)
e.ws = null e.ws = null
this.emit('close', e.peer) this.emit('close', e.peer)
if (e.alive) this.scheduleConnect(e, e.backoff) if (e.alive) this.scheduleConnect(e, e.backoff)
e.backoff = Math.min(e.backoff * 2, 30_000) e.backoff = Math.min(e.backoff * 2, 5_000)
}) })
ws.on('error', (err: Error) => { ws.on('error', (err: Error) => {
this.emit('error', e.peer, err) this.emit('error', e.peer, err)
@@ -116,12 +139,27 @@ export class ChatClient extends EventEmitter {
}) })
} }
send(frame: WsFrame, deviceId?: string) { private stopPing(e: Entry) {
if (e.pingTimer) {
clearInterval(e.pingTimer)
e.pingTimer = null
}
}
send(frame: WsFrame, deviceId?: string): boolean {
if (deviceId) { if (deviceId) {
const e = this.entries.get(deviceId) const e = this.entries.get(deviceId)
if (e?.ws && e.ws.readyState === WebSocket.OPEN) { if (e?.ws && e.ws.readyState === WebSocket.OPEN) {
try {
e.ws.send(JSON.stringify(frame)) e.ws.send(JSON.stringify(frame))
return true return true
} catch {
// send 抛错 (例如连接刚挂但 readyState 还没更新) — 关掉这个 entry 让下一次 flush 重连
try { e.ws.terminate() } catch {}
e.ws = null
if (e.alive) this.scheduleConnect(e, 0)
return false
}
} }
return false return false
} }
@@ -129,8 +167,7 @@ export class ChatClient extends EventEmitter {
let any = false let any = false
for (const e of this.entries.values()) { for (const e of this.entries.values()) {
if (e.ws && e.ws.readyState === WebSocket.OPEN) { if (e.ws && e.ws.readyState === WebSocket.OPEN) {
e.ws.send(JSON.stringify(frame)) try { e.ws.send(JSON.stringify(frame)); any = true } catch {}
any = true
} }
} }
return any return any
+5
View File
@@ -10,6 +10,7 @@ export interface ChatServerEvents {
message: (from: DeviceInfo, msg: MessageEnvelope) => void message: (from: DeviceInfo, msg: MessageEnvelope) => void
recall: (from: DeviceInfo, messageId: string) => void recall: (from: DeviceInfo, messageId: string) => void
typing: (from: DeviceInfo) => void typing: (from: DeviceInfo) => void
read: (from: DeviceInfo, upToMessageId: string) => void
} }
export class ChatServer extends EventEmitter { export class ChatServer extends EventEmitter {
@@ -77,6 +78,10 @@ export class ChatServer extends EventEmitter {
case 'recall': case 'recall':
if (peer) this.emit('recall', peer, frame.messageId) if (peer) this.emit('recall', peer, frame.messageId)
break break
case 'read':
// 已读回执: 接收方已看到 upTo (含), 把 ≤ upTo 的发送方消息视为已读
if (peer) this.emit('read', peer, frame.upTo)
break
case 'typing': case 'typing':
if (peer) this.emit('typing', peer) if (peer) this.emit('typing', peer)
break break
+18 -2
View File
@@ -80,8 +80,11 @@ export function upsertDevice(d: DeviceRow) {
upsertDeviceStmt.run(d) upsertDeviceStmt.run(d)
} }
export function listDevices(): DeviceRow[] { export function listDevices(includeIgnored = false): DeviceRow[] {
return db.prepare(`SELECT * FROM devices ORDER BY last_seen DESC`).all() as DeviceRow[] const sql = includeIgnored
? `SELECT * FROM devices ORDER BY last_seen DESC`
: `SELECT * FROM devices WHERE ignored = 0 ORDER BY last_seen DESC`
return db.prepare(sql).all() as DeviceRow[]
} }
export function getDevice(id: string): DeviceRow | undefined { export function getDevice(id: string): DeviceRow | undefined {
@@ -178,6 +181,19 @@ export function listPendingMessages(peerId: string, selfId: string): MessageRow[
).all(key) as MessageRow[] ).all(key) as MessageRow[]
} }
// 已上传完但 WS update 帧 (带 savedPath) 没送达: metadata 帧 status='sent',
// body.savedPath 已填, 重发完整 envelope 让收件方出 "打开/位置"
export function listSentFileUpdates(selfId: string, peerId: string): MessageRow[] {
return db.prepare(
`SELECT * FROM messages
WHERE from_id = ? AND to_id = ?
AND status = 'sent'
AND type IN ('file', 'image')
AND json_extract(body_json, '$.savedPath') IS NOT NULL
ORDER BY ts ASC`
).all(selfId, peerId) as MessageRow[]
}
export function listAllPending(selfId: string): MessageRow[] { export function listAllPending(selfId: string): MessageRow[] {
return db.prepare( return db.prepare(
`SELECT * FROM messages WHERE status = 'pending' AND from_id = ? ORDER BY ts ASC` `SELECT * FROM messages WHERE status = 'pending' AND from_id = ? ORDER BY ts ASC`
+145 -24
View File
@@ -9,8 +9,8 @@ import { getSettings, updateSettings, ensureDownloadDir } from './settings'
import { import {
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage, db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread, updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
getUnread, totalUnread, listPendingMessages, listAllPending, getUnread, totalUnread, listPendingMessages, listAllPending, listSentFileUpdates,
setDeviceIgnored, listIgnoredDevices, deleteConversation setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation
} from './db' } from './db'
import { Discovery, listNetInterfaces, type NetInterface } from './discovery' import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
import { ChatServer } from './chat-server' import { ChatServer } from './chat-server'
@@ -19,7 +19,7 @@ import { FileServer, uploadFile, uploadBuffer } from './file-server'
import { broadcastToRenderer, focusMainWindow } from './window' import { broadcastToRenderer, focusMainWindow } from './window'
import { rebuildMenu } from './tray' import { rebuildMenu } from './tray'
import { notify, setBadgeCount } from './notify' import { notify, setBadgeCount } from './notify'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage } from './protocol' import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef } from './protocol'
interface NetCtx { interface NetCtx {
self: DeviceInfo self: DeviceInfo
@@ -32,7 +32,8 @@ interface NetCtx {
let ctx: NetCtx | null = null let ctx: NetCtx | null = null
// 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer // 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer
// 任一进度事件或 received 完成事件取消; 到了 -> mark failed 并删消息, 避免气泡永远 "receiving" // 任一进度事件或 received 完成事件取消; 到了 -> 只通知 sender, 不自动删消息
// 气泡留在 receiving 状态, 让用户右键手动删
const PENDING_FILE_TIMEOUT_MS = 60_000 const PENDING_FILE_TIMEOUT_MS = 60_000
const pendingMetaTimers = new Map<string, { timer: NodeJS.Timeout; messageId: string; fromDeviceId: string }>() const pendingMetaTimers = new Map<string, { timer: NodeJS.Timeout; messageId: string; fromDeviceId: string }>()
@@ -40,10 +41,8 @@ function armPendingMetaTimer(fileId: string, messageId: string, fromDeviceId: st
disarmPendingMetaTimer(fileId) disarmPendingMetaTimer(fileId)
const timer = setTimeout(() => { const timer = setTimeout(() => {
pendingMetaTimers.delete(fileId) pendingMetaTimers.delete(fileId)
console.warn(`[ipc] file ${fileId} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s, marking failed`) console.warn(`[ipc] file ${fileId.slice(0, 8)} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s; bubble stays for user to delete`)
try { deleteMessage(messageId) } catch {} // 通知 sender 标记失败 (sender 在线时能把 status='sent' 升级到 'failed')
broadcastToRenderer('message:recalled', { messageId, conversationKey: '' })
// 也告诉 sender: 这文件永远来不了 (如果是 sender 在线, 它能 mark failed)
ctx?.chatClient.send({ type: 'fileFailed', fileId, messageId, reason: 'timeout' } as any, fromDeviceId) ctx?.chatClient.send({ type: 'fileFailed', fileId, messageId, reason: 'timeout' } as any, fromDeviceId)
}, PENDING_FILE_TIMEOUT_MS) }, PENDING_FILE_TIMEOUT_MS)
pendingMetaTimers.set(fileId, { timer, messageId, fromDeviceId }) pendingMetaTimers.set(fileId, { timer, messageId, fromDeviceId })
@@ -61,6 +60,8 @@ export function bindNetworkContext(c: NetCtx) {
ctx = c ctx = c
// 网络事件 -> 渲染进程 // 网络事件 -> 渲染进程
c.discovery.on('found', (d) => { c.discovery.on('found', (d) => {
// 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连
if (getDevice(d.deviceId)?.ignored) return
upsertDevice({ upsertDevice({
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform, device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0 app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
@@ -69,6 +70,7 @@ export function bindNetworkContext(c: NetCtx) {
c.chatClient.connectTo(d) c.chatClient.connectTo(d)
}) })
c.discovery.on('updated', (d: DeviceInfo) => { c.discovery.on('updated', (d: DeviceInfo) => {
if (getDevice(d.deviceId)?.ignored) return
upsertDevice({ upsertDevice({
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform, device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0 app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
@@ -77,6 +79,7 @@ export function bindNetworkContext(c: NetCtx) {
c.chatClient.connectTo(d) c.chatClient.connectTo(d)
}) })
c.discovery.on('lost', (id) => { c.discovery.on('lost', (id) => {
if (getDevice(id)?.ignored) return
broadcastToRenderer('device:lost', { deviceId: id }) broadcastToRenderer('device:lost', { deviceId: id })
}) })
@@ -172,6 +175,17 @@ export function bindNetworkContext(c: NetCtx) {
console.warn(`[chat-client] ws error to ${peer.name} (${peer.address}:${peer.chatPort}): ${err.message}`) console.warn(`[chat-client] ws error to ${peer.name} (${peer.address}:${peer.chatPort}): ${err.message}`)
}) })
// 我们这端 outgoing WS 接通 / 断开 — 都要广播给 renderer, 让 UI 能区分
// "discovery 在线" 和 "WS 在线"; 同时 open 时主动 flush 一次待发消息 (双端 WS 都建好后必走 chat-server.on('connect') 那条路,
// 但如果对方先来认我们这边, 我们 outgoing 还没通, flush 在自己这条线路上反而能立刻推)
c.chatClient.on('open', (peer) => {
broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: true })
flushPendingFor(peer.deviceId)
})
c.chatClient.on('close', (peer) => {
broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: false })
})
c.chatServer.on('disconnect', (id) => { c.chatServer.on('disconnect', (id) => {
broadcastToRenderer('device:offline', { deviceId: id }) broadcastToRenderer('device:offline', { deviceId: id })
}) })
@@ -189,6 +203,37 @@ export function bindNetworkContext(c: NetCtx) {
c.fileServer.on('received', (info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }) => { c.fileServer.on('received', (info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }) => {
disarmPendingMetaTimer(info.fileId) disarmPendingMetaTimer(info.fileId)
}) })
// 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示
c.chatServer.on('recall', (from, messageId) => {
const row = getMessage(messageId)
if (!row) return
let savedPath: string | undefined
try { savedPath = (JSON.parse(row.body_json) as any)?.savedPath } catch {}
deleteMessage(messageId)
if (savedPath) {
fsp.unlink(savedPath).catch((e) => {
if (e?.code !== 'ENOENT') console.warn(`[recall] unlink ${savedPath} failed: ${e.message}`)
})
}
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
})
// 已读回执: 对端说"我看到 upTo 了", 把我方所有 from=self, to=peer, ts<=read.ts, status='delivered' 的消息升 'read'
c.chatServer.on('read', (from, upToMessageId) => {
if (!ctx) return
const row = getMessage(upToMessageId)
if (!row) return
const upToTs = row.ts
// 只升级 delivered -> read, sent 留给 ack 处理
const stmt = db.prepare(`UPDATE messages SET status = 'read' WHERE from_id = ? AND to_id = ? AND status = 'delivered' AND ts <= ?`)
stmt.run(ctx.self.deviceId, from.deviceId, upToTs)
// 把这次升级告诉 renderer (逐条 statusChanged, 复用现有渲染层逻辑)
const updated = db.prepare(`SELECT message_id FROM messages WHERE from_id = ? AND to_id = ? AND ts <= ? AND status = 'read'`).all(ctx.self.deviceId, from.deviceId, upToTs) as Array<{ message_id: string }>
for (const r of updated) {
broadcastToRenderer('message:statusChanged', { messageId: r.message_id, status: 'read' })
}
})
} }
function notifyIfNeeded(from: DeviceInfo, env: MessageEnvelope) { function notifyIfNeeded(from: DeviceInfo, env: MessageEnvelope) {
@@ -229,8 +274,10 @@ async function deliverAndStore(env: MessageEnvelope): Promise<{ ok: boolean; rea
// 2. 通过 ws 发送 // 2. 通过 ws 发送
const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, env.toDeviceId) const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, env.toDeviceId)
if (!sent) { if (!sent) {
// 对方不在线, 保持 pending, flush // WS 没开 — 消息已在 DB 里 status='pending', flushPendingFor 在对端 outgoing WS 接通时会自动重发.
return { ok: false, reason: 'peer offline', status: 'pending' } // 不当作 "失败" 报给前端, 避免误导 (因为对方 UDP 可能还在, 只是 WS 抖了).
// 副作用: 渲染端的 "待对方上线" 气泡/重试按钮照常显示; 不再弹 "发送失败" toast.
return { ok: true, status: 'pending' }
} }
// 3. 标记 sent (本地认为已送出; 真实送达需要 ack 协议, 见 deliverAndStoreAck) // 3. 标记 sent (本地认为已送出; 真实送达需要 ack 协议, 见 deliverAndStoreAck)
updateMessageStatus(env.messageId, 'sent') updateMessageStatus(env.messageId, 'sent')
@@ -267,10 +314,10 @@ async function flushPendingFor(peerId: string) {
} }
// 2) 上传完但 WS update 帧没送达的消息 (savedPath 已填, status 还在 'sent') // 2) 上传完但 WS update 帧没送达的消息 (savedPath 已填, status 还在 'sent')
// 实际是 WS metadata 已发, 接收方在等 savedPath; 重连后重发完整 envelope 让他出 "打开/位置" // 实际是 WS metadata 已发, 接收方在等 savedPath; 重连后重发完整 envelope 让他出 "打开/位置"
for (const row of rows) { // 注意: rows 上面已经被 status='pending' 过滤, 这里必须重新查 status='sent' + savedPath 已填的文件
const updateRows = listSentFileUpdates(ctx.self.deviceId, peerId)
for (const row of updateRows) {
const body = JSON.parse(row.body_json) const body = JSON.parse(row.body_json)
const fileBody = body as any
if ((row.type === 'file' || row.type === 'image') && fileBody.savedPath) {
const env: MessageEnvelope = { const env: MessageEnvelope = {
messageId: row.message_id, messageId: row.message_id,
type: row.type as any, type: row.type as any,
@@ -286,7 +333,6 @@ async function flushPendingFor(peerId: string) {
count++ count++
} }
} }
}
if (count > 0) console.log(`[ipc] flushed ${count} pending/update messages to ${peerId.slice(0, 8)}`) if (count > 0) console.log(`[ipc] flushed ${count} pending/update messages to ${peerId.slice(0, 8)}`)
} }
@@ -326,6 +372,56 @@ export function registerIpc() {
return true return true
}) })
// 删除设备: 标记 ignored=1, 停掉后台重连, 从 sidebar 消失
// UDP 后续再发现也不会重新弹 (discovery 上游已过滤 ignored)
ipcMain.handle('device:delete', (_e, deviceId: string) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
if (deviceId === ctx.self.deviceId) return { ok: false, reason: '不能删除本机' }
setDeviceIgnored(deviceId, true)
ctx.chatClient.remove(deviceId) // 停掉 backoff 重连
ctx.discovery.forget(deviceId) // 从 in-memory peers Map 移除
broadcastToRenderer('device:lost', { deviceId }) // 通知 renderer 把它从 device store 摘掉
// 清掉对应未读
clearUnread(deviceId)
return { ok: true }
})
// 删除单条消息 (本地) — 不广播, 不撤回对端
// opts.deleteFile: 同时删掉磁盘上的文件 (按 msg.body.savedPath)
// 返回 { ok, deletedFile? } 让 UI 在弹框里展示真实路径
ipcMain.handle('message:delete', async (_e, messageId: string, opts?: { deleteFile?: boolean }) => {
const row = getMessage(messageId)
if (!row) return { ok: false, reason: '消息不存在' }
let deletedFile: string | undefined
if (opts?.deleteFile) {
const body = (() => { try { return JSON.parse(row.body_json) } catch { return null } })()
const filePath: string | undefined = body?.savedPath
if (filePath) {
try {
await fsp.unlink(filePath)
deletedFile = filePath
} catch (e: any) {
// 文件不在磁盘上 (用户手动删了/换盘) 不视为失败 — 把消息也删了
if (e?.code !== 'ENOENT') return { ok: false, reason: `删除文件失败: ${e?.message || e}` }
}
}
}
deleteMessage(messageId)
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
return { ok: true, deletedFile }
})
// 已读回执: 本机在 active 对话里看到了新消息, 告诉对端把 upTo (含) 之前的消息标 'read'
ipcMain.handle('message:markRead', (_e, args: { peerId: string; upToMessageId: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
if (!args?.peerId || !args?.upToMessageId) return { ok: false, reason: '参数缺失' }
const sent = ctx.chatClient.send(
{ type: 'read', upTo: args.upToMessageId, ts: Date.now() },
args.peerId
)
return { ok: sent, reason: sent ? undefined : '对方离线' }
})
// ---- 消息 ---- // ---- 消息 ----
ipcMain.handle('message:list', (_e, peerId: string) => { ipcMain.handle('message:list', (_e, peerId: string) => {
if (!ctx) return [] if (!ctx) return []
@@ -333,7 +429,7 @@ export function registerIpc() {
return rows.map(rowToEnvelope) return rows.map(rowToEnvelope)
}) })
ipcMain.handle('message:sendText', async (_e, args: { toDeviceId: string; content: string }) => { ipcMain.handle('message:sendText', async (_e, args: { toDeviceId: string; content: string; replyTo?: ReplyRef }) => {
if (!ctx) return { ok: false, reason: 'no ctx' } if (!ctx) return { ok: false, reason: 'no ctx' }
const env: MessageEnvelope = { const env: MessageEnvelope = {
messageId: randomUUID(), messageId: randomUUID(),
@@ -345,6 +441,7 @@ export function registerIpc() {
type: 'text', type: 'text',
messageId: '', ts: 0, fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, messageId: '', ts: 0, fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
content: args.content, content: args.content,
...(args.replyTo ? { replyTo: args.replyTo } : {}),
} as TextMessage, } as TextMessage,
} }
;(env.body as TextMessage).messageId = env.messageId ;(env.body as TextMessage).messageId = env.messageId
@@ -365,6 +462,7 @@ export function registerIpc() {
mime?: string mime?: string
asImage?: boolean asImage?: boolean
onProgress?: boolean onProgress?: boolean
replyTo?: ReplyRef
}) => { }) => {
if (!ctx) return { ok: false, reason: 'no ctx' } if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId) const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
@@ -387,6 +485,7 @@ export function registerIpc() {
savedPath: null as string | null, savedPath: null as string | null,
hash: null as string | null, hash: null as string | null,
localPath: args.localPath, // retry 时复用 localPath: args.localPath, // retry 时复用
...(args.replyTo ? { replyTo: args.replyTo } : {}),
} }
const env: MessageEnvelope = { const env: MessageEnvelope = {
messageId, messageId,
@@ -453,8 +552,12 @@ export function registerIpc() {
} }
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' }) broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
// 再发一次 WS, 这次带 savedPath (对端气泡更新, 出现"打开") // 再发一次 WS, 这次带 savedPath (对端气泡更新, 出现"打开")
if (ctx!.chatClient.isOpen(args.toDeviceId)) { // 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId) const sentUpdate = ctx!.chatClient.isOpen(args.toDeviceId)
? ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
: false
if (!sentUpdate) {
console.warn(`[sendFile] ws closed, update frame will retry on reconnect (mid=${messageId.slice(0, 8)})`)
} }
// 不用再 broadcast statusChanged: 上一帧的 status='sent' 已经是终态之一, 等 ack 升级 delivered // 不用再 broadcast statusChanged: 上一帧的 status='sent' 已经是终态之一, 等 ack 升级 delivered
void fileId // suppress unused warning void fileId // suppress unused warning
@@ -473,6 +576,7 @@ export function registerIpc() {
dataBase64: string dataBase64: string
name: string name: string
mime: string mime: string
replyTo?: ReplyRef
}) => { }) => {
if (!ctx) return { ok: false, reason: 'no ctx' } if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId) const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
@@ -490,6 +594,7 @@ export function registerIpc() {
fileId, name: args.name, size: buf.length, mime: args.mime, fileId, name: args.name, size: buf.length, mime: args.mime,
thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined, thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
dataBase64: args.dataBase64, dataBase64: args.dataBase64,
...(args.replyTo ? { replyTo: args.replyTo } : {}),
} }
const env: MessageEnvelope = { const env: MessageEnvelope = {
messageId, type: 'image', messageId, type: 'image',
@@ -540,8 +645,12 @@ export function registerIpc() {
body: { ...baseBody, savedPath: uploadResult.savedPath as any, hash: uploadResult.hash as any }, body: { ...baseBody, savedPath: uploadResult.savedPath as any, hash: uploadResult.hash as any },
} }
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' }) broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
if (ctx!.chatClient.isOpen(args.toDeviceId)) { // 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId) const sentUpdate = ctx!.chatClient.isOpen(args.toDeviceId)
? ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
: false
if (!sentUpdate) {
console.warn(`[sendBuffer] ws closed, update frame will retry on reconnect (mid=${messageId.slice(0, 8)})`)
} }
void fileId void fileId
})().catch((e: any) => { })().catch((e: any) => {
@@ -556,11 +665,17 @@ export function registerIpc() {
ipcMain.handle('message:recall', async (_e, messageId: string) => { ipcMain.handle('message:recall', async (_e, messageId: string) => {
if (!ctx) return { ok: false } if (!ctx) return { ok: false }
const row = getMessage(messageId) const row = getMessage(messageId)
if (!row) return { ok: false, reason: 'no message' } if (!row) return { ok: false, reason: '消息不存在' }
if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: 'not your message' } if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: '只能撤回自己发的消息' }
// 微信式 2 分钟时限
const RECALL_LIMIT_MS = 2 * 60_000
if (Date.now() - row.ts > RECALL_LIMIT_MS) {
const ageMin = Math.round((Date.now() - row.ts) / 60_000)
return { ok: false, reason: `超过 2 分钟无法撤回 (已 ${ageMin} 分钟)` }
}
// 删除本地 // 删除本地
deleteMessage(messageId) deleteMessage(messageId)
// 通知对端 // 通知对端 (不传 savedPath 让对端从 DB 自行取 — 这里的 row 还在; 但保险起见让对端查自己的 DB)
ctx.chatClient.send({ type: 'recall', messageId, ts: Date.now() }, row.to_id) ctx.chatClient.send({ type: 'recall', messageId, ts: Date.now() }, row.to_id)
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key }) broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
return { ok: true } return { ok: true }
@@ -599,7 +714,7 @@ export function registerIpc() {
const peer = ctx.discovery.getPeers().find(p => p.deviceId === row.to_id) const peer = ctx.discovery.getPeers().find(p => p.deviceId === row.to_id)
if (!peer) { if (!peer) {
setStatus('pending') setStatus('pending')
return { ok: false, reason: 'peer offline', status: 'pending' } return { ok: false, reason: '对方设备不在线', status: 'pending', wsDown: true }
} }
if (!ctx.chatClient.isOpen(row.to_id)) { if (!ctx.chatClient.isOpen(row.to_id)) {
setStatus('pending') setStatus('pending')
@@ -701,7 +816,13 @@ export function registerIpc() {
ensureDownloadDir() ensureDownloadDir()
if (patch.deviceName) { if (patch.deviceName) {
ctx!.self.name = patch.deviceName ctx!.self.name = patch.deviceName
// 通知前端并重启 broadcast 频次 }
if ('autoStart' in patch && app.isPackaged) {
try {
app.setLoginItemSettings({ openAtLogin: !!patch.autoStart, path: process.execPath })
} catch (e: any) {
console.warn('[settings:set] setLoginItemSettings failed:', e?.message)
}
} }
rebuildMenu() rebuildMenu()
broadcastToRenderer('settings:changed', ns) broadcastToRenderer('settings:changed', ns)
+11
View File
@@ -35,17 +35,28 @@ export type WsFrame =
| { type: 'ack'; messageId: string; status: 'sent' | 'delivered' | 'read' } | { type: 'ack'; messageId: string; status: 'sent' | 'delivered' | 'read' }
| { type: 'recall'; messageId: string; ts: number } | { type: 'recall'; messageId: string; ts: number }
| { type: 'typing'; from: string; ts: number } | { type: 'typing'; from: string; ts: number }
| { type: 'read'; upTo: string; ts: number } // 接收方发, upTo = 看到的最后一条 messageId; ≤ upTo 的发送方消息视为已读
// ============ 消息 ============ // ============ 消息 ============
export type MessageType = 'text' | 'image' | 'file' | 'system' export type MessageType = 'text' | 'image' | 'file' | 'system'
// 引用回复的快照: 只携带"足够渲染"作者 + 内容预览, 不依赖原消息存在/可见
// (原消息被撤回/删除也不影响这条引用)
export interface ReplyRef {
messageId: string
authorName: string
type: MessageType // 只能是 text/file/image, 不允许 system/system
preview: string // text: 截断的正文; file/image: 文件名; 多行用空格折
}
interface BaseMessage { interface BaseMessage {
messageId: string messageId: string
type: MessageType type: MessageType
fromDeviceId: string fromDeviceId: string
toDeviceId: string toDeviceId: string
ts: number ts: number
replyTo?: ReplyRef // 可选引用
} }
export interface TextMessage extends BaseMessage { export interface TextMessage extends BaseMessage {
+8 -4
View File
@@ -9,15 +9,19 @@ contextBridge.exposeInMainWorld('api', {
self: () => ipcRenderer.invoke('self:info'), self: () => ipcRenderer.invoke('self:info'),
listDevices: () => ipcRenderer.invoke('device:list'), listDevices: () => ipcRenderer.invoke('device:list'),
triggerScan: () => ipcRenderer.invoke('device:triggerScan'), triggerScan: () => ipcRenderer.invoke('device:triggerScan'),
deleteDevice: (deviceId: string) => ipcRenderer.invoke('device:delete', deviceId),
// 消息 // 消息
listMessages: (peerId: string) => ipcRenderer.invoke('message:list', peerId), listMessages: (peerId: string) => ipcRenderer.invoke('message:list', peerId),
sendText: (toDeviceId: string, content: string) => ipcRenderer.invoke('message:sendText', { toDeviceId, content }), sendText: (toDeviceId: string, content: string, opts?: { replyTo?: any }) =>
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) => ipcRenderer.invoke('message:sendText', { toDeviceId, content, replyTo: opts?.replyTo }),
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean; replyTo?: any }) =>
ipcRenderer.invoke('message:sendFile', { toDeviceId, localPath, ...(opts || {}) }), ipcRenderer.invoke('message:sendFile', { toDeviceId, localPath, ...(opts || {}) }),
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) => sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string, opts?: { replyTo?: any }) =>
ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime }), ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime, replyTo: opts?.replyTo }),
recall: (messageId: string) => ipcRenderer.invoke('message:recall', messageId), recall: (messageId: string) => ipcRenderer.invoke('message:recall', messageId),
deleteMessage: (messageId: string, opts?: { deleteFile?: boolean }) => ipcRenderer.invoke('message:delete', messageId, opts),
markRead: (peerId: string, upToMessageId: string) => ipcRenderer.invoke('message:markRead', { peerId, upToMessageId }),
retry: (messageId: string) => ipcRenderer.invoke('message:retry', messageId), retry: (messageId: string) => ipcRenderer.invoke('message:retry', messageId),
typing: (toDeviceId: string) => ipcRenderer.invoke('message:typing', toDeviceId), typing: (toDeviceId: string) => ipcRenderer.invoke('message:typing', toDeviceId),
+23 -1
View File
@@ -6,6 +6,7 @@ import { useSessionStore } from '@/stores/session'
import Sidebar from '@/components/Sidebar.vue' import Sidebar from '@/components/Sidebar.vue'
import ChatView from '@/components/ChatView.vue' import ChatView from '@/components/ChatView.vue'
import SettingsView from '@/components/SettingsView.vue' import SettingsView from '@/components/SettingsView.vue'
import ContextMenu from '@/components/ContextMenu.vue'
import { formatTime } from '@/utils/format' import { formatTime } from '@/utils/format'
import type { DeviceView, MessageView, Settings } from '@/api' import type { DeviceView, MessageView, Settings } from '@/api'
@@ -59,12 +60,20 @@ onMounted(async () => {
const d = device.devices.find(x => x.deviceId === deviceId) const d = device.devices.find(x => x.deviceId === deviceId)
if (d) d.online = false if (d) d.online = false
}) })
// 我们 outgoing WS 接通/断开 — 让 chat header 能区分 "discovery 在线" 和 "WS 在线"
window.api.on('device:wsState', ({ deviceId, wsOpen }: { deviceId: string; wsOpen: boolean }) => {
device.setWsOpen(deviceId, wsOpen)
})
window.api.on('message:received', (env: MessageView) => { window.api.on('message:received', (env: MessageView) => {
const fromId = env.fromDeviceId const fromId = env.fromDeviceId
// 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次 // 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次
// 第二次带 savedPath, 气泡自动有"打开/位置" // 第二次带 savedPath, 气泡自动有"打开/位置"
const fid = (env.body as any)?.fileId const fid = (env.body as any)?.fileId
if (fid && !(fid in message.progressByFileId)) { const savedPath = (env.body as any)?.savedPath
if (fid && savedPath) {
// 完整帧: 进度条直接清, 防止进度 entry 在 setProgress(0) 时被复活成 sent=0 幽灵
message.clearProgress(fid)
} else if (fid && !(fid in message.progressByFileId)) {
message.setProgress(fid, 0, (env.body as any)?.size || 0) message.setProgress(fid, 0, (env.body as any)?.size || 0)
} }
message.upsert(env) message.upsert(env)
@@ -83,6 +92,10 @@ onMounted(async () => {
device.unread = next device.unread = next
} }
window.api.clearUnread(fromId) window.api.clearUnread(fromId)
// 已读回执: 当前在 active 对话里看到了新消息, 告诉对端
if (env.type !== 'system') {
window.api.markRead(fromId, env.messageId).catch(() => {})
}
return return
} }
device.refresh().then(() => { device.refresh().then(() => {
@@ -141,6 +154,14 @@ watch(() => session.activePeerId, async (id) => {
} }
await message.ensureLoaded(id) await message.ensureLoaded(id)
await window.api.clearUnread(id) await window.api.clearUnread(id)
// 已读回执: 切到对话时把看到的最后一条发给对端; 对端会把 ≤ upTo.ts 的发送方消息标 'read'
const msgs = message.byPeer[id] || []
if (msgs.length > 0) {
const latest = msgs[msgs.length - 1]
if (latest.type !== 'system') {
window.api.markRead(id, latest.messageId).catch(() => {})
}
}
}) })
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
@@ -168,5 +189,6 @@ document.addEventListener('click', (e) => {
<div v-if="imageViewer" class="image-viewer" @click="imageViewer = null"> <div v-if="imageViewer" class="image-viewer" @click="imageViewer = null">
<img :src="imageViewer" /> <img :src="imageViewer" />
</div> </div>
<ContextMenu />
</div> </div>
</template> </template>
+15 -3
View File
@@ -6,12 +6,15 @@ export type LnmApi = {
self: () => Promise<DeviceSelf | null> self: () => Promise<DeviceSelf | null>
listDevices: () => Promise<DeviceView[]> listDevices: () => Promise<DeviceView[]>
triggerScan: () => Promise<boolean> triggerScan: () => Promise<boolean>
deleteDevice: (deviceId: string) => Promise<{ ok: boolean; reason?: string }>
listMessages: (peerId: string) => Promise<MessageView[]> listMessages: (peerId: string) => Promise<MessageView[]>
sendText: (toDeviceId: string, content: string) => Promise<{ ok: boolean; reason?: string }> sendText: (toDeviceId: string, content: string, opts?: { replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }>
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) => Promise<{ ok: boolean; reason?: string }> sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean; replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }>
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) => Promise<{ ok: boolean; reason?: string }> sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string, opts?: { replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }>
recall: (messageId: string) => Promise<{ ok: boolean; reason?: string }> recall: (messageId: string) => Promise<{ ok: boolean; reason?: string }>
deleteMessage: (messageId: string, opts?: { deleteFile?: boolean }) => Promise<{ ok: boolean; reason?: string; deletedFile?: string }>
markRead: (peerId: string, upToMessageId: string) => Promise<{ ok: boolean; reason?: string }>
retry: (messageId: string) => Promise<{ ok: boolean; reason?: string; status?: string }> retry: (messageId: string) => Promise<{ ok: boolean; reason?: string; status?: string }>
typing: (toDeviceId: string) => Promise<void> typing: (toDeviceId: string) => Promise<void>
@@ -104,3 +107,12 @@ export interface ProgressEvent {
sent: number sent: number
total: number total: number
} }
// 引用回复快照 (与 main/protocol.ts 的 ReplyRef 保持一致)
// 仅携带足够渲染作者 + 内容预览的字段 — 原消息被撤回/删除也不影响这条引用
export interface ReplyRef {
messageId: string
authorName: string
type: MessageType
preview: string
}
+53 -4
View File
@@ -6,7 +6,7 @@ import { useSessionStore } from '@/stores/session'
import MessageItem from './MessageItem.vue' import MessageItem from './MessageItem.vue'
import MessageInput from './MessageInput.vue' import MessageInput from './MessageInput.vue'
import type { DeviceView } from '@/api' import type { DeviceView } from '@/api'
import { initialsOf, colorFor } from '@/utils/format' import { initialsOf, colorFor, formatTime } from '@/utils/format'
import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus' import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus'
import { ChatLineRound, Promotion, UploadFilled } from '@element-plus/icons-vue' import { ChatLineRound, Promotion, UploadFilled } from '@element-plus/icons-vue'
@@ -20,6 +20,13 @@ const session = useSessionStore()
const self = computed(() => device.self) const self = computed(() => device.self)
const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : []) const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : [])
const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false) const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false)
// 我方 outgoing WS 是否连通 — 严格默认 false (从没收到过 device:wsState 时,
// 显示 "等待重连" 而不是骗用户 "在线"; 看到首次 open 事件后才会变成 true)
const wsOpen = computed(() => {
if (!props.peer) return false
const v = device.wsOpen[props.peer.deviceId]
return v === undefined ? false : v
})
const bodyEl = ref<{ scrollTo: (opts: { top: number }) => void } | null>(null) const bodyEl = ref<{ scrollTo: (opts: { top: number }) => void } | null>(null)
function scrollToBottom() { function scrollToBottom() {
@@ -28,6 +35,24 @@ function scrollToBottom() {
}) })
} }
// 点击回复卡片 → 滚到原消息 (并短闪高亮)
function onScrollToMessage(messageId: string) {
const inner = document.querySelector('.chat-body-inner')
if (!inner) return
const target = inner.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`) as HTMLElement | null
if (!target) {
ElMessage.info('原消息已撤回或不在当前会话')
return
}
// 滚到目标附近 (让浏览器负责处理 scrollable 容器)
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 短闪高亮 (重启动画 — 强制 reflow 去掉旧 class)
target.classList.remove('flash-target')
void (target as HTMLElement).offsetWidth
target.classList.add('flash-target')
setTimeout(() => target.classList.remove('flash-target'), 1500)
}
watch(() => session.activePeerId, () => scrollToBottom()) watch(() => session.activePeerId, () => scrollToBottom())
watch(() => messages.value.length, () => scrollToBottom()) watch(() => messages.value.length, () => scrollToBottom())
@@ -105,9 +130,19 @@ async function onGlobalDrop(e: DragEvent) {
<div class="header-info"> <div class="header-info">
<div class="header-name">{{ peer.name }}</div> <div class="header-name">{{ peer.name }}</div>
<div class="header-meta"> <div class="header-meta">
<span class="status-dot" :class="{ online: peer.online }"></span> <span
<template v-if="peer.online">在线 · {{ peer.address }}</template> class="status-dot"
<template v-else>离线</template> :class="{ online: peer.online && wsOpen, 'ws-down': peer.online && !wsOpen, offline: !peer.online }"
/>
<template v-if="!peer.online">
离线 · {{ formatTime(peer.lastSeen) }}
</template>
<template v-else-if="!wsOpen">
在线 · {{ peer.address }} <span class="ws-note">(等待重连)</span>
</template>
<template v-else>
在线 · {{ peer.address }}
</template>
</div> </div>
</div> </div>
</header> </header>
@@ -123,6 +158,7 @@ async function onGlobalDrop(e: DragEvent) {
:peer="peer" :peer="peer"
:self="self!" :self="self!"
@open-image="emit('open-image', $event)" @open-image="emit('open-image', $event)"
@scroll-to-message="onScrollToMessage"
/> />
</div> </div>
</el-scrollbar> </el-scrollbar>
@@ -196,6 +232,19 @@ async function onGlobalDrop(e: DragEvent) {
display: inline-block; display: inline-block;
} }
.status-dot.online { background: var(--el-color-success); } .status-dot.online { background: var(--el-color-success); }
.status-dot.ws-down {
background: var(--el-color-warning);
animation: ws-pulse 1.6s ease-in-out infinite;
}
@keyframes ws-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.ws-note {
color: var(--el-color-warning);
font-weight: 500;
margin-left: 2px;
}
.chat-body { flex: 1; min-height: 0; } .chat-body { flex: 1; min-height: 0; }
.chat-body-inner { padding: 16px 20px 0; } .chat-body-inner { padding: 16px 20px 0; }
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ctxMenuState, pickContextMenu, closeContextMenu } from '@/composables/contextMenu'
const menuEl = ref<HTMLDivElement | null>(null)
// 菜单展示后, 调整位置不让菜单跑出屏幕外
watch(() => ctxMenuState.visible, async (v) => {
if (!v) return
await nextTick()
if (!menuEl.value) return
const rect = menuEl.value.getBoundingClientRect()
let nx = ctxMenuState.x, ny = ctxMenuState.y
// 优先"右击位置为菜单的左上角"; 如果右侧跑出去, 左移菜单; 下侧跑出去, 上移菜单
if (rect.right > window.innerWidth - 8) nx = window.innerWidth - rect.width - 8
if (rect.bottom > window.innerHeight - 8) ny = window.innerHeight - rect.height - 8
if (nx < 8) nx = 8
if (ny < 8) ny = 8
if (nx !== ctxMenuState.x || ny !== ctxMenuState.y) {
menuEl.value.style.left = nx + 'px'
menuEl.value.style.top = ny + 'px'
}
})
function pick(cmd: string) { pickContextMenu(cmd) }
</script>
<template>
<Teleport to="body">
<!-- 透明遮罩: 点空白或右键空白都关闭 -->
<div
v-if="ctxMenuState.visible"
class="ctx-overlay"
@click="closeContextMenu"
@contextmenu.prevent="closeContextMenu"
/>
<div
v-if="ctxMenuState.visible"
ref="menuEl"
class="ctx-menu"
:style="{ left: ctxMenuState.x + 'px', top: ctxMenuState.y + 'px' }"
@contextmenu.prevent.stop
>
<div
v-for="item in ctxMenuState.items"
:key="item.cmd"
class="ctx-item"
:class="{ 'ctx-danger': item.danger, 'ctx-divided': item.divided }"
@click="pick(item.cmd)"
>
<el-icon v-if="item.icon" :size="14" class="ctx-icon">
<component :is="item.icon" />
</el-icon>
<span>{{ item.label }}</span>
</div>
</div>
</Teleport>
</template>
<style scoped>
.ctx-overlay {
position: fixed;
inset: 0;
z-index: 9998;
}
.ctx-menu {
position: fixed;
z-index: 9999;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.06);
min-width: 160px;
user-select: none;
}
.ctx-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
color: var(--el-text-color-primary);
transition: background 0.1s;
}
.ctx-item:hover {
background: var(--el-fill-color-light);
}
.ctx-icon {
color: var(--el-text-color-secondary);
}
.ctx-danger {
color: var(--el-color-danger);
}
.ctx-danger .ctx-icon {
color: var(--el-color-danger);
}
.ctx-danger:hover {
background: var(--el-color-danger-light-9);
}
.ctx-divided {
border-top: 1px solid var(--el-border-color-lighter);
margin-top: 4px;
padding-top: 8px;
}
</style>
+94 -6
View File
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import type { DeviceView } from '@/api' import type { DeviceView, MessageView, ReplyRef } from '@/api'
import { useDeviceStore } from '@/stores/device' import { useDeviceStore } from '@/stores/device'
import { useSessionStore } from '@/stores/session'
import { ElButton, ElTooltip, ElIcon, ElMessage } from 'element-plus' import { ElButton, ElTooltip, ElIcon, ElMessage } from 'element-plus'
import { Link, Picture, Promotion, ChatLineRound, UploadFilled } from '@element-plus/icons-vue' import { Link, Picture, Promotion, ChatLineRound, UploadFilled, Close } from '@element-plus/icons-vue'
const props = defineProps<{ peer: DeviceView }>() const props = defineProps<{ peer: DeviceView }>()
const device = useDeviceStore() const device = useDeviceStore()
const session = useSessionStore()
const text = ref('') const text = ref('')
const inputEl = ref<HTMLTextAreaElement | null>(null) const inputEl = ref<HTMLTextAreaElement | null>(null)
@@ -19,6 +21,50 @@ const uploading = ref(false)
const dragDepth = ref(0) // 用 depth 计数避免子元素进出时误判 const dragDepth = ref(0) // 用 depth 计数避免子元素进出时误判
const isDragging = computed(() => dragDepth.value > 0) const isDragging = computed(() => dragDepth.value > 0)
// 引用回复: 显示被引用条 + × 取消
const replyTo = computed(() => session.replyTo)
const replyPreview = computed(() => {
const m = replyTo.value
if (!m) return { author: '', text: '', icon: '' }
const author = m.fromDeviceId === device.self?.deviceId
? (device.self?.name || '我')
: (props.peer.name || '对方')
const b = m.body
let text = ''
let icon = ''
if (m.type === 'text') {
text = (b?.content || '').replace(/\s+/g, ' ').slice(0, 80)
icon = '💬'
} else if (m.type === 'image') {
text = `[图片] ${b?.name || ''}`
icon = '🖼'
} else if (m.type === 'file') {
text = `[文件] ${b?.name || ''}`
icon = '📎'
}
return { author, text, icon }
})
function cancelReply() {
session.setReplyTo(null)
}
// 切到别的设备时清掉 replyTo (跨对话的引用不合法)
watch(() => session.activePeerId, () => session.setReplyTo(null))
function buildReplyRef(msg: MessageView): ReplyRef {
const b = msg.body
let preview = ''
if (msg.type === 'text') preview = (b?.content || '').replace(/\s+/g, ' ').slice(0, 200)
else if (msg.type === 'image') preview = `[图片] ${b?.name || ''}`
else if (msg.type === 'file') preview = `[文件] ${b?.name || ''}`
// 用真实设备名 (而不是硬编码"我"), 接收方看到的是清晰的人名
const authorName = msg.fromDeviceId === device.self?.deviceId
? (device.self?.name || '我')
: (props.peer.name || '对方')
return { messageId: msg.messageId, authorName, type: msg.type, preview }
}
function autoSize() { function autoSize() {
if (!inputEl.value) return if (!inputEl.value) return
inputEl.value.style.height = 'auto' inputEl.value.style.height = 'auto'
@@ -35,17 +81,20 @@ async function send() {
const t = text.value.trim() const t = text.value.trim()
if (!t && pendingImages.value.length === 0) return if (!t && pendingImages.value.length === 0) return
uploading.value = true uploading.value = true
const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
try { try {
if (t) { if (t) {
const r = await window.api.sendText(props.peer.deviceId, t) const r = await window.api.sendText(props.peer.deviceId, t, replyRef ? { replyTo: replyRef } : undefined)
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning') if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
} }
for (const img of pendingImages.value) { for (const img of pendingImages.value) {
const r = await window.api.sendBuffer(props.peer.deviceId, img.dataBase64, img.name, img.mime) const r = await window.api.sendBuffer(props.peer.deviceId, img.dataBase64, img.name, img.mime,
replyRef ? { replyTo: replyRef } : undefined)
if (!r.ok) toast('图片发送失败: ' + (r.reason || ''), 'warning') if (!r.ok) toast('图片发送失败: ' + (r.reason || ''), 'warning')
} }
text.value = '' text.value = ''
pendingImages.value = [] pendingImages.value = []
session.setReplyTo(null)
autoSize() autoSize()
} finally { } finally {
uploading.value = false uploading.value = false
@@ -63,7 +112,11 @@ async function sendLocalFile(localPath: string, name: string, asImage: boolean)
toast('无法获取文件路径', 'warning') toast('无法获取文件路径', 'warning')
return return
} }
const r = await window.api.sendFile(props.peer.deviceId, localPath, { asImage, withProgress: false }) const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
const r = await window.api.sendFile(props.peer.deviceId, localPath, {
asImage, withProgress: false,
...(replyRef ? { replyTo: replyRef } : {}),
})
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning') if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
} }
@@ -218,6 +271,13 @@ function onKeyDown(e: KeyboardEvent) {
<span class="x" @click="removePending(img.id)" title="移除">×</span> <span class="x" @click="removePending(img.id)" title="移除">×</span>
</div> </div>
</div> </div>
<div v-if="replyTo" class="reply-bar">
<div class="reply-bar-text">
<div class="reply-bar-author">回复 {{ replyPreview.author }}:</div>
<div class="reply-bar-preview">{{ replyPreview.text }}</div>
</div>
<el-button :icon="Close" size="small" plain circle @click="cancelReply" />
</div>
<textarea <textarea
ref="inputEl" ref="inputEl"
class="composer-input" class="composer-input"
@@ -350,6 +410,34 @@ function onKeyDown(e: KeyboardEvent) {
line-height: 1; line-height: 1;
} }
.pending-item .x:hover { background: var(--el-color-danger); } .pending-item .x:hover { background: var(--el-color-danger); }
.reply-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--el-color-primary-light-9);
border-left: 3px solid var(--el-color-primary);
border-radius: 4px;
margin: 6px 10px 0;
}
.reply-bar-text {
flex: 1;
min-width: 0;
font-size: 12px;
line-height: 1.4;
}
.reply-bar-author {
font-weight: 600;
color: var(--el-color-primary);
margin-bottom: 1px;
}
.reply-bar-preview {
color: var(--el-text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.composer-footer { .composer-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+200 -5
View File
@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { MessageView, DeviceView, DeviceSelf } from '@/api' import type { MessageView, DeviceView, DeviceSelf, ReplyRef } from '@/api'
import { initialsOf, colorFor, formatTime, formatSize } from '@/utils/format' import { initialsOf, colorFor, formatTime, formatSize } from '@/utils/format'
import MarkdownView from './MarkdownView.vue' import MarkdownView from './MarkdownView.vue'
import { ElAvatar, ElButton, ElMessage, ElProgress } from 'element-plus' import { ElAvatar, ElButton, ElMessage, ElProgress, ElMessageBox } from 'element-plus'
import { DocumentCopy, Delete, DocumentRemove, ChatLineSquare, ArrowLeft } from '@element-plus/icons-vue'
import { useMessageStore } from '@/stores/message' import { useMessageStore } from '@/stores/message'
import { useSessionStore } from '@/stores/session'
import { showContextMenu, type ContextMenuItem } from '@/composables/contextMenu'
const props = defineProps<{ const props = defineProps<{
msg: MessageView msg: MessageView
@@ -13,13 +16,21 @@ const props = defineProps<{
peer: DeviceView peer: DeviceView
self: DeviceSelf self: DeviceSelf
}>() }>()
const emit = defineEmits<{ (e: 'open-image', url: string): void }>() const emit = defineEmits<{
(e: 'open-image', url: string): void
(e: 'scroll-to-message', messageId: string): void
}>()
const messageStore = useMessageStore() const messageStore = useMessageStore()
const session = useSessionStore()
const api = window.api const api = window.api
const isSelf = computed(() => props.msg.fromDeviceId === props.self.deviceId) const isSelf = computed(() => props.msg.fromDeviceId === props.self.deviceId)
const isFileLike = computed(() => props.msg.type === 'file' || props.msg.type === 'image')
const isReplyable = computed(() => props.msg.type === 'text' || props.msg.type === 'file' || props.msg.type === 'image')
const canRecall = computed(() => isSelf.value && props.msg.type !== 'system')
const replyTo = computed<ReplyRef | undefined>(() => (props.msg.body as any)?.replyTo)
// 进度条: 文件/图片 + 在传中 (sender: pending/sending/sent, receiver: receiving), 100% 自动消失 // 进度条: 文件/图片 + 在传中 (sender: pending/sending/sent, receiver: receiving), 100% 自动消失
const progress = computed(() => { const progress = computed(() => {
@@ -27,9 +38,11 @@ const progress = computed(() => {
if (!fid) return null if (!fid) return null
const st = props.msg.status const st = props.msg.status
// sender: pending/sending/sent 都在传中; delivered 后等下次刷新就清掉了 // sender: pending/sending/sent 都在传中; delivered 后等下次刷新就清掉了
// receiver: 没 status 字段, 有进度记录就显示 // receiver: 没 status 字段, 有进度记录就显示 — 但 savedPath 已落地就视为完成, 隐藏进度
if (isSelf.value) { if (isSelf.value) {
if (st && !['pending', 'sending', 'sent'].includes(st)) return null if (st && !['pending', 'sending', 'sent'].includes(st)) return null
} else if ((props.msg.body as any)?.savedPath) {
return null
} }
const p = messageStore.progressByFileId[fid] const p = messageStore.progressByFileId[fid]
if (!p || !p.total) return null if (!p || !p.total) return null
@@ -76,10 +89,11 @@ const imageUrl = computed(() => {
const statusText = computed(() => { const statusText = computed(() => {
switch (props.msg.status) { switch (props.msg.status) {
case 'pending': return '待对方上线' case 'pending': return '等待连接'
case 'sending': return '发送中' case 'sending': return '发送中'
case 'sent': return '已发送' case 'sent': return '已发送'
case 'delivered': return '已送达' case 'delivered': return '已送达'
case 'read': return '已读'
case 'failed': return '失败' case 'failed': return '失败'
default: return props.msg.status || '' default: return props.msg.status || ''
} }
@@ -126,6 +140,116 @@ async function onRetry() {
retrying.value = false retrying.value = false
} }
} }
// ---- 右键菜单: 复制 / 删除 / 删除消息+文件 ----
async function copyText() {
const text = (props.msg.body as any).content || ''
if (!text) {
ElMessage.warning('没有可复制的文本')
return
}
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败, 请检查剪贴板权限')
}
}
async function deleteMessage(opts: { deleteFile?: boolean }) {
const m = props.msg
const filePath: string | undefined = opts.deleteFile ? (m.body as any).savedPath : undefined
if (opts.deleteFile && !filePath) {
ElMessage.warning('本条消息没有磁盘文件可删 (文件未落地或被清理)')
return
}
const title = opts.deleteFile ? '删除消息和文件' : '删除消息'
const body = opts.deleteFile
? `确定删除此消息 + 磁盘上的文件吗?\n\n文件路径:\n${filePath}`
: '确定删除此消息吗?\n\n消息将在本机消失 (不会撤回对端设备上的副本)。'
try {
await ElMessageBox.confirm(body, title, {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
dangerouslyUseHTMLString: false,
})
} catch {
return
}
const r = await api.deleteMessage(m.messageId, opts)
if (!r.ok) {
ElMessage.error(r.reason || '删除失败')
return
}
if (opts.deleteFile && r.deletedFile) {
ElMessage.success(`已删除文件: ${r.deletedFile}`)
} else {
ElMessage.success('消息已删除')
}
}
function onMsgCtxMenu(e: MouseEvent) {
const m = props.msg
const items: ContextMenuItem[] = []
if (m.type === 'text' || m.type === 'file' || m.type === 'image') {
items.push({ cmd: 'reply', label: '回复', icon: ChatLineSquare })
}
if (m.type === 'text') {
items.push({ cmd: 'copy', label: '复制', icon: DocumentCopy })
}
if (isSelf.value && m.type !== 'system') {
items.push({ cmd: 'recall', label: '撤回', icon: ArrowLeft })
}
items.push({ cmd: 'delete', label: '删除消息', icon: Delete, divided: true })
if (m.type === 'file' || m.type === 'image') {
items.push({ cmd: 'deleteFile', label: '删除消息和文件', icon: DocumentRemove })
}
showContextMenu(e, items, m, (cmd) => {
if (cmd === 'copy') copyText()
else if (cmd === 'reply') replyToMsg()
else if (cmd === 'recall') recallMsg()
else if (cmd === 'delete') deleteMessage({})
else if (cmd === 'deleteFile') deleteMessage({ deleteFile: true })
})
}
// ---- 引用回复 ----
function replyToMsg() {
session.setReplyTo(props.msg)
ElMessage.info(`已挂起对 ${props.msg.fromDeviceId === props.self.deviceId ? '我' : props.peer.name} 的回复`)
}
function replyPreview(r: ReplyRef): string {
if (r.type === 'text') return r.preview
if (r.type === 'image') return `[图片] ${r.preview}`
if (r.type === 'file') return `[文件] ${r.preview}`
return r.preview
}
// 点击引用卡片 → 通知 ChatView 滚动到原消息 (高亮 + 滚入可见区域)
function onQuoteClick(r: ReplyRef) {
emit('scroll-to-message', r.messageId)
}
// ---- 撤回 ----
async function recallMsg() {
try {
await ElMessageBox.confirm(
'确定撤回这条消息吗?\n对方消息记录会一并删除 (包括对方已接收的文件)。\n仅 2 分钟内可撤回。',
'撤回消息',
{ type: 'warning', confirmButtonText: '撤回', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
)
} catch { return }
const r = await api.recall(props.msg.messageId)
if (!r.ok) {
ElMessage.error(r.reason || '撤回失败')
return
}
ElMessage.success('已撤回')
}
</script> </script>
<template> <template>
@@ -144,6 +268,9 @@ async function onRetry() {
self: isSelf, self: isSelf,
grouped: isConsecutive, grouped: isConsecutive,
}" }"
@contextmenu.prevent="onMsgCtxMenu"
:data-message-id="msg.messageId"
:data-from-device="msg.fromDeviceId"
> >
<!-- 头像: 同人连续消息时折叠 --> <!-- 头像: 同人连续消息时折叠 -->
<div class="msg-avatar-col"> <div class="msg-avatar-col">
@@ -162,12 +289,28 @@ async function onRetry() {
<div v-if="msg.type === 'text'" class="bubble-wrap"> <div v-if="msg.type === 'text'" class="bubble-wrap">
<div class="bubble text"> <div class="bubble text">
<div
v-if="replyTo"
class="reply-quote"
@click.stop="onQuoteClick(replyTo)"
>
<div class="reply-author">{{ replyTo.authorName }}</div>
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
</div>
<MarkdownView :source="text" /> <MarkdownView :source="text" />
</div> </div>
</div> </div>
<div v-else-if="msg.type === 'image'" class="bubble-wrap"> <div v-else-if="msg.type === 'image'" class="bubble-wrap">
<div class="bubble image"> <div class="bubble image">
<div
v-if="replyTo"
class="reply-quote reply-quote-on-image"
@click.stop="onQuoteClick(replyTo)"
>
<div class="reply-author">{{ replyTo.authorName }}</div>
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
</div>
<img <img
v-if="imageUrl" v-if="imageUrl"
:src="imageUrl" :src="imageUrl"
@@ -198,6 +341,14 @@ async function onRetry() {
<div v-else-if="msg.type === 'file'" class="bubble-wrap"> <div v-else-if="msg.type === 'file'" class="bubble-wrap">
<div class="bubble file-card"> <div class="bubble file-card">
<div
v-if="replyTo"
class="reply-quote reply-quote-on-file"
@click.stop="onQuoteClick(replyTo)"
>
<div class="reply-author">{{ replyTo.authorName }}</div>
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
</div>
<div class="file-row"> <div class="file-row">
<div class="file-icon"> <div class="file-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
@@ -249,9 +400,17 @@ async function onRetry() {
gap: 10px; gap: 10px;
padding: 0 20px; padding: 0 20px;
align-items: stretch; align-items: stretch;
transition: background-color 1.4s ease-out;
} }
.msg.self { flex-direction: row-reverse; } .msg.self { flex-direction: row-reverse; }
.msg.grouped { margin-top: 2px; } .msg.grouped { margin-top: 2px; }
.msg.flash-target {
animation: msg-flash 1.4s ease-out;
}
@keyframes msg-flash {
0% { background-color: rgba(51, 112, 255, 0.18); }
100% { background-color: transparent; }
}
.msg-avatar-col { .msg-avatar-col {
width: 36px; width: 36px;
@@ -351,6 +510,41 @@ async function onRetry() {
.msg-progress :deep(.el-progress-bar__outer) { .msg-progress :deep(.el-progress-bar__outer) {
background: var(--el-fill-color-light); background: var(--el-fill-color-light);
} }
/* 引用回复条 — WeChat / 飞书风格, 左边一条竖线 + 作者名 + 内容预览 */
.reply-quote {
border-left: 3px solid var(--el-color-primary);
padding: 4px 8px;
margin-bottom: 6px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
line-height: 1.4;
max-width: 100%;
}
.reply-quote:hover {
background: rgba(51, 112, 255, 0.08);
}
.reply-quote-on-image,
.reply-quote-on-file {
background: rgba(255, 255, 255, 0.6);
}
.reply-author {
font-weight: 600;
color: var(--el-color-primary);
margin-bottom: 2px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reply-preview {
color: var(--el-text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.msg-progress-meta { .msg-progress-meta {
margin-top: 4px; margin-top: 4px;
display: flex; display: flex;
@@ -389,6 +583,7 @@ async function onRetry() {
.msg-status.pending { color: var(--el-color-warning); } .msg-status.pending { color: var(--el-color-warning); }
.msg-status.sending { color: var(--el-text-color-secondary); } .msg-status.sending { color: var(--el-text-color-secondary); }
.msg-status.delivered { color: var(--el-color-success); } .msg-status.delivered { color: var(--el-color-success); }
.msg-status.read { color: var(--el-color-primary); }
.msg-status :deep(.el-button) { .msg-status :deep(.el-button) {
font-size: 11px; font-size: 11px;
padding: 0 4px; padding: 0 4px;
+13 -22
View File
@@ -27,9 +27,8 @@ const form = ref<FormState>({
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const autoStartAvailable = ref(false)
const dirty = ref(false) const dirty = ref(false)
const initialAutoStart = ref(false) const hydrating = ref(false)
const formRef = ref() const formRef = ref()
const ifaces = ref<NetInterface[]>([]) const ifaces = ref<NetInterface[]>([])
@@ -40,24 +39,24 @@ watch(() => device.settings, (s) => {
}, { immediate: true }) }, { immediate: true })
onMounted(async () => { onMounted(async () => {
try {
initialAutoStart.value = await window.api.getAutoStart()
autoStartAvailable.value = true
} catch {
autoStartAvailable.value = false
}
await loadIfaces() await loadIfaces()
loading.value = false loading.value = false
}) })
function hydrate(s: any) { function hydrate(s: any) {
hydrating.value = true
form.value = { form.value = {
...s, deviceName: s.deviceName ?? '',
autoStart: initialAutoStart.value, downloadDir: s.downloadDir ?? '',
notifications: s.notifications ?? true,
sound: s.sound ?? true,
autoStart: s.autoStart ?? false,
theme: s.theme ?? 'light',
} }
hydrating.value = false
} }
watch(form, () => { dirty.value = true }, { deep: true }) watch(form, () => { if (!hydrating.value) dirty.value = true }, { deep: true })
async function loadIfaces() { async function loadIfaces() {
loadingIfaces.value = true loadingIfaces.value = true
@@ -78,11 +77,6 @@ async function pickDir() {
async function save() { async function save() {
saving.value = true saving.value = true
try { try {
if (autoStartAvailable.value && form.value.autoStart !== initialAutoStart.value) {
const ok = await window.api.setAutoStart(form.value.autoStart)
if (ok) initialAutoStart.value = form.value.autoStart
else form.value.autoStart = initialAutoStart.value
}
const r = await window.api.setSettings({ const r = await window.api.setSettings({
deviceName: form.value.deviceName, deviceName: form.value.deviceName,
downloadDir: form.value.downloadDir, downloadDir: form.value.downloadDir,
@@ -90,7 +84,7 @@ async function save() {
sound: form.value.sound, sound: form.value.sound,
theme: form.value.theme, theme: form.value.theme,
autoStart: form.value.autoStart, autoStart: form.value.autoStart,
} as any) })
device.settings = r device.settings = r
dirty.value = false dirty.value = false
ElMessage.success('设置已保存') ElMessage.success('设置已保存')
@@ -148,11 +142,8 @@ const currentAddress = computed(() => device.self?.address || '-')
</el-form-item> </el-form-item>
<el-form-item label="开机自启"> <el-form-item label="开机自启">
<el-switch v-model="form.autoStart" :disabled="!autoStartAvailable" /> <el-switch v-model="form.autoStart" />
<span class="form-hint"> <span class="form-hint">登录系统时自动启动 LocalNetMsg</span>
<template v-if="!autoStartAvailable">仅在打包后可用</template>
<template v-else>登录系统时自动启动 LocalNetMsg</template>
</span>
</el-form-item> </el-form-item>
</el-form> </el-form>
+41 -2
View File
@@ -4,8 +4,10 @@ import { useDeviceStore } from '@/stores/device'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { useMessageStore } from '@/stores/message' import { useMessageStore } from '@/stores/message'
import { initialsOf, colorFor, formatTime } from '@/utils/format' import { initialsOf, colorFor, formatTime } from '@/utils/format'
import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag } from 'element-plus' import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag, ElMessageBox, ElMessage } from 'element-plus'
import { Refresh, Setting, UserFilled } from '@element-plus/icons-vue' import { Refresh, Setting, UserFilled, Delete } from '@element-plus/icons-vue'
import { showContextMenu } from '@/composables/contextMenu'
import type { DeviceView } from '@/api'
const device = useDeviceStore() const device = useDeviceStore()
const session = useSessionStore() const session = useSessionStore()
@@ -24,6 +26,42 @@ function isSelf(id: string) {
const totalUnread = computed(() => Object.values(device.unread).reduce((a, b) => a + b, 0)) const totalUnread = computed(() => Object.values(device.unread).reduce((a, b) => a + b, 0))
function onDeviceCtxMenu(e: MouseEvent, d: DeviceView) {
showContextMenu(e, [
{ cmd: 'delete', label: '删除设备', icon: Delete, danger: true, divided: true },
], d, (cmd, payload) => {
if (cmd !== 'delete') return
const target = payload as DeviceView
onDeviceDelete(target)
})
}
async function onDeviceDelete(d: DeviceView) {
try {
await ElMessageBox.confirm(
`确定从设备列表移除 "${d.name}" 吗?\n\n移除后将停止后台连接, 局域网内再次被发现也不会自动恢复, 如需恢复需要手动添加。`,
'删除设备',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
)
} catch {
return
}
const r = await window.api.deleteDevice(d.deviceId)
if (!r.ok) {
ElMessage.error(r.reason || '删除失败')
return
}
// 本地 store 也清掉 (主进程发 device:lost 只置 offline, 不会移除)
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
if (idx >= 0) device.devices.splice(idx, 1)
const u = { ...device.unread }
delete u[d.deviceId]
device.unread = u
// 如果当前正跟这台设备聊天, 切回空状态
if (session.activePeerId === d.deviceId) session.setActive(null)
ElMessage.success(`已移除 ${d.name}`)
}
onMounted(() => { onMounted(() => {
if (!self.value) device.loadSelf().then(() => { if (!self.value) device.loadSelf().then(() => {
if (self.value) message.setSelf(self.value.deviceId) if (self.value) message.setSelf(self.value.deviceId)
@@ -74,6 +112,7 @@ onMounted(() => {
class="device-item" class="device-item"
:class="{ active: session.activePeerId === d.deviceId }" :class="{ active: session.activePeerId === d.deviceId }"
@click="pick(d)" @click="pick(d)"
@contextmenu.prevent="onDeviceCtxMenu($event, d)"
> >
<el-avatar <el-avatar
:size="40" :size="40"
@@ -0,0 +1,75 @@
// 全局单实例右键菜单 store
//
// 用法:
// const items = [
// { cmd: 'delete', label: '删除', danger: true, divided: true },
// ...
// ]
// function onCtx(e: MouseEvent) {
// useContextMenu().show(e, items, payload /* 任意 */, (cmd, payload) => { ... })
// }
//
// 单实例避免多菜单并开;position 跟鼠标坐标走,不存在 "右侧空白" / "最左侧展开" 这类
// el-dropdown 在 v-for 里 + trigger=contextmenu 的玄学行为
import { reactive } from 'vue'
export interface ContextMenuItem {
cmd: string
label: string
icon?: any
danger?: boolean
divided?: boolean
}
export const ctxMenuState = reactive({
visible: false,
x: 0,
y: 0,
items: [] as ContextMenuItem[],
payload: null as unknown,
})
let pendingHandler: ((cmd: string, payload: unknown) => void) | null = null
// 同步切换: 关掉旧菜单再开新的, 避免同一个菜单组件里瞬间出现两次条目
export function showContextMenu(
e: MouseEvent,
items: ContextMenuItem[],
payload: unknown,
handler: (cmd: string, payload: unknown) => void,
) {
e.preventDefault()
e.stopPropagation()
pendingHandler = handler
if (ctxMenuState.visible) {
// 已经在显示中 — 直接改位置 + 条目即可 (避免叠两层)
ctxMenuState.x = e.clientX
ctxMenuState.y = e.clientY
ctxMenuState.items = items
ctxMenuState.payload = payload
return
}
ctxMenuState.x = e.clientX
ctxMenuState.y = e.clientY
ctxMenuState.items = items
ctxMenuState.payload = payload
ctxMenuState.visible = true
}
export function pickContextMenu(cmd: string) {
const h = pendingHandler
const p = ctxMenuState.payload
ctxMenuState.visible = false
ctxMenuState.payload = null
ctxMenuState.items = []
pendingHandler = null
if (h) h(cmd, p)
}
export function closeContextMenu() {
ctxMenuState.visible = false
ctxMenuState.payload = null
ctxMenuState.items = []
pendingHandler = null
}
+9 -2
View File
@@ -9,6 +9,9 @@ export const useDeviceStore = defineStore('device', () => {
const unread = ref<Record<string, number>>({}) const unread = ref<Record<string, number>>({})
const scanning = ref(false) const scanning = ref(false)
const lastScanAt = ref(0) const lastScanAt = ref(0)
// 我们 outgoing WS 接通状态 (与 discovery.online 区分: UDP 在 ≠ WS 通)
// 默认乐观地认为 WS 通的 (首次进入 chat 还没收到 device:wsState 事件前), 直到第一次 close
const wsOpen = ref<Record<string, boolean>>({})
async function refresh() { async function refresh() {
const [d, s, u] = await Promise.all([ const [d, s, u] = await Promise.all([
@@ -32,11 +35,15 @@ export const useDeviceStore = defineStore('device', () => {
setTimeout(() => { scanning.value = false }, 3000) setTimeout(() => { scanning.value = false }, 3000)
} }
function setWsOpen(deviceId: string, open: boolean) {
wsOpen.value = { ...wsOpen.value, [deviceId]: open }
}
const onlineDevices = computed(() => devices.value.filter(d => d.online)) const onlineDevices = computed(() => devices.value.filter(d => d.online))
return { return {
self, devices, settings, unread, scanning, lastScanAt, self, devices, settings, unread, scanning, lastScanAt, wsOpen,
refresh, loadSelf, triggerScan, refresh, loadSelf, triggerScan, setWsOpen,
onlineDevices, onlineDevices,
} }
}) })
+6 -1
View File
@@ -1,14 +1,19 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import type { MessageView } from '@/api'
export const useSessionStore = defineStore('session', () => { export const useSessionStore = defineStore('session', () => {
const activePeerId = ref<string | null>(null) const activePeerId = ref<string | null>(null)
const peerTyping = ref<Record<string, boolean>>({}) const peerTyping = ref<Record<string, boolean>>({})
// 当前会话准备要回复的消息 (引用输入框里的预览条). null = 没有挂起的回复
const replyTo = ref<MessageView | null>(null)
function setActive(id: string | null) { activePeerId.value = id } function setActive(id: string | null) { activePeerId.value = id }
function setTyping(deviceId: string, v: boolean) { function setTyping(deviceId: string, v: boolean) {
peerTyping.value[deviceId] = v peerTyping.value[deviceId] = v
} }
function setReplyTo(msg: MessageView | null) { replyTo.value = msg }
function clearReplyTo() { replyTo.value = null }
return { activePeerId, peerTyping, setActive, setTyping } return { activePeerId, peerTyping, replyTo, setActive, setTyping, setReplyTo, clearReplyTo }
}) })