no message

This commit is contained in:
wangdongbo 2026-01-20 13:21:50 +08:00
parent e838f8af15
commit a8fe202cf0
32 changed files with 8892 additions and 3 deletions

98
api/consult-order.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -9,7 +9,9 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"dayjs": "^1.11.10"
"dayjs": "^1.11.10",
"tim-upload-plugin": "^1.4.2",
"tim-wx-sdk": "^2.27.6"
},
"devDependencies": {}
},
@ -17,6 +19,16 @@
"version": "1.11.19",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
},
"node_modules/tim-upload-plugin": {
"version": "1.4.3",
"resolved": "https://registry.npmmirror.com/tim-upload-plugin/-/tim-upload-plugin-1.4.3.tgz",
"integrity": "sha512-3ZmbA36dr3eG9YGDon9MLBUtbNawYWkL+TBa+VS0Uviguc7PlVSOIVRG2C4irXX16slDT2Kj+HAZapp+Xqp2xg=="
},
"node_modules/tim-wx-sdk": {
"version": "2.27.6",
"resolved": "https://registry.npmmirror.com/tim-wx-sdk/-/tim-wx-sdk-2.27.6.tgz",
"integrity": "sha512-zB+eRdmigdhEDeqrXC0bLJonUQZzS5uKNPLFtrje503WAnmuxVQjq/n4Zle4FYHG4FiKHKhsrVd0aCYXABlFEg=="
}
}
}

View File

@ -10,7 +10,9 @@
"author": "",
"license": "ISC",
"dependencies": {
"dayjs": "^1.11.10"
"dayjs": "^1.11.10",
"tim-upload-plugin": "^1.4.2",
"tim-wx-sdk": "^2.27.6"
},
"uni-app": {
"scripts": {

1235
pages/message/chat.scss Normal file

File diff suppressed because it is too large Load Diff

View 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">{{ '&#xe61a;' }}</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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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;
// 100rpx400rpx
const minWidth = 100;
const maxWidth = 400;
// 15rpx
// 1100rpx2115rpx3130rpx ... 60400rpx
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>

View 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>

View 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>

View 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>

View 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 }
}

View 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
View 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) {
// APIID
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);
}
// IDID
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>

View File

@ -2,6 +2,7 @@ import { ref } from "vue";
import { defineStore } from "pinia";
import api from '@/utils/api';
import { toast } from '@/utils/widget';
import { getInitIMPromise, clearInitIMPromise } from "@/utils/tim-chat.js";
const env = __VITE_ENV__;
@ -9,6 +10,9 @@ export default defineStore("accountStore", () => {
const appid = env.MP_WX_APP_ID;
const account = ref(null);
const loading = ref(false)
// IM 相关
const openid = ref("");
const isIMInitialized = ref(false);
async function login(phoneCode = '') {
if (loading.value) return;
@ -30,6 +34,10 @@ export default defineStore("accountStore", () => {
console.log(res)
if (res.success && res.data && res.data.mobile) {
account.value = res.data;
// 兼容不同字段名
openid.value = res.data.openid || res.data.openId || res.data.userId || "";
isIMInitialized.value = false;
clearInitIMPromise();
return res.data
}
}
@ -40,5 +48,25 @@ export default defineStore("accountStore", () => {
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
View 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最大值 60000010 分钟)
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
View 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
View 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

File diff suppressed because it is too large Load Diff