Files
wecom_it_smart_desk/frontend-agent/src/components/chat/InputBox.vue
T

1229 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =============================================================================
// 企微IT智能服务台 — 坐席端输入框组件
// =============================================================================
// 说明:坐席端回复消息的输入区域
// 功能:
// - 输入框默认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>