web端简易版聊天组件

web端简易版聊天组件

由于项目需要需要使用vue3 setup + js实现简易版web端聊天组件,特此记录一下,相关代码如下:

WebChat.vue 组件:
<template>
    <div class="chat-container">
        <div class="chat-area pr"
             ref="chatAreaRef">
            <div class="loading-box"
                 v-if="isLoading">正在加载中...</div>
            <div class="loading-box"
                 v-if="!isLoadingNextPage">暂无更多数据~~</div>
            <div v-for="(item,index) in msgList"
                 :key="index"
                 :class="['message',item.createBy !== userInfo.userId ? 'message-left' : 'message-right']"
                 :id="`msg-${item.groupChatMessageId}`"
                 :ref="(el) => {refMsgNodesList[item.groupChatMessageId] = el} ">
                <el-avatar class="avatar-iocn"
                           :size="40"
                           :src="img_man_avater"
                           fit="cover" />
                <div class="item flex-col-b"
                     :style="{
                    '--align-self':item.createBy !== userInfo.userId?'flex-start':'flex-end',
                    '--bg-color':item.createBy !== userInfo.userId?'#f5f5f5':'#00b0c16e',
                }">
                    <div class="name">
                        {{item?.userInfo?.departmentName }}{{$dictValue('accountType', item?.userInfo?.userType)}}
                        {{item?.userInfo?.realName}}
                        {{formatDate(item?.createTime, "yyyy-MM-dd hh:mm:ss")}}
                    </div>
                    <!-- 文本 -->
                    <div v-if="item.messageType == 1"
                         class="text">
                        <div class="text-box">{{item.messageContent}}</div>
                    </div>
                    <!-- 图片 -->
                    <div v-else-if="item.messageType == 2"
                         class="text">
                        <el-image class="img-box"
                                  draggable="false"
                                  :src="`${fileUrl}${item.messageContent}`"
                                  :zoom-rate="1.2"
                                  :preview-src-list="[`${fileUrl}${item.messageContent}`]"
                                  :hide-on-click-modal="true"
                                  fit="cover" />
                    </div>
                    <!-- 语音 -->
                    <div v-else-if="item.messageType == 3"
                         class="text">
                        <audio :src="`${fileUrl}${item.messageContent}`"
                               style="height: 50px;width: 300px"
                               controls>
                        </audio>
                    </div>
                    <!-- 视频 -->
                    <div v-else-if="item.messageType == 4"
                         class="text">
                        <video controls
                               preload="auto"
                               width="280"
                               height="180"
                               style="object-fit: fill">
                            <source :src="`${fileUrl}${item.messageContent}`"
                                    type="video/mp4" />
                        </video>
                    </div>
                </div>
            </div>
        </div>
        <div class="input-area pr">
            <div class="tools flex-sc">
                <div class="tools-item">
                    <g-upload-img v-model="imgUrl"
                                  :slotStant="true"
                                  :showFileList="false"
                                  :isEcho="false"
                                  :onSuccess="onSuccess"
                                  listType="text"
                                  :type="['jpg', 'png', 'bmp', 'jpeg', 'gif']"
                                  url="upload-image"
                                  size="5120k"
                                  :wh="[1920, 1500]">
                        <template #icon>
                            <el-icon :size="24"
                                     color="#5f6f85">
                                <PictureFilled />
                            </el-icon>
                        </template>
                    </g-upload-img>
                </div>
                <div class="tools-item">
                    <g-upload-video id="uploadVideo"
                                    v-model="videoUrl"
                                    :type="['mp4']"
                                    :on-success="handleVideoSuccess"
                                    size="10m"
                                    :isTip="false"
                                    :iconSize="24"
                                    :isDefaultIcon="false"
                                    tip="只能上传mp4格式文件,大小不超过10m">
                        <template #icon>
                            <el-icon :size="24"
                                     color="#5f6f85">
                                <VideoCameraFilled />
                            </el-icon>
                        </template>
                    </g-upload-video>
                </div>
            </div>
            <div class="input">
                <el-input ref="inputRef"
                          v-model="inputValue"
                          maxlength="500"
                          :autofocus="true"
                          style="width: 100%"
                          rows='4'
                          @keyup.enter="enterSend"
                          type="textarea" />
                <!-- <EditorInput ref="editorInputRef"
                             v-model="inputValue"
                             @focusFn="focusFn"></EditorInput> -->
            </div>
        </div>
        <div class="btn flex-e pr">
            <el-button :disabled="!inputValue"
                       @click="send">发送(Enter)</el-button>
            <!-- <div class="empty-tip"></div> -->
        </div>
    </div>
</template>

<script setup>
import { ref, onMounted, nextTick, getCurrentInstance, computed, onUnmounted, watch } from 'vue'
import { useStore } from 'vuex';

const props = defineProps({})
const { proxy } = getCurrentInstance();
const store = useStore();

const userInfo = xxx //当前登录用户信息

const inputValue = ref('') // 输入框内容
const imgUrl = ref('') // 图片地址
const videoUrl = ref('') // 视频地址
const scrollTop = ref(0) // 滚动条距离
const refMsgNodesList = ref({}); // 消息节点列表,用户向上滚动加载滚动定位

const pageIndex = ref(1)
const pageSize = ref(defaultsetting.defaultPageSize)
const pageTotal = ref(0)
const isLoading = ref(false) // 是否正在加载数据
const chatAreaDom = ref(null) // 聊天区域dom

// 消息列表
const msgList = ref([])
// 回车发送
const enterSend = (event) => {
    if (event.keyCode === 13 && !event.shiftKey && !event.ctrlKey) {
        send()
    }
}
// 发送消息
const sendMessage = (type, text) => {
    let message = {
        "query": {}
    }
    // 长连接
    (webChat/sendMessage', {message: message }) ..................
}
// 设置本人发送的消息
const setSelfMessage = (type, text) => {
    let obj = {}
    msgList.value.push(obj)
    if (type === 1) {
        inputValue.value = ''
    }
    scrollToBottom()
}
// 发送消息
const send = () => {
    if (!inputValue.value.trim()) {
        proxy.$message.warning('不能发送空白消息')
        return
    }
    sendMessage(1, inputValue.value)
    // setSelfMessage(1, inputValue.value)
}
// 上传图片成功回调
const onSuccess = (res) => {
}
// 上传视频成功回调
const handleVideoSuccess = (res) => {
}

const isLoadingNextPage = ref(false)
// 获取聊天信息
const getChatMessage = async (isFristLoading = false) => {
    try {

        let topId = msgList.value?.[0]?.groupChatMessageId // 消息id topId

        const res = await API({
            "pageIndex": 1,
            "pageSize": 20,
            "query": {}
        })
        if (res.code === 2000) {
            // console.log('获取聊天信息', res);
            if (res?.result.length === 0) {
                isLoadingNextPage.value = false
                return
            } else {
                isLoadingNextPage.value = true
            }

            msgList.value = [...res?.result, ...msgList.value]
            pageTotal.value = res?.totalRows || 0

            nextTick(() => {
                if (isFristLoading) {
                    scrollToBottom()
                } else {
                    refMsgNodesList.value[topId]?.scrollIntoView()
                }
            })
        }
    } catch (error) {
        console.error('Error loading data:', error);
    } finally {
        isLoading.value = false;
    }
}

// 监听滚动条位置
const scrollFn = () => {
    chatAreaDom.value = proxy.$refs.chatAreaRef;
    chatAreaDom.value.addEventListener('scroll', handleScroll);
}
// 滚动条事件
const handleScroll = () => {
    scrollTop.value = chatAreaDom.value.scrollTop;
    // console.log('滚动条位置', scrollTop.value);
    // 判断是否滚动到顶部且不在加载状态
    if (scrollTop.value === 0 && !isLoading.value) {
        isLoading.value = true;
        console.log('加载数据');
        if (isLoadingNextPage.value) {
            pageIndex.value++
            setTimeout(() => {
                getChatMessage();
            }, 300)
        } else {
            isLoading.value = false;
        }
    }
}
// 获取焦点
const onFocusFn = () => {
    nextTick(() => {
        proxy.$refs.inputRef.focus()
    })
}
// 滚动条置底
const scrollToBottom = (isInstant = true) => {
    nextTick(() => {
        chatAreaDom.value?.scrollTo({
            top: chatAreaDom.value.scrollHeight,
            behavior: isInstant ? 'instant' : 'smooth',
        });
    })
}

//监听推送过来的某个卡片数据信息
watch(() => chatMsg, (val) => {
    if (!val) return
    msgList.value = [...msgList.value, val]
    inputValue.value = ''
    setTimeout(() => {
        scrollToBottom()
    }, 50)
})

// 建立连接
const initWebChatMsg = () => {}

onMounted(() => { })
onUnmounted(() => {
    webChat/closeSignalR............. //关闭连接
    chatAreaDom.value?.removeEventListener('scroll', handleScroll);
})

defineExpose({
    onFocusFn,
    scrollFn,
    scrollToBottom,
    getChatMessage,
    initWebChatMsg,
})
</script>

<style lang="scss" scoped>
.chat-container {
    width: 100%;
    height: 650px;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    .chat-area {
        flex: 1;
        overflow-y: auto;
        border-bottom: 1px solid #00000020;
        border-top: 1px solid #00000020;
        .loading-box {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 40px;
            background: transparent;
        }

        .message {
            display: flex;
            width: 100%;
            padding: 10px;
            border-radius: 10px;
            margin-bottom: 15px;
            word-wrap: break-word;
        }
        .message-left {
            flex-direction: row;
            align-content: flex-start;
            // background-color: #f5f5f5;
        }

        .message-right {
            flex-direction: row-reverse;
            align-content: flex-start;
            // background-color: #00b0c1;
        }
        .avatar-iocn {
            margin: 0 10px;
        }
        .item {
            .name {
                margin-bottom: 5px;
                display: flex;
                align-self: var(--align-self);
            }
            .text {
                max-width: 450px;
                min-height: 40px;
                display: flex;
                justify-content: var(--align-self);
                .text-box {
                    padding: 10px;
                    border-radius: 8px;
                    width: fit-content;
                    background-color: var(--bg-color);
                    color: #131414;
                    font-size: 14px;
                    font-style: normal;
                    font-weight: 400;
                    line-height: normal;
                    word-break: break-all;
                }
                .img-box {
                    border-radius: 8px;
                    width: 200px;
                    border-radius: 8px;
                    height: 100%;
                }
            }
        }
    }
    .input-area {
        height: 150px;
        position: relative;
        .tools {
            position: absolute;
            left: 0;
            top: 5px;
            height: 30px;
            line-height: 30px;
            :deep(.logo-upload .el-upload) {
                border: none;
                margin-top: 2px;
            }
            .tools-item {
                width: 30px;
                height: 30px;
                cursor: pointer;
                margin-right: 2px;
            }
        }
        .input {
            margin-top: 35px;
            height: 100px;
            overflow: auto;
            :deep(.el-textarea__inner) {
                outline: none;
                border: none;
                resize: none;
                box-shadow: none;
                background: transparent;
                padding: 0;
            }
        }
    }
    .btn {
        display: flex;
        flex-direction: row-reverse;
        align-content: flex-start;
        button {
            text-align: center;
            height: 32px;
            padding: 5px 16px;
            flex-shrink: 0;
            border-radius: 4px;
            background: #f1f1f1;
            outline: none;
            border: none;
            color: #429f4f;
            font-size: 16px;
            font-weight: 400;
        }
    }
}
</style>
二分
JSRUN前端笔记, 是针对前端工程师开放的一个笔记分享平台,是前端工程师记录重点、分享经验的一个笔记本。JSRUN前端采用的 MarkDown 语法 (极客专用语法), 这里属于IT工程师。