Files
workspace/code/link/link-frontend/src/views/im/index.vue
T
2025-12-29 17:31:10 +08:00

1553 lines
44 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.
<script setup>
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import Avatar from '@/components/avatar.vue'
import Icon from '@/components/icon.vue'
import { axiosGet, axiosPost } from '@/utils/axios'
import { useScrollWatcher } from '../../hooks/useScrollWatcher'
import { useUserStore } from '../../stores/userStore'
import { useWebSocket } from '../../stores/websocketStore'
import { formatImTime, formatTimestamp, generateUUID } from '../../utils/commonUtils'
const route = useRoute()
const websocket = useWebSocket()
const userStore = useUserStore()
const chatInputRef = ref(null)
const chatInputValue = ref('')
const chatSendLoading = ref(false)
// 群聊列表
const groups = ref([])
const groupsPage = ref({
page: 1,
pageSize: 10,
total: 0,
loading: false,
})
// 好友列表
const friends = ref([])
const friendsPage = ref({
page: 1,
pageSize: 10,
total: 0,
loading: false,
})
// 当前聊天对象
const initialFriendId = ref(route.query.friendId || '')
const currentChat = ref(null)
const isGroupChat = computed(() => currentChat.value?.type === 'group')
const isPrivateChat = computed(() => currentChat.value?.type === 'private')
// 私聊消息列表
const privateMessages = ref([])
const privateMessagesPage = ref({
pageSize: 10,
loading: false,
hasMore: true,
})
const privateTimeMessages = computed(() => processMessagesWithTime(privateMessages.value))
const groupMessages = ref([])
const groupMessagesPage = ref({
pageSize: 10,
loading: false,
hasMore: true,
})
const groupTimeMessages = computed(() => processMessagesWithTime(groupMessages.value))
const messageListRef = ref()
const { scrollToBottom: scrollToLatestMessage } = useScrollWatcher({
target: messageListRef,
onReachTop: () => {
console.log('=========top===========')
let loading = false
let hasMore = false
if (isPrivateChat.value) {
loading = privateMessagesPage.value.loading
hasMore = privateMessagesPage.value.hasMore
}
else {
loading = groupMessagesPage.value.loading
hasMore = groupMessagesPage.value.hasMore
}
console.log(loading)
if (loading)
return
if (!hasMore) {
ElMessage.warning('没有更多消息了')
return
}
if (isPrivateChat.value) {
loadPrivateMessages('up')
}
else {
loadGroupMessages('up')
}
},
})
// 消息监听
const unlistenWebSocket = ref()
onMounted(async () => {
loadFriends()
loadGroups()
unlistenWebSocket.value = websocket.onMessage((msg) => {
const { type, from, data } = msg
// --- 在线/离线状态更新 ---
if (type === 'online' || type === 'offline') {
const status = type === 'online' ? 1 : 0
const friend = friends.value.find(f => f.b_frienduser_id === from)
if (friend)
friend.is_online = status
return
}
// 私聊信息处理
if (type === 'private') {
const tempId = data?._tempId
const isSelf = from === userStore.userInfo.userid
const isCurrentChat = isPrivateChat.value ? from === currentChat.value?.b_frienduser_id : false
const to = msg.data.b_receiver_id
// 更新好友列表内容
const targetFriendId = isSelf ? to : from
const friendIndex = friends.value.findIndex(f => f.b_frienduser_id === targetFriendId)
if (friendIndex !== -1) {
const friend = friends.value[friendIndex]
// 更新最新消息和时间
friend.v_last_message = data.b_content
friend.v_last_message_time = data.b_send_time
// 移动到列表顶部
friends.value.splice(friendIndex, 1)
friends.value.unshift(friend)
}
// 1. 处理自己发出的消息(回写状态 or 多页面同步)
if (isSelf) {
const index = privateMessages.value.findIndex(item => item._tempId === tempId)
if (index !== -1) {
privateMessages.value[index] = {
...privateMessages.value[index],
...data,
_status: 'finished',
}
}
else {
if (isCurrentChat) {
privateMessages.value.push({
...data,
_status: 'finished',
})
nextTick(scrollToLatestMessage)
}
}
}
else {
// 2. 收到当前聊天好友的消息
if (isCurrentChat) {
privateMessages.value.push(data)
nextTick(scrollToLatestMessage)
// 这个还需要进行更新未读数量
axiosPost('/updatePrivateUnread', {
b_id: currentChat.value.b_id,
b_unread_count: 0,
})
}
// 3. 收到其他好友的消息,增加未读数
else {
const otherFriend = friends.value.find(f => f.b_frienduser_id === from)
if (otherFriend) {
otherFriend.b_unread_count = (otherFriend.b_unread_count || 0) + 1
}
}
}
return
}
if (type === 'friend_update') {
friends.value.unshift(data)
return
}
// 群聊
if (type === 'group') {
const tempId = data?._tempId
const isSelf = from === userStore.userInfo.userid
const isCurrentChat = isGroupChat.value
? msg.data.b_group_id === currentChat.value?.b_id
: false
const findGroupIndex = groups.value.findIndex(f => f.b_id === msg.data.b_group_id)
console.log(findGroupIndex)
if (findGroupIndex !== -1) {
const group = groups.value[findGroupIndex]
// 更新最新消息和时间
group.v_last_message_id = data.b_id
group.v_last_message = data.b_content
group.v_last_message_time = data.b_send_time
group.v_last_message_sender_name = data.v_sender_name
// 移动到列表顶部
groups.value.splice(findGroupIndex, 1)
groups.value.unshift(group)
console.log(group)
console.log(groups.value)
}
// 1. 处理自己发出的消息(回写状态 or 多页面同步)
if (isSelf) {
const index = groupMessages.value.findIndex(item => item._tempId === tempId)
if (index !== -1) {
groupMessages.value[index] = {
...groupMessages.value[index],
...data,
_status: 'finished',
}
}
else {
if (isCurrentChat) {
groupMessages.value.push({
...data,
_status: 'finished',
})
nextTick(scrollToLatestMessage)
}
}
}
else {
if (isCurrentChat) {
groupMessages.value.push(data)
nextTick(scrollToLatestMessage)
// 这个还需要进行更新未读数量
axiosPost('/updateGroupUnread', {
b_id: currentChat.value.b_id,
b_unread_count: 0,
})
}
// 3. 收到其他群组的消息,增加未读数
else {
const otherGroup = groups.value.find(f => f.b_id === msg.data.b_group_id)
if (otherGroup) {
otherGroup.b_unread_count = (otherGroup.b_unread_count || 0) + 1
}
}
}
}
})
})
onUnmounted(() => {
unlistenWebSocket.value?.()
})
// 加载好友列表
async function loadFriends() {
if (friendsPage.value.loading)
return
friendsPage.value.loading = true
const userId = userStore.userInfo.userid
try {
const res = await axiosGet('/friends', {
userId,
page: friendsPage.value.page,
pageSize: friendsPage.value.pageSize,
friendId: initialFriendId.value, // 可选参数
})
if (res.data) {
const { records, total } = res.data
if (friendsPage.value.page === 1) {
friends.value = records
}
else {
friends.value = friends.value.concat(records)
}
// 设置当前聊天对象
if (initialFriendId.value) {
const friend = friends.value.find(f => f.b_frienduser_id === initialFriendId.value)
if (friend) {
openPrivateChat(friend)
}
initialFriendId.value = ''
}
friendsPage.value.total = total
}
}
catch (err) {
console.error('加载好友列表失败', err)
ElMessage.error(err.response?.data?.message || err.message || '加载失败')
}
finally {
friendsPage.value.loading = false
}
}
// 好友列表滚动事件
function onFriendListScroll(e) {
const target = e.target
const totalPages = Math.ceil(friendsPage.value.total / friendsPage.value.pageSize)
// 滚动到底部,加载下一页
if (target.scrollHeight - target.scrollTop - target.clientHeight <= 5) {
if (friendsPage.value.page < totalPages && !friendsPage.value.loading) {
friendsPage.value.page += 1
loadFriends()
}
}
}
// 激活私聊
function openPrivateChat(friend) {
// 将 friend 的未读置0
const hadUnread = friend.b_unread_count > 0
friend.b_unread_count = 0
// 设置当前聊天
currentChat.value = { ...friend, type: 'private' }
// 初始化分页
privateMessagesPage.value = { pageSize: 10, loading: false, hasMore: true }
// 加载最新消息
loadPrivateMessages('down')
// 后台更新已读数量(仅当有未读时)
if (hadUnread) {
axiosPost('/updatePrivateUnread', { b_id: friend.b_id, b_unread_count: 0 })
}
}
/**
* 处理聊天消息,生成 _showTime 字段
* 如果与上一条消息相差超过 5 分钟,则显示时间
*/
function processMessagesWithTime(messages) {
let lastTimestamp = 0
return messages.map((msg) => {
if (!msg.b_send_time)
return { ...msg, _showTime: null }
const ts = Number(msg.b_send_time)
const showTime = !lastTimestamp || ts - lastTimestamp > 5 * 60 * 1000
lastTimestamp = ts
return { ...msg, _showTime: showTime ? formatImTime(ts, 'chat') : null }
})
}
// 加载私聊消息
async function loadPrivateMessages(direction = 'down') {
if (!currentChat.value || privateMessagesPage.value.loading || (direction === 'up' && !privateMessagesPage.value.hasMore))
return
privateMessagesPage.value.loading = true
const userId = userStore.userInfo.userid
const friendId = currentChat.value.b_frienduser_id
let beforeTimestamp = null
let beforeBId = null
if (direction === 'up' && privateMessages.value.length > 0) {
const firstMsg = privateMessages.value[0]
beforeTimestamp = Number(firstMsg.b_send_time)
beforeBId = firstMsg.b_id
}
try {
const res = await axiosGet('/privateMessages', {
userId,
friendId,
pageSize: privateMessagesPage.value.pageSize,
beforeTimestamp,
beforeBId,
})
if (res.data && Array.isArray(res.data)) {
const records = res.data.reverse()
if (direction === 'down') {
privateMessages.value = records
await nextTick()
scrollToLatestMessage()
}
else if (direction === 'up') {
if (records.length === 0) {
privateMessagesPage.value.hasMore = false
ElMessage.warning('没有更多消息了')
}
else {
privateMessages.value.unshift(...records)
}
}
}
}
catch (err) {
console.error('加载私聊消息失败', err)
ElMessage.error(err.response?.data?.message || err.message || '加载失败')
}
finally {
privateMessagesPage.value.loading = false
}
}
// 加载群组列表
async function loadGroups() {
if (groupsPage.value.loading)
return
groupsPage.value.loading = true
const userId = userStore.userInfo.userid
try {
const res = await axiosGet('/groups', {
userId,
page: groupsPage.value.page,
pageSize: groupsPage.value.pageSize,
})
if (res.data) {
const { records, total } = res.data
if (groupsPage.value.page === 1) {
groups.value = records
}
else {
groups.value = groups.value.concat(records)
}
groupsPage.value.total = total
}
}
catch (err) {
console.error('加载群组列表失败', err)
ElMessage.error(err.response?.data?.message || err.message || '加载失败')
}
finally {
groupsPage.value.loading = false
}
}
// 好友列表滚动事件
function onGroupListScroll(e) {
const target = e.target
const totalPages = Math.ceil(groupsPage.value.total / groupsPage.value.pageSize)
// 滚动到底部,加载下一页
if (target.scrollHeight - target.scrollTop - target.clientHeight <= 5) {
if (groupsPage.value.page < totalPages && !groupsPage.value.loading) {
groupsPage.value.page += 1
loadGroups()
}
}
}
// 激活群聊
function openGroupChat(group) {
console.log(group)
// 将 group 的未读置0
const hadUnread = group.v_unread_count > 0
group.v_unread_count = 0
// 设置当前聊天
currentChat.value = { ...group, type: 'group' }
// 初始化分页
groupMessagesPage.value = { pageSize: 10, loading: false, hasMore: true }
// 加载最新消息
loadGroupMessages('down')
loadGroupMembers()
// 后台更新已读数量(仅当有未读时)
if (hadUnread) {
axiosPost('/updateGroupUnread', { b_id: group.v_group_member_id, b_unread_count: 0 })
}
}
const groupMembers = ref([])
async function loadGroupMembers() {
if (!isGroupChat.value) {
return
}
try {
const res = await axiosGet('/groupMembers', {
groupId: currentChat.value.b_id,
})
groupMembers.value = res.data || []
console.log('群成员列表:', groupMembers.value)
}
catch (err) {
console.error(err)
ElMessage.error(`请求错误:${err.message || '未知错误'}`)
}
}
// 加载私聊消息
async function loadGroupMessages(direction = 'down') {
if (!currentChat.value || groupMessagesPage.value.loading || (direction === 'up' && !groupMessagesPage.value.hasMore))
return
groupMessagesPage.value.loading = true
let beforeTimestamp = null
let beforeBId = null
if (direction === 'up' && groupMessages.value.length > 0) {
const firstMsg = groupMessages.value[0]
beforeTimestamp = Number(firstMsg.b_send_time)
beforeBId = firstMsg.b_id
}
try {
const res = await axiosGet('/groupMessages', {
groupId: currentChat.value.b_id,
pageSize: groupMessagesPage.value.pageSize,
beforeTimestamp,
beforeBId,
})
if (res.data && Array.isArray(res.data)) {
const records = res.data.reverse()
if (direction === 'down') {
groupMessages.value = records
await nextTick()
scrollToLatestMessage()
}
else if (direction === 'up') {
if (records.length === 0) {
groupMessagesPage.value.hasMore = false
ElMessage.warning('没有更多消息了')
}
else {
groupMessages.value.unshift(...records)
}
}
}
}
catch (err) {
console.error('加载私聊消息失败', err)
ElMessage.error(err.response?.data?.message || err.message || '加载失败')
}
finally {
groupMessagesPage.value.loading = false
}
}
// 聊天框输入事件
function handleChatInputChange(event) {
// 只取文本内容
const text = event.target.textContent || ''
chatInputValue.value = text.trim()
}
// 聊天框回车事件
function handleChatInputKeyDown(event) {
// 判断是否是回车键
if (event.key === 'Enter') {
event.preventDefault()
if (!chatSendLoading.value) {
sendMessage()
}
}
}
async function sendMessage() {
const content = chatInputValue.value?.trim()
if (!content) {
ElMessage.warning('发送消息不能为空')
return
}
const userId = userStore.userInfo.userid
const _tempId = generateUUID()
const sendTime = Date.now()
const to = isPrivateChat.value ? currentChat.value.b_frienduser_id : currentChat.value.b_id
// 构建发送对象
const sendObj = {
type: isPrivateChat.value ? 'private' : 'group',
from: userId,
to,
timestamp: sendTime,
data: {
content,
messageType: 'text',
_tempId,
},
}
// 构建本地显示消息
const localMessage = {
b_sender_id: userId,
b_content: content,
b_message_type: 'text',
b_send_time: formatTimestamp(sendTime),
_tempId,
_status: 'sending',
}
if (isPrivateChat.value) {
localMessage.b_receiver_id = currentChat.value.b_frienduser_id
privateMessages.value.push(localMessage)
}
else {
localMessage.b_group_id = currentChat.value.b_id
groupMessages.value.push(localMessage)
}
// 清空输入框
if (chatInputRef.value) {
chatInputRef.value.innerHTML = ''
}
chatInputValue.value = ''
// 发送消息
websocket.sendMessage(sendObj)
// 等待 DOM 更新再滚动到底部
await nextTick()
scrollToLatestMessage()
}
</script>
<template>
<div class="im">
<div class="im__container">
<div class="im__header">
<div class="im__desc">
<div class="im__logo">
<Icon icon="solar:buildings-2-linear" :size="28" color="#fff" />
</div>
<div class="im__desc-extra">
<div class="im__title">
企业IM通信平台
</div>
<div class="im__subtitle">
Enterprise IM Communication Platform
</div>
</div>
</div>
<div class="im__user">
<Avatar name="User" />
<div class="im__user-info">
<div class="im__user-name">
{{ userStore.userInfo.username }}
</div>
<div
class="im__user-status"
:class="websocket.isConnected.value ? 'im__user-status--online' : 'im__user-status--offline'"
>
<span>{{ websocket.isConnected.value ? '在线' : '离线' }}</span>
</div>
</div>
</div>
</div>
<div class="im__body">
<div class="im__list">
<div class="im__list-search">
<el-input placeholder="请根据名称搜索" :prefix-icon="Search" />
</div>
<div class="im__list-item">
<div class="im__list-header">
<Icon icon="tabler:message-circle" :size="16" />
<span class="im__list-header-title">
单聊
</span>
</div>
<div class="im__list-body" @scroll="onFriendListScroll">
<div
v-for="item in friends" :key="item.b_id" class="im__chat-item"
:class="{
'im__chat-item--selected':
isPrivateChat && item.b_id === currentChat.b_id,
}"
@click="openPrivateChat(item)"
>
<el-badge
is-dot
:color="item.is_online === 1 ? '#24e68a' : '#bfbfbf'"
:badge-style="{
width: '12px',
height: '12px',
}"
:offset="[-4, 34]"
>
<Avatar name="A" class="im__chat-item-avatar" />
</el-badge>
<div class="im__chat-item-info">
<div class="im__chat-item-main">
<div class="im__chat-item-name">
{{ item.v_frienduser_name }}
</div>
<div class="im__chat-item-last-time">
{{ formatImTime(item.v_last_message_time, 'list') }}
</div>
</div>
<div class="im__chat-item-message">
<div class="im__chat-item-last-message">
{{ item.v_last_message }}
</div>
<el-badge :value="item.b_unread_count || 0" :show-zero="false" :max="99" class="im__chat-item-unread-count" />
</div>
</div>
</div>
</div>
</div>
<div class="im__list-item">
<div class="im__list-header">
<Icon icon="bi:people" :size="16" />
<span class="im__list-header-title">
群聊
</span>
</div>
<div class="im__list-body" @scroll="onGroupListScroll">
<div
v-for="item in groups" :key="item.b_id" class="im__chat-item"
:class="{
'im__chat-item--selected':
isGroupChat && item.b_id === currentChat.b_id,
}"
@click="openGroupChat(item)"
>
<Avatar name="#" class="im__chat-item-avatar" bg-color="#574b90" />
<div class="im__chat-item-info">
<div class="im__chat-item-main">
<div class="im__chat-item-name">
{{ item.b_groupname }}
</div>
<div class="im__chat-item-last-time">
{{ formatImTime(item.v_last_message_time, 'list') }}
</div>
</div>
<div class="im__chat-item-message">
<div v-if="item.v_last_message_id" class="im__chat-item-last-message">
{{ item.v_last_message_sender_name }}: {{ item.v_last_message }}
</div>
<el-badge :value="item.v_unread_count || 0" :show-zero="false" :max="99" class="im__chat-item-unread-count" />
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="currentChat" class="im__window">
<div class="im__window-header">
<div class="im__window-peer">
<div class="im__window-peer-info">
<div class="im__window-peer-name">
{{ isPrivateChat ? currentChat.v_frienduser_name : currentChat.b_groupname }}
</div>
<div v-if="isPrivateChat" class="im__window-peer-desc">
<div
class="im__window-peer-status"
:class="currentChat.is_online === 1 ? 'im__window-peer-status--online' : 'im__window-peer-status--offline'"
>
<span>[ </span>
<div class="im__window-peer-status-dot" />
<span>{{ currentChat.is_online === 1 ? '在线' : '离线' }}</span>
<span> ]</span>
</div>
<span>
公司描述等等等...
</span>
</div>
</div>
</div>
<div class="im__window-actions" />
</div>
<div class="im__window-body">
<template v-if="isGroupChat">
<el-splitter layout="vertical">
<el-splitter-panel>
<div ref="messageListRef" class="im__chat-main">
<div v-if="groupMessagesPage.loading" class="im__chat-message-loading">
<Icon icon="svg-spinners:180-ring" color="#1553f1" />
</div>
<div
v-for="item in groupTimeMessages"
:key="item.b_id"
class="im__chat-message-wrapper"
>
<div v-if="item._showTime" class="im__chat-message-time">
{{ item._showTime }}
</div>
<div
class="im__chat-message"
:class="userStore.userInfo.userid === item.b_sender_id
? 'im__chat-message--from'
: 'im__chat-message--to'"
>
<div class="im__chat-message-avatar">
<Avatar name="A" />
</div>
<div class="im__chat-message-body">
<div class="im__chat-message-name">
{{ item.v_sender_name }}
</div>
<div class="im__chat-message-content">
<div v-if="item._status && item._status !== 'sent'" class="im__chat-message-status">
<Icon
v-if="item._status === 'sending'"
icon="svg-spinners:180-ring"
color="#1553f1"
/>
<Icon
v-else-if="item._status === 'failed'"
icon="material-symbols:error"
color="red"
/>
</div>
<div class="im__chat-message-content--text">
{{ item.b_content }}
</div>
</div>
</div>
</div>
</div>
</div>
</el-splitter-panel>
<el-splitter-panel :min="100" :max="300" :size="150">
<div class="im__chat-footer">
<div class="im__chat-toolbar">
<Icon icon="proicons:emoji" />
<div class="im__chat-toolbar-right">
<Icon
icon="solar:history-broken"
/>
</div>
</div>
<div class="im__chat-editor">
<div
ref="chatInputRef"
class="im__chat-editor-input"
contenteditable="true"
@input="handleChatInputChange"
@keydown="handleChatInputKeyDown"
/>
</div>
<div class="im__chat-sendarea">
<el-button type="primary" size="small" class="im__chat-send">
发送(回车)
</el-button>
</div>
</div>
</el-splitter-panel>
</el-splitter>
<div class="im__chat-group">
<div class="im__group-section-header">
<!-- <el-input placeholder="请根据名称搜索" :prefix-icon="Search" /> -->
<div class="im__group-section-header-title">
<Icon icon="bi:people" :size="18" color="#666666" />
<span class="im__group-section-header-text">群成员({{ groupMembers.length }})</span>
</div>
<div class="im__group-section-header-extra">
<div class="im__group-section-header--search">
<Icon icon="lucide:search" :size="16" />
</div>
</div>
</div>
<div class="im__group-section-body">
<div v-for="item in groupMembers" :key="item.b_member_id" class="im__group-item">
<el-badge
is-dot
:color="item.is_online ? '#24e68a' : '#bfbfbf'"
:badge-style="{
width: '12px',
height: '12px',
}"
:offset="[-4, 34]"
>
<!-- 未登录:b3b7c2 -->
<Avatar name="A" />
</el-badge>
<div class="im__group-item-info">
<div class="im__group-item-name">
{{ item.v_member_name }}
</div>
<div class="im__group-item-desc">
desc
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="isPrivateChat">
<el-splitter layout="vertical">
<el-splitter-panel>
<div ref="messageListRef" class="im__chat-main">
<div v-if="privateMessagesPage.loading" class="im__chat-message-loading">
<Icon icon="svg-spinners:180-ring" color="#1553f1" />
</div>
<div
v-for="item in privateTimeMessages"
:key="item.b_id"
class="im__chat-message-wrapper"
>
<div v-if="item._showTime" class="im__chat-message-time">
{{ item._showTime }}
</div>
<div
class="im__chat-message"
:class="userStore.userInfo.userid === item.b_sender_id
? 'im__chat-message--from'
: 'im__chat-message--to'"
>
<div class="im__chat-message-avatar">
<Avatar name="A" />
</div>
<div class="im__chat-message-body">
<div class="im__chat-message-name">
{{
userStore.userInfo.userid === item.b_sender_id ? userStore.userInfo.username : currentChat.v_frienduser_name
}}
</div>
<div class="im__chat-message-content">
<div v-if="item._status && item._status !== 'sent'" class="im__chat-message-status">
<Icon
v-if="item._status === 'sending'"
icon="svg-spinners:180-ring"
color="#1553f1"
/>
<Icon
v-else-if="item._status === 'failed'"
icon="material-symbols:error"
color="red"
/>
</div>
<div class="im__chat-message-content--text">
{{ item.b_content }}
</div>
</div>
</div>
</div>
</div>
</div>
</el-splitter-panel>
<el-splitter-panel :min="100" :max="300" :size="150">
<div class="im__chat-footer">
<div class="im__chat-toolbar">
<Icon icon="proicons:emoji" />
<div class="im__chat-toolbar-right">
<Icon
icon="solar:history-broken"
/>
</div>
</div>
<div class="im__chat-editor">
<div
ref="chatInputRef"
class="im__chat-editor-input"
contenteditable="true"
@input="handleChatInputChange"
@keydown="handleChatInputKeyDown"
/>
</div>
<div class="im__chat-sendarea">
<el-button type="primary" size="small" class="im__chat-send">
发送(回车)
</el-button>
</div>
</div>
</el-splitter-panel>
</el-splitter>
</template>
</div>
</div>
<div v-else class="im__window-center">
<el-result
icon="primary"
title="提示"
sub-title="请选择需要交流的用户或群聊"
/>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.im {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
background-color: #f5f6fa;
& *::-webkit-scrollbar {
width: 6px;
height: 6px;
}
& *::-webkit-scrollbar-track {
background-color: #fff;
border-radius: 3px;
}
& *::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
transition: background-color 0.2s ease;
}
& *::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.4);
}
.im__container {
width: 1300px;
height: 80%;
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.im__header {
padding: 0 16px;
height: 64px;
border-bottom: 1px solid #e6e9ef;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
.im__desc {
display: flex;
align-items: center;
.im__logo {
background-color: #1553f1;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.im__desc-extra {
margin-left: 12px;
.im__title {
font-size: 16px;
font-weight: bold;
}
.im__subtitle {
font-size: 14px;
color: #666;
}
}
}
.im__user {
display: flex;
align-items: center;
height: 100%;
padding: 0px 8px;
&:hover {
cursor: pointer;
background-color: #f4f4f4;
}
.im__user-info {
margin-left: 8px;
display: flex;
flex-direction: column;
.im__user-name {
font-size: 14px;
}
.im__user-status {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
}
.im__user-status--online {
color: #4caf50;
}
.im__user-status--offline {
color: #666;
}
}
}
}
.im__body {
display: flex;
flex: 1;
min-height: 0;
}
.im__list {
width: 300px;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
min-height: 0;
.im__list-search {
height: 56px;
padding: 10px;
border-bottom: 1px solid #e6e9ef;
}
.im__list-item {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
transition: background-color 0.25s ease;
.im__list-header {
font-size: 14px;
padding: 8px 10px;
background-color: #f8f9fc;
flex-shrink: 0;
display: flex;
align-items: center;
.im__list-header-title {
margin-left: 4px;
}
}
.im__list-body {
overflow-y: auto;
flex: 1;
min-height: 0;
.im__chat-item {
display: flex;
align-items: center;
height: 64px;
width: 100%;
padding: 0 10px;
overflow-x: hidden;
&:hover {
background-color: #f5f7fa;
cursor: pointer;
}
.im__chat-item-avatar {
flex-shrink: 0;
}
.im__chat-item-info {
display: flex;
flex-direction: column;
flex: 1;
margin-left: 10px;
min-width: 0;
.im__chat-item-main,
.im__chat-item-message {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.im__chat-item-message:has(.im__chat-item-last-message:not(:empty)) {
margin-top: 4px;
}
.im__chat-item-name {
font-size: 14px;
font-weight: 500;
}
.im__chat-item-last-time,
.im__chat-item-last-message {
flex-shrink: 0;
color: #666;
font-size: 12px;
}
.im__chat-item-last-message,
.im__chat-item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.im__chat-item-last-time {
margin-left: 8px;
line-height: 1;
}
.im__chat-item-unread-count {
display: flex;
margin-left: 8px;
}
}
}
.im__chat-item--selected {
position: relative;
background-color: #f0f6ff;
transition: background-color 0.25s ease;
&::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
background-color: #3b82f6;
border-radius: 2px;
box-shadow: 0 0 4px rgba(59, 130, 246, 0.4);
}
}
}
}
}
.im__window {
flex: 1;
background-color: #fff;
display: flex;
flex-direction: column;
.im__window-header {
height: 56px;
border-bottom: 1px solid #e6e9ef;
display: flex;
align-items: center;
flex-shrink: 0;
.im__window-peer {
display: flex;
align-items: center;
padding: 0px 8px;
.im__window-peer-info {
margin-left: 8px;
.im__window-peer-name {
font-size: 16px;
}
.im__window-peer-desc {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
.im__window-peer-status {
display: flex;
align-items: center;
margin-right: 4px;
.im__window-peer-status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin: 0px 4px;
}
}
.im__window-peer-status--online {
.im__window-peer-status-dot {
background-color: #24e68a;
}
}
.im__window-peer-status--offline {
.im__window-peer-status-dot {
background-color: #b3b7c2;
}
}
}
}
}
}
.im__window-body {
flex: 1;
display: flex;
min-height: 0;
:deep(.el-splitter-panel) {
scrollbar-width: auto;
}
.im__chat-main {
padding: 16px 8px;
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
scrollbar-gutter: stable both-edges;
overflow-y: auto;
min-width: 0;
background-color: #f8fafc;
height: 100%;
.im__chat-message-loading {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 14px;
}
.im__chat-message-wrapper {
width: 100%;
position: relative;
}
.im__chat-message-time {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #666;
}
.im__chat-message {
display: flex;
width: fit-content;
width: 100%;
.im__chat-message-avatar {
flex-shrink: 0;
}
.im__chat-message-body {
width: 100%;
.im__chat-message-name {
font-size: 14px;
line-height: 1;
}
.im__chat-message-content {
display: inline-flex;
align-items: center;
margin-top: 8px;
max-width: 70%;
}
.im__chat-message-content--text {
background-color: #ffffff;
color: #000000;
word-break: break-all;
padding: 6px 12px;
border-radius: 4px;
text-align: left;
}
}
}
.im__chat-message--from {
align-self: flex-end;
flex-direction: row-reverse;
.im__chat-message-body {
margin-right: 8px;
text-align: right;
.im__chat-message-content--text {
background-color: #3c73ff;
color: #fff;
}
.im__chat-message-name {
text-align: right;
}
.im__chat-message-status {
margin-right: 8px;
}
}
}
.im__chat-message--to {
text-align: left;
align-items: flex-start;
.im__chat-message-body {
margin-left: 8px;
.im__chat-message-status {
margin-left: 8px;
}
}
}
}
.im__chat-group {
width: 300px;
border-left: 1px solid #e6e9ef;
min-height: 0;
display: flex;
flex-direction: column;
flex-shrink: 0;
.im__group-section-header {
height: 46px;
border-bottom: 1px solid #e6e9ef;
display: flex;
align-items: center;
padding: 0px 8px;
justify-content: space-between;
.im__group-section-header-title {
display: flex;
align-items: center;
}
.im__group-section-header-text {
margin-left: 4px;
line-height: 1;
font-size: 14px;
}
.im__group-section-header--search {
cursor: pointer;
color: #666666;
&:hover {
color: #1553f1 !important;
}
}
}
.im__group-section-body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
.im__group-item {
height: 64px;
padding: 0 10px;
display: flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
&:hover {
background-color: #f5f7fa;
}
.im__group-item-info {
margin-left: 8px;
.im__group-item-name {
line-height: 1;
font-size: 14px;
}
.im__group-item-desc {
margin-top: 8px;
font-size: 12px;
color: #666;
}
}
}
}
}
.im__chat-footer {
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
height: 100%;
.im__chat-toolbar {
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0px 8px;
gap: 8px;
.im__chat-toolbar-right {
margin-left: auto;
}
}
.im__chat-editor {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
flex: 1;
min-height: 0;
width: 100%;
scrollbar-gutter: stable both-edges;
.im__chat-editor-input {
padding: 4px;
flex: 1;
// overflow-y: auto;
box-sizing: border-box;
border: none;
resize: none;
background-color: transparent;
outline: none;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
cursor: text;
}
}
.im__chat-sendarea {
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: auto;
padding: 0px 16px;
.im__chat-send {
background-color: #1553f1;
}
}
}
}
}
.im__window-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>