学习目标:
- 能够基于 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个分组的时间节点做为下一次获取历史消息的起始点
- 获取数据即表示请求结束,要关闭下拉交互的动画
- 判断是否还存在更多的历史消息