uniapp-微信小程序-聊天界面-发送表情包

前言

该文章记录一下工作中遇到的一些问题,后续将会逐步增加,所有内容均从网上整理而来,加上自己得理解做一个整合,方便工作中使用。

一、需求

  • 发送文字、表情包(emoji和jif动图)
  • 轮询消息列表
  • 上滑加载历史记录

二、组件

  • 发送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>
相关推荐
珹洺6 分钟前
Java-servlet(十)使用过滤器,请求调度程序和Servlet线程(附带图谱表格更好对比理解)
java·开发语言·前端·hive·hadoop·servlet·html
熙曦Sakura20 分钟前
【C++】map
前端·c++
黑贝是条狗23 分钟前
html 列表循环滚动,动态初始化字段数据
前端·javascript·html
萌萌哒草头将军40 分钟前
🔥🔥🔥4 月 1 日尤雨溪突然宣布使用 Go 语言重写 Rolldown 和 Oxc!
前端·javascript·vue.js
搬砖的阿wei43 分钟前
从零开始学 Flask:构建你的第一个 Web 应用
前端·后端·python·flask
萌萌哒草头将军1 小时前
🏖️ TanStack:一套为现代 Web 开发打造的强大、无头且类型安全的库集合 🔥
前端·javascript·vue.js
指针满天飞1 小时前
同步、异步、Promise、then、async/await
前端·javascript·vue.js
Alang1 小时前
记一次错误使用 useEffect 导致电脑差点“报废”
前端·react.js
牛奶2 小时前
前端学AI:LangGraph学习-基础概念
前端·langchain·ai编程
welkin2 小时前
算法区间合并问题
前端·算法