feat: add message management and delivery states
This commit is contained in:
+42
-5
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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">
|
<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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user