前言
该文章记录一下工作中遇到的一些问题,后续将会逐步增加,所有内容均从网上整理而来,加上自己得理解做一个整合,方便工作中使用。
一、需求
- 发送文字、表情包(emoji和jif动图)
- 轮询消息列表
- 上滑加载历史记录
二、组件
- 使用
z-paging组件-聊天记录模式
官方链接
- 发送jif动图时,
[/imgurl]
格式包裹图片链接,然后判断消息类型是img还是text,然后渲染
ts
//判断聊天内容
export function checkType(text: string) {
const regex = /\[(?:\/[^\]]+)\]/;
return regex.test(text) ? 'img' : 'text';
}
三、代码
js
<template>
<z-paging
ref="paging"
use-chat-record-mode
safe-area-inset-bottom
bottom-bg-color="#f8f8f8"
v-model="dataList"
@query="queryList"
@keyboardHeightChange="keyboardHeightChange"
@hidedKeyboard="hidedKeyboard"
>
<view
v-for="(item, index) in dataList"
:key="item.onlyKey"
style="position: relative"
>
<!-- 如果要给聊天item添加长按的popup,请在popup标签上写style="transform: scaleY(-1);",注意style="transform: scaleY(-1);"不要写在最外层,否则可能导致popup被其他聊天item盖住 -->
<!-- <view class="popup" style="transform: scaleY(-1);">popUp</view> -->
<!-- style="transform: scaleY(-1)"必须写,否则会导致列表倒置 -->
<!-- 注意不要直接在chat-item组件标签上设置style,因为在微信小程序中是无效的,请包一层view -->
<view style="transform: scaleY(-1)">
<chat-item :item="item"></chat-item>
</view>
</view>
<!-- 底部聊天输入框 -->
<template #bottom>
<chat-input-bar ref="inputBar" @send="doSend" />
</template>
</z-paging>
</template>
<script setup>
import ChatItem from './components/ChatItem.vue';
import ChatInputBar from './components/ChatInputBar.vue';
import dayjs from 'dayjs';
import { onShow, onLoad } from '@dcloudio/uni-app';
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
import { storeToRefs } from 'pinia';
import { useComStore } from '@/store/com';
import { baseImgUrl, moneyIcon1, moneyIcon2 } from '@/utils/imgUrls';
import {
reqTalkTo,
reqMoreMessage,
reqTalkList,
reqTalkidCheck
} from '@/api/sms';
import { coverImg, checkType } from '@/utils/index';
const comStore = useComStore();
const { userInfo } = storeToRefs(comStore);
const paging = ref(null);
const inputBar = ref(null);
const dataList = ref([]);
const talkid = ref(0);
const otherInfo = ref(null);
const timer = ref(null);
const loopLoading = ref(false);
onLoad(async (option) => {
uni.setNavigationBarTitle({
title: option?.name || ''
});
otherInfo.value = {
name: option?.name || null,
avatar: option?.avatar || null,
uid: option?.uid || null
};
if (option.talkid == 'null' || !option.talkid) {
let res = await reqTalkidCheck({ tuid: option?.uid });
talkid.value = res.data;
} else {
talkid.value = option?.talkid;
}
if (talkid.value && talkid.value !== 'null') {
timer.value = setInterval(receiveMsg, 5000);
}
});
onUnmounted(() => {
timer.value && clearInterval(timer.value);
});
async function queryList(pageNo, pageSize) {
try {
const { data } = await reqTalkList({
talkid: talkid.value,
page: pageNo || 1,
limit: pageSize
});
let newArr = data.list.data.map((item) => {
const content = item.content;
const isMe = item.suid == userInfo.value.id;
const icon =
baseImgUrl + (isMe ? userInfo.value.avatar : otherInfo.value.avatar);
const name = isMe ? userInfo.value.nickname : otherInfo.value.name;
const time = dayjs(item.createtime * 1000).format('MM-DD HH:mm');
const timeShow = false;
const onlyKey = item.createtime + Math.floor(Math.random() * 100001);
const type = checkType(item.content);
return {
isMe,
icon,
name,
time,
content,
timeShow,
type,
onlyKey,
createtime: item.createtime
};
});
let pointTimer = dataList.value.length
? dataList.value[dataList.value.length - 1].createtime
: newArr[0].createtime;
newArr.forEach((item, i) => {
if (i == 0 && dataList.value.length === 0) {
item.timeShow = true;
} else {
const timeDiff = pointTimer - item.createtime >= 60 * 10;
if (timeDiff) {
item.timeShow = true;
pointTimer = item.createtime;
}
}
});
paging.value.complete(newArr);
} catch (error) {
paging.value.complete(false);
} finally {
}
}
// 监听键盘高度改变,请不要直接通过uni.onKeyboardHeightChange监听,否则可能导致z-paging内置的键盘高度改变监听失效(如果不需要切换表情面板则不用写)
function keyboardHeightChange(res) {
inputBar.value && inputBar.value.updateKeyboardHeightChange(res);
}
// 用户尝试隐藏键盘,此时如果表情面板在展示中,应当通知chatInputBar隐藏表情面板(如果不需要切换表情面板则不用写)
function hidedKeyboard() {
inputBar.value && inputBar.value.hidedKeyboard();
}
async function doSend(msg) {
uni.showLoading({
title: '发送中...'
});
try {
const { data } = await reqTalkTo({
tuid: otherInfo.value.uid,
content: msg
});
let pointTimer = dataList.value[0]?.createtime || 0;
let obj = {
time: dayjs().format('MM-DD HH:mm'),
icon: baseImgUrl + userInfo.value.avatar,
name: userInfo.value.nickname,
content: msg,
isMe: true,
timeShow: data.lasttime - pointTimer >= 60 * 10 ? true : false,
createtime: dayjs().unix(),
onlyKey: data.lasttime + Math.floor(Math.random() * 100001),
type: checkType(msg)
};
talkid.value = data.talkid;
paging.value.addChatRecordData(obj);
if (!timer.value) {
setInterval(receiveMsg, 5000);
}
} catch (error) {
//TODO handle the exception
} finally {
uni.hideLoading();
}
}
//轮询接收消息
async function receiveMsg() {
if (loopLoading.value) return;
loopLoading.value = true;
try {
const { data } = await reqMoreMessage({
talkid: talkid.value,
lasttime: dataList.value[0].createtime
});
if (data.length == 0) return;
let newArr = data.map((item) => {
const content = item.content;
const isMe = false;
const icon = baseImgUrl + otherInfo.value.avatar;
const name = otherInfo.value.name;
const time = dayjs(item.createtime * 1000).format('MM-DD HH:mm');
const timeShow = false;
const onlyKey = item.createtime + Math.floor(Math.random() * 100001);
const type = checkType(item.content);
return {
isMe,
icon,
name,
time,
content,
timeShow,
onlyKey,
type,
createtime: item.createtime
};
});
let pointTimer = dataList.value[0]?.createtime || 0;
newArr.forEach((item, i) => {
if (i == 0 && dataList.value.length === 0) {
item.timeShow = true;
} else {
const timeDiff = pointTimer - item.createtime >= 60 * 10;
if (timeDiff) {
item.timeShow = true;
pointTimer = item.createtime;
}
}
paging.value.addChatRecordData(item);
});
// paging.value.complete([...newArr, ...dataList.value]);
} catch (error) {
} finally {
loopLoading.value = false;
}
}
</script>
<style lang="less" scoped></style>
js
//ChatItem-组件-每条聊天信息
<template>
<view class="chat-item">
<text class="chat-time" v-if="item.timeShow">
{{ item.time }}
</text>
<view :class="{ 'chat-container': true, 'chat-location-me': item.isMe }">
<view class="chat-icon-container">
<image class="chat-icon" :src="item.icon" mode="aspectFill" />
</view>
<view class="chat-content-container">
<view
class="chat-text-container-super"
:style="[{ justifyContent: item.isMe ? 'flex-end' : 'flex-start' }]"
>
<view
v-if="item.type == 'text'"
:class="{
'chat-text-container': true,
'chat-text-container-me': item.isMe,
'chat-text-container-other': !item.isMe
}"
>
<text :class="{ 'chat-text': true, 'chat-text-me': item.isMe }">
{{ item.content }}
</text>
</view>
<view class="msg-img" v-if="item.type == 'img'">
<image :src="imgUrl(item.content)" mode="aspectFill"></image>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { baseImgUrl } from '@/utils/imgUrls';
const props = defineProps({
item: {
type: Object,
required: true,
default: {
time: '',
icon: '',
name: '',
content: '',
isMe: false,
timeShow: false
}
}
});
const imgUrl = computed(() => (val) => {
return baseImgUrl + val.replace(/[\[\]]/g, '');
});
</script>
<style scoped lang="less">
.chat-item {
display: flex;
flex-direction: column;
padding: 20rpx;
.chat-time {
padding: 4rpx 0rpx;
text-align: center;
font-size: 22rpx;
color: #aaaaaa;
}
.chat-container {
display: flex;
flex-direction: row;
.chat-icon-container {
.chat-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #eeeeee;
}
}
.chat-content-container {
margin: 0rpx 15rpx;
.chat-user-name {
font-size: 26rpx;
color: #888888;
}
.chat-text-container-super {
display: flex;
flex-direction: row;
.msg-img {
image {
width: 80px;
height: 80px;
}
}
.chat-text-container {
text-align: left;
background-color: #f1f1f1;
border-radius: 8rpx;
padding: 10rpx 15rpx;
margin-top: 10rpx;
/* #ifndef APP-NVUE */
max-width: 500rpx;
/* #endif */
.chat-text {
font-size: 28rpx;
/* #ifndef APP-NVUE */
word-break: break-all;
/* #endif */
/* #ifdef APP-NVUE */
max-width: 500rpx;
/* #endif */
}
.chat-text-me {
color: #000;
}
}
.chat-text-container-other {
position: relative;
&::after {
content: '';
position: absolute;
top: 15px;
left: -3px;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
border-right: 6px solid #f1f1f1;
}
}
.chat-text-container-me {
background-color: #89d961;
position: relative;
&::after {
content: '';
position: absolute;
top: 15px;
right: -9px;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
border-left: 6px solid #89d961;
}
}
}
}
}
.chat-location-me {
flex-direction: row-reverse;
text-align: right;
}
}
</style>
js
//输入框
<!-- z-paging聊天输入框 -->
<template>
<view class="chat-input-bar-container">
<view class="chat-input-bar">
<view class="chat-input-container">
<!-- :adjust-position="false"必须设置,防止键盘弹窗自动上顶,交由z-paging内部处理 -->
<input
:focus="focus"
class="chat-input"
v-model="msg"
:adjust-position="false"
confirm-type="send"
type="text"
placeholder="请输入内容"
@confirm="sendClick"
/>
</view>
<!-- 表情图标(如果不需要切换表情面板则不用写) -->
<view class="emoji-container">
<image
class="emoji-img"
:src="`/static/${emojiType || 'emoji'}.png`"
@click="emojiChange"
></image>
</view>
<view
:class="{
'chat-input-send': true,
'chat-input-send-disabled': !sendEnabled
}"
@click="sendClick"
>
<text class="chat-input-send-text">发送</text>
</view>
</view>
<!-- 表情面板,这里使用height控制隐藏显示是为了有高度变化的动画效果(如果不需要切换表情面板则不用写) -->
<view
class="emoji-panel-container"
:style="[{ height: emojiType === 'keyboard' ? '400rpx' : '0px' }]"
>
<swiper class="swiper" :indicator-dots="true">
<swiper-item>
<scroll-view scroll-y style="height: 100%; flex: 1">
<view class="emoji-img-panel">
<image
v-for="(item, i) in gifArr"
:key="i"
:src="baseImgUrl + item"
mode="aspectFill"
class="emoji-image"
@click="emojiClick(item, 'img')"
></image>
</view>
</scroll-view>
</swiper-item>
<swiper-item>
<scroll-view scroll-y style="height: 100%; flex: 1">
<view class="emoji-panel">
<text
class="emoji-panel-text"
v-for="(item, index) in emojisArr"
:key="index"
@click="emojiClick(item)"
>
{{ item }}
</text>
</view>
</scroll-view>
</swiper-item>
</swiper>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { baseImgUrl } from '@/utils/imgUrls';
const emit = defineEmits(['send']);
const gifArr = [
'/uploads/20250326/ff8d96751e5a65747437b7a47a670374.gif',
'/uploads/20250326/4015ddb929dac71dccb7672962a74922.gif',
'/uploads/20250326/d9569adcb914660ba7aec6cc8fabe00c.gif',
'/uploads/20250326/0e07ff8b4d692f5355c9931be6bbc1de.gif',
'/uploads/20250326/be68665a69b5d0fb414ebfa17f2b7fdc.gif',
'/uploads/20250327/a6d4858b9b854e8a60669623909116b5.gif',
'/uploads/20250327/0abd08e15dc31e3900ad8a794412eb4b.gif',
'/uploads/20250327/773e75c06e4464025e96b9200a9130f2.gif',
'/uploads/20250327/3659e965aac7e043c571ddda65b49d34.gif',
'/uploads/20250327/d62d38a777d40bfe4f21361ccc6e5555.gif',
'/uploads/20250327/3f0cb21bab22d753223c81732a9fd582.gif',
'/uploads/20250327/323f3d946048b855e995309f627a7389.gif'
];
const emojisArr = [
'😊',
'😁',
'😀',
'😃',
'😣',
'😞',
'😩',
'😫',
'😲',
'😟',
'😦',
'😜',
'😳',
'😋',
'😥',
'😰',
'🤠',
'😎',
'😇',
'😉',
'😭',
'😈',
'😕',
'😏',
'😘',
'😤',
'😡',
'😅',
'😬',
'😺',
'😻',
'😽',
'😼',
'🙈',
'🙉',
'🙊',
'🔥',
'👍',
'👎',
'👌',
'✌️',
'🙏',
'💪',
'👻'
];
const msg = ref('');
const focus = ref(false);
const emojiType = ref('');
const props = defineProps({
disabled: {
type: Boolean,
default: false
}
});
const sendEnabled = computed(() => {
return !props.disabled && msg.value.length;
});
// 更新了键盘高度
function updateKeyboardHeightChange(res) {
if (res.height > 0) {
// 键盘展开,将emojiType设置为emoji
emojiType.value = 'emoji';
}
}
// 用户尝试隐藏键盘
function hidedKeyboard() {
if (emojiType.value === 'keyboard') {
emojiType.value = '';
}
}
// 点击了切换表情面板/键盘
function emojiChange() {
// this.$emit('emojiTypeChange', this.emojiType);
if (emojiType.value === 'keyboard') {
// 点击了键盘,展示键盘
focus.value = true;
} else {
// 点击了切换表情面板
focus.value = false;
// 隐藏键盘
uni.hideKeyboard();
}
emojiType.value =
!emojiType.value || emojiType.value === 'emoji' ? 'keyboard' : 'emoji';
}
// 点击了某个表情,将其插入输入内容中
function emojiClick(text, type) {
if (type == 'img') {
emit('send', `[${text}]`);
} else {
msg.value += text;
}
}
// 点击了发送按钮
function sendClick() {
if (!sendEnabled.value) return;
emit('send', msg.value);
msg.value = '';
}
defineExpose({
updateKeyboardHeightChange,
hidedKeyboard
});
</script>
<style scoped lang="less">
.chat-input-bar {
display: flex;
flex-direction: row;
align-items: center;
border-top: solid 1px #f5f5f5;
background-color: #f8f8f8;
padding: 10rpx 20rpx;
}
.chat-input-container {
flex: 1;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
padding: 15rpx;
background-color: white;
border-radius: 10rpx;
}
.chat-input {
flex: 1;
font-size: 28rpx;
}
.emoji-container {
width: 54rpx;
height: 54rpx;
margin: 10rpx 0rpx 10rpx 20rpx;
}
.emoji-img {
width: 54rpx;
height: 54rpx;
}
.chat-input-send {
background-color: #007aff;
margin: 10rpx 10rpx 10rpx 20rpx;
border-radius: 10rpx;
width: 110rpx;
height: 60rpx;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
justify-content: center;
align-items: center;
}
.chat-input-send-disabled {
background-color: #bbbbbb;
}
.chat-input-send-text {
color: white;
font-size: 26rpx;
}
.swiper {
height: 100%;
.swiper-item {
background: pink;
}
}
.emoji-panel-container {
background-color: #f8f8f8;
overflow: hidden;
transition-property: height;
transition-duration: 0.15s;
/* #ifndef APP-NVUE */
will-change: height;
/* #endif */
}
.emoji-img-panel {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
flex-wrap: wrap;
padding-right: 10rpx;
padding-left: 15rpx;
padding-bottom: 10rpx;
/* 调整每行间距 */
row-gap: 10px;
/* 调整每列间距 */
column-gap: calc((100% - 5 * 60px) / 4);
/* 限制每行最多 5 个元素 */
max-width: calc(5 * 60px + 4 * ((100% - 5 * 60px) / 4));
.emoji-image {
width: 60px;
height: 60px;
}
}
.emoji-panel {
font-size: 30rpx;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
flex-wrap: wrap;
padding-right: 10rpx;
padding-left: 15rpx;
padding-bottom: 10rpx;
.emoji-panel-text {
font-size: 50rpx;
margin-left: 15rpx;
margin-top: 20rpx;
}
}
</style>