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

787 lines
31 KiB
Vue
Raw Normal View History

<template>
2026-04-01 22:39:11 +08:00
<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-popover placement="left" :width="120" trigger="click">
<template #reference>
<span class="chat-action-btn" title="删除会话">
<el-icon><MoreFilled /></el-icon>
</span>
</template>
<div class="chat-action-list" @click="deleteRecentChat(chat)">
<el-icon><Delete /></el-icon>
删除会话
</div>
</el-popover>
2026-04-01 22:39:11 +08:00
</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-mini">
2026-04-01 22:39:11 +08:00
<el-icon class="is-loading"><Loading /></el-icon>
<span>发送中</span>
2026-04-01 22:39:11 +08:00
</div>
</template>
<template v-else-if="msg.tempType === 'file'">
<div class="uploading-mini">
2026-04-01 22:39:11 +08:00
<el-icon class="is-loading"><Loading /></el-icon>
<span>发送中</span>
2026-04-01 22:39:11 +08:00
</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">
2026-04-01 22:39:11 +08:00
<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, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion, MoreFilled, Delete } from '@element-plus/icons-vue'
2026-04-01 22:39:11 +08:00
import { useUserStore } from '@/store/user'
import { getUsers, getMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList, deleteConversation } from '@/api/message'
2026-04-01 22:39:11 +08:00
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) => {
// 确定消息应该放在哪个联系人下
let contactId
if (msg.isSelf) {
// 自己发送的消息放到接收方toUserId
contactId = msg.toUserId
} else {
// 收到的消息放到发送方fromUserId
contactId = msg.fromUserId
}
2026-04-01 22:39:11 +08:00
if (!messages.value[contactId]) messages.value[contactId] = []
// 使用唯一 key 避免重复
const msgKey = msg.tempType ? `temp_${msg.id}` : msg.id
const exists = messages.value[contactId].some(m => (m.tempType ? `temp_${m.id}` : m.id) === msgKey)
if (!exists) {
messages.value[contactId].push(msg)
}
2026-04-01 22:39:11 +08:00
}
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 handleChatAction = async (command, chat) => {
if (command === 'delete') {
await deleteConversation(chat.id)
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
}
2026-04-01 22:39:11 +08:00
}
const deleteRecentChat = async (chat) => {
try {
await deleteConversation(chat.id)
} catch (e) {
// 即使 API 失败也删本地
}
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
}
2026-04-01 22:39:11 +08:00
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 currentUserId = Number(userStore.userId)
const msgFromUserId = Number(msg.fromUserId)
const msgToUserId = Number(msg.toUserId)
// 判断是否是自己发送的消息
const isSelf = (msgFromUserId === currentUserId)
2026-04-01 22:39:11 +08:00
msg.isSelf = isSelf
msg.sending = false
msg.failed = false
msg.fromColor = colors[Math.abs(msgFromUserId % colors.length)]
// 确定消息放在哪个联系人下
// 自己发的消息放到接收方toUserId收到的消息放到发送方fromUserId
const contactId = isSelf ? msgToUserId : msgFromUserId
if (!messages.value[contactId]) messages.value[contactId] = []
const list = messages.value[contactId]
2026-04-01 22:39:11 +08:00
// 清除同类型的临时消息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
}
}
}
// 去重(根据消息 id 或临时消息的 tempType
const msgKey = msg.tempType ? `temp_${msg.id}` : msg.id
const exists = list.some(m => (m.tempType ? `temp_${m.id}` : m.id) === msgKey)
2026-04-01 22:39:11 +08:00
if (!exists) list.push(msg)
2026-04-01 22:39:11 +08:00
// 滚动
if (currentContact.value && Number(currentContact.value.id) === contactId) {
2026-04-01 22:39:11 +08:00
nextTick(() => scrollToBottom())
}
2026-04-01 22:39:11 +08:00
// 更新最近聊天
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; }
.chat-action-btn { display: flex; align-items: center; cursor: pointer; padding: 4px; border-radius: 4px; color: #909399; transition: all 0.2s; }
.chat-action-btn:hover { color: #409eff; background: #f0f9ff; }
.chat-action-list { display: flex; align-items: center; gap: 8px; padding: 8px; cursor: pointer; border-radius: 4px; font-size: 13px; color: #f56c6c; transition: background 0.2s; }
.chat-action-list:hover { background: #fef0f0; }
2026-04-01 22:39:11 +08:00
.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; }
2026-04-01 22:39:11 +08:00
.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; }
.uploading-mini { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; color: #409eff; font-size: 12px; background: #ecf5ff; border-radius: 4px; }
.uploading-mini .el-icon { font-size: 14px; }
2026-04-01 22:39:11 +08:00
.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>