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

1010 lines
28 KiB
Vue
Raw Normal View History

<!-- =============================================================================
// 企微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>