Files
workspace/code/link56/link56-frontend-new/src/views/im/components/GroupChatWindows.vue
T
2026-01-03 22:02:11 +08:00

634 lines
15 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 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>