1229 lines
32 KiB
Vue
1229 lines
32 KiB
Vue
<!-- =============================================================================
|
||
// 企微IT智能服务台 — 坐席端输入框组件
|
||
// =============================================================================
|
||
// 说明:坐席端回复消息的输入区域
|
||
// 功能:
|
||
// - 输入框默认3行高度,自动扩展(max-height: 150px)
|
||
// - 底部显示字数统计(当前/最大,如:120/500)
|
||
// - 右下角发送按钮(icon)
|
||
// - Enter键发送,Shift+Enter换行
|
||
// - 空内容时禁用发送按钮
|
||
// - 支持粘贴图片/文件上传
|
||
// - 快捷工具栏(表情/图片/截图/文件/语音/快速回复)
|
||
// ============================================================================= -->
|
||
|
||
<template>
|
||
<div class="input-box" ref="replyBoxRef">
|
||
<!-- 引用回复预览(回复某条消息时显示) -->
|
||
<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">
|
||
<!-- 截图功能 - 放在最前面 -->
|
||
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
|
||
📷
|
||
<span class="tb-tip">截图</span>
|
||
</button>
|
||
|
||
<div class="emoji-wrapper">
|
||
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
|
||
😊
|
||
<span class="tb-tip">表情</span>
|
||
</button>
|
||
<!-- 自定义中文表情选择面板 -->
|
||
<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('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="handleToolbarClick('quickReply')">
|
||
⚡
|
||
<span class="tb-tip">快速回复</span>
|
||
</button>
|
||
<button class="tb-btn" title="发名片" @click="handleToolbarClick('contactCard')">
|
||
👤
|
||
<span class="tb-tip">发名片</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 输入行 -->
|
||
<div class="chat-input-card">
|
||
<textarea
|
||
ref="inputRef"
|
||
v-model="inputText"
|
||
class="chat-input"
|
||
placeholder="输入回复内容... (Enter发送,Shift+Enter换行)"
|
||
:style="{ height: textareaHeight + 'px' }"
|
||
:disabled="!conversationStore.currentConversation"
|
||
@keydown="handleKeydown"
|
||
@input="handleInput"
|
||
@paste="handlePaste"
|
||
></textarea>
|
||
|
||
<!-- 发送按钮 — icon样式 -->
|
||
<button
|
||
class="btn-send"
|
||
:class="{ 'btn-send--active': canSend }"
|
||
:disabled="!canSend"
|
||
@click="handleSend"
|
||
>
|
||
<svg v-if="!conversationStore.loadingMessages" class="send-icon" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M2.01 21L23 12 2.01 3 2 10l15-2-15-2z"/>
|
||
</svg>
|
||
<van-loading v-else size="16px" color="#fff" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 字数统计 -->
|
||
<div class="input-box__counter">
|
||
{{ charCount }}/{{ maxChars }}
|
||
</div>
|
||
|
||
<!-- 邀请弹窗 -->
|
||
<InviteParticipantDialog
|
||
v-model="showInviteDialog"
|
||
:conversation-id="conversationStore.currentConversation?.id || ''"
|
||
:existing-participant-ids="existingParticipantIds"
|
||
@success="onInviteSuccess"
|
||
/>
|
||
|
||
<!-- 发名片弹窗 -->
|
||
<el-dialog
|
||
v-model="showContactCardDialog"
|
||
title="发送业务联系人名片"
|
||
width="460px"
|
||
:close-on-click-modal="false"
|
||
append-to-body
|
||
>
|
||
<div class="contact-picker">
|
||
<!-- 搜索框 -->
|
||
<el-input
|
||
v-model="contactSearchKeyword"
|
||
placeholder="搜索姓名/部门..."
|
||
:prefix-icon="SearchIcon"
|
||
clearable
|
||
size="default"
|
||
style="margin-bottom: 12px"
|
||
@input="onContactSearchInput"
|
||
/>
|
||
|
||
<!-- 业务类别筛选 -->
|
||
<div class="contact-picker__categories">
|
||
<el-tag
|
||
v-for="cat in businessCategories"
|
||
:key="cat"
|
||
:type="contactFilterCategory === cat ? '' : 'info'"
|
||
:effect="contactFilterCategory === cat ? 'dark' : 'plain'"
|
||
size="small"
|
||
class="contact-picker__category-tag"
|
||
@click="onCategoryFilter(cat)"
|
||
>
|
||
{{ cat }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<!-- 联系人列表 -->
|
||
<div class="contact-picker__list">
|
||
<div v-if="contactLoading" class="contact-picker__loading">
|
||
<el-icon class="is-loading"><Loading /></el-icon>
|
||
<span>加载中...</span>
|
||
</div>
|
||
<div v-else-if="filteredContacts.length === 0" class="contact-picker__empty">
|
||
暂无联系人
|
||
</div>
|
||
<div
|
||
v-else
|
||
v-for="contact in filteredContacts"
|
||
:key="contact.id"
|
||
class="contact-picker__item"
|
||
:class="{ 'contact-picker__item--selected': selectedContactId === contact.id }"
|
||
@click="selectedContactId = contact.id"
|
||
>
|
||
<div class="contact-picker__item-avatar" :class="getAvatarGradient(contact.name)">
|
||
{{ contact.name.charAt(0) }}
|
||
</div>
|
||
<div class="contact-picker__item-info">
|
||
<div class="contact-picker__item-name">
|
||
{{ contact.name }}
|
||
<span class="contact-picker__item-cat">{{ contact.business_category }}</span>
|
||
</div>
|
||
<div class="contact-picker__item-dept">{{ contact.department }} · {{ contact.position }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 推荐原因输入 -->
|
||
<el-input
|
||
v-model="contactCardReason"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="推荐原因(可选,如:员工咨询报销问题,为您推荐财务联系人)"
|
||
style="margin-top: 12px"
|
||
/>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<el-button @click="showContactCardDialog = false">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:disabled="!selectedContactId || sendingContactCard"
|
||
:loading="sendingContactCard"
|
||
@click="handleSendContactCard"
|
||
>
|
||
发送名片
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 隐藏的文件输入框 -->
|
||
<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">
|
||
/**
|
||
* InputBox 坐席端输入框组件
|
||
* 输入框默认3行高度,自动扩展
|
||
* 底部显示字数统计,右下角发送按钮
|
||
* Enter发送,Shift+Enter换行
|
||
*/
|
||
import { ref, computed, watch, nextTick } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Search, Loading } from '@element-plus/icons-vue'
|
||
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 { getContacts, sendContactCard } from '@/api/routing'
|
||
import type { BusinessContactInfo } from '@/api/routing'
|
||
|
||
// Element Plus 图标组件引用(模板中通过 :prefix-icon 和 <Loading /> 使用)
|
||
const SearchIcon = Search
|
||
|
||
// ============================================================================
|
||
// 工具函数
|
||
// ============================================================================
|
||
function formatErrorDetail(detail: any): string {
|
||
if (!detail) return ''
|
||
if (typeof detail === 'string') return detail
|
||
if (Array.isArray(detail)) {
|
||
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 {
|
||
/** 引用回复:正在回复的消息 */
|
||
replyToMessage?: Message | null
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'send', content: string): void
|
||
(e: 'cancelReply'): void
|
||
}
|
||
|
||
withDefaults(defineProps<Props>(), {
|
||
replyToMessage: null,
|
||
})
|
||
|
||
const emit = defineEmits<Emits>()
|
||
|
||
// ============================================================================
|
||
// 状态
|
||
// ============================================================================
|
||
const conversationStore = useConversationStore()
|
||
|
||
/** 输入框文本 */
|
||
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)
|
||
|
||
/** textarea 高度 */
|
||
const textareaHeight = ref(60)
|
||
|
||
/** 最大字符数 */
|
||
const maxChars = 500
|
||
|
||
/** 是否显示表情面板 */
|
||
const showEmojiPicker = ref(false)
|
||
|
||
/** 邀请弹窗是否可见 */
|
||
const showInviteDialog = ref(false)
|
||
|
||
/** 发名片弹窗是否可见 */
|
||
const showContactCardDialog = ref(false)
|
||
|
||
/** 联系人搜索关键词 */
|
||
const contactSearchKeyword = ref('')
|
||
|
||
/** 联系人筛选业务类别(空字符串表示全部) */
|
||
const contactFilterCategory = ref('')
|
||
|
||
/** 联系人列表(从后端加载) */
|
||
const contacts = ref<BusinessContactInfo[]>([])
|
||
|
||
/** 联系人列表加载中 */
|
||
const contactLoading = ref(false)
|
||
|
||
/** 选中的联系人ID */
|
||
const selectedContactId = ref<number | null>(null)
|
||
|
||
/** 推荐原因 */
|
||
const contactCardReason = ref('')
|
||
|
||
/** 名片发送中 */
|
||
const sendingContactCard = ref(false)
|
||
|
||
/** 截图编辑器是否可见 */
|
||
const showScreenshotEditor = ref(false)
|
||
|
||
/** html2canvas 生成的截图 Canvas */
|
||
let screenshotCanvas: HTMLCanvasElement | null = null
|
||
|
||
/** 常用表情列表 */
|
||
const commonEmojis = [
|
||
'😀','😃','😄','😁','😆','😅','🤣','😂',
|
||
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
|
||
'😚','😙','😋','😛','😜','🤪','😝','🤑',
|
||
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
|
||
'😶','😏','😒','🙄','😬','😮','🤯','😲',
|
||
'😳','🥺','😢','😭','😤','😠','😡','🤬',
|
||
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
|
||
'❤️','🧡','💛','💚','💙','💜','💯','✅',
|
||
]
|
||
|
||
/** 当前字符数 */
|
||
const charCount = computed(() => inputText.value.length)
|
||
|
||
/** 是否可以发送 */
|
||
const canSend = computed(() => {
|
||
return inputText.value.trim().length > 0 && !conversationStore.loadingMessages && charCount.value <= maxChars
|
||
})
|
||
|
||
/** 已在参与者列表中的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
|
||
})
|
||
|
||
/** 业务类别列表(从联系人数据中动态提取,去重) */
|
||
const businessCategories = computed(() => {
|
||
const categories = contacts.value.map(c => c.business_category)
|
||
return Array.from(new Set(categories))
|
||
})
|
||
|
||
/** 过滤后的联系人列表(按搜索关键词 + 业务类别筛选) */
|
||
const filteredContacts = computed(() => {
|
||
let result = contacts.value
|
||
|
||
// 按业务类别筛选
|
||
if (contactFilterCategory.value) {
|
||
result = result.filter(c => c.business_category === contactFilterCategory.value)
|
||
}
|
||
|
||
// 按搜索关键词筛选(姓名/部门)
|
||
const keyword = contactSearchKeyword.value.trim().toLowerCase()
|
||
if (keyword) {
|
||
result = result.filter(c =>
|
||
c.name.toLowerCase().includes(keyword) ||
|
||
c.department.toLowerCase().includes(keyword)
|
||
)
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
// ============================================================================
|
||
// 监听
|
||
// ============================================================================
|
||
watch(
|
||
() => conversationStore.pendingReplyText,
|
||
(newVal) => {
|
||
if (newVal) {
|
||
inputText.value = newVal
|
||
conversationStore.pendingReplyText = ''
|
||
nextTick(() => {
|
||
inputRef.value?.focus()
|
||
})
|
||
}
|
||
}
|
||
)
|
||
|
||
// ============================================================================
|
||
// 输入框高度自适应
|
||
// ============================================================================
|
||
function handleInput(): void {
|
||
nextTick(() => {
|
||
if (inputRef.value) {
|
||
const scrollHeight = inputRef.value.scrollHeight
|
||
const newHeight = Math.min(Math.max(scrollHeight, 60), 150)
|
||
textareaHeight.value = newHeight
|
||
}
|
||
})
|
||
}
|
||
|
||
// ============================================================================
|
||
// 键盘事件
|
||
// ============================================================================
|
||
function handleKeydown(event: KeyboardEvent): void {
|
||
if (event.key === 'Enter' && !event.shiftKey) {
|
||
event.preventDefault()
|
||
handleSend()
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 发送消息
|
||
// ============================================================================
|
||
async function handleSend(): Promise<void> {
|
||
const content = inputText.value.trim()
|
||
if (!content || conversationStore.loadingMessages) return
|
||
|
||
try {
|
||
emit('send', content)
|
||
inputText.value = ''
|
||
textareaHeight.value = 60
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error)
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// 粘贴事件
|
||
// ============================================================================
|
||
/**
|
||
* 处理粘贴事件
|
||
* 做什么:检测剪贴板中的图片或文件,自动上传并发送消息
|
||
* 支持:
|
||
* 1. 粘贴图片(截图工具 Ctrl+V / 复制图片)
|
||
* 2. 粘贴文件(从文件管理器/文档复制的文件)
|
||
* 3. 纯文本粘贴(默认行为,不拦截)
|
||
*
|
||
* 修复记录:
|
||
* - v1.x: 修复纯文本粘贴不生效问题 - 确保 clipboardData 为空时也允许默认行为
|
||
*/
|
||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||
// 获取剪贴板数据,如果不存在则允许浏览器默认处理
|
||
const clipboardData = event.clipboardData
|
||
if (!clipboardData) {
|
||
// clipboardData 为空时,不阻止默认行为,让浏览器处理纯文本粘贴
|
||
return
|
||
}
|
||
|
||
const items = clipboardData.items
|
||
|
||
// 如果 items 为空(某些浏览器安全限制),允许默认处理
|
||
if (!items || items.length === 0) {
|
||
return
|
||
}
|
||
|
||
for (const item of Array.from(items)) {
|
||
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
|
||
}
|
||
}
|
||
|
||
// 循环结束且没有拦截:说明是纯文本或其他不支持的类型
|
||
// 不调用 preventDefault,让浏览器执行默认粘贴行为(插入文本到输入框)
|
||
}
|
||
|
||
// ============================================================================
|
||
// 文件上传
|
||
// ============================================================================
|
||
async function handleImageUpload(file: File | Blob): Promise<void> {
|
||
const convId = conversationStore.currentConversation?.id
|
||
if (!convId) {
|
||
ElMessage.warning('请先选择一个会话')
|
||
return
|
||
}
|
||
|
||
try {
|
||
ElMessage.info('图片上传中...')
|
||
const result = await uploadFile(file)
|
||
const newMsg = await sendMessage(convId, '[图片]', 'image', {
|
||
media_url: result.url,
|
||
file_name: result.filename,
|
||
file_size: result.file_size,
|
||
})
|
||
conversationStore.messages.push(newMsg)
|
||
ElMessage.success('图片发送成功')
|
||
} catch (error: any) {
|
||
console.error('图片上传失败:', error)
|
||
ElMessage.error(
|
||
formatErrorDetail(error?.response?.data?.detail) ||
|
||
error?.message ||
|
||
'图片上传失败,请重试'
|
||
)
|
||
}
|
||
}
|
||
|
||
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}`)
|
||
const result = await uploadFile(file)
|
||
const newMsg = await sendMessage(convId, `[文件] ${result.filename}`, 'file', {
|
||
media_url: result.url,
|
||
file_name: result.filename,
|
||
file_size: result.file_size,
|
||
})
|
||
conversationStore.messages.push(newMsg)
|
||
ElMessage.success('文件发送成功')
|
||
} catch (error: any) {
|
||
console.error('文件发送失败:', error)
|
||
ElMessage.error(
|
||
formatErrorDetail(error?.response?.data?.detail) ||
|
||
error?.message ||
|
||
'文件发送失败,请重试'
|
||
)
|
||
}
|
||
}
|
||
|
||
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}`)
|
||
}
|
||
const result = await uploadFile(file)
|
||
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,
|
||
}
|
||
)
|
||
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.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
|
||
|
||
case 'contactCard':
|
||
handleOpenContactCardDialog()
|
||
break
|
||
|
||
default:
|
||
const actionMap: Record<string, string> = {
|
||
voice: '语音消息功能开发中',
|
||
}
|
||
ElMessage.info(actionMap[action] || '功能开发中')
|
||
}
|
||
}
|
||
|
||
async function handleScreenshot(): Promise<void> {
|
||
const convId = conversationStore.currentConversation?.id
|
||
if (!convId) {
|
||
ElMessage.warning('请先选择一个会话')
|
||
return
|
||
}
|
||
|
||
try {
|
||
ElMessage.info('正在截取页面...')
|
||
const canvas = await html2canvas(document.body, {
|
||
useCORS: true,
|
||
scale: window.devicePixelRatio || 1,
|
||
logging: false,
|
||
backgroundColor: '#ffffff',
|
||
})
|
||
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,
|
||
})
|
||
conversationStore.messages.push(newMsg)
|
||
ElMessage.success('截图发送成功')
|
||
} catch (error: any) {
|
||
console.error('[InputBox] 截图发送失败:', error)
|
||
ElMessage.error(
|
||
`截图发送失败:${
|
||
formatErrorDetail(error?.response?.data?.detail) ||
|
||
error?.message ||
|
||
'未知错误'
|
||
}`
|
||
)
|
||
} finally {
|
||
showScreenshotEditor.value = false
|
||
screenshotCanvas = null
|
||
}
|
||
}
|
||
|
||
function onScreenshotCancel(): void {
|
||
showScreenshotEditor.value = false
|
||
screenshotCanvas = null
|
||
}
|
||
|
||
// ============================================================================
|
||
// 发名片功能
|
||
// ============================================================================
|
||
|
||
/**
|
||
* 打开发名片弹窗,加载联系人列表。
|
||
* 每次打开都重新拉取最新联系人数据。
|
||
*/
|
||
async function handleOpenContactCardDialog(): Promise<void> {
|
||
const convId = conversationStore.currentConversation?.id
|
||
if (!convId) {
|
||
ElMessage.warning('请先选择一个会话')
|
||
return
|
||
}
|
||
|
||
// 重置状态
|
||
showContactCardDialog.value = true
|
||
contactSearchKeyword.value = ''
|
||
contactFilterCategory.value = ''
|
||
selectedContactId.value = null
|
||
contactCardReason.value = ''
|
||
contactLoading.value = true
|
||
|
||
try {
|
||
const data = await getContacts()
|
||
contacts.value = data.items || []
|
||
} catch (error: any) {
|
||
console.error('[InputBox] 加载联系人列表失败:', error)
|
||
ElMessage.error(
|
||
formatErrorDetail(error?.response?.data?.detail) ||
|
||
error?.message ||
|
||
'加载联系人列表失败'
|
||
)
|
||
contacts.value = []
|
||
} finally {
|
||
contactLoading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 联系人搜索输入事件。
|
||
* 使用 computed filteredContacts 自动响应,此处仅用于触发 UI 反馈。
|
||
*/
|
||
function onContactSearchInput(): void {
|
||
// filteredContacts 是 computed,自动响应 contactSearchKeyword 变化
|
||
// 此函数保留用于后续可能的防抖优化
|
||
}
|
||
|
||
/**
|
||
* 切换业务类别筛选。
|
||
* 点击已选中的类别时取消筛选(设为空字符串)。
|
||
*/
|
||
function onCategoryFilter(cat: string): void {
|
||
if (contactFilterCategory.value === cat) {
|
||
contactFilterCategory.value = ''
|
||
} else {
|
||
contactFilterCategory.value = cat
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据姓名首字返回头像渐变色块样式类。
|
||
* 与 ContactCardInline.vue 中的 avatarGradient 逻辑一致。
|
||
*/
|
||
function getAvatarGradient(name: string): string {
|
||
const gradients = [
|
||
'gradient-1', 'gradient-2', 'gradient-3', 'gradient-4', 'gradient-5',
|
||
]
|
||
if (!name) return gradients[0]
|
||
const charCode = name.charCodeAt(0)
|
||
return gradients[charCode % gradients.length]
|
||
}
|
||
|
||
/**
|
||
* 发送名片卡片到当前会话。
|
||
* 调用后端 POST /conversations/{id}/send-contact-card 接口,
|
||
* 后端创建 contact_card 消息并 WS 双通道推送。
|
||
*/
|
||
async function handleSendContactCard(): Promise<void> {
|
||
if (selectedContactId.value === null) {
|
||
ElMessage.warning('请先选择一位联系人')
|
||
return
|
||
}
|
||
|
||
const convId = conversationStore.currentConversation?.id
|
||
if (!convId) {
|
||
ElMessage.warning('请先选择一个会话')
|
||
return
|
||
}
|
||
|
||
sendingContactCard.value = true
|
||
try {
|
||
const result = await sendContactCard(
|
||
convId,
|
||
selectedContactId.value,
|
||
contactCardReason.value.trim() || undefined
|
||
)
|
||
ElMessage.success(`已发送${result.business_category}联系人名片:${result.contact_name}`)
|
||
showContactCardDialog.value = false
|
||
} catch (error: any) {
|
||
console.error('[InputBox] 发送名片失败:', error)
|
||
ElMessage.error(
|
||
formatErrorDetail(error?.response?.data?.detail) ||
|
||
error?.message ||
|
||
'发送名片失败,请重试'
|
||
)
|
||
} finally {
|
||
sendingContactCard.value = false
|
||
}
|
||
}
|
||
|
||
function onEmojiSelect(emoji: string): void {
|
||
inputText.value += emoji
|
||
showEmojiPicker.value = false
|
||
nextTick(() => {
|
||
inputRef.value?.focus()
|
||
})
|
||
}
|
||
|
||
function onInviteSuccess(): void {
|
||
conversationStore.fetchConversations()
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 整体容器 */
|
||
.input-box {
|
||
padding: 0 12px 8px 12px;
|
||
background: var(--bg-primary);
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
}
|
||
|
||
/* 快捷工具栏 */
|
||
.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-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;
|
||
}
|
||
|
||
.tb-btn:hover .tb-tip {
|
||
display: block;
|
||
}
|
||
|
||
.tb-sep {
|
||
width: 1px;
|
||
height: 14px;
|
||
background: var(--border);
|
||
margin: 0 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 输入卡片 */
|
||
.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;
|
||
}
|
||
|
||
.chat-input-card:focus-within {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||
}
|
||
|
||
/* 输入框 */
|
||
.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: 150px;
|
||
outline: none;
|
||
line-height: 1.5;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.chat-input:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.chat-input::placeholder {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* 发送按钮 */
|
||
.btn-send {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: none;
|
||
background: var(--bg-tertiary);
|
||
border-radius: 50%;
|
||
cursor: not-allowed;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 4px 6px 4px 0;
|
||
flex-shrink: 0;
|
||
transition: all 0.25s;
|
||
padding: 0;
|
||
}
|
||
|
||
.btn-send--active {
|
||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn-send--active:hover {
|
||
opacity: 0.9;
|
||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||
}
|
||
|
||
.send-icon {
|
||
width: 18px;
|
||
height: 18px;
|
||
color: var(--text-placeholder);
|
||
}
|
||
|
||
.btn-send--active .send-icon {
|
||
color: #fff;
|
||
}
|
||
|
||
/* 字数统计 */
|
||
.input-box__counter {
|
||
text-align: right;
|
||
font-size: 11px;
|
||
color: var(--text-placeholder);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 表情选择器 */
|
||
.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);
|
||
}
|
||
|
||
.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-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);
|
||
}
|
||
|
||
/* ============================================================================
|
||
// 发名片弹窗 — 联系人选择面板
|
||
// ============================================================================ */
|
||
.contact-picker {
|
||
max-height: 400px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* 业务类别筛选标签 */
|
||
.contact-picker__categories {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.contact-picker__category-tag {
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.contact-picker__category-tag:hover {
|
||
opacity: 0.85;
|
||
}
|
||
|
||
/* 联系人列表 */
|
||
.contact-picker__list {
|
||
max-height: 220px;
|
||
overflow-y: auto;
|
||
border: 1px solid var(--border-color, #e4e7ed);
|
||
border-radius: 6px;
|
||
background: var(--bg-primary, #fff);
|
||
}
|
||
|
||
/* 加载中 */
|
||
.contact-picker__loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 40px 0;
|
||
font-size: 13px;
|
||
color: var(--text-tertiary, #909399);
|
||
}
|
||
|
||
/* 空状态 */
|
||
.contact-picker__empty {
|
||
text-align: center;
|
||
padding: 40px 0;
|
||
font-size: 13px;
|
||
color: var(--text-tertiary, #909399);
|
||
}
|
||
|
||
/* 联系人列表项 */
|
||
.contact-picker__item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||
}
|
||
|
||
.contact-picker__item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.contact-picker__item:hover {
|
||
background: var(--accent-soft, #f5f7fa);
|
||
}
|
||
|
||
.contact-picker__item--selected {
|
||
background: var(--accent-soft, #ecf5ff);
|
||
}
|
||
|
||
.contact-picker__item--selected::after {
|
||
content: '✓';
|
||
margin-left: auto;
|
||
color: var(--accent, #409eff);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 联系人头像 */
|
||
.contact-picker__item-avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 6px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.gradient-1 { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||
.gradient-2 { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||
.gradient-3 { background: linear-gradient(135deg, #4facfe, #00f2fe); }
|
||
.gradient-4 { background: linear-gradient(135deg, #43e97b, #38f9d7); }
|
||
.gradient-5 { background: linear-gradient(135deg, #fa709a, #fee140); }
|
||
|
||
/* 联系人信息 */
|
||
.contact-picker__item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.contact-picker__item-name {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-primary, #303133);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.contact-picker__item-cat {
|
||
font-size: 11px;
|
||
color: var(--text-tertiary, #909399);
|
||
background: var(--bg-tertiary, #f0f0f0);
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.contact-picker__item-dept {
|
||
font-size: 12px;
|
||
color: var(--text-tertiary, #909399);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
</style> |