Skip to content

问诊室 WebSocket 消息(二) ​

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

学习目标 ​

  • 能够基于订单状态控制问诊室状态栏与操作栏的条件渲染,并验证待接诊、接诊中、已结束时输入框和图片上传的禁用/启用状态。
  • 能够实现文字消息的输入收集、回车发送、Socket 消息发送,并接收服务端消息完成发送方与接收方文本渲染、时间格式化和消息列表自动滚动。
  • 能够实现图片上传、图片消息发送与接收渲染,并验证患者端与医生端图片消息可正常展示。
  • 能够实现聊天记录首次进入定位最新消息、下拉刷新加载更多、记录最早消息时间,并在无更多记录时关闭加载状态和给出轻提示。
  • 能够实现未读消息数量展示,并在进入问诊室及收到新消息时调用已读接口更新消息状态。
  • 能够渲染处方消息卡片,并在点击“查看原始处方”时根据处方 ID 请求图片地址并完成图片预览。

知识点 ​

先明确目标:状态栏和操作栏都要依赖订单状态 ​

为什么需要:上一节已经拿到问诊室订单数据,但页面还不会随订单状态变化。待接诊、接诊中、已结束应该显示不同状态栏;只有接诊中才允许输入文字和上传图片。
怎么想:不要在两个子组件里各自请求订单,而是由父组件把订单状态、倒计时、是否禁用通过 props 传下去。子组件只负责“根据传入值渲染”。
怎么做:先改状态栏组件 RoomStatus,暴露 status 和 countDown;再改操作栏组件 RoomAction,暴露 disable;最后在问诊室父组件里用真实订单数据接线。
易错点:状态判断不要写字符串魔法值,要用项目里的 OrderType 枚举;倒计时单位是秒,Vant 的 CountDown 要毫秒。

状态栏组件:用 props 接收状态与倒计时 ​

为什么需要:状态栏有三块内容:待接诊、接诊中、已结束。接诊中还要显示倒计时,所以组件必须知道当前订单状态和剩余时间。
怎么想:status 决定显示哪块 DIV,countDown 只在接诊中且有值时显示倒计时。两个值都不一定存在,因此用可选 props。
怎么做:

vue
<!-- RoomStatus.vue -->
<template>
  <div>
    <div v-if="status === OrderType.ConsultWait">待接诊</div>

    <div v-else-if="status === OrderType.ConsultChat">
      接诊中
      <CountDown v-if="countDown" :time="countDown * 1000" />
    </div>

    <div
      v-else-if="
        status === OrderType.ConsultComplete ||
        status === OrderType.ConsultCancel
      "
    >
      已结束
    </div>
  </div>
</template>

<script setup lang="ts">
import { CountDown } from 'vant'
import { OrderType } from '@/enums'

defineProps<{
  status?: OrderType
  countDown?: number
}>()
</script>

易错点:转写里提到“把类型去掉再点枚举”,本质是不要在运行时把 OrderType 只当类型使用;模板里比较枚举时,要保证导入的是值。countDown 可能为 undefined,直接 countDown * 1000 会有类型错误,所以先 v-if="countDown"。

倒计时渲染:秒和毫秒的转换坑 ​

为什么需要:后端返回的倒计时通常是秒,而 Vant 的 CountDown 组件 time 属性要求毫秒。
怎么想:组件只接收“秒”,在模板里乘 1000 转成毫秒;父组件传真实 consult.countDown 即可。
怎么做:先用写死数据调试:status 传 OrderType.ConsultChat,countDown 传 10000,观察倒计时是否出现;再把 status 改成 OrderType.ConsultComplete,确认显示“已结束”。
易错点:不要把 10000 直接当毫秒传,否则显示只有 10 秒;也不要漏掉 countDown 的空值判断。转写里口述“2小时”,实际以组件显示为准,调试时重点看是否按秒转毫秒。

操作栏组件:用 disable 控制输入和上传 ​

为什么需要:待接诊、已结束都不应该让患者发消息或传图片;只有接诊中才开放。
怎么想:操作栏不自己判断订单,而是接收一个布尔值 disable:true 禁用,false 开启。父组件负责判断“是否接诊中”。
怎么做:

vue
<!-- RoomAction.vue -->
<template>
  <van-field :disabled="disable" />
  <van-uploader :disabled="disable" />
</template>

<script setup lang="ts">
defineProps<{
  disable: boolean
}>()
</script>

父组件接线:

vue
<RoomStatus
  :status="consult.status"
  :count-down="consult.countDown"
/>

<RoomAction
  :disable="consult.status !== OrderType.ConsultChat"
/>

易错点:判断条件写成“等于接诊中才开启”,不要写成“等于待接诊才禁用”,否则已结束、取消等状态会漏掉。disable 在子组件里定义了,模板中必须真正绑定到输入框和上传组件,否则父组件传了也不生效。

父组件接线与调试:先写死,再换真实数据 ​

为什么需要:直接上真实数据时,状态可能不齐,难以判断是渲染问题还是数据问题。
怎么想:先写死枚举验证三种状态,再换成 consult.status、consult.countDown,最后用超级医生接诊改变订单状态做联调。
怎么做:先传 OrderType.ConsultWait 看“待接诊”;改 OrderType.ConsultChat 并传倒计时看“接诊中”;改 OrderType.ConsultComplete 看“已结束”。然后复制订单 ID,在超级医生里抢单/接诊,回到问诊室刷新,确认状态变为咨询中,输入框和上传按钮可用。
易错点:真实订单数据可能为空,父组件可用可选链兜底;状态切换后要刷新或等待 socket 数据更新,不要只看静态页面就判断失败。

本节目标:先把文字从输入框送到父组件,再通过 socket 发出去 ​

这一节对应“问诊室-文字聊天-发送文字”。此时订单已经处于“咨询中”,操作栏不再禁用,用户可以输入文字。发送文字要拆成两段:第一段在操作栏组件里收集输入内容,并在回车时通过自定义事件交给父组件;第二段在问诊室父组件里接收文字,再按接口文档约定,用 socket.emit 触发 send-message,把消息发给服务端。渲染文字是下一节的事,本节先把“发得出去”做扎实。

为什么需要拆成两段? 输入框在 RoomAction 操作栏组件里,而 socket 发送逻辑和订单详情在问诊室父组件里。输入框组件不应该直接耦合订单和 socket,所以用自定义事件把文字“上交”。

怎么想? 操作栏只负责“收集 + 提交 + 清空”;父组件负责“接收 + 组装消息 + 发送”。这样职责清楚,也方便下一节复用接收逻辑。

怎么做? 在操作栏组件中定义 text,绑定到输入框;监听回车,触发 send-text 事件,把 text.value 传出去,并立即清空输入框。

ts
const text = ref('')
const emit = defineEmits<{
  (e: 'send-text', text: string): void
}>()

const sendText = () => {
  emit('send-text', text.value)
  text.value = ''
}

模板中给输入框绑定:

html
<van-field v-model="text" @keyup.enter="sendText" />

易错点: 事件名不要随手写成 sendText 又监听 send-text;emit 后忘记清空输入框,用户会重复发送;回车事件要绑在输入框上,不要绑到外层容器导致触发不稳定。

父组件接收文字:先打印验证,再发 socket ​

为什么需要先打印? 发送 socket 前要先确认自定义事件链路通了。转写稿中的调试过程就是:父组件绑定 @send-text="onSendText",在函数里 console.log(text),浏览器输入“您好”回车,控制台能打印且输入框已清空,说明第一段完成。

怎么想? 父组件收到的是纯文本,还不能直接发给服务端;要按通讯规则组装 from、to、messageType、message。from 是当前登录用户 ID,to 是当前订单里的医生 ID。

怎么做?

ts
const onSendText = (text: string) => {
  console.log(text)

  socket.emit('send-message', {
    from: store.user?.id,
    to: consult.value?.doctorInfo?.id,
    messageType: MessageType.MESSAGE_TEXT,
    message: {
      content: text
    }
  })
}

模板中:

html
<RoomAction @send-text="onSendText" />

易错点: from 不要写成医生 ID;to 不要写成订单 ID;文本内容必须放在 message.content 里,而不是直接塞到顶层;messageType 要用枚举值,不要手写魔法数字。转写稿里提到的 send-message 是发送事件名,receive-message 是接收事件名,二者不要混。

调试验证:去超级医生端和 WS 面板看结果 ​

为什么需要验证? 只看到控制台打印不代表服务端收到。转写稿给了两种验证方式:一是去“超级医生”端查看该订单是否收到患者消息;二是在浏览器控制台“网络”面板找到 WS,点开当前订单连接,查看是否有 send-message 日志,消息内容里能看到 content 为“你好,医生”。

怎么做? 输入“你好,医生”回车,先看输入框是否清空;再看超级医生端是否出现消息;最后看 WS 面板是否有 send-message。如果服务端随后回推 receive-message,说明消息链路已经通了,下一节再做列表渲染。

易错点: 只看界面没变化就以为失败,其实本节还没渲染消息;WS 面板要选对订单连接;如果 to 取不到,先检查 consult 订单详情是否已加载。

小结 ​

发送文字的关键链路是:RoomAction 用 v-model 收集 text,回车触发 sendText,通过 send-text 自定义事件交给父组件并清空输入框;父组件 onSendText 接收文字,按接口规则组装 from、to、messageType、message.content,用 socket.emit('send-message', payload) 发出。最后用超级医生端和 WS 面板双重验证。

本节目标:把收到的消息渲染到聊天窗口 ​

上一节已经完成了“发送文字”,但发送成功后,消息还需要显示在聊天窗口里。本节要做的不是再写发送逻辑,而是接收服务端推送的消息,把消息追加到消息列表,再根据消息类型和发送人判断渲染“我发的文字”还是“医生发的文字”,最后处理时间格式和滚动到底部。

转写中明确提到:无论自己发消息还是医生发消息,服务端都会通过同一个消息事件 receive-message 把消息推回来。因此渲染逻辑应围绕“接收消息”展开,而不是发送后手动往页面塞一条假数据。

先接收消息:用 receive-message 拿到服务端数据 ​

为什么需要先接收?因为聊天窗口展示的是服务端确认并回推的消息。自己发送的消息会由服务端回推给自己,医生发送的消息也会推给自己。这样双方看到的是同一套消息数据。

怎么想?先在问诊室组件里监听 receive-message,把事件对象打印出来确认结构。转写中验证过:自己发“嗨”、医生发消息,都能在 receive-message 中收到,并且数据结构与之前定义的 message 结构一致。

怎么做?在 socket 上监听事件,收到后 push 到消息列表:

ts
socket.on('receive-message', (event) => {
  // event 结构与 message 一致
  messageList.value.push(event)
})

易错点:不要用 unshift。聊天消息按时间从旧到新排列,新消息应追加到列表末尾,所以用 push。另外要确认监听的是 receive-message,不是发送事件 send-message。

条件渲染:区分“我发的文字”和“医生发的文字” ​

为什么需要判断?聊天窗口通常右侧显示自己发送的消息,左侧显示对方发送的消息。两类消息的布局、头像位置不同,所以必须根据“消息类型”和“发送人”区分。

怎么想?先判断消息类型是否为文本消息:item.messageType === MessageType.TEXT。再判断发送人是否等于当前登录用户 ID:item.from === userStore.user.id。相等就是自己发的,不相等就是医生发的。

怎么做?模板中分别用 v-if 控制两个结构:

vue
<!-- 自己发送的文字 -->
<div v-if="item.messageType === MessageType.TEXT && item.from === userStore.user.id">
  <span>{{ formatTime(item.createTime) }}</span>
  <span>{{ item.message.content }}</span>
</div>

<!-- 接收到的文字 -->
<div v-if="item.messageType === MessageType.TEXT && item.from !== userStore.user.id">
  <span>{{ formatTime(item.createTime) }}</span>
  <span>{{ item.message.content }}</span>
</div>

易错点:不要只判断消息类型,否则自己和医生的文本会渲染成同一种布局。也不要写死用户 ID,当前用户 ID 应从用户仓库读取。消息内容位置是 item.message.content,不是 item.content。

滚动到底部:等 DOM 更新后再 scrollTo ​

为什么需要滚动?转写中测试发现消息确实渲染了,但页面没有自动滚到最新消息,需要手动往上翻才能看到。体验不好,所以收到消息后要滚到底部。

怎么想?消息 push 后,Vue 的 DOM 更新不是同步完成的。如果立刻调用 scrollTo,可能页面高度还没更新,滚不到真正底部。因此要先 await nextTick(),等 DOM 更新完再滚动。

怎么做?

ts
import { nextTick } from 'vue'

socket.on('receive-message', async (event) => {
  messageList.value.push(event)
  await nextTick()
  window.scrollTo(0, document.body.scrollHeight)
})

易错点:nextTick 要导入;滚动高度用 document.body.scrollHeight;X 轴传 0,Y 轴传可滚动高度。不要在没有更新 DOM 前直接滚动。

时间格式化:用 dayjs 输出 HH:mm ​

为什么需要处理时间?后台返回的是完整时间字符串,聊天窗口只需要小时和分钟。转写中要求显示成小时和分钟。

怎么想?安装 dayjs,写一个 formatTime 函数,把时间字符串转成 dayjs 对象,再用 format 输出。小时是 HH,分钟是 mm。

怎么做?

ts
import dayjs from 'dayjs'

const formatTime = (time: string) => {
  return dayjs(time).format('HH:mm')
}

模板中使用:

vue
<span>{{ formatTime(item.createTime) }}</span>

易错点:分钟是 mm,不是 MM;MM 表示月份。时间字段是 item.createTime。安装依赖后最好重启项目再验证。

验证与小结 ​

验证时先刷新页面,自己发送一条消息,观察列表是否新增并滚动到底部;再到超级医生端发送消息,回到问诊室确认医生消息也能渲染。控制台可查看 receive-message 的事件对象,网络 WS 面板可确认消息推送。最后检查时间是否显示为 HH:mm。本节的核心链路是:监听 receive-message → push 到列表 → 条件渲染文本消息 → nextTick 后滚动到底部 → dayjs 格式化时间。

代码示例 ​

① 状态栏控制组件 room-status.vue

vue
<!-- src/views/room/components/room-status.vue -->
<script setup lang="ts">
import { OrderType } from '@/enums'

// 两个数据都不一定有,所以都设为可选(非必填)
defineProps<{
  status?: OrderType
  countdown?: number
}>()
</script>

<template>
  <div class="room-status">
    <!-- 待接诊 -->
    <div class="status wait" v-if="status === OrderType.ConsultWait">待接诊</div>

    <!-- 接诊中:同时展示倒计时 -->
    <div class="status chat" v-else-if="status === OrderType.ConsultChat">
      <span>接诊中</span>
      <van-count-down v-if="countdown" :time="countdown * 1000" />
    </div>

    <!-- 已完成 / 已取消 都算「已结束」 -->
    <div
      class="status end"
      v-else-if="status === OrderType.ConsultComplete || status === OrderType.ConsultCancel"
    >
      已结束
    </div>
  </div>
</template>

<style lang="scss" scoped>
.room-status {
  .status {
    padding: 12px;
    text-align: center;
    font-size: 14px;
  }
  .wait {
    background: #e6f7ff;
    color: #1677ff;
  }
  .chat {
    display: flex;
    justify-content: center;
    gap: 6px;
    background: #f0fff0;
    color: #07c160;
  }
  .end {
    background: #f5f5f5;
    color: #999;
  }
}
</style>

要点与易错点:

  • status 用 OrderType 类型声明,不能写成 OrderType.ConsultWait 这类枚举值(否则模板里点不出来);枚举值只在模板里做 === 判断。
  • Vant 的 CountDown 的 time 单位是毫秒,后台返回的是秒,所以必须 countdown * 1000。
  • countdown 是可选值,直接 :time="countdown * 1000" 会有 undefined 的类型错误,所以要 v-if="countdown" 先判断有值再渲染。
  • 三个分支用 v-if / v-else-if 串联,别写成三个独立 v-if,否则状态切换时可能出现多块同时显示。

② 操作栏组件 room-action.vue(发送文字 + 上传图片)

vue
<!-- src/views/room/components/room-action.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { showLoadingToast } from 'vant'
import type { UploaderAfterRead } from 'vant'
import { uploadImage } from '@/api/consult'
import type { Image } from '@/types/room'

// 父组件根据订单状态算好,传进来
defineProps<{ disable?: boolean }>()

const emit = defineEmits<{
  (e: 'send-text', text: string): void
  (e: 'send-image', image: Image): void
}>()

const text = ref('')

// 回车发送:先抛给父组件,再清空输入框
const sendText = () => {
  emit('send-text', text.value)
  text.value = ''
}

const sendImage: UploaderAfterRead = async (item) => {
  if (Array.isArray(item)) return
  if (!item.file) return

  const t = showLoadingToast({ message: '正在上传...', duration: 0, forbidClick: true })
  const res = await uploadImage(item.file)
  t.close()

  emit('send-image', res.data)
}
</script>

<template>
  <div class="room-action">
    <van-field
      v-model="text"
      :disabled="disable"
      placeholder="请输入聊天内容"
      @keyup.enter="sendText"
    />
    <van-uploader :after-read="sendImage" :disabled="disable">
      <van-icon name="photo-o" size="24" />
    </van-uploader>
  </div>
</template>

要点与易错点:

  • 输入框用 v-model="text" 收集数据,@keyup.enter="sendText" 监听回车(不是 @click)。
  • 抛数据给父组件必须用 defineEmits 约定事件名 send-text,父组件用 @send-text="onSendText" 接收;事件名是 kebab-case。
  • sendText 里发完要立刻 text.value = '' 清空,否则输入框内容不清。
  • disable 由父组件传:consult.status !== OrderType.ConsultChat 时为 true,同时绑在 van-field 和 van-uploader 上,两处都要绑,只绑一处会漏。
  • after-read 回调的参数可能是数组,要先 Array.isArray(item) 判断,再判断 item.file 是否存在。
  • showLoadingToast 的 duration: 0 表示不自动关闭,上传完必须手动 t.close()。

③ 消息渲染组件 room-message.vue(渲染文字)

vue
<!-- src/views/room/components/room-message.vue -->
<script setup lang="ts">
import dayjs from 'dayjs'
import { MessageType } from '@/enums'
import type { Message } from '@/types/room'
import { useUserStore } from '@/stores'

defineProps<{ item: Message }>()

const store = useUserStore()

// 后台返回的是完整时间,这里只保留「时:分」
const formatTime = (time: string) => dayjs(time).format('HH:mm')
</script>

<template>
  <div class="room-message">
    <!-- 自己发送的文字:文本类型 && from 等于自己的 ID -->
    <div
      class="msg self"
      v-if="item.messageType === MessageType.MsgText && item.from === store.user?.id"
    >
      <img class="avatar" :src="item.fromAvatar" alt="" />
      <div class="body">
        <p class="time">{{ formatTime(item.createTime) }}</p>
        <div class="content">{{ item.message.content }}</div>
      </div>
    </div>

    <!-- 接收的文字:文本类型 && from 不是自己(即医生发的) -->
    <div
      class="msg other"
      v-if="item.messageType === MessageType.MsgText && item.from !== store.user?.id"
    >
      <img class="avatar" :src="item.fromAvatar" alt="" />
      <div class="body">
        <p class="time">{{ formatTime(item.createTime) }}</p>
        <div class="content">{{ item.message.content }}</div>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.msg {
  display: flex;
  gap: 8px;
  padding: 8px 12px;

  .avatar {
    width: 40px;
    height: 40px;
    border-radius: 50%;
  }
  .time {
    margin: 0 0 4px;
    font-size: 12px;
    color: #999;
  }
  .content {
    padding: 8px 12px;
    border-radius: 8px;
    background: #fff;
    max-width: 240px;
    word-break: break-all;
  }
}
.self {
  flex-direction: row-reverse;
  text-align: right;
}
</style>

要点与易错点:

  • 区分「自己发」和「医生发」只靠一个判断:item.from === store.user?.id,等于自己就是发送方,不等于就是接收方,两种结构各写一份 v-if。
  • 消息类型用枚举 MessageType.MsgText 判断,不要硬编码数字。
  • 时间格式化用 dayjs:小时是 HH(24 小时制),分钟是 mm(小写,大写 MM 是月份,最常见的坑)。
  • 内容取的是 item.message.content,不是 item.content,少一层会渲染不出来。
  • 本组件的 item 由父组件 index.vue 用 v-for 渲染,数据来源是 socket.on('receive-message') 里 list.value.push(event);push 之后要在 await nextTick() 再 window.scrollTo(0, document.body.scrollHeight) 滚到底部,否则 DOM 还没渲染完,滚动高度取不到最新值。

常见错误 ​

  1. 状态栏显示错乱或枚举点不出来 → 把枚举当类型使用、条件判断写成魔法数字 → 统一使用订单状态枚举值做 v-if/v-else-if,状态与展示一一对应。
  2. 倒计时显示 NaN、类型报错或不显示 → countDown 可能为 undefined,且 CountDown 组件要求毫秒而接口给秒 → 先 v-if 判有值,再传 countDown * 1000。
  3. 接诊中仍不能输入/上传,或未接诊却能发消息 → disable 判断写反,或只绑定到输入框未绑定上传按钮 → 由父组件按订单状态统一计算 disable,操作栏内所有可交互入口都使用同一值。
  4. 回车发送文字无效、父组件收不到 → 子组件未 emit、事件名不一致、发送后未清空输入框 → 先约定 send-text 事件契约,回车触发 emit,父组件同名监听,发送后清空输入。
  5. 自己和医生消息左右颠倒 → from 与当前用户 ID 比较写反,或未区分文本/图片类型 → 用 from === user.id 判断自己,否则为对方;再按 messageType 分支渲染。
  6. 新消息已入列表但页面没滚到底 → push 后立即操作 DOM,列表尚未渲染完成 → await nextTick() 后再 scrollTo 到底部。
  7. 时间格式不是“小时:分钟” → 未安装/未引入 dayjs,或格式大小写写错 → 使用 dayjs,格式化为 HH:mm,注意分钟是小写 mm。
  8. 下拉刷新一直转圈或无更多记录无提示 → 收到聊天记录后未复位 loading,空数组未提示 → 接收后置 loading=false,空数组 toast 提示并停止继续加载。
  9. 未读数不减少反而持续累加 → 进入诊室未标记历史消息已读,实时收到消息也未标记当前消息已读 → 进入时把列表最后一条消息 ID 发后台,收到消息时立即把该消息 ID 发后台。
  10. 处方卡片模板报错或原始处方打不开 → 处方对象可能不存在仍直接取深层字段,预览时未按 ID 请求图片 URL → 外层先判断处方存在或使用可选链;点击时按处方 ID 调接口取 URL 再预览。

练习 ​

  1. 为 room-status 设计 status、countDown 两个可选 props,并写出三种状态的条件渲染检查点:待接诊、接诊中倒计时、已结束/取消。
  2. 实现操作栏文字发送链路:输入框回车触发自定义事件,父组件接收后通过 WebSocket 发送,并检查事件名、参数、清空输入框三处是否一致。
  3. 在消息组件中同时渲染自己/对方的文字与图片消息,列出判断条件、图片字段路径、时间格式化位置。
  4. 实现聊天记录分页:首次进入滚到底部,下拉刷新加载更早 20 条,记录每段最早消息时间,并处理 loading 复位与“没有更多聊天记录”提示。
  5. 检查消息已读与处方功能:进入诊室标记最新消息已读、实时收到消息即已读、点击原始处方按 ID 获取图片 URL 并预览,列出联调验证步骤。

课件图示 ​

课件第19页:问诊室-文字聊天-发送文字 操作栏传递文字 诊室接收文字 socket发送文字

课件第24页:问诊室-图片聊天 操作栏提交图片 诊室接收并发送 渲染图片消息

课件第17页:问诊室-接诊状态-控制组件 控制状态栏 控制操作栏

课件第22页:问诊室-文字聊天-渲染消息 接收消息 展示文字消息 处理时间

课件第26页:问诊室-聊天记录 默认看最新消息 实现下拉刷新 记录消息时间 通知服务器, lo

课件第30页:问诊室-查看处方 医生开处方&渲染 原始处方API 预览原始处方

原视频对照 ​

#视频时长
1问诊室-接诊状态-控制组件12:48
2问诊室-文字聊天-发送文字11:08
3问诊室-文本聊天-渲染文字12:41
4问诊室-图片聊天14:01
5问诊室-聊天记录17:29
6问诊室-消息已读10:20
7问诊室-查看处方09:58

小结 ​

  1. 接诊状态页面的核心是“订单状态驱动 UI”,状态栏与操作栏必须共用同一份订单数据判断。
  2. 组件之间先定契约再写实现:props 控制展示,emits 向上提交文字/图片等用户动作。
  3. WebSocket 相关功能必须严格对照接口文档:事件名、消息类型、发送人/接收人、内容字段不能凭感觉写。
  4. 消息列表渲染要同时处理“自己/对方”和“文字/图片/处方”两个维度,避免条件判断互相污染。
  5. 列表更新后的滚动、已读、分页等操作都依赖渲染时机,nextTick 是必要检查点。
  6. 聊天记录分页依赖“最早消息时间”作为游标,首次滚动与后续下拉刷新要用标记区分。
  7. 消息已读有两条路径:进入诊室批量标记历史消息,聊天过程中逐条实时标记。
  8. 联调时同时看医生端、WS 面板和页面列表状态,比只看控制台打印更容易定位消息发送/接收/渲染问题。