feat: add message management and delivery states

This commit is contained in:
2026-07-16 17:23:24 +08:00
parent 07c7260a3c
commit 565d044f18
17 changed files with 881 additions and 97 deletions
+23 -1
View File
@@ -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
View File
@@ -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
}
+53 -4
View File
@@ -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; }
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ctxMenuState, pickContextMenu, closeContextMenu } from '@/composables/contextMenu'
const menuEl = ref<HTMLDivElement | null>(null)
// 菜单展示后, 调整位置不让菜单跑出屏幕外
watch(() => ctxMenuState.visible, async (v) => {
if (!v) return
await nextTick()
if (!menuEl.value) return
const rect = menuEl.value.getBoundingClientRect()
let nx = ctxMenuState.x, ny = ctxMenuState.y
// 优先"右击位置为菜单的左上角"; 如果右侧跑出去, 左移菜单; 下侧跑出去, 上移菜单
if (rect.right > window.innerWidth - 8) nx = window.innerWidth - rect.width - 8
if (rect.bottom > window.innerHeight - 8) ny = window.innerHeight - rect.height - 8
if (nx < 8) nx = 8
if (ny < 8) ny = 8
if (nx !== ctxMenuState.x || ny !== ctxMenuState.y) {
menuEl.value.style.left = nx + 'px'
menuEl.value.style.top = ny + 'px'
}
})
function pick(cmd: string) { pickContextMenu(cmd) }
</script>
<template>
<Teleport to="body">
<!-- 透明遮罩: 点空白或右键空白都关闭 -->
<div
v-if="ctxMenuState.visible"
class="ctx-overlay"
@click="closeContextMenu"
@contextmenu.prevent="closeContextMenu"
/>
<div
v-if="ctxMenuState.visible"
ref="menuEl"
class="ctx-menu"
:style="{ left: ctxMenuState.x + 'px', top: ctxMenuState.y + 'px' }"
@contextmenu.prevent.stop
>
<div
v-for="item in ctxMenuState.items"
:key="item.cmd"
class="ctx-item"
:class="{ 'ctx-danger': item.danger, 'ctx-divided': item.divided }"
@click="pick(item.cmd)"
>
<el-icon v-if="item.icon" :size="14" class="ctx-icon">
<component :is="item.icon" />
</el-icon>
<span>{{ item.label }}</span>
</div>
</div>
</Teleport>
</template>
<style scoped>
.ctx-overlay {
position: fixed;
inset: 0;
z-index: 9998;
}
.ctx-menu {
position: fixed;
z-index: 9999;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.06);
min-width: 160px;
user-select: none;
}
.ctx-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
color: var(--el-text-color-primary);
transition: background 0.1s;
}
.ctx-item:hover {
background: var(--el-fill-color-light);
}
.ctx-icon {
color: var(--el-text-color-secondary);
}
.ctx-danger {
color: var(--el-color-danger);
}
.ctx-danger .ctx-icon {
color: var(--el-color-danger);
}
.ctx-danger:hover {
background: var(--el-color-danger-light-9);
}
.ctx-divided {
border-top: 1px solid var(--el-border-color-lighter);
margin-top: 4px;
padding-top: 8px;
}
</style>
+94 -6
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
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;
+200 -5
View File
@@ -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;
+13 -22
View File
@@ -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>
+41 -2
View File
@@ -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 -2
View File
@@ -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,
}
})
+6 -1
View File
@@ -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 }
})