feat: add message management and delivery states
This commit is contained in:
+42
-5
@@ -14,6 +14,7 @@ interface Entry {
|
||||
peer: DeviceInfo
|
||||
ws: WebSocket | null
|
||||
retryTimer: NodeJS.Timeout | null
|
||||
pingTimer: NodeJS.Timeout | null
|
||||
backoff: number
|
||||
alive: boolean
|
||||
}
|
||||
@@ -51,7 +52,7 @@ export class ChatClient extends EventEmitter {
|
||||
}
|
||||
this.scheduleConnect(e, e.backoff)
|
||||
} 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.scheduleConnect(e, 0)
|
||||
}
|
||||
@@ -73,6 +74,7 @@ export class ChatClient extends EventEmitter {
|
||||
if (!e) return
|
||||
e.alive = false
|
||||
if (e.retryTimer) clearTimeout(e.retryTimer)
|
||||
this.stopPing(e)
|
||||
try { e.ws?.close() } catch {}
|
||||
this.entries.delete(deviceId)
|
||||
}
|
||||
@@ -90,13 +92,33 @@ export class ChatClient extends EventEmitter {
|
||||
const ws = new WebSocket(url, { handshakeTimeout: 5000 })
|
||||
e.ws = ws
|
||||
ws.on('open', () => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'hello', from: this.self }))
|
||||
} catch {}
|
||||
e.backoff = 2000
|
||||
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) => {
|
||||
try {
|
||||
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 变了)
|
||||
if (frame.type === 'hello' && frame.from) {
|
||||
e.peer = { ...peer, ...frame.from, address: peer.address }
|
||||
@@ -105,10 +127,11 @@ export class ChatClient extends EventEmitter {
|
||||
} catch {}
|
||||
})
|
||||
ws.on('close', () => {
|
||||
this.stopPing(e)
|
||||
e.ws = null
|
||||
this.emit('close', e.peer)
|
||||
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) => {
|
||||
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) {
|
||||
const e = this.entries.get(deviceId)
|
||||
if (e?.ws && e.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
e.ws.send(JSON.stringify(frame))
|
||||
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
|
||||
}
|
||||
@@ -129,8 +167,7 @@ export class ChatClient extends EventEmitter {
|
||||
let any = false
|
||||
for (const e of this.entries.values()) {
|
||||
if (e.ws && e.ws.readyState === WebSocket.OPEN) {
|
||||
e.ws.send(JSON.stringify(frame))
|
||||
any = true
|
||||
try { e.ws.send(JSON.stringify(frame)); any = true } catch {}
|
||||
}
|
||||
}
|
||||
return any
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ChatServerEvents {
|
||||
message: (from: DeviceInfo, msg: MessageEnvelope) => void
|
||||
recall: (from: DeviceInfo, messageId: string) => void
|
||||
typing: (from: DeviceInfo) => void
|
||||
read: (from: DeviceInfo, upToMessageId: string) => void
|
||||
}
|
||||
|
||||
export class ChatServer extends EventEmitter {
|
||||
@@ -77,6 +78,10 @@ export class ChatServer extends EventEmitter {
|
||||
case 'recall':
|
||||
if (peer) this.emit('recall', peer, frame.messageId)
|
||||
break
|
||||
case 'read':
|
||||
// 已读回执: 接收方已看到 upTo (含), 把 ≤ upTo 的发送方消息视为已读
|
||||
if (peer) this.emit('read', peer, frame.upTo)
|
||||
break
|
||||
case 'typing':
|
||||
if (peer) this.emit('typing', peer)
|
||||
break
|
||||
|
||||
+18
-2
@@ -80,8 +80,11 @@ export function upsertDevice(d: DeviceRow) {
|
||||
upsertDeviceStmt.run(d)
|
||||
}
|
||||
|
||||
export function listDevices(): DeviceRow[] {
|
||||
return db.prepare(`SELECT * FROM devices ORDER BY last_seen DESC`).all() as DeviceRow[]
|
||||
export function listDevices(includeIgnored = false): 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 {
|
||||
@@ -178,6 +181,19 @@ export function listPendingMessages(peerId: string, selfId: string): 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[] {
|
||||
return db.prepare(
|
||||
`SELECT * FROM messages WHERE status = 'pending' AND from_id = ? ORDER BY ts ASC`
|
||||
|
||||
+145
-24
@@ -9,8 +9,8 @@ import { getSettings, updateSettings, ensureDownloadDir } from './settings'
|
||||
import {
|
||||
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
|
||||
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
|
||||
getUnread, totalUnread, listPendingMessages, listAllPending,
|
||||
setDeviceIgnored, listIgnoredDevices, deleteConversation
|
||||
getUnread, totalUnread, listPendingMessages, listAllPending, listSentFileUpdates,
|
||||
setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation
|
||||
} from './db'
|
||||
import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
|
||||
import { ChatServer } from './chat-server'
|
||||
@@ -19,7 +19,7 @@ import { FileServer, uploadFile, uploadBuffer } from './file-server'
|
||||
import { broadcastToRenderer, focusMainWindow } from './window'
|
||||
import { rebuildMenu } from './tray'
|
||||
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 {
|
||||
self: DeviceInfo
|
||||
@@ -32,7 +32,8 @@ interface NetCtx {
|
||||
let ctx: NetCtx | null = null
|
||||
|
||||
// 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer
|
||||
// 任一进度事件或 received 完成事件取消; 到了 -> mark failed 并删消息, 避免气泡永远 "receiving"
|
||||
// 任一进度事件或 received 完成事件取消; 到了 -> 只通知 sender, 不自动删消息
|
||||
// 气泡留在 receiving 状态, 让用户右键手动删
|
||||
const PENDING_FILE_TIMEOUT_MS = 60_000
|
||||
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)
|
||||
const timer = setTimeout(() => {
|
||||
pendingMetaTimers.delete(fileId)
|
||||
console.warn(`[ipc] file ${fileId} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s, marking failed`)
|
||||
try { deleteMessage(messageId) } catch {}
|
||||
broadcastToRenderer('message:recalled', { messageId, conversationKey: '' })
|
||||
// 也告诉 sender: 这文件永远来不了 (如果是 sender 在线, 它能 mark 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`)
|
||||
// 通知 sender 标记失败 (sender 在线时能把 status='sent' 升级到 'failed')
|
||||
ctx?.chatClient.send({ type: 'fileFailed', fileId, messageId, reason: 'timeout' } as any, fromDeviceId)
|
||||
}, PENDING_FILE_TIMEOUT_MS)
|
||||
pendingMetaTimers.set(fileId, { timer, messageId, fromDeviceId })
|
||||
@@ -61,6 +60,8 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
ctx = c
|
||||
// 网络事件 -> 渲染进程
|
||||
c.discovery.on('found', (d) => {
|
||||
// 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连
|
||||
if (getDevice(d.deviceId)?.ignored) return
|
||||
upsertDevice({
|
||||
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
|
||||
@@ -69,6 +70,7 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
c.chatClient.connectTo(d)
|
||||
})
|
||||
c.discovery.on('updated', (d: DeviceInfo) => {
|
||||
if (getDevice(d.deviceId)?.ignored) return
|
||||
upsertDevice({
|
||||
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
|
||||
@@ -77,6 +79,7 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
c.chatClient.connectTo(d)
|
||||
})
|
||||
c.discovery.on('lost', (id) => {
|
||||
if (getDevice(id)?.ignored) return
|
||||
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}`)
|
||||
})
|
||||
|
||||
// 我们这端 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) => {
|
||||
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 }) => {
|
||||
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) {
|
||||
@@ -229,8 +274,10 @@ async function deliverAndStore(env: MessageEnvelope): Promise<{ ok: boolean; rea
|
||||
// 2. 通过 ws 发送
|
||||
const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, env.toDeviceId)
|
||||
if (!sent) {
|
||||
// 对方不在线, 保持 pending, 等 flush
|
||||
return { ok: false, reason: 'peer offline', status: 'pending' }
|
||||
// WS 没开 — 消息已在 DB 里 status='pending', flushPendingFor 在对端 outgoing WS 接通时会自动重发.
|
||||
// 不当作 "失败" 报给前端, 避免误导 (因为对方 UDP 可能还在, 只是 WS 抖了).
|
||||
// 副作用: 渲染端的 "待对方上线" 气泡/重试按钮照常显示; 不再弹 "发送失败" toast.
|
||||
return { ok: true, status: 'pending' }
|
||||
}
|
||||
// 3. 标记 sent (本地认为已送出; 真实送达需要 ack 协议, 见 deliverAndStoreAck)
|
||||
updateMessageStatus(env.messageId, 'sent')
|
||||
@@ -267,10 +314,10 @@ async function flushPendingFor(peerId: string) {
|
||||
}
|
||||
// 2) 上传完但 WS update 帧没送达的消息 (savedPath 已填, status 还在 'sent')
|
||||
// 实际是 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 fileBody = body as any
|
||||
if ((row.type === 'file' || row.type === 'image') && fileBody.savedPath) {
|
||||
const env: MessageEnvelope = {
|
||||
messageId: row.message_id,
|
||||
type: row.type as any,
|
||||
@@ -286,7 +333,6 @@ async function flushPendingFor(peerId: string) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
})
|
||||
|
||||
// 删除设备: 标记 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) => {
|
||||
if (!ctx) return []
|
||||
@@ -333,7 +429,7 @@ export function registerIpc() {
|
||||
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' }
|
||||
const env: MessageEnvelope = {
|
||||
messageId: randomUUID(),
|
||||
@@ -345,6 +441,7 @@ export function registerIpc() {
|
||||
type: 'text',
|
||||
messageId: '', ts: 0, fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
||||
content: args.content,
|
||||
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||
} as TextMessage,
|
||||
}
|
||||
;(env.body as TextMessage).messageId = env.messageId
|
||||
@@ -365,6 +462,7 @@ export function registerIpc() {
|
||||
mime?: string
|
||||
asImage?: boolean
|
||||
onProgress?: boolean
|
||||
replyTo?: ReplyRef
|
||||
}) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
|
||||
@@ -387,6 +485,7 @@ export function registerIpc() {
|
||||
savedPath: null as string | null,
|
||||
hash: null as string | null,
|
||||
localPath: args.localPath, // retry 时复用
|
||||
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||
}
|
||||
const env: MessageEnvelope = {
|
||||
messageId,
|
||||
@@ -453,8 +552,12 @@ export function registerIpc() {
|
||||
}
|
||||
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
|
||||
// 再发一次 WS, 这次带 savedPath (对端气泡更新, 出现"打开")
|
||||
if (ctx!.chatClient.isOpen(args.toDeviceId)) {
|
||||
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
|
||||
// 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发
|
||||
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
|
||||
void fileId // suppress unused warning
|
||||
@@ -473,6 +576,7 @@ export function registerIpc() {
|
||||
dataBase64: string
|
||||
name: string
|
||||
mime: string
|
||||
replyTo?: ReplyRef
|
||||
}) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
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,
|
||||
thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
|
||||
dataBase64: args.dataBase64,
|
||||
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||
}
|
||||
const env: MessageEnvelope = {
|
||||
messageId, type: 'image',
|
||||
@@ -540,8 +645,12 @@ export function registerIpc() {
|
||||
body: { ...baseBody, savedPath: uploadResult.savedPath as any, hash: uploadResult.hash as any },
|
||||
}
|
||||
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
|
||||
if (ctx!.chatClient.isOpen(args.toDeviceId)) {
|
||||
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
|
||||
// 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发
|
||||
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
|
||||
})().catch((e: any) => {
|
||||
@@ -556,11 +665,17 @@ export function registerIpc() {
|
||||
ipcMain.handle('message:recall', async (_e, messageId: string) => {
|
||||
if (!ctx) return { ok: false }
|
||||
const row = getMessage(messageId)
|
||||
if (!row) return { ok: false, reason: 'no message' }
|
||||
if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: 'not your message' }
|
||||
if (!row) return { ok: false, reason: '消息不存在' }
|
||||
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)
|
||||
// 通知对端
|
||||
// 通知对端 (不传 savedPath 让对端从 DB 自行取 — 这里的 row 还在; 但保险起见让对端查自己的 DB)
|
||||
ctx.chatClient.send({ type: 'recall', messageId, ts: Date.now() }, row.to_id)
|
||||
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
|
||||
return { ok: true }
|
||||
@@ -599,7 +714,7 @@ export function registerIpc() {
|
||||
const peer = ctx.discovery.getPeers().find(p => p.deviceId === row.to_id)
|
||||
if (!peer) {
|
||||
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)) {
|
||||
setStatus('pending')
|
||||
@@ -701,7 +816,13 @@ export function registerIpc() {
|
||||
ensureDownloadDir()
|
||||
if (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()
|
||||
broadcastToRenderer('settings:changed', ns)
|
||||
|
||||
@@ -35,17 +35,28 @@ export type WsFrame =
|
||||
| { type: 'ack'; messageId: string; status: 'sent' | 'delivered' | 'read' }
|
||||
| { type: 'recall'; messageId: 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 interface ReplyRef {
|
||||
messageId: string
|
||||
authorName: string
|
||||
type: MessageType // 只能是 text/file/image, 不允许 system/system
|
||||
preview: string // text: 截断的正文; file/image: 文件名; 多行用空格折
|
||||
}
|
||||
|
||||
interface BaseMessage {
|
||||
messageId: string
|
||||
type: MessageType
|
||||
fromDeviceId: string
|
||||
toDeviceId: string
|
||||
ts: number
|
||||
replyTo?: ReplyRef // 可选引用
|
||||
}
|
||||
|
||||
export interface TextMessage extends BaseMessage {
|
||||
|
||||
@@ -9,15 +9,19 @@ contextBridge.exposeInMainWorld('api', {
|
||||
self: () => ipcRenderer.invoke('self:info'),
|
||||
listDevices: () => ipcRenderer.invoke('device:list'),
|
||||
triggerScan: () => ipcRenderer.invoke('device:triggerScan'),
|
||||
deleteDevice: (deviceId: string) => ipcRenderer.invoke('device:delete', deviceId),
|
||||
|
||||
// 消息
|
||||
listMessages: (peerId: string) => ipcRenderer.invoke('message:list', peerId),
|
||||
sendText: (toDeviceId: string, content: string) => ipcRenderer.invoke('message:sendText', { toDeviceId, content }),
|
||||
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) =>
|
||||
sendText: (toDeviceId: string, content: string, opts?: { replyTo?: any }) =>
|
||||
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 || {}) }),
|
||||
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) =>
|
||||
ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime }),
|
||||
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string, opts?: { replyTo?: any }) =>
|
||||
ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime, replyTo: opts?.replyTo }),
|
||||
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),
|
||||
typing: (toDeviceId: string) => ipcRenderer.invoke('message:typing', toDeviceId),
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import Sidebar from '@/components/Sidebar.vue'
|
||||
import ChatView from '@/components/ChatView.vue'
|
||||
import SettingsView from '@/components/SettingsView.vue'
|
||||
import ContextMenu from '@/components/ContextMenu.vue'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import type { DeviceView, MessageView, Settings } from '@/api'
|
||||
|
||||
@@ -59,12 +60,20 @@ onMounted(async () => {
|
||||
const d = device.devices.find(x => x.deviceId === deviceId)
|
||||
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) => {
|
||||
const fromId = env.fromDeviceId
|
||||
// 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次
|
||||
// 第二次带 savedPath, 气泡自动有"打开/位置"
|
||||
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.upsert(env)
|
||||
@@ -83,6 +92,10 @@ onMounted(async () => {
|
||||
device.unread = next
|
||||
}
|
||||
window.api.clearUnread(fromId)
|
||||
// 已读回执: 当前在 active 对话里看到了新消息, 告诉对端
|
||||
if (env.type !== 'system') {
|
||||
window.api.markRead(fromId, env.messageId).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
device.refresh().then(() => {
|
||||
@@ -141,6 +154,14 @@ watch(() => session.activePeerId, async (id) => {
|
||||
}
|
||||
await message.ensureLoaded(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) => {
|
||||
@@ -168,5 +189,6 @@ document.addEventListener('click', (e) => {
|
||||
<div v-if="imageViewer" class="image-viewer" @click="imageViewer = null">
|
||||
<img :src="imageViewer" />
|
||||
</div>
|
||||
<ContextMenu />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+15
-3
@@ -6,12 +6,15 @@ export type LnmApi = {
|
||||
self: () => Promise<DeviceSelf | null>
|
||||
listDevices: () => Promise<DeviceView[]>
|
||||
triggerScan: () => Promise<boolean>
|
||||
deleteDevice: (deviceId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||
|
||||
listMessages: (peerId: string) => Promise<MessageView[]>
|
||||
sendText: (toDeviceId: string, content: string) => Promise<{ ok: boolean; reason?: string }>
|
||||
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) => Promise<{ ok: boolean; reason?: string }>
|
||||
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: 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; replyTo?: ReplyRef }) => 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 }>
|
||||
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 }>
|
||||
typing: (toDeviceId: string) => Promise<void>
|
||||
|
||||
@@ -104,3 +107,12 @@ export interface ProgressEvent {
|
||||
sent: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// 引用回复快照 (与 main/protocol.ts 的 ReplyRef 保持一致)
|
||||
// 仅携带足够渲染作者 + 内容预览的字段 — 原消息被撤回/删除也不影响这条引用
|
||||
export interface ReplyRef {
|
||||
messageId: string
|
||||
authorName: string
|
||||
type: MessageType
|
||||
preview: string
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import MessageInput from './MessageInput.vue'
|
||||
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 { ChatLineRound, Promotion, UploadFilled } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -20,6 +20,13 @@ const session = useSessionStore()
|
||||
const self = computed(() => device.self)
|
||||
const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : [])
|
||||
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)
|
||||
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(() => messages.value.length, () => scrollToBottom())
|
||||
|
||||
@@ -105,9 +130,19 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
<div class="header-info">
|
||||
<div class="header-name">{{ peer.name }}</div>
|
||||
<div class="header-meta">
|
||||
<span class="status-dot" :class="{ online: peer.online }"></span>
|
||||
<template v-if="peer.online">在线 · {{ peer.address }}</template>
|
||||
<template v-else>离线</template>
|
||||
<span
|
||||
class="status-dot"
|
||||
: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>
|
||||
</header>
|
||||
@@ -123,6 +158,7 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
:peer="peer"
|
||||
:self="self!"
|
||||
@open-image="emit('open-image', $event)"
|
||||
@scroll-to-message="onScrollToMessage"
|
||||
/>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
@@ -196,6 +232,19 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
display: inline-block;
|
||||
}
|
||||
.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-inner { padding: 16px 20px 0; }
|
||||
|
||||
@@ -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>
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import type { DeviceView } from '@/api'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import type { DeviceView, MessageView, ReplyRef } from '@/api'
|
||||
import { useDeviceStore } from '@/stores/device'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
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 device = useDeviceStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
const text = ref('')
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||
@@ -19,6 +21,50 @@ const uploading = ref(false)
|
||||
const dragDepth = ref(0) // 用 depth 计数避免子元素进出时误判
|
||||
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() {
|
||||
if (!inputEl.value) return
|
||||
inputEl.value.style.height = 'auto'
|
||||
@@ -35,17 +81,20 @@ async function send() {
|
||||
const t = text.value.trim()
|
||||
if (!t && pendingImages.value.length === 0) return
|
||||
uploading.value = true
|
||||
const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
|
||||
try {
|
||||
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')
|
||||
}
|
||||
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')
|
||||
}
|
||||
text.value = ''
|
||||
pendingImages.value = []
|
||||
session.setReplyTo(null)
|
||||
autoSize()
|
||||
} finally {
|
||||
uploading.value = false
|
||||
@@ -63,7 +112,11 @@ async function sendLocalFile(localPath: string, name: string, asImage: boolean)
|
||||
toast('无法获取文件路径', 'warning')
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -218,6 +271,13 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
<span class="x" @click="removePending(img.id)" title="移除">×</span>
|
||||
</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
|
||||
ref="inputEl"
|
||||
class="composer-input"
|
||||
@@ -350,6 +410,34 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
line-height: 1;
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
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 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 { useSessionStore } from '@/stores/session'
|
||||
import { showContextMenu, type ContextMenuItem } from '@/composables/contextMenu'
|
||||
|
||||
const props = defineProps<{
|
||||
msg: MessageView
|
||||
@@ -13,13 +16,21 @@ const props = defineProps<{
|
||||
peer: DeviceView
|
||||
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 session = useSessionStore()
|
||||
|
||||
const api = window.api
|
||||
|
||||
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% 自动消失
|
||||
const progress = computed(() => {
|
||||
@@ -27,9 +38,11 @@ const progress = computed(() => {
|
||||
if (!fid) return null
|
||||
const st = props.msg.status
|
||||
// sender: pending/sending/sent 都在传中; delivered 后等下次刷新就清掉了
|
||||
// receiver: 没 status 字段, 有进度记录就显示
|
||||
// receiver: 没 status 字段, 有进度记录就显示 — 但 savedPath 已落地就视为完成, 隐藏进度
|
||||
if (isSelf.value) {
|
||||
if (st && !['pending', 'sending', 'sent'].includes(st)) return null
|
||||
} else if ((props.msg.body as any)?.savedPath) {
|
||||
return null
|
||||
}
|
||||
const p = messageStore.progressByFileId[fid]
|
||||
if (!p || !p.total) return null
|
||||
@@ -76,10 +89,11 @@ const imageUrl = computed(() => {
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.msg.status) {
|
||||
case 'pending': return '待对方上线'
|
||||
case 'pending': return '等待连接'
|
||||
case 'sending': return '发送中'
|
||||
case 'sent': return '已发送'
|
||||
case 'delivered': return '已送达'
|
||||
case 'read': return '已读'
|
||||
case 'failed': return '失败'
|
||||
default: return props.msg.status || ''
|
||||
}
|
||||
@@ -126,6 +140,116 @@ async function onRetry() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -144,6 +268,9 @@ async function onRetry() {
|
||||
self: isSelf,
|
||||
grouped: isConsecutive,
|
||||
}"
|
||||
@contextmenu.prevent="onMsgCtxMenu"
|
||||
:data-message-id="msg.messageId"
|
||||
:data-from-device="msg.fromDeviceId"
|
||||
>
|
||||
<!-- 头像: 同人连续消息时折叠 -->
|
||||
<div class="msg-avatar-col">
|
||||
@@ -162,12 +289,28 @@ async function onRetry() {
|
||||
|
||||
<div v-if="msg.type === 'text'" class="bubble-wrap">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="msg.type === 'image'" class="bubble-wrap">
|
||||
<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
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
@@ -198,6 +341,14 @@ async function onRetry() {
|
||||
|
||||
<div v-else-if="msg.type === 'file'" class="bubble-wrap">
|
||||
<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-icon">
|
||||
<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;
|
||||
padding: 0 20px;
|
||||
align-items: stretch;
|
||||
transition: background-color 1.4s ease-out;
|
||||
}
|
||||
.msg.self { flex-direction: row-reverse; }
|
||||
.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 {
|
||||
width: 36px;
|
||||
@@ -351,6 +510,41 @@ async function onRetry() {
|
||||
.msg-progress :deep(.el-progress-bar__outer) {
|
||||
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 {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
@@ -389,6 +583,7 @@ async function onRetry() {
|
||||
.msg-status.pending { color: var(--el-color-warning); }
|
||||
.msg-status.sending { color: var(--el-text-color-secondary); }
|
||||
.msg-status.delivered { color: var(--el-color-success); }
|
||||
.msg-status.read { color: var(--el-color-primary); }
|
||||
.msg-status :deep(.el-button) {
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
|
||||
@@ -27,9 +27,8 @@ const form = ref<FormState>({
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const autoStartAvailable = ref(false)
|
||||
const dirty = ref(false)
|
||||
const initialAutoStart = ref(false)
|
||||
const hydrating = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const ifaces = ref<NetInterface[]>([])
|
||||
@@ -40,24 +39,24 @@ watch(() => device.settings, (s) => {
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
initialAutoStart.value = await window.api.getAutoStart()
|
||||
autoStartAvailable.value = true
|
||||
} catch {
|
||||
autoStartAvailable.value = false
|
||||
}
|
||||
await loadIfaces()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function hydrate(s: any) {
|
||||
hydrating.value = true
|
||||
form.value = {
|
||||
...s,
|
||||
autoStart: initialAutoStart.value,
|
||||
deviceName: s.deviceName ?? '',
|
||||
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() {
|
||||
loadingIfaces.value = true
|
||||
@@ -78,11 +77,6 @@ async function pickDir() {
|
||||
async function save() {
|
||||
saving.value = true
|
||||
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({
|
||||
deviceName: form.value.deviceName,
|
||||
downloadDir: form.value.downloadDir,
|
||||
@@ -90,7 +84,7 @@ async function save() {
|
||||
sound: form.value.sound,
|
||||
theme: form.value.theme,
|
||||
autoStart: form.value.autoStart,
|
||||
} as any)
|
||||
})
|
||||
device.settings = r
|
||||
dirty.value = false
|
||||
ElMessage.success('设置已保存')
|
||||
@@ -148,11 +142,8 @@ const currentAddress = computed(() => device.self?.address || '-')
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="开机自启">
|
||||
<el-switch v-model="form.autoStart" :disabled="!autoStartAvailable" />
|
||||
<span class="form-hint">
|
||||
<template v-if="!autoStartAvailable">仅在打包后可用</template>
|
||||
<template v-else>登录系统时自动启动 LocalNetMsg</template>
|
||||
</span>
|
||||
<el-switch v-model="form.autoStart" />
|
||||
<span class="form-hint">登录系统时自动启动 LocalNetMsg</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useDeviceStore } from '@/stores/device'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { initialsOf, colorFor, formatTime } from '@/utils/format'
|
||||
import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag } from 'element-plus'
|
||||
import { Refresh, Setting, UserFilled } from '@element-plus/icons-vue'
|
||||
import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag, ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { Refresh, Setting, UserFilled, Delete } from '@element-plus/icons-vue'
|
||||
import { showContextMenu } from '@/composables/contextMenu'
|
||||
import type { DeviceView } from '@/api'
|
||||
|
||||
const device = useDeviceStore()
|
||||
const session = useSessionStore()
|
||||
@@ -24,6 +26,42 @@ function isSelf(id: string) {
|
||||
|
||||
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(() => {
|
||||
if (!self.value) device.loadSelf().then(() => {
|
||||
if (self.value) message.setSelf(self.value.deviceId)
|
||||
@@ -74,6 +112,7 @@ onMounted(() => {
|
||||
class="device-item"
|
||||
:class="{ active: session.activePeerId === d.deviceId }"
|
||||
@click="pick(d)"
|
||||
@contextmenu.prevent="onDeviceCtxMenu($event, d)"
|
||||
>
|
||||
<el-avatar
|
||||
: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,6 +9,9 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
const unread = ref<Record<string, number>>({})
|
||||
const scanning = ref(false)
|
||||
const lastScanAt = ref(0)
|
||||
// 我们 outgoing WS 接通状态 (与 discovery.online 区分: UDP 在 ≠ WS 通)
|
||||
// 默认乐观地认为 WS 通的 (首次进入 chat 还没收到 device:wsState 事件前), 直到第一次 close
|
||||
const wsOpen = ref<Record<string, boolean>>({})
|
||||
|
||||
async function refresh() {
|
||||
const [d, s, u] = await Promise.all([
|
||||
@@ -32,11 +35,15 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
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))
|
||||
|
||||
return {
|
||||
self, devices, settings, unread, scanning, lastScanAt,
|
||||
refresh, loadSelf, triggerScan,
|
||||
self, devices, settings, unread, scanning, lastScanAt, wsOpen,
|
||||
refresh, loadSelf, triggerScan, setWsOpen,
|
||||
onlineDevices,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { MessageView } from '@/api'
|
||||
|
||||
export const useSessionStore = defineStore('session', () => {
|
||||
const activePeerId = ref<string | null>(null)
|
||||
const peerTyping = ref<Record<string, boolean>>({})
|
||||
// 当前会话准备要回复的消息 (引用输入框里的预览条). null = 没有挂起的回复
|
||||
const replyTo = ref<MessageView | null>(null)
|
||||
|
||||
function setActive(id: string | null) { activePeerId.value = id }
|
||||
function setTyping(deviceId: string, v: boolean) {
|
||||
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 }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user