学习目标:
-
能够基于 WebSocket 完成问诊全流程
-
能够使用 uniCloud 云存储上传文件
-
能够完成查看电子处方的功能
-
能够完成医生评价的功能
一、问诊室
以对话聊天的方式向医生介绍病情并获取诊断方案,聊天的内容支持文字和图片两种形式。
首先新建一个页面并完成分包的配置:
{ "subPackages": [ { "root": "subpkg_consult", "pages": [ { "path": "room/index", "style": { "navigationBarTitleText": "问诊室" } } ] }, ] }
该页面的内容特别多我们分段来数据模板代码移到项目当中:
<!-- subpkg_consult/room/index.vue --> <script setup></script> <template> <view class="room-page"> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <!-- 此处将来填充更多代码... --> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> <template v-if="true"> <uni-easyinput disabled :clearable="false" :input-border="false" placeholder-style="font-size: 32rpx; color: #c3c3c5;" placeholder="问医生" /> <view class="image-button"> <uni-icons size="40" color="#979797" type="image"></uni-icons> </view> </template> <button v-else class="uni-button">咨询其它医生</button> </view> </view> </template> <style lang="scss"> @import './index.scss'; </style>
// subpkg_consult/room/index.scss .room-page { display: flex; flex-direction: column; height: 100vh; /* #ifdef H5 */ height: calc(100vh - 44px); /* #endif */ overflow: hidden; box-sizing: border-box; background-color: #f2f2f2; } .message-container { padding: 0 30rpx 60rpx; overflow: hidden; } .message-bar { background-color: red; display: flex; padding: 30rpx 30rpx calc(env(safe-area-inset-bottom) + 40rpx); background-color: #fff; :deep(.is-disabled) { background-color: transparent !important; } :deep(.uni-easyinput__content-input) { height: 88rpx; padding: 0 44rpx !important; border-radius: 88rpx; color: #3c3e42; font-size: 32rpx; background-color: #f6f6f6; } .image-button { display: flex; justify-content: center; align-items: center; height: 88rpx; width: 88rpx; margin-left: 30rpx; } .uni-button { flex: 1; } }
1.1 WebSocket 连接
首先安装 Socket.IO
npm install socket.io-client
然后建立连接,在建立连接进需要传入参数和登录信息:
-
auth
登录状态信息,即token
-
query
建立连接时传递的参数 -
transports
建立连接时使用的协议 -
timeout
超时设置
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' // 用户登录信息(不具有响应式) const { token } = useUserStore() // 获取地址中的参数 const props = defineProps({ orderId: String, }) // 建立 socket 连接 const socket = io('https://consult-api.itheima.net', { auth: { token: 'Bearer ' + token }, query: { orderId: props.orderId }, transports: ['websocket', 'polling'], timeout: 5000, }) </script>
1.2 接收消息
Socket.IO 是基于事件来实现数据通信的,事件的名称是由前后端商定好的,详见接口文档说明,消息的获取分成两种情况:
-
历史消息,事件名称为
chatMsgList
-
即时消息,事件名称为
receiveChatMsg
1.2.1 消息列表
在建立连接时服务端会通过 chatMsgList
传递历史数据,通过 on
方法进行监听来获取这些数据:
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' // 省略前面小节的代码... // 消息列表 const messageList = ref([]) // 获取历史消息 socket.on('chatMsgList', ({ code, data }) => { // 没有返回数据 if (code !== 10000) return // 提取列表数据 data.forEach(({ items }) => { // 追加到消息列表中 messageList.value.push(...items) }) }) </script>
在消息列表数据中包含了不同类型的消息且展示的方式也不相同,因此在对数据进行遍历的过程中需要通过 v-if
来渲染不同的模板,不同的类型对应了一个数值:
消息类型 | 说明 | 备注 |
---|---|---|
21 | 患者信息 | |
22 | 处方信息 | |
23 | 未提交评价 | |
24 | 已提交评价 | |
31 | 普通通知 | 白底黑字 |
32 | 温馨提示 | |
33 | 取消订单 | 灰底黑字 |
4 | 图片消息 | |
1 | 文字消息 |
首次进入问诊室返回的 3 条件的类型分别为患者信息(21)、普通通知(31)、温馨提示(32),我们逐个进行渲染。
1.2.2 患者消息
首先创建患者消息组件,组件的模板布局如下:
<!-- subpkg_consult/room/components/patient-info.vue --> <script setup></script> <template> <!-- 患者信息(21) --> <view class="patient-info"> <view class="header"> <view class="title">李富贵 男 31岁</view> <view class="note">一周内 | 未去医院就诊</view> </view> <view class="content"> <view class="list-item"> <text class="label">病情描述</text> <text class="note">头痛、头晕、恶心</text> </view> <view class="list-item"> <text class="label">图片</text> <text class="note">点击查看</text> </view> </view> </view> </template> <style lang="scss"> .patient-info { padding: 30rpx; margin-top: 60rpx; border-radius: 20rpx; box-sizing: border-box; background-color: #fff; .header { padding-bottom: 20rpx; border-bottom: 1rpx solid #ededed; .title { font-size: 32rpx; color: #121826; margin-bottom: 10rpx; } .note { font-size: 26rpx; color: #848484; } } .content { margin-top: 20rpx; font-size: 26rpx; .list-item { display: flex; margin-top: 10rpx; } .label { width: 130rpx; color: #3c3e42; } .note { flex: 1; line-height: 1.4; color: #848484; } } } </style>
接下来分成3个步骤来实现:
-
自定义组件的相关逻辑,要求组件能接收外部传入的数据
<!-- subpkg_consult/room/components/patient-info.vue --> <script setup> // 定义属性接收外部传入的数据 const props = defineProps({ info: { type: Object, default: {}, }, }) // 患病时长 const illnessTimes = { 1: '一周内', 2: '一个月内', 3: '半年内', 4: '半年以上', } // 是否就诊过 const consultFlags = { 1: '就诊过', 0: '没有就诊过', } </script> <template> ... </template>
-
在页面应用组件并传入数据
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' // 引入患者信息组件 import patientInfo from './components/patient-info.vue' // 省略前面小节的代码 </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <template v-for="message in messageList" :key="message.id"> <!-- 患者信息(21) --> <patient-info v-if="message.msgType === 21" :info="message.msg.consultRecord" /> <!-- 此处将来填充更多代码... --> </template> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> ... </view> </view> </template>
-
在组件内部接收并渲染数据
<!-- subpkg_consult/room/components/patient-info.vue --> <script setup> // 省略前面小节的代码... </script> <template> <!-- 患者信息(21) --> <view class="patient-info"> <view class="header"> <view class="title"> {{ props.info.patientInfo.name }} {{ props.info.patientInfo.genderValue }} {{ props.info.patientInfo.age }}岁 </view> <view class="note"> {{ illnessTimes[props.info.illnessTime] }} | {{ consultFlags[props.info.illnessType] }} </view> </view> <view class="content"> <view class="list-item"> <text class="label">病情描述</text> <text class="note">{{ props.info.illnessDesc }}</text> </view> <view class="list-item"> <text class="label">图片</text> <text v-if="props.info.pictures?.length" class="note"> 点击查看 </text> <text v-else class="note">暂无图片</text> </view> </view> </view> </template>
-
大图查看患者病情图片,uni-app 提供了大图查看图片的 API
uni.previewImage
<script setup> // 省略前面小节的代码... // 点击查看病情介绍图片 async function onPreviewClick(urls) { uni.previewImage({ urls: urls.map((item) => item.url), }) } </script> <template> <!-- 患者信息(21) --> <view class="patient-info"> <view class="header"> ... </view> <view class="content"> <view class="list-item"> <text class="label">病情描述</text> <text class="note">{{ props.info.illnessDesc }}</text> </view> <view class="list-item"> <text class="label">图片</text> <text v-if="props.info.pictures?.length" @click="onPreviewClick(props.info.pictures)" class="note" > 点击查看 </text> <text v-else class="note">暂无图片</text> </view> </view> </view> </template>
1.2.3 通知消息
通知消息分为3种,分别为:
消息类型 | 说明 | 备注 |
---|---|---|
31 | 普通通知 | 白底黑字 |
32 | 温馨提示 | |
33 | 取消订单 | 灰底黑字 |
首先创建消息通知组伯,通知消息的模板如下:
<!-- subpkg_consult/room/components/notify-info.vue --> <script setup></script> <template> <!-- 普通通知(31) --> <view class="message-tips"> <view class="wrapper">医护人员正在赶来,请耐心等候</view> </view> <!-- 温馨提示(32) --> <view class="message-tips"> <view class="wrapper"> <text class="label">温馨提示:</text> 在线咨询不能代替面诊,医护人员建议仅供参考 </view> </view> </template> <style lang="scss"> .message-tips { display: flex; justify-content: center; margin-top: 60rpx; &:first-child { margin-top: 30rpx; } } .wrapper { line-height: 1; text-align: center; padding: 20rpx 30rpx; // margin-top: 60rpx; font-size: 24rpx; border-radius: 70rpx; color: #848484; background-color: #fff; .label { color: #16c2a3; } } </style>
接下来分成3个步骤来实现:
-
定义组件的逻辑,要求能区分通知的类型并通过插槽来展示内容
<!-- subpkg_consult/room/components/notify-info.vue --> <script setup> // 接收外部传入的数据 const props = defineProps({ type: { type: Number, default: 31, }, }) </script> <template> <!-- 温馨提示(32) --> <view class="message-tips"> <text class="label">温馨提示:</text> <slot /> </view> </template>
-
在页面应用通知消息组件并传入数据
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' // 引入通知消息组件 import notifyInfo from './components/notify-info.vue' // 省略前面小节的代码 </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <template v-for="message in messageList" :key="message.id"> <!-- 消息通知 --> <notify-info v-if="message.msgType >= 31" :type="message.msgType"> {{ message.msg.content }} </notify-info> <!-- 此处将来填充更多代码... --> </template> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> ... </view> </view> </template>
-
接收并渲染组件数据
<!-- subpkg_consult/room/components/notify-info.vue --> <script setup> // 省略前面小节的代码... </script> <template> <!-- 温馨提示(32) --> <view class="message-tips"> <text v-if="props.type === 32" class="label">温馨提示:</text> <slot /> </view> </template>
1.2.4 文字/图片消息
实时接收到医生发送过来的消息,包括文字消息和图片消息两种类型,使用超级医生来模拟医生端发送消息,根据订单 ID 来打通医生端和患者端的聊天连接。
首先接收医生端的回复的消息需要监听的事件为 receiveChatMsg
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' // 省略前面小节的代码... // 接收消息 socket.on('receiveChatMsg', (message) => { // 修改消息为已读 socket.emit('updateMsgStatus', message.id) // 接收到的消息追加到消息列表中 messageList.value.push(message) }) </script>
然后创建文字消息组件,组件模板如下:
<!-- subpkg_consult/room/components/message-info.vue --> <script setup></script> <template> <!-- 文字/图片消息 --> <view class="message-item reverse"> <image class="room-avatar" src="/static/uploads/doctor-avatar-2.png" /> <view class="room-message"> <view class="time">14:13</view> <view class="text"> 您好,我是医师王医生,已收到您的问诊信息,我会尽量及时、准确、负责的回复您的问题,请您稍等。 </view> <image v-if="false" class="image" src="/static/uploads/feed-1.jpeg" mode="widthFix" /> </view> </view> </template> <style lang="scss"> .message-item { display: flex; align-self: flex-start; margin-top: 60rpx; .room-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; } .room-message { margin-left: 20rpx; } .time { font-size: 26rpx; color: #979797; } .image { max-width: 420rpx; margin-top: 10rpx; } .text { max-width: 420rpx; line-height: 1.75; padding: 30rpx 40rpx; margin-top: 16rpx; border-radius: 20rpx; font-size: 30rpx; color: #3c3e42; background-color: #fff; position: relative; &::after { content: ''; position: absolute; top: 0; left: -25rpx; width: 26rpx; height: 52rpx; background-image: url(https://consult-patient.oss-cn-hangzhou.aliyuncs.com/static/images/im-arrow-1.png); background-size: contain; } } &.reverse { flex-direction: row-reverse; align-self: flex-end; .room-message { margin-left: 0; margin-right: 20rpx; } .time { text-align: right; } .text { background-color: #16c2a3; color: #fff; &::after { left: auto; right: -25rpx; background-image: url(https://consult-patient.oss-cn-hangzhou.aliyuncs.com/static/images/im-arrow-2.png); } } } } </style>
接下来分成3个步骤来实现:
-
定义组件的逻辑,要求能接收外部传入的数据
<!-- subpkg_consult/room/components/message-info.vue --> <script setup> // 接收外部传入的数据 const props = defineProps({ info: { type: Object, default: {}, }, type: { type: Number, default: 1, }, }) </script>
-
到页面中应用组件并传入数据
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' // 引入通知消息组件 import messageInfo from './components/message-info.vue' // 省略前面小节的代码 </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <template v-for="message in messageList" :key="message.id"> <!-- 文字图片消息 --> <message-info v-if="message.msgType <= 4" :info="message" :type="message.msgType" /> <!-- 此处将来填充更多代码... --> </template> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> ... </view> </view> </template>
-
到组件是接收并渲染数据
<!-- subpkg_consult/room/components/message-info.vue --> <script setup> // ... </script> <template> <!-- 文字/图片消息 --> <view class="message-item"> <image class="room-avatar" :src="props.info.fromAvatar" /> <view class="room-message"> <view class="time">{{ props.info.createTime }}</view> <!-- 文字消息 --> <view v-if="props.type === 1" class="text"> {{ props.info.msg.content }} </view> <!-- 图片消息 --> <image v-if="props.type === 4" class="image" :src="props.info.msg.picture.url" mode="widthFix" /> </view> </view> </template>
-
处理消息的时间,安装
dayjs
npm install dayjs
<!-- subpkg_consult/room/index.vue --> <script setup> import dayjs from 'dayjs' // 省略前面小节的代码... // 格式化显示时间 function dateFormat(date) { return dayjs(date).format('hh:mm:ss') } </script> <template> <!-- 文字/图片消息 --> <view class="message-item"> <image class="room-avatar" :src="props.info.fromAvatar" /> <view class="room-message"> <view class="time">{{ dateFormat(props.info.createTime) }}</view> <view v-if="props.type === 1" class="text"> {{ props.info.msg.content }} </view> <image v-if="props.type === 4" class="image" :src="props.info.msg.picture.url" mode="widthFix" /> </view> </view> </template>
1.2.5 处方消息
医生根据问诊的情况开具诊断结果即为处方消息,到消息的类型值为 22
,首先创建组件,布局模板如下所示:
1
接下来分成3个步骤来实现:
-
定义组件逻辑,要求能接收组件外部传入的数据
<!-- subpkg_consult/room/components/prescription-info.vue --> <script setup> // 接收组件外部传入的数据 const props = defineProps({ info: { type: Object, default: {}, }, }) </script>
-
在页面中应用组件并传入数据
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' // 引入处方消息组件 import prescriptionInfo from './components/prescription-info.vue' // 省略前面小节的代码 </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <template v-for="message in messageList" :key="message.id"> <!-- 电子处方 --> <prescription-info v-if="message.msgType === 22" :info="message.msg.prescription" /> <!-- 此处将来填充更多代码... --> </template> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> ... </view> </view> </template>
-
在组件中接收并渲染数据
<!-- subpkg_consult/room/components/prescription-info.vue --> <script setup> // ... </script> <template> <!-- 处方消息(22)--> <view class="e-prescription"> <view class="prescription-content"> <view class="list-title"> <view class="label">电子处方</view> <view class="extra"> 原始处方 <uni-icons size="16" color="#848484" type="right" /> </view> </view> <view class="list-item"> {{ props.info.name }} {{ props.info.genderValue }} {{ props.info.age }}岁 {{ props.info.diagnosis }} </view> <view class="list-item">开方时间:{{ props.info.createTime }}</view> <view class="dividing-line"></view> <template v-for="medicine in props.info.medicines" :key="medicine.id"> <view class="list-title"> <view class="label"> <text class="name">{{ medicine.name }}</text> <text class="unit">85ml</text> <text class="quantity">x{{ medicine.quantity }}</text> </view> </view> <view class="list-item">{{ medicine.usageDosag }}</view> </template> </view> <navigator class="uni-link" hover-class="none" url="/subpkg_medicine/payment/index" > 购买药品 </navigator> </view> </template>
1.2.6 原始处方
在医生开完处方后会生成电子版的处方,通过调用接口进行查看。
1.2.7 医生评价
在医生端结束问诊后,患者可以对医生进行评价,医生评价的布局模板为:
<!-- subpkg_consult/room/components/rate-info.vue --> <script setup></script> <template> <!-- 医生评价 --> <view class="doctor-rating"> <view class="title">医生服务评价</view> <view class="subtitle">本次在线问诊服务您还满意吗?</view> <view class="rating"> <uni-rate :size="28" margin="12" :value="0" /> </view> <view class="text"> <uni-easyinput type="textarea" maxlength="150" :input-border="false" :styles="{ backgroundColor: '#f6f6f6' }" placeholder-style="font-size: 28rpx; color: #979797" placeholder="请描述您对医生的评价或是在医生看诊过程中遇到的问题" /> <text class="word-count">0/150</text> </view> <view class="anonymous"> <uni-icons v-if="true" size="16" color="#16C2A3" type="checkbox-filled" /> <uni-icons v-else size="16" color="#d1d1d1" type="circle" /> <text class="label">匿名评价</text> </view> <button disabled class="uni-button">提交</button> </view> </template> <script> export default { options: { styleIsolation: 'shared', }, } </script> <style lang="scss"> .doctor-rating { padding: 30rpx 30rpx 40rpx; border-radius: 20rpx; background-color: #fff; margin-top: 60rpx; .title { text-align: center; font-size: 30rpx; color: #121826; } .subtitle { text-align: center; font-size: 24rpx; color: #6f6f6f; margin: 10rpx 0 20rpx; } .rating { display: flex; justify-content: center; } .text { padding: 20rpx 30rpx; margin-top: 20rpx; background-color: #f6f6f6; border-radius: 20rpx; position: relative; } :deep(.uni-easyinput__content-textarea) { font-size: 28rpx; } .word-count { position: absolute; bottom: 20rpx; right: 30rpx; line-height: 1; font-size: 24rpx; color: #6f6f6f; } .anonymous { display: flex; align-items: center; justify-content: center; margin: 30rpx 0; color: #6f6f6f; font-size: 24rpx; .label { margin-left: 6rpx; } } .uni-button[disabled] { color: #a6dbd5; background-color: #eaf8f6; } } </style>
接下来分成5个步骤来实现:
-
到页面中应用该组件,消息的类型值是
23
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' // 引入处方消息组件 import rateInfo from './components/rate-info.vue' // 省略前面小节的代码 </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <template v-for="message in messageList" :key="message.id"> <!-- 医生评价 --> <rate-info v-if="message.msgType === 23"></rate-info> <!-- 此处将来填充更多代码... --> </template> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> ... </view> </view> </template>
-
获取评价数据并对数据进行验证:
-
v-model
获取数据 -
字数统计使用计算属性
-
控制字数使用
maxlength
-
<!-- subpkg_consult/room/components/rate-info.vue --> <script setup> import { computed, ref } from 'vue' // 评价内容 const formData = ref({ score: 0, content: '', anonymousFlag: 0, }) // 统计字数 const wordCount = computed(() => { return formData.value.content.length }) // 是否允许提交 const buttonEnable = computed(() => { return formData.value.score }) // 是否匿名评价 function onAnonymousClick() { formData.value.anonymousFlag = Math.abs(formData.value.anonymousFlag - 1) } </script> <template> <!-- 医生评价 --> <view class="doctor-rating"> <view class="title">医生服务评价</view> <view class="subtitle">本次在线问诊服务您还满意吗?</view> <view class="rating"> <uni-rate v-model="formData.score" :size="28" margin="12" /> </view> <view class="text"> <uni-easyinput type="textarea" maxlength="150" v-model="formData.content" :input-border="false" :styles="{ backgroundColor: '#f6f6f6' }" placeholder-style="font-size: 28rpx; color: #979797" placeholder="请描述您对医生的评价或是在医生看诊过程中遇到的问题" /> <text class="word-count">{{ wordCount }}/150</text> </view> <view @click="onAnonymousClick" class="anonymous"> <uni-icons v-if="formData.anonymousFlag" size="16" color="#16C2A3" type="checkbox-filled" /> <uni-icons v-else size="16" color="#d1d1d1" type="circle" /> <text class="label">匿名评价</text> </view> <button :disabled="!buttonEnable" class="uni-button">提交</button> </view> </template>
-
在提交评价时,需要获取问诊订单详情,在问诊订单详情中包含了医生的 ID,接口文档在这里
// services/consult.js import { http } from '@/utils/http' // 省略前面小节的代码... /** * 问诊订单详情 */ export const orderDetailApi = (orderId) => { return http.get('/patient/consult/order/detail', { params: { orderId } }) }
将订单 ID 和医生 ID 传入组件
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' import { orderDetailApi } from '@/services/consult' // 省略前面小节的代码... // 问诊订单详情 const orderDetail = ref({}) // 省略前面小节的代码... // 获取问诊订单详情 async function getOrderDetail() { // 调用接口 const { code, data, message } = await orderDetailApi(props.orderId) // 检测接口是否调用成功 if (code !== 10000) return uni.utils.toast(message) // 渲染问诊订单数据 orderDetail.value = data } getOrderDetail() </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <template v-for="message in messageList" :key="message.id"> <!-- 医生评价 --> <rate-info :order-id="props.orderId" :doctor-id="orderDetail.docInfo?.id" v-if="message.msgType === 23" /> <!-- 此处将来填充更多代码... --> </template> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> ... </view> </view> </template>
-
调用接口提交评价的数据,接口文档在这里
// services/doctor.js import { http } from '@/utils/http' // 省略了前面小节的代码... /** * 评价医生 */ export const evaluateDoctorApi = (data) => { return http.post('/patient/order/evaluate', data) }
<!-- subpkg_consult/room/components/rate-info.vue --> <script setup> import { computed, ref } from 'vue' import { evaluateDoctorApi } from '@/services/doctor' // 接收组件外部的数据 const props = defineProps({ orderId: String, doctorId: String, }) // 提交表单 async function onFormSubmit() { // 调用接口 const { code, data, message } = await evaluateDoctorApi({ docId: props.doctorId, orderId: props.orderId, ...formData.value, }) // 检测接口是否调用成功 if (code !== 10000) return uni.utils.toast(message) uni.utils.toast('感谢您的评价!') // 标记已经评价过 hasEvaluate.value = true } </script> <template> <!-- 医生评价 --> <view class="doctor-rating"> <view class="title">医生服务评价</view> <view class="subtitle">本次在线问诊服务您还满意吗?</view> <view class="rating"> <uni-rate v-model="formData.score" :size="28" margin="12" /> </view> <view class="text"> <uni-easyinput type="textarea" maxlength="150" v-model="formData.content" :input-border="false" :styles="{ backgroundColor: '#f6f6f6' }" placeholder-style="font-size: 28rpx; color: #979797" placeholder="请描述您对医生的评价或是在医生看诊过程中遇到的问题" /> <text class="word-count">{{ wordCount }}/150</text> </view> <view @click="onAnonymousClick" v-if="!hasEvaluate" class="anonymous"> <uni-icons v-if="formData.anonymousFlag" size="16" color="#16C2A3" type="checkbox-filled" /> <uni-icons v-else size="16" color="#d1d1d1" type="circle" /> <text class="label">匿名评价</text> </view> <button v-if="!hasEvaluate" :disabled="!buttonEnable" @click="onFormSubmit" class="uni-button" > 提交 </button> </view> </template>
-
已评价状态,消息类型值 为 24
<!-- subpkg_consult/room/components/rate-info.vue --> <script setup> import { ref, computed } from 'vue' import { evaluateDoctorApi } from '@/services/doctor' // 接收组件外部的数据 const props = defineProps({ orderId: String, doctorId: String, // 是否已评价过 hasEvaluate: { type: Boolean, default: false, }, // 评价的内容 evaluateDoc: { type: Object, default: {}, }, }) // 评价内容 const formData = ref({ score: props.evaluateDoc.score, content: props.evaluateDoc.content, // 注意要指定一个默认值为 0 anonymousFlag: 0, }) // 是否已经评价过 const hasEvaluate = ref(props.hasEvaluate) // 统计字数 const wordCount = computed(() => { // 通过 ? 来避免初始数据中 content 不存在的情况 return formData.value.content?.length || 0 }) </script>
<!-- subpkg_consult/room/index.vue --> <script setup> import { ref } from 'vue' import { io } from 'socket.io-client' import { useUserStore } from '@/stores/user' import { orderDetailApi } from '@/services/consult' // 省略前面小节的代码... </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <template v-for="message in messageList" :key="message.id"> <!-- 医生评价(已评价) --> <rate-info :evaluateDoc="message.msg.evaluateDoc" has-evaluate v-if="message.msgType === 24" /> <!-- 此处将来填充更多代码... --> </template> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> </view> </view> </template>
1.3 发送消息
患者向医生告之病情及询问诊断方法,分为文字图片消息两种类型,且只有问诊订单状态处理咨询中时才以发送消息,问诊订单的状态包含在订单详情数据中。
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节的代码... // 订单状态为3时,表示 问诊中... // 监听订单状态变化 socket.on('statusChange', getOrderDetail) // 省略前面小节的代码... </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> <!-- 省略前面小节的代码... --> </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> <template v-if="true"> <uni-easyinput :disabled="orderDetail.status !== 3" :clearable="false" :input-border="false" placeholder-style="font-size: 32rpx; color: #c3c3c5;" placeholder="问医生" /> <view class="image-button"> <uni-icons size="40" color="#979797" type="image"></uni-icons> </view> </template> <button v-else class="uni-button">咨询其它医生</button> </view> </view> </template>
1.3.1 文字消息
发送文字消息分3个步骤来实现:
-
监听
uni-easyinput
组件的confirm
事件并使用v-model
获取表单的内容
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节的代码... // 文字消息 const textMessage = ref('') // 省略前面小节的代码... // 发送文字消息 function onInputConfirm() { console.log(textMessage.value) } // 省略前面小节的代码... </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> ... </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> <template v-if="true"> <uni-easyinput v-model="textMessage" @confirm="onInputConfirm" :disabled="orderDetail.status !== 3" :clearable="false" :input-border="false" placeholder-style="font-size: 32rpx; color: #c3c3c5;" placeholder="问医生" /> <view class="image-button"> <uni-icons size="40" color="#979797" type="image"></uni-icons> </view> </template> <button v-else class="uni-button">咨询其它医生</button> </view> </view> </template>
-
触发服务端正在监听的事件类型,文档地址在这里
<script setup> // 省略前面小节的代码... // 用户登录信息(不具有响应式) const { token, userId } = useUserStore() // 问诊订单详情 const orderDetail = ref({}) // 文字消息 const textMessage = ref('') // 省略前面小节的代码... // 发送文字消息 function onInputConfirm() { // 发送消息 socket.emit('sendChatMsg', { // 当前登录用户的ID from: userId, to: orderDetail.value?.docInfo?.id, msgType: 1, msg: { content: textMessage.value, }, }) // 清空表单 textMessage.value = '' } // 省略前面小节的代码... </script>
在用户登录成功时,只记录了用户的 token
在患者向医生发送消息时还需要传递用户的 ID,在 Pinia 中添加数据来记录登录用户的 ID
// stores/user.js import { ref } from 'vue' import { defineStore } from 'pinia' export const useUserStore = defineStore( 'user', () => { // 记录用户登录状态 const token = ref('') // 记录登录成功后要路转的地址(默认值为首页) const redirectURL = ref('/pages/index/index') // 跳转地址时采用的 API 名称 const openType = ref('switchTab') // 用户ID const userId = ref('') return { token, userId, redirectURL, openType } }, { persist: { paths: ['token', 'userId', 'redirectURL', 'openType'], }, } )
<!-- pages/login/index.vue --> <script setup> async function onFormSubmit() { // 判断是否勾选协议 if (!isAgree.value) return uni.utils.toast('请先同意协议!') // 调用 uniForms 组件验证数据的方法 try { // 省略前面小节的代码... // 持久化存储 token userStore.token = data.token // 存储登录用户的 ID userStore.userId = data.id } catch (error) { console.log(error) } } </script>
-
调整消息的对齐方式,患者消息靠右显示
在消息中包含的属性 from
是消息发送者的 ID,如果与登录用户的 ID 一致,则表示是患者发送的消息,消息的内容要靠右显示,类名 reverse
可以控制靠右对齐。
<!-- subpkg_consult/room/components/message-info.vue --> <script setup> import dayjs from 'dayjs' import { useUserStore } from '@/stores/user.js' // 登录用户 ID const { userId } = useUserStore() // 省略前面小节的代码... </script> <template> <!-- 文字/图片消息 --> <view :class="{ reverse: props.info.from === userId }" class="message-item"> <image class="room-avatar" :src="props.info.fromAvatar" /> <view class="room-message"> <view class="time">{{ dateFormat(props.info.createTime) }}</view> <view v-if="props.type === 1" class="text"> {{ props.info.msg.content }} </view> <image v-if="props.type === 4" class="image" :src="props.info.msg.picture.url" mode="widthFix" /> </view> </view> </template>
1.3.2 图片消息
发送图片消息需要将图片上传到云空间,需要调用 uniCloud
提供的 API chooseAndUploadFile
,我们分x步来实现:
-
判断问诊订单状态是否为问诊中
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节代码... // 发送图片消息 function onImageButtonClick() { // 是否在问诊状态中... if (orderDetail.value.status !== 3) { return uni.utils.toast('医生当前不在线!') } } // 省略前面小节代码... </script> <template> <view class="room-page"> <!-- 此处将来填充更多代码... --> <scroll-view refresher-enabled refresher-background="#f2f2f2" scroll-y style="flex: 1; overflow: hidden" > <view class="message-container"> ... </view> </scroll-view> <!-- 发送消息 --> <view class="message-bar"> <template v-if="true"> <uni-easyinput v-model="textMessage" @confirm="onInputConfirm" :disabled="orderDetail.status !== 3" :clearable="false" :input-border="false" placeholder-style="font-size: 32rpx; color: #c3c3c5;" placeholder="问医生" /> <view @click="onImageButtonClick" class="image-button"> <uni-icons size="40" color="#979797" type="image"></uni-icons> </view> </template> <button v-else class="uni-button">咨询其它医生</button> </view> </view> </template>
-
调用 API 上传到 uniCloud 存储空间
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节代码... // 发送图片消息 function onImageButtonClick() { // 是否在问诊状态中... if (orderDetail.value.status !== 3) { return uni.utils.toast('医生当前不在线!') } // 上传图片到 uniCloud uniCloud.chooseAndUploadFile({ type: 'image', count: 1, extension: ['.jpg', '.png', '.gif'], success: ({ tempFiles }) => { console.log(tempFiles) }, }) } // 省略前面小节代码... </script> <template> ... </template>
-
向医生发送图片消息,文档地址在这里
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节代码... // 用户登录信息(不具有响应式) const { token, userId } = useUserStore() // 发送图片消息 function onImageButtonClick() { // 是否在问诊状态中... if (orderDetail.value.status !== 3) { return uni.utils.toast('医生当前不在线!') } // 上传图片到 uniCloud uniCloud.chooseAndUploadFile({ type: 'image', count: 1, extension: ['.jpg', '.png', '.gif'], success: ({ tempFiles }) => { // 上传成功的图片 const picture = { id: tempFiles[0].lastModified, url: tempFiles[0].url, } // 发送消息 socket.emit('sendChatMsg', { from: userId, to: orderDetail.value?.docInfo?.id, msgType: 4, msg: { picture }, }) }, }) } // 省略前面小节代码... </script>
1.4 问诊订单状态
患者在与医生对话的过程中问诊订单状态会发生改变,包括待支付、待接诊、咨询中、已完成、已取消,在页面的顶部要根据订单的状态展示不同的内容。
-
将问诊状态的布局模板独立到组件中,要求组件能接收3个数据
-
status
问诊订单的状态值 -
statusValue
问诊订单的文字描述 -
countdown
倒计时剩余时长
-
<!-- subpkg_consult/room/components/room-status.vue --> <script setup> // 接收组件外部传入的数据 const props = defineProps({ status: Number, statusValue: String, countdown: Number, }) </script> <template> <!-- 咨询室状态 --> <view class="room-status"> <view class="status countdown" v-if="false"> <text class="label">咨询中</text> <view class="time"> 剩余时间: <uni-countdown color="#3c3e42" :font-size="14" :show-day="false" :second="0" /> </view> </view> <view v-else-if="false" class="status waiting"> 已通知医生尽快接诊,24小时内医生未回复将自动退款 </view> <view v-else class="status"> <uni-icons size="20" color="#121826" type="checkbox-filled" /> 已结束 </view> </view> </template> <style lang="scss"> .room-status { font-size: 26rpx; position: sticky; top: 0; z-index: 99; .status { display: flex; padding: 30rpx; background-color: #fff; } .waiting { color: #16c2a3; background-color: #eaf8f6; } .countdown { justify-content: space-between; } .label { color: #16c2a3; } .icon-done { color: #121826; font-size: 28rpx; margin-right: 5rpx; } .time { display: flex; color: #3c3e42; } :deep(.uni-countdown) { margin-left: 6rpx; } } </style>
-
在页面中应用组件并传入数据,查询订单状态的的 API 在前面小节中已经调用了,即
getOrderDetail
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节的代码... // 问诊订单详情 const orderDetail = ref({}) // 获取问诊订单详情 async function getOrderDetail() { // 调用接口 const { code, data, message } = await orderDetailApi(props.orderId) // 检测接口是否调用成功 if (code !== 10000) return uni.utils.toast(message) // 渲染问诊订单数据 orderDetail.value = data } // 省略前面小节的代码... </script> <template> <view class="room-page"> <!-- 问诊订单状态 --> <room-status :status-value="orderDetail.statusValue" :countdown="orderDetail.countdown" :status="orderDetail.status" /> <!-- 省略前面小节的代码 --> </view> </template>
-
根据传入组件的订单状态展示数据
<!-- subpkg_consult/room/components/room-status.vue --> <template> <!-- 咨询室状态 --> <view class="room-status"> <!-- 待接诊(status: 2) --> <view v-if="props.status === 2" class="status waiting"> {{ props.statusValue }} </view> <!-- 咨询中(status: 3) --> <view class="status" v-if="props.status === 3"> <text class="label">{{ props.statusValue }}</text> <view class="time"> 剩余时间: <uni-countdown color="#3c3e42" :font-size="14" :show-day="false" :second="props.countdown" /> </view> </view> <!-- 已完成(status: 4) --> <view v-if="props.status === 4" class="status"> <view class="wrap"> <uni-icons size="20" color="#121826" type="checkbox-filled" /> {{ props.statusValue }} </view> </view> </view> </template>
1.5 消息分段
每次重新建立 Socket 连接后(刷新页面),后端都会对数据进行分组,前端在进行展示时也相应的需要展示分段的时间节点,这个时间节点按通知消息类型处理。
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节的代码... // 接收消息列表 socket.on('chatMsgList', ({ code, data }) => { // 没有返回数据 if (code !== 10000) return // 提取列表数据 const tempList = [] data.forEach(({ createTime, items }) => { // 追加到消息列表中 tempList.push( // 构造一条数据,显示时间节点 { msgType: 31, msg: { content: createTime }, id: createTime, }, ...items ) }) // 追加到消息列表中 messageList.value.unshift(...tempList) }) // 省略后面小节的代码... </script>
在返回的数据中 data
是一个数组,每个单元是一个消息的分组,在对该数组遍历时前端构造一条数据放到数组单元中,被构告的这条件数据仅仅是要显示一个时间节点。
1.6 历史消息
用户下拉操作时分页获取聊天记录,按以下几个步骤来实现:
-
启动下拉刷新并监听下拉操作
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节的代码... // 关闭下拉动画交互 const refreshTrigger = ref(false) // 省略前面小节的代码... // 下拉获取历史消息 function onPullDownRefresh() { // 开启下拉交互动画 refreshTrigger.value = true setTimeout(() => { // 关闭下拉交互动画 refreshTrigger.value = false }, 1000) } // 省略前面小节的代码... </script> <template> <view class="room-page"> <!-- 省略前面小节的代码... --> <scroll-view @refresherrefresh="onPullDownRefresh" refresher-enabled :refresher-triggered="refreshTrigger" background-color="#f2f2f2" > ... </scroll-view> <!-- 省略前面小节的代码... --> </view> </template>
-
触发后端定义的事件类型获取历史消息,文档地址在这里。
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节的代码... // 关闭下拉动画交互 const refreshTrigger = ref(false) // 上次获取历史消息节点 const lastTime = ref(dayjs().format('YYYY-MM-DD HH:mm:ss')) // 省略前面小节的代码... // 下拉获取历史消息 function onPullDownRefresh() { // 开启下拉交互动画 refreshTrigger.value = true // 获取历史消息 socket.emit('getChatMsgList', 20, lastTime.value, props.orderId) } // 省略前面小节的代码... </script> <template> <view class="room-page"> <!-- 省略前面小节的代码... --> <scroll-page @refresherrefresh="onPullDownRefresh" refresher-enabled :refresher-triggered="refreshTrigger" background-color="#f2f2f2" > ... </scroll-page> <!-- 省略前面小节的代码... --> </view> </template>
-
更新时间节点,获取的历史消息会返回给客户端
<!-- subpkg_consult/room/index.vue --> <script setup> // 省略前面小节的代码... // 接收消息列表 socket.on('chatMsgList', ({ code, data }) => { // 关闭下拉交互动画 refreshTrigger.value = false // 没有返回数据 if (code !== 10000) return // 提取列表数据 const tempList = [] data.forEach(({ createTime, items }, index) => { // 获取消息的时间节点 if (index === 0) lastTime.value = createTime // 追加到消息列表中 tempList.push( { msgType: 31, msg: { content: createTime }, id: createTime, }, ...items ) }) // 是否获取到新数据 if (tempList.length === 0) return uni.utils.toast('没有更多聊天记录了') // 追加到消息列表中 messageList.value.unshift(...tempList) }) // 省略前面小节的代码... </script>
注意事项:
-
历史消息是以从后往前的顺序获取,将历史消息中第1个分组的时间节点做为下一次获取历史消息的起始点
-
获取数据即表示请求结束,要关闭下拉交互的动画
-
判断是否还存在更多的历史消息
支付宝支付账号,密码为 111111
scobys4865@sandbox.com
askgxl8276@sandbox.com