no message
This commit is contained in:
parent
e838f8af15
commit
a8fe202cf0
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
|
||||||
|
})
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
38
api/file.js
Normal file
38
api/file.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
const env = __VITE_ENV__;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件(图片等)
|
||||||
|
* 与 zdh-hlw-patient 的 /file/upload 接口保持一致
|
||||||
|
*/
|
||||||
|
export function uploadFile(tempFilePath, businessType, accessLevel = 'public') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: `${env.MP_API_BASE_URL}/file/upload`,
|
||||||
|
filePath: tempFilePath,
|
||||||
|
name: 'file',
|
||||||
|
formData: {
|
||||||
|
businessType,
|
||||||
|
accessLevel,
|
||||||
|
},
|
||||||
|
success: (res) => {
|
||||||
|
try {
|
||||||
|
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
|
||||||
|
if (data && data.success) {
|
||||||
|
resolve(data.data);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('upload file parse error:', e);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.log('upload file error:', err);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.10"
|
"dayjs": "^1.11.10",
|
||||||
|
"tim-upload-plugin": "^1.4.2",
|
||||||
|
"tim-wx-sdk": "^2.27.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
},
|
},
|
||||||
@ -17,6 +19,16 @@
|
|||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
|
"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": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.10"
|
"dayjs": "^1.11.10",
|
||||||
|
"tim-upload-plugin": "^1.4.2",
|
||||||
|
"tim-wx-sdk": "^2.27.6"
|
||||||
},
|
},
|
||||||
"uni-app": {
|
"uni-app": {
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
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>
|
||||||
|
|
||||||
@ -2,6 +2,7 @@ import { ref } from "vue";
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import api from '@/utils/api';
|
import api from '@/utils/api';
|
||||||
import { toast } from '@/utils/widget';
|
import { toast } from '@/utils/widget';
|
||||||
|
import { getInitIMPromise, clearInitIMPromise } from "@/utils/tim-chat.js";
|
||||||
const env = __VITE_ENV__;
|
const env = __VITE_ENV__;
|
||||||
|
|
||||||
|
|
||||||
@ -9,6 +10,9 @@ export default defineStore("accountStore", () => {
|
|||||||
const appid = env.MP_WX_APP_ID;
|
const appid = env.MP_WX_APP_ID;
|
||||||
const account = ref(null);
|
const account = ref(null);
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
// IM 相关
|
||||||
|
const openid = ref("");
|
||||||
|
const isIMInitialized = ref(false);
|
||||||
|
|
||||||
async function login(phoneCode = '') {
|
async function login(phoneCode = '') {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
@ -30,6 +34,10 @@ export default defineStore("accountStore", () => {
|
|||||||
console.log(res)
|
console.log(res)
|
||||||
if (res.success && res.data && res.data.mobile) {
|
if (res.success && res.data && res.data.mobile) {
|
||||||
account.value = res.data;
|
account.value = res.data;
|
||||||
|
// 兼容不同字段名
|
||||||
|
openid.value = res.data.openid || res.data.openId || res.data.userId || "";
|
||||||
|
isIMInitialized.value = false;
|
||||||
|
clearInitIMPromise();
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,5 +48,25 @@ export default defineStore("accountStore", () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return { account, login }
|
/**
|
||||||
|
* 登录后初始化 IM(供聊天页调用)
|
||||||
|
* @param {string} userID - IM userID(通常用 openid/openId)
|
||||||
|
*/
|
||||||
|
async function initIMAfterLogin(userID) {
|
||||||
|
const uid = userID || openid.value;
|
||||||
|
if (!uid) {
|
||||||
|
toast("缺少 openid,无法初始化IM");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await getInitIMPromise(uid, true);
|
||||||
|
isIMInitialized.value = true;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
isIMInitialized.value = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { account, openid, isIMInitialized, login, initIMAfterLogin }
|
||||||
})
|
})
|
||||||
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
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