u
This commit is contained in:
1 parent
28678fb4df
commit
a6dbe24dd4
43 files changed
+2285
-206
No files matched your search
@@ -0,0 +1,570 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import type { ImFriend, ImPrivateMessage } from '@/types/im'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useScrollWatcher } from '@/hooks/useScrollWatcher'
|
||||
import { queryPrivateMessageApi } 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({
|
||||
friend: { type: Object as PropType<ImFriend>, required: true },
|
||||
})
|
||||
|
||||
// websocket
|
||||
const websocket = useWebSocket()
|
||||
|
||||
onMounted(() => {
|
||||
const userId = useUserStore().user.id
|
||||
const friendId = props.friend.friendId
|
||||
|
||||
websocket.onMessage(async (msg) => {
|
||||
const { from, type, data } = msg
|
||||
// 只处理私聊的
|
||||
if (type === MessageType.PRIVATE) {
|
||||
// 1.自己发的
|
||||
if (userId === from) {
|
||||
messages.value.forEach((item) => {
|
||||
if (item._tempId === data.tempId) {
|
||||
// 更新对象
|
||||
item._status = 'sent'
|
||||
item._tempId = ''
|
||||
|
||||
item.id = data.id
|
||||
}
|
||||
})
|
||||
}
|
||||
// 2.别人发的
|
||||
else {
|
||||
// 2.1 如果当前聊天的正好是这个
|
||||
if (friendId === from) {
|
||||
const newData: ImPrivateMessage = {
|
||||
id: data.id,
|
||||
senderId: data.senderId,
|
||||
receiverId: data.receiverId,
|
||||
content: data.content,
|
||||
sendTime: data.sendTime,
|
||||
type: data.type,
|
||||
}
|
||||
|
||||
messages.value.push(newData)
|
||||
// 更新未读数量,交给外部
|
||||
|
||||
// 等待 DOM 更新再滚动到底部
|
||||
await nextTick()
|
||||
scrollToLatestMessage()
|
||||
}
|
||||
else {
|
||||
// 2.2 如果不是当前这个的话,不做处理
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 聊天框
|
||||
const chatInputRef = ref<HTMLDivElement | null>(null)
|
||||
const chatInputValue = ref('')
|
||||
const chatSendLoading = ref(false)
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 私聊消息列表
|
||||
const messages = ref<ImPrivateMessage[]>([])
|
||||
const pagination = ref({
|
||||
pageSize: 10,
|
||||
loading: false,
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
const timeMessages = computed(() => processMessagesWithTime(messages.value))
|
||||
|
||||
watch(
|
||||
() => props.friend,
|
||||
(newVal) => {
|
||||
if (newVal !== null) {
|
||||
messages.value = []
|
||||
pagination.value = { pageSize: 10, loading: false, hasMore: true }
|
||||
loadMessages('down')
|
||||
}
|
||||
},
|
||||
{ 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: ImPrivateMessage[]) {
|
||||
let lastTimestamp = 0
|
||||
|
||||
return messages.map((msg: ImPrivateMessage) => {
|
||||
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
|
||||
|
||||
const userId = userStore.user.id
|
||||
|
||||
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 queryPrivateMessageApi({
|
||||
userId,
|
||||
friendId: props.friend.friendId,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天框输入事件
|
||||
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('发送消息不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
const userId = userStore.user.id
|
||||
const _tempId = generateUUID()
|
||||
|
||||
const localMessage: ImPrivateMessage = {
|
||||
id: -Date.now(),
|
||||
senderId: userId,
|
||||
receiverId: props.friend.friendId,
|
||||
content,
|
||||
type: 0,
|
||||
sendTime: Date.now(),
|
||||
_tempId,
|
||||
_status: 'sending',
|
||||
}
|
||||
|
||||
messages.value.push(localMessage)
|
||||
|
||||
// 清空输入框
|
||||
if (chatInputRef.value) {
|
||||
chatInputRef.value.innerHTML = ''
|
||||
}
|
||||
|
||||
const privateData: any = {
|
||||
content,
|
||||
type: 0,
|
||||
tempId: _tempId,
|
||||
}
|
||||
|
||||
websocket.sendPrivate(props.friend.friendId, privateData)
|
||||
|
||||
chatInputValue.value = ''
|
||||
// 等待 DOM 更新再滚动到底部
|
||||
await nextTick()
|
||||
scrollToLatestMessage()
|
||||
}
|
||||
</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">
|
||||
<SafeImage :url=" userStore.user.id === item.senderId ? userStore.user.avatar : props.friend.friendAvatar" :size="40" round />
|
||||
</div>
|
||||
<div class="im__chat-message-body">
|
||||
<div class="im__chat-message-name">
|
||||
{{
|
||||
userStore.user.id === item.senderId ? userStore.user.name : props.friend.friendName
|
||||
}}
|
||||
</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"
|
||||
data-placeholder="请输入内容..."
|
||||
@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>
|
||||
|
||||
<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>
|
||||
Reference in new issue
Block a user