Skip to content

问诊室消息收发(一) ​

本页对应 问诊室消息收发,共 7 段视频,约 1 小时 5 分。

学习目标 ​

  1. 能够使用 Socket.IO 监听 receive chat message 事件,将医生单条消息追加到消息列表,并通过控制台和 Vue Devtools 验证消息内容与 messageType。
  2. 能够封装文字/图片消息组件,通过 props 接收消息数据与 type,使用 v-if 区分 type=1 的文字消息和 type=4 的图片消息,并正确渲染头像、时间、文字内容及图片 URL。
  3. 能够为消息列表渲染添加 type<=4 条件,避免非文字/图片消息误渲染,并使用 reverse 样式区分医生与患者消息的左右布局。
  4. 能够在接收到消息后通过 Socket.IO 向服务端发送已读消息 ID,并使用 dayjs 将消息时间格式化为 HH:mm:ss 显示。
  5. 能够按 type=22 条件加载处方消息组件,向组件传入处方数据并渲染患者信息与药品列表。
  6. 能够按 type=23 加载医生评价组件,使用 v-model 获取评分和评价内容,使用计算属性统计字数并控制提交按钮启用状态,通过点击事件切换匿名状态。

知识点 ​

先明确消息类型,再决定渲染方式 ​

为什么需要:问诊室本质是患者和医生聊天,但消息不只有文字,还有图片、通知、处方、评价等。不同类型展示结构不同,不能用一个模板硬套。
怎么想:先看消息里的类型值,再分流。转写中明确:文字消息类型为 1,图片消息类型为 4;患者、通知等还有 21、31、32、33,处方是 22,医生评价是 23。
怎么做:在遍历消息列表时加 v-if,只有文字/图片消息才加载对应组件;后续处方、评价再按类型追加组件。
易错点:不要只处理当前已有的三条消息,必须模拟医生发文字和图片;类型判断不能漏,否则通知消息也会被当成聊天内容渲染,出现头像错乱、图片重复等问题。

模拟医生端发消息并接收 ​

为什么需要:本地页面一开始只有患者信息和通知,没有医生发来的文字、图片,必须构造测试数据。
怎么想:转写中使用“测试抢单”客户端模拟医生。先复制当前问诊订单的 orderId,在测试端抢单,让这个医生和当前患者建立问诊关系,然后医生端发送文字和图片。
怎么做:前端用 socket.on 监听接收消息事件,把新消息 push 到消息列表,并打印调试。核心代码是:

js
socket.on('receive chat message', (message) => {
  console.log(message)
  messageList.value.push(message)
})

易错点:接收消息用 on,给服务端发消息才用 emit;订单号要复制完整;列表追加要用 push。转写中一开始用了 unshift,导致刷新后消息顺序颠倒,后来改回 push 才正常。

用 message-info 组件渲染文字和图片 ​

为什么需要:文字消息和图片消息结构相似,都有头像、时间、内容区,只是内容区一个放文字、一个放图片,封装组件更利于复用。
怎么想:组件接收两个关键数据:info 接收整条消息,type 接收消息类型。页面中只在类型为 1 或 4 时加载组件。转写中为省事写过 <=4,本质是只放行 1 和 4;更稳妥是显式判断。
怎么做:组件内按类型渲染:

vue
<img :src="info.fromAvatar" alt="医生头像">
<span>{{ dataFormat(info.createTime) }}</span>

<p v-if="type === 1">{{ info.msg.content }}</p>
<img
  v-if="type === 4"
  :src="info.msg.picture.url"
  alt="医生发送的图片"
>

页面使用时可写:

vue
<message-info
  v-if="message.msg.type <= 4"
  :type="message.msg.type"
  :info="message"
/>

易错点:info 要传整条消息,不能只传 message.msg.content,否则头像、时间拿不到;图片路径不能写死,要从 info.msg.picture.url 取;医生消息一般在左侧,患者消息在右侧,模板里的 reverse 用来控制左右,处理医生回复时先不要加。

已读状态和时间格式化 ​

为什么需要:收到医生消息后,要告诉后端“用户已读”;聊天时间通常只展示时分秒,完整日期不友好。
怎么想:已读是前端给服务端发消息,所以用 emit,把消息 ID 传过去;时间格式化用 dayjs。
怎么做:接收消息后调用已读事件;安装 dayjs,封装格式化方法:

js
import dayjs from 'dayjs'

const dataFormat = (time) => dayjs(time).format('HH:mm:ss')

组件中把 info.createTime 传给 dataFormat。
易错点:emit 和 on 不要混;格式化方法要放在使用它的组件里;格式大小写要按 dayjs 规则写,HH 是小时,mm 是分钟,ss 是秒。

处方与医生评价消息的扩展 ​

为什么需要:医生开处方、结束问诊后评价,也会作为消息进入问诊室,必须能展示对应模板。
怎么想:处方类型为 22,评价类型为 23。处方新建组件并在 v-if 为 22 时加载,把 message.msg 传入;评价要区分“未评价/已评价”,转写中先处理未评价。
怎么做:评价页用 uni-rate 获取分数,用输入框获取评价内容,用点击事件切换匿名状态;字数统计和按钮可用状态用计算属性:

js
const wordCount = computed(() => formData.value.content.length)
const btnEnable = computed(() => formData.value.score > 0)

匿名切换利用转写中的小算法:

js
formData.value.匿名状态 = Math.abs(formData.value.匿名状态 - 1)

易错点:组件使用要写在循环内部,否则拿不到当前遍历消息;评价必须先选星,按钮才可提交;字数统计用 content.length;匿名状态在 0 和 1 之间切换,取绝对值可避免出现 -1。

先看消息类型:为什么不能把所有消息都当聊天内容渲染 ​

为什么需要:问诊室里的消息不只文字和图片,还有患者信息、通知、处方、评价等。转写稿里反复强调“等于21展示患者,等于31/32/33展示通知”,所以渲染前必须先看 type。
怎么想:文字消息 type === 1,图片消息 type === 4;只有这两个值才交给文字/图片消息组件处理。
怎么做:在消息列表循环里先加条件,满足再加载 message-info 组件。
易错点:转写稿中一开始没加条件,结果其他类型消息也加载了组件,头像和内容错乱。不要图省事写 type <= 4,当前虽能“投机取巧”,但以后出现 2、3 类型会误渲染。

html
<message-info
  v-if="message.msg.type === 1 || message.msg.type === 4"
  :info="message"
  :type="message.msg.type"
/>

(原笔记此处配有问诊室消息列表截图,alt:问诊室文字与图片消息列表)

接收医生单条消息:从“拿列表”到“逐条追加” ​

为什么需要:建立连接时只拿到一次消息列表,医生后续发的消息要实时接收。
怎么想:接收服务端数据用 socket.on,事件名按转写稿为 receiveChatMessage;收到后追加到 messageList。
怎么做:先打印 message 看结构,再 push 到列表。转写稿里先看到文字消息 content,再看到图片消息 messageType === 4 且 msg.picture.url 有值。
易错点:转写稿中“an shift”应纠正为 unshift。最初列表用了 unshift 导致刷新后顺序颠倒,后改为 push 从后往前追加,顺序才正常。

js
socket.on('receiveChatMessage', (message) => {
  console.log(message)
  messageList.value.push(message)
})

封装 message-info 组件:模板结构要与数据字段对应 ​

为什么需要:文字和图片消息结构相似,都有头像、时间、内容区,封装组件便于复用。
怎么想:组件接收两个外部数据:info 接收整条消息,type 判断文字还是图片。转写稿中一开始只传了 msg.content,导致头像拿不到;后来改为传整条 message。
怎么做:头像取 info.fromAvatar,时间取 info.createTime,文字取 info.msg.content,图片取 info.msg.picture.url。
易错点:图片路径不要写死。转写稿中先写死图片,后来才发现真实路径在 msg.picture.url。

html
<div class="message-info">
  <img :src="info.fromAvatar" alt="医生头像" />
  <span>{{ dayjs(info.createTime).format('HH:mm:ss') }}</span>

  <div v-if="type === 1">{{ info.msg.content }}</div>
  <img v-if="type === 4" :src="info.msg.picture.url" alt="图片消息" />
</div>
js
const props = defineProps({
  info: { type: Object, required: true },
  type: { type: Number, required: true }
})

reverse 类:区分“医生发的”和“自己发的” ​

为什么需要:聊天界面通常医生在左、患者在右,需要靠样式区分方向。
怎么想:转写稿里的“reward”应纠正为 reverse,即反转布局。加上 reverse 内容靠右,不加靠左。
怎么做:当前先处理医生回复,医生消息在左侧,所以先不加 reverse;后续患者自己发的消息再加。
易错点:不要把所有消息都加 reverse,否则左右方向全反。

时间格式化与已读回执:两个补充处理 ​

为什么需要:直接展示 createTime 不友好;收到消息后还要告诉服务端“已读”。
怎么想:转写稿里的“GGS”应纠正为 dayjs,用 format('HH:mm:ss') 只保留时分秒;已读用 emit 发消息 ID。
怎么做:安装并引入 dayjs,封装 dateFormat 方法;收到消息后按接口文档 emit 已读事件。
易错点:转写稿里的“imit”应纠正为 emit;dayjs 要在使用组件内引入,别放错文件。

js
import dayjs from 'dayjs'

const dateFormat = (time) => dayjs(time).format('HH:mm:ss')
js
// 已读事件名以接口文档为准
socket.emit('已读事件名', message.id)

调试顺序:先通数据,再调样式 ​

转写稿的调试路径很清楚:先模拟医生抢单,拿到订单号;医生发“你好”和图片;页面打印 message;检查 messageList 最后一条;再检查组件 props.info 是否收到整条数据;最后改图片路径。这样能快速定位“是没收到、没传对、还是没渲染”。常见坑是:只传 msg 导致头像缺失、图片路径写死、条件没加导致头像错乱、列表用 unshift 导致刷新顺序颠倒。按“接收 → 追加 → 传参 → 条件渲染 → 格式化”的顺序写,问题最容易收敛。

先明确本节要补的两件事 ​

文字、图片消息的接收和基础渲染跑通后,本节还要补两个细节:一是收到医生消息后,要通知服务端“这条消息用户已经读了”;二是聊天时间不能直接展示完整时间串,要格式化成时:分:秒。转写稿里这两个点都不复杂,但都容易放错位置,下面按“为什么需要→怎么想→怎么做→易错点”拆开。

知识点一:收到消息后发送已读回执 ​

为什么需要:消息列表里展示出医生回复,只代表前端收到了数据,服务端并不知道用户已经读过。如果不回执,后端无法维护消息状态,未读数、已读状态都会不准。

怎么想:接收消息用 socket.on('receive chat message', ...);给服务端发消息则用 socket.emit。所以收到单条消息、把它追加进列表后,再发一次“已读”事件,把这条消息的 ID 传过去即可。转写稿里明确说:接口文档中有“修改信息已读”的事件,需要传消息 ID。

怎么做:在接收消息的回调里先 push,再 emit。事件名以接口文档为准,这里用常量占位,避免写死:

js
const READ_EVENT = '修改信息已读' // 事件名以接口文档为准

socket.on('receive chat message', (message) => {
  messageList.value.push(message)
  socket.emit(READ_EVENT, message.msg.id)
})

易错点:不要只 console.log 不落列表;不要用 unshift 做初始化追加,转写稿后面发现刷新后顺序错乱,根因就是初始化列表用了 unshift,改成 push 后顺序才正常。已读事件别自己猜英文名,按文档来。

知识点二:用 dayjs 格式化时间显示 ​

为什么需要:聊天场景里通常只关心“时:分:秒”,完整日期串太长,界面不友好。转写稿说“这个时间不是特别友好,只保留时分秒”。

怎么想:时间格式化交给 dayjs。给它一个时间,再用 format 指定格式:H 小时、m 分钟、s 秒;要两位补零就用 HH:mm:ss。不要自己手写补零逻辑。

怎么做:先安装 dayjs,在组件里引入并封装方法:

js
import dayjs from 'dayjs'

const dataFormat = (time) => dayjs(time).format('HH:mm:ss')

模板里把 createTime 传进去:

html
<span>{{ dataFormat(info.createTime) }}</span>

易错点:format 里大小写有含义,HH:mm:ss 和 hh:mm:ss 不一样;方法要放在使用它的组件里,不要放到页面外层;引入 dayjs 也要在组件内。转写稿里就出现过“放错地方”,后来把方法和引包都挪到组件里才生效。

知识点三:在组件里完成格式化的调试过程 ​

为什么需要:消息模板已经拆成 message-info 组件,时间展示发生在组件内部,所以格式化逻辑也应跟着组件走。

怎么想:先确认数据从父页面传到组件:info 接收整条消息,type 接收消息类型。组件里拿到 info.createTime 后再调用 dataFormat。转写稿的调试路径是:先传 info,在组件里打印/查看 props,确认时间字段存在,再把模板里的原始时间替换成方法调用。

怎么做:组件定义 props,模板里调用方法;保存后发一条医生消息,观察时间是否变成 HH:mm:ss。同时保留文字/图片的条件判断:类型为 1 显示文字,类型为 4 显示图片,图片路径用 info.msg.picture.url,不要写死。

html
<img :src="info.msg.picture.url" alt="医生发送的图片消息" />

易错点:不要把所有消息都套进文字/图片模板,否则头像和内容会重复错乱;v-if 要判断类型 1 或 4;图片地址不要固定成某张测试图。

易错点汇总 ​

已读回执要“收到即发”,但事件名和字段以接口文档为准;时间格式化要放组件内,格式用 HH:mm:ss;消息列表初始化用 push,不要用 unshift;文字/图片渲染必须加类型条件。按这个顺序写,本节两个补充点就能稳定落地。

代码示例 ​

1. 问诊室接收医生消息并追加到消息列表(room/index.vue) ​

vue
<template>
  <view class="room">
    <view class="msg-list">
      <template v-for="item in messageList" :key="item.id">
        <!-- 只有文字(1)和图片(4)才交给消息组件渲染 -->
        <message-info v-if="item.msgType <= 4" :type="item.msgType" :info="item" />
      </template>
    </view>
  </view>
</template>

<script setup>
import { ref } from 'vue'
import MessageInfo from './components/message-info.vue'

// 消息列表
const messageList = ref([])

// 接收医生端回复的单条消息
socket.on('receive chat message', (message) => {
  console.log(message) // 先打印,核对 msgType 是 1 还是 4

  // 追加到消息列表:必须用 push,不能用 unshift
  messageList.value.push(message)

  // 告诉服务端:这条消息用户已经收到了(已读)
  socket.emit('update msg status', message.id)
})
</script>

<style lang="scss" scoped>
.room {
  .msg-list {
    padding: 20rpx;
  }
}
</style>

要点解释:

  • socket.on('receive chat message', cb) 只负责接收别人的数据;要给服务端发数据必须用 socket.emit(...),这是本节的第一个补充点(修改消息已读状态)。
  • 收到消息后 一定要 push。转写稿里最初写成了 unshift,结果一刷新消息顺序整个颠倒,改成 push 才恢复正常,这是本节最典型的易错点。
  • console.log(message) 不是多余的:先确认这条消息的 msgType 到底是 1(文字)还是 4(图片),再去写渲染条件,否则条件写错会“渲染出一堆重复头像”。
  • 外层遍历时加 v-if="item.msgType <= 4" 做过滤。因为当前消息类型里没有 2、3,所以用 <= 4 就等价于“只有 1 和 4”,不加这个条件,21/22/23/31 这些通知、处方、评价消息也会被塞进消息组件里。

2. 文字 / 图片消息渲染组件(room/components/message-info.vue) ​

vue
<template>
  <div class="message-info">
    <!-- 头像 -->
    <image class="avatar" :src="info.fromAvatar" mode="aspectFill" />

    <div class="body">
      <!-- 时间:用 dayjs 格式化,只留时分秒 -->
      <p class="time">{{ dataFormat(info.createTime) }}</p>

      <!-- 文字消息:type === 1 -->
      <p class="content" v-if="type === 1">{{ info.msg.content }}</p>

      <!-- 图片消息:type === 4,路径来自 info.msg.picture.url -->
      <image
        class="picture"
        v-if="type === 4"
        :src="info.msg.picture.url"
        mode="widthFix"
      />
    </div>
  </div>
</template>

<script setup>
import dayjs from 'dayjs'

const props = defineProps({
  // 整条消息(不是只传 msg.content),头像、时间都靠它
  info: { type: Object, required: true },
  // 1 文字消息,4 图片消息
  type: { type: Number, required: true }
})

// 时间格式化:只显示 时:分:秒
const dataFormat = (time) => dayjs(time).format('HH:mm:ss')
</script>

<style lang="scss" scoped>
.message-info {
  display: flex;
  padding: 20rpx 0;

  .avatar {
    width: 80rpx;
    height: 80rpx;
    border-radius: 50%;
  }

  .body {
    margin-left: 20rpx;

    .time {
      font-size: 24rpx;
      color: #999;
    }

    .content {
      margin-top: 10rpx;
      padding: 16rpx 24rpx;
      background: #fff;
      border-radius: 12rpx;
    }

    .picture {
      margin-top: 10rpx;
      width: 320rpx;
      border-radius: 12rpx;
    }
  }
}
</style>

要点解释:

  • 父组件传的是 整条消息 info,不是 msg.content。转写稿里第一次只传了 msg.content,结果头像拿不到,info.fromAvatar 是 undefined,头像就乱了——这是易错点。
  • 文字和图片用两个互斥的 v-if(type === 1 / type === 4)区分,同一时刻只会渲染其中一个。
  • 图片地址必须是 info.msg.picture.url,不能写死。转写稿里一开始写死了一个固定图片,页面能显示但不是医生发的那张。
  • 时间不要直接输出原始时间戳,用 dayjs(time).format('HH:mm:ss'),HH 小时、mm 分钟、ss 秒,两位补零用大写/双写。
  • 医生消息默认在左侧,所以不带 reverse 样式;reverse 是留给“自己发出去的消息”靠右显示的,这里不要加。

3. 医生评价组件:表单数据获取、字数统计与按钮校验(room/components/rate-info.vue) ​

vue
<template>
  <div class="rate-info">
    <!-- 评分:v-model 拿到分数 -->
    <uni-rate v-model="formData.score" :max="5" />

    <!-- 评价内容:v-model 拿到文本,maxlength 限制 150 -->
    <uni-easyinput
      v-model="formData.content"
      type="textarea"
      :maxlength="150"
      placeholder="请输入评价内容"
    />
    <p class="count">{{ wordCount }}/150</p>

    <!-- 是否匿名:点击切换图标 + 切换 0/1 -->
    <div class="anonymous" @click="toggleAnonymous">
      <uni-icons
        :type="formData.anonymous === 1 ? 'checkbox-filled' : 'circle'"
        size="20"
      />
      <span>匿名评价</span>
    </div>

    <!-- 未评分时按钮禁用 -->
    <button class="submit" :disabled="!btnEnable">提交评价</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

// 评价相关的三个数据
const formData = ref({
  score: 0,      // 星星分数
  content: '',   // 评价内容
  anonymous: 0   // 是否匿名:0 不匿名,1 匿名
})

// 字数统计(计算属性)
const wordCount = computed(() => formData.value.content.length)

// 是否允许提交:分数大于 0 才允许
const btnEnable = computed(() => formData.value.score > 0)

// 匿名状态在 0 和 1 之间切换
const toggleAnonymous = () => {
  formData.value.anonymous = Math.abs(formData.value.anonymous - 1)
}
</script>

<style lang="scss" scoped>
.rate-info {
  padding: 20rpx;

  .count {
    text-align: right;
    font-size: 24rpx;
    color: #999;
  }

  .anonymous {
    display: flex;
    align-items: center;

    span {
      margin-left: 10rpx;
      font-size: 26rpx;
    }
  }

  .submit {
    margin-top: 30rpx;
  }
}
</style>

要点解释:

  • score 和 content 直接用 v-model 绑定到 formData,就能双向拿到用户输入;评分不点就是初始值 0,输入框不填就是空串。
  • 字数统计用 computed,formData.value.content.length,输入时实时变化;输入框本身再用 :maxlength="150" 从源头限制长度,两件事不要混在一起做。
  • 提交按钮的禁用状态用 computed 的 btnEnable(score > 0),模板里 :disabled="!btnEnable" 取反。易错点:disabled 不要写死成固定值,否则选了星也不会高亮可点。
  • 匿名切换用的是 Math.abs(formData.value.anonymous - 1):0 减 1 得 -1,取绝对值得 1;1 减 1 得 0,正好实现 0/1 来回切换。必须取绝对值,否则会出现 -1。
  • 图标用 formData.anonymous === 1 三元判断:勾选显示 checkbox-filled,未勾选显示 circle;点击事件绑在外层 div 上,点文字也能切换。

常见错误 ​

  1. 刷新后消息顺序错乱,新消息跑到历史消息前面 → 历史消息接收时用了 unshift,与后续实时消息追加方向不一致 → 统一改为 push,或接收后按时间字段统一排序。
  2. 文字/图片消息渲染后头像重复、通知消息也被套用聊天模板 → 模板加载时未判断 message.msg.type → 在 v-for 内显式判断 type === 1 || type === 4 再加载消息组件。
  3. 图片消息显示的是固定图片,不是医生发送的图片 → 图片路径写死,未取消息对象中的真实地址 → 改为读取 info.msg.picture.url 等接口返回字段。
  4. 时间显示为完整原始格式,聊天区不友好 → 直接渲染 createTime,未做格式化 → 引入 dayjs,在组件内封装格式化方法,仅显示时分秒。
  5. 收到消息后后端仍显示未读 → 只监听了接收事件,未发送已读回执 → 接收消息后通过 emit 发送消息 ID,通知服务端修改已读状态。
  6. 处方/评价组件拿不到当前循环项数据 → 组件写在 v-for 循环外,作用域内没有当前 message → 将组件放入循环内,并通过 props 传入当前消息对象。
  7. 评价提交按钮一直禁用或一直可点 → disabled 写死,未根据评分动态计算 → 用计算属性判断 score > 0,模板中绑定 :disabled="!enable"。
  8. 匿名勾选切换无效或出现负值 → 直接对 0/1 做减法,未处理 -1 → 使用 Math.abs(current - 1) 在 0/1 间切换,并由单一状态驱动图标。
  9. 字数统计不随输入变化 → 模板中直接写 content.length 或未使用响应式计算 → 用 computed 基于表单内容返回字数,再插值展示。
  10. 只给消息组件传了 msg.content,导致头像、时间缺失 → 传参范围过小 → 改为传整条消息对象,组件内按需取字段。

练习 ​

  1. 实现问诊室接收医生文字消息和图片消息:接收后追加到消息列表,并验证刷新后顺序不再错乱。
  2. 封装消息展示组件:根据 type 分别渲染文字或图片,头像、时间、图片地址均从传入的完整消息对象中获取。
  3. 完成消息已读回执:收到医生消息后发送消息 ID 给服务端,并将时间格式化为“时:分:秒”展示。
  4. 实现处方消息 type === 22 的条件渲染:组件放入循环内,传入当前消息,并渲染处方中的药品列表。
  5. 完成医生评价交互:星级评分、评价内容、字数统计、匿名切换、提交按钮可用状态验证。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1优医咨询-问诊室接收文字消息07:28
2优医咨询-问诊室文字图片消息渲染15:53
3优医咨询-格式化时显示和消息已读状态07:24
4优医咨询-问诊室处方消息09:15
5优医咨询-医生评价模板07:04
6优医咨询-获取医生评价数据13:07
7优医咨询-医生评价字数统计和数据验证05:28

小结 ​

  1. 消息渲染要先按 type 做显式分流,避免用模糊条件把通知、处方、评价等消息误套进聊天模板。
  2. 列表追加方向必须与历史消息加载策略一致,否则实时消息和刷新后的顺序会冲突。
  3. 消息组件应接收完整消息对象,而不是只传内容字段,否则头像、时间、图片等扩展字段容易丢失。
  4. 实时消息链路要形成“接收消息 + 已读回执”的闭环,不能只做前端展示。
  5. 时间、字数、按钮可用状态等展示逻辑应集中到工具函数或计算属性中,避免模板里堆判断。
  6. 循环内使用的子组件必须放在 v-for 作用域内,并显式传入当前项,否则条件渲染和取数都会出问题。
  7. 交互状态要由单一数据源驱动,如评分、匿名状态、按钮禁用状态,避免写死属性或直接操作 DOM。
  8. 排查渲染问题前先验证数据流:看接口消息结构、看 props 是否传入、看 v-for 当前项,再改模板。