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






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