728 lines
29 KiB
Vue
728 lines
29 KiB
Vue
|
|
<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="400" 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: 8px; }
|
|||
|
|
.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); }
|
|||
|
|
|
|||
|
|
.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>
|