From cd4693bad4a3a4daa227e554e57d00287ffed62d Mon Sep 17 00:00:00 2001 From: wangdongbo <949818794@qq.com> Date: Thu, 22 Jan 2026 15:13:33 +0800 Subject: [PATCH] no message --- .vscode/settings.json | 3 + pages/message/components/consultation-bar.vue | 43 - pages/message/hooks/use-group-chat.js | 62 ++ static/home/avatar.svg | 1 + utils/chat-utils.js | 798 ++++++++++++++++++ 5 files changed, 864 insertions(+), 43 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 pages/message/components/consultation-bar.vue create mode 100644 pages/message/hooks/use-group-chat.js create mode 100644 static/home/avatar.svg create mode 100644 utils/chat-utils.js diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5480842 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "kiroAgent.configureMCP": "Disabled" +} \ No newline at end of file diff --git a/pages/message/components/consultation-bar.vue b/pages/message/components/consultation-bar.vue deleted file mode 100644 index 6c73414..0000000 --- a/pages/message/components/consultation-bar.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - - - \ No newline at end of file diff --git a/pages/message/hooks/use-group-chat.js b/pages/message/hooks/use-group-chat.js new file mode 100644 index 0000000..e550907 --- /dev/null +++ b/pages/message/hooks/use-group-chat.js @@ -0,0 +1,62 @@ +import { ref, computed } from 'vue' +import { onShow, onUnload } from '@dcloudio/uni-app' + +/** + * 简单的群聊hook + * @param {string} groupID 群组ID + */ +export default function useGroupChat(groupID) { + const groupInfo = ref({}) + const members = ref([]) + + // 群聊成员映射 + const chatMember = computed(() => { + const res = {} + members.value.forEach(member => { + res[member.id] = { + name: member.name, + avatar: member.avatar || '/static/default-avatar.png' + } + }) + return res + }) + + // 获取群聊信息 + async function getGroupInfo() { + const gid = typeof groupID === 'string' ? groupID : groupID.value + if (!gid) return + + try { + // 这里可以调用API获取群聊信息 + // const res = await getGroupDetail(gid) + // if (res && res.success) { + // groupInfo.value = res.data + // members.value = res.data.members || [] + // } + + // 暂时使用本地数据 + groupInfo.value = { + groupID: gid, + name: '群聊', + status: 'active' + } + } catch (error) { + console.error('获取群聊信息失败:', error) + } + } + + onShow(() => { + getGroupInfo() + }) + + onUnload(() => { + // 清理资源 + }) + + return { + groupInfo, + members, + chatMember, + getGroupInfo + } +} diff --git a/static/home/avatar.svg b/static/home/avatar.svg new file mode 100644 index 0000000..21ec70f --- /dev/null +++ b/static/home/avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/utils/chat-utils.js b/utils/chat-utils.js new file mode 100644 index 0000000..01164ee --- /dev/null +++ b/utils/chat-utils.js @@ -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) { + 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} 是否有权限 + */ +const checkAlbumPermission = () => { + return new Promise((resolve) => { + uni.getSetting({ + success: (res) => { + const authStatus = res.authSetting['scope.album']; + + if (authStatus === undefined) { + // 未授权过,会自动弹出授权窗口 + resolve(true); + } else if (authStatus === false) { + // 已拒绝授权,需要引导用户手动开启 + uni.showModal({ + title: '需要相册权限', + content: '请在设置中开启相册权限,以便选择图片', + confirmText: '去设置', + success: (modalRes) => { + if (modalRes.confirm) { + uni.openSetting({ + success: (settingRes) => { + if (settingRes.authSetting['scope.album']) { + resolve(true); + } else { + resolve(false); + } + }, + fail: () => { + resolve(false); + } + }); + } else { + resolve(false); + } + }, + fail: () => { + resolve(false); + } + }); + } else { + // 已授权 + resolve(true); + } + }, + fail: () => { + // 获取设置失败,尝试直接调用 + resolve(true); + } + }); + }); +}; + +/** + * 选择媒体文件 + * @param {object} options - 选择选项 + * @param {function} onSuccess - 成功回调 + * @param {function} onFail - 失败回调 + */ +export const chooseMedia = async (options, onSuccess, onFail) => { + // 如果需要从相册选择,先检查权限 + const sourceType = options.sourceType || ['album', 'camera']; + if (sourceType.includes('album')) { + const hasPermission = await checkAlbumPermission(); + if (!hasPermission) { + console.log('用户未授予相册权限'); + if (onFail) { + onFail({ errMsg: '未授权相册权限' }); + } + return; + } + } + + uni.chooseMedia({ + count: options.count || 1, + mediaType: options.mediaType || ['image'], + sizeType: options.sizeType || ['original', 'compressed'], + sourceType: sourceType, + success: function (res) { + console.log('选择媒体成功:', res); + if (onSuccess) onSuccess(res); + }, + fail: function (err) { + // 用户取消选择 + if (err.errMsg.includes('cancel')) { + console.log('用户取消选择'); + return; + } + + // 权限相关错误 + if (err.errMsg.includes('permission') || err.errMsg.includes('auth') || err.errMsg.includes('拒绝')) { + console.error('相册权限被拒绝:', err); + uni.showModal({ + title: '需要相册权限', + content: '请在设置中开启相册权限后重试', + confirmText: '去设置', + success: (modalRes) => { + if (modalRes.confirm) { + uni.openSetting(); + } + } + }); + if (onFail) { + onFail(err); + } + return; + } + + // 其他错误 + console.error('选择媒体失败:', err); + if (onFail) { + onFail(err); + } else { + showMessage('选择图片失败,请重试'); + } + } + }); +}; + +/** + * 选择图片 + * @param {function} onSuccess - 成功回调 + * @param {function} onFail - 失败回调 + */ +export const chooseImage = (onSuccess, onFail) => { + chooseMedia({ + count: 1, + mediaType: ['image'], + sizeType: ['original', 'compressed'], + sourceType: ['album', 'camera'] + }, onSuccess, onFail); +}; + +/** + * 拍照 + * @param {function} onSuccess - 成功回调 + * @param {function} onFail - 失败回调 + */ +export const takePhoto = (onSuccess, onFail) => { + chooseMedia({ + count: 1, + mediaType: ['image'], + sizeType: ['original', 'compressed'], + sourceType: ['camera'] + }, onSuccess, onFail); +}; + +// ==================== 录音相关工具方法 ==================== + +/** + * 初始化录音管理器 + * @param {object} options - 录音选项 + * @param {function} onStop - 录音结束回调 + * @param {function} onError - 录音错误回调 + * @returns {object} 录音管理器实例 + */ +export const initRecorderManager = (options = {}, onStop, onError) => { + const recorderManager = wx.getRecorderManager(); + + // 监听录音结束事件 + recorderManager.onStop((res) => { + console.log('录音成功,结果:', res); + if (onStop) onStop(res); + }); + + // 监听录音错误事件 + recorderManager.onError((err) => { + console.error('录音失败:', err); + if (onError) { + onError(err); + } else { + showMessage("录音失败"); + } + }); + + return recorderManager; +}; + +/** + * 开始录音 + * @param {object} recorderManager - 录音管理器 + * @param {object} options - 录音参数 + */ +export const startRecord = (recorderManager, options = {}) => { + if (!recorderManager) { + console.error('录音管理器未初始化'); + return; + } + + const recordOptions = { + duration: 60000, // 录音的时长,单位 ms,最大值 600000(10 分钟) + sampleRate: 44100, // 采样率 + numberOfChannels: 1, // 录音通道数 + encodeBitRate: 192000, // 编码码率 + format: 'aac', // 音频格式 + ...options + }; + + recorderManager.start(recordOptions); +}; + +/** + * 停止录音 + * @param {object} recorderManager - 录音管理器 + */ +export const stopRecord = (recorderManager) => { + if (!recorderManager) { + console.error('录音管理器未初始化'); + return; + } + recorderManager.stop(); +}; + +// ==================== 消息发送相关工具方法 ==================== + +/** + * 创建自定义消息 + * @param {string} messageType - 消息类型 + * @param {object} data - 消息数据 + * @param {function} formatTime - 时间格式化函数 + * @returns {object} 自定义消息对象 + */ +export const createCustomMessage = (messageType, data, formatTime) => { + return { + messageType, + time: formatTime(Date.now()), + ...data + }; +}; + +/** + * 发送自定义消息的通用方法 + * @param {object} messageData - 消息数据 + * @param {object} timChatManager - IM管理器 + * @param {function} validateBeforeSend - 发送前验证函数 + * @param {function} onSuccess - 成功回调 + */ +export const sendCustomMessage = async (messageData, timChatManager, validateBeforeSend, onSuccess) => { + if (!validateBeforeSend()) { + return; + } + + const result = await timChatManager.sendCustomMessage(messageData); + + if (result && result.success) { + if (onSuccess) onSuccess(); + } else { + console.error('发送自定义消息失败:', result?.error); + } +}; + +/** + * 发送消息的通用方法 + * @param {string} messageType - 消息类型 + * @param {any} data - 消息数据 + * @param {object} timChatManager - IM管理器 + * @param {function} validateBeforeSend - 发送前验证函数 + * @param {function} onSuccess - 成功回调 + */ +export const sendMessage = async (messageType, data, timChatManager, validateBeforeSend, onSuccess, cloudCustomData) => { + if (!validateBeforeSend()) { + return; + } + + let result; + + switch (messageType) { + case 'text': + result = await timChatManager.sendTextMessage(data, cloudCustomData); + break; + case 'image': + result = await timChatManager.sendImageMessage(data, cloudCustomData); + break; + case 'voice': + result = await timChatManager.sendVoiceMessage(data.file, data.duration,cloudCustomData); + break; + default: + console.error('未知的消息类型:', messageType); + return; + } + + if (result && result.success) { + if (onSuccess) onSuccess(); + } else { + console.error('发送消息失败:', result?.error); + showMessage('发送失败,请重试'); + } +}; + +// ==================== 状态检查相关工具方法 ==================== + +/** + * 检查IM连接状态 + * @param {object} timChatManager - IM管理器 + * @param {function} onError - 错误回调 + * @returns {boolean} 连接状态 + */ +export const checkIMConnectionStatus = (timChatManager, onError) => { + if (!timChatManager.tim || !timChatManager.isLoggedIn) { + const errorMsg = "IM连接异常,请重新进入"; + if (onError) { + onError(errorMsg); + } else { + showMessage(errorMsg); + } + return false; + } + return true; +}; + +/** + * 检查是否显示时间分割线 + * @param {object} message - 当前消息 + * @param {number} index - 消息索引 + * @param {Array} messageList - 消息列表 + * @returns {boolean} 是否显示时间分割线 + */ +export const shouldShowTime = (message, index, messageList) => { + if (index === 0) return true; + + const prevMessage = messageList[index - 1]; + + // 使用工具函数验证时间戳 + if (!validateTimestamp(message.lastTime) || !validateTimestamp(prevMessage.lastTime)) { + return false; + } + + const timeDiff = message.lastTime - prevMessage.lastTime; + + return timeDiff > 5 * 60 * 1000; // 5分钟显示一次时间 +}; + +/** + * 预览图片 + * @param {string} url - 图片URL + */ +export const previewImage = (url) => { + uni.previewImage({ + urls: [url], + current: url, + }); +}; + +// ==================== 录音相关工具方法 ==================== + +/** + * 检查录音时长并处理 + * @param {object} res - 录音结果 + * @param {Function} onTimeTooShort - 时间太短的回调 + * @returns {boolean} 录音时长是否有效 + */ +export const checkRecordingDuration = (res, onTimeTooShort = null) => { + const duration = Math.floor(res.duration / 1000); + if (duration < 1) { + console.log('录音时间太短,取消发送'); + if (onTimeTooShort) { + onTimeTooShort(); + } else { + showMessage('说话时间太短'); + } + return false; + } + return true; +}; + +// ==================== 防抖和节流工具 ==================== + +/** + * 防抖函数 + * @param {Function} func - 要防抖的函数 + * @param {number} wait - 等待时间(毫秒) + * @returns {Function} 防抖后的函数 + */ +export const debounce = (func, wait = 300) => { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +}; + +/** + * 节流函数 + * @param {Function} func - 要节流的函数 + * @param {number} limit - 限制时间(毫秒) + * @returns {Function} 节流后的函数 + */ +export const throttle = (func, limit = 300) => { + let inThrottle; + return function executedFunction(...args) { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +}; + +// ==================== 自定义消息解析相关工具方法 ==================== + +// 自定义消息解析缓存 +const customMessageCache = new Map(); + +/** + * 解析自定义消息(带缓存) + * @param {object} message - 消息对象 + * @param {function} formatTime - 时间格式化函数 + * @returns {object} 解析后的消息对象 + */ +export const parseCustomMessage = (message, formatTime) => { + // 使用消息ID作为缓存键 + const cacheKey = message.ID; + + // 检查缓存 + if (customMessageCache.has(cacheKey)) { + return customMessageCache.get(cacheKey); + } + + try { + const customData = JSON.parse(message.payload.data); + const parsedMessage = { + messageType: customData.messageType, + content: customData.content, + symptomContent: customData.symptomContent, + hasVisitedHospital: customData.hasVisitedHospital, + selectedDiseases: customData.selectedDiseases, + images: customData.images, + medicines: customData.medicines, + diagnosis: customData.diagnosis, + prescriptionType: customData.prescriptionType, + prescriptionDesc: customData.prescriptionDesc, + tcmPrescription: customData.tcmPrescription, // 新增中药处方字段 + patientName: customData.patientName, + gender: customData.gender, + age: customData.age, + surveyTitle: customData.surveyTitle, + surveyDescription: customData.surveyDescription, + surveyName: customData.surveyName, + estimatedTime: customData.estimatedTime, + reward: customData.reward, + note: customData.note, + orderId: customData.orderId, // 新增订单ID字段 + timestamp: customData.timestamp, // 新增时间戳字段 + conversationID: message.conversationID, // 保留conversationID + time: formatTime(message.lastTime), + }; + + // 缓存解析结果 + customMessageCache.set(cacheKey, parsedMessage); + return parsedMessage; + } catch (error) { + const fallbackMessage = { + messageType: "unknown", + content: "未知消息类型", + }; + + // 缓存错误结果,避免重复解析 + customMessageCache.set(cacheKey, fallbackMessage); + return fallbackMessage; + } +}; + +/** + * 清理消息缓存 + */ +export const clearMessageCache = () => { + customMessageCache.clear(); +}; + +/** + * 获取解析后的自定义消息(带缓存) + * @param {object} message - 消息对象 + * @param {function} formatTime - 时间格式化函数 + * @returns {object} 解析后的消息对象 + */ +export const getParsedCustomMessage = (message, formatTime) => { + return parseCustomMessage(message, formatTime); +}; + +/** + * 处理查看详情 + * @param {object} message - 解析后的消息对象 + * @param {object} patientInfo - 患者信息 + */ +export const handleViewDetail = (message, patientInfo) => { + if (message.messageType === "symptom") { + uni.showModal({ + title: "完整病情描述", + content: message.symptomContent, + showCancel: false, + confirmText: "知道了", + }); + } else if (message.messageType === "prescription") { + // 处理处方单详情查看 + let content = `患者:${patientInfo.name}\n诊断:${message.diagnosis || '无'}\n\n`; + + if (message.prescriptionType === '中药处方' && message.tcmPrescription) { + content += `处方类型:中药处方\n处方详情:${message.tcmPrescription.description}\n`; + if (message.tcmPrescription.usage) { + content += `用法用量:${message.tcmPrescription.usage}\n`; + } + } else if (message.prescriptionType === '西药处方' && message.medicines) { + content += `处方类型:西药处方\n药品清单:\n`; + const medicineDetails = message.medicines + .map((med) => `${med.name} ${med.spec} ×${med.count}`) + .join("\n"); + content += medicineDetails + "\n"; + + // 添加用法用量 + const usageDetails = message.medicines + .filter(med => med.usage) + .map(med => `${med.name}:${med.usage}`) + .join("\n"); + if (usageDetails) { + content += `\n用法用量:\n${usageDetails}\n`; + } + } + + content += `\n开方时间:${message.time}`; + + uni.showModal({ + title: "处方详情", + content: content, + showCancel: false, + confirmText: "知道了", + }); + } else if (message.messageType === "refill") { + // 处理续方申请详情查看 + let content = `患者:${message.patientName} ${message.gender} ${message.age}岁\n诊断:${message.diagnosis}\n\n`; + + if (message.prescriptionType === "中药处方") { + content += `处方类型:${message.prescriptionType}\n处方详情:${message.prescriptionDesc}`; + } else { + const medicineDetails = message.medicines + .map((med) => `${med.name} ${med.spec} ${med.count}\n${med.usage}`) + .join("\n\n"); + content += `药品清单:\n${medicineDetails}`; + } + + uni.showModal({ + title: "续方申请详情", + content: content, + showCancel: false, + confirmText: "知道了", + }); + } else if (message.messageType === "survey") { + // 处理问卷调查详情查看或跳转 + uni.showModal({ + title: "问卷调查", + content: `${message.surveyTitle}\n\n${message.surveyDescription + }\n\n问卷名称:${message.surveyName}\n预计用时:${message.estimatedTime}${message.reward ? "\n完成奖励:" + message.reward : "" + }${message.note ? "\n\n说明:" + message.note : ""}`, + confirmText: "去填写", + cancelText: "稍后再说", + success: (res) => { + if (res.confirm) { + // 这里可以跳转到问卷页面 + uni.showToast({ + title: "正在跳转到问卷页面", + icon: "none", + }); + } + }, + }); + } +}; \ No newline at end of file