右键复制撤回功能

This commit is contained in:
2026-02-26 18:47:39 +08:00
parent fdb4a56197
commit 4780e15ffa
2 changed files with 146 additions and 13 deletions

View File

@@ -263,6 +263,28 @@ export function goEasyMessageRead(data) {
})
}
// 撤回消息
export function goEasyRecallMessage(message) {
if (!isGoEasyEnabled()) {
return Promise.reject(new Error('GoEasy 未启用'))
}
const goeasy = getPkGoEasy()
const im = goeasy.im
return new Promise((resolve, reject) => {
im.recallMessage({
messages: [message],
onSuccess: function () {
resolve(true)
},
onFailed: function (error) {
console.log('撤回失败, code:' + error.code + ' content:' + error.content)
reject(error)
}
})
})
}
// 删除会话
export function goEasyRemoveConversation(conversation) {
if (!isGoEasyEnabled()) {

View File

@@ -1,6 +1,6 @@
<template>
<!-- 消息页面 -->
<div class="message-page">
<div class="message-page" ref="messagePageRef">
<div class="message-layout">
<!-- 会话列表 -->
<div class="conversation-panel">
@@ -45,14 +45,20 @@
class="message-item"
:class="{ mine: msg.senderId == currentUser.id }"
>
<div class="message-bubble">
<div v-if="msg.type === 'text'" class="text-message">{{ msg.payload.text }}</div>
<PictureMessage v-else-if="msg.type === 'image'" :item="msg" />
<MiniPKMessage v-else-if="msg.type === 'pk'" :item="msg" />
<VoiceMessage v-else-if="msg.type === 'audio'" :item="msg.payload.url" :size="msg.payload.duration" />
<div v-if="msg.senderId == currentUser.id" class="read-status">
{{ msg.read ? '已读' : '未读' }}
</div>
<div
class="message-bubble"
@contextmenu.prevent="msg.type !== 'pk' ? showContextMenu($event, msg, index) : null"
>
<div v-if="msg.recalled" class="recalled-tip">消息已撤回</div>
<template v-else>
<div v-if="msg.type === 'text'" class="text-message">{{ msg.payload.text }}</div>
<PictureMessage v-else-if="msg.type === 'image'" :item="msg" />
<MiniPKMessage v-else-if="msg.type === 'pk'" :item="msg" />
<VoiceMessage v-else-if="msg.type === 'audio'" :item="msg.payload.url" :size="msg.payload.duration" />
<div v-if="msg.senderId == currentUser.id" class="read-status">
{{ msg.read ? '已读' : '未读' }}
</div>
</template>
</div>
</div>
</div>
@@ -89,6 +95,16 @@
style="display: none"
@change="handleFileSelect"
/>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
@mouseleave="hideContextMenu"
>
<div v-if="contextMenu.msg?.type === 'text'" class="context-menu-item" @click="copyMessage">复制</div>
<div v-if="contextMenu.msg?.senderId == currentUser.id" class="context-menu-item danger" @click="recallMessage">撤回</div>
</div>
</div>
</template>
@@ -104,6 +120,7 @@ import {
goEasySendImageMessage,
goEasyMessageRead,
goEasyRemoveConversation,
goEasyRecallMessage,
getPkGoEasy,
GoEasy
} from '@/utils/pk-mini/goeasy'
@@ -123,6 +140,53 @@ const isScrollReady = ref(false)
const chatMessagesRef = ref(null)
const fileInputRef = ref(null)
const unreadStore = pkUnreadStore()
const contextMenu = ref({ visible: false, x: 0, y: 0, msg: null, index: -1 })
const messagePageRef = ref(null)
function showContextMenu(event, msg, index) {
const menuWidth = 110
const menuHeight = msg.senderId == currentUser.value.id ? 80 : 44
const rect = messagePageRef.value?.getBoundingClientRect() || { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
let x = event.clientX - rect.left
let y = event.clientY - rect.top
if (x + menuWidth > rect.width) x -= menuWidth
if (y + menuHeight > rect.height) y -= menuHeight
contextMenu.value = { visible: true, x, y, msg, index }
}
function hideContextMenu() {
contextMenu.value.visible = false
}
function copyMessage() {
const text = contextMenu.value.msg?.payload?.text || ''
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => {})
} else {
const el = document.createElement('textarea')
el.value = text
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
hideContextMenu()
}
async function recallMessage() {
const msg = contextMenu.value.msg
const index = contextMenu.value.index
hideContextMenu()
if (!msg) return
try {
await goEasyRecallMessage(msg)
// 用对象替换触发 Vue 响应式更新
messagesList.value.splice(index, 1, { ...msg, recalled: true })
} catch (e) {
console.error('撤回失败', e)
ElMessage.error('撤回失败消息超过4小时或发送中')
}
}
const formatTime = TimestamptolocalTime
@@ -273,6 +337,7 @@ async function removeConversation(item, index) {
onMounted(() => {
currentUser.value = getMainUserData() || {}
document.addEventListener('click', hideContextMenu)
if (isGoEasyEnabled()) {
loadConversations()
const goeasy = getPkGoEasy()
@@ -280,6 +345,7 @@ onMounted(() => {
goeasy.im.on(GoEasy.IM_EVENT.PRIVATE_MESSAGE_RECEIVED, onMessageReceived)
goeasy.im.on(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, onConversationsUpdated)
goeasy.im.on(GoEasy.IM_EVENT.MESSAGE_READ, onMessageRead)
goeasy.im.on(GoEasy.IM_EVENT.MESSAGE_RECALLED, onMessageRecalled)
}
}
// 切换回消息页面时,滚到聊天记录最底部
@@ -302,11 +368,19 @@ function onConversationsUpdated(conversations) {
}
function onMessageRead(messages) {
// 收到对方已读回执,更新本地消息的 read 状态
messages.forEach(readMsg => {
const target = messagesList.value.find(m => m.messageId === readMsg.messageId)
if (target) {
target.read = true
const index = messagesList.value.findIndex(m => m.messageId === readMsg.messageId)
if (index !== -1) {
messagesList.value.splice(index, 1, { ...messagesList.value[index], read: true })
}
})
}
function onMessageRecalled(messages) {
messages.forEach(recalled => {
const index = messagesList.value.findIndex(m => m.messageId === recalled.messageId)
if (index !== -1) {
messagesList.value.splice(index, 1, { ...messagesList.value[index], recalled: true })
}
})
}
@@ -327,6 +401,7 @@ function onMessageReceived(message) {
}
onUnmounted(() => {
document.removeEventListener('click', hideContextMenu)
if (isGoEasyEnabled()) {
const goeasy = getPkGoEasy()
if (goeasy) {
@@ -334,6 +409,7 @@ onUnmounted(() => {
goeasy.im.off(GoEasy.IM_EVENT.PRIVATE_MESSAGE_RECEIVED, onMessageReceived)
goeasy.im.off(GoEasy.IM_EVENT.CONVERSATIONS_UPDATED, onConversationsUpdated)
goeasy.im.off(GoEasy.IM_EVENT.MESSAGE_READ, onMessageRead)
goeasy.im.off(GoEasy.IM_EVENT.MESSAGE_RECALLED, onMessageRecalled)
} catch (e) {
console.warn('清理 GoEasy 监听器失败', e)
}
@@ -344,6 +420,7 @@ onUnmounted(() => {
<style scoped lang="less">
.message-page {
position: relative;
width: 100%;
height: 100%;
background-color: #ffffff;
@@ -591,6 +668,40 @@ onUnmounted(() => {
color: #60a5fa;
}
.recalled-tip {
font-size: 13px;
color: #94a3b8;
font-style: italic;
padding: 6px 12px;
}
.context-menu {
position: absolute;
z-index: 9999;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
min-width: 100px;
overflow: hidden;
}
.context-menu-item {
padding: 10px 16px;
font-size: 14px;
cursor: pointer;
color: #374151;
&:hover {
background-color: #f3f4f6;
}
&.danger {
color: #ef4444;
&:hover {
background-color: #fee2e2;
}
}
}
.empty-tip {
text-align: center;
padding: 50px;