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

1010 lines
28 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智能服务台 — 回复输入框组件(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 APIgetDisplayMedia)在企微桌面端被限制,不稳定
* - 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>