Files
wecom_it_smart_desk/frontend-agent/src/components/chat/MessageItem.vue
T
Simon 018c87e10b feat(agent): approval queue inline card + exclusion panel
新增 ApprovalQueue 视图、ApprovalInlineCard/AgentExclusionPanel 组件与 useApprovalQueue; 对话/API 适配; public 静态资源。
2026-07-09 11:50:07 +08:00

635 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =============================================================================
// 企微IT智能服务台 — 坐席端消息气泡组件
// =============================================================================
// 说明:单条消息的气泡展示
// 功能:
// - 长按/右键弹出操作菜单:复制、撤回(2分钟内)、删除
// - 消息状态显示:发送中、已发送、已送达、已读
// - 时间戳显示规则:同日期只显示时间,不同日期显示月日时间
// - 消息类型:文本、图片、文件、语音、系统消息
// ============================================================================= -->
<template>
<!-- 系统消息居中灰色文字 -->
<div v-if="message.sender_type === 'system'" class="message-item message-item--system">
<span class="message-item__system-text">{{ message.content }}</span>
</div>
<!-- 非系统消息 -->
<div
v-else
class="message-row"
:class="`message-row-${message.sender_type}`"
@contextmenu.prevent="showContextMenu"
>
<!-- 发送者名称 -->
<div v-if="message.sender_type !== 'agent'" class="message-sender-name">
{{ senderLabel }}
<img v-if="message.sender_type === 'ai'" src="/duckula.webp" class="duckula-avatar" alt="Duckula" />
</div>
<!-- 消息气泡 -->
<div class="message-bubble" :class="`message-${message.sender_type}`">
<!-- 引用回复摘要 -->
<div v-if="message.reply_to_id && replyToContent" class="reply-quote" @click.stop="$emit('scrollToMessage', message.reply_to_id)">
<div class="reply-quote-bar"></div>
<div class="reply-quote-text">
<span class="reply-quote-sender">{{ replyToSender }}</span>
{{ replyToContent }}
</div>
</div>
<!-- 文本消息 -->
<template v-if="message.msg_type === 'text'">
<div style="white-space: pre-wrap;">{{ message.content }}</div>
</template>
<!-- 图片消息 -->
<template v-else-if="message.msg_type === 'image'">
<div class="image-message" @click="previewImage">
<img
v-if="message.media_url || message.extra_data?.pic_url"
:src="message.media_url || message.extra_data?.pic_url"
:alt="message.file_name || '图片'"
class="image-thumbnail"
loading="lazy"
/>
<div v-else class="media-card">
<div class="media-icon">🖼</div>
<div class="media-info">
<span class="media-type-label">图片消息</span>
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
</div>
</div>
</div>
</template>
<!-- 文件消息 -->
<template v-else-if="message.msg_type === 'file'">
<a
v-if="message.media_url"
:href="message.media_url"
target="_blank"
class="media-card media-card-link"
>
<div class="media-icon">📎</div>
<div class="media-info">
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</a>
<div v-else class="media-card">
<div class="media-icon">📎</div>
<div class="media-info">
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</div>
</template>
<!-- 其他消息 -->
<template v-else>
<div class="media-card">
<div class="media-icon">{{ mediaIcon }}</div>
<div class="media-info">
<span class="media-type-label">{{ mediaTypeLabel }}</span>
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</div>
</template>
<!-- 操作按钮hover 显示 -->
<div v-if="showActions" class="bubble-actions">
<button
v-if="message.msg_type === 'text'"
class="action-btn"
title="复制消息"
@click.stop="copyMessage"
>
{{ copySuccess ? '✓' : '📋' }}
</button>
<button
class="action-btn"
title="回复"
@click.stop="$emit('reply', message)"
>
</button>
</div>
<!-- 消息状态 -->
<div v-if="showStatus" class="message-status">
{{ statusText }}
</div>
</div>
<!-- 时间戳 -->
<div class="message-time">{{ formatMessageTime }}</div>
<!-- 操作菜单 -->
<div v-if="contextMenuVisible" class="context-menu" :style="contextMenuStyle">
<button class="context-menu__item" @click.stop="copyMessage">
📋 复制
</button>
<button
v-if="canRecall"
class="context-menu__item"
@click.stop="recallMessage"
>
撤回
</button>
<button
v-if="message.sender_type === 'employee'"
class="context-menu__item context-menu__item--danger"
@click.stop="deleteMessage"
>
🗑 删除
</button>
</div>
<div v-if="contextMenuVisible" class="context-menu__overlay" @click="closeContextMenu"></div>
</div>
</template>
<script setup lang="ts">
/**
* MessageItem 消息气泡组件
* 长按/右键弹出操作菜单
* 消息状态显示
* 时间戳显示规则
*/
import { computed, ref } from 'vue'
import { useClipboard } from '@vueuse/core'
import { ElMessage } from 'element-plus'
import type { Message } from '@/api/message'
import { useConversationStore } from '@/stores/conversation'
const props = defineProps<{
message: Message
}>()
const emit = defineEmits<{
(e: 'scrollToMessage', messageId: string): void
(e: 'reply', message: Message): void
(e: 'recall', messageId: string): void
(e: 'delete', messageId: string): void
}>()
const conversationStore = useConversationStore()
const { copy } = useClipboard()
/** 是否显示操作按钮 */
const showActions = ref(false)
/** 操作菜单位置 */
const contextMenuVisible = ref(false)
const contextMenuStyle = ref<Record<string, string>>({})
/** 复制成功反馈 */
const copySuccess = ref(false)
// ============================================================================
// 计算属性
// ============================================================================
/** 发送者标签 */
const senderLabel = computed(() => {
const labelMap: Record<string, string> = {
employee: props.message.sender_name || conversationStore.currentConversation?.employee_name || '未知',
agent: props.message.sender_name || '我',
ai: 'Duckula(达寇拉)',
}
return labelMap[props.message.sender_type] || '未知'
})
/** 格式化时间 */
const formatMessageTime = computed(() => {
if (!props.message.created_at) return ''
const date = new Date(props.message.created_at)
const now = new Date()
const isSameDay = date.toDateString() === now.toDateString()
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
if (isSameDay) {
return `${hours}:${minutes}`
} else {
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
})
/** 是否显示状态 */
const showStatus = computed(() => {
return props.message.sender_type === 'agent' && props.message.status
})
/** 状态文本 */
const statusText = computed(() => {
const statusMap: Record<string, string> = {
sending: '发送中',
sent: '已发送',
delivered: '已送达',
read: '已读',
}
return statusMap[props.message.status || ''] || ''
})
/** 是否可以撤回 */
const canRecall = computed(() => {
if (props.message.sender_type !== 'agent') return false
if (!props.message.created_at) return false
const createdAt = new Date(props.message.created_at)
const now = new Date()
const diffMs = now.getTime() - createdAt.getTime()
const diffMinutes = diffMs / (1000 * 60)
return diffMinutes <= 2
})
/** 媒体图标 */
const mediaIcon = computed(() => {
const icons: Record<string, string> = {
image: '🖼️',
voice: '🎤',
video: '🎬',
file: '📎',
location: '📍',
}
return icons[props.message.msg_type] || '📄'
})
/** 媒体类型标签 */
const mediaTypeLabel = computed(() => {
const labels: Record<string, string> = {
image: '图片消息',
voice: '语音消息',
video: '视频消息',
file: '文件消息',
location: '位置消息',
}
return labels[props.message.msg_type] || '媒体消息'
})
/** 引用回复内容 */
const replyToContent = computed(() => {
if (!props.message.reply_to_id) return ''
const repliedMsg = conversationStore.messages.find(
(m: Message) => m.id === props.message.reply_to_id
)
if (!repliedMsg) return '...'
const text = repliedMsg.content || ''
return text.length > 50 ? text.substring(0, 50) + '...' : text
})
/** 引用回复发送者 */
const replyToSender = computed(() => {
if (!props.message.reply_to_id) return ''
const repliedMsg = conversationStore.messages.find(
(m: Message) => m.id === props.message.reply_to_id
)
if (!repliedMsg) return ''
return repliedMsg.sender_name || '未知'
})
// ============================================================================
// 操作菜单
// ============================================================================
function showContextMenu(event: MouseEvent): void {
contextMenuStyle.value = {
left: `${event.clientX}px`,
top: `${event.clientY}px`,
}
contextMenuVisible.value = true
}
function closeContextMenu(): void {
contextMenuVisible.value = false
}
// ============================================================================
// 消息操作
// ============================================================================
async function copyMessage(): Promise<void> {
try {
await copy(props.message.content)
copySuccess.value = true
ElMessage.success('已复制')
closeContextMenu()
setTimeout(() => {
copySuccess.value = false
}, 1500)
} catch (err) {
console.error('复制失败:', err)
}
}
function recallMessage(): void {
emit('recall', props.message.id)
closeContextMenu()
}
function deleteMessage(): void {
emit('delete', props.message.id)
closeContextMenu()
}
// ============================================================================
// 工具方法
// ============================================================================
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function previewImage(): void {
const url = props.message.media_url || props.message.extra_data?.pic_url
if (url) {
window.open(url, '_blank')
}
}
</script>
<style scoped>
/* ============================================================================
// 消息行
// ============================================================================ */
.message-row {
display: flex;
flex-direction: column;
padding: 4px 16px;
max-width: 100%;
position: relative;
}
.message-row-employee {
align-items: flex-start;
}
.message-row-agent {
align-items: flex-end;
}
.message-row-ai {
align-items: flex-start;
}
/* 系统消息 */
.message-item--system {
align-items: center;
padding: 8px 16px;
}
.message-item__system-text {
font-size: 12px;
color: var(--color-system-text);
background-color: var(--color-system-bg);
padding: 4px 12px;
border-radius: 10px;
max-width: 80%;
text-align: center;
line-height: 1.5;
}
/* 发送者名称 */
.message-sender-name {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 3px;
}
.ai-tag {
display: inline-block;
font-size: 10px;
color: var(--color-ai-tag-text);
background-color: var(--color-ai-tag-bg);
padding: 1px 6px;
border-radius: 3px;
font-weight: 500;
}
/* 消息气泡 */
.message-bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: 12px;
word-break: break-word;
line-height: 1.5;
position: relative;
}
.message-employee {
background-color: var(--color-agent-bg);
border: 1px solid var(--color-agent-border);
}
.message-agent {
background-color: var(--color-employee-bg);
border-top-right-radius: 4px;
}
.message-ai {
background-color: var(--color-ai-bg);
border-top-left-radius: 4px;
}
/* 消息时间 */
.message-time {
font-size: 10px;
color: var(--text-placeholder);
margin-top: 3px;
}
/* 引用回复 */
.reply-quote {
display: flex;
align-items: stretch;
gap: 6px;
padding: 6px 8px;
margin-bottom: 6px;
background: var(--bg-tertiary);
border-radius: 4px;
cursor: pointer;
}
.reply-quote:hover {
background: var(--accent-soft);
}
.reply-quote-bar {
width: 3px;
border-radius: 2px;
background: var(--accent);
flex-shrink: 0;
}
.reply-quote-text {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.reply-quote-sender {
color: var(--accent);
font-weight: 500;
margin-right: 4px;
}
/* 图片消息 */
.image-message {
cursor: pointer;
border-radius: 8px;
overflow: hidden;
max-width: 280px;
}
.image-thumbnail {
display: block;
max-width: 280px;
max-height: 200px;
object-fit: contain;
border-radius: 6px;
}
/* 媒体卡片 */
.media-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
min-width: 180px;
max-width: 260px;
}
.media-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
}
.media-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.media-type-label {
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
}
.media-filename {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-size {
font-size: 11px;
color: var(--text-placeholder);
}
.media-card-link {
text-decoration: none;
color: inherit;
}
.media-card-link:hover {
background: var(--accent-soft);
border-color: var(--accent);
}
/* 操作按钮 */
.bubble-actions {
position: absolute;
top: 4px;
right: 4px;
display: flex;
gap: 2px;
z-index: 2;
}
.action-btn {
width: 24px;
height: 24px;
border: none;
background: var(--bg-tertiary);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s, background 0.2s;
padding: 0;
}
.action-btn:hover {
opacity: 1;
background: var(--accent-soft);
}
/* 消息状态 */
.message-status {
position: absolute;
bottom: 4px;
right: 8px;
font-size: 10px;
color: var(--text-placeholder);
}
/* 操作菜单 */
.context-menu {
position: fixed;
z-index: 1000;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.context-menu__item {
display: block;
width: 100%;
padding: 8px 16px;
border: none;
background: transparent;
text-align: left;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
border-radius: 4px;
}
.context-menu__item:hover {
background: var(--bg-tertiary);
}
.context-menu__item--danger {
color: #ee0a24;
}
.context-menu__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
</style>