984 lines
27 KiB
Vue
984 lines
27 KiB
Vue
|
|
<!-- =============================================================================
|
|||
|
|
// 企微IT智能服务台 — 回复输入框组件(v5.4 圆角卡片+工具栏+拖拽调整)
|
|||
|
|
// =============================================================================
|
|||
|
|
// 说明:坐席回复消息的输入区域
|
|||
|
|
// 功能:
|
|||
|
|
// 1. 上方拖拽手柄调整输入区高度(textarea 同步伸缩)
|
|||
|
|
// 2. 快捷工具栏(表情/图片/截图/文件/语音/远程协助/快速回复)
|
|||
|
|
// 3. 输入框+发送按钮合为圆角卡片,渐变蓝紫发送按钮
|
|||
|
|
// 4. Enter 发送,Shift+Enter 换行
|
|||
|
|
// 5. 支持粘贴图片/拖拽文件上传(预留接口)
|
|||
|
|
// ============================================================================= -->
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="reply-box" ref="replyBoxRef">
|
|||
|
|
<!-- 上方拖拽手柄(调整输入区高度) -->
|
|||
|
|
<div
|
|||
|
|
class="input-resize-handle"
|
|||
|
|
:class="{ dragging: isResizing }"
|
|||
|
|
@mousedown="startResize"
|
|||
|
|
title="拖拽调整输入框高度"
|
|||
|
|
></div>
|
|||
|
|
|
|||
|
|
<!-- 引用回复预览(回复某条消息时显示) -->
|
|||
|
|
<div v-if="replyToMessage" class="reply-preview">
|
|||
|
|
<div class="reply-preview-bar"></div>
|
|||
|
|
<div class="reply-preview-content">
|
|||
|
|
<span class="reply-preview-sender">{{ replyToMessage.sender_name || '未知' }}</span>
|
|||
|
|
<span class="reply-preview-text">{{ replyToMessage.content?.substring(0, 60) }}{{ replyToMessage.content?.length > 60 ? '...' : '' }}</span>
|
|||
|
|
</div>
|
|||
|
|
<button class="reply-preview-close" title="取消回复" @click="$emit('cancelReply')">✕</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 快捷工具栏 -->
|
|||
|
|
<div class="chat-toolbar">
|
|||
|
|
<div class="emoji-wrapper">
|
|||
|
|
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
|
|||
|
|
😊
|
|||
|
|
<span class="tb-tip">表情</span>
|
|||
|
|
</button>
|
|||
|
|
<!-- 自定义中文表情选择面板(8x8 网格,64个常用表情) -->
|
|||
|
|
<div v-if="showEmojiPicker" class="emoji-picker-popup">
|
|||
|
|
<div class="emoji-grid">
|
|||
|
|
<button
|
|||
|
|
v-for="emoji in commonEmojis"
|
|||
|
|
:key="emoji"
|
|||
|
|
class="emoji-grid__item"
|
|||
|
|
@click="onEmojiSelect(emoji)"
|
|||
|
|
>{{ emoji }}</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 点击表情面板外部关闭 -->
|
|||
|
|
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
|
|||
|
|
</div>
|
|||
|
|
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
|
|||
|
|
🖼
|
|||
|
|
<span class="tb-tip">图片</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
|
|||
|
|
✂
|
|||
|
|
<span class="tb-tip">截图</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
|
|||
|
|
📎
|
|||
|
|
<span class="tb-tip">文件</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="tb-btn" title="语音" @click="handleToolbarClick('voice')">
|
|||
|
|
🎤
|
|||
|
|
<span class="tb-tip">语音</span>
|
|||
|
|
</button>
|
|||
|
|
<div class="tb-sep"></div>
|
|||
|
|
<button class="tb-btn" title="邀请员工/部门" @click="showInviteDialog = true">
|
|||
|
|
👥
|
|||
|
|
<span class="tb-tip">邀请</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="tb-btn" title="远程协助" @click="handleToolbarClick('remote')">
|
|||
|
|
🖥
|
|||
|
|
<span class="tb-tip">远程协助</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="tb-btn" title="快速回复" @click="handleToolbarClick('quickReply')">
|
|||
|
|
⚡
|
|||
|
|
<span class="tb-tip">快速回复</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 输入行(圆角卡片:textarea + 发送按钮) -->
|
|||
|
|
<div class="chat-input-card">
|
|||
|
|
<textarea
|
|||
|
|
ref="inputRef"
|
|||
|
|
v-model="inputText"
|
|||
|
|
class="chat-input"
|
|||
|
|
placeholder="输入回复内容... (Enter发送,Shift+Enter换行)"
|
|||
|
|
:style="{ height: textareaHeight + 'px' }"
|
|||
|
|
@keydown="handleKeydown"
|
|||
|
|
@paste="handlePaste"
|
|||
|
|
></textarea>
|
|||
|
|
<button
|
|||
|
|
class="btn-send"
|
|||
|
|
:disabled="!inputText.trim()"
|
|||
|
|
@click="handleSend"
|
|||
|
|
>
|
|||
|
|
发 送
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 邀请员工/部门弹窗 -->
|
|||
|
|
<InviteParticipantDialog
|
|||
|
|
v-model="showInviteDialog"
|
|||
|
|
:conversation-id="conversationStore.currentConversation?.id || ''"
|
|||
|
|
:existing-participant-ids="existingParticipantIds"
|
|||
|
|
@success="onInviteSuccess"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 隐藏的文件输入框(图片/文件上传用,由工具栏按钮触发) -->
|
|||
|
|
<input
|
|||
|
|
ref="fileInputRef"
|
|||
|
|
type="file"
|
|||
|
|
style="display: none"
|
|||
|
|
@change="handleFileSelect"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 截图区域选择编辑器(对标微信/企微截图体验) -->
|
|||
|
|
<ScreenshotEditor
|
|||
|
|
v-if="showScreenshotEditor"
|
|||
|
|
:screenshot-canvas="screenshotCanvas"
|
|||
|
|
@confirm="onScreenshotConfirm"
|
|||
|
|
@cancel="onScreenshotCancel"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
// ============================================================================
|
|||
|
|
// 导入
|
|||
|
|
// ============================================================================
|
|||
|
|
import { ref, watch, nextTick, onUnmounted, computed } from 'vue'
|
|||
|
|
import { ElMessage } from 'element-plus'
|
|||
|
|
import html2canvas from 'html2canvas-pro'
|
|||
|
|
import { useConversationStore } from '@/stores/conversation'
|
|||
|
|
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
|
|||
|
|
import ScreenshotEditor from './ScreenshotEditor.vue'
|
|||
|
|
import { uploadFile } from '@/api/upload'
|
|||
|
|
import { sendMessage } from '@/api/message'
|
|||
|
|
import type { Message } from '@/api/message'
|
|||
|
|
import { useWebSocket } from '@/composables/useWebSocket'
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 工具函数:安全提取错误详情(防止 [object Object])
|
|||
|
|
// ============================================================================
|
|||
|
|
/**
|
|||
|
|
* 将 error.response.data.detail 安全转换为字符串
|
|||
|
|
* FastAPI 422 时 detail 是数组,直接拼接会变成 "[object Object]"
|
|||
|
|
*/
|
|||
|
|
function formatErrorDetail(detail: any): string {
|
|||
|
|
if (!detail) return ''
|
|||
|
|
if (typeof detail === 'string') return detail
|
|||
|
|
if (Array.isArray(detail)) {
|
|||
|
|
// FastAPI 验证错误:取每个元素的 msg 和 loc
|
|||
|
|
return detail.map((d: any) => {
|
|||
|
|
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
|
|||
|
|
if (d.msg) return d.msg
|
|||
|
|
return JSON.stringify(d)
|
|||
|
|
}).join('; ')
|
|||
|
|
}
|
|||
|
|
if (typeof detail === 'object') return JSON.stringify(detail)
|
|||
|
|
return String(detail)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// Props & Emits
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
/** 引用回复:正在回复的消息(null 表示普通发送) */
|
|||
|
|
replyToMessage?: Message | null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Emits {
|
|||
|
|
/** 发送消息事件 */
|
|||
|
|
(e: 'send', content: string): void
|
|||
|
|
/** 取消引用回复 */
|
|||
|
|
(e: 'cancelReply'): void
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
withDefaults(defineProps<Props>(), {
|
|||
|
|
replyToMessage: null,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const emit = defineEmits<Emits>()
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 状态
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
/** 输入框文本 */
|
|||
|
|
const inputText = ref('')
|
|||
|
|
|
|||
|
|
/** 输入框 DOM 引用 */
|
|||
|
|
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
|||
|
|
|
|||
|
|
/** 隐藏文件输入框 DOM 引用(用于触发系统文件选择器) */
|
|||
|
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
|||
|
|
|
|||
|
|
/** ReplyBox 容器 DOM 引用 */
|
|||
|
|
const replyBoxRef = ref<HTMLElement | null>(null)
|
|||
|
|
|
|||
|
|
/** 会话 Store */
|
|||
|
|
const conversationStore = useConversationStore()
|
|||
|
|
|
|||
|
|
/** WebSocket 组合函数(用于发送 typing 事件) */
|
|||
|
|
const { sendTyping } = useWebSocket()
|
|||
|
|
|
|||
|
|
/** textarea 高度(px),默认3行约60px */
|
|||
|
|
const textareaHeight = ref(60)
|
|||
|
|
|
|||
|
|
/** 是否正在拖拽调整高度 */
|
|||
|
|
const isResizing = ref(false)
|
|||
|
|
|
|||
|
|
/** 邀请弹窗是否可见 */
|
|||
|
|
const showInviteDialog = ref(false)
|
|||
|
|
|
|||
|
|
/** 截图编辑器是否可见 */
|
|||
|
|
const showScreenshotEditor = ref(false)
|
|||
|
|
|
|||
|
|
/** html2canvas 生成的完整页面截图 Canvas 对象(传给 ScreenshotEditor) */
|
|||
|
|
let screenshotCanvas: HTMLCanvasElement | null = null
|
|||
|
|
const showEmojiPicker = ref(false)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 常用表情列表(8行x8列 = 64 个常用表情)
|
|||
|
|
* 覆盖日常沟通场景,与 H5 端保持一致
|
|||
|
|
*/
|
|||
|
|
const commonEmojis = [
|
|||
|
|
'😀','😃','😄','😁','😆','😅','🤣','😂',
|
|||
|
|
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
|
|||
|
|
'😚','😙','😋','😛','😜','🤪','😝','🤑',
|
|||
|
|
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
|
|||
|
|
'😶','😏','😒','🙄','😬','😮','🤯','😲',
|
|||
|
|
'😳','🥺','😢','😭','😤','😠','😡','🤬',
|
|||
|
|
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
|
|||
|
|
'❤️','🧡','💛','💚','💙','💜','💯','✅',
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
/** 已在参与者列表中的ID(排除已有员工和参与者) */
|
|||
|
|
const existingParticipantIds = computed(() => {
|
|||
|
|
const conv = conversationStore.currentConversation
|
|||
|
|
if (!conv) return []
|
|||
|
|
const ids: string[] = [conv.employee_id] // 排除发起咨询的员工自己
|
|||
|
|
if (conv.participants) {
|
|||
|
|
ids.push(...conv.participants.map((p: any) => p.id))
|
|||
|
|
}
|
|||
|
|
return ids
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/** 邀请成功回调 */
|
|||
|
|
function onInviteSuccess(): void {
|
|||
|
|
// 刷新会话列表(重新拉取最新数据)
|
|||
|
|
conversationStore.fetchConversations()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 表情选择回调 — 将选中的表情插入到输入框 */
|
|||
|
|
function onEmojiSelect(emoji: string): void {
|
|||
|
|
inputText.value += emoji
|
|||
|
|
showEmojiPicker.value = false
|
|||
|
|
nextTick(() => {
|
|||
|
|
inputRef.value?.focus()
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 拖拽相关临时变量 */
|
|||
|
|
let resizeStartY = 0
|
|||
|
|
let resizeStartHeight = 0
|
|||
|
|
let resizeStartTextareaHeight = 0
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 监听
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 监听 pendingReplyText 变化
|
|||
|
|
* 当快速回复模板设置待填充文本时,自动填充到输入框
|
|||
|
|
*/
|
|||
|
|
watch(
|
|||
|
|
() => conversationStore.pendingReplyText,
|
|||
|
|
(newVal) => {
|
|||
|
|
if (newVal) {
|
|||
|
|
inputText.value = newVal
|
|||
|
|
conversationStore.pendingReplyText = ''
|
|||
|
|
nextTick(() => {
|
|||
|
|
inputRef.value?.focus()
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 监听输入框文本变化,发送 typing 事件
|
|||
|
|
* 当用户输入文字时,通知 WebSocket 让其他人看到"正在输入..."
|
|||
|
|
* 节流:3 秒内最多发送一次(在 sendTyping 内部控制)
|
|||
|
|
*/
|
|||
|
|
watch(inputText, () => {
|
|||
|
|
const convId = conversationStore.currentConversation?.id
|
|||
|
|
if (convId && inputText.value.trim()) {
|
|||
|
|
sendTyping(convId)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 拖拽调整输入区高度
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
/** 开始拖拽 */
|
|||
|
|
function startResize(e: MouseEvent): void {
|
|||
|
|
e.preventDefault()
|
|||
|
|
isResizing.value = true
|
|||
|
|
resizeStartY = e.clientY
|
|||
|
|
resizeStartHeight = replyBoxRef.value?.offsetHeight || 80
|
|||
|
|
resizeStartTextareaHeight = textareaHeight.value
|
|||
|
|
document.addEventListener('mousemove', onResizeMove)
|
|||
|
|
document.addEventListener('mouseup', onResizeEnd)
|
|||
|
|
document.body.style.cursor = 'row-resize'
|
|||
|
|
document.body.style.userSelect = 'none'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 拖拽中 */
|
|||
|
|
function onResizeMove(e: MouseEvent): void {
|
|||
|
|
if (!isResizing.value) return
|
|||
|
|
// 向上拖 → 输入区变高(delta为负),向下拖 → 变矮
|
|||
|
|
const dy = resizeStartY - e.clientY
|
|||
|
|
const newBoxH = Math.max(80, Math.min(400, resizeStartHeight + dy))
|
|||
|
|
const delta = newBoxH - resizeStartHeight
|
|||
|
|
const newTaH = Math.max(60, Math.min(300, resizeStartTextareaHeight + delta))
|
|||
|
|
textareaHeight.value = newTaH
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 结束拖拽 */
|
|||
|
|
function onResizeEnd(): void {
|
|||
|
|
isResizing.value = false
|
|||
|
|
document.removeEventListener('mousemove', onResizeMove)
|
|||
|
|
document.removeEventListener('mouseup', onResizeEnd)
|
|||
|
|
document.body.style.cursor = ''
|
|||
|
|
document.body.style.userSelect = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 方法
|
|||
|
|
// ============================================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理键盘事件
|
|||
|
|
* Enter 发送消息,Shift+Enter 换行
|
|||
|
|
*/
|
|||
|
|
function handleKeydown(event: KeyboardEvent): void {
|
|||
|
|
// Enter 且没有按 Shift → 发送
|
|||
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|||
|
|
event.preventDefault()
|
|||
|
|
handleSend()
|
|||
|
|
}
|
|||
|
|
// Shift+Enter → 默认换行,不处理
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 发送消息
|
|||
|
|
*/
|
|||
|
|
async function handleSend(): Promise<void> {
|
|||
|
|
const content = inputText.value.trim()
|
|||
|
|
if (!content) return
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
emit('send', content)
|
|||
|
|
inputText.value = ''
|
|||
|
|
// 恢复 textarea 高度(3行默认)
|
|||
|
|
textareaHeight.value = 60
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('发送消息失败:', error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理粘贴事件
|
|||
|
|
* 做什么:检测剪贴板中的图片或文件,自动上传并发送消息
|
|||
|
|
* 为什么:用户需要从剪贴板直接粘贴图片/文件,不用每次点按钮
|
|||
|
|
*
|
|||
|
|
* 支持:
|
|||
|
|
* 1. 粘贴图片(截图工具 Ctrl+V / 复制图片)
|
|||
|
|
* 2. 粘贴文件(从文件管理器复制的文件)
|
|||
|
|
* 3. 纯文本粘贴(默认行为,不拦截)
|
|||
|
|
*/
|
|||
|
|
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
|||
|
|
const items = event.clipboardData?.items
|
|||
|
|
if (!items) return
|
|||
|
|
|
|||
|
|
for (const item of Array.from(items)) {
|
|||
|
|
// 情况1:剪贴板包含文件(图片/任意文件)
|
|||
|
|
if (item.kind === 'file') {
|
|||
|
|
event.preventDefault() // 阻止默认粘贴(文件不插入文本)
|
|||
|
|
const file = item.getAsFile()
|
|||
|
|
if (!file) continue
|
|||
|
|
|
|||
|
|
// 根据文件类型选择上传方式
|
|||
|
|
if (file.type.startsWith('image/')) {
|
|||
|
|
await handleImageUpload(file)
|
|||
|
|
} else {
|
|||
|
|
await handleFileUpload(file)
|
|||
|
|
}
|
|||
|
|
return // 每次粘贴只处理第一个文件
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 情况2:剪贴板包含图片类型数据(如截图工具复制的PNG)
|
|||
|
|
if (item.type.startsWith('image/') && item.kind === 'string') {
|
|||
|
|
// 某些浏览器将截图放在 item.getAsFile() 中,上面 'file' 分支已覆盖
|
|||
|
|
// 这里仅作防御性检查
|
|||
|
|
event.preventDefault()
|
|||
|
|
const file = item.getAsFile()
|
|||
|
|
if (file) {
|
|||
|
|
await handleImageUpload(file)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 纯文本:不拦截,浏览器默认行为(插入文本到输入框)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 上传图片并发送图片消息
|
|||
|
|
*
|
|||
|
|
* @param file - 图片文件对象(File 或 Blob)
|
|||
|
|
*/
|
|||
|
|
async function handleImageUpload(file: File | Blob): Promise<void> {
|
|||
|
|
const convId = conversationStore.currentConversation?.id
|
|||
|
|
if (!convId) {
|
|||
|
|
ElMessage.warning('请先选择一个会话')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
ElMessage.info('图片上传中...')
|
|||
|
|
|
|||
|
|
// 1. 上传图片到服务器
|
|||
|
|
const result = await uploadFile(file)
|
|||
|
|
|
|||
|
|
// 2. 发送图片消息
|
|||
|
|
const newMsg = await sendMessage(convId, '[图片]', 'image', {
|
|||
|
|
media_url: result.url,
|
|||
|
|
file_name: result.filename,
|
|||
|
|
file_size: result.file_size,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 3. 把新消息加入 store,让界面立即刷新显示
|
|||
|
|
conversationStore.messages.push(newMsg)
|
|||
|
|
|
|||
|
|
ElMessage.success('图片发送成功')
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('图片上传失败:', error)
|
|||
|
|
ElMessage.error(
|
|||
|
|
formatErrorDetail(error?.response?.data?.detail) ||
|
|||
|
|
error?.message ||
|
|||
|
|
'图片上传失败,请重试'
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 上传文件并发送文件消息
|
|||
|
|
* 做什么:处理非图片文件的上传和发送
|
|||
|
|
* 为什么:粘贴功能需要支持任意文件类型,不仅限于图片
|
|||
|
|
*
|
|||
|
|
* @param file - 文件对象(File 或 Blob)
|
|||
|
|
*/
|
|||
|
|
async function handleFileUpload(file: File | Blob): Promise<void> {
|
|||
|
|
const convId = conversationStore.currentConversation?.id
|
|||
|
|
if (!convId) {
|
|||
|
|
ElMessage.warning('请先选择一个会话')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const fileName = file instanceof File ? file.name : '文件'
|
|||
|
|
ElMessage.info(`正在上传: ${fileName}`)
|
|||
|
|
|
|||
|
|
// 1. 上传文件到服务器
|
|||
|
|
const result = await uploadFile(file)
|
|||
|
|
|
|||
|
|
// 2. 发送文件消息
|
|||
|
|
const newMsg = await sendMessage(convId, `[文件] ${result.filename}`, 'file', {
|
|||
|
|
media_url: result.url,
|
|||
|
|
file_name: result.filename,
|
|||
|
|
file_size: result.file_size,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 3. 把新消息加入 store,让界面立即刷新显示
|
|||
|
|
conversationStore.messages.push(newMsg)
|
|||
|
|
|
|||
|
|
ElMessage.success('文件发送成功')
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('文件发送失败:', error)
|
|||
|
|
ElMessage.error(
|
|||
|
|
formatErrorDetail(error?.response?.data?.detail) ||
|
|||
|
|
error?.message ||
|
|||
|
|
'文件发送失败,请重试'
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理文件选择上传
|
|||
|
|
* 通过隐藏的 <input type="file"> 触发
|
|||
|
|
*/
|
|||
|
|
async function handleFileSelect(event: Event): Promise<void> {
|
|||
|
|
const input = event.target as HTMLInputElement
|
|||
|
|
const files = input.files
|
|||
|
|
if (!files || files.length === 0) return
|
|||
|
|
|
|||
|
|
const convId = conversationStore.currentConversation?.id
|
|||
|
|
if (!convId) {
|
|||
|
|
ElMessage.warning('请先选择一个会话')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const file of Array.from(files)) {
|
|||
|
|
try {
|
|||
|
|
// 显示上传中提示(仅第一个文件显示)
|
|||
|
|
if (file === files[0]) {
|
|||
|
|
ElMessage.info(`正在上传: ${file.name}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 1. 上传文件到服务器
|
|||
|
|
const result = await uploadFile(file)
|
|||
|
|
|
|||
|
|
// 2. 发送文件消息
|
|||
|
|
const isImage = result.msg_type === 'image'
|
|||
|
|
const newMsg = await sendMessage(
|
|||
|
|
convId,
|
|||
|
|
isImage ? '[图片]' : `[文件] ${file.name}`,
|
|||
|
|
isImage ? 'image' : 'file',
|
|||
|
|
{
|
|||
|
|
media_url: result.url,
|
|||
|
|
file_name: result.filename,
|
|||
|
|
file_size: result.file_size,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 3. 把新消息加入 store,让界面立即刷新显示
|
|||
|
|
conversationStore.messages.push(newMsg)
|
|||
|
|
|
|||
|
|
ElMessage.success(`${file.name} 发送成功`)
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('文件上传失败:', error)
|
|||
|
|
ElMessage.error(
|
|||
|
|
`${file.name} 上传失败: ${
|
|||
|
|
formatErrorDetail(error?.response?.data?.detail) ||
|
|||
|
|
error?.message ||
|
|||
|
|
'请重试'
|
|||
|
|
}`
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重置 input,允许重复选择同一文件
|
|||
|
|
input.value = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 快捷工具栏按钮点击处理
|
|||
|
|
* 各功能对应的实际行为
|
|||
|
|
*/
|
|||
|
|
function handleToolbarClick(action: string): void {
|
|||
|
|
switch (action) {
|
|||
|
|
case 'image':
|
|||
|
|
// 图片上传:触发文件选择器(限定图片类型)
|
|||
|
|
if (fileInputRef.value) {
|
|||
|
|
fileInputRef.value.accept = 'image/*'
|
|||
|
|
fileInputRef.value.multiple = true
|
|||
|
|
fileInputRef.value.click()
|
|||
|
|
}
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
case 'file':
|
|||
|
|
// 文件上传:触发文件选择器(不限类型)
|
|||
|
|
if (fileInputRef.value) {
|
|||
|
|
fileInputRef.value.accept = ''
|
|||
|
|
fileInputRef.value.multiple = true
|
|||
|
|
fileInputRef.value.click()
|
|||
|
|
}
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
case 'quickReply':
|
|||
|
|
// 快速回复按钮:聚焦输入框(快速回复面板已在右栏可用)
|
|||
|
|
inputRef.value?.focus()
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
case 'screenshot':
|
|||
|
|
// 截图功能:截取页面后进入区域选择模式(对标微信/企微)
|
|||
|
|
handleScreenshot()
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
// 其他功能暂未实现
|
|||
|
|
const actionMap: Record<string, string> = {
|
|||
|
|
voice: '语音消息功能开发中',
|
|||
|
|
remote: '远程协助功能开发中',
|
|||
|
|
}
|
|||
|
|
ElMessage.info(actionMap[action] || '功能开发中')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理截图功能
|
|||
|
|
*
|
|||
|
|
* 做什么:使用 html2canvas 截取当前页面内容,弹出预览,确认后上传发送
|
|||
|
|
*
|
|||
|
|
* 为什么用 html2canvas:
|
|||
|
|
* - Screen Capture API(getDisplayMedia)在企微桌面端被限制,不稳定
|
|||
|
|
* - html2canvas 纯前端渲染,不依赖任何浏览器 API,所有环境都可用
|
|||
|
|
* - 适合客服场景:坐席想截取当前对话内容发给用户
|
|||
|
|
*
|
|||
|
|
* 交互流程:点击截图按钮 → 截取页面 → 弹出预览对话框 → 确认发送 / 取消
|
|||
|
|
*/
|
|||
|
|
async function handleScreenshot(): Promise<void> {
|
|||
|
|
const convId = conversationStore.currentConversation?.id
|
|||
|
|
if (!convId) {
|
|||
|
|
ElMessage.warning('请先选择一个会话')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
ElMessage.info('正在截取页面...')
|
|||
|
|
|
|||
|
|
// 1. 截取整个页面
|
|||
|
|
const canvas = await html2canvas(document.body, {
|
|||
|
|
useCORS: true,
|
|||
|
|
scale: window.devicePixelRatio || 1,
|
|||
|
|
logging: false,
|
|||
|
|
backgroundColor: '#ffffff',
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 2. 保存 canvas 并显示截图编辑器(对标微信/企微)
|
|||
|
|
screenshotCanvas = canvas
|
|||
|
|
showScreenshotEditor.value = true
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('截图失败:', error)
|
|||
|
|
ElMessage.error('截图失败,请重试')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 确认发送截图
|
|||
|
|
* 上传截图并发送图片消息
|
|||
|
|
*/
|
|||
|
|
async function onScreenshotConfirm(blob: Blob): Promise<void> {
|
|||
|
|
const convId = conversationStore.currentConversation?.id
|
|||
|
|
if (!convId) return
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
ElMessage.info('截图上传中...')
|
|||
|
|
|
|||
|
|
const result = await uploadFile(blob)
|
|||
|
|
|
|||
|
|
const newMsg = await sendMessage(convId, '[截图]', 'image', {
|
|||
|
|
media_url: result.url,
|
|||
|
|
file_name: result.filename,
|
|||
|
|
file_size: result.file_size,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 把新消息加入 store,让界面立即刷新显示
|
|||
|
|
conversationStore.messages.push(newMsg)
|
|||
|
|
|
|||
|
|
ElMessage.success('截图发送成功')
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('[ReplyBox] 截图发送失败:', error)
|
|||
|
|
const errMsg =
|
|||
|
|
formatErrorDetail(error?.response?.data?.detail) ||
|
|||
|
|
error?.message ||
|
|||
|
|
'未知错误'
|
|||
|
|
ElMessage.error(`截图发送失败:${errMsg}`)
|
|||
|
|
} finally {
|
|||
|
|
showScreenshotEditor.value = false
|
|||
|
|
screenshotCanvas = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 截图编辑器取消回调
|
|||
|
|
*/
|
|||
|
|
function onScreenshotCancel(): void {
|
|||
|
|
showScreenshotEditor.value = false
|
|||
|
|
screenshotCanvas = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 组件卸载时清理拖拽事件
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
document.removeEventListener('mousemove', onResizeMove)
|
|||
|
|
document.removeEventListener('mouseup', onResizeEnd)
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
/* 整体容器 */
|
|||
|
|
.reply-box {
|
|||
|
|
padding: 0 12px 8px 12px;
|
|||
|
|
background: var(--bg-primary);
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 上方拖拽手柄 */
|
|||
|
|
.input-resize-handle {
|
|||
|
|
height: 4px;
|
|||
|
|
cursor: row-resize;
|
|||
|
|
background: var(--border);
|
|||
|
|
transition: background 0.2s;
|
|||
|
|
margin: 0 -12px 0 -12px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.input-resize-handle:hover,
|
|||
|
|
.input-resize-handle.dragging {
|
|||
|
|
background: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 快捷工具栏 */
|
|||
|
|
.chat-toolbar {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 1px;
|
|||
|
|
padding: 8px 6px 4px 6px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tb-btn {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 32px;
|
|||
|
|
height: 28px;
|
|||
|
|
border: none;
|
|||
|
|
background: transparent;
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
transition: all 0.2s;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
.tb-btn:hover {
|
|||
|
|
background: var(--accent-soft);
|
|||
|
|
color: var(--accent);
|
|||
|
|
}
|
|||
|
|
.tb-btn:active {
|
|||
|
|
transform: scale(0.92);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 工具提示气泡 */
|
|||
|
|
.tb-tip {
|
|||
|
|
display: none;
|
|||
|
|
position: absolute;
|
|||
|
|
bottom: 100%;
|
|||
|
|
left: 50%;
|
|||
|
|
transform: translateX(-50%);
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
background: var(--text-primary);
|
|||
|
|
color: var(--bg-secondary);
|
|||
|
|
font-size: 10px;
|
|||
|
|
padding: 3px 8px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
pointer-events: none;
|
|||
|
|
box-shadow: var(--shadow);
|
|||
|
|
}
|
|||
|
|
.tb-tip::after {
|
|||
|
|
content: '';
|
|||
|
|
position: absolute;
|
|||
|
|
top: 100%;
|
|||
|
|
left: 50%;
|
|||
|
|
transform: translateX(-50%);
|
|||
|
|
border: 4px solid transparent;
|
|||
|
|
border-top-color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
.tb-btn:hover .tb-tip {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 工具栏分隔线 */
|
|||
|
|
.tb-sep {
|
|||
|
|
width: 1px;
|
|||
|
|
height: 14px;
|
|||
|
|
background: var(--border);
|
|||
|
|
margin: 0 6px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 输入卡片(textarea + 发送按钮,统一圆角卡片) */
|
|||
|
|
.chat-input-card {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-end;
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border: 1px solid var(--border);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
overflow: hidden;
|
|||
|
|
transition: border-color 0.25s, box-shadow 0.25s;
|
|||
|
|
box-shadow: var(--shadow-sm);
|
|||
|
|
}
|
|||
|
|
.chat-input-card:focus-within {
|
|||
|
|
border-color: var(--accent);
|
|||
|
|
box-shadow: 0 0 0 2px var(--accent-soft);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 输入框 — 默认3行(60px),最大300px,自适应内容高度 */
|
|||
|
|
.chat-input {
|
|||
|
|
flex: 1;
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
padding: 10px 0 10px 14px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-family: inherit;
|
|||
|
|
resize: none;
|
|||
|
|
min-height: 60px;
|
|||
|
|
max-height: 300px;
|
|||
|
|
outline: none;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
.chat-input:focus {
|
|||
|
|
outline: none;
|
|||
|
|
}
|
|||
|
|
.chat-input::placeholder {
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 发送按钮 — 内嵌于卡片右侧,渐变蓝紫 */
|
|||
|
|
.btn-send {
|
|||
|
|
padding: 8px 18px;
|
|||
|
|
margin: 4px 6px 4px 0;
|
|||
|
|
background: linear-gradient(135deg, var(--accent), var(--purple));
|
|||
|
|
color: #fff;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
font-size: 12px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.25s;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
letter-spacing: 0.5px;
|
|||
|
|
}
|
|||
|
|
.btn-send:hover:not(:disabled) {
|
|||
|
|
opacity: 0.9;
|
|||
|
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
|||
|
|
}
|
|||
|
|
.btn-send:active:not(:disabled) {
|
|||
|
|
transform: scale(0.95);
|
|||
|
|
}
|
|||
|
|
.btn-send:disabled {
|
|||
|
|
opacity: 0.5;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 表情选择器弹出层(自定义中文表情网格) */
|
|||
|
|
.emoji-wrapper {
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.emoji-picker-popup {
|
|||
|
|
position: absolute;
|
|||
|
|
bottom: 36px;
|
|||
|
|
left: 0;
|
|||
|
|
z-index: 100;
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border: 1px solid var(--border);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
padding: 8px;
|
|||
|
|
max-height: 240px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 8x8 表情网格 */
|
|||
|
|
.emoji-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(8, 32px);
|
|||
|
|
gap: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.emoji-grid__item {
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
font-size: 18px;
|
|||
|
|
border: none;
|
|||
|
|
background: transparent;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: background 0.15s;
|
|||
|
|
outline: none;
|
|||
|
|
padding: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.emoji-grid__item:hover {
|
|||
|
|
background: var(--accent-soft);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.emoji-grid__item:active {
|
|||
|
|
transform: scale(1.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 表情面板遮罩(点击外部关闭) */
|
|||
|
|
.emoji-picker-overlay {
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
z-index: 99;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ============================================================================
|
|||
|
|
// 引用回复预览样式
|
|||
|
|
// ============================================================================ */
|
|||
|
|
|
|||
|
|
.reply-preview {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 6px 12px;
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
border-bottom: 1px solid var(--border);
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.reply-preview-bar {
|
|||
|
|
width: 3px;
|
|||
|
|
height: 28px;
|
|||
|
|
border-radius: 2px;
|
|||
|
|
background: var(--accent);
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.reply-preview-content {
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 0;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 1px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.reply-preview-sender {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--accent);
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.reply-preview-text {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--text-tertiary);
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.reply-preview-close {
|
|||
|
|
width: 20px;
|
|||
|
|
height: 20px;
|
|||
|
|
border: none;
|
|||
|
|
background: transparent;
|
|||
|
|
color: var(--text-tertiary);
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
transition: all 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.reply-preview-close:hover {
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
</style>
|