Merge remote-tracking branch 'origin/dev-wdb' into dev-hjf
This commit is contained in:
commit
590a35d54a
@ -1,3 +1,4 @@
|
||||
MP_API_BASE_URL=http://localhost:8080
|
||||
MP_CACHE_PREFIX=development
|
||||
MP_WX_APP_ID=wx93af55767423938e
|
||||
MP_CORP_ID=wwe3fb2faa52cf9dfb
|
||||
|
||||
218
App.vue
218
App.vue
@ -1,351 +1,353 @@
|
||||
<script>
|
||||
import useAccountStore from "@/store/account.js";
|
||||
export default {
|
||||
onLaunch: function () {
|
||||
console.log('App Launch: ')
|
||||
},
|
||||
onShow: function () {
|
||||
console.log('App Show')
|
||||
},
|
||||
onHide: function () {
|
||||
console.log('App Hide')
|
||||
}
|
||||
}
|
||||
onLaunch: function () {
|
||||
// 需在 pinia 安装后再获取 store,避免 getActivePinia 报错
|
||||
const { login } = useAccountStore();
|
||||
login();
|
||||
console.log("App Launch: ");
|
||||
},
|
||||
onShow: function () {
|
||||
console.log("App Show");
|
||||
},
|
||||
onHide: function () {
|
||||
console.log("App Hide");
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$primary-color: #0074ff;
|
||||
|
||||
page {
|
||||
height: 100%;
|
||||
background: #f5f5f5;
|
||||
height: 100%;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.shadow-up {
|
||||
box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px,
|
||||
rgba(0, 0, 0, 0.1) 0px -10px 15px -3px, rgba(0, 0, 0, 0.1) 0px -4px 6px -4px;
|
||||
box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px,
|
||||
rgba(0, 0, 0, 0.1) 0px -10px 15px -3px, rgba(0, 0, 0, 0.1) 0px -4px 6px -4px;
|
||||
}
|
||||
|
||||
|
||||
.shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-around {
|
||||
justify-content: space-around;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bg-gray {
|
||||
background: #f5f5f5;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background: $primary-color;
|
||||
background: $primary-color;
|
||||
}
|
||||
|
||||
.bg-success {
|
||||
background: green;
|
||||
background: green;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background: #fff;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
background: orange;
|
||||
background: orange;
|
||||
}
|
||||
|
||||
.bg-danger {
|
||||
background: rgb(248 113 113);
|
||||
background: rgb(248 113 113);
|
||||
}
|
||||
|
||||
.py-5 {
|
||||
padding-top: 10rpx;
|
||||
padding-bottom: 10rpx;
|
||||
padding-top: 10rpx;
|
||||
padding-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.p-15 {
|
||||
padding: 30rpx;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.p-10 {
|
||||
padding: 20rpx;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.px-5 {
|
||||
padding-left: 10rpx;
|
||||
padding-right: 10rpx;
|
||||
padding-left: 10rpx;
|
||||
padding-right: 10rpx;
|
||||
}
|
||||
|
||||
.px-10 {
|
||||
padding-left: 20rpx;
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
padding-right: 20rpx;
|
||||
}
|
||||
|
||||
.px-12 {
|
||||
padding-left: 24rpx;
|
||||
padding-right: 24rpx;
|
||||
padding-left: 24rpx;
|
||||
padding-right: 24rpx;
|
||||
}
|
||||
|
||||
.px-15 {
|
||||
padding-left: 30rpx;
|
||||
padding-right: 30rpx;
|
||||
padding-left: 30rpx;
|
||||
padding-right: 30rpx;
|
||||
}
|
||||
|
||||
.pt-5 {
|
||||
padding-top: 10rpx;
|
||||
padding-top: 10rpx;
|
||||
}
|
||||
.pt-15 {
|
||||
padding-top: 30rpx;
|
||||
padding-top: 30rpx;
|
||||
}
|
||||
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pb-5 {
|
||||
padding-bottom: 10rpx;
|
||||
padding-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.pb-10 {
|
||||
padding-bottom: 20rpx;
|
||||
padding-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.py-10 {
|
||||
padding-top: 20rpx;
|
||||
padding-bottom: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
padding-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.py-12 {
|
||||
padding-top: 24rpx;
|
||||
padding-bottom: 24rpx;
|
||||
padding-top: 24rpx;
|
||||
padding-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.py-15 {
|
||||
padding-top: 30rpx;
|
||||
padding-bottom: 30rpx;
|
||||
padding-top: 30rpx;
|
||||
padding-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.mr-5 {
|
||||
margin-right: 10rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
|
||||
.mr-10 {
|
||||
margin-right: 20rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.ml-15 {
|
||||
margin-left: 30rpx;
|
||||
margin-left: 30rpx;
|
||||
}
|
||||
|
||||
.mb-5 {
|
||||
margin-bottom: 10rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.mb-10 {
|
||||
margin-bottom: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 20rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.mt-15 {
|
||||
margin-top: 30rpx;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 24rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.mx-10 {
|
||||
margin-left: 20rpx;
|
||||
margin-right: 20rpx;
|
||||
margin-left: 20rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 16rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.rounded-sm {
|
||||
border-radius: 8rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 999px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-gray {
|
||||
color: #999;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.text-black {
|
||||
color: #000;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.text-dark {
|
||||
color: #333;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: $primary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #67c23a;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.text-white {
|
||||
color: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #f56c6c;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 24rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 28rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 32rpx;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 36rpx;
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.leading-normal {
|
||||
line-height: 1.5;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1px solid #eee;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.border-dashed {
|
||||
border: 1px dashed #eee;
|
||||
border: 1px dashed #eee;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.border-auto {
|
||||
border: 1px solid;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.border-dashed-auto {
|
||||
border: 1px dashed;
|
||||
border: 1px dashed;
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border: 1px solid $primary-color;
|
||||
border: 1px solid $primary-color;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.w-0 {
|
||||
width: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.border-box {
|
||||
box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.safe-bottom-padding {
|
||||
height: env(safe-area-inset-bottom);
|
||||
height: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
|
||||
98
api/consult-order.js
Normal file
98
api/consult-order.js
Normal file
@ -0,0 +1,98 @@
|
||||
// 患者管理模块 API 封装
|
||||
import api from '../utils/http.js'
|
||||
|
||||
// API 基础路径
|
||||
const BASE_PATH = '/order'
|
||||
|
||||
/**
|
||||
* 初始化咨询订单
|
||||
*/
|
||||
export function initConsultOrder({ doctorCode, accountId, openId, memberId }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/init`, { doctorCode, accountId, openId, memberId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定订单的患者信息
|
||||
*/
|
||||
export function bindOrderPatient({ accountId, memberId, orderId }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/bind-member`, { accountId, memberId, orderId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 补充病情描述
|
||||
*/
|
||||
export function submitOrderDescription({ accountId, orderId, description, diseases, images, hasVisitedHospital }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/supplement-description`, { accountId, orderId, description, diseases, images, hasVisitedHospital })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单信息
|
||||
*/
|
||||
export function getOrderInfo({ orderId }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/get`, { orderId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单列表
|
||||
*/
|
||||
export function getOrderList({ accountId, page, pageSize: limit, orderStatus }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/list`, { accountId, page, limit, orderStatus })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新订单
|
||||
*/
|
||||
export function getLatestOrder({ accountId, chatGroupId }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/latest`, { accountId, chatGroupId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单支付信息
|
||||
*/
|
||||
export function getOrderTradeNo({ orderId, accountId }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/get-trade-no`, { accountId, orderId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
export function cancelOrder({ orderId, accountId }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/cancel`, { orderId, accountId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号统计信息
|
||||
*/
|
||||
export function getAccountStats(accountId, statusList) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/account-stats`, { accountId, statusList })
|
||||
}
|
||||
|
||||
export function getConsultInfo({doctorCode, memberId, accountId}) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/get-consult-info`, { doctorCode, memberId, accountId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新订单最后一条消息ID
|
||||
*/
|
||||
export function updateLastMessageId({ orderId, lastMessageId }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/update-last-message`, { orderId, lastMessageId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到群组
|
||||
* @param {Object} params
|
||||
* @param {string} params.groupId - 群组ID
|
||||
* @param {string} params.desc - 消息描述/类型,如:'WAIT_DOCTOR_ACCEPT'
|
||||
* @param {string} params.message - 消息内容
|
||||
* @param {string} params.ext - 扩展信息(JSON字符串)
|
||||
* @param {string} params.fromAccount - 发送者账号(可选)
|
||||
*/
|
||||
export function sendMessageToGroup({ groupId, desc, message, ext, fromAccount }) {
|
||||
return api.post(`${BASE_PATH}/consult-orders/send-message-to-group`, {
|
||||
groupId,
|
||||
desc,
|
||||
message,
|
||||
ext,
|
||||
fromAccount
|
||||
})
|
||||
}
|
||||
13
api/corp/dept.js
Normal file
13
api/corp/dept.js
Normal file
@ -0,0 +1,13 @@
|
||||
import api from "../../utils/http.js";
|
||||
|
||||
const BASE_PATH = "/corp";
|
||||
|
||||
/**
|
||||
* 获取科室列表(按 corpId)
|
||||
* @param {Object} params
|
||||
* @param {string} params.corpId
|
||||
*/
|
||||
export function getDeptList(params = {}) {
|
||||
return api.post(`${BASE_PATH}`, { type: "getDeptList", ...params });
|
||||
}
|
||||
|
||||
48
api/corp/im.js
Normal file
48
api/corp/im.js
Normal file
@ -0,0 +1,48 @@
|
||||
import api from '../../utils/http.js'
|
||||
|
||||
// API 基础路径
|
||||
const BASE_PATH = '/corp'
|
||||
|
||||
/**
|
||||
* 获取用户签名
|
||||
* @param {string} userId - 用户ID
|
||||
* @returns {Promise} 返回包含 userSig 信息的 Promise
|
||||
*/
|
||||
export const getUserSig = (userId) => {
|
||||
return api.post(`${BASE_PATH}/tencent-im/user-sig`, { userId })
|
||||
}
|
||||
|
||||
export async function getChatStatus(chatGroupId) {
|
||||
return api.post(`${BASE_PATH}/tencent-im/get-chat-status`, { chatGroupId, role: 'patient' })
|
||||
}
|
||||
|
||||
export const sendSystemMessage = (groupId, data, Desc = '', Ext = '') => {
|
||||
return api.post(`${BASE_PATH}/tencent-im/send-group-message`, {
|
||||
groupId,
|
||||
msgBody: [
|
||||
{
|
||||
MsgType: "TIMCustomElem",
|
||||
MsgContent: {
|
||||
Data: data,
|
||||
Desc,
|
||||
Ext
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群组聊天记录(POST请求)
|
||||
* @param {string} groupId - 群组ID
|
||||
* @param {number} limit - 每页数量,默认20,最大100
|
||||
* @param {number} skip - 跳过数量,默认0
|
||||
* @returns {Promise} 返回包含聊天记录的 Promise
|
||||
*/
|
||||
export const getChatRecordsByGroupId = (groupId, limit = 20, skip = 0) => {
|
||||
return api.post(`${BASE_PATH}/tencent-im/chat-records`, {
|
||||
GroupId: groupId,
|
||||
limit,
|
||||
skip
|
||||
})
|
||||
}
|
||||
15
api/corp/rate.js
Normal file
15
api/corp/rate.js
Normal file
@ -0,0 +1,15 @@
|
||||
import api from '../../utils/http.js'
|
||||
|
||||
// API 基础路径
|
||||
const BASE_PATH = '/corp/rate-records'
|
||||
|
||||
export const getRate = (id) => {
|
||||
return api.post(`${BASE_PATH}/get-by-id`, { id })
|
||||
}
|
||||
export function submitRate({ id, rate, words }) {
|
||||
return api.post(`${BASE_PATH}/submit`, { id, rate, words })
|
||||
}
|
||||
|
||||
export const getRateList = ({ page, pageSize, doctorId }) => {
|
||||
return api.post(`${BASE_PATH}/displayable-list`, { page, limit: pageSize, userId: doctorId })
|
||||
}
|
||||
75
api/doctor/doctor.js
Normal file
75
api/doctor/doctor.js
Normal file
@ -0,0 +1,75 @@
|
||||
import api from '../../utils/http.js'
|
||||
|
||||
const BASE_PATH = "/corp/doctors";
|
||||
|
||||
/**
|
||||
* 获取医生
|
||||
* @param {string} id - 医生ID
|
||||
* @returns {Promise<Object>} 医生数据
|
||||
*/
|
||||
export function getDoctorInfo(id) {
|
||||
return api.post(`${BASE_PATH}/get-by-id`, { id })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取邀请医生
|
||||
* @param {string} doctorId - 医生ID
|
||||
* @returns {Promise<Object>} 邀请医生数据
|
||||
*/
|
||||
export function getDoctorByDoctorId(doctorId) {
|
||||
return api.get(`${BASE_PATH}/doctor/${doctorId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取医生
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns
|
||||
*/
|
||||
export function getDoctorInfoByAccountId(accountId) {
|
||||
return api.post(`${BASE_PATH}/get-by-account-id`, { accountId })
|
||||
}
|
||||
/**
|
||||
* 获取医生
|
||||
* @param {string} doctorId - 医生ID
|
||||
* @returns
|
||||
*/
|
||||
export function getDoctorInfoByDoctorId(doctorId) {
|
||||
return api.post(`${BASE_PATH}/get-by-doctor-id`, { doctorId })
|
||||
}
|
||||
/**
|
||||
* 创建医生
|
||||
* @param {Object} data - 医生数据
|
||||
* @param {string} data.accountId - 账户ID
|
||||
* @param {string} data.avatar - 医生头像
|
||||
* @param {string} data.name - 医生姓名
|
||||
* @param {string} data.phone - 医生手机号
|
||||
* @param {string} data.hospitalId - 医院ID
|
||||
* @param {string} data.hospitalName - 医院名称
|
||||
* @param {string} data.departmentId - 科室ID
|
||||
* @param {string} data.title - 医生职称
|
||||
* @param {string} data.specialty - 医生擅长
|
||||
* @param {string} data.intro - 个人简介
|
||||
* @param {string} data.titleCertificate - 职称证书
|
||||
* @param {string} data.practiceLicenseCode - 执业证编号
|
||||
* @param {string} data.practiceLicenseFront - 执业证正面
|
||||
* @param {string} data.practiceLicenseBack - 执业证背面
|
||||
* @param {string} data.medicalLicenseFront - 资格证正面
|
||||
* @param {string} data.medicalLicenseBack - 资格证背面
|
||||
* @param {string} data.medicalLicenseCode - 资格证编号
|
||||
* @param {string} data.idCardFront - 身份证正面
|
||||
* @param {string} data.idCardBack - 身份证背面
|
||||
* @param {string} data.workCard - 工作证
|
||||
* @returns {Promise<Object>} 创建结果
|
||||
*/
|
||||
export function createDoctorInfo(data) {
|
||||
return api.post(`${BASE_PATH}/create`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新医生
|
||||
* @param {Object} data - 医生数据
|
||||
* @returns {Promise<Object>} 更新结果
|
||||
*/
|
||||
export function updateDoctorInfo(data) {
|
||||
return api.post(`${BASE_PATH}/update`, data)
|
||||
}
|
||||
50
api/medicine.js
Normal file
50
api/medicine.js
Normal file
@ -0,0 +1,50 @@
|
||||
// 患者管理模块 API 封装
|
||||
import api from '../utils/http.js'
|
||||
|
||||
// API 基础路径
|
||||
const BASE_PATH = '/medicine'
|
||||
|
||||
export function getDiagnosisList({ type, keyword, page, limit }) {
|
||||
return api.post(`${BASE_PATH}/diagnoses/list`, { type, keyword, page, limit })
|
||||
}
|
||||
|
||||
export function getAccountPrescriptions({ accountId: patientAccountId, doctorId, patientId, statusList, page, limit }) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/get-by-patient-account`, { patientAccountId, doctorId, statusList, page, limit, patientId })
|
||||
}
|
||||
|
||||
export function getPrescriptionStats(accountId, statusList) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/account-stats`, { accountId, statusList })
|
||||
}
|
||||
|
||||
export function getPrescriptionDetail(id) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/get-by-id`, { id })
|
||||
}
|
||||
|
||||
export function getPrescriptionDetailWithRps(id) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/get-by-id-with-rps`, { id })
|
||||
}
|
||||
|
||||
export function bindSharePrescription({ id, accountId, openId, patientId }) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/bind-member`, { id, accountId, openId, patientId })
|
||||
}
|
||||
|
||||
export function getPrescriptionTradeNo({ id, openId, accountId, logistics }) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/get-trade-no`, { id, openId, accountId, logistics })
|
||||
}
|
||||
|
||||
export function cancelPrescription({ accountId, id }) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/cancel`, { id, accountId })
|
||||
}
|
||||
|
||||
export function applyMedicines({ id, patientAccountId, openId }) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/apply-represcription`, { id, patientAccountId, openId })
|
||||
}
|
||||
|
||||
export function getCartMedicines({ accountId, ids }) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/get-cart-medicines`, { accountId, ids })
|
||||
}
|
||||
|
||||
export function getMergeTradeNo({ ids, openId, accountId, logistics }) {
|
||||
return api.post(`${BASE_PATH}/prescriptions/get-merge-trade-no`, { ids, openId, accountId, logistics })
|
||||
|
||||
}
|
||||
76
hooks/useImageUpload.js
Normal file
76
hooks/useImageUpload.js
Normal file
@ -0,0 +1,76 @@
|
||||
import { ref } from "vue";
|
||||
import { uploadFile } from '@/api/file';
|
||||
import { loading as showLoading, hideLoading, toast } from '@/utils/widget'
|
||||
|
||||
const BusinessType = {
|
||||
order: 'consult_order', // 咨询订单
|
||||
chat: 'chat_message', // 聊天消息
|
||||
other: 'other' // 其他
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传hook
|
||||
* @param {*} maxCount
|
||||
* @returns
|
||||
*/
|
||||
export default function useImageUpload(type, maxCount = 1) {
|
||||
const images = ref([])
|
||||
const businessType = BusinessType[type] || '';
|
||||
const tempPaths = [];
|
||||
|
||||
if (!businessType) {
|
||||
toast('无效的businessType')
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (!businessType) {
|
||||
toast(`无效的businessType`)
|
||||
return
|
||||
}
|
||||
if (images.value.length > maxCount) {
|
||||
toast('最多上传' + maxCount + '张图片')
|
||||
return
|
||||
}
|
||||
uni.chooseMedia({
|
||||
count: maxCount - images.value.length,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
sizeType: ['compressed'],
|
||||
success: (res) => {
|
||||
const tempFilePaths = res.tempFiles.map(item => item.tempFilePath);
|
||||
images.value = [...images.value, ...tempFilePaths];
|
||||
tempPaths.push(...tempFilePaths);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
images.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function uploadImages() {
|
||||
const uploadPaths = images.value.filter(i => tempPaths.includes(i));
|
||||
showLoading('正在上传图片...')
|
||||
const res = await Promise.all(uploadPaths.map(uploadImage));
|
||||
let failCount = 0;
|
||||
uploadPaths.forEach((path, idx) => {
|
||||
const index = images.value.findIndex(i => i === path && path);
|
||||
const url = typeof res[idx] === 'string' && res[idx].trim() ? res[idx] : '';
|
||||
if(url && index >= 0) {
|
||||
images.value[index] = url;
|
||||
} else if(!url) {
|
||||
failCount++;
|
||||
}
|
||||
})
|
||||
hideLoading()
|
||||
toast(failCount ? `${failCount}张图片上传失败` : '图片上传成功')
|
||||
return failCount === 0
|
||||
}
|
||||
|
||||
async function uploadImage(path) {
|
||||
const res = await uploadFile(path, businessType);
|
||||
return res ? res.previewUrl : '';
|
||||
}
|
||||
|
||||
return { images, add, remove, uploadImage, uploadImages }
|
||||
}
|
||||
22
hooks/useOnceEvent.js
Normal file
22
hooks/useOnceEvent.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
|
||||
export function registerOnceEvent(callback, prefix = 'once_') {
|
||||
// 生成一个唯一事件名
|
||||
const eventName = `${prefix}${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
||||
uni.$once(eventName, callback);
|
||||
return eventName;
|
||||
}
|
||||
|
||||
// 触发事件
|
||||
export function triggerOnceEvent() {
|
||||
let eventName = '';
|
||||
onLoad((e) => {
|
||||
eventName = e.eventName;
|
||||
})
|
||||
function trigger(data) {
|
||||
if (eventName && typeof eventName === 'string') {
|
||||
uni.$emit(eventName, data);
|
||||
}
|
||||
}
|
||||
return trigger;
|
||||
}
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -9,7 +9,9 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.10"
|
||||
"dayjs": "^1.11.10",
|
||||
"tim-upload-plugin": "^1.4.2",
|
||||
"tim-wx-sdk": "^2.27.6"
|
||||
},
|
||||
"devDependencies": {}
|
||||
},
|
||||
@ -17,6 +19,16 @@
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
|
||||
},
|
||||
"node_modules/tim-upload-plugin": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/tim-upload-plugin/-/tim-upload-plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-3ZmbA36dr3eG9YGDon9MLBUtbNawYWkL+TBa+VS0Uviguc7PlVSOIVRG2C4irXX16slDT2Kj+HAZapp+Xqp2xg=="
|
||||
},
|
||||
"node_modules/tim-wx-sdk": {
|
||||
"version": "2.27.6",
|
||||
"resolved": "https://registry.npmmirror.com/tim-wx-sdk/-/tim-wx-sdk-2.27.6.tgz",
|
||||
"integrity": "sha512-zB+eRdmigdhEDeqrXC0bLJonUQZzS5uKNPLFtrje503WAnmuxVQjq/n4Zle4FYHG4FiKHKhsrVd0aCYXABlFEg=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,9 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.10"
|
||||
"dayjs": "^1.11.10",
|
||||
"tim-upload-plugin": "^1.4.2",
|
||||
"tim-wx-sdk": "^2.27.6"
|
||||
},
|
||||
"uni-app": {
|
||||
"scripts": {
|
||||
|
||||
18
pages.json
18
pages.json
@ -65,6 +65,24 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "工作台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/work/profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "完善个人信息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/work/department-select",
|
||||
"style": {
|
||||
"navigationBarTitleText": "选择科室"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@ -82,7 +82,7 @@ function remind() {
|
||||
|
||||
function toHome() {
|
||||
uni.navigateTo({
|
||||
url: "/pages/home/home",
|
||||
url: "/pages/message/message",
|
||||
});
|
||||
}
|
||||
|
||||
@ -112,7 +112,11 @@ onLoad((opts) => {
|
||||
console.log("redirectUrl", redirectUrl.value);
|
||||
return;
|
||||
}
|
||||
redirectUrl.value = opts.redirectUrl || "";
|
||||
if (opts.redirect) {
|
||||
redirectUrl.value = decodeURIComponent(opts.redirect);
|
||||
} else {
|
||||
redirectUrl.value = opts.redirectUrl || "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
1235
pages/message/chat.scss
Normal file
1235
pages/message/chat.scss
Normal file
File diff suppressed because it is too large
Load Diff
389
pages/message/components/chat-input.vue
Normal file
389
pages/message/components/chat-input.vue
Normal file
@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<view class="input-section">
|
||||
<view class="input-toolbar">
|
||||
<view @click="toggleVoiceInput" class="voice-toggle-btn">
|
||||
<uni-icons v-if="showVoiceInput" fontFamily="keyboard" :size="28">{{ '' }}</uni-icons>
|
||||
<uni-icons v-else type="mic" size="28" color="#666" />
|
||||
</view>
|
||||
<view class="input-area">
|
||||
<input v-if="!showVoiceInput" class="text-input" v-model="inputText" placeholder="我来说两句..."
|
||||
@confirm="sendTextMessage" @focus="handleInputFocus" />
|
||||
<input v-else class="voice-input-btn" :class="{ recording: isRecording }" @touchstart="startRecord"
|
||||
@touchmove="onRecordTouchMove" @touchend="stopRecord" @touchcancel="cancelRecord" :placeholder="isRecording ? '松开发送' : '按住说话'" disabled>
|
||||
</input>
|
||||
</view>
|
||||
<button v-if="inputText.trim()" class="send-btn" @click="sendTextMessage">
|
||||
发送
|
||||
</button>
|
||||
<view v-else class="plus-btn" @click="toggleMorePanel()">
|
||||
<uni-icons type="plusempty" size="28" color="#666" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="more-panel" v-if="showMorePanel">
|
||||
<view v-for="btn in morePanelButtons" :key="btn.text" class="more-btn" @click="btn.action">
|
||||
<image :src="btn.icon" class="more-icon" mode="aspectFit"></image>
|
||||
<text>{{ btn.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音遮罩层 -->
|
||||
<view v-if="isRecording" class="recording-overlay">
|
||||
<view class="recording-modal" :class="{ 'cancel-mode': isCancelMode }">
|
||||
<view class="recording-icon-container">
|
||||
<view v-if="!isCancelMode" class="wave-circle wave-1"></view>
|
||||
<view v-if="!isCancelMode" class="wave-circle wave-2"></view>
|
||||
<view v-if="!isCancelMode" class="wave-circle wave-3"></view>
|
||||
<view class="mic-icon-wrapper" :class="{ 'cancel-icon': isCancelMode }">
|
||||
<uni-icons v-if="!isCancelMode" type="mic-filled" size="60" color="#fff" />
|
||||
<uni-icons v-else type="closeempty" size="60" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="recording-text" :class="{ 'cancel-text': isCancelMode }">
|
||||
{{ isCancelMode ? '松开手指,取消录音' : '正在录音...' }}
|
||||
</view>
|
||||
<view v-if="!isCancelMode" class="recording-hint">松开发送,上滑取消</view>
|
||||
<view v-if="!isCancelMode" class="recording-duration">{{ recordingDuration }}s</view>
|
||||
<view v-else class="cancel-hint">
|
||||
<uni-icons type="up" size="20" color="#ff4757" />
|
||||
<text>已上滑</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import {
|
||||
chooseImage,
|
||||
takePhoto as takePhotoUtil,
|
||||
initRecorderManager as initRecorderManagerUtil,
|
||||
startRecord as startRecordUtil,
|
||||
stopRecord as stopRecordUtil,
|
||||
createCustomMessage,
|
||||
sendCustomMessage as sendCustomMessageUtil,
|
||||
sendMessage as sendMessageUtil,
|
||||
checkRecordingDuration,
|
||||
validateBeforeSend
|
||||
} from '@/utils/chat-utils.js';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
timChatManager: { type: Object, required: true },
|
||||
patientInfo: { type: Object, default: () => ({}) },
|
||||
chatRoomBusiness: { type: Object, default: () => ({}) },
|
||||
formatTime: { type: Function, required: true }
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['messageSent', 'scrollToBottom']);
|
||||
|
||||
// 输入相关状态
|
||||
const inputText = ref("");
|
||||
const showVoiceInput = ref(false);
|
||||
const showMorePanel = ref(false);
|
||||
const isRecording = ref(false);
|
||||
const recordingText = ref("录音中...");
|
||||
const cloudCustomData = computed(() => {
|
||||
const arr = [props.chatRoomBusiness.businessType, props.chatRoomBusiness.businessId];
|
||||
return arr.filter(Boolean).join('|');
|
||||
})
|
||||
|
||||
// 录音相关扩展状态(特效 + 取消逻辑)
|
||||
const recordingDuration = ref(0);
|
||||
let recordingTimer = null;
|
||||
const isCancelMode = ref(false);
|
||||
let touchStartY = 0;
|
||||
const CANCEL_DISTANCE = 100;
|
||||
let discardRecording = false; // 取消后丢弃本次录音
|
||||
|
||||
// 录音管理器
|
||||
let recorderManager = null;
|
||||
|
||||
// 初始化录音管理器
|
||||
const initRecorderManager = () => {
|
||||
recorderManager = initRecorderManagerUtil(
|
||||
{},
|
||||
(res) => {
|
||||
// 若本次被标记为丢弃(取消录音触发的 stop),则忽略发送
|
||||
if (discardRecording) {
|
||||
discardRecording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查录音时长
|
||||
if (!checkRecordingDuration(res, () => {
|
||||
isRecording.value = false;
|
||||
recordingText.value = "录音中...";
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置录音状态
|
||||
isRecording.value = false;
|
||||
recordingText.value = "录音中...";
|
||||
clearDurationTimer();
|
||||
|
||||
// 发送语音消息
|
||||
const duration = Math.floor(res.duration / 1000);
|
||||
sendVoiceMessage(res, duration);
|
||||
},
|
||||
(err) => {
|
||||
isRecording.value = false;
|
||||
recordingText.value = "录音中...";
|
||||
clearDurationTimer();
|
||||
discardRecording = false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 发送文本消息
|
||||
const sendTextMessage = async () => {
|
||||
if (!inputText.value.trim()) return;
|
||||
|
||||
await sendMessage('text', inputText.value);
|
||||
inputText.value = "";
|
||||
};
|
||||
|
||||
// 发送图片消息
|
||||
const sendImageMessage = async (imageUrl) => {
|
||||
await sendMessage('image', imageUrl);
|
||||
};
|
||||
|
||||
// 发送语音消息
|
||||
const sendVoiceMessage = async (voiceFile, duration) => {
|
||||
await sendMessage('voice', { file: voiceFile, duration });
|
||||
};
|
||||
|
||||
// 发送消息的通用方法(文本、图片、语音)
|
||||
const sendMessage = async (messageType, data) => {
|
||||
await sendMessageUtil(
|
||||
messageType,
|
||||
data,
|
||||
props.timChatManager,
|
||||
() => validateBeforeSend(false, false, props.timChatManager),
|
||||
() => {
|
||||
showMorePanel.value = false;
|
||||
emit('messageSent');
|
||||
},
|
||||
cloudCustomData.value
|
||||
);
|
||||
};
|
||||
|
||||
// 发送自定义消息的通用方法
|
||||
const sendCustomMessage = async (messageData) => {
|
||||
await sendCustomMessageUtil(
|
||||
messageData,
|
||||
props.timChatManager,
|
||||
() => validateBeforeSend(false, false, props.timChatManager),
|
||||
() => {
|
||||
showMorePanel.value = false;
|
||||
emit('messageSent');
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 输入相关方法
|
||||
const toggleVoiceInput = () => {
|
||||
showVoiceInput.value = !showVoiceInput.value;
|
||||
showMorePanel.value = false;
|
||||
};
|
||||
|
||||
const toggleMorePanel = () => {
|
||||
showMorePanel.value = !showMorePanel.value;
|
||||
showVoiceInput.value = false;
|
||||
};
|
||||
|
||||
// 处理图片选择
|
||||
const showImagePicker = () => {
|
||||
chooseImage(
|
||||
(res) => sendImageMessage(res),
|
||||
(err) => {
|
||||
console.error('选择图片失败:', err);
|
||||
if (!err.errMsg?.includes('permission') && !err.errMsg?.includes('auth') && !err.errMsg?.includes('拒绝') && !err.errMsg?.includes('未授权')) {
|
||||
uni.showToast({
|
||||
title: '选择图片失败,请重试',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const takePhoto = () => {
|
||||
takePhotoUtil(
|
||||
(res) => sendImageMessage(res),
|
||||
(err) => {
|
||||
console.error('拍照失败:', err);
|
||||
if (!err.errMsg?.includes('permission') && !err.errMsg?.includes('auth') && !err.errMsg?.includes('拒绝') && !err.errMsg?.includes('未授权')) {
|
||||
uni.showToast({
|
||||
title: '拍照失败,请重试',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function startDurationTimer() {
|
||||
clearDurationTimer();
|
||||
recordingDuration.value = 0;
|
||||
recordingTimer = setInterval(() => {
|
||||
recordingDuration.value++;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearDurationTimer() {
|
||||
if (recordingTimer) {
|
||||
clearInterval(recordingTimer);
|
||||
recordingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const startRecord = (e) => {
|
||||
isRecording.value = true;
|
||||
recordingText.value = "录音中...";
|
||||
isCancelMode.value = false;
|
||||
discardRecording = false;
|
||||
recordingDuration.value = 0;
|
||||
if (e && e.touches && e.touches[0]) {
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}
|
||||
|
||||
// 确保录音管理器已初始化
|
||||
if (!recorderManager) {
|
||||
initRecorderManager();
|
||||
}
|
||||
|
||||
startDurationTimer();
|
||||
startRecordUtil(recorderManager);
|
||||
};
|
||||
|
||||
const onRecordTouchMove = (e) => {
|
||||
if (!isRecording.value) return;
|
||||
if (e && e.touches && e.touches[0]) {
|
||||
const currentY = e.touches[0].clientY;
|
||||
const deltaY = touchStartY - currentY; // 上滑为正
|
||||
isCancelMode.value = deltaY > CANCEL_DISTANCE;
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecord = () => {
|
||||
if (!isRecording.value) return;
|
||||
// 如果处于取消模式,则按取消处理
|
||||
if (isCancelMode.value) {
|
||||
cancelRecord();
|
||||
return;
|
||||
}
|
||||
isRecording.value = false;
|
||||
recordingText.value = "录音中...";
|
||||
clearDurationTimer();
|
||||
stopRecordUtil(recorderManager);
|
||||
};
|
||||
|
||||
const cancelRecord = () => {
|
||||
if (!isRecording.value) return;
|
||||
isRecording.value = false;
|
||||
isCancelMode.value = false;
|
||||
recordingText.value = "已取消";
|
||||
discardRecording = true; // 标记为丢弃,阻止 onStop 发送
|
||||
clearDurationTimer();
|
||||
stopRecordUtil(recorderManager);
|
||||
};
|
||||
|
||||
// 发送病情描述消息
|
||||
const sendSymptomMessage = async () => {
|
||||
const symptomMessage = createCustomMessage("symptom", {
|
||||
content: "发送了病情描述",
|
||||
symptomContent: "患者主诉:头痛、发热、咳嗽3天。既往史:无特殊。现病史:3天前开始出现头痛,伴有发热,体温最高38.5℃,同时有干咳症状。",
|
||||
hasVisitedHospital: "yes",
|
||||
selectedDiseases: [{ text: "感冒" }, { text: "上呼吸道感染" }],
|
||||
images: ["/static/home/photo.png"],
|
||||
}, props.formatTime);
|
||||
|
||||
await sendCustomMessage(symptomMessage);
|
||||
};
|
||||
|
||||
// 发送处方单消息
|
||||
const sendPrescriptionMessage = async () => {
|
||||
const prescriptionMessage = createCustomMessage("prescription", {
|
||||
content: "医生开了处方",
|
||||
diagnosis: "上呼吸道感染",
|
||||
medicines: [
|
||||
{ name: "阿莫西林胶囊", spec: "0.25g×20粒", count: 2 },
|
||||
{ name: "甲硝唑片", spec: "0.5mg×20片", count: 2 },
|
||||
],
|
||||
}, props.formatTime);
|
||||
|
||||
await sendCustomMessage(prescriptionMessage);
|
||||
};
|
||||
|
||||
// 发送续方申请消息
|
||||
const sendRefillMessage = async () => {
|
||||
const refillMessage = createCustomMessage("refill", {
|
||||
content: "发送了续方申请",
|
||||
patientName: props.patientInfo.name,
|
||||
gender: props.patientInfo.gender,
|
||||
age: props.patientInfo.age,
|
||||
diagnosis: "慢性胃炎,脾胃虚弱",
|
||||
prescriptionType: "中药处方",
|
||||
prescriptionDesc: "共14剂,每日1剂,1剂分2次服用,饭后温服",
|
||||
}, props.formatTime);
|
||||
|
||||
await sendCustomMessage(refillMessage);
|
||||
};
|
||||
|
||||
// 发送问卷调查消息
|
||||
const sendSurveyMessage = async () => {
|
||||
const surveyMessage = createCustomMessage("survey", {
|
||||
content: "医生发送了问卷调查",
|
||||
surveyTitle: "治疗效果评估",
|
||||
surveyDescription: "您好,为了帮助了解您的病情变化,请您如实填写问卷。",
|
||||
surveyMessage: "慢性病患者生活质量评估问卷",
|
||||
estimatedTime: "约3-5分钟",
|
||||
reward: "积分奖励10分",
|
||||
note: "问卷内容涉及您的症状变化、用药情况等,请根据实际情况填写。",
|
||||
}, props.formatTime);
|
||||
|
||||
await sendCustomMessage(surveyMessage);
|
||||
};
|
||||
|
||||
// 更多面板按钮配置
|
||||
const morePanelButtons = [
|
||||
{ text: '照片', icon: '/static/home/photo.png', action: showImagePicker },
|
||||
{ text: '拍摄', icon: '/static/home/video.png', action: takePhoto },
|
||||
// { text: '病情', icon: '/static/home/doctor.png', action: sendSymptomMessage },
|
||||
// { text: '处方', icon: '/static/home/doctor.png', action: sendPrescriptionMessage },
|
||||
// { text: '续方', icon: '/static/home/doctor.png', action: sendRefillMessage },
|
||||
// { text: '问卷', icon: '/static/home/doctor.png', action: sendSurveyMessage }
|
||||
];
|
||||
|
||||
function handleInputFocus() {
|
||||
console.log('handleInputFocus')
|
||||
nextTick().then(() => {
|
||||
emit('scrollToBottom')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化录音管理器
|
||||
initRecorderManager();
|
||||
|
||||
// 监听关闭功能栏事件
|
||||
uni.$on('closeMorePanel', () => {
|
||||
showMorePanel.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除事件监听
|
||||
uni.$off('closeMorePanel');
|
||||
clearDurationTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../chat.scss";
|
||||
</style>
|
||||
43
pages/message/components/consultation-bar.vue
Normal file
43
pages/message/components/consultation-bar.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<view class="consultation-bar-wrapper">
|
||||
<!-- 问诊功能栏:仅问诊结束后显示 -->
|
||||
<view class="consultation-bar">
|
||||
<view class="consultation-info">
|
||||
<view class="consultation-icon">
|
||||
<uni-icons type="chatboxes" size="24" color="#0877F1"></uni-icons>
|
||||
</view>
|
||||
<view class="consultation-details">
|
||||
<text class="consultation-title">图文问诊</text>
|
||||
<!-- <text class="consultation-price">¥{{ doctor.price }}/次</text> -->
|
||||
</view>
|
||||
</view>
|
||||
<button class="consultation-btn" @click="toConsult">
|
||||
问诊医生
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
doctor: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
function toConsult() {
|
||||
if (props.doctor.doctorId) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-home/consultation/select-patient?doctorId=${props.doctor.doctorId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../chat.scss";
|
||||
|
||||
.consultation-bar-wrapper {
|
||||
flex-shrink: 0;
|
||||
padding: 24rpx 30rpx;
|
||||
}
|
||||
</style>
|
||||
417
pages/message/components/description-popup.vue
Normal file
417
pages/message/components/description-popup.vue
Normal file
@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<uni-popup ref="symptomPopup" type="bottom" :mask-click="false">
|
||||
<view class="symptom-popup">
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">请描述您的病情</text>
|
||||
</view>
|
||||
|
||||
<view class="popup-content">
|
||||
<textarea class="symptom-textarea" v-model="symptomDescription" placeholder="请描述您的病情..." :maxlength="200"
|
||||
show-confirm-bar="false"></textarea>
|
||||
|
||||
<view class="section">
|
||||
<view class="checkbox-row">
|
||||
<text class="section-title">是否到线下医院就诊过</text>
|
||||
<uni-data-checkbox class="checkbox-group" mode="tag" v-model="hasVisitedHospital" :localdata="yesNoOptions"
|
||||
:multiple="false"></uni-data-checkbox>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 诊断选择 - 只有选择"是"才显示 -->
|
||||
<view class="section" v-if="hasVisitedHospital === 'yes'">
|
||||
<text class="section-title">诊断*</text>
|
||||
<view class="diagnosis-container">
|
||||
<!-- 已选择的疾病标签 -->
|
||||
<view class="selected-diseases" v-if="selectedDiseases.length > 0">
|
||||
<view v-for="(disease, index) in selectedDiseases" :key="`${index}-common`" class="tag-item tag-item-active"
|
||||
@click="removeDiseaseTag(index)">
|
||||
{{ disease }}
|
||||
</view>
|
||||
<!-- <uni-tag v-for="(disease, index) in selectedDiseases" :key="index" :text="disease" type="primary"
|
||||
size="small" :circle="true" @click="removeDiseaseTag(index)"></uni-tag> -->
|
||||
</view>
|
||||
<!-- 添加疾病按钮 -->
|
||||
<view class="add-disease-btn" @click="goToSelectDisease">
|
||||
<view class="tag-item">+ 添加</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="upload-header">
|
||||
<text class="section-title">上传资料</text>
|
||||
<text class="upload-subtitle">病历、处方、检查检验报告等图片</text>
|
||||
</view>
|
||||
|
||||
<view class="upload-actions">
|
||||
<view class="upload-btn" @click="add()">
|
||||
<uni-icons type="camera" size="24" color="#666"></uni-icons>
|
||||
<text class="upload-text">添加照片</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 已选择的图片预览 -->
|
||||
<view class="image-preview" v-if="images.length > 0">
|
||||
<view v-for="(image, index) in images" :key="image" class="preview-item">
|
||||
<image :src="image" class="preview-image" mode="aspectFill"></image>
|
||||
<view class="remove-btn" @click="remove(index)">
|
||||
<uni-icons type="close" size="16" color="white"></uni-icons>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-footer">
|
||||
<button class="popup-btn cancel-btn" @click="closeSymptomPopup">取消</button>
|
||||
<button class="popup-btn submit-btn" @click="submitSymptomDescription">提交</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { submitOrderDescription } from '@/api/consult-order';
|
||||
import { registerOnceEvent } from '@/hooks/useOnceEvent'
|
||||
import useImageUpload from '@/hooks/useImageUpload'
|
||||
import { set } from '@/utils/cache'
|
||||
import { toast } from '@/utils/widget'
|
||||
|
||||
const emits = defineEmits(['close', 'submit'])
|
||||
// 定义组件的props
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
orderId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
accountId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isIMMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// 定义组件的emit事件
|
||||
|
||||
// 响应式数据
|
||||
const symptomPopup = ref(null)
|
||||
const symptomDescription = ref('')
|
||||
const loading = ref(false)
|
||||
const { images, add, remove, uploadImages } = useImageUpload('order')
|
||||
|
||||
// 是否到线下医院就诊过
|
||||
const hasVisitedHospital = ref('')
|
||||
const yesNoOptions = ref([
|
||||
{ value: 'yes', text: '是' },
|
||||
{ value: 'no', text: '否' }
|
||||
])
|
||||
// 诊断选择
|
||||
const selectedDiseases = ref([])
|
||||
|
||||
|
||||
// 关闭病情描述弹窗
|
||||
const closeSymptomPopup = () => {
|
||||
emits('close')
|
||||
}
|
||||
|
||||
|
||||
// 跳转到疾病选择页面
|
||||
const goToSelectDisease = () => {
|
||||
const eventName = registerOnceEvent(changeDisease)
|
||||
set('diseaseSelected', selectedDiseases.value)
|
||||
// 将当前已选择的疾病数据传递到选择页面
|
||||
uni.navigateTo({
|
||||
url: `/pages-home/consultation/disease?eventName=${eventName}`
|
||||
})
|
||||
}
|
||||
|
||||
// 删除疾病标签
|
||||
const removeDiseaseTag = (index) => {
|
||||
selectedDiseases.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 提交病情描述
|
||||
const submitSymptomDescription = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
if (!symptomDescription.value.trim() && images.value.length === 0) {
|
||||
toast('请输入病情描述或上传图片')
|
||||
loading.value = false;
|
||||
return
|
||||
}
|
||||
if (images.value.length) {
|
||||
const res = await uploadImages()
|
||||
if (!res) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 构造要提交的数据
|
||||
const descriptionData = {
|
||||
description: symptomDescription.value.trim(),
|
||||
hasVisitedHospital: hasVisitedHospital.value === 'yes',
|
||||
diseases: selectedDiseases.value,
|
||||
images: images.value
|
||||
}
|
||||
const res = await submitOrderDescription({
|
||||
accountId: props.accountId,
|
||||
orderId: props.orderId,
|
||||
...descriptionData
|
||||
})
|
||||
if (res && res.success) {
|
||||
toast(res.message);
|
||||
closeSymptomPopup()
|
||||
emits('submit', res.data)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function changeDisease(diseases) {
|
||||
selectedDiseases.value = diseases
|
||||
}
|
||||
|
||||
watch(() => props.visible, (n) => {
|
||||
if (n) {
|
||||
symptomPopup.value && symptomPopup.value.open()
|
||||
} else {
|
||||
symptomPopup.value && symptomPopup.value.close()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 病情描述弹窗样式 */
|
||||
.symptom-popup {
|
||||
background-color: white;
|
||||
border-top-left-radius: 16rpx;
|
||||
border-top-right-radius: 16rpx;
|
||||
max-height: 75vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 999; // 确保弹窗在最上层,避免被 ConsultationBar 遮挡
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
padding: 20rpx 20rpx 0;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: $font-size-text;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
flex: 1;
|
||||
padding: 10rpx 20rpx 20rpx 20rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.symptom-textarea {
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
background-color: #f8f9fa;
|
||||
border: 1rpx solid #e9ecef;
|
||||
border-radius: 16rpx;
|
||||
padding: 12rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: $font-size-text;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.upload-subtitle {
|
||||
font-size: $font-size-tip;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.upload-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.upload-header .section-title {
|
||||
margin-bottom: 0;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.upload-header .upload-subtitle {
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
background-color: #f8f9fa;
|
||||
border: 1rpx solid #e9ecef;
|
||||
border-radius: 16rpx;
|
||||
gap: 8rpx;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-btn:active {
|
||||
background-color: #e9ecef;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: $font-size-tip;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
position: relative;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -8rpx;
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
background-color: #ff4757;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
padding: 20rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: $font-size-text;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f8f9fa;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cancel-btn:active {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background-color: $primary-color;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.popup-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.checkbox-row .uni-data-checklist {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 诊断容器样式 */
|
||||
.diagnosis-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.selected-diseases {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.add-disease-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
border: 1px solid #eee;
|
||||
padding: 8rpx 30rpx;
|
||||
border-radius: 40rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.tag-item-active {
|
||||
background-color: #0074ff;
|
||||
border-color: #0074ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
</style>
|
||||
60
pages/message/components/head-card.vue
Normal file
60
pages/message/components/head-card.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<view>
|
||||
<!-- 等待医生接诊卡片 -->
|
||||
<view class="waiting-section">
|
||||
<view class="waiting-bg"></view>
|
||||
<view class="waiting-card-outer">
|
||||
<view class="hospital-name">{{ order.hospitalName || '医院' }}</view>
|
||||
<view class="waiting-consultation-card">
|
||||
<view class="waiting-card-row">
|
||||
<image class="waiting-illust" src="/static/home/waiting-illust.png" mode="aspectFit"></image>
|
||||
<view class="waiting-title-row">
|
||||
<text class="waiting-title">等待医生接诊.....</text>
|
||||
</view>
|
||||
<view class="doctor-avatar-outer">
|
||||
<!-- chatInfo.avatar || -->
|
||||
<image class="doctor-avatar" :src="avatar" mode="aspectFill">
|
||||
</image>
|
||||
</view>
|
||||
</view>
|
||||
<view class="waiting-desc">为了更好的获得医生帮助,请补充病情描述</view>
|
||||
<view class="waiting-btn-wrap">
|
||||
<button class="waiting-btn" @click="addSymptomDescription">
|
||||
{{ hasFilledDescription ? '已提交病情描述' : '补充病情描述' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['addSymptomDescription']);
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
doctorInfo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const hasFilledDescription = computed(() => props.order && ('description' in props.order));
|
||||
const avatar = computed(() => props.doctorInfo?.avatar || '/static/home/doctor.png')
|
||||
|
||||
function addSymptomDescription() {
|
||||
if (!hasFilledDescription.value) {
|
||||
emit('addSymptomDescription')
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../chat.scss";
|
||||
</style>
|
||||
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<view v-if="data" class="symptom-detail">
|
||||
<view class="symptom-row">
|
||||
<text class="label-text">患者:</text>
|
||||
<text class="content-text">{{ data.patient }}</text>
|
||||
</view>
|
||||
<view class="symptom-row">
|
||||
<text class="label-text">病情描述:</text>
|
||||
<text class="content-text">{{ data.description }}</text>
|
||||
</view>
|
||||
<view v-if="data.diseases" class="symptom-row">
|
||||
<text class="label-text">线下确诊疾病:</text>
|
||||
<text class="content-text">{{ data.diseases }}</text>
|
||||
</view>
|
||||
<view v-if="data.images && data.images.length" class="symptom-row" @click="previewImage()">
|
||||
<text class="label-text">附件:</text>
|
||||
<text class="preview-btn">{{ data.images.length }}张 点击查看</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const data = computed(() => {
|
||||
try {
|
||||
const extension = JSON.parse(props.payload.extension);
|
||||
return {
|
||||
patient: typeof extension.patient === 'string' ? extension.patient : '',
|
||||
description: typeof extension.description === 'string' ? extension.description : '',
|
||||
hasVisitedHospital: typeof extension.hasVisitedHospital === 'boolean' ? extension.hasVisitedHospital : false,
|
||||
diseases: typeof extension.diseases === 'string' ? extension.diseases : '',
|
||||
images: Array.isArray(extension.images) ? extension.images : [],
|
||||
}
|
||||
} catch (e) { }
|
||||
return null
|
||||
})
|
||||
|
||||
|
||||
function previewImage() {
|
||||
console.log('预览图片')
|
||||
uni.previewImage({
|
||||
urls: data.value.images
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
<style scpoed>
|
||||
/* 病情描述样式 */
|
||||
.symptom-detail {
|
||||
min-width: 360rpx;
|
||||
max-width: 600rpx;
|
||||
}
|
||||
|
||||
.symptom-row {
|
||||
padding: 4rpx 0;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: #333;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
display: inline-block;
|
||||
border: 1px solid;
|
||||
color: #0074ff;
|
||||
line-height: 36rpx;
|
||||
font-size: 24rpx;
|
||||
padding: 0 12rpx;
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
</style>
|
||||
140
pages/message/components/message-card/medicines-card.vue
Normal file
140
pages/message/components/message-card/medicines-card.vue
Normal file
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<view v-if="data" class="card-detail">
|
||||
<view class="card-row">
|
||||
<text class="label-text">患者:</text>
|
||||
<text class="content-text">{{ data.patient }}</text>
|
||||
</view>
|
||||
<view class="card-row">
|
||||
<text class="label-text">诊断:</text>
|
||||
<text class="content-text">{{ data.diseases }}</text>
|
||||
</view>
|
||||
<view v-if="data.medicines.length && data.type === 'western'" class="card-row card-row--flex">
|
||||
<view class="row-label label-text">RP:</view>
|
||||
<view class="row-content">
|
||||
<view v-for="(i, idx) in data.medicines" :key="idx">
|
||||
<view class="content-text"> {{ i.info }}</view>
|
||||
<view class="label-text"> {{ i.useInfo }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="data.type === 'chinese'" class="card-row card-row--flex">
|
||||
<view class="row-label label-text">RP: </view>
|
||||
<view class="row-content">
|
||||
<view class="content-text">中药处方</view>
|
||||
<view class="label-text">
|
||||
共{{ data.doseNum }}剂, 每日{{ data.dailyDoseNum }}剂,1剂分{{ data.timesPerDosage }}次服用, {{ data.administrationTime
|
||||
}},
|
||||
{{ data.instructions || '' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="buy-btn" @click="toBuy()">立即购买</view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getPrescriptionDetail } from '@/api/medicine';
|
||||
import { toast } from '@/utils/widget';
|
||||
const props = defineProps({
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const data = computed(() => {
|
||||
try {
|
||||
const extension = JSON.parse(props.payload.extension);
|
||||
return {
|
||||
...extension,
|
||||
id: extension.id,
|
||||
patient: typeof extension.patient === 'string' ? extension.patient : '',
|
||||
diseases: typeof extension.diseases === 'string' ? extension.diseases : '',
|
||||
medicines: Array.isArray(extension.medicines) ? formatWesternMedicine(extension.medicines) : [],
|
||||
type: typeof extension.type === 'string' ? extension.type : '',
|
||||
}
|
||||
} catch (e) { }
|
||||
return null
|
||||
})
|
||||
|
||||
function formatWesternMedicine(medicines) {
|
||||
return medicines.map(med => {
|
||||
const info = `${med.name} ${med.specification || ''} x${med.quantity || ''}${med.unit || ''}`;
|
||||
const useInfo = `${med.administrationTime || ''} ${med.usageName || ''}, ${med.frequencyName}, 每次${med.dosage || ''} ${med.dosageUnit || ''} ${med.medicationCycle ? `用药${med.medicationCycle}`:''} `
|
||||
return { info, useInfo }
|
||||
});
|
||||
}
|
||||
|
||||
async function toBuy() {
|
||||
const res = await getPrescriptionDetail(data.value.id);
|
||||
if (res.data && res.data.status === 'init') {
|
||||
uni.navigateTo({
|
||||
url: `/pages-cart/payment?ids=${data.value.id}`
|
||||
})
|
||||
} else if (res.data && res.data.status == 'abolished') {
|
||||
toast('处方已作废')
|
||||
} else if (res.data) {
|
||||
toast('处方已失效')
|
||||
} else {
|
||||
toast('查询处方信息失败')
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
<style scpoed>
|
||||
/* 病情描述样式 */
|
||||
.card-detail {
|
||||
min-width: 360rpx;
|
||||
max-width: 600rpx;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
padding: 4rpx 0;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.card-row--flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card-row--flex .row-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-row--flex .row-content {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: #333;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
display: inline-block;
|
||||
border: 1px solid;
|
||||
color: #0074ff;
|
||||
line-height: 36rpx;
|
||||
font-size: 24rpx;
|
||||
padding: 0 12rpx;
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
background-color: #0074ff;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 60rpx;
|
||||
font-size: 28rpx;
|
||||
border-radius: 30rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
</style>
|
||||
574
pages/message/components/message-card/message-card.vue
Normal file
574
pages/message/components/message-card/message-card.vue
Normal file
@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<view class="message-card" :class="cardTypeClass">
|
||||
<view class="card-header">
|
||||
<text class="card-title">{{ title }}</text>
|
||||
<view v-if="showDetail" class="detail-btn" @click.stop="handleDetail">
|
||||
<text class="detail-text">详情</text>
|
||||
<uni-icons type="right" size="14" color="#fff"></uni-icons>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-content">
|
||||
<!-- 病情描述类型 -->
|
||||
<fill-description-card v-if="payload.description === 'PATIENT_FILL_CONSULTATION'" :payload="payload" />
|
||||
<medicines-card v-else-if="payload.description === 'DOCTOR_WRITE_PRESCRIPTION'" :payload="payload" />
|
||||
<refill-medicine-card v-else-if="payload.description === 'USER_REFILL_PRESCRIPTION'" :payload="payload" />
|
||||
<reject-refill-medicine-card v-else-if="payload.description === 'REJECT_REFILL_PRESCRIPTION'"
|
||||
:payload="payload" />
|
||||
|
||||
<!-- 续方申请类型 -->
|
||||
<template v-if="messageData.messageType === 'refill'">
|
||||
<view class="refill-detail">
|
||||
<!-- 诊断信息 -->
|
||||
<view class="refill-diagnosis">
|
||||
<text class="label-text">诊断:</text>
|
||||
<text class="content-text">{{ messageData.diagnosis }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 药品信息 -->
|
||||
<view class="refill-medicines">
|
||||
<text class="section-title">药品:</text>
|
||||
|
||||
<!-- 中药处方 -->
|
||||
<view v-if="messageData.prescriptionType === '中药处方'" class="chinese-medicine-refill">
|
||||
<text class="medicine-type-title">{{
|
||||
messageData.prescriptionType
|
||||
}}</text>
|
||||
<text class="medicine-description">{{
|
||||
messageData.prescriptionDesc
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 西药处方 -->
|
||||
<view v-else class="western-medicine-refill">
|
||||
<view v-for="(medicine, index) in messageData.medicines" :key="index" class="medicine-item-refill">
|
||||
<view class="medicine-info-refill">
|
||||
<text class="medicine-name-refill">{{ medicine.name }} {{ medicine.spec }}</text>
|
||||
<text class="medicine-count-refill">{{
|
||||
medicine.count
|
||||
}}</text>
|
||||
</view>
|
||||
<text class="medicine-usage-refill">{{ medicine.usage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 问卷调查类型 -->
|
||||
<template v-if="messageData.messageType === 'survey'">
|
||||
<view class="survey-detail">
|
||||
<!-- 问卷标题和描述 -->
|
||||
<view class="survey-header">
|
||||
<view class="survey-info">
|
||||
<text class="survey-title">{{ messageData.surveyTitle }}</text>
|
||||
<text class="survey-subtitle">{{
|
||||
messageData.surveyDescription
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问卷详情 -->
|
||||
<view class="survey-content">
|
||||
<view class="survey-item">
|
||||
<text class="survey-label">问卷名称:</text>
|
||||
<text class="survey-value">{{ messageData.surveyName }}</text>
|
||||
</view>
|
||||
<view class="survey-item">
|
||||
<text class="survey-label">预计用时:</text>
|
||||
<text class="survey-value">{{ messageData.estimatedTime }}</text>
|
||||
</view>
|
||||
<view class="survey-item" v-if="messageData.reward">
|
||||
<text class="survey-label">完成奖励:</text>
|
||||
<text class="survey-value reward-text">{{
|
||||
messageData.reward
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问卷说明 -->
|
||||
<view class="survey-note" v-if="messageData.note">
|
||||
<text class="note-text">{{ messageData.note }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 立即购买按钮 -->
|
||||
<view class="card-footer" @click="handlePurchase" v-if="messageData.messageType === 'prescription'">
|
||||
<text class="view-all-text">立即购药</text>
|
||||
</view>
|
||||
|
||||
<!-- 续方申请按钮 -->
|
||||
<view class="card-footer" @click="handleBuy" v-if="messageData.messageType === 'refill'">
|
||||
<text class="view-all-text">申请再次购买</text>
|
||||
</view>
|
||||
|
||||
<!-- 问卷调查按钮 -->
|
||||
<view class="card-footer" @click="handleBuy" v-if="messageData.messageType === 'survey'">
|
||||
<text class="view-all-text">去填写</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { sendSystemMessage } from "@/api/corp/im.js";
|
||||
|
||||
import fillDescriptionCard from "./fill-description-card.vue";
|
||||
import medicinesCard from "./medicines-card.vue";
|
||||
import refillMedicineCard from "./refill-medicine-card.vue";
|
||||
import rejectRefillMedicineCard from "./reject-refill-medicine-card.vue";
|
||||
|
||||
const props = defineProps({
|
||||
payload: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
messageData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
flow: {
|
||||
type: String,
|
||||
default: 'out'
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["viewDetail"]);
|
||||
const CardTitle = {
|
||||
PATIENT_FILL_CONSULTATION: "患者病情描述",
|
||||
USER_REFILL_PRESCRIPTION: '续方申请',
|
||||
DOCTOR_WRITE_PRESCRIPTION: "医嘱",
|
||||
REJECT_REFILL_PRESCRIPTION: '续方失败',
|
||||
symptom: "病情描述",
|
||||
prescription: "处方单",
|
||||
refill: "续方申请",
|
||||
survey: "问卷调查",
|
||||
};
|
||||
|
||||
// const showDetailBtn = computed(() => {
|
||||
// return ['DOCTOR_WRITE_PRESCRIPTION'].includes(props.payload.description)
|
||||
// });
|
||||
|
||||
// 计算卡片标题
|
||||
const title = computed(() => {
|
||||
return CardTitle[props.payload.description] || CardTitle[props.messageData.messageType] || "消息卡片";
|
||||
});
|
||||
|
||||
const extension = computed(() => {
|
||||
try {
|
||||
return JSON.parse(props.payload.extension);
|
||||
} catch (e) { }
|
||||
return {}
|
||||
})
|
||||
|
||||
const showDetail = computed(() => {
|
||||
if (props.payload.description === 'DOCTOR_WRITE_PRESCRIPTION' && extension.value.id) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
function handleDetail() {
|
||||
if (props.payload.description === 'DOCTOR_WRITE_PRESCRIPTION' && extension.value.id) {
|
||||
uni.navigateTo({
|
||||
url: `/pages-home/prescription/detail?id=${extension.value.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 是否有附件
|
||||
const hasAttachment = computed(() => {
|
||||
return props.messageData.images && props.messageData.images.length > 0;
|
||||
});
|
||||
|
||||
// 附件数量
|
||||
const number = computed(() => {
|
||||
return `${props.messageData.images.length}张`;
|
||||
});
|
||||
|
||||
// 计算卡片样式类
|
||||
const cardTypeClass = computed(() => {
|
||||
return `card-${props.messageData.messageType}`;
|
||||
});
|
||||
|
||||
// 处理查看详情点击
|
||||
const handleBuy = () => {
|
||||
emit("viewDetail", props.messageData);
|
||||
};
|
||||
|
||||
// 处理立即购买点击
|
||||
const handlePurchase = () => {
|
||||
console.log('购买成功', props.messageData);
|
||||
const groupID = props.messageData.conversationID.replace('GROUP', '');
|
||||
sendSystemMessage(groupID, 'purchased');
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.message-card {
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-symptom,
|
||||
.card-prescription,
|
||||
.card-refill,
|
||||
.card-survey {
|
||||
background-color: $primary-color;
|
||||
}
|
||||
|
||||
|
||||
.card-header {
|
||||
padding: 12rpx 12rpx 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: white;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 4rpx 8rpx;
|
||||
border-radius: 4rpx;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
color: white;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.detail-arrow {
|
||||
color: white;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin: 10rpx;
|
||||
padding: 12rpx 16rpx 16rpx;
|
||||
background-color: white;
|
||||
border-radius: 4rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: #333;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 医院就诊信息样式 */
|
||||
.hospital-visit-info {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.visit-text {
|
||||
color: #666;
|
||||
font-size: 13rpx;
|
||||
}
|
||||
|
||||
/* 处方单样式 */
|
||||
.prescription-detail {
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.prescription-section {
|
||||
width: 420rpx;
|
||||
margin-bottom: 8rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.medicine-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.medicine-item-wrapper {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6rpx;
|
||||
padding: 8rpx 12rpx;
|
||||
}
|
||||
|
||||
.medicine-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.medicine-usage {
|
||||
margin-top: 4rpx;
|
||||
padding-top: 4rpx;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.medicine-name {
|
||||
color: #333;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.medicine-spec {
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.medicine-count {
|
||||
color: #333;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
min-width: 30rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.usage-text {
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 中药处方样式 */
|
||||
.tcm-prescription {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6rpx;
|
||||
padding: 12rpx;
|
||||
}
|
||||
|
||||
.tcm-label {
|
||||
color: #333;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: $primary-color;
|
||||
border-radius: 4rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
text-align: center;
|
||||
margin: 0 -4rpx;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.card-footer:active {
|
||||
background-color: $primary-color;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.view-all-text {
|
||||
color: white;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 续方申请样式 */
|
||||
.refill-detail {
|
||||
margin-bottom: 12rpx;
|
||||
width: 420rpx;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.refill-patient-info {
|
||||
margin-bottom: 12rpx;
|
||||
padding-bottom: 8rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.refill-diagnosis {
|
||||
margin-bottom: 12rpx;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.refill-diagnosis .label-text {
|
||||
min-width: 50rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.refill-medicines {
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.refill-medicines .section-title {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
/* 中药续方样式 */
|
||||
.chinese-medicine-refill {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6rpx;
|
||||
padding: 12rpx;
|
||||
}
|
||||
|
||||
.medicine-type-title {
|
||||
color: #333;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.medicine-description {
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 西药续方样式 */
|
||||
.western-medicine-refill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.medicine-item-refill {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6rpx;
|
||||
padding: 10rpx 12rpx;
|
||||
}
|
||||
|
||||
.medicine-info-refill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.medicine-name-refill {
|
||||
color: #333;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.medicine-count-refill {
|
||||
color: #333;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
min-width: 30rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.medicine-usage-refill {
|
||||
color: #666;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 问卷调查样式 */
|
||||
.survey-detail {
|
||||
margin-bottom: 12rpx;
|
||||
width: 420rpx;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.survey-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16rpx;
|
||||
padding-bottom: 12rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.survey-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.survey-title {
|
||||
color: #333;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 4rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.survey-subtitle {
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.survey-content {
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.survey-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.survey-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.survey-label {
|
||||
color: #666;
|
||||
font-size: 24rpx;
|
||||
min-width: 80rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.survey-value {
|
||||
color: #333;
|
||||
font-size: 24rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reward-text {
|
||||
color: #ff6b35;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.survey-note {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6rpx;
|
||||
padding: 10rpx 12rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
color: #666;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
102
pages/message/components/message-card/refill-medicine-card.vue
Normal file
102
pages/message/components/message-card/refill-medicine-card.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<view v-if="data" class="card-detail">
|
||||
<view class="card-row">
|
||||
<text class="label-text">患者:</text>
|
||||
<text class="content-text">{{ data.patient }}</text>
|
||||
</view>
|
||||
<view class="card-row">
|
||||
<text class="label-text">诊断:</text>
|
||||
<text class="content-text">{{ data.diseases }}</text>
|
||||
</view>
|
||||
<view v-if="data.medicines.length && data.type === 'western'" class="card-row card-row--flex">
|
||||
<view class="row-label label-text">RP:</view>
|
||||
<view class="row-content">
|
||||
<view v-for="i in data.medicines" :key="i._id">
|
||||
<view class="content-text"> {{ i.info }}</view>
|
||||
<view class="label-text"> {{ i.useInfo }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="data.type === 'chinese'" class="card-row card-row--flex">
|
||||
<view class="row-label label-text">RP: </view>
|
||||
<view class="row-content">
|
||||
<view class="content-text">中药处方</view>
|
||||
<view class="label-text">
|
||||
共{{ data.doseNum }}剂, 每日{{ data.dailyDoseNum }}剂,1剂分{{ data.timesPerDosage }}服用, {{ data.administrationTime
|
||||
}},
|
||||
{{ data.instructions || '' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const data = computed(() => {
|
||||
try {
|
||||
const extension = JSON.parse(props.payload.extension);
|
||||
return {
|
||||
...extension,
|
||||
patient: typeof extension.patient === 'string' ? extension.patient : '',
|
||||
diseases: typeof extension.diseases === 'string' ? extension.diseases : '',
|
||||
medicines: Array.isArray(extension.medicines) ? formatWesternMedicine(extension.medicines) : [],
|
||||
type: typeof extension.type === 'string' ? extension.type : '',
|
||||
}
|
||||
} catch (e) { }
|
||||
return null
|
||||
})
|
||||
|
||||
function formatWesternMedicine(medicines) {
|
||||
return medicines.map(med => {
|
||||
const info = `${med.name} ${med.specification || ''} x${med.quantity || ''}${med.unit || ''}`;
|
||||
const useInfo = `${med.administrationTime || ''} ${med.usageName || ''}, ${med.frequencyName}, 每次${med.dosage || ''} ${med.dosageUnit || ''} `
|
||||
return { info, useInfo }
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<style scpoed>
|
||||
/* 病情描述样式 */
|
||||
.card-detail {
|
||||
min-width: 360rpx;
|
||||
max-width: 600rpx;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
padding: 4rpx 0;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.card-row--flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card-row--flex .row-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-row--flex .row-content {
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: #333;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<view v-if="data" class="card-detail">
|
||||
<view class="card-row">
|
||||
<text class="label-text">理由:</text>
|
||||
<text class="content-text">{{ data.reason || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const data = computed(() => {
|
||||
try {
|
||||
const extension = JSON.parse(props.payload.extension);
|
||||
return extension
|
||||
} catch (e) { }
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
<style scpoed>
|
||||
/* 病情描述样式 */
|
||||
.card-detail {
|
||||
min-width: 360rpx;
|
||||
max-width: 600rpx;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
padding: 4rpx 0;
|
||||
line-height: 42rpx;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: #333;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
</style>
|
||||
168
pages/message/components/message-types.vue
Normal file
168
pages/message/components/message-types.vue
Normal file
@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<!-- 文本消息 -->
|
||||
<text v-if="message.type === 'TIMTextElem'" class="message-text">
|
||||
{{ message.payload.text }}
|
||||
</text>
|
||||
|
||||
<!-- 图片消息 -->
|
||||
<image
|
||||
v-else-if="message.type === 'TIMImageElem'"
|
||||
class="message-image"
|
||||
:src="
|
||||
message.payload.imageInfoArray[0].LocalURL ||
|
||||
message.payload.imageInfoArray[0].url
|
||||
"
|
||||
mode="aspectFill"
|
||||
:style="getImageStyle(message.payload.imageInfoArray[0])"
|
||||
@click="
|
||||
$emit(
|
||||
'previewImage',
|
||||
message.payload.imageInfoArray[0].LocalURL ||
|
||||
message.payload.imageInfoArray[0].url
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- 语音消息 -->
|
||||
<view
|
||||
v-else-if="message.type === 'TIMSoundElem'"
|
||||
class="voice-message"
|
||||
:class="{ 'voice-playing': isPlaying }"
|
||||
:style="getVoiceStyle(message.payload.second)"
|
||||
@click="$emit('playVoice', message)"
|
||||
>
|
||||
<view class="voice-content">
|
||||
<view class="voice-icon-wrapper">
|
||||
<uni-icons
|
||||
type="sound"
|
||||
size="20"
|
||||
:color="message.flow === 'out' ? '#fff' : '#333'"
|
||||
:class="{ 'icon-animate': isPlaying }"
|
||||
/>
|
||||
<!-- 播放中的声波动画 -->
|
||||
<view v-if="isPlaying" class="sound-wave">
|
||||
<view
|
||||
class="wave-bar"
|
||||
:style="{ background: message.flow === 'out' ? '#fff' : '#0877f1' }"
|
||||
style="animation-delay: 0s"
|
||||
></view>
|
||||
<view
|
||||
class="wave-bar"
|
||||
:style="{ background: message.flow === 'out' ? '#fff' : '#0877f1' }"
|
||||
style="animation-delay: 0.2s"
|
||||
></view>
|
||||
<view
|
||||
class="wave-bar"
|
||||
:style="{ background: message.flow === 'out' ? '#fff' : '#0877f1' }"
|
||||
style="animation-delay: 0.4s"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="voice-duration">{{ message.payload.second }}"</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义消息卡片 -->
|
||||
<template v-else-if="message.type === 'TIMCustomElem'">
|
||||
<view
|
||||
class="card-avatar-row"
|
||||
@click="() => console.log('点击头像', message)"
|
||||
>
|
||||
<MessageCard
|
||||
:payload="message.payload"
|
||||
:messageData="getParsedCustomMessage(message, formatTime)"
|
||||
:flow="message.flow"
|
||||
@viewDetail="$emit('viewDetail', $event)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { getParsedCustomMessage } from "@/utils/chat-utils.js";
|
||||
import MessageCard from "./message-card/message-card.vue";
|
||||
|
||||
const props = defineProps({
|
||||
message: Object,
|
||||
patientInfo: Object,
|
||||
formatTime: Function,
|
||||
playingVoiceId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(["playVoice", "previewImage", "viewDetail"]);
|
||||
|
||||
// 计算当前语音是否正在播放
|
||||
const isPlaying = computed(() => {
|
||||
return props.playingVoiceId === props.message.ID;
|
||||
});
|
||||
|
||||
// 计算图片样式
|
||||
const getImageStyle = (imageInfo) => {
|
||||
// 如果没有尺寸信息,使用默认样式
|
||||
if (!imageInfo || !imageInfo.width || !imageInfo.height) {
|
||||
return {
|
||||
width: "400rpx",
|
||||
height: "300rpx",
|
||||
};
|
||||
}
|
||||
|
||||
const maxWidth = 400; // 最大宽度400rpx
|
||||
const maxHeight = 400; // 最大高度400rpx
|
||||
const minHeight = 120; // 最小高度120rpx
|
||||
|
||||
let width = imageInfo.width;
|
||||
let height = imageInfo.height;
|
||||
|
||||
// 如果图片尺寸超过限制,按比例缩放
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width = Math.floor(width * ratio);
|
||||
height = Math.floor(height * ratio);
|
||||
}
|
||||
|
||||
// 确保最小高度
|
||||
if (height < minHeight) {
|
||||
height = minHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
width: width + "rpx",
|
||||
height: height + "rpx",
|
||||
};
|
||||
};
|
||||
|
||||
// 计算语音消息宽度
|
||||
const getVoiceStyle = (duration) => {
|
||||
// 语音时长,单位:秒
|
||||
const second = Number(duration) || 1;
|
||||
|
||||
// 基础宽度:100rpx(包含图标和时长文字的最小宽度)
|
||||
const baseWidth = 100;
|
||||
|
||||
// 最小宽度:100rpx,最大宽度:400rpx
|
||||
const minWidth = 100;
|
||||
const maxWidth = 400;
|
||||
|
||||
// 每秒增加的宽度:15rpx
|
||||
// 1秒:100rpx,2秒:115rpx,3秒:130rpx ... 60秒:400rpx
|
||||
const widthPerSecond = 15;
|
||||
|
||||
// 计算宽度
|
||||
let width = baseWidth + (second - 1) * widthPerSecond;
|
||||
|
||||
// 限制在最小和最大宽度之间
|
||||
width = Math.max(minWidth, Math.min(width, maxWidth));
|
||||
|
||||
return {
|
||||
width: width + "rpx",
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../chat.scss";
|
||||
</style>
|
||||
188
pages/message/components/special-message/evaluation.vue
Normal file
188
pages/message/components/special-message/evaluation.vue
Normal file
@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<!-- 医生满意度评价卡片 -->
|
||||
<view class="evaluation-card">
|
||||
<view class="evaluation-content">
|
||||
<view class="evaluation-icon">
|
||||
<uni-icons type="star-filled" size="24" color="#0877F1"></uni-icons>
|
||||
</view>
|
||||
<view class="evaluation-info">
|
||||
<text class="evaluation-title">医生满意度评价</text>
|
||||
<text class="evaluation-subtitle">您对医生的本次服务满意吗?</text>
|
||||
</view>
|
||||
<view class="evaluation-btn">
|
||||
<text class="btn-text" @click="openEvaluationPopup">评价</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 评价弹窗 -->
|
||||
<uni-popup ref="evaluationPopup" type="bottom" :mask-click="false" class="evaluation-popup-wrapper">
|
||||
<view class="evaluation-popup-container">
|
||||
<view class="evaluation-popup">
|
||||
<!-- 顶部指示条 -->
|
||||
<view class="popup-indicator"></view>
|
||||
|
||||
<view class="popup-close" @click="closeEvaluationPopup">
|
||||
<uni-icons type="closeempty" size="24" color="#999"></uni-icons>
|
||||
</view>
|
||||
|
||||
<view v-if="doctorInfo" class="doctor-info-section">
|
||||
<image class="doctor-avatar-large" :src="doctorInfo.avatar" mode="aspectFill">
|
||||
</image>
|
||||
<text class="doctor-name">{{ doctorInfo.name }}</text>
|
||||
<text class="doctor-dept">{{ doctorInfo.title }} | {{ doctorInfo.department }}</text>
|
||||
</view>
|
||||
|
||||
<view class="rating-section">
|
||||
<view class="rate-wrapper">
|
||||
<uni-rate v-model="evaluationRating" :max="5" :size="32" :margin="8" color="#e0e0e0" active-color="#0877F1"
|
||||
:disabled="false"></uni-rate>
|
||||
</view>
|
||||
<text class="rating-text" :class="{ 'no-rating': evaluationRating === 0 }">{{ ratingText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="comment-section">
|
||||
<textarea class="evaluation-textarea" v-model="evaluationComment" placeholder="分享您的就诊经历、治疗方式、治疗效果、医生对您的帮助。"
|
||||
:maxlength="1000" show-confirm-bar="false"></textarea>
|
||||
<view class="char-count">{{ evaluationComment.length }}/500</view>
|
||||
</view>
|
||||
|
||||
<view class="evaluation-footer">
|
||||
<button class="submit-evaluation-btn" @click="submitEvaluation">
|
||||
提交评价
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<!-- 评价完成提示弹窗 -->
|
||||
<uni-popup ref="evaluationSuccessPopup" type="bottom" :mask-click="false" class="evaluation-popup-wrapper">
|
||||
<view class="success-popup">
|
||||
<view class="popup-close" @click="closeEvaluationSuccessPopup">
|
||||
<uni-icons type="closeempty" size="24" color="#999"></uni-icons>
|
||||
</view>
|
||||
<view class="success-header-row">
|
||||
<view class="success-icon">
|
||||
<uni-icons type="checkmarkempty" size="48" color="#4CAF50"></uni-icons>
|
||||
</view>
|
||||
<text class="success-title">评价发布完成</text>
|
||||
</view>
|
||||
<view class="success-eval-box">
|
||||
<view class="success-subtitle">对医生的总评价</view>
|
||||
<view class="success-rating">
|
||||
<uni-rate :value="evaluationRating" :max="5" :size="24" :margin="4" color="#0877F1" active-color="#0877F1"
|
||||
:readonly="true" :touchable="false"></uni-rate>
|
||||
<text class="success-rating-text">{{ ratingText }}</text>
|
||||
</view>
|
||||
<view class="success-comment" v-if="evaluationComment">
|
||||
<text class="comment-text">{{ evaluationComment }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { getRate, submitRate } from '@/api/corp/rate';
|
||||
import { toast } from '@/utils/widget';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
doctorInfo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
extension: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['evaluationSubmitted', 'popupStatusChange']);
|
||||
|
||||
// 评价相关状态
|
||||
const evaluationPopup = ref(null);
|
||||
const evaluationSuccessPopup = ref(null);
|
||||
const evaluationRating = ref(0);
|
||||
const evaluationComment = ref('');
|
||||
const record = ref(null)
|
||||
const loading = ref(false);
|
||||
|
||||
// 计算评分文字
|
||||
const ratingText = computed(() => {
|
||||
const texts = ['请选择评分', '很不满意', '不满意', '一般', '满意', '非常满意'];
|
||||
return texts[evaluationRating.value] || '请选择评分';
|
||||
});
|
||||
|
||||
// 打开评价弹窗
|
||||
const openEvaluationPopup = async () => {
|
||||
evaluationRating.value = 0
|
||||
evaluationComment.value = ''
|
||||
const res = await getRate(props.extension.rateId);
|
||||
if (res && res.success) {
|
||||
record.value = res.data;
|
||||
evaluationRating.value = typeof res.data.rate === 'number' ? res.data.rate : 0;
|
||||
evaluationComment.value = typeof res.data.words === 'string' ? res.data.words : '';
|
||||
if (record.value.status === 'init') {
|
||||
evaluationPopup.value.open();
|
||||
emit('popupStatusChange', true); // 通知父组件弹窗已打开
|
||||
} else {
|
||||
evaluationSuccessPopup.value.open();
|
||||
emit('popupStatusChange', true); // 通知父组件弹窗已打开
|
||||
}
|
||||
} else {
|
||||
toast('获取评价信息失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭评价弹窗
|
||||
const closeEvaluationPopup = () => {
|
||||
evaluationPopup.value.close();
|
||||
emit('popupStatusChange', false); // 通知父组件弹窗已关闭
|
||||
};
|
||||
|
||||
// 关闭评价完成弹窗
|
||||
const closeEvaluationSuccessPopup = () => {
|
||||
evaluationSuccessPopup.value.close();
|
||||
emit('popupStatusChange', false); // 通知父组件弹窗已关闭
|
||||
};
|
||||
|
||||
// 提交评价
|
||||
const submitEvaluation = async () => {
|
||||
if (evaluationRating.value === 0) {
|
||||
uni.showToast({ title: '请先评分', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
const res = await submitRate({
|
||||
id: props.extension.rateId,
|
||||
rate: evaluationRating.value,
|
||||
words: evaluationComment.value
|
||||
});
|
||||
if (res && res.success) {
|
||||
evaluationPopup.value.close();
|
||||
setTimeout(() => {
|
||||
evaluationSuccessPopup.value.open();
|
||||
// 弹窗仍然保持打开状态,不需要额外通知
|
||||
}, 600);
|
||||
} else {
|
||||
// 提交失败,弹窗关闭
|
||||
emit('popupStatusChange', false);
|
||||
}
|
||||
loading.value = false
|
||||
|
||||
// // 触发评价提交事件
|
||||
// emit('evaluationSubmitted', {
|
||||
// rating: evaluationRating.value,
|
||||
// comment: evaluationComment.value
|
||||
// });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../chat.scss";
|
||||
</style>
|
||||
40
pages/message/components/special-message/index.vue
Normal file
40
pages/message/components/special-message/index.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<evaluation-message
|
||||
v-if="payload.description === 'PATIENT_RATE_MESSAGE'"
|
||||
:doctorInfo="doctorInfo"
|
||||
:extension="extension"
|
||||
@popupStatusChange="handlePopupStatusChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import evaluationMessage from './evaluation.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object
|
||||
},
|
||||
doctorInfo:{
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['popupStatusChange']);
|
||||
|
||||
const payload = computed(() => {
|
||||
return props.message && props.message.payload ? props.message.payload : {};
|
||||
})
|
||||
const extension = computed(() => {
|
||||
try {
|
||||
return JSON.parse(payload.value.extension)
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// 处理弹窗状态变化,传递给父组件
|
||||
const handlePopupStatusChange = (isOpen) => {
|
||||
emit('popupStatusChange', isOpen);
|
||||
}
|
||||
</script>
|
||||
64
pages/message/components/system-message.vue
Normal file
64
pages/message/components/system-message.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<view v-if="notifyText" class="notify-bar">
|
||||
<view class="notify-line"></view>
|
||||
<view class="notify-text">{{ notifyText }}</view>
|
||||
<view class="notify-line"></view>
|
||||
</view>
|
||||
<view class="system-message">
|
||||
<view class="system-text">{{ text }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const payload = computed(() => props.message?.payload || {});
|
||||
|
||||
const extension = computed(() => {
|
||||
try {
|
||||
return JSON.parse(payload.value.extension);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const text = computed(() => extension.value.patient || payload.value.data || '')
|
||||
|
||||
const notifyText = computed(() => extension.value.notifyText || '')
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../chat.scss";
|
||||
|
||||
.notify-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30rpx;
|
||||
}
|
||||
|
||||
.notify-line {
|
||||
flex-grow: 1;
|
||||
min-width: 30rpx;
|
||||
height: 1px;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.notify-text {
|
||||
margin: 0 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: red;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 80%;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
30
pages/message/hooks/chat-order-hook.js
Normal file
30
pages/message/hooks/chat-order-hook.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import dayjs from 'dayjs';
|
||||
import { getChatOrder, orderStatus } from "@/utils/order.js";
|
||||
|
||||
export default function useChatOrder(orderId) {
|
||||
const currentOrder = ref(null);// 当前聊天室的最新咨询订单
|
||||
const countdown = ref(''); // 倒计时
|
||||
|
||||
const chatRoomStatus = computed(() => {
|
||||
const order = currentOrder.value || {};
|
||||
const isWaiting = order.orderStatus === orderStatus.PAID;
|
||||
const isPending = order.orderStatus === orderStatus.CONSULTING;
|
||||
return { isWaiting, isPending };
|
||||
})
|
||||
|
||||
async function getCurrentOrder() {
|
||||
if (orderId.value) {
|
||||
currentOrder.value = await getChatOrder(orderId.value)
|
||||
} else {
|
||||
currentOrder.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(orderId, n=>{
|
||||
getCurrentOrder()
|
||||
})
|
||||
|
||||
return { currentOrder, chatRoomStatus, countdown, getCurrentOrder }
|
||||
}
|
||||
146
pages/message/hooks/use-chat-business.js
Normal file
146
pages/message/hooks/use-chat-business.js
Normal file
@ -0,0 +1,146 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import { getChatStatus } from "@/api/corp/im.js";
|
||||
import { getDoctorByDoctorId } from '@/api/doctor/doctor';
|
||||
import { toast } from '@/utils/widget';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import useChatOrder from './chat-order-hook';
|
||||
|
||||
|
||||
/**
|
||||
* 获取聊天室当前进行中业务hook
|
||||
* @param {string聊天群组ID} groupID
|
||||
*/
|
||||
export default function useChatBusiness(groupID) {
|
||||
const groupInfo = ref({});
|
||||
|
||||
const assistant = computed(() => {
|
||||
if (groupInfo.value.assistant?.memberAccount) {
|
||||
return { assistantId: groupInfo.value.assistant.memberAccount, name: groupInfo.value.assistant.name }
|
||||
}
|
||||
return null;
|
||||
})
|
||||
|
||||
const doctor = computed(() => {
|
||||
// 优先返回从API获取的详细医生信息
|
||||
if (doctorInfo.value) {
|
||||
return doctorInfo.value;
|
||||
}
|
||||
// 如果详细信息还未加载,返回基本信息
|
||||
if (groupInfo.value.doctor?.memberAccount) {
|
||||
return {
|
||||
doctorId: groupInfo.value.doctor.memberAccount,
|
||||
name: groupInfo.value.doctor.name,
|
||||
avatar: groupInfo.value.doctor.avatar
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
|
||||
const patient = computed(() => {
|
||||
if (groupInfo.value.patient?.memberAccount) {
|
||||
return { openId: groupInfo.value.patient.memberAccount, name: groupInfo.value.patient.name, patientId: groupInfo.value.patient.patientId }
|
||||
}
|
||||
return null;
|
||||
})
|
||||
const chatMember = computed(() => {
|
||||
const res = {
|
||||
assistant: { name: '医生助理', avatar: '/static/assistant.png' }
|
||||
};
|
||||
if (assistant.value) {
|
||||
res[assistant.value.assistantId] = { name: `医生助理${assistant.value.name}`, avatar: '/static/assistant.png' };
|
||||
}
|
||||
if (doctor.value) {
|
||||
|
||||
res[doctor.value.doctorId] = { name: `医生${doctor.value.name || ''}`, avatar: doctor.value.avatar || '/static/home/doctor.svg' };
|
||||
}
|
||||
if (patient.value) {
|
||||
res[patient.value.openId] = { name: '我' || `患者${patient.value.name}`, avatar: '/static/center/user-avatar.png' };
|
||||
}
|
||||
return res;
|
||||
})
|
||||
|
||||
const chatRoomBusiness = computed(() => groupInfo.value.groupBusiness || {});
|
||||
const isPending = computed(() => groupInfo.value.status === 'active');
|
||||
const isClosed = computed(() => !isPending.value);
|
||||
const doctorInfo = ref(null) // 医生信息
|
||||
|
||||
const orderId = computed(() => {
|
||||
return chatRoomBusiness.value.businessType === 'consultation' ? chatRoomBusiness.value.businessId : ''
|
||||
})
|
||||
|
||||
const { currentOrder, chatRoomStatus, countdown, getCurrentOrder } = useChatOrder(orderId)
|
||||
|
||||
const chatRoomCountDown = ref('') // 会话倒计时
|
||||
const showCountdown = computed(() => {
|
||||
const isPendingOrder = currentOrder.value && chatRoomStatus.value.isPending && orderId.value == currentOrder.value.orderId;
|
||||
const isRefill = chatRoomBusiness.value.businessType === 'refill_prescription'
|
||||
return chatRoomCountDown.value && (isPendingOrder || isRefill)
|
||||
})
|
||||
|
||||
async function getChatBusiness() {
|
||||
if (!groupID.value) return;
|
||||
const res = await getChatStatus(groupID.value);
|
||||
if (res && res.success) {
|
||||
groupInfo.value = res.data;
|
||||
setCountdown()
|
||||
getDoctor()
|
||||
} else {
|
||||
await toast('获取聊天室业务失败')
|
||||
uni.navigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
function setCountdown() {
|
||||
if (isPending.value && groupInfo.value.expireTime && dayjs(groupInfo.value.expireTime).isAfter(dayjs())) {
|
||||
if (getApp().chatRoomCountDownTimer) clearInterval(getApp().chatRoomCountDownTimer);
|
||||
getApp().chatRoomCountDownTimer = setInterval(() => {
|
||||
const now = dayjs();
|
||||
const endTime = dayjs(groupInfo.value.expireTime);
|
||||
const diff = endTime.diff(now, 'second');
|
||||
if (diff <= 0) {
|
||||
clearCountdown();
|
||||
getChatBusiness()
|
||||
return;
|
||||
}
|
||||
// 计算剩余时分秒
|
||||
const hours = Math.floor(diff / 3600);
|
||||
const minutes = Math.floor((diff % 3600) / 60);
|
||||
const seconds = diff % 60;
|
||||
|
||||
const pad = n => n.toString().padStart(2, '0');
|
||||
chatRoomCountDown.value = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`.replace(/^00\:/, '');
|
||||
}, 1000)
|
||||
} else {
|
||||
clearCountdown()
|
||||
}
|
||||
}
|
||||
|
||||
function clearCountdown() {
|
||||
if (getApp().chatRoomCountDownTimer) {
|
||||
clearInterval(getApp().chatRoomCountDownTimer);
|
||||
getApp().chatRoomCountDownTimer = null;
|
||||
}
|
||||
chatRoomCountDown.value = ''
|
||||
}
|
||||
|
||||
async function getDoctor() {
|
||||
const doctorId = groupInfo.value.doctor?.memberAccount;
|
||||
if (doctorId && (!doctorInfo.value || doctorInfo.value.doctorId !== doctorId)) {
|
||||
const res = await getDoctorByDoctorId(doctorId)
|
||||
doctorInfo.value = res && res.data ? res.data : null;
|
||||
}
|
||||
}
|
||||
|
||||
onUnload(() => {
|
||||
clearCountdown()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
getChatBusiness()
|
||||
})
|
||||
|
||||
|
||||
return { chatRoomBusiness, getChatBusiness, currentOrder, chatRoomStatus, countdown, getCurrentOrder, chatRoomCountDown, showCountdown, doctorInfo, isClosed, chatMember }
|
||||
}
|
||||
993
pages/message/index.vue
Normal file
993
pages/message/index.vue
Normal file
@ -0,0 +1,993 @@
|
||||
<template>
|
||||
<view class="chat-page">
|
||||
<head-card
|
||||
v-if="chatRoomStatus.isWaiting"
|
||||
:doctorInfo="doctorInfo"
|
||||
:order="currentOrder"
|
||||
@addSymptomDescription="addSymptomDescription()"
|
||||
/>
|
||||
|
||||
<!-- 倒计时显示 -->
|
||||
<view
|
||||
v-if="chatRoomCountDown && showCountdown"
|
||||
class="consult-countdown-bar"
|
||||
>
|
||||
<text class="countdown-text">问诊剩余时间:{{ chatRoomCountDown }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 聊天消息区域 -->
|
||||
<scroll-view
|
||||
class="chat-content"
|
||||
:class="{ 'chat-content-compressed': waitingForDoctor }"
|
||||
scroll-y="true"
|
||||
enhanced="true"
|
||||
bounces="false"
|
||||
:scroll-into-view="scrollIntoView"
|
||||
@scroll="onScroll"
|
||||
@scrolltoupper="handleScrollToUpper"
|
||||
ref="chatScrollView"
|
||||
>
|
||||
<!-- 加载更多提示 -->
|
||||
<view class="load-more-tip" v-if="messageList.length >= 15">
|
||||
<view class="loading" v-if="isLoadingMore">
|
||||
<text class="loading-icon">⏳</text>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<view class="load-tip" v-else-if="!isCompleted">
|
||||
<text class="tip-text">↑ 上滑加载更多</text>
|
||||
</view>
|
||||
<view class="load-tip" v-else>
|
||||
<text class="completed-text">已加载全部消息</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 聊天消息列表 -->
|
||||
<view class="message-list" @click="closeMorePanel">
|
||||
<view
|
||||
v-for="(message, index) in messageList"
|
||||
:key="message.ID"
|
||||
:id="`msg-${message.ID}`"
|
||||
class="message-item"
|
||||
:class="{
|
||||
'message-right': message.flow === 'out',
|
||||
'message-left': message.flow === 'in',
|
||||
}"
|
||||
>
|
||||
<!-- 时间分割线 -->
|
||||
<view
|
||||
v-if="shouldShowTime(message, index, messageList)"
|
||||
class="time-divider"
|
||||
>
|
||||
<text class="time-text">{{ formatTime(message.lastTime) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="isSystemMessage(message)">
|
||||
<SystemMessage :message="message" />
|
||||
</view>
|
||||
<view v-else-if="isSpecialMessage(message)">
|
||||
<special-message
|
||||
:doctorInfo="doctorInfo"
|
||||
:message="message"
|
||||
@popupStatusChange="handleEvaluationPopupStatusChange"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<view v-else class="message-content">
|
||||
<!-- 头像 -->
|
||||
<image
|
||||
v-if="message.flow === 'in'"
|
||||
class="doctor-msg-avatar"
|
||||
:src="chatMember[message.from]?.avatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<!-- 用户头像(右侧) -->
|
||||
<image
|
||||
v-if="message.flow === 'out'"
|
||||
class="user-msg-avatar"
|
||||
:src="chatMember[message.from]?.avatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<!-- 消息内容区域 -->
|
||||
<view class="message-bubble-container">
|
||||
<!-- 用户名显示 -->
|
||||
<view
|
||||
class="username-label"
|
||||
:class="{
|
||||
left: message.flow === 'in',
|
||||
right: message.flow === 'out',
|
||||
}"
|
||||
>
|
||||
<text class="username-text">{{
|
||||
chatMember[message.from]?.name
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<view class="message-bubble" :class="getBubbleClass(message)">
|
||||
<!-- 消息内容 -->
|
||||
<MessageTypes
|
||||
:message="message"
|
||||
:formatTime="formatTime"
|
||||
:playingVoiceId="playingVoiceId"
|
||||
@playVoice="playVoice"
|
||||
@previewImage="previewImage"
|
||||
@viewDetail="(message) => handleViewDetail(message)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发送状态 -->
|
||||
<view v-if="message.flow === 'out'" class="message-status">
|
||||
<text
|
||||
v-if="message.status === 'failed'"
|
||||
class="status-text failed"
|
||||
>发送失败</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<!-- 评价组件 -->
|
||||
|
||||
<!-- 聊天输入组件 -->
|
||||
<ChatInput
|
||||
v-if="!isClosed && !isEvaluationPopupOpen && !showDescriptionPopup"
|
||||
:timChatManager="timChatManager"
|
||||
:chatRoomBusiness="chatRoomBusiness"
|
||||
:formatTime="formatTime"
|
||||
@scrollToBottom="scrollToBottom"
|
||||
@messageSent="scrollToBottom"
|
||||
/>
|
||||
<!-- 问诊功能栏组件 -->
|
||||
<ConsultationBar
|
||||
v-if="isClosed && doctorInfo && !isEvaluationPopupOpen"
|
||||
:doctor="doctorInfo"
|
||||
/>
|
||||
<!-- 病情描述弹窗 -->
|
||||
<DescriptionPopup
|
||||
:orderId="currentOrder ? currentOrder.orderId : ''"
|
||||
:accountId="account ? account.id : ''"
|
||||
:visible="showDescriptionPopup"
|
||||
:isIMMode="true"
|
||||
@close="showDescriptionPopup = false"
|
||||
@submit="handleSubmitDescription"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted, nextTick, watch, computed } from "vue";
|
||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import { storeToRefs } from "pinia";
|
||||
import useAccountStore from "@/store/account.js";
|
||||
import { globalTimChatManager } from "@/utils/tim-chat.js";
|
||||
import {
|
||||
startIMMonitoring,
|
||||
stopIMMonitoring,
|
||||
} from "@/utils/im-status-manager.js";
|
||||
import { updateLastMessageId } from "@/api/consult-order.js";
|
||||
import {
|
||||
getVoiceUrl,
|
||||
validateVoiceUrl,
|
||||
createAudioContext,
|
||||
showMessage,
|
||||
formatTime,
|
||||
shouldShowTime,
|
||||
previewImage,
|
||||
throttle,
|
||||
clearMessageCache,
|
||||
handleViewDetail,
|
||||
checkIMConnectionStatus,
|
||||
} from "@/utils/chat-utils.js";
|
||||
import useChatBusiness from "./hooks/use-chat-business";
|
||||
import DescriptionPopup from "./components/description-popup.vue";
|
||||
import HeadCard from "./components/head-card.vue";
|
||||
import MessageTypes from "./components/message-types.vue";
|
||||
import ChatInput from "./components/chat-input.vue";
|
||||
import ConsultationBar from "./components/consultation-bar.vue";
|
||||
import SystemMessage from "./components/system-message.vue";
|
||||
import SpecialMessage from "./components/special-message/index.vue";
|
||||
|
||||
const timChatManager = globalTimChatManager;
|
||||
|
||||
// 获取登录状态
|
||||
const { account, openid, isIMInitialized } = storeToRefs(useAccountStore());
|
||||
const { initIMAfterLogin } = useAccountStore();
|
||||
|
||||
const groupId = ref("");
|
||||
const {
|
||||
chatRoomBusiness,
|
||||
getChatBusiness,
|
||||
currentOrder,
|
||||
chatRoomStatus,
|
||||
getCurrentOrder,
|
||||
chatRoomCountDown,
|
||||
showCountdown,
|
||||
doctorInfo,
|
||||
isClosed,
|
||||
chatMember,
|
||||
} = useChatBusiness(groupId);
|
||||
|
||||
// 动态设置导航栏标题
|
||||
const updateNavigationTitle = () => {
|
||||
const doctorName =
|
||||
chatRoomBusiness.value && chatRoomBusiness.value.doctorName
|
||||
? chatRoomBusiness.value.doctorName
|
||||
: "";
|
||||
const title = doctorName ? `${doctorName}医生云工作室` : "问诊咨询";
|
||||
uni.setNavigationBarTitle({
|
||||
title: title,
|
||||
});
|
||||
};
|
||||
|
||||
// 聊天信息
|
||||
const chatInfo = ref({
|
||||
conversationID: "",
|
||||
userID: "",
|
||||
avatar: "/static/home/doctor.png",
|
||||
});
|
||||
|
||||
// 评价弹窗状态
|
||||
const isEvaluationPopupOpen = ref(false);
|
||||
|
||||
// 医生名字
|
||||
const doctorName = ref("");
|
||||
const showDescriptionPopup = ref(false);
|
||||
// 用户头像
|
||||
const userAvatar = ref("/static/center/user-avatar.png");
|
||||
|
||||
// 消息列表相关状态
|
||||
const messageList = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const scrollIntoView = ref("");
|
||||
|
||||
// 分页加载相关状态
|
||||
const isLoadingMore = ref(false);
|
||||
const isCompleted = ref(false);
|
||||
const lastFirstMessageId = ref(""); // 记录加载前的第一条消息ID,用于加载后定位
|
||||
|
||||
// 问诊相关状态
|
||||
const consultationEnded = ref(false);
|
||||
const waitingForDoctor = ref(false); // 是否等待接诊
|
||||
const consultationEndTime = ref(null); // 问诊结束时间戳
|
||||
|
||||
// 判断是否为系统消息
|
||||
function isSystemMessage(message) {
|
||||
const description = message.payload?.description;
|
||||
|
||||
return (
|
||||
message.type === "TIMCustomElem" && description === "SYSTEM_NOTIFICATION"
|
||||
);
|
||||
}
|
||||
|
||||
function isSpecialMessage(message) {
|
||||
const description = message.payload?.description;
|
||||
return (
|
||||
message.type === "TIMCustomElem" &&
|
||||
["PATIENT_RATE_MESSAGE"].includes(description)
|
||||
);
|
||||
}
|
||||
|
||||
// 获取消息气泡样式类
|
||||
function getBubbleClass(message) {
|
||||
// 如果是自定义消息(包含消息卡片),根据flow决定气泡样式
|
||||
if (message.type === "TIMCustomElem") {
|
||||
return message.flow === "out" ? "user-bubble" : "doctor-bubble-blue";
|
||||
}
|
||||
// 其他消息根据flow判断
|
||||
return message.flow === "out" ? "user-bubble" : "doctor-bubble";
|
||||
}
|
||||
|
||||
// 保存问诊状态到本地存储
|
||||
function saveConsultationStatus() {
|
||||
const orderId = uni.getStorageSync("currentOrderId");
|
||||
const doctorId = uni.getStorageSync("currentDoctorId");
|
||||
const patientId = uni.getStorageSync("currentPatientId");
|
||||
|
||||
if (orderId && doctorId && patientId) {
|
||||
const consultationKey = `consultation_${orderId}_${doctorId}_${patientId}`;
|
||||
const status = {
|
||||
waitingForDoctor: waitingForDoctor.value,
|
||||
consultationEndTime: consultationEndTime.value,
|
||||
consultationEnded: consultationEnded.value,
|
||||
orderId,
|
||||
doctorId,
|
||||
patientId,
|
||||
};
|
||||
uni.setStorageSync(consultationKey, status);
|
||||
}
|
||||
}
|
||||
|
||||
// 从本地存储恢复问诊状态
|
||||
function restoreConsultationStatus() {
|
||||
const orderId = uni.getStorageSync("currentOrderId");
|
||||
const doctorId = uni.getStorageSync("currentDoctorId");
|
||||
const patientId = uni.getStorageSync("currentPatientId");
|
||||
|
||||
if (orderId && doctorId && patientId) {
|
||||
const consultationKey = `consultation_${orderId}_${doctorId}_${patientId}`;
|
||||
const savedStatus = uni.getStorageSync(consultationKey);
|
||||
|
||||
if (savedStatus) {
|
||||
const now = Date.now();
|
||||
|
||||
if (
|
||||
savedStatus.consultationEnded ||
|
||||
(savedStatus.consultationEndTime &&
|
||||
now >= savedStatus.consultationEndTime)
|
||||
) {
|
||||
consultationEnded.value = true;
|
||||
} else if (
|
||||
!savedStatus.waitingForDoctor &&
|
||||
savedStatus.consultationEndTime
|
||||
) {
|
||||
consultationEndTime.value = savedStatus.consultationEndTime;
|
||||
}
|
||||
} else {
|
||||
consultationEndTime.value = null;
|
||||
consultationEnded.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理问诊结束
|
||||
async function handleConsultationEnd(message) {
|
||||
// 从聊天业务信息中获取订单ID
|
||||
const orderId = chatRoomBusiness.value.businessId;
|
||||
|
||||
const lastMessageId = message.ID;
|
||||
|
||||
console.log("更新最后一条消息ID:", {
|
||||
orderId,
|
||||
lastMessageId,
|
||||
messageType: message.type,
|
||||
status: message.payload?.data,
|
||||
chatRoomBusiness: chatRoomBusiness.value,
|
||||
currentOrder: currentOrder.value,
|
||||
});
|
||||
|
||||
if (orderId && lastMessageId) {
|
||||
// 调用API更新订单的最后一条消息ID
|
||||
const result = await updateLastMessageId({
|
||||
orderId,
|
||||
lastMessageId,
|
||||
});
|
||||
|
||||
if (result && result.success) {
|
||||
console.log("最后一条消息ID更新成功:", result);
|
||||
} else {
|
||||
console.error("最后一条消息ID更新失败:", result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
onLoad((options) => {
|
||||
groupId.value = options.groupID || "";
|
||||
// 清空消息列表,准备加载新群聊的消息
|
||||
messageList.value = [];
|
||||
// 重置加载状态,确保新聊天室可以加载
|
||||
isLoading.value = false;
|
||||
if (options.conversationID) {
|
||||
chatInfo.value.conversationID = options.conversationID;
|
||||
// 立即设置当前会话ID,防止消息混淆
|
||||
timChatManager.setConversationID(options.conversationID);
|
||||
console.log("设置当前会话ID:", options.conversationID);
|
||||
}
|
||||
if (options.userID) {
|
||||
chatInfo.value.userID = options.userID;
|
||||
}
|
||||
if (options.doctorName) {
|
||||
doctorName.value = decodeURIComponent(options.doctorName);
|
||||
}
|
||||
|
||||
// 保存问诊参数
|
||||
if (options.orderId) uni.setStorageSync("currentOrderId", options.orderId);
|
||||
if (options.doctorId) uni.setStorageSync("currentDoctorId", options.doctorId);
|
||||
if (options.patientId)
|
||||
uni.setStorageSync("currentPatientId", options.patientId);
|
||||
// 保存lastMessageId用于消息定位
|
||||
if (options.lastMessageId) {
|
||||
uni.setStorageSync("targetMessageId", options.lastMessageId);
|
||||
}
|
||||
// 从群聊ID提取医生ID
|
||||
if (!chatInfo.value.userID && options.conversationID) {
|
||||
const groupId = options.conversationID;
|
||||
if (groupId.startsWith("GROUP")) {
|
||||
const actualGroupId = groupId.replace("GROUP", "");
|
||||
const parts = actualGroupId.split("_");
|
||||
if (parts.length >= 1) {
|
||||
chatInfo.value.userID = parts[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
checkLoginAndInitTIM();
|
||||
restoreConsultationStatus();
|
||||
updateNavigationTitle();
|
||||
});
|
||||
|
||||
// 检查登录状态并初始化IM
|
||||
const checkLoginAndInitTIM = async () => {
|
||||
if (!account.value || !openid.value) {
|
||||
uni.showModal({
|
||||
title: "提示",
|
||||
content: "请先登录后再进入聊天",
|
||||
showCancel: false,
|
||||
confirmText: "去登录",
|
||||
success: () => {
|
||||
uni.redirectTo({
|
||||
url: "/pages/login/login",
|
||||
});
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIMInitialized.value) {
|
||||
uni.showLoading({
|
||||
title: "连接中...",
|
||||
});
|
||||
const success = await initIMAfterLogin(openid.value);
|
||||
uni.hideLoading();
|
||||
|
||||
if (!success) {
|
||||
uni.showToast({
|
||||
title: "IM连接失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (!timChatManager.isLoggedIn) {
|
||||
uni.showLoading({
|
||||
title: "重连中...",
|
||||
});
|
||||
const reconnected = await timChatManager.ensureIMConnection();
|
||||
uni.hideLoading();
|
||||
|
||||
if (!reconnected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
initTIMCallbacks();
|
||||
};
|
||||
|
||||
// 初始化IM回调函数(使用全局已初始化的IM管理器)
|
||||
const initTIMCallbacks = async () => {
|
||||
// 设置当前页面的回调函数
|
||||
timChatManager.setCallback("onSDKReady", () => {
|
||||
// SDK准备就绪后,只有在消息列表为空且未在加载中时才加载消息列表
|
||||
if (messageList.value.length === 0 && !isLoading.value) {
|
||||
loadMessageList();
|
||||
}
|
||||
});
|
||||
|
||||
timChatManager.setCallback("onSDKNotReady", () => {});
|
||||
|
||||
timChatManager.setCallback("onMessageReceived", (message) => {
|
||||
// tim-chat.js层已经做了会话过滤,这里再次验证消息归属
|
||||
console.log("页面收到消息:", {
|
||||
messageID: message.ID,
|
||||
conversationID: message.conversationID,
|
||||
currentConversationID: chatInfo.value.conversationID,
|
||||
type: message.type,
|
||||
flow: message.flow,
|
||||
});
|
||||
|
||||
// 二次验证:确保消息属于当前群聊
|
||||
if (message.conversationID !== chatInfo.value.conversationID) {
|
||||
console.log("⚠️ 消息不属于当前群聊,已过滤:", {
|
||||
messageConversationID: message.conversationID,
|
||||
currentConversationID: chatInfo.value.conversationID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSystemMessage(message)) {
|
||||
judgeReloadOrder(message);
|
||||
}
|
||||
|
||||
// 检查消息是否已存在,避免重复添加
|
||||
const existingMessage = messageList.value.find(
|
||||
(msg) => msg.ID === message.ID
|
||||
);
|
||||
if (!existingMessage) {
|
||||
messageList.value.push(message);
|
||||
console.log("✓ 添加消息到列表,当前消息数量:", messageList.value.length);
|
||||
nextTick(() => {
|
||||
// 延迟滚动,确保DOM完全渲染
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
});
|
||||
} else {
|
||||
console.log("消息已存在,跳过添加:", message.ID);
|
||||
}
|
||||
|
||||
// 监听系统消息
|
||||
if (message.type === "TIMCustomElem") {
|
||||
const status = message.payload?.data;
|
||||
|
||||
if (status === "pending") {
|
||||
consultationEnded.value = false;
|
||||
saveConsultationStatus();
|
||||
} else if (status === "active") {
|
||||
consultationEnded.value = false;
|
||||
saveConsultationStatus();
|
||||
} else if (
|
||||
["rejected", "completed", "cancelled", "问诊已结束"].includes(status)
|
||||
) {
|
||||
consultationEnded.value = true;
|
||||
saveConsultationStatus();
|
||||
console.log("问诊结束,准备更新最后一条消息ID:", message);
|
||||
handleConsultationEnd(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
timChatManager.setCallback("onMessageSent", (data) => {
|
||||
const { messageID, status } = data;
|
||||
|
||||
// 更新消息状态
|
||||
const message = messageList.value.find((msg) => msg.ID === messageID);
|
||||
if (message) {
|
||||
message.status = status;
|
||||
console.log("更新消息状态:", messageID, status);
|
||||
}
|
||||
});
|
||||
|
||||
timChatManager.setCallback("onMessageListLoaded", (data) => {
|
||||
console.log("【onMessageListLoaded】收到消息列表回调");
|
||||
uni.hideLoading();
|
||||
|
||||
let messages = [];
|
||||
if (typeof data === "object" && data.messages) {
|
||||
messages = data.messages;
|
||||
} else {
|
||||
messages = data;
|
||||
}
|
||||
isLoading.value = false;
|
||||
|
||||
// 去重处理 + 严格过滤当前群聊的消息
|
||||
const uniqueMessages = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
messages.forEach((message) => {
|
||||
// 严格验证:消息必须属于当前群聊
|
||||
const belongsToCurrentConversation =
|
||||
message.conversationID === chatInfo.value.conversationID;
|
||||
|
||||
if (
|
||||
message.ID &&
|
||||
!seenIds.has(message.ID) &&
|
||||
belongsToCurrentConversation
|
||||
) {
|
||||
seenIds.add(message.ID);
|
||||
uniqueMessages.push(message);
|
||||
} else if (!belongsToCurrentConversation) {
|
||||
console.log("⚠️ 过滤掉不属于当前群聊的消息:", {
|
||||
messageID: message.ID,
|
||||
messageConversationID: message.conversationID,
|
||||
currentConversationID: chatInfo.value.conversationID,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
messageList.value = uniqueMessages;
|
||||
console.log(
|
||||
"消息列表已更新,原始",
|
||||
messages.length,
|
||||
"条,过滤后",
|
||||
uniqueMessages.length,
|
||||
"条消息",
|
||||
data.isRefresh ? "(后台刷新)" : ""
|
||||
);
|
||||
|
||||
// 同步分页状态
|
||||
isCompleted.value = data.isCompleted || false;
|
||||
isLoadingMore.value = false;
|
||||
console.log("【onMessageListLoaded】同步状态完成");
|
||||
console.log(" isCompleted =", isCompleted.value);
|
||||
console.log(" isPullUp =", data.isPullUp);
|
||||
|
||||
nextTick(() => {
|
||||
// 如果是后台刷新,不进行滚动操作,保持用户当前位置
|
||||
if (data.isRefresh) {
|
||||
console.log("后台刷新完成,保持当前滚动位置");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是上拉加载,需要定位到加载前的第一条消息(保持用户阅读位置)
|
||||
if (data.isPullUp && lastFirstMessageId.value) {
|
||||
console.log(
|
||||
" 上拉加载完成,定位到加载前的第一条消息:",
|
||||
lastFirstMessageId.value
|
||||
);
|
||||
setTimeout(() => {
|
||||
scrollIntoView.value = `msg-${lastFirstMessageId.value}`;
|
||||
console.log(" ✅ 已定位到消息:", lastFirstMessageId.value);
|
||||
// 清除记录
|
||||
lastFirstMessageId.value = "";
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有目标消息需要定位
|
||||
const targetMessageId = uni.getStorageSync("targetMessageId");
|
||||
if (targetMessageId && !data.isPullUp) {
|
||||
// 延迟执行滚动,确保DOM完全渲染
|
||||
setTimeout(() => {
|
||||
scrollToMessage(targetMessageId);
|
||||
// 清除存储的目标消息ID
|
||||
uni.removeStorageSync("targetMessageId");
|
||||
}, 300);
|
||||
} else if (!data.isPullUp) {
|
||||
// 延迟执行滚动到底部,增加延迟时间并多次尝试确保滚动成功
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
// 再次尝试确保滚动到底部
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
timChatManager.setCallback("onError", (error) => {
|
||||
console.error("TIM错误:", error);
|
||||
uni.showToast({
|
||||
title: error,
|
||||
icon: "none",
|
||||
});
|
||||
});
|
||||
|
||||
// 检查IM是否已经初始化,如果已初始化则直接加载消息列表
|
||||
// 使用 nextTick 确保会话ID已经设置完成
|
||||
nextTick(() => {
|
||||
if (timChatManager.tim && timChatManager.isLoggedIn) {
|
||||
// 添加短暂延迟,确保前面的状态设置完成
|
||||
setTimeout(() => {
|
||||
loadMessageList();
|
||||
}, 50);
|
||||
} else if (timChatManager.tim) {
|
||||
// 等待登录完成
|
||||
let checkCount = 0;
|
||||
const checkInterval = setInterval(() => {
|
||||
checkCount++;
|
||||
if (timChatManager.isLoggedIn) {
|
||||
clearInterval(checkInterval);
|
||||
loadMessageList();
|
||||
} else if (checkCount > 10) {
|
||||
// 10秒超时
|
||||
clearInterval(checkInterval);
|
||||
showMessage("IM登录超时,请重新进入");
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
// showMessage("IM连接异常,请重新进入");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 加载消息列表
|
||||
const loadMessageList = () => {
|
||||
if (isLoading.value) {
|
||||
console.log("正在加载中,跳过重复加载");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"【loadMessageList】开始加载消息,会话ID:",
|
||||
chatInfo.value.conversationID
|
||||
);
|
||||
isLoading.value = true;
|
||||
|
||||
// 显示加载提示
|
||||
uni.showLoading({
|
||||
title: "加载中...",
|
||||
mask: false,
|
||||
});
|
||||
|
||||
timChatManager.enterConversation(chatInfo.value.conversationID || "test1");
|
||||
|
||||
// 标记会话为已读
|
||||
if (
|
||||
timChatManager.tim &&
|
||||
timChatManager.isLoggedIn &&
|
||||
chatInfo.value.conversationID
|
||||
) {
|
||||
timChatManager.tim
|
||||
.setMessageRead({
|
||||
conversationID: chatInfo.value.conversationID,
|
||||
})
|
||||
.then(() => {
|
||||
console.log("会话已标记为已读:", chatInfo.value.conversationID);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("标记会话已读失败:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 语音播放相关
|
||||
let currentAudioContext = null;
|
||||
const playingVoiceId = ref(null); // 当前正在播放的语音消息ID
|
||||
|
||||
// 播放语音
|
||||
const playVoice = (message) => {
|
||||
// 如果正在播放同一条语音,则停止播放
|
||||
if (playingVoiceId.value === message.ID && currentAudioContext) {
|
||||
currentAudioContext.stop();
|
||||
currentAudioContext.destroy();
|
||||
currentAudioContext = null;
|
||||
playingVoiceId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 停止之前正在播放的语音
|
||||
if (currentAudioContext) {
|
||||
currentAudioContext.stop();
|
||||
currentAudioContext.destroy();
|
||||
currentAudioContext = null;
|
||||
}
|
||||
|
||||
const voiceUrl = getVoiceUrl(message);
|
||||
if (!validateVoiceUrl(voiceUrl)) return;
|
||||
|
||||
// 设置当前播放的语音ID
|
||||
playingVoiceId.value = message.ID;
|
||||
|
||||
currentAudioContext = createAudioContext(voiceUrl);
|
||||
currentAudioContext.onEnded(() => {
|
||||
currentAudioContext = null;
|
||||
playingVoiceId.value = null;
|
||||
});
|
||||
currentAudioContext.onError(() => {
|
||||
currentAudioContext = null;
|
||||
playingVoiceId.value = null;
|
||||
});
|
||||
currentAudioContext.play();
|
||||
};
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
if (messageList.value.length > 0) {
|
||||
const lastMessage = messageList.value[messageList.value.length - 1];
|
||||
console.log(lastMessage);
|
||||
scrollIntoView.value = ``;
|
||||
setTimeout(() => {
|
||||
scrollIntoView.value = `msg-${lastMessage.ID}`;
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
// 滚动到指定消息
|
||||
const scrollToMessage = (messageId, retryCount = 0) => {
|
||||
if (!messageId) {
|
||||
console.warn("scrollToMessage: messageId is empty");
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
// 检查消息是否存在于当前消息列表中
|
||||
const targetMessageIndex = messageList.value.findIndex(
|
||||
(msg) => msg.ID === messageId
|
||||
);
|
||||
if (targetMessageIndex !== -1) {
|
||||
// 计算目标滚动位置:目标消息前5条消息
|
||||
const scrollToIndex = Math.max(0, targetMessageIndex - 5);
|
||||
const targetScrollMessage = messageList.value[scrollToIndex];
|
||||
|
||||
if (targetScrollMessage) {
|
||||
scrollIntoView.value = `msg-${targetScrollMessage.ID}`;
|
||||
|
||||
// 延迟检查是否滚动成功,如果没有成功则重试
|
||||
setTimeout(() => {
|
||||
if (retryCount < 3) {
|
||||
scrollIntoView.value = `msg-${targetScrollMessage.ID}`;
|
||||
retryCount++;
|
||||
setTimeout(() => scrollToMessage(messageId, retryCount), 200);
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
// 如果计算出的位置没有消息,滚动到底部
|
||||
scrollToBottom();
|
||||
}
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭功能栏
|
||||
const closeMorePanel = () => {
|
||||
uni.$emit("closeMorePanel");
|
||||
};
|
||||
|
||||
// 处理评价弹窗状态变化
|
||||
const handleEvaluationPopupStatusChange = (isOpen) => {
|
||||
console.log("评价弹窗状态变化:", isOpen);
|
||||
isEvaluationPopupOpen.value = isOpen;
|
||||
};
|
||||
|
||||
// 滚动事件
|
||||
const onScroll = throttle((e) => {
|
||||
// 滚动处理
|
||||
}, 100);
|
||||
|
||||
// 处理上滑加载更多
|
||||
const handleScrollToUpper = async () => {
|
||||
console.log("【handleScrollToUpper】触发上滑事件,准备加载更多");
|
||||
console.log(
|
||||
" 当前状态: isLoadingMore=",
|
||||
isLoadingMore.value,
|
||||
"isCompleted=",
|
||||
isCompleted.value
|
||||
);
|
||||
console.log(" 消息数量:", messageList.value.length);
|
||||
|
||||
// 如果正在加载或已加载全部,不处理
|
||||
if (isLoadingMore.value || isCompleted.value) {
|
||||
console.log(
|
||||
" ⏭️ 跳过加载:isLoadingMore=",
|
||||
isLoadingMore.value,
|
||||
"isCompleted=",
|
||||
isCompleted.value
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果消息数量少于15条,说明第一页还没加载完,不加载更多
|
||||
if (messageList.value.length < 15) {
|
||||
console.log(" ⏭️ 消息数量不足15条,跳过加载更多");
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录加载前的第一条消息ID,用于加载后定位
|
||||
if (messageList.value.length > 0) {
|
||||
lastFirstMessageId.value = messageList.value[0].ID;
|
||||
console.log(" 📍 记录当前第一条消息ID:", lastFirstMessageId.value);
|
||||
}
|
||||
|
||||
isLoadingMore.value = true;
|
||||
|
||||
try {
|
||||
console.log(" 📡 调用 timChatManager.loadMoreMessages()");
|
||||
const result = await timChatManager.loadMoreMessages();
|
||||
|
||||
console.log(" 📥 加载结果:", result);
|
||||
|
||||
if (result.success) {
|
||||
console.log(` ✅ 加载更多成功,新增 ${result.count} 条消息`);
|
||||
} else {
|
||||
console.log(" ⚠️ 加载失败:", result.message || result.error);
|
||||
if (result.message === "已加载全部消息") {
|
||||
console.log(" ✅ 已加载全部消息");
|
||||
isCompleted.value = true;
|
||||
} else if (result.message !== "正在加载中") {
|
||||
uni.showToast({
|
||||
title: result.message || "加载失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(" ❌ 加载更多失败:", error);
|
||||
uni.showToast({
|
||||
title: "加载失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
console.log(" 🏁 加载完成,isLoadingMore 设置为 false");
|
||||
}
|
||||
};
|
||||
|
||||
// 补充病情描述
|
||||
const addSymptomDescription = () => {
|
||||
console.log("添加病情描述: addSymptomDescription");
|
||||
if (checkIMConnectionStatus(timChatManager)) {
|
||||
console.log("添加病情描述: checkIMConnectionStatus");
|
||||
showDescriptionPopup.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理病情描述提交
|
||||
const handleSubmitDescription = async (descriptionData) => {
|
||||
showDescriptionPopup.value = false;
|
||||
// 更新订单信息
|
||||
currentOrder.value = {
|
||||
...currentOrder.value,
|
||||
...descriptionData,
|
||||
};
|
||||
};
|
||||
|
||||
async function judgeReloadOrder(message) {
|
||||
try {
|
||||
const extension = JSON.parse(message.payload.extension);
|
||||
const orderId = extension.orderId || "";
|
||||
const orderStatus = extension.orderStatus || "";
|
||||
const isSameOrder =
|
||||
orderId && currentOrder.value && orderId === currentOrder.value.orderId;
|
||||
const isSameStatus =
|
||||
orderStatus &&
|
||||
currentOrder.value &&
|
||||
orderStatus === currentOrder.value.orderStatus;
|
||||
const isExpired = extension.notifyType === "CHAT_GROUP_EXPIRE"; // 会话到达结束时间
|
||||
if (
|
||||
isExpired ||
|
||||
orderId ||
|
||||
!isSameOrder ||
|
||||
(isSameOrder && !isSameStatus)
|
||||
) {
|
||||
await getChatBusiness();
|
||||
getCurrentOrder();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 页面显示
|
||||
onShow(() => {
|
||||
if (!account.value || !openid.value) {
|
||||
uni.redirectTo({
|
||||
url: "/pages/login/login",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIMInitialized.value) {
|
||||
checkLoginAndInitTIM();
|
||||
} else if (timChatManager.tim && !timChatManager.isLoggedIn) {
|
||||
timChatManager.ensureIMConnection();
|
||||
}
|
||||
|
||||
// 启动IM状态监控
|
||||
startIMMonitoring(30000);
|
||||
});
|
||||
|
||||
// 页面隐藏
|
||||
onHide(() => {
|
||||
// 停止IM状态监控
|
||||
stopIMMonitoring();
|
||||
});
|
||||
|
||||
// 页面卸载
|
||||
onUnmounted(() => {
|
||||
clearMessageCache();
|
||||
|
||||
timChatManager.setCallback("onSDKReady", null);
|
||||
timChatManager.setCallback("onSDKNotReady", null);
|
||||
timChatManager.setCallback("onMessageReceived", null);
|
||||
timChatManager.setCallback("onMessageListLoaded", null);
|
||||
timChatManager.setCallback("onError", null);
|
||||
});
|
||||
|
||||
// 监听 chatRoomBusiness 变化,更新导航栏标题
|
||||
watch(
|
||||
chatRoomBusiness,
|
||||
() => {
|
||||
updateNavigationTitle();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
showDescriptionPopup,
|
||||
(val) => {
|
||||
console.log("病情描述弹窗状态变化:", val, Date.now());
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./chat.scss";
|
||||
</style>
|
||||
|
||||
402
pages/work/department-select.vue
Normal file
402
pages/work/department-select.vue
Normal file
@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<view class="dept-page">
|
||||
<view class="dept-container bg-white">
|
||||
<view class="sidebar">
|
||||
<view class="sidebar-search">
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
type="text"
|
||||
confirm-type="search"
|
||||
placeholder="搜索一级科室"
|
||||
placeholder-class="search-placeholder"
|
||||
/>
|
||||
<view v-if="keyword" class="clear-btn" @click="keyword = ''"
|
||||
>清空</view
|
||||
>
|
||||
</view>
|
||||
<scroll-view class="sidebar-list" scroll-y>
|
||||
<view
|
||||
v-for="item in filteredLevel1List"
|
||||
:key="item.deptId"
|
||||
:class="[
|
||||
'sidebar-item',
|
||||
item.deptId === selectedParentId ? 'active' : '',
|
||||
]"
|
||||
@click="selectParent(item)"
|
||||
>
|
||||
<text class="sidebar-text">{{
|
||||
item.hlwDeptName || item.deptName
|
||||
}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="content" scroll-y>
|
||||
<view v-if="contentList.length" class="content-list">
|
||||
<view
|
||||
v-for="child in contentList"
|
||||
:key="child.deptId"
|
||||
:class="['dept-item', isSelected(child) ? 'selected' : '']"
|
||||
@click="selectDept(child)"
|
||||
>
|
||||
<view class="dept-name-row">
|
||||
<view class="dept-name">{{ child.deptName }}</view>
|
||||
<text v-if="isSelected(child)" class="check-tag">已选</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-wrapper">
|
||||
<empty-data text="暂无科室" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="footer-tip">
|
||||
如没有符合的内容,请联系客服
|
||||
<text class="link" @click="contactService">点击添加客服</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { storeToRefs } from "pinia";
|
||||
import useGuard from "@/hooks/useGuard.js";
|
||||
import useAccountStore from "@/store/account.js";
|
||||
import EmptyData from "@/components/empty-data.vue";
|
||||
import api from "@/utils/api";
|
||||
import { toast } from "@/utils/widget";
|
||||
|
||||
const { useLoad } = useGuard();
|
||||
|
||||
const deptList = ref([]);
|
||||
const selectedParentId = ref("");
|
||||
const selectedDeptId = ref("");
|
||||
const keyword = ref("");
|
||||
|
||||
const sorter = (a, b) => {
|
||||
const sortA = typeof a.sort === "number" ? a.sort : 0;
|
||||
const sortB = typeof b.sort === "number" ? b.sort : 0;
|
||||
if (sortA !== sortB) return sortA - sortB;
|
||||
const timeA = a.createTime || 0;
|
||||
const timeB = b.createTime || 0;
|
||||
return timeA - timeB;
|
||||
};
|
||||
|
||||
const level1List = computed(() =>
|
||||
deptList.value.filter((i) => i.level === 1).sort(sorter)
|
||||
);
|
||||
|
||||
const filteredLevel1List = computed(() => {
|
||||
const key = keyword.value.trim().toLowerCase();
|
||||
if (!key) return level1List.value;
|
||||
return level1List.value.filter((i) =>
|
||||
i.deptName.toString().toLowerCase().includes(key)
|
||||
);
|
||||
});
|
||||
|
||||
const contentList = computed(() => {
|
||||
const children = deptList.value
|
||||
.filter(
|
||||
(i) =>
|
||||
i.level === 2 && i.parentId && i.parentId === selectedParentId.value
|
||||
)
|
||||
.sort(sorter);
|
||||
return children;
|
||||
});
|
||||
|
||||
function isSelected(item) {
|
||||
const targetId = selectedDeptId.value;
|
||||
return !!targetId && (item.deptId === targetId || item.deptId === targetId);
|
||||
}
|
||||
|
||||
async function fetchDeptList() {
|
||||
uni.showLoading({ title: "加载中..." });
|
||||
try {
|
||||
const res = await api("getDeptList");
|
||||
if (res && res.success && Array.isArray(res.data)) {
|
||||
deptList.value = res.data;
|
||||
hydrateSelectedParent();
|
||||
} else {
|
||||
toast(res?.message || "获取科室失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取科室失败", error);
|
||||
toast("获取科室失败");
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateSelectedParent() {
|
||||
// 优先根据已选科室找到对应父级
|
||||
if (selectedDeptId.value) {
|
||||
const match =
|
||||
deptList.value.find(
|
||||
(i) =>
|
||||
i.deptId === selectedDeptId.value || i.deptId === selectedDeptId.value
|
||||
) || null;
|
||||
if (match?.parentId) {
|
||||
selectedParentId.value = match.parentId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 再次兜底使用入参的 parentId
|
||||
if (selectedParentId.value) return;
|
||||
|
||||
// 最后兜底选第一个一级科室
|
||||
const first = level1List.value[0];
|
||||
if (first) {
|
||||
selectedParentId.value = first.deptId;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
filteredLevel1List,
|
||||
(list) => {
|
||||
if (!list.length) return;
|
||||
const exists = list.some((i) => i.deptId === selectedParentId.value);
|
||||
if (!exists) {
|
||||
selectedParentId.value = list[0].deptId;
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
function selectParent(item) {
|
||||
selectedParentId.value = item.deptId;
|
||||
console.log("selectedParentId", selectedParentId.value);
|
||||
}
|
||||
|
||||
function emitSelection(item) {
|
||||
const eventChannel =
|
||||
typeof getOpenerEventChannel === "function"
|
||||
? getOpenerEventChannel()
|
||||
: null;
|
||||
eventChannel?.emit("deptSelected", {
|
||||
id: item.deptId,
|
||||
deptId: item.deptId || item.deptId,
|
||||
name: item.deptName,
|
||||
level: item.level,
|
||||
parentId: item.parentId,
|
||||
});
|
||||
}
|
||||
|
||||
function selectDept(item) {
|
||||
selectedDeptId.value = item.deptId || item.deptId;
|
||||
emitSelection(item);
|
||||
uni.navigateBack();
|
||||
}
|
||||
|
||||
function contactService() {
|
||||
uni.showToast({
|
||||
title: "请联系客服添加科室",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
|
||||
useLoad(() => {
|
||||
fetchDeptList();
|
||||
});
|
||||
|
||||
onLoad((opts) => {
|
||||
// 支持从外部传入默认选中的一级科室
|
||||
if (opts.parentId) {
|
||||
selectedParentId.value = opts.parentId;
|
||||
}
|
||||
const passedDeptId = opts.deptId || opts.departmentId || opts.id;
|
||||
if (passedDeptId) {
|
||||
selectedDeptId.value = passedDeptId;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dept-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dept-container {
|
||||
display: flex;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #fdfdfd 0%, #f6f8fb 100%);
|
||||
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.06);
|
||||
height: 88vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 230rpx;
|
||||
background: #f8f9fb;
|
||||
border-right: 1px solid #eef0f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
padding: 16rpx 14rpx 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: #a0a8b8;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
font-size: 24rpx;
|
||||
color: #2a6ff2;
|
||||
padding: 6rpx 10rpx;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
padding: 30rpx 18rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #2a6ff2;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 4rpx 0 0 #2a6ff2;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-text {
|
||||
display: inline-block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 40rpx 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #8c94a6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-top-right-radius: 12rpx;
|
||||
border-bottom-right-radius: 12rpx;
|
||||
}
|
||||
|
||||
.content-list {
|
||||
padding: 12rpx 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.dept-item {
|
||||
padding: 26rpx 22rpx 14rpx;
|
||||
border-bottom: 1px solid #f2f4f7;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 14rpx;
|
||||
background: #fff;
|
||||
transition: box-shadow 0.2s ease, transform 0.15s ease;
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 8rpx 24rpx rgba(42, 111, 242, 0.08);
|
||||
border: 1px solid #e1e9ff;
|
||||
transform: translateY(-2rpx);
|
||||
}
|
||||
}
|
||||
|
||||
.dept-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.dept-name {
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dept-sub-list {
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.dept-sub-item {
|
||||
padding: 12rpx 16rpx;
|
||||
background: #f6f8fb;
|
||||
border-radius: 10rpx;
|
||||
font-size: 26rpx;
|
||||
color: #3a3a3a;
|
||||
border: 1px solid #e5e9f2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.selected {
|
||||
border-color: #2a6ff2;
|
||||
background: #eaf1ff;
|
||||
color: #1f5ed6;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-sub-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.check-tag {
|
||||
padding: 6rpx 14rpx;
|
||||
font-size: 22rpx;
|
||||
color: #2a6ff2;
|
||||
background: #eaf1ff;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.check-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #2a6ff2;
|
||||
}
|
||||
|
||||
.empty-wrapper {
|
||||
padding-top: 120rpx;
|
||||
}
|
||||
|
||||
.footer-tip {
|
||||
padding: 24rpx 20rpx 40rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #2a6ff2;
|
||||
}
|
||||
</style>
|
||||
|
||||
288
pages/work/profile.vue
Normal file
288
pages/work/profile.vue
Normal file
@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<view class="profile-page">
|
||||
<!-- 表单区域 -->
|
||||
<view class="form-section bg-white">
|
||||
<!-- 姓名 -->
|
||||
<form-input
|
||||
name="姓名"
|
||||
:required="true"
|
||||
:form="formData"
|
||||
title="name"
|
||||
@change="handleFieldChange"
|
||||
/>
|
||||
<!-- 头像 -->
|
||||
<common-cell name="头像">
|
||||
<view class="form-content__wrapper" @click="chooseAvatar">
|
||||
<view class="flex-main-content flex items-center">
|
||||
<image
|
||||
v-if="formData.avatar"
|
||||
class="avatar-preview"
|
||||
:src="formData.avatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
<uni-icons class="form-arrow" type="arrowright"></uni-icons>
|
||||
</view>
|
||||
</common-cell>
|
||||
<!-- 性别 -->
|
||||
<form-select
|
||||
name="性别"
|
||||
:form="formData"
|
||||
title="gender"
|
||||
:range="genderOptions"
|
||||
@change="handleFieldChange"
|
||||
/>
|
||||
<!-- 手机号(不可修改) -->
|
||||
<common-cell name="手机号 (不可修改)">
|
||||
<view class="form-content__wrapper">
|
||||
<view class="flex-main-content text-dark">{{ formData.mobile }}</view>
|
||||
</view>
|
||||
</common-cell>
|
||||
<!-- 岗位 -->
|
||||
<form-select
|
||||
name="岗位"
|
||||
:form="formData"
|
||||
title="position"
|
||||
:range="positionOptions"
|
||||
@change="handleFieldChange"
|
||||
/>
|
||||
<!-- 职称 -->
|
||||
<form-select
|
||||
name="职称"
|
||||
:form="formData"
|
||||
title="title"
|
||||
:range="titleOptions"
|
||||
@change="handleFieldChange"
|
||||
/>
|
||||
<!-- 科室 -->
|
||||
<common-cell name="科室">
|
||||
<view class="form-content__wrapper" @click="openDepartmentSelect">
|
||||
<view
|
||||
class="flex-main-content text-right"
|
||||
:class="{ 'text-placeholder': !formData.departmentName }"
|
||||
>
|
||||
{{ formData.departmentName || "请选择" }}
|
||||
</view>
|
||||
<uni-icons class="form-arrow" type="arrowright"></uni-icons>
|
||||
</view>
|
||||
</common-cell>
|
||||
|
||||
|
||||
<!-- 个人介绍 -->
|
||||
<form-textarea
|
||||
name="个人介绍"
|
||||
:form="formData"
|
||||
title="intro"
|
||||
:word-limit="500"
|
||||
@change="handleFieldChange"
|
||||
/>
|
||||
</view>
|
||||
<!-- 底部按钮 -->
|
||||
<view class="button-footer">
|
||||
<view class="btn btn-cancel" @click="handleCancel">取消</view>
|
||||
<view class="btn btn-save" @click="handleSave">保存</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import useGuard from "@/hooks/useGuard.js";
|
||||
import { chooseAndUploadImage } from "@/utils/file.js";
|
||||
import useAccountStore from "@/store/account.js";
|
||||
import { storeToRefs } from "pinia";
|
||||
import CommonCell from "@/components/form-template/common-cell.vue";
|
||||
import FormInput from "@/components/form-template/form-cell/form-input.vue";
|
||||
import FormSelect from "@/components/form-template/form-cell/form-select.vue";
|
||||
import FormTextarea from "@/components/form-template/form-cell/form-textarea.vue";
|
||||
import api from "@/utils/api.js";
|
||||
const { account, doctorInfo } = storeToRefs(useAccountStore());
|
||||
const { useLoad } = useGuard();
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
name: "",
|
||||
avatar: "",
|
||||
gender: "",
|
||||
mobile: "",
|
||||
position: "",
|
||||
title: "",
|
||||
department: "",
|
||||
departmentName: "",
|
||||
departmentId: "",
|
||||
intro: "",
|
||||
});
|
||||
|
||||
// 选项数据
|
||||
const genderOptions = ["男", "女"];
|
||||
const positionOptions = ["医生", "护士", "药师", "技师", "其他"];
|
||||
const titleOptions = ["主任医师", "副主任医师", "主治医师", "医师", "其他"];
|
||||
|
||||
// 字段变更处理
|
||||
const handleFieldChange = (e) => {
|
||||
formData.value[e.title] = e.value;
|
||||
};
|
||||
|
||||
// 选择头像
|
||||
const chooseAvatar = async () => {
|
||||
const uploadRes = await chooseAndUploadImage({
|
||||
businessType: "other",
|
||||
accessLevel: "public",
|
||||
loadingTitle: "上传中...",
|
||||
});
|
||||
if (uploadRes?.previewUrl) {
|
||||
formData.value.avatar = uploadRes.previewUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
// 验证必填字段
|
||||
if (!formData.value.name) {
|
||||
uni.showToast({
|
||||
title: "请输入姓名",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.showLoading({
|
||||
title: "保存中...",
|
||||
});
|
||||
};
|
||||
|
||||
useLoad(() => {
|
||||
debugger
|
||||
if (doctorInfo.value) {
|
||||
formData.value = doctorInfo.value;
|
||||
} else {
|
||||
formData.value.mobile = account.value.mobile;
|
||||
}
|
||||
});
|
||||
|
||||
// 创建医生信息
|
||||
const createDoctorInfo = async () => {
|
||||
let params = {
|
||||
anotherName: formData.value.name,
|
||||
avatar: formData.value.avatar,
|
||||
gender: formData.value.gender,
|
||||
mobile: formData.value.mobile,
|
||||
deptIds: [],
|
||||
};
|
||||
const res = await api("addCorpMember", {
|
||||
params,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
uni.showToast({
|
||||
title: "创建成功",
|
||||
icon: "success",
|
||||
});
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: "创建失败",
|
||||
icon: "none",
|
||||
});
|
||||
console.error("创建医生信息失败:", res);
|
||||
}
|
||||
};
|
||||
// 打开科室选择
|
||||
const openDepartmentSelect = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/work/department-select",
|
||||
events: {
|
||||
deptSelected: ({ name, deptId }) => {
|
||||
formData.value.department = name || "";
|
||||
formData.value.departmentName = name || "";
|
||||
formData.value.departmentId = deptId || "";
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.form-content__wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
font-size: 28rpx;
|
||||
|
||||
input {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.text-placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: #e5e5e5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.avatar-icon {
|
||||
font-size: 48rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.button-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
padding: 20rpx 30rpx;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
gap: 20rpx;
|
||||
z-index: 100;
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
border-radius: 8rpx;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #fff;
|
||||
border: 1px solid #007aff;
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,9 +1,213 @@
|
||||
<template>
|
||||
<div>work</div>
|
||||
<view class="work-page">
|
||||
<!-- 顶部用户信息区域 -->
|
||||
<view class="user-header bg-white px-15 py-15">
|
||||
<view class="flex items-center justify-between">
|
||||
<!-- 左侧:用户头像和信息 -->
|
||||
<view class="flex items-center flex-grow">
|
||||
<view class="user-avatar mr-10">
|
||||
<image
|
||||
class="avatar-img"
|
||||
src="/static/default-avatar.png"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
<view class="flex-col">
|
||||
<text class="user-name text-black text-lg font-semibold"
|
||||
>请完善信息</text
|
||||
>
|
||||
<view class="flex items-center mt-5">
|
||||
<view
|
||||
class="status-tag tag-orange mr-10"
|
||||
@click="handleCompleteInfo"
|
||||
>
|
||||
<text class="tag-text text-white">信息待完善</text>
|
||||
</view>
|
||||
<view class="status-tag tag-gray" @click="handleVerify">
|
||||
<text class="tag-text text-dark">未认证</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<view class="flex items-center">
|
||||
<view
|
||||
class="action-btn flex-col items-center mr-15"
|
||||
@click="handleInvitePatient"
|
||||
>
|
||||
<view class="qrcode-icon">
|
||||
<text class="qrcode-text">▦</text>
|
||||
</view>
|
||||
<text class="action-text text-black text-sm mt-5">邀请患者</text>
|
||||
</view>
|
||||
<view class="more-btn" @click="handleMore">
|
||||
<text class="more-icon">⋯</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 待办列表区域 -->
|
||||
<view class="todo-section px-15 mt-15">
|
||||
<view class="section-title mb-15">
|
||||
<text class="text-black text-lg font-semibold">待办列表11</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state">
|
||||
<empty-data text="暂无记录" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import useGuard from "@/hooks/useGuard.js";
|
||||
import EmptyData from "@/components/empty-data.vue";
|
||||
|
||||
const { useLoad } = useGuard();
|
||||
|
||||
// 完善信息
|
||||
const handleCompleteInfo = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/work/profile",
|
||||
});
|
||||
};
|
||||
|
||||
// 认证
|
||||
const handleVerify = () => {
|
||||
uni.showToast({
|
||||
title: "跳转到认证页面",
|
||||
icon: "none",
|
||||
});
|
||||
};
|
||||
|
||||
// 邀请患者
|
||||
const handleInvitePatient = () => {
|
||||
uni.showToast({
|
||||
title: "邀请患者",
|
||||
icon: "none",
|
||||
});
|
||||
};
|
||||
|
||||
// 更多操作
|
||||
const handleMore = () => {
|
||||
uni.showActionSheet({
|
||||
itemList: ["设置", "关于"],
|
||||
success: (res) => {
|
||||
console.log("选择了第" + (res.tapIndex + 1) + "个按钮");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useLoad(() => {
|
||||
console.log("工作台页面加载");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style lang="scss" scoped>
|
||||
.work-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #e5e5e5;
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&.tag-orange {
|
||||
background: #ff6b35;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
&.tag-gray {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.qrcode-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.qrcode-text {
|
||||
font-size: 40rpx;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action-text {
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.more-icon {
|
||||
font-size: 48rpx;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-section {
|
||||
.section-title {
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// .empty-state {
|
||||
// min-height: 600rpx;
|
||||
// padding: 100rpx 0;
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// align-items: center;
|
||||
// justify-content: center;
|
||||
// width: 100%;
|
||||
// }
|
||||
</style>
|
||||
@ -1,8 +1,20 @@
|
||||
export default [
|
||||
{
|
||||
path: 'pages/home/home',
|
||||
meta: { title: '首页', login: true },
|
||||
path: 'pages/message/message',
|
||||
meta: { title: '首页', login: false },
|
||||
style: { navigationStyle: 'custom' }
|
||||
},
|
||||
{
|
||||
path: 'pages/work/work',
|
||||
meta: { title: '工作台', login: false }
|
||||
},
|
||||
{
|
||||
path: 'pages/work/profile',
|
||||
meta: { title: '完善个人信息', login: false }
|
||||
},
|
||||
{
|
||||
path: 'pages/work/department-select',
|
||||
meta: { title: '选择科室', login: false }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import api from '@/utils/api';
|
||||
import { toast } from '@/utils/widget';
|
||||
import { getInitIMPromise, clearInitIMPromise } from "@/utils/tim-chat.js";
|
||||
const env = __VITE_ENV__;
|
||||
|
||||
|
||||
@ -9,11 +10,16 @@ export default defineStore("accountStore", () => {
|
||||
const appid = env.MP_WX_APP_ID;
|
||||
const account = ref(null);
|
||||
const loading = ref(false)
|
||||
// IM 相关
|
||||
const openid = ref("");
|
||||
// 医生信息
|
||||
const doctorInfo = ref(null);
|
||||
|
||||
|
||||
async function login(phoneCode = '') {
|
||||
debugger
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { code } = await uni.login({
|
||||
appid,
|
||||
@ -26,10 +32,23 @@ export default defineStore("accountStore", () => {
|
||||
phoneCode,
|
||||
code,
|
||||
});
|
||||
loading.value = false
|
||||
console.log(res)
|
||||
if (res.success && res.data && res.data.mobile) {
|
||||
loading.value = false;
|
||||
if (res.success && res.data) {
|
||||
if (!res.data.mobile) {
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1];
|
||||
const params = current && current.options
|
||||
? Object.keys(current.options).map(key => `${key}=${current.options[key]}`).join('&')
|
||||
: '';
|
||||
const redirectUrl = current && current.route ? `/${current.route}${params ? `?${params}` : ''}` : '';
|
||||
const target = redirectUrl ? `/pages/login/login?redirect=${encodeURIComponent(redirectUrl)}` : '/pages/login/login';
|
||||
uni.redirectTo({ url: target });
|
||||
return;
|
||||
}
|
||||
account.value = res.data;
|
||||
openid.value = res.data.openid;
|
||||
debugger;
|
||||
await getDoctorInfo(openid.value);
|
||||
return res.data
|
||||
}
|
||||
}
|
||||
@ -40,5 +59,18 @@ export default defineStore("accountStore", () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return { account, login }
|
||||
async function getDoctorInfo(weChatOpenId) {
|
||||
try {
|
||||
const res = await api('getCorpMemberData', {
|
||||
weChatOpenId,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
doctorInfo.value = res.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取医生信息失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return { account, openid, doctorInfo, login }
|
||||
})
|
||||
@ -7,6 +7,10 @@ const urlsConfig = {
|
||||
getTeamData: 'getTeamData',
|
||||
queryWxJoinedTeams: 'queryWxJoinedTeams',
|
||||
wxAppLogin: 'wxAppLogin',
|
||||
getDeptList: 'getRealDeptList',
|
||||
getHospitalList: 'getRealHospital',
|
||||
addCorpMember: 'addCorpMember',
|
||||
getCorpMemberData: 'getCorpMemberData'
|
||||
},
|
||||
|
||||
knowledgeBase: {
|
||||
@ -24,6 +28,7 @@ const urlsConfig = {
|
||||
wecom: {
|
||||
addContactWay: 'addContactWay'
|
||||
}
|
||||
|
||||
}
|
||||
const urls = Object.keys(urlsConfig).reduce((acc, path) => {
|
||||
const config = urlsConfig[path] || {};
|
||||
@ -53,4 +58,5 @@ export default async function api(urlId, data) {
|
||||
type,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
798
utils/chat-utils.js
Normal file
798
utils/chat-utils.js
Normal file
@ -0,0 +1,798 @@
|
||||
/**
|
||||
* 聊天相关工具函数
|
||||
*/
|
||||
|
||||
// 通用消息提示
|
||||
export const showMessage = (title, icon = 'none') => {
|
||||
uni.showToast({
|
||||
title,
|
||||
icon,
|
||||
});
|
||||
};
|
||||
|
||||
// 检查问诊状态
|
||||
export const checkConsultationStatus = (waitingForDoctor, consultationEnded) => {
|
||||
if (waitingForDoctor) {
|
||||
showMessage("等待医生接诊中,无法发送消息");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (consultationEnded) {
|
||||
showMessage("问诊已结束,无法发送消息");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 检查IM连接状态
|
||||
export const checkIMConnection = (timChatManager) => {
|
||||
if (!timChatManager.tim || !timChatManager.isLoggedIn) {
|
||||
// showMessage("IM连接异常,请重新进入");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 发送消息前的通用验证
|
||||
export const validateBeforeSend = (waitingForDoctor, consultationEnded, timChatManager) => {
|
||||
if (!checkConsultationStatus(waitingForDoctor, consultationEnded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!checkIMConnection(timChatManager)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 获取语音文件URL
|
||||
export const getVoiceUrl = (message) => {
|
||||
let voiceUrl = '';
|
||||
if (message.payload && message.payload.url) {
|
||||
voiceUrl = message.payload.url;
|
||||
} else if (message.payload && message.payload.file) {
|
||||
voiceUrl = message.payload.file;
|
||||
} else if (message.payload && message.payload.tempFilePath) {
|
||||
voiceUrl = message.payload.tempFilePath;
|
||||
} else if (message.payload && message.payload.filePath) {
|
||||
voiceUrl = message.payload.filePath;
|
||||
}
|
||||
return voiceUrl;
|
||||
};
|
||||
|
||||
// 验证语音URL格式
|
||||
export const validateVoiceUrl = (voiceUrl) => {
|
||||
if (!voiceUrl) {
|
||||
console.error('语音文件URL不存在');
|
||||
showMessage('语音文件不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!voiceUrl.startsWith('http') && !voiceUrl.startsWith('wxfile://') && !voiceUrl.startsWith('/')) {
|
||||
console.error('语音文件URL格式不正确:', voiceUrl);
|
||||
showMessage('语音文件格式错误');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 创建音频上下文
|
||||
export const createAudioContext = (voiceUrl) => {
|
||||
const audioContext = uni.createInnerAudioContext();
|
||||
audioContext.src = voiceUrl;
|
||||
|
||||
audioContext.onPlay(() => {
|
||||
console.log('语音开始播放');
|
||||
});
|
||||
|
||||
audioContext.onEnded(() => {
|
||||
console.log('语音播放结束');
|
||||
});
|
||||
|
||||
audioContext.onError((err) => {
|
||||
console.error('语音播放失败:', err);
|
||||
console.error('错误详情:', {
|
||||
errMsg: err.errMsg,
|
||||
errno: err.errno,
|
||||
src: voiceUrl
|
||||
});
|
||||
showMessage('语音播放失败');
|
||||
});
|
||||
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
// ==================== 时间相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 验证时间戳格式
|
||||
* @param {number|string} timestamp - 时间戳
|
||||
* @returns {boolean} 是否为有效时间戳
|
||||
*/
|
||||
export const validateTimestamp = (timestamp) => {
|
||||
if (!timestamp) return false;
|
||||
|
||||
const num = Number(timestamp);
|
||||
if (isNaN(num)) return false;
|
||||
|
||||
// 检查是否为有效的时间戳范围(1970年到2100年)
|
||||
const minTimestamp = 0;
|
||||
const maxTimestamp = 4102444800000; // 2100年1月1日
|
||||
|
||||
return num >= minTimestamp && num <= maxTimestamp;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化时间 - 今天/昨天显示文字,其他显示日期 + 空格 + 24小时制时间
|
||||
* @param {number|string} timestamp - 时间戳
|
||||
* @returns {string} 格式化后的时间字符串
|
||||
*/
|
||||
export const formatTime = (timestamp) => {
|
||||
// 验证时间戳
|
||||
if (!validateTimestamp(timestamp)) {
|
||||
return "未知时间";
|
||||
}
|
||||
|
||||
// 确保时间戳是毫秒级
|
||||
let timeInMs = timestamp;
|
||||
if (timestamp < 1000000000000) {
|
||||
// 如果时间戳小于这个值,可能是秒级时间戳
|
||||
timeInMs = timestamp * 1000;
|
||||
}
|
||||
|
||||
const date = new Date(timeInMs);
|
||||
const now = new Date();
|
||||
|
||||
// 验证日期是否有效
|
||||
if (isNaN(date.getTime())) {
|
||||
return "未知时间";
|
||||
}
|
||||
|
||||
// 格式化时间:HH:MM (24小时制)
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const timeStr = `${hours}:${minutes}`;
|
||||
|
||||
// 检查是否是今天
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return `${timeStr}`;
|
||||
}
|
||||
|
||||
// 检查是否是昨天
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return `昨天 ${timeStr}`;
|
||||
}
|
||||
|
||||
// 其他日期显示完整日期
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const dateStr = `${month}/${day}`;
|
||||
|
||||
return `${dateStr} ${timeStr}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算时间差
|
||||
* @param {number|string} startTime - 开始时间戳
|
||||
* @param {number|string} endTime - 结束时间戳
|
||||
* @returns {object} 包含天、小时、分钟、秒的时间差对象
|
||||
*/
|
||||
export const calculateTimeDiff = (startTime, endTime) => {
|
||||
if (!validateTimestamp(startTime) || !validateTimestamp(endTime)) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
}
|
||||
|
||||
let startMs = startTime;
|
||||
let endMs = endTime;
|
||||
|
||||
if (startTime < 1000000000000) startMs = startTime * 1000;
|
||||
if (endTime < 1000000000000) endMs = endTime * 1000;
|
||||
|
||||
const diffMs = Math.abs(endMs - startMs);
|
||||
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||
|
||||
return { days, hours, minutes, seconds };
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化倒计时
|
||||
* @param {number|string} endTime - 结束时间戳
|
||||
* @param {number|string} currentTime - 当前时间戳(可选,默认使用当前时间)
|
||||
* @returns {string} 格式化后的倒计时字符串
|
||||
*/
|
||||
export const formatCountdown = (endTime, currentTime = Date.now()) => {
|
||||
const diff = calculateTimeDiff(currentTime, endTime);
|
||||
|
||||
if (diff.days > 0) {
|
||||
return `${diff.days}天${diff.hours}时${diff.minutes}分`;
|
||||
} else if (diff.hours > 0) {
|
||||
return `${diff.hours}时${diff.minutes}分${diff.seconds}秒`;
|
||||
} else if (diff.minutes > 0) {
|
||||
return `${diff.minutes}分${diff.seconds}秒`;
|
||||
} else {
|
||||
return `${diff.seconds}秒`;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 媒体选择相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 检查并请求相册权限
|
||||
* @returns {Promise<boolean>} 是否有权限
|
||||
*/
|
||||
const checkAlbumPermission = () => {
|
||||
return new Promise((resolve) => {
|
||||
uni.getSetting({
|
||||
success: (res) => {
|
||||
const authStatus = res.authSetting['scope.album'];
|
||||
|
||||
if (authStatus === undefined) {
|
||||
// 未授权过,会自动弹出授权窗口
|
||||
resolve(true);
|
||||
} else if (authStatus === false) {
|
||||
// 已拒绝授权,需要引导用户手动开启
|
||||
uni.showModal({
|
||||
title: '需要相册权限',
|
||||
content: '请在设置中开启相册权限,以便选择图片',
|
||||
confirmText: '去设置',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
uni.openSetting({
|
||||
success: (settingRes) => {
|
||||
if (settingRes.authSetting['scope.album']) {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 已授权
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 获取设置失败,尝试直接调用
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择媒体文件
|
||||
* @param {object} options - 选择选项
|
||||
* @param {function} onSuccess - 成功回调
|
||||
* @param {function} onFail - 失败回调
|
||||
*/
|
||||
export const chooseMedia = async (options, onSuccess, onFail) => {
|
||||
// 如果需要从相册选择,先检查权限
|
||||
const sourceType = options.sourceType || ['album', 'camera'];
|
||||
if (sourceType.includes('album')) {
|
||||
const hasPermission = await checkAlbumPermission();
|
||||
if (!hasPermission) {
|
||||
console.log('用户未授予相册权限');
|
||||
if (onFail) {
|
||||
onFail({ errMsg: '未授权相册权限' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uni.chooseMedia({
|
||||
count: options.count || 1,
|
||||
mediaType: options.mediaType || ['image'],
|
||||
sizeType: options.sizeType || ['original', 'compressed'],
|
||||
sourceType: sourceType,
|
||||
success: function (res) {
|
||||
console.log('选择媒体成功:', res);
|
||||
if (onSuccess) onSuccess(res);
|
||||
},
|
||||
fail: function (err) {
|
||||
// 用户取消选择
|
||||
if (err.errMsg.includes('cancel')) {
|
||||
console.log('用户取消选择');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限相关错误
|
||||
if (err.errMsg.includes('permission') || err.errMsg.includes('auth') || err.errMsg.includes('拒绝')) {
|
||||
console.error('相册权限被拒绝:', err);
|
||||
uni.showModal({
|
||||
title: '需要相册权限',
|
||||
content: '请在设置中开启相册权限后重试',
|
||||
confirmText: '去设置',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
uni.openSetting();
|
||||
}
|
||||
}
|
||||
});
|
||||
if (onFail) {
|
||||
onFail(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
console.error('选择媒体失败:', err);
|
||||
if (onFail) {
|
||||
onFail(err);
|
||||
} else {
|
||||
showMessage('选择图片失败,请重试');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择图片
|
||||
* @param {function} onSuccess - 成功回调
|
||||
* @param {function} onFail - 失败回调
|
||||
*/
|
||||
export const chooseImage = (onSuccess, onFail) => {
|
||||
chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera']
|
||||
}, onSuccess, onFail);
|
||||
};
|
||||
|
||||
/**
|
||||
* 拍照
|
||||
* @param {function} onSuccess - 成功回调
|
||||
* @param {function} onFail - 失败回调
|
||||
*/
|
||||
export const takePhoto = (onSuccess, onFail) => {
|
||||
chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['camera']
|
||||
}, onSuccess, onFail);
|
||||
};
|
||||
|
||||
// ==================== 录音相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 初始化录音管理器
|
||||
* @param {object} options - 录音选项
|
||||
* @param {function} onStop - 录音结束回调
|
||||
* @param {function} onError - 录音错误回调
|
||||
* @returns {object} 录音管理器实例
|
||||
*/
|
||||
export const initRecorderManager = (options = {}, onStop, onError) => {
|
||||
const recorderManager = wx.getRecorderManager();
|
||||
|
||||
// 监听录音结束事件
|
||||
recorderManager.onStop((res) => {
|
||||
console.log('录音成功,结果:', res);
|
||||
if (onStop) onStop(res);
|
||||
});
|
||||
|
||||
// 监听录音错误事件
|
||||
recorderManager.onError((err) => {
|
||||
console.error('录音失败:', err);
|
||||
if (onError) {
|
||||
onError(err);
|
||||
} else {
|
||||
showMessage("录音失败");
|
||||
}
|
||||
});
|
||||
|
||||
return recorderManager;
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始录音
|
||||
* @param {object} recorderManager - 录音管理器
|
||||
* @param {object} options - 录音参数
|
||||
*/
|
||||
export const startRecord = (recorderManager, options = {}) => {
|
||||
if (!recorderManager) {
|
||||
console.error('录音管理器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const recordOptions = {
|
||||
duration: 60000, // 录音的时长,单位 ms,最大值 600000(10 分钟)
|
||||
sampleRate: 44100, // 采样率
|
||||
numberOfChannels: 1, // 录音通道数
|
||||
encodeBitRate: 192000, // 编码码率
|
||||
format: 'aac', // 音频格式
|
||||
...options
|
||||
};
|
||||
|
||||
recorderManager.start(recordOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止录音
|
||||
* @param {object} recorderManager - 录音管理器
|
||||
*/
|
||||
export const stopRecord = (recorderManager) => {
|
||||
if (!recorderManager) {
|
||||
console.error('录音管理器未初始化');
|
||||
return;
|
||||
}
|
||||
recorderManager.stop();
|
||||
};
|
||||
|
||||
// ==================== 消息发送相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 创建自定义消息
|
||||
* @param {string} messageType - 消息类型
|
||||
* @param {object} data - 消息数据
|
||||
* @param {function} formatTime - 时间格式化函数
|
||||
* @returns {object} 自定义消息对象
|
||||
*/
|
||||
export const createCustomMessage = (messageType, data, formatTime) => {
|
||||
return {
|
||||
messageType,
|
||||
time: formatTime(Date.now()),
|
||||
...data
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送自定义消息的通用方法
|
||||
* @param {object} messageData - 消息数据
|
||||
* @param {object} timChatManager - IM管理器
|
||||
* @param {function} validateBeforeSend - 发送前验证函数
|
||||
* @param {function} onSuccess - 成功回调
|
||||
*/
|
||||
export const sendCustomMessage = async (messageData, timChatManager, validateBeforeSend, onSuccess) => {
|
||||
if (!validateBeforeSend()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await timChatManager.sendCustomMessage(messageData);
|
||||
|
||||
if (result && result.success) {
|
||||
if (onSuccess) onSuccess();
|
||||
} else {
|
||||
console.error('发送自定义消息失败:', result?.error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送消息的通用方法
|
||||
* @param {string} messageType - 消息类型
|
||||
* @param {any} data - 消息数据
|
||||
* @param {object} timChatManager - IM管理器
|
||||
* @param {function} validateBeforeSend - 发送前验证函数
|
||||
* @param {function} onSuccess - 成功回调
|
||||
*/
|
||||
export const sendMessage = async (messageType, data, timChatManager, validateBeforeSend, onSuccess, cloudCustomData) => {
|
||||
if (!validateBeforeSend()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
switch (messageType) {
|
||||
case 'text':
|
||||
result = await timChatManager.sendTextMessage(data, cloudCustomData);
|
||||
break;
|
||||
case 'image':
|
||||
result = await timChatManager.sendImageMessage(data, cloudCustomData);
|
||||
break;
|
||||
case 'voice':
|
||||
result = await timChatManager.sendVoiceMessage(data.file, data.duration,cloudCustomData);
|
||||
break;
|
||||
default:
|
||||
console.error('未知的消息类型:', messageType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
if (onSuccess) onSuccess();
|
||||
} else {
|
||||
console.error('发送消息失败:', result?.error);
|
||||
showMessage('发送失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 状态检查相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 检查IM连接状态
|
||||
* @param {object} timChatManager - IM管理器
|
||||
* @param {function} onError - 错误回调
|
||||
* @returns {boolean} 连接状态
|
||||
*/
|
||||
export const checkIMConnectionStatus = (timChatManager, onError) => {
|
||||
if (!timChatManager.tim || !timChatManager.isLoggedIn) {
|
||||
const errorMsg = "IM连接异常,请重新进入";
|
||||
if (onError) {
|
||||
onError(errorMsg);
|
||||
} else {
|
||||
showMessage(errorMsg);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否显示时间分割线
|
||||
* @param {object} message - 当前消息
|
||||
* @param {number} index - 消息索引
|
||||
* @param {Array} messageList - 消息列表
|
||||
* @returns {boolean} 是否显示时间分割线
|
||||
*/
|
||||
export const shouldShowTime = (message, index, messageList) => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const prevMessage = messageList[index - 1];
|
||||
|
||||
// 使用工具函数验证时间戳
|
||||
if (!validateTimestamp(message.lastTime) || !validateTimestamp(prevMessage.lastTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeDiff = message.lastTime - prevMessage.lastTime;
|
||||
|
||||
return timeDiff > 5 * 60 * 1000; // 5分钟显示一次时间
|
||||
};
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
* @param {string} url - 图片URL
|
||||
*/
|
||||
export const previewImage = (url) => {
|
||||
uni.previewImage({
|
||||
urls: [url],
|
||||
current: url,
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 录音相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 检查录音时长并处理
|
||||
* @param {object} res - 录音结果
|
||||
* @param {Function} onTimeTooShort - 时间太短的回调
|
||||
* @returns {boolean} 录音时长是否有效
|
||||
*/
|
||||
export const checkRecordingDuration = (res, onTimeTooShort = null) => {
|
||||
const duration = Math.floor(res.duration / 1000);
|
||||
if (duration < 1) {
|
||||
console.log('录音时间太短,取消发送');
|
||||
if (onTimeTooShort) {
|
||||
onTimeTooShort();
|
||||
} else {
|
||||
showMessage('说话时间太短');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// ==================== 防抖和节流工具 ====================
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func - 要防抖的函数
|
||||
* @param {number} wait - 等待时间(毫秒)
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
export const debounce = (func, wait = 300) => {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func - 要节流的函数
|
||||
* @param {number} limit - 限制时间(毫秒)
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
export const throttle = (func, limit = 300) => {
|
||||
let inThrottle;
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 自定义消息解析相关工具方法 ====================
|
||||
|
||||
// 自定义消息解析缓存
|
||||
const customMessageCache = new Map();
|
||||
|
||||
/**
|
||||
* 解析自定义消息(带缓存)
|
||||
* @param {object} message - 消息对象
|
||||
* @param {function} formatTime - 时间格式化函数
|
||||
* @returns {object} 解析后的消息对象
|
||||
*/
|
||||
export const parseCustomMessage = (message, formatTime) => {
|
||||
// 使用消息ID作为缓存键
|
||||
const cacheKey = message.ID;
|
||||
|
||||
// 检查缓存
|
||||
if (customMessageCache.has(cacheKey)) {
|
||||
return customMessageCache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const customData = JSON.parse(message.payload.data);
|
||||
const parsedMessage = {
|
||||
messageType: customData.messageType,
|
||||
content: customData.content,
|
||||
symptomContent: customData.symptomContent,
|
||||
hasVisitedHospital: customData.hasVisitedHospital,
|
||||
selectedDiseases: customData.selectedDiseases,
|
||||
images: customData.images,
|
||||
medicines: customData.medicines,
|
||||
diagnosis: customData.diagnosis,
|
||||
prescriptionType: customData.prescriptionType,
|
||||
prescriptionDesc: customData.prescriptionDesc,
|
||||
tcmPrescription: customData.tcmPrescription, // 新增中药处方字段
|
||||
patientName: customData.patientName,
|
||||
gender: customData.gender,
|
||||
age: customData.age,
|
||||
surveyTitle: customData.surveyTitle,
|
||||
surveyDescription: customData.surveyDescription,
|
||||
surveyName: customData.surveyName,
|
||||
estimatedTime: customData.estimatedTime,
|
||||
reward: customData.reward,
|
||||
note: customData.note,
|
||||
orderId: customData.orderId, // 新增订单ID字段
|
||||
timestamp: customData.timestamp, // 新增时间戳字段
|
||||
conversationID: message.conversationID, // 保留conversationID
|
||||
time: formatTime(message.lastTime),
|
||||
};
|
||||
|
||||
// 缓存解析结果
|
||||
customMessageCache.set(cacheKey, parsedMessage);
|
||||
return parsedMessage;
|
||||
} catch (error) {
|
||||
const fallbackMessage = {
|
||||
messageType: "unknown",
|
||||
content: "未知消息类型",
|
||||
};
|
||||
|
||||
// 缓存错误结果,避免重复解析
|
||||
customMessageCache.set(cacheKey, fallbackMessage);
|
||||
return fallbackMessage;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理消息缓存
|
||||
*/
|
||||
export const clearMessageCache = () => {
|
||||
customMessageCache.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取解析后的自定义消息(带缓存)
|
||||
* @param {object} message - 消息对象
|
||||
* @param {function} formatTime - 时间格式化函数
|
||||
* @returns {object} 解析后的消息对象
|
||||
*/
|
||||
export const getParsedCustomMessage = (message, formatTime) => {
|
||||
return parseCustomMessage(message, formatTime);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理查看详情
|
||||
* @param {object} message - 解析后的消息对象
|
||||
* @param {object} patientInfo - 患者信息
|
||||
*/
|
||||
export const handleViewDetail = (message, patientInfo) => {
|
||||
if (message.messageType === "symptom") {
|
||||
uni.showModal({
|
||||
title: "完整病情描述",
|
||||
content: message.symptomContent,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
} else if (message.messageType === "prescription") {
|
||||
// 处理处方单详情查看
|
||||
let content = `患者:${patientInfo.name}\n诊断:${message.diagnosis || '无'}\n\n`;
|
||||
|
||||
if (message.prescriptionType === '中药处方' && message.tcmPrescription) {
|
||||
content += `处方类型:中药处方\n处方详情:${message.tcmPrescription.description}\n`;
|
||||
if (message.tcmPrescription.usage) {
|
||||
content += `用法用量:${message.tcmPrescription.usage}\n`;
|
||||
}
|
||||
} else if (message.prescriptionType === '西药处方' && message.medicines) {
|
||||
content += `处方类型:西药处方\n药品清单:\n`;
|
||||
const medicineDetails = message.medicines
|
||||
.map((med) => `${med.name} ${med.spec} ×${med.count}`)
|
||||
.join("\n");
|
||||
content += medicineDetails + "\n";
|
||||
|
||||
// 添加用法用量
|
||||
const usageDetails = message.medicines
|
||||
.filter(med => med.usage)
|
||||
.map(med => `${med.name}:${med.usage}`)
|
||||
.join("\n");
|
||||
if (usageDetails) {
|
||||
content += `\n用法用量:\n${usageDetails}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
content += `\n开方时间:${message.time}`;
|
||||
|
||||
uni.showModal({
|
||||
title: "处方详情",
|
||||
content: content,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
} else if (message.messageType === "refill") {
|
||||
// 处理续方申请详情查看
|
||||
let content = `患者:${message.patientName} ${message.gender} ${message.age}岁\n诊断:${message.diagnosis}\n\n`;
|
||||
|
||||
if (message.prescriptionType === "中药处方") {
|
||||
content += `处方类型:${message.prescriptionType}\n处方详情:${message.prescriptionDesc}`;
|
||||
} else {
|
||||
const medicineDetails = message.medicines
|
||||
.map((med) => `${med.name} ${med.spec} ${med.count}\n${med.usage}`)
|
||||
.join("\n\n");
|
||||
content += `药品清单:\n${medicineDetails}`;
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: "续方申请详情",
|
||||
content: content,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
} else if (message.messageType === "survey") {
|
||||
// 处理问卷调查详情查看或跳转
|
||||
uni.showModal({
|
||||
title: "问卷调查",
|
||||
content: `${message.surveyTitle}\n\n${message.surveyDescription
|
||||
}\n\n问卷名称:${message.surveyName}\n预计用时:${message.estimatedTime}${message.reward ? "\n完成奖励:" + message.reward : ""
|
||||
}${message.note ? "\n\n说明:" + message.note : ""}`,
|
||||
confirmText: "去填写",
|
||||
cancelText: "稍后再说",
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 这里可以跳转到问卷页面
|
||||
uni.showToast({
|
||||
title: "正在跳转到问卷页面",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
75
utils/file.js
Normal file
75
utils/file.js
Normal file
@ -0,0 +1,75 @@
|
||||
const env = __VITE_ENV__;
|
||||
|
||||
export async function uploadFile(tempFilePath, businessType, accessLevel = 'public') {
|
||||
try {
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${env.MP_API_BASE_URL}/upload`,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
formData: { businessType, accessLevel },
|
||||
success: (resp) => resolve(resp),
|
||||
fail: (err) => reject(err),
|
||||
});
|
||||
});
|
||||
|
||||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
|
||||
if (data && data.success) {
|
||||
return data.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('upload file error:', e);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择单张图片并上传,成功返回上传结果(接口返回的 data.data)
|
||||
* - 内部会处理 loading / toast
|
||||
* - 失败或取消返回 null
|
||||
*/
|
||||
export async function chooseAndUploadImage(options = {}) {
|
||||
const {
|
||||
count = 1,
|
||||
sizeType = ['compressed'],
|
||||
sourceType = ['album', 'camera'],
|
||||
businessType = 'other',
|
||||
accessLevel = 'public',
|
||||
loadingTitle = '上传中...',
|
||||
successToast = '上传成功',
|
||||
failToast = '上传失败',
|
||||
} = options;
|
||||
|
||||
const imageResult = await new Promise((resolve) => {
|
||||
uni.chooseImage({
|
||||
count,
|
||||
sizeType,
|
||||
sourceType,
|
||||
success: (res) => resolve(res),
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
|
||||
const tempFilePath = imageResult?.tempFilePaths?.[0];
|
||||
if (!tempFilePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
uni.showLoading({ title: loadingTitle });
|
||||
try {
|
||||
const uploadRes = await uploadFile(tempFilePath, businessType, accessLevel);
|
||||
uni.hideLoading();
|
||||
if (uploadRes) {
|
||||
uni.showToast({ title: successToast, icon: 'success' });
|
||||
return uploadRes;
|
||||
}
|
||||
uni.showToast({ title: failToast, icon: 'none' });
|
||||
return null;
|
||||
} catch (e) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: failToast, icon: 'none' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -97,6 +97,10 @@ async function refreshAccessToken() {
|
||||
const request = async (options = {}, showLoading = true) => {
|
||||
// 合并用户传入的配置和默认配置
|
||||
if (!options.data) options.data = {};
|
||||
if(!options.data.corpId) {
|
||||
options.data.corpId = env.MP_CORP_ID;
|
||||
}
|
||||
|
||||
|
||||
const config = {
|
||||
...defaultOptions,
|
||||
|
||||
229
utils/im-status-manager.js
Normal file
229
utils/im-status-manager.js
Normal file
@ -0,0 +1,229 @@
|
||||
import {
|
||||
checkGlobalIMStatus,
|
||||
ensureGlobalIMConnection,
|
||||
getGlobalIMLoginStatus,
|
||||
setGlobalIMCallback
|
||||
} from './tim-chat.js'
|
||||
|
||||
/**
|
||||
* 全局IM状态管理器
|
||||
* 提供统一的IM状态检测和管理接口
|
||||
*/
|
||||
class IMStatusManager {
|
||||
constructor() {
|
||||
this.statusCheckInterval = null
|
||||
this.isMonitoring = false
|
||||
this.checkIntervalTime = 60000 // 默认1分钟检查一次
|
||||
this.lastCheckTime = 0
|
||||
this.callbacks = {
|
||||
onStatusChange: [],
|
||||
onReconnectSuccess: [],
|
||||
onReconnectFailed: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动IM状态监控
|
||||
* @param {number} intervalTime 检查间隔时间(毫秒)
|
||||
*/
|
||||
startMonitoring(intervalTime = 60000) {
|
||||
if (this.isMonitoring) {
|
||||
console.log('IM状态监控已在运行')
|
||||
return
|
||||
}
|
||||
|
||||
this.checkIntervalTime = intervalTime
|
||||
this.isMonitoring = true
|
||||
|
||||
// 立即检查一次
|
||||
this.checkIMStatus()
|
||||
|
||||
// 启动定时检查
|
||||
this.statusCheckInterval = setInterval(() => {
|
||||
this.checkIMStatus()
|
||||
}, this.checkIntervalTime)
|
||||
|
||||
console.log(`IM状态监控已启动,检查间隔:${intervalTime / 1000}秒`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止IM状态监控
|
||||
*/
|
||||
stopMonitoring() {
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval)
|
||||
this.statusCheckInterval = null
|
||||
}
|
||||
this.isMonitoring = false
|
||||
console.log('IM状态监控已停止')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查IM状态
|
||||
*/
|
||||
async checkIMStatus() {
|
||||
const now = Date.now()
|
||||
this.lastCheckTime = now
|
||||
|
||||
try {
|
||||
console.log('执行IM状态检查...')
|
||||
const isLoggedIn = checkGlobalIMStatus()
|
||||
|
||||
// 触发状态变化回调
|
||||
this.triggerCallbacks('onStatusChange', {
|
||||
isLoggedIn,
|
||||
checkTime: now,
|
||||
timestamp: new Date().toLocaleString()
|
||||
})
|
||||
|
||||
if (!isLoggedIn) {
|
||||
console.log('检测到IM未登录,尝试重连...')
|
||||
await this.attemptReconnect()
|
||||
} else {
|
||||
console.log('IM状态正常')
|
||||
}
|
||||
|
||||
return isLoggedIn
|
||||
} catch (error) {
|
||||
console.error('IM状态检查异常:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试重连IM
|
||||
*/
|
||||
async attemptReconnect() {
|
||||
try {
|
||||
console.log('开始尝试IM重连...')
|
||||
const success = await ensureGlobalIMConnection()
|
||||
|
||||
if (success) {
|
||||
console.log('IM重连成功')
|
||||
this.triggerCallbacks('onReconnectSuccess', {
|
||||
timestamp: new Date().toLocaleString()
|
||||
})
|
||||
|
||||
} else {
|
||||
console.log('IM重连失败')
|
||||
this.triggerCallbacks('onReconnectFailed', {
|
||||
timestamp: new Date().toLocaleString()
|
||||
})
|
||||
}
|
||||
|
||||
return success
|
||||
} catch (error) {
|
||||
console.error('IM重连异常:', error)
|
||||
this.triggerCallbacks('onReconnectFailed', {
|
||||
error,
|
||||
timestamp: new Date().toLocaleString()
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发IM连接检查
|
||||
*/
|
||||
async forceCheck() {
|
||||
console.log('手动触发IM连接检查')
|
||||
return await this.checkIMStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前IM登录状态
|
||||
*/
|
||||
getCurrentStatus() {
|
||||
return {
|
||||
isLoggedIn: getGlobalIMLoginStatus(),
|
||||
isMonitoring: this.isMonitoring,
|
||||
lastCheckTime: this.lastCheckTime,
|
||||
checkInterval: this.checkIntervalTime
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加状态变化回调
|
||||
*/
|
||||
onStatusChange(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.callbacks.onStatusChange.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加重连成功回调
|
||||
*/
|
||||
onReconnectSuccess(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.callbacks.onReconnectSuccess.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加重连失败回调
|
||||
*/
|
||||
onReconnectFailed(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.callbacks.onReconnectFailed.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除回调
|
||||
*/
|
||||
removeCallback(type, callback) {
|
||||
if (this.callbacks[type]) {
|
||||
const index = this.callbacks[type].indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.callbacks[type].splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发回调
|
||||
*/
|
||||
triggerCallbacks(type, data) {
|
||||
if (this.callbacks[type]) {
|
||||
this.callbacks[type].forEach(callback => {
|
||||
try {
|
||||
callback(data)
|
||||
} catch (error) {
|
||||
console.error(`执行${type}回调失败:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态报告
|
||||
*/
|
||||
getStatusReport() {
|
||||
const status = this.getCurrentStatus()
|
||||
return {
|
||||
...status,
|
||||
report: {
|
||||
isLoggedIn: status.isLoggedIn ? '已登录' : '未登录',
|
||||
monitoring: status.isMonitoring ? '监控中' : '未监控',
|
||||
lastCheck: status.lastCheckTime ?
|
||||
new Date(status.lastCheckTime).toLocaleString() : '从未检查',
|
||||
interval: `${status.checkInterval / 1000}秒`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局实例
|
||||
const globalIMStatusManager = new IMStatusManager()
|
||||
|
||||
// 便捷函数
|
||||
export const startIMMonitoring = (interval) => globalIMStatusManager.startMonitoring(interval)
|
||||
export const stopIMMonitoring = () => globalIMStatusManager.stopMonitoring()
|
||||
export const checkIMStatusNow = () => globalIMStatusManager.forceCheck()
|
||||
export const getIMStatus = () => globalIMStatusManager.getCurrentStatus()
|
||||
export const getIMStatusReport = () => globalIMStatusManager.getStatusReport()
|
||||
|
||||
// 导出管理器实例和类
|
||||
export { globalIMStatusManager, IMStatusManager }
|
||||
export default globalIMStatusManager
|
||||
22
utils/order.js
Normal file
22
utils/order.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { getOrderInfo } from "@/api/consult-order.js";
|
||||
|
||||
// 聊天页依赖的最小订单状态枚举(与后端 consult-order 一致)
|
||||
export const orderStatus = {
|
||||
INIT: "INIT",
|
||||
PAID: "PAID",
|
||||
CONSULTING: "CONSULTING",
|
||||
COMPLETED: "COMPLETED",
|
||||
CANCELLED: "CANCELLED",
|
||||
};
|
||||
|
||||
/**
|
||||
* 供 `pages/message/hooks/chat-order-hook.js` 使用:
|
||||
* 根据 orderId 获取订单信息
|
||||
*/
|
||||
export async function getChatOrder(orderId) {
|
||||
const res = await getOrderInfo({ orderId });
|
||||
if (res && res.success) return res.data;
|
||||
return Promise.reject(res?.message || "获取订单失败");
|
||||
}
|
||||
|
||||
|
||||
2652
utils/tim-chat.js
Normal file
2652
utils/tim-chat.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user