主题
问诊室消息收发(三)
本页对应 问诊室消息收发,共 6 段视频,约 47 分钟。
学习目标
- 能够使用
v-model获取输入框文字,监听confirm回车事件,并通过 Socketemit发送文字消息,发送后将输入框清空。 - 能够在登录成功后把用户 ID 存入 Pinia,并完成持久化配置,在聊天页面中正确读取该用户 ID 用于消息发送。
- 能够根据消息的
from与当前用户 ID 是否一致动态添加reverse类名,实现患者消息靠右、医生消息靠左展示。 - 能够调用
uniCloud.chooseAndUploadFile选择并上传图片,并从返回结果中获取图片地址与可用唯一标识。 - 能够按照接口要求构造图片消息的
from、to、messageType和图片内容,通过 Socketemit发送给医生。 - 能够创建问诊订单状态组件,并将其作为
room-config的直接子组件引入页面,正确展示待接诊、咨询中、已完成等状态。
知识点
先补用户 ID:发送消息前必须知道“我是谁”
为什么需要:文字消息要传 from 和 to,其中 from 是当前登录用户 ID。转写里一开始消息发不出去,就是因为用户 ID 还没存下来。
怎么想:用户 ID 在登录成功时由后端返回。之前只存了 token,现在要在 Pinia 里新增 userID,并持久化。登录成功后同时写入 token 和 userID;验证码登录和密码登录两条路径都要处理。
怎么做:
js
// store/user.js
state: () => ({
token: '',
userID: ''
})
// 需要持久化的字段一并配置js
// 登录成功后
userStore.token = res.token
userStore.userID = res.id页面里再解构使用:
js
const { userID } = userStore易错点:只在 state 定义、忘了 return 或持久化;只改验证码登录、漏掉密码登录;页面里直接解构后误以为它具备响应式,这里只是发送时取值,问题不大,但要清楚用途。
发送文字消息:获取内容、监听回车、emit 三步
为什么需要:用户输入的文字要拿到,回车要触发发送,最终还要通过 Socket 发给后端。
怎么想:转写里明确三步:第一步 v-model 获取输入框内容;第二步监听小程序输入框的 confirm 事件;第三步调用 emit 触发后端约定事件。后端事件名口述为“S 的…Message”,实际以接口文档为准。
怎么做:
html
<input v-model="message" @confirm="sendText" />js
const message = ref('')
function sendText() {
console.log('发送文字消息', message.value)
socket.emit('后端文档中的事件名', {
from: userID,
to: orderDetail.doctorInfo?.id,
messageType: 1, // 1 表示文字消息
content: message.value
})
message.value = ''
}易错点:confirm 不是普通 click,它表示回车/确认;messageType 不能把文字写成图片类型;发送后要清空输入框,否则内容会残留。调试时先看 console.log 能否拿到文字,再看医生端能否收到。
消息左右对齐:用 from 和当前 userID 判断
为什么需要:患者消息靠右、医生消息靠左,聊天才易读。
怎么想:消息数据里有 from。如果 from === userID,说明是自己发的,就加 reverse 类名靠右;否则靠左。
怎么做:
html
<view
v-for="item in messageList"
:key="item.id"
:class="{ reverse: item.from === userID }"
>
{{ item.content }}
</view>易错点:类名拼写要统一,转写里反复提醒 reverse 不要写错;不要所有消息都加,否则全跑右侧。演示时可让医生回一条,检查一左一右。
图片消息:先上传,再把图片信息发给医生
为什么需要:图片不能直接塞进聊天内容,要先上传到云存储,拿到地址后再作为消息发送。
怎么想:点击图片按钮时调用云开发上传 API;上传成功后从结果里取 url,接口还要求 id,转写里用 lastModified 充当不重复 ID;最后 emit 图片消息。
怎么做:
js
async function chooseImage() {
if (orderStatus !== 3) return
const res = await uniCloud.chooseAndUploadFile({
type: 'image',
count: 1,
extension: ['.jpg', '.png', '.gif']
})
const file = res.tempFiles[0]
const picture = {
id: file.lastModified,
url: file.url
}
socket.emit('后端文档中的事件名', {
from: userID,
to: orderDetail.doctorInfo?.id,
messageType: 4, // 图片消息
content: picture
})
}易错点:云空间要先关联;上传结果要确认有 url;图片类型字段按文档传 picture 或对应字段,不能照搬文字消息的 content 结构。调试时先打印上传结果,转写里就出现点击无反应、Socket 断连、重启项目后恢复的情况。
订单状态模板先占位
为什么需要:聊天区顶部要显示“待接诊/咨询中/已完成”等状态。转写里医生抢单后状态变为咨询中,结束问诊后变已完成。
怎么做:新建 room-status 组件,引入页面,并放在 room-container 的直接子级位置。
易错点:转写特别强调不要放错层级,必须是“这个盒子的直接儿子”;本节先把组件加载出来,状态逻辑下一节再补。
本节要解决的问题:消息里的 from 从哪里来
发送文字消息时,后端要求传 from、to、messageType、messageContent 等字段。其中 from 表示“谁发的”,必须是当前登录用户的 ID。演示时先把发送消息的代码粘过来又注释掉,原因是:主流程虽然能走到发送,但用户 ID 还没存,消息数据不完整。所以本节不是单纯“存一个值”,而是补齐发送消息所依赖的登录用户身份。
先确认登录接口是否返回 ID
不要凭空造用户 ID。重新清空登录状态、刷新、用手机号验证码登录,并在网络请求里观察登录返回结果,发现之前只存了 token,但接口其实还返回了 ID。这说明 ID 的来源是登录成功响应。
怎么想:先看接口返回,再决定存什么;不要凭字段名猜。怎么做:登录成功后从返回数据里取 ID。易错点:只盯着 token,忽略登录响应里的用户信息;字段名大小写写错,例如 id 和 ID 不一致。
在 Pinia 中新增 userID 并持久化
为什么需要:用户 ID 要在聊天页面等多个页面使用,刷新后也不能丢。怎么想:它属于用户登录态,应该放到用户 store 中,而不是某个页面局部变量。怎么做:在用户 store 里新增 userID,并做持久化。转写中反复强调“三个地方”:定义数据、return 数据、持久化数据,三处都要补齐。
示例结构如下,具体写法按项目现有 store 调整:
js
// store/user.js
state: () => ({
token: '',
userID: ''
}),
// setup 写法则在 return 中暴露 userID
persist: true易错点:只加了 state,忘了 return 或持久化配置;持久化后仍用旧登录态测试,看起来“没生效”。
登录成功后写入 userID
在验证码登录成功的位置,除了存 token,再存用户 ID:
js
const store = useUserStore()
// 登录成功后,按接口实际返回字段取值
store.token = d.token
store.userID = d.ID特别提醒:项目里有两种登录方式,验证码登录改了,密码登录也要改;实际演示中密码登录未实现,所以没加,但自己实现过的要同步补上。易错点:只改一个登录入口;把 d.ID 写成不存在的字段;登录成功后没有重新登录验证。
验证:重新登录并检查本地存储
改完后清空登录状态,重新用 13212345678 走验证码登录,打开开发者工具查看本地存储,确认 userID 已被持久化。验证通过后,再到聊天页面引入 store、取出 userID,打开之前注释的发送消息代码。演示发送“你好,医生”,医生端能收到,说明 emit 发送的数据已经带上了正确的 from。
页面使用 userID 并清空输入框
页面中按项目方式引入用户 store,取出 userID,发送时作为 from。转写中说明这里只是“用一下”,所以直接解构;若后续需要响应式变化,应使用 storeToRefs。发送完成后顺手清空输入框:
js
const userStore = useUserStore()
const { userID } = userStore
// 发送文字消息时
socket.emit(事件名, {
from: userID,
to: doctorID,
messageType: 1,
messageContent: message.value
})
message.value = ''易错点:解构后误以为它一定具备响应性;userID 为空仍发送;清空输入框放在发送前导致内容丢失。本节核心就是:登录成功不只存 token,还要存 userID,并在发送消息时使用它。
先明确本节要解决的问题
为什么需要:聊天页面里,如果患者和医生发的消息都挤在同一侧,阅读时就很难快速区分“谁说的”。转写稿里一开始正是这种情况:患者发“你好”,医生回“你好,描述一下病情”,两条消息都在一侧,看起来不方便。
怎么想:消息列表不能只按“当前页面是患者端”就统一靠右,而要根据每条消息的发送人来决定左右。患者自己发的靠右,医生发的靠左。
怎么做:在消息项上根据条件添加 reverse 类名。转写稿里明确提到,文字消息结构里已经有 reverse 类名,加上后消息会靠右显示。
易错点:不要一上来就把 reverse 写死到所有消息上,否则会像转写稿演示的那样“通通都跑到右侧”,仍然无法区分医生和患者。
用 reverse 类名控制消息靠右
为什么需要:页面样式里已经提供了 reverse 这个类名,复用它比重新写一套左右布局更稳,也符合当前消息组件结构。
怎么想:先手动加类名验证样式是否生效,再把“是否加类名”改成条件判断。转写稿的调试顺序就是:先在 message 信息里加 reverse,保存后发现全部靠右,说明类名有效,但还缺少判断条件。
怎么做:模板里保留 reverse 类名,条件写法按你项目里的变量名调整。关键逻辑是:当前消息的 from 等于当前登录用户 ID 时,才加 reverse。
html
<view class="message" :class="{ reverse: item.from === user.id }">
<!-- 消息内容 -->
</view>易错点:类名是 reverse,转写稿里口误成“reward”,不要拼错;条件不要写成固定 true,否则所有消息都会靠右。
用 from 与当前用户 ID 判断消息归属
为什么需要:只加类名只能让消息靠右,不能判断“这条消息是不是自己发的”。必须拿到每条消息的发送人,再和当前登录用户比较。
怎么想:转写稿里查看接口数据时,消息对象里有 from 和 to。from 表示谁发的,to 表示发给谁。当前登录用户 ID 如果等于 from,说明这条消息是患者自己发的,应该靠右;否则是医生发的,应该靠左。
怎么做:先在登录成功后把用户 ID 存起来。转写稿强调,之前登录只存了 token,后来发现还需要存用户 ID,并且要放到 store 里做持久化。页面中再从 store 取出用户 ID,和 item.from 比较。转写稿里看到自己 ID 是 200,某条消息的 from 也是 200,就判断为“自己发的”。
易错点:不要拿 to 去比较,to 是接收人;要确认登录后确实存了用户 ID;转写稿还提醒,如果两种登录方式都实现了,两种方式都要存,不能只改一种;store 里定义数据、return 数据、持久化数据这三处都要补齐。
调试与验证过程
为什么需要:对齐是视觉结果,只看代码容易误判,必须发消息验证左右效果。
怎么想:先发一条患者消息,再让医生回一条,观察两条消息是否一左一右。转写稿中患者发“你好,医生”,医生回“你好,描述一下病情”,然后检查医生消息靠左、患者消息靠右。
怎么做:保存代码后重新发消息;如果数据没刷新,先检查 socket 是否断开。转写稿里曾出现 socket 连不上、数据没有的情况,重新启动项目后才恢复。恢复后再看消息列表,确认条件判断生效。
易错点:socket 断开时不要误判成 class 写错;修改登录存储后要重新登录一次,让用户 ID 真正写入;验证时不要只看一条消息,要同时看患者消息和医生消息。
本节易错点汇总
reverse拼写要准确,条件不要写死。- 判断归属看
from,不要看to。 - 当前用户 ID 要在登录成功后存入 store,并做好持久化。
- 先手动加类名验证样式,再加条件判断,调试顺序不要反。
- 图片消息进入同一消息列表后,左右判断仍应围绕发送人逻辑处理,不要另起一套混乱规则。
代码示例
示例一:发送文字消息(v-model 取内容 → @confirm 监听回车 → emit 发送)
vue
<!-- pages/room/room.vue -->
<template>
<view class="room-page">
<!-- 消息列表 -->
<view class="message-container">
<view v-for="item in messageList" :key="item.id" class="message-item">
<text>{{ item.messageContent }}</text>
</view>
</view>
<!-- 底部输入栏 -->
<view class="message-bar">
<input
v-model="message"
class="message-input"
placeholder="请输入内容"
@confirm="handleSendText"
/>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { useUserStore } from '@/stores/user'
// socket 实例在前面章节已经连接好
const userStore = useUserStore()
const message = ref('') // 输入框里的文字
const messageList = ref([]) // 消息列表
const orderDetail = ref({}) // 订单详情(含医生信息)
// 发送文字消息
const handleSendText = () => {
console.log('用户输入的文字消息:', message.value)
socket.emit('sendTextMessage', {
from: userStore.userId, // 谁发的(当前登录用户)
to: orderDetail.value?.docInfo?.id, // 发给谁(接诊医生)
messageType: 1, // 1 = 文字消息
messageContent: message.value // 输入框里的内容
})
// 发送完成后清空输入框
message.value = ''
}
</script>
<style scoped>
.message-bar {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
border-top: 1rpx solid #eee;
}
.message-input {
flex: 1;
height: 70rpx;
padding: 0 20rpx;
background: #f5f5f5;
border-radius: 35rpx;
}
</style>要点解释
- 第一步取数据用
v-model="message"双向绑定输入框,不需要手动去读 DOM 节点的 value。 - 第二步监听回车:
input组件的@confirm事件(小程序 / uni-app 特有),用户敲回车时才触发,不是@keyup。 - 第三步
socket.emit('sendTextMessage', {...}):第一个参数是后端约定的事件名,第二个参数是一个对象。 messageType: 1表示文字消息(图片消息是4),messageContent就是message.value。- 易错点 1:发送完一定要
message.value = ''清空输入框,否则文字还留在框里。 - 易错点 2:
to必须用可选链orderDetail.value?.docInfo?.id,页面刚进来数据还没回来时会报“读取 undefined 的属性”。 - 易错点 3:事件名和四个字段名(from / to / messageType / messageContent)必须与后端接口文档完全一致,不能自己起名字。
示例二:登录成功后存储用户 ID(Pinia 定义 + 登录页写入)
js
// stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore(
'user',
() => {
// 登录凭证
const token = ref('')
// 当前登录用户的 ID
const userId = ref('')
return {
token,
userId
}
},
{
// 持久化到本地存储
persist: true
}
)js
// 登录页面 login.vue 中的登录逻辑
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const handleLogin = async () => {
const res = await loginApi({ mobile: mobile.value, code: code.value })
// 之前只存了 token
userStore.token = res.token
// 现在再把用户 ID 也存上
userStore.userId = res.id
}要点解释
- store 里加一个
userId需要动三个地方:const userId = ref('')定义数据、return里导出数据、persist: true做持久化。少一个都拿不到值。 - 持久化的作用:刷新页面后
userId依然在,任何页面都能通过useUserStore()直接取到。 - 登录接口返回里除了
token还有id,登录成功时两个都要写进 store。 - 易错点 1:只改了 store 忘了在登录处赋值,
userId永远是空字符串。 - 易错点 2:账号密码登录和验证码登录是两套方法,两种方式都实现了的话,两处都要写,否则换一种登录方式就取不到 ID。
示例三:调整消息对齐方式(自己发的靠右,医生发的靠左)
vue
<template>
<view class="message-container">
<view
v-for="item in messageList"
:key="item.id"
:class="['message-item', { reverse: item.from === userId }]"
>
<view class="message-content">
<text>{{ item.messageContent }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 当前登录用户 ID,用来判断消息是不是自己发的
const userId = userStore.userId
</script>
<style scoped>
/* reverse 类名项目里已有样式,这里示意对齐效果 */
.message-item {
display: flex;
justify-content: flex-start;
margin-bottom: 20rpx;
}
.message-item.reverse {
justify-content: flex-end;
}
</style>要点解释
- 判断依据是消息数据里的
from(发送人 ID)与当前登录用户的userId是否相等:相等说明是患者自己发的,加reverse类名靠右;否则是医生发的,保持靠左。 :class用对象语法{ reverse: 条件 },条件为true时才追加这个类名,不需要写if。userId从 store 里取,依赖示例二里的持久化数据,所以必须先登录一次才有值。- 易错点 1:
reverse拼写错误,或者写成:class="{ reverse: item.from === userStore.userId }"时漏了.value之类的取值问题,导致永远不加类名。 - 易错点 2:不加这个判断的话,医生和自己发的消息全挤在一侧,聊天记录没法区分谁说的。
常见错误
1. 按回车没反应,控制台也没有日志
- 现象:输入框里敲回车,既没触发方法也没打印内容。
- 原因:把事件绑成了
@keyup.enter或@input,而小程序input组件的回车走的是confirm;或者回调方法只写了名字却没在setup/methods中定义、没return出去。 - 修法:改成
@confirm="sendText",先在回调里console.log打一行,确认事件能进方法体,再往里写业务。
2. 消息能发出去,但 from / to 是 undefined 或空字符串
- 现象:医生端收不到,或收到的消息没有发送人。
- 原因:登录成功后只存了 token 没存 userId;或 Pinia 里
state加了字段但return或持久化配置漏了一处,刷新后取不到;或页面里解构时字段名大小写写错(userid/userId)。 - 修法:登录成功的两个分支都要写 userId;store 三处(定义、返回、持久化)同时补齐;页面里解构后立刻打印一次确认有值,再放进 emit 的参数里。
3. 自己的消息跑到左边,或两边消息全挤在同一侧
- 现象:患者发的和医生发的都在同一侧,
reverse类名没生效或全生效。 - 原因:判断条件写成了固定值
true(如:class="{ reverse: true }");或from与 userId 类型不一致(一个是数字一个是字符串)导致===永远为 false;或类名拼成revers。 - 修法:写成
:class="{ reverse: item.from === userId }",比较前确认两边类型一致,类名照抄不要手打。
4. 点图片按钮不弹选择框
- 现象:点了没任何反应,或弹出后图片类型被屏蔽。
- 原因:没有关联云服务空间;
accept里写成jpg/png/gif少了点;点击事件绑到了包裹的view而不是真正的触发元素;方法里先做了状态判断直接return。 - 修法:确认已关联云空间、扩展名写成
.jpg,.png,.gif、@click绑在可点元素上,并在方法第一行打印日志确认进入。
5. 图片发出去了,医生端收不到或报参数错误
- 现象:控制台有上传结果,但对方无消息。
- 原因:
picture里只传了url缺id,或直接把本地临时路径当成了云存储地址。 - 修法:从上传返回的数组第 0 项里取字段,
id用lastModified、url用url,两个一起塞进picture;传参前先打印整个返回结构对照字段名。
6. 消息发送成功,但输入框里文字还留着
- 现象:发完消息输入框不清空,重复发送同一条。
- 原因:只做了 emit,忘了手动清空绑定变量。
- 修法:emit 之后紧跟一句把绑定变量置为空字符串。
7. 订单状态组件样式错乱 / 跑到聊天区里
- 现象:状态条挤在消息列表中间,或整个页面布局被顶乱。
- 原因:组件标签插错了父节点层级,放进了消息容器内部,而不是订单容器的直接子级。
- 修法:对照模板层级把组件挪到订单容器的直接子级,保存后看结构是否与预期一致。
练习
给文字发送补一个“空消息拦截”:输入为空或只有空格时不允许 emit,并给出轻提示。写完后验证:回车不报错、无请求发出。
把“是否允许发送”抽成一个判断(订单状态不等于咨询中时禁止发文字和发图片),文字和图片两个入口都要拦住,并分别验证一次拦截效果。
登录页有两种登录方式(验证码 / 密码),把“存 token + 存 userId”抽成一个公共方法,两个分支都调用它。验证:清空本地存储后分别用两种方式登录,
userId都能落盘且刷新不丢。消息列表里加一个时间戳展示:用接口返回的字段渲染,注意自己发的和对方发的样式区分仍然正确。验证:连续发 3 条,左右分布不错乱。
把图片发送的
picture组装逻辑抽成一个函数buildPicture(res),入参是上传返回结果,出参是{ id, url }。验证:故意传一个空数组,函数不会抛异常。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 优医咨询-发送文字消息 | 10:22 |
| 2 | 优医咨询-登录成功后存储用户ID | 07:27 |
| 3 | 优医咨询-调整消息对齐方式 | 05:53 |
| 4 | 优医咨询-云开发上传图片 | 11:55 |
| 5 | 优医咨询-发送图片消息 | 06:01 |
| 6 | 优医咨询-问诊订单状态模板 | 05:27 |
小结
- 一条消息能发出去,是“取数据 → 监听事件 → emit”三步闭环,缺任何一步都表现为“点了没反应”,排查时按这三步顺序打日志最快。
- 前端发消息的前提是数据齐全:token 决定身份、userId 决定
from、医生 ID 决定to,缺一个发出去的就是脏数据,服务端不会替你兜底。 - 持久化不是可选项。登录接口返回的 id 只有那一次机会,不落盘就意味着刷新即丢,后续所有依赖 userId 的功能全部失效。
- 消息左右对齐本质是数据问题不是样式问题——靠
from与当前 userId 的比较结果决定是否加reverse,比较时务必保证类型一致。 - 文字消息和图片消息走的是同一条发送通道,差别只在消息类型和内容字段(
contentvspicture{id,url}),把公共部分抽出来以后加语音、视频都不费劲。 - 云上传返回的是一个文件描述对象而非路径字符串,动手写组装逻辑前先打印完整结构,比对着文档猜字段名可靠得多。
- 组件放哪儿由模板层级决定,插错父节点样式必然错乱,改布局前先确认“我是谁的直接子级”。
- 建议先跑通主链路,再补状态拦截和 UI 增强;一开始就把判断堆满,出问题时你分不清是链路断了还是被自己的条件拦住了。