This commit is contained in:
oneao committed 2025-12-30 17:30:48 +08:00
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>