634 lines
15 KiB
Vue
634 lines
15 KiB
Vue
<script lang="ts" setup>
|
||
import type { PropType } from 'vue'
|
||
import type { ImGroup, ImGroupMessage, ImGroupUser } from '@/types/im'
|
||
import { ElMessage } from 'element-plus'
|
||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||
import { useScrollWatcher } from '@/hooks/useScrollWatcher'
|
||
import { queryGroupMessageApi, queryGroupUserApi } from '@/request/api/im'
|
||
import { useUserStore } from '@/store/user'
|
||
import { useWebSocket } from '@/store/websocket'
|
||
import { MessageType } from '@/types/im'
|
||
import { formatImTime } from '@/utils/time'
|
||
import { generateUUID } from '@/utils/uuid'
|
||
|
||
const props = defineProps({
|
||
group: { type: Object as PropType<ImGroup>, required: true },
|
||
})
|
||
const emits = defineEmits<{
|
||
(e: 'avatarClick', id: number): void
|
||
(e: 'userClick', groupUser: ImGroupUser): void
|
||
}>()
|
||
// websocket
|
||
const websocket = useWebSocket()
|
||
|
||
onMounted(() => {
|
||
const userId = useUserStore().user.id
|
||
|
||
websocket.onMessage(async (msg) => {
|
||
const { from, type, data } = msg
|
||
|
||
if (type === MessageType.GROUP) {
|
||
// 自己发的
|
||
if (userId === data.senderId) {
|
||
messages.value.forEach((item) => {
|
||
if (item._tempId === data.tempId) {
|
||
item._status = 'sent'
|
||
item._tempId = ''
|
||
item.id = data.id
|
||
}
|
||
})
|
||
}
|
||
else {
|
||
if (props.group.groupId === from) {
|
||
const newData: ImGroupMessage = {
|
||
id: data.id,
|
||
groupId: data.groupId,
|
||
senderId: data.senderId,
|
||
senderName: data.senderName,
|
||
senderAvatar: data.senderAvatar,
|
||
content: data.content,
|
||
type: data.type,
|
||
sendTime: data.sendTime,
|
||
}
|
||
|
||
messages.value.push(newData)
|
||
// 更新未读数量,交给外部
|
||
|
||
// 等待 DOM 更新再滚动到底部
|
||
await nextTick()
|
||
scrollToLatestMessage()
|
||
}
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
// 聊天框
|
||
const chatInputRef = ref<HTMLDivElement | null>(null)
|
||
const chatInputValue = ref('')
|
||
const chatSendLoading = ref(false)
|
||
|
||
const userStore = useUserStore()
|
||
|
||
// 私聊消息列表
|
||
const messages = ref<ImGroupMessage[]>([])
|
||
const pagination = ref({
|
||
pageSize: 10,
|
||
loading: false,
|
||
hasMore: true,
|
||
})
|
||
|
||
const timeMessages = computed(() => processMessagesWithTime(messages.value))
|
||
|
||
const groupUsers = ref<ImGroupUser[]>([])
|
||
|
||
watch(
|
||
() => props.group,
|
||
(newVal) => {
|
||
if (newVal !== null) {
|
||
messages.value = []
|
||
pagination.value = { pageSize: 10, loading: false, hasMore: true }
|
||
loadMessages('down')
|
||
|
||
groupUsers.value = []
|
||
loadGroupUsers()
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// ref
|
||
const messageListRef = ref()
|
||
const { scrollToBottom: scrollToLatestMessage } = useScrollWatcher({
|
||
target: messageListRef,
|
||
onReachTop: () => {
|
||
let loading = false
|
||
let hasMore = false
|
||
|
||
loading = pagination.value.loading
|
||
hasMore = pagination.value.hasMore
|
||
|
||
if (loading)
|
||
return
|
||
|
||
if (!hasMore) {
|
||
ElMessage.warning('没有更多消息了')
|
||
}
|
||
|
||
loadMessages('up')
|
||
},
|
||
})
|
||
|
||
/**
|
||
* 处理聊天消息,生成 _showTime 字段
|
||
* 如果与上一条消息相差超过 5 分钟,则显示时间
|
||
*/
|
||
function processMessagesWithTime(messages: ImGroupMessage[]) {
|
||
let lastTimestamp = 0
|
||
|
||
return messages.map((msg: ImGroupMessage) => {
|
||
if (!msg.sendTime)
|
||
return { ...msg, _showTime: null }
|
||
|
||
const ts = msg.sendTime
|
||
const showTime = !lastTimestamp || ts - lastTimestamp > 5 * 60 * 1000
|
||
lastTimestamp = ts
|
||
|
||
return { ...msg, _showTime: showTime ? formatImTime(ts, 'chat') : null }
|
||
})
|
||
}
|
||
|
||
// 加载私聊消息
|
||
async function loadMessages(direction = 'down') {
|
||
if (direction === 'up' && !pagination.value.hasMore)
|
||
return
|
||
|
||
pagination.value.loading = true
|
||
|
||
let beforeTimestamp: number | null = null
|
||
let beforeId: number | null = null
|
||
|
||
if (direction === 'up' && messages.value.length > 0) {
|
||
const firstMsg = messages.value[0]
|
||
beforeTimestamp = firstMsg.sendTime
|
||
beforeId = firstMsg.id
|
||
}
|
||
|
||
try {
|
||
const res = await queryGroupMessageApi({
|
||
groupId: props.group.groupId,
|
||
pageSize: pagination.value.pageSize,
|
||
beforeTimestamp,
|
||
beforeId,
|
||
})
|
||
|
||
if (res.isSuccess) {
|
||
const records = res.data.reverse()
|
||
|
||
if (direction === 'down') {
|
||
messages.value = records
|
||
await nextTick()
|
||
scrollToLatestMessage()
|
||
}
|
||
else if (direction === 'up') {
|
||
if (records.length === 0) {
|
||
pagination.value.hasMore = false
|
||
ElMessage.warning('没有更多消息了')
|
||
}
|
||
else {
|
||
messages.value.unshift(...records)
|
||
}
|
||
}
|
||
}
|
||
else {
|
||
ElMessage.error(res.message || '加载消息失败,请稍后再试')
|
||
}
|
||
}
|
||
catch (err) {
|
||
console.error(err)
|
||
ElMessage.error('加载消息失败,请稍后再试')
|
||
}
|
||
finally {
|
||
pagination.value.loading = false
|
||
}
|
||
}
|
||
|
||
async function loadGroupUsers() {
|
||
queryGroupUserApi({
|
||
groupId: props.group.groupId,
|
||
userName: '',
|
||
}).then((res) => {
|
||
if (res.isSuccess) {
|
||
groupUsers.value = res.data
|
||
}
|
||
else {
|
||
ElMessage.error(res.message || '获取群组成员列表失败,请稍后再试')
|
||
}
|
||
}).catch((err) => {
|
||
console.log(err)
|
||
ElMessage.error('获取群组成员失败,请稍后再试')
|
||
})
|
||
}
|
||
|
||
// 聊天框输入事件
|
||
function handleChatInputChange(event: Event) {
|
||
const target = event.target as HTMLElement
|
||
// 去除首尾空格
|
||
chatInputValue.value = (target.textContent || '').trim()
|
||
}
|
||
|
||
// 聊天框回车事件
|
||
function handleChatInputKeyDown(event: KeyboardEvent) {
|
||
// 判断是否是回车键
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault()
|
||
if (!chatSendLoading.value) {
|
||
sendMessage()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function sendMessage() {
|
||
const content = chatInputValue.value?.trim()
|
||
if (!content) {
|
||
ElMessage.warning('发送消息不能为空')
|
||
}
|
||
|
||
const userId = userStore.user.id
|
||
const groupId = props.group.groupId
|
||
const _tempId = generateUUID()
|
||
|
||
const localMessage: ImGroupMessage = {
|
||
id: -Date.now(),
|
||
groupId,
|
||
senderId: userId,
|
||
senderAvatar: userStore.user.avatar,
|
||
senderName: userStore.user.name,
|
||
content,
|
||
type: 0,
|
||
sendTime: Date.now(),
|
||
_tempId,
|
||
_status: 'sending',
|
||
}
|
||
|
||
messages.value.push(localMessage)
|
||
|
||
// 清空输入框
|
||
if (chatInputRef.value) {
|
||
chatInputRef.value.innerHTML = ''
|
||
}
|
||
|
||
const sentData: any = {
|
||
content,
|
||
type: 0,
|
||
tempId: _tempId,
|
||
senderAvatar: userStore.user.avatar,
|
||
senderName: userStore.user.name,
|
||
}
|
||
|
||
websocket.sendGroup(groupId, sentData)
|
||
|
||
chatInputValue.value = ''
|
||
// 等待 DOM 更新再滚动到底部
|
||
await nextTick()
|
||
scrollToLatestMessage()
|
||
}
|
||
|
||
function handleClickAvatar(userId: number) {
|
||
emits('avatarClick', userId)
|
||
}
|
||
|
||
function handleClickGroupUser(groupUser: ImGroupUser) {
|
||
emits('userClick', groupUser)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<el-splitter layout="vertical">
|
||
<el-splitter-panel>
|
||
<div ref="messageListRef" class="im__chat-main">
|
||
<div v-if="pagination.loading" class="im__chat-message-loading">
|
||
<SvgIcon icon="svg-spinners:180-ring" color="#1553f1" />
|
||
</div>
|
||
<div
|
||
v-for="item in timeMessages"
|
||
:key="item.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.user.id === item.senderId
|
||
? 'im__chat-message--from'
|
||
: 'im__chat-message--to'"
|
||
>
|
||
<div class="im__chat-message-avatar" @click="handleClickAvatar(item.senderId)">
|
||
<SafeImage :url="item.senderAvatar" :size="40" round />
|
||
</div>
|
||
<div class="im__chat-message-body">
|
||
<div class="im__chat-message-name">
|
||
{{ item.senderName }}
|
||
</div>
|
||
<div class="im__chat-message-content">
|
||
<div v-if="item._status && item._status !== 'sent'" class="im__chat-message-status">
|
||
<SvgIcon
|
||
v-if="item._status === 'sending'"
|
||
icon="svg-spinners:180-ring"
|
||
color="#1553f1"
|
||
/>
|
||
<SvgIcon
|
||
v-else-if="item._status === 'failed'"
|
||
icon="material-symbols:error"
|
||
color="red"
|
||
/>
|
||
</div>
|
||
<div class="im__chat-message-content--text">
|
||
{{ item.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">
|
||
<SvgIcon icon="proicons:emoji" :size="20" />
|
||
|
||
<div class="im__chat-toolbar-right">
|
||
<SvgIcon icon="solar:history-broken" :size="20" />
|
||
</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" @click="sendMessage">
|
||
发送(回车)
|
||
</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">
|
||
<SvgIcon icon="bi:people" :size="18" color="#666666" />
|
||
<span class="im__group-section-header-text">群成员({{ groupUsers.length }})</span>
|
||
</div>
|
||
<div class="im__group-section-header-extra">
|
||
<div class="im__group-section-header--search">
|
||
<SvgIcon icon="lucide:search" :size="16" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="im__group-section-body">
|
||
<div v-for="item in groupUsers" :key="item.userId" class="im__group-item" @click="handleClickGroupUser(item)">
|
||
<el-badge
|
||
is-dot
|
||
:color="item.isOnline ? '#24e68a' : '#bfbfbf'"
|
||
:badge-style="{
|
||
width: '12px',
|
||
height: '12px',
|
||
}"
|
||
:offset="[-4, 34]"
|
||
>
|
||
<SafeImage :url="item.userAvatar" :size="40" round />
|
||
</el-badge>
|
||
<div class="im__group-item-info">
|
||
<div class="im__group-item-name">
|
||
{{ item.userName }}
|
||
</div>
|
||
<!-- <div class="im__group-item-desc">
|
||
desc
|
||
</div> -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.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: #ffffff;
|
||
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: #ddd;
|
||
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-editor-input:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: #aaa;
|
||
pointer-events: none;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
}
|
||
</style>
|