chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,983 @@
|
||||
<!-- =============================================================================
|
||||
// 企微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>
|
||||
Reference in New Issue
Block a user