Files
system-file/web-vue/src/components/ChatDialog.vue

732 lines
29 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.

<template>
<el-dialog
v-model="visible"
title="消息"
width="60%"
:close-on-click-modal="false"
class="chat-dialog custom-dialog"
@opened="onDialogOpen"
@closed="onDialogClose"
>
<div class="chat-container">
<!-- 左侧通讯录 + 最近聊天 -->
<div class="chat-sidebar">
<div class="sidebar-tabs">
<div class="sidebar-tab" :class="{ active: sidebarTab === 'recent' }" @click="sidebarTab = 'recent'">
<el-icon><ChatLineRound /></el-icon>
<span>最近</span>
</div>
<div class="sidebar-tab" :class="{ active: sidebarTab === 'contacts' }" @click="sidebarTab = 'contacts'">
<el-icon><User /></el-icon>
<span>通讯录</span>
</div>
</div>
<div class="sidebar-search">
<el-input v-model="searchKeyword" placeholder="搜索用户" clearable>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<!-- 最近聊天列表 -->
<div v-if="sidebarTab === 'recent'" class="sidebar-list">
<div v-for="chat in recentChats" :key="chat.id" class="sidebar-item" :class="{ active: currentContact?.id === chat.id }" @click="selectContact(chat)">
<el-badge :value="chat.unread || 0" :hidden="!chat.unread" :max="99">
<el-avatar :size="36" :src="chat.avatar || undefined" :style="{ background: chat.avatar ? 'transparent' : chat.color }">
{{ chat.name?.[0] || '?' }}
</el-avatar>
</el-badge>
<div class="sidebar-item-info">
<div class="sidebar-item-name">{{ chat.name }}</div>
<div class="sidebar-item-msg">{{ chat.lastMsg || '暂无消息' }}</div>
</div>
<el-button v-if="chat.lastMsg" class="delete-btn" link type="danger" @click.stop="deleteRecentChat(chat)">
<el-icon><Close /></el-icon>
</el-button>
</div>
<div v-if="recentChats.length === 0" class="sidebar-empty">暂无最近聊天</div>
</div>
<!-- 通讯录列表 -->
<div v-else class="sidebar-list">
<div v-for="contact in filteredContacts" :key="contact.id" class="sidebar-item" :class="{ active: currentContact?.id === contact.id }" @click="selectContact(contact)">
<el-avatar :size="36" :src="contact.avatar || undefined" :style="{ background: contact.avatar ? 'transparent' : contact.color }">
{{ contact.name?.[0] || '?' }}
</el-avatar>
<div class="sidebar-item-info">
<div class="sidebar-item-name">{{ contact.nickname || contact.username }}</div>
<div class="sidebar-item-status" :class="{ online: contact.online }">
{{ contact.online ? '在线' : '离线' }}
</div>
</div>
</div>
<div v-if="filteredContacts.length === 0" class="sidebar-empty">暂无联系人</div>
</div>
</div>
<!-- 右侧聊天区域 -->
<div class="chat-main" v-if="currentContact">
<div class="chat-header">
<el-avatar :size="32" :src="currentContact.avatar || undefined" :style="{ background: currentContact.avatar ? 'transparent' : currentContact.color }">
{{ currentContact.name?.[0] || currentContact.username?.[0] || '?' }}
</el-avatar>
<span class="chat-title">{{ currentContact.nickname || currentContact.username || currentContact.name }}</span>
<span class="chat-status" :class="{ online: currentContact.online }">
{{ currentContact.online ? '在线' : '离线' }}
</span>
</div>
<!-- 消息列表 -->
<div class="chat-messages" ref="messagesRef">
<div v-if="loadingMessages" class="messages-loading">
<el-icon class="is-loading"><Loading /></el-icon> 加载中..
</div>
<template v-else>
<div v-for="msg in currentMessages" :key="msg.id" class="message-wrapper" :class="{ self: msg.isSelf }">
<el-popover placement="left" :width="200" trigger="hover">
<template #reference>
<el-avatar
:size="30"
:src="msg.isSelf ? (userStore.avatar || undefined) : (msg.fromAvatar || undefined)"
:style="{ background: msg.isSelf ? (userStore.avatar ? 'transparent' : '#409eff') : (msg.fromAvatar ? 'transparent' : (msg.fromColor || '#909399')) }"
class="msg-avatar"
>
{{ (msg.isSelf ? currentUserName : (msg.fromNickname || msg.fromUsername))?.[0] || '?' }}
</el-avatar>
</template>
<div class="user-info-popover">
<div class="user-info-row">
<span class="user-info-label">账号:</span>
<span class="user-info-value">{{ msg.isSelf ? currentUserName : (msg.fromUsername || '未知') }}</span>
</div>
<div class="user-info-row">
<span class="user-info-label">昵称:</span>
<span class="user-info-value">{{ msg.isSelf ? (currentUserNickname || currentUserName) : (msg.fromNickname || '未知') }}</span>
</div>
<div class="user-info-row signature-row">
<span class="user-info-label">签名:</span>
<div class="user-info-signature">
{{ msg.isSelf ? (userStore.signature || '暂无') : (msg.fromSignature || '暂无') }}
</div>
</div>
</div>
</el-popover>
<div class="message-content">
<div class="message-info">
<span class="sender-name">{{ msg.isSelf ? '' : (msg.fromNickname || msg.fromUsername) }}</span>
<span class="message-time">{{ formatTime(msg.createTime) }}</span>
</div>
<!-- 发送中 -->
<div v-if="msg.sending" class="message-bubble message-sending">
<template v-if="msg.tempType === 'image'">
<div class="uploading-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
<span>图片发送中...</span>
</div>
</template>
<template v-else-if="msg.tempType === 'file'">
<div class="uploading-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
<span>{{ msg.fileName || '文件' }} 发送中...</span>
</div>
</template>
<template v-else>{{ msg.content }}</template>
</div>
<!-- 发送失败 -->
<div v-else-if="msg.failed" class="message-bubble message-failed" @click="retrySend(msg)">
<template v-if="msg.tempType === 'image'">
<div>图片发送失败点击重试</div>
</template>
<template v-else-if="msg.tempType === 'file'">
<div>文件发送失败点击重试</div>
</template>
<template v-else>{{ msg.content }} <span class="failed-hint">(发送失败点击重试)</span></template>
</div>
<!-- 已发送 -->
<template v-else>
<div v-if="msg.type === 'text' || !msg.type" class="message-bubble">{{ msg.content }}</div>
<div v-else-if="msg.type === 'image'" class="message-image">
<el-image :src="getMediaUrl(msg.content)" fit="cover" :preview-src-list="[getMediaUrl(msg.content)]" :preview-teleported="true" />
</div>
<div v-else-if="msg.type === 'file'" class="message-file">
<div class="file-icon" :style="{ background: getFileColor(msg.fileName) }">
<span>{{ getFileExt(msg.fileName) }}</span>
</div>
<div class="file-info">
<div class="file-name" :title="msg.fileName">{{ msg.fileName || '文件' }}</div>
<div class="file-size">{{ formatSize(msg.fileSize) }}</div>
</div>
<el-button link @click="downloadFile(msg)">
<el-icon><Download /></el-icon>
</el-button>
</div>
</template>
</div>
</div>
</template>
</div>
<!-- 工具栏 -->
<div class="chat-toolbar">
<el-tooltip content="发送图片">
<el-button link :disabled="uploading" @click="triggerImageUpload">
<el-icon :size="18"><Picture /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="发送文件">
<el-button link :disabled="uploading" @click="triggerFileUpload">
<el-icon :size="18"><Paperclip /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="表情">
<el-popover placement="top" :width="440" :height="280" trigger="click" v-model:visible="emojiVisible">
<template #reference>
<el-button link style="font-size:18px">😊</el-button>
</template>
<div class="emoji-panel">
<span v-for="emoji in emojis" :key="emoji" class="emoji-item" @click="sendEmoji(emoji)">{{ emoji }}</span>
</div>
</el-popover>
</el-tooltip>
</div>
<!-- 输入框 -->
<div class="chat-input-area">
<el-input v-model="inputText" type="textarea" :rows="4" placeholder="输入消息Enter 发送" resize="none" @keydown.enter.exact.prevent="sendMessage" />
<el-button type="primary" @click="sendMessage" class="send-btn"><el-icon><Promotion /></el-icon>发送</el-button>
</div>
</div>
<!-- 空状态 -->
<div v-else class="chat-empty">
<el-icon :size="48"><ChatDotRound /></el-icon>
<p>选择联系人开始聊天</p>
</div>
</div>
<!-- 隐藏的文件选择器 -->
<input ref="imageInputRef" type="file" accept="image/*" style="display:none" @change="handleImageSelect" />
<input ref="fileInputRef" type="file" style="display:none" @change="handleFileSelect" />
</el-dialog>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { ChatLineRound, User, Search, Close, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import { getUsers, getMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList } from '@/api/message'
import { chatService } from '@/services/chat'
const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue', 'unread-change'])
const userStore = useUserStore()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const sidebarTab = ref('recent')
const searchKeyword = ref('')
const contacts = ref([])
const recentChats = ref([])
const messages = ref({})
const onlineUsers = ref(new Set())
const currentUserName = ref('')
const currentUserNickname = ref('')
const currentContact = ref(null)
const inputText = ref('')
const loadingMessages = ref(false)
const emojiVisible = ref(false)
const messagesRef = ref(null)
const imageInputRef = ref(null)
const fileInputRef = ref(null)
const uploading = ref(false)
let unsubscribeWs = null
let tempMsgId = 0
const emojis = ['😀','😂','😍','🥰','😎','🤔','😅','😭','🎉','👍','❤️','🔥','😱','🙏','💪','😘','😜','😊','😁','😃','😄','😆','😉','😌','😋','😛','😲','😞','😠','😡','😢','😤','😥','😦','😧','😨','😩','😪','😫','😬','😮','😯','😰','😳','😴','😵','😶','😷','😸','😹','😺','😻','😼','😽','😾','😿','🙀','🙁','🙂','🙃','🙄']
const colors = ['#f56c6c', '#e6a23c', '#67c23a', '#409eff', '#909399', '#c71585', '#00bcd4']
const currentMessages = computed(() => {
if (!currentContact.value) return []
return messages.value[currentContact.value.id] || []
})
const filteredContacts = computed(() => {
if (!searchKeyword.value) return contacts.value
const kw = searchKeyword.value.toLowerCase()
return contacts.value.filter(c =>
(c.username || '').toLowerCase().includes(kw) ||
(c.nickname || '').toLowerCase().includes(kw)
)
})
// ======== 工具函数 ========
const getMediaUrl = (url) => url || ''
const formatTime = (date) => {
if (!date) return ''
const d = new Date(date)
const now = new Date()
const diff = now - d
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return d.toLocaleDateString('zh-CN')
}
const formatSize = (size) => {
if (!size) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
return size.toFixed(2) + ' ' + units[i]
}
const getFileExt = (name) => {
if (!name) return '?'
const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : '?'
return ext
}
const getFileColor = (name) => {
if (!name) return '#909399'
const ext = name.split('.').pop().toLowerCase()
const colorMap = {
pdf: '#e74c3c', doc: '#2980b9', docx: '#2980b9', xls: '#27ae60', xlsx: '#27ae60',
ppt: '#e67e22', pptx: '#e67e22', zip: '#8e44ad', rar: '#8e44ad', '7z': '#8e44ad',
txt: '#95a5a6', md: '#95a5a6', csv: '#27ae60', mp3: '#e91e63', mp4: '#9c27b0',
avi: '#9c27b0', mov: '#9c27b0', jpg: '#00bcd4', jpeg: '#00bcd4', png: '#00bcd4',
gif: '#00bcd4', svg: '#00bcd4', webp: '#00bcd4', psd: '#1565c0', ai: '#f57c00'
}
return colorMap[ext] || '#909399'
}
// ======== 联系人 & 消息加载 ========
const loadContacts = async () => {
try {
const res = await getUsers()
contacts.value = (res.data || []).map((u, i) => ({
...u,
id: u.id,
name: u.nickname || u.username,
color: colors[i % colors.length],
online: onlineUsers.value.has(Number(u.id)),
unread: 0,
lastMsg: ''
}))
} catch (e) {
ElMessage.error('加载联系人失败')
}
}
const loadUnreadChats = async () => {
try {
const res = await getUnreadList()
const list = res.data || []
if (list.length === 0) return
// 加载联系人后再填充未读数据
list.forEach(item => {
const contact = contacts.value.find(c => Number(c.id) === Number(item.userId))
if (!contact) {
// 联系人列表中找不到,用未读信息创建
const i = recentChats.value.length
const newContact = {
id: item.userId,
username: item.username || '',
nickname: item.nickname || item.username || '',
avatar: item.avatar || '',
signature: item.signature || '',
name: item.nickname || item.username || '',
color: colors[i % colors.length],
online: false,
unread: item.unread || 0,
lastMsg: item.lastMsg || ''
}
recentChats.value.push(newContact)
} else {
// 已在联系人列表中,更新最近聊天
const chat = recentChats.value.find(c => c.id === contact.id)
if (chat) {
chat.unread = item.unread || 0
chat.lastMsg = item.lastMsg || chat.lastMsg
} else {
contact.unread = item.unread || 0
contact.lastMsg = item.lastMsg || ''
recentChats.value.push({ ...contact })
}
}
})
} catch (e) {
// ignore
}
}
const selectContact = async (contact) => {
currentContact.value = contact
contact.unread = 0
loadingMessages.value = true
try {
const res = await getMessages({ userId: contact.id })
messages.value[contact.id] = (res.data || []).map(msg => {
const isSelf = String(msg.fromUserId) === String(userStore.userId)
return {
...msg,
isSelf,
sending: false,
failed: false,
fromColor: colors[Math.abs(Number(isSelf ? userStore.userId : msg.fromUserId) % colors.length)]
}
})
updateRecentChat(contact, '')
} catch (e) {
ElMessage.error('加载消息失败')
} finally {
loadingMessages.value = false
scrollToBottom()
}
}
const updateRecentChat = (contact, lastMsg) => {
let chat = recentChats.value.find(c => c.id === contact.id)
if (!chat) {
chat = { ...contact }
recentChats.value.unshift(chat)
}
if (lastMsg) chat.lastMsg = lastMsg
chat.unread = 0
}
// ======== 发送消息 ========
const sendMessage = () => {
const text = inputText.value.trim()
if (!text || !currentContact.value) return
inputText.value = ''
// 文本消息不创建临时消息,直接通过 WebSocket 发送,等待回调显示
chatService.send({
type: 'chat',
toUserId: currentContact.value.id,
content: text,
msgType: 'text'
})
}
const sendEmoji = (emoji) => {
inputText.value += emoji
emojiVisible.value = false
}
// ======== 图片 & 文件上传 ========
const triggerImageUpload = () => { if (!uploading.value) imageInputRef.value?.click() }
const triggerFileUpload = () => { if (!uploading.value) fileInputRef.value?.click() }
const pushMessage = (msg) => {
const contactId = msg.isSelf ? msg.toUserId : msg.fromUserId
if (!messages.value[contactId]) messages.value[contactId] = []
messages.value[contactId].push(msg)
}
const handleImageSelect = async (e) => {
const file = e.target.files?.[0]
if (!file || !currentContact.value) return
e.target.value = ''
uploading.value = true
// 先在聊天窗口显示"发送中"
const tempMsg = {
id: ++tempMsgId,
fromUserId: userStore.userId,
toUserId: currentContact.value.id,
content: '',
type: 'image',
tempType: 'image',
isSelf: true,
sending: true,
failed: false,
createTime: new Date(),
fromColor: colors[Math.abs(Number(userStore.userId) % colors.length)]
}
pushMessage(tempMsg)
scrollToBottom()
try {
const formData = new FormData()
formData.append('file', file)
const res = await uploadChatFile(formData)
chatService.send({
type: 'chat',
toUserId: currentContact.value.id,
content: res.url,
msgType: 'image'
})
updateRecentChat(currentContact.value, '[图片]')
} catch (err) {
// 标记失败
const list = messages.value[currentContact.value.id]
if (list) {
const idx = list.findIndex(m => m.id === tempMsg.id)
if (idx > -1) {
list[idx].sending = false
list[idx].failed = true
}
}
ElMessage.error('图片上传失败')
} finally {
uploading.value = false
}
}
const handleFileSelect = async (e) => {
const file = e.target.files?.[0]
if (!file || !currentContact.value) return
e.target.value = ''
uploading.value = true
const tempMsg = {
id: ++tempMsgId,
fromUserId: userStore.userId,
toUserId: currentContact.value.id,
content: '',
type: 'file',
tempType: 'file',
fileName: file.name,
fileSize: file.size,
isSelf: true,
sending: true,
failed: false,
createTime: new Date(),
fromColor: colors[Math.abs(Number(userStore.userId) % colors.length)]
}
pushMessage(tempMsg)
scrollToBottom()
try {
const formData = new FormData()
formData.append('file', file)
const res = await uploadChatFile(formData)
chatService.send({
type: 'chat',
toUserId: currentContact.value.id,
content: res.url,
msgType: 'file',
fileName: file.name,
fileSize: file.size
})
updateRecentChat(currentContact.value, '[文件]')
} catch (err) {
const list = messages.value[currentContact.value.id]
if (list) {
const idx = list.findIndex(m => m.id === tempMsg.id)
if (idx > -1) {
list[idx].sending = false
list[idx].failed = true
}
}
ElMessage.error('文件上传失败')
} finally {
uploading.value = false
}
}
// ======== 文件下载 ========
const downloadFile = async (msg) => {
try {
const res = await fetch(msg.content)
if (!res.ok) throw new Error('下载失败')
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = msg.fileName || '文件'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error('下载失败')
}
}
// ======== 其他操作 ========
const deleteRecentChat = (chat) => {
const idx = recentChats.value.findIndex(c => c.id === chat.id)
if (idx > -1) recentChats.value.splice(idx, 1)
if (currentContact.value?.id === chat.id) currentContact.value = null
}
const scrollToBottom = () => {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
// ======== WebSocket ========
const onDialogOpen = () => {
loadContacts().then(() => loadUnreadChats())
currentUserName.value = userStore.username || ''
currentUserNickname.value = userStore.nickname || ''
chatService.connect()
unsubscribeWs = chatService.onMessage((data) => {
if (data.type === 'chat') {
const msg = data.message
const isSelf = Number(msg.fromUserId) === Number(userStore.userId)
msg.isSelf = isSelf
msg.sending = false
msg.failed = false
msg.fromColor = colors[Math.abs(Number(msg.fromUserId) % colors.length)]
const targetUserId = isSelf ? msg.toUserId : msg.fromUserId
if (!messages.value[targetUserId]) messages.value[targetUserId] = []
const list = messages.value[targetUserId]
// 清除同类型的临时消息sending 状态的)
if (isSelf && (msg.type === 'image' || msg.type === 'file')) {
for (let i = list.length - 1; i >= 0; i--) {
if (list[i].sending && list[i].tempType === msg.type) {
list.splice(i, 1)
break
}
}
}
// 去重
const exists = list.some(m => m.id === msg.id)
if (!exists) list.push(msg)
// 滚动
if (currentContact.value && Number(currentContact.value.id) === Number(targetUserId)) {
nextTick(() => scrollToBottom())
}
// 更新最近聊天
if (!isSelf) {
const contact = contacts.value.find(c => Number(c.id) === Number(msg.fromUserId))
if (contact) {
const lastMsg = msg.type === 'file' ? '[文件]' : (msg.type === 'image' ? '[图片]' : msg.content)
updateRecentChat(contact, lastMsg)
// 如果当前不在这个聊天窗口,增加未读计数
const currentChatId = currentContact.value ? Number(currentContact.value.id) : null
if (currentChatId !== Number(msg.fromUserId)) {
const chat = recentChats.value.find(c => Number(c.id) === Number(msg.fromUserId))
if (chat) chat.unread = (chat.unread || 0) + 1
}
}
}
} else if (data.type === 'online') {
onlineUsers.value = new Set(data.users)
contacts.value.forEach(c => { c.online = onlineUsers.value.has(Number(c.id)) })
recentChats.value.forEach(c => { c.online = onlineUsers.value.has(Number(c.id)) })
}
})
}
const onDialogClose = () => {
currentContact.value = null
inputText.value = ''
if (unsubscribeWs) { unsubscribeWs(); unsubscribeWs = null }
// 通知导航栏刷新未读数
emit('unread-change')
}
onMounted(() => { loadContacts() })
onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
</script>
<style scoped>
.chat-container { display: flex; height: 600px; gap: 12px; }
.chat-sidebar { width: 280px; display: flex; flex-direction: column; border-right: 1px solid #e4e7ed; }
.sidebar-tabs { display: flex; border-bottom: 1px solid #e4e7ed; }
.sidebar-tab { flex: 1; padding: 12px; text-align: center; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; }
.sidebar-tab.active { border-bottom-color: #409eff; color: #409eff; }
.sidebar-search { padding: 8px; }
.sidebar-list { flex: 1; overflow-y: auto; }
.sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; transition: all 0.2s; border-left: 3px solid transparent; }
.sidebar-item:hover { background: #f5f7fa; }
.sidebar-item.active { background: #f0f9ff; border-left-color: #409eff; }
.sidebar-item-info { flex: 1; min-width: 0; }
.sidebar-item-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-item-msg { font-size: 12px; color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-item-status { font-size: 12px; color: #909399; }
.sidebar-item-status.online { color: #67c23a; }
.sidebar-empty { padding: 20px; text-align: center; color: #909399; font-size: 12px; }
.delete-btn { opacity: 0; transition: opacity 0.2s; }
.sidebar-item:hover .delete-btn { opacity: 1; }
.chat-main { flex: 1; display: flex; flex-direction: column; }
.chat-header { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid #e4e7ed; }
.chat-title { flex: 1; font-weight: 500; font-size: 14px; }
.chat-status { font-size: 12px; color: #909399; }
.chat-status.online { color: #67c23a; }
.chat-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; }
.messages-loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #909399; }
.message-wrapper { display: flex; gap: 8px; align-items: flex-end; }
.message-wrapper.self { flex-direction: row-reverse; }
.msg-avatar { flex-shrink: 0; cursor: pointer; }
.message-content { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.message-wrapper.self .message-content { align-items: flex-end; }
.message-info { display: flex; gap: 8px; font-size: 12px; color: #909399; }
.sender-name { font-weight: 500; }
.message-bubble { background: #f0f0f0; padding: 8px 12px; border-radius: 8px; word-break: break-word; max-width: 300px; font-size: 13px; }
.message-wrapper.self .message-bubble { background: #409eff; color: white; }
/* 发送中 */
.message-sending { opacity: 0.6; }
.uploading-placeholder { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #909399; }
/* 发送失败 */
.message-failed { background: #fef0f0 !important; color: #f56c6c !important; cursor: pointer; }
.message-failed:hover { background: #fde2e2 !important; }
.failed-hint { font-size: 11px; opacity: 0.8; }
/* 图片消息 */
.message-image { max-width: 200px; }
.message-image :deep(.el-image) { border-radius: 8px; overflow: hidden; }
.message-image :deep(.el-image img) { display: block; }
/* 文件消息 */
.message-file { display: flex; align-items: center; gap: 10px; background: #f8f9fa; padding: 10px 12px; border-radius: 8px; max-width: 260px; border: 1px solid #ebeef5; }
.message-wrapper.self .message-file { background: #ecf5ff; border-color: #b3d8ff; }
.file-icon { width: 36px; height: 36px; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.file-icon span { font-size: 10px; font-weight: 700; color: white; letter-spacing: 0.5px; }
.file-info { flex: 1; min-width: 0; }
.file-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #303133; }
.file-size { font-size: 11px; color: #909399; margin-top: 2px; }
.message-emoji { font-size: 32px; }
.chat-toolbar { display: flex; gap: 8px; padding: 8px; border-top: 1px solid #e4e7ed; }
.chat-input-area { display: flex; flex-direction: column; gap: 8px; padding: 8px; border-top: 1px solid #e4e7ed; }
.chat-input-area :deep(.el-textarea) { flex: 1; }
.send-btn { align-self: flex-end; }
.emoji-panel { display: grid; grid-template-columns: repeat(10, 1fr); gap: 6px; padding: 4px; }
.emoji-item { font-size: 20px; cursor: pointer; text-align: center; padding: 4px; border-radius: 4px; transition: all 0.2s; }
.emoji-item:hover { background: #f0f0f0; transform: scale(1.2); }
.uploading-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 20px; color: #909399; }
.uploading-placeholder .el-icon { font-size: 32px; color: #409eff; }
.uploading-placeholder span { font-size: 12px; }
.user-info-popover { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; }
.user-info-row { display: flex; gap: 8px; font-size: 12px; line-height: 1.5; }
.user-info-label { font-weight: 500; min-width: 50px; color: #303133; }
.user-info-value { color: #606266; word-break: break-all; flex: 1; }
.signature-row { flex-direction: column; align-items: flex-start; }
.user-info-signature { color: #606266; font-size: 12px; line-height: 1.4; word-break: break-all; margin-top: 4px; }
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #909399; gap: 12px; }
.chat-empty p { margin: 0; font-size: 14px; }
.chat-dialog :deep(.el-dialog) { height: 720px; margin: auto !important; top: 50% !important; transform: translateY(-50%) !important; }
</style>