1010 lines
28 KiB
Vue
1010 lines
28 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. 纯文本粘贴(默认行为,不拦截)
|
||
*
|
||
* 修复记录:
|
||
* - v5.4: 扩展支持文档复制的图片(application/octet-stream等非标准MIME类型)
|
||
*/
|
||
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
|
||
|
||
// 根据文件类型选择上传方式
|
||
// 支持:标准image/*、文档复制的图片(可能是application/octet-stream或空类型但有文件扩展名)
|
||
const isImage = file.type.startsWith('image/') ||
|
||
file.name.match(/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff?)$/i)
|
||
|
||
if (isImage) {
|
||
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
|
||
}
|
||
}
|
||
|
||
// 情况3:文档/文件夹复制的项目(kind='string' 或 MIME类型未知但有文件)
|
||
// 从Word/Excel/PDF等复制的图片,MIME可能是空的或非标准类型
|
||
if (item.kind === 'string') {
|
||
event.preventDefault()
|
||
// 尝试作为文件获取
|
||
const file = item.getAsFile()
|
||
if (file) {
|
||
// 检查是否是图片(按扩展名或MIME)
|
||
const isImage = file.type.startsWith('image/') ||
|
||
file.name.match(/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff?)$/i)
|
||
if (isImage) {
|
||
await handleImageUpload(file)
|
||
} else {
|
||
await handleFileUpload(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>
|