diff --git a/App.vue b/App.vue index 0d882b8..6d885f3 100644 --- a/App.vue +++ b/App.vue @@ -8,8 +8,14 @@ leadInfo, servInfo, } from "@/api/all.js" + import { initWebSocket, disconnectWebSocket, rebindUser, addMessageHandler, isWebSocketConnected } from '@/utils/ws/initWebSocket.js'; + import { chatRoomManager } from '@/utils/chat/chatRoomManager.js'; + export default { onLaunch() { + // 初始化WebSocket(如果已登录) + // 参考患者端的方式,直接初始化,不延迟 + this.initWebSocketConnection(); // 获取小程序更新机制兼容 if (uni.canIUse('getUpdateManager')) { const updateManager = uni.getUpdateManager() @@ -68,12 +74,54 @@ if (uni.getStorageSync('token')) { this.getInfo() } + + // 监听登录事件 + uni.$on('user-login', () => { + this.initWebSocketConnection(); + }); + + // 监听登出事件 + uni.$on('user-logout', () => { + disconnectWebSocket(); + }); + + // 监听token刷新事件 + uni.$on('token-refreshed', () => { + rebindUser(); + }); + + // 注册聊天室消息处理器 + addMessageHandler((data) => { + chatRoomManager.handleWebSocketMessage(data); + }); + }, + onShow: function() { + // 应用显示时,如果已登录但WebSocket未连接,尝试连接 + if (uni.getStorageSync('token') && !isWebSocketConnected()) { + this.initWebSocketConnection(); + } }, - onShow: function() {}, onHide: function() { - // this.$store.dispatch('closeWebSocket') + // 应用隐藏时不断开WebSocket,保持连接以便接收消息 + // 如果需要断开,可以调用 disconnectWebSocket() }, methods: { + /** + * 初始化WebSocket连接 + * 职责:检查登录状态,如果已登录则初始化WebSocket + * 参考患者端的方式,直接连接,不延迟 + */ + initWebSocketConnection() { + const token = uni.getStorageSync('token'); + const doctorId = uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id'); + + if (token && doctorId) { + // 直接初始化,不延迟(参考患者端的方式) + initWebSocket(); + } + }, + + async getInfo() { let role = uni.getStorageSync('identity') let req = {}; diff --git a/api/doctorOrder.js b/api/doctorOrder.js index f59e6ed..2329ef5 100644 --- a/api/doctorOrder.js +++ b/api/doctorOrder.js @@ -11,7 +11,7 @@ import { */ export function doctorOrderCommonList(data) { return req.request({ - url: '/newApi/doctor-order/common-list', + url: '/newApi/doctor-reception-wx/common-list', method: 'get', data }) diff --git a/api/reception.js b/api/reception.js new file mode 100644 index 0000000..f9f6a6c --- /dev/null +++ b/api/reception.js @@ -0,0 +1,273 @@ +import { + req +} from '@/common/js/index.js'; + +// 转接方患者列表 +export function getPatientList(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/patient-list', + method: 'GET', + data + }) +} + +// 获取患者详情 +export function getPatientItem(id) { + return req.request({ + url: '/newApi/doctor-reception-wx/patient-item', + method: 'GET', + data: { id } + }) +} + +/** + * 根据患者ID获取患者信息 + * @param {number} patientId - 患者ID + * @returns {Promise} + */ +export function getPatientInfoByPatientId(patientId) { + return req.request({ + url: '/newApi/doctor-reception-wx/get-patient-info-by-patient-id', + method: 'GET', + data: { patient_id: patientId } + }) +} + +// 接诊 +export function receptionApi(id) { + return req.request({ + url: '/newApi/doctor-reception-wx/reception', + method: 'POST', + data: { id } + }) +} + +// 拒诊 +export function refuseReceptionApi(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/refuse-reception', + method: 'POST', + data + }) +} + +// 获取拒诊原因列表 +export function refuseReceptionListApi() { + return req.request({ + url: '/newApi/doctor-reception-wx/refuse-reception-list', + method: 'GET' + }) +} + +// 结束问诊 +export function endOfDiagnosisApi(id) { + return req.request({ + url: '/newApi/doctor-reception-wx/end-of-diagnosis', + method: 'POST', + data: { id } + }) +} + +// 获取商品列表 +export function getProductListDoctorReception(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/product-list', + method: 'GET', + data + }) +} + +// 获取药品使用方式列表 +export function getDrugUseList() { + return req.request({ + url: '/newApi/doctor-reception-wx/drug-use-list', + method: 'GET' + }) +} + +// 获取药品使用方式及默认用法用量 +export function getDrugUseWithDefault(drugId) { + return req.request({ + url: '/newApi/doctor-reception-wx/drug-use-with-default', + method: 'GET', + data: { drug_id: drugId } + }) +} + +// 获取疾病列表 +export function getDiseaseList(name, page = 1, pageSize = 20) { + return req.request({ + url: '/newApi/doctor-reception-wx/disease-list', + // url: '/doctor-reception-wx/disease-list', + method: 'GET', + data: { name, page, page_size: pageSize } + }) +} + +// 获取医生的常用诊断列表 +export function getMyDiseaseList() { + return req.request({ + url: '/newApi/doctor-reception-wx/my-disease-list', + method: 'GET' + }) +} + +// 添加常用诊断 +export function addMyDisease(diseaseId) { + return req.request({ + url: '/newApi/doctor-reception-wx/add-my-disease', + method: 'POST', + data: { disease_id: diseaseId } + }) +} + +// 删除常用诊断 +export function deleteMyDisease(id) { + return req.request({ + url: '/newApi/doctor-reception-wx/delete-my-disease', + method: 'POST', + data: { id } + }) +} + +// 获取医嘱列表 +export function getDoctorOrderList() { + return req.request({ + url: '/newApi/doctor-reception-wx/doctor-order-list', + // url: '/doctor-reception-wx/doctor-order-list', + method: 'GET' + }) +} + +// 创建医嘱 +export function createDoctorOrder(content) { + return req.request({ + url: '/newApi/doctor-reception-wx/create-doctor-order', + method: 'POST', + data: { content } + }) +} + +// 删除医嘱 +export function deleteDoctorOrder(id) { + return req.request({ + url: '/newApi/doctor-reception-wx/delete-doctor-order', + method: 'POST', + data: { id } + }) +} + +// 添加西药处方 +export function addWestPrescription(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/add-west-prescription', + method: 'POST', + data + }) +} + +// 检查中药相冲 +export function checkChineseMedicineConflictApi(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/check-chinese-medicine-conflict', + method: 'POST', + data + }) +} + +// 获取我的诊所列表 +export function getMyStoreListApi() { + return req.request({ + url: '/newApi/doctor-reception-wx/get-my-store-list', + method: 'GET' + }) +} + +// 获取当前诊所类型 +export function getCurrentStoreTypeApi(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/get-current-store-type', + method: 'GET', + data + }) +} + +// 切换诊所 +export function switchStoreApi(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/switch-store', + method: 'POST', + data + }) +} + +// 获取处方详情 +export function getPrescriptionInfoApi(id) { + return req.request({ + url: '/newApi/prescription/detail', + method: 'GET', + data: { id } + }) +} + +// 获取加工规则列表 +export function getProcessRuleList(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/process-rule-list', + method: 'GET', + data + }) +} + +// 获取煎煮方式列表 +export function getUseWayList() { + return req.request({ + url: '/newApi/doctor-reception-wx/drug-use-way-list', + method: 'GET' + }) +} + +// 获取常用方列表 +export function getCommonPrescriptionListApi(storeId) { + return req.request({ + url: '/newApi/common-prescription/list', + method: 'GET', + data: { store_id: storeId } + }) +} + +// 获取常用方详情 +export function getCommonPrescriptionDetailApi(id, type) { + return req.request({ + url: '/newApi/common-prescription/detail', + method: 'GET', + data: { id, type } + }) +} + +// 保存西药常用方 +export function saveWestCommonPrescriptionApi(data) { + return req.request({ + url: '/newApi/common-prescription/save-west', + method: 'POST', + data + }) +} + +// 保存中药常用方 +export function saveChineseCommonPrescriptionApi(data) { + return req.request({ + url: '/newApi/common-prescription/save-chinese', + method: 'POST', + data + }) +} + +// 获取公共医嘱列表 +export function getDoctorOrderCommonList(data) { + return req.request({ + url: '/newApi/doctor-reception-wx/common-list', + method: 'GET', + data + }) +} diff --git a/common/js/common.js b/common/js/common.js index cea80f3..494bc00 100644 --- a/common/js/common.js +++ b/common/js/common.js @@ -3,9 +3,10 @@ import {config} from "@/common/js/index"; export async function requestConfig(ins, options, successHandler = null, failHandler = null, completeHandler = null){ //原来是:ins.baseUrl = options.baseUrl || ins.baseUrl let baseUrl = options.baseUrl || ins.baseUrl; + // TODO 这里写dev还是生产环境 if (options.url.split('/').indexOf('newApi') !== -1) { - // baseUrl = "http://127.0.0.1:18001/api/doctor/" - baseUrl = "https://api.xiaokang88.com/api/doctor/" + baseUrl = "http://127.0.0.1:18001/api/doctor/" + // baseUrl = "https://api.xiaokang88.com/api/doctor/" } options.url = options.url.replace('/oldApi/', ''); options.url = options.url.replace('/newApi/', ''); @@ -41,6 +42,20 @@ export async function requestConfig(ins, options, successHandler = null, failHan config["dataType"] = options.dataType || "json" config["responseType"] = options.responseType || "text" config['sslVerify'] = false + + // 如果是需要提交复杂对象/数组的写操作(POST/PUT/PATCH),强制使用 JSON 提交 + const method = (config["method"] || "GET").toUpperCase() + if (["POST", "PUT", "PATCH"].includes(method)) { + const data = config["data"] + if (data && typeof data === 'object') { + const hasComplexValue = Object.values(data).some((v) => { + return v && typeof v === 'object' + }) + if (hasComplexValue) { + config.header["Content-Type"] = "application/json" + } + } + } }else if(type === "upload"){ config["filePath"] = options.filePath config["name"] = options.name diff --git a/common/js/index.js b/common/js/index.js index d7b01c8..93b21eb 100644 --- a/common/js/index.js +++ b/common/js/index.js @@ -1,8 +1,9 @@ import Request from './request.js'; import errorCode from './errorCode.js'; import {Base64} from "js-base64"; -let baseUrl = 'https://app.xiaokang88.com/service/v1/'; // 域名 -// let baseUrl = 'http://127.0.0.1:18000/service/v1/'; // 域名 +// TODO 这里写dev还是生产环境 +// let baseUrl = 'https://app.xiaokang88.com/service/v1/'; // 域名 +let baseUrl = 'http://127.0.0.1:18000/service/v1/'; // 域名 // let baseUrl = 'http://www.xk888.com/service/v1/'; // 域名 export const config = { baseUrl: baseUrl diff --git a/config/chat.js b/config/chat.js new file mode 100644 index 0000000..913d18c --- /dev/null +++ b/config/chat.js @@ -0,0 +1,74 @@ +/** + * 聊天室配置文件 + * 统一管理聊天室相关配置 + */ + +/** + * 聊天室配置 + */ +export const chatConfig = { + /** + * 消息类型映射 + * 将消息类型名称映射到数字类型 + */ + messageTypes: { + text: 0, // 文本消息 + image: 1, // 图片消息 + audio: 2, // 语音消息 + video: 3, // 视频消息 + prescription: 4, // 处方消息 + file: 5, // 文件消息 + register: 10, // 挂号消息 + 'patient-experience': 11, // 患者体验消息 + 'product-card': 12, // 产品卡片消息 + 'end-consultation': 13 // 结束问诊消息 + }, + + /** + * 根据消息类型名称获取数字类型 + * @param {string} typeName - 消息类型名称 + * @returns {number} 消息类型数字 + */ + getMessageType(typeName) { + return this.messageTypes[typeName] || 0; + }, + + /** + * 根据数字类型获取消息类型名称 + * @param {number} type - 消息类型数字 + * @returns {string} 消息类型名称 + */ + getMessageTypeName(type) { + const entries = Object.entries(this.messageTypes); + const found = entries.find(([name, value]) => value === type); + return found ? found[0] : 'text'; + }, + + /** + * 房间配置 + */ + room: { + messagePageSize: 20, // 每页消息数量 + maxCacheSize: 100 // 最大缓存消息数 + }, + + /** + * 未读数配置 + */ + unread: { + maxCount: 99 // 最大未读数显示(超过显示99+) + }, + + /** + * 格式化未读数显示 + * @param {number} count - 未读数 + * @returns {string} 格式化后的未读数 + */ + formatUnreadCount(count) { + if (count <= 0) return ''; + if (count > this.unread.maxCount) { + return `${this.unread.maxCount}+`; + } + return count.toString(); + } +}; diff --git a/config/websocket.js b/config/websocket.js new file mode 100644 index 0000000..98a3e76 --- /dev/null +++ b/config/websocket.js @@ -0,0 +1,55 @@ +/** + * WebSocket配置文件 + * 统一管理WebSocket相关配置 + */ + +// 检查是否为开发环境 +// 在uni-app中,可以通过条件编译或manifest.json判断 +const isDev = true; // TODO: 根据实际环境配置 + +/** + * WebSocket配置 + */ +export const websocketConfig = { + /** + * WebSocket URL配置 + * dev: 开发环境WebSocket地址 + * prod: 生产环境WebSocket地址 + */ + url: { + dev: 'ws://127.0.0.1:12080/ws', + prod: 'wss://api.ws.g.xiaokang88.com/ws' + }, + + /** + * 获取当前环境的WebSocket URL + * @returns {string} WebSocket URL + */ + getUrl() { + return isDev ? this.url.dev : this.url.prod; + }, + + /** + * 重连配置 + */ + reconnect: { + maxAttempts: 5, // 最大重连次数 + initialDelay: 1000, // 初始重连延迟(毫秒) + maxDelay: 30000 // 最大重连延迟(毫秒) + }, + + /** + * 心跳配置 + */ + heartbeat: { + interval: 300000 // 心跳间隔(毫秒),5分钟 + }, + + /** + * 用户标识配置 + */ + user: { + prefix: 'doctor-', // 医生小程序使用doctor-前缀 + platform: 'doctor-miniprogram' // 平台标识(医生小程序) + } +}; diff --git a/pages.json b/pages.json index 7e45003..3ab4321 100644 --- a/pages.json +++ b/pages.json @@ -187,6 +187,13 @@ "navigationBarTitleText": "常用方详情" } }, + { + "path": "prescription_v2/index", + "style": { + "navigationBarTitleText": "开方", + "navigationStyle": "custom" + } + }, { "path": "workbench_basicInfo", "style": { @@ -394,6 +401,28 @@ "navigationBarTitleText": "视频" } }] + }, { + "root": "subPackages/sub_reception", + "pages": [{ + "path": "reception_list", + "style": { + "navigationBarTitleText": "转接方", + "navigationStyle": "custom" + } + }, { + "path": "reception_chat", + "style": { + "navigationBarTitleText": "转接方聊天", + "navigationStyle": "custom", + "enablePullDownRefresh": false + } + }, { + "path": "reception_prescription", + "style": { + "navigationBarTitleText": "转接方开方", + "navigationStyle": "custom" + } + }] } ], "tabBar": { diff --git a/pages/login/index.vue b/pages/login/index.vue index 5939634..fa8f776 100644 --- a/pages/login/index.vue +++ b/pages/login/index.vue @@ -148,6 +148,15 @@ uni.setStorageSync('logInfo', res.data['ServiceUser']) // 记录店铺id uni.setStorageSync('store_id', res.data['StoreDoctor']['store_id']) + + // 存储doctor_id或user_id,用于WebSocket绑定 + if (res.data['ServiceUser'] && res.data['ServiceUser']['id']) { + uni.setStorageSync('doctor_id', res.data['ServiceUser']['id']); + uni.setStorageSync('user_id', res.data['ServiceUser']['id']); + } + + // 触发WebSocket初始化(登录成功后) + uni.$emit('user-login'); const data = { store_id: uni.getStorageSync('store_id') || res.data['StoreDoctor']['store_id'] || '11001', diff --git a/pages/login/register.vue b/pages/login/register.vue index f64fa93..628f148 100644 --- a/pages/login/register.vue +++ b/pages/login/register.vue @@ -120,6 +120,15 @@ if (res.errcode != -1) { uni.setStorageSync('token', res.data['token']) uni.setStorageSync('logInfo', res.data['user']) + + // 存储doctor_id或user_id,用于WebSocket绑定 + if (res.data['user'] && res.data['user']['id']) { + uni.setStorageSync('doctor_id', res.data['user']['id']); + uni.setStorageSync('user_id', res.data['user']['id']); + } + + // 触发WebSocket初始化(注册成功后) + uni.$emit('user-login'); setTimeout(() => { uni.showToast({ diff --git a/request/api/im.js b/request/api/im.js new file mode 100644 index 0000000..7a531bd --- /dev/null +++ b/request/api/im.js @@ -0,0 +1,60 @@ +import { req } from '@/common/js/index.js'; + +// 发送消息 +export async function sendToUserApi(params) { + return req.request({ + url: 'imApi/send-to-user', + method: 'POST', + data: params, + header: { + 'Content-Type': 'application/json' + } + }); +} + +// 获取房间聊天记录 +export async function getMessagesByRoomIdApi(params) { + return req.request({ + url: 'chat-friends/messages-by-room-id', + method: 'GET', + data: params + }); +} + +// 获取聊天注册信息 +export async function getChatRegisterInfoApi(params) { + return req.request({ + url: 'chat-friends/chat-register-info', + method: 'GET', + data: params + }); +} + +// 上传聊天文件 +export function upLoadChatFileApi(params) { + return req.request({ + url: 'chat-friends/upload-chat-file', + method: 'UPLOAD', + filePath: params.filePath, + name: params.name || 'file', + formData: params.formData || {} + }); +} + +// 获取医生信息 +export async function getDoctorInfoApi(params) { + return req.request({ + url: 'chat-friends/get-doctor-info', + method: 'GET', + data: params + }); +} + +// 获取房间状态 +export async function getRoomStatusApi(params) { + return req.request({ + url: 'chat-friends/get-room-status', + method: 'GET', + data: params + }); +} diff --git a/store/chat/chat.js b/store/chat/chat.js new file mode 100644 index 0000000..45232ed --- /dev/null +++ b/store/chat/chat.js @@ -0,0 +1,173 @@ +import { getChatRegisterInfoApi, getMessagesByRoomIdApi } from "@/request/api/im"; + +/** + * 聊天消息管理器(简化版) + */ +const ChatManager = { + currentRoomId: null, + lastMessageId: 0, + unreadCounts: {}, + messageCache: {}, + roomMessages: {}, + + enterRoom(roomId) { + this.currentRoomId = roomId; + uni.setStorageSync('currentRoomId', roomId); + this.resetUnreadCount(roomId); + }, + + leaveRoom() { + this.currentRoomId = null; + uni.removeStorageSync('currentRoomId'); + }, + + getCurrentRoomId() { + return this.currentRoomId || uni.getStorageSync('currentRoomId'); + }, + + increaseUnreadCount(roomId) { + if (this.getCurrentRoomId() !== roomId) { + this.unreadCounts[roomId] = (this.unreadCounts[roomId] || 0) + 1; + this.notifyUnreadChange(); + } + }, + + resetUnreadCount(roomId) { + if (this.unreadCounts[roomId]) { + this.unreadCounts[roomId] = 0; + this.notifyUnreadChange(); + } + }, + + getUnreadCount(roomId) { + return this.unreadCounts[roomId] || 0; + }, + + getAllUnreadCount() { + return Object.values(this.unreadCounts).reduce((sum, count) => sum + count, 0); + }, + + notifyUnreadChange() { + uni.$emit('unread-message-update', { + total: this.getAllUnreadCount(), + byRoom: { ...this.unreadCounts } + }); + }, + + addMessageToRoom(roomId, message) { + if (!this.roomMessages[roomId]) { + this.roomMessages[roomId] = []; + } + this.roomMessages[roomId].push(message); + }, + + getRoomMessages(roomId) { + let that = this; + this.roomMessages[roomId] = []; + getMessagesByRoomIdApi({ + room_id: roomId, + last_message_id: 0, + }).then((res) => { + if (res && res.data && res.data.result) { + that.roomMessages[roomId] = that.checkMessage(res.data.result.list || []); + that.lastMessageId = res.data.result.last_message_id || 0; + uni.$emit('load-room-message', 1); + } + }).catch(err => { + console.error('获取消息失败:', err); + }); + return this.roomMessages[roomId] || []; + }, + + checkMessage(message, type = 0) { + if (type === 1) { + return { + id: message.id, + sender_user_id: message.sender_user_id, + receiver_user_id: message.receiver_user_id, + message_type: message.message_type, + message_content: message.message_content, + duration: message.duration, + room_id: message.room_id, + created_at: message.created_at || message.send_time, + created_at_text: message.created_at_text || this.formatTime(message.send_time || message.created_at), + timestamp: message.send_time || message.created_at + }; + } + return message.map((item) => { + const result = { + id: item.id, + sender_user_id: item.sender_user_id, + receiver_user_id: item.receiver_user_id, + message_type: item.message_type, + message_content: item.message_content, + duration: item.duration, + room_id: item.room_id, + created_at: item.created_at || item.send_time, + created_at_text: item.created_at_text || this.formatTime(item.send_time || item.created_at), + timestamp: item.send_time || item.created_at + }; + + if (item.message_type === 10) { + this.getChatRegisterInfo(result, message.indexOf(item)); + } + return result; + }); + }, + + formatTime(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + }, + + getChatRegisterInfo(message, index) { + const parsedContent = this.parseMessageContent(message.message_content); + if (parsedContent._loaded || !parsedContent.id) return; + + getChatRegisterInfoApi({ id: parsedContent.id, message_id: message.id || '' }).then((res) => { + if (res && res.data && res.data.result) { + const resultWithFlag = { ...res.data.result, _loaded: true }; + const roomId = message.room_id; + if (this.roomMessages[roomId] && this.roomMessages[roomId][index]) { + this.roomMessages[roomId][index].message_content = JSON.stringify(resultWithFlag); + } + } + }); + }, + + parseMessageContent(content) { + try { + return typeof content === 'object' ? content : JSON.parse(content); + } catch (e) { + return content; + } + }, + + loadHistory(roomId) { + getMessagesByRoomIdApi({ + room_id: roomId, + last_message_id: this.lastMessageId, + }).then((res) => { + if (res && res.data && res.data.result) { + const newMessages = this.checkMessage(res.data.result.list || []); + if (newMessages.length > 0) { + this.roomMessages[roomId] = [...newMessages, ...(this.roomMessages[roomId] || [])]; + this.lastMessageId = res.data.result.last_message_id || 0; + uni.$emit('load-room-message', 0); + } else { + uni.$emit('no-more-history'); + } + } else { + uni.$emit('no-more-history'); + } + }).catch(err => { + console.error('加载历史消息失败:', err); + uni.$emit('no-more-history'); + }); + } +}; + +export { ChatManager }; diff --git a/store/index.js b/store/index.js index 6aba8d9..453551b 100644 --- a/store/index.js +++ b/store/index.js @@ -1,7 +1,7 @@ import Vue from 'vue' import Vuex from 'vuex' -import WebSocket from '@/common/common.js'; import Utils from '@/common/utils.js'; +import { disconnectWebSocket } from '@/utils/ws/initWebSocket.js'; import { registerList @@ -9,8 +9,6 @@ import { Vue.use(Vuex) const store = new Vuex.Store({ state: { - // url: 'wss://wss.nbxlmy.com/ws', - webSocket: null, utils: null, consult: [], //咨询会话列表 docSession: [], //医生首页消息会话列表 @@ -25,37 +23,37 @@ const store = new Vuex.Store({ }, }, actions: { + /** + * 登出 + * 职责:断开WebSocket连接,清理状态 + */ logout({ state, dispatch }) { - dispatch('closeWebSocket') - // state.webSocket.logout() + dispatch('closeWebSocket'); }, + + /** + * 初始化(保留用于其他初始化逻辑) + * 注意:WebSocket初始化已在App.vue中统一处理 + */ init({ state, dispatch }) { - console.log('这里是websocket init!!!!') - // 拿到存储 - let userInfo = uni.getStorageSync('userInfo') - if (userInfo) { - console.log(1); - // 连接socket - state.webSocket = new WebSocket({ - url: state.url - }) - // state.utils = new Utils() - // 获取会话列表 - // dispatch('getSessionList') - } + // WebSocket初始化已移至App.vue,这里保留其他初始化逻辑 + // state.utils = new Utils() + // 获取会话列表 + // dispatch('getSessionList') }, - closeWebSocket({ - state - }) { - if (state.webSocket) { - state.webSocket.close(); - } + + /** + * 关闭WebSocket + * 职责:断开WebSocket连接 + */ + closeWebSocket() { + disconnectWebSocket(); }, // 获取挂号信息 getConsult({ diff --git a/subPackages/sub_reception/components/DiagnosisModal.vue b/subPackages/sub_reception/components/DiagnosisModal.vue new file mode 100644 index 0000000..62021b8 --- /dev/null +++ b/subPackages/sub_reception/components/DiagnosisModal.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/subPackages/sub_reception/components/DoctorOrderModal.vue b/subPackages/sub_reception/components/DoctorOrderModal.vue new file mode 100644 index 0000000..d9c2b24 --- /dev/null +++ b/subPackages/sub_reception/components/DoctorOrderModal.vue @@ -0,0 +1,392 @@ + + + + + diff --git a/subPackages/sub_reception/components/MessageBubble.vue b/subPackages/sub_reception/components/MessageBubble.vue new file mode 100644 index 0000000..8e2b192 --- /dev/null +++ b/subPackages/sub_reception/components/MessageBubble.vue @@ -0,0 +1,615 @@ + + + + + diff --git a/subPackages/sub_reception/components/WesternMedicineUsageModal.vue b/subPackages/sub_reception/components/WesternMedicineUsageModal.vue new file mode 100644 index 0000000..13feed1 --- /dev/null +++ b/subPackages/sub_reception/components/WesternMedicineUsageModal.vue @@ -0,0 +1,382 @@ + + + + + diff --git a/subPackages/sub_reception/reception_chat.vue b/subPackages/sub_reception/reception_chat.vue new file mode 100644 index 0000000..13bb964 --- /dev/null +++ b/subPackages/sub_reception/reception_chat.vue @@ -0,0 +1,1135 @@ + + + + + diff --git a/subPackages/sub_reception/reception_list.vue b/subPackages/sub_reception/reception_list.vue new file mode 100644 index 0000000..4293351 --- /dev/null +++ b/subPackages/sub_reception/reception_list.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/subPackages/sub_reception/reception_prescription.vue b/subPackages/sub_reception/reception_prescription.vue new file mode 100644 index 0000000..ce6035a --- /dev/null +++ b/subPackages/sub_reception/reception_prescription.vue @@ -0,0 +1,980 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/components/ChineseMedicineConfig.vue b/subPackages/sub_workbench/prescription_v2/components/ChineseMedicineConfig.vue new file mode 100644 index 0000000..5c77eb1 --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/components/ChineseMedicineConfig.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/components/modals/ChineseMedicineModal.vue b/subPackages/sub_workbench/prescription_v2/components/modals/ChineseMedicineModal.vue new file mode 100644 index 0000000..58a0495 --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/components/modals/ChineseMedicineModal.vue @@ -0,0 +1,729 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/components/modals/CommonPrescriptionModal.vue b/subPackages/sub_workbench/prescription_v2/components/modals/CommonPrescriptionModal.vue new file mode 100644 index 0000000..0370ada --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/components/modals/CommonPrescriptionModal.vue @@ -0,0 +1,487 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/components/modals/DiagnosisModal.vue b/subPackages/sub_workbench/prescription_v2/components/modals/DiagnosisModal.vue new file mode 100644 index 0000000..32c0d3c --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/components/modals/DiagnosisModal.vue @@ -0,0 +1,517 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/components/modals/DoctorOrderModal.vue b/subPackages/sub_workbench/prescription_v2/components/modals/DoctorOrderModal.vue new file mode 100644 index 0000000..d4f4410 --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/components/modals/DoctorOrderModal.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/components/modals/SimpleProductModal.vue b/subPackages/sub_workbench/prescription_v2/components/modals/SimpleProductModal.vue new file mode 100644 index 0000000..0555ad5 --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/components/modals/SimpleProductModal.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/components/modals/WesternMedicineModal.vue b/subPackages/sub_workbench/prescription_v2/components/modals/WesternMedicineModal.vue new file mode 100644 index 0000000..bf27f55 --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/components/modals/WesternMedicineModal.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/components/modals/WesternMedicineUsageModal.vue b/subPackages/sub_workbench/prescription_v2/components/modals/WesternMedicineUsageModal.vue new file mode 100644 index 0000000..1a1235c --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/components/modals/WesternMedicineUsageModal.vue @@ -0,0 +1,455 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/index.vue b/subPackages/sub_workbench/prescription_v2/index.vue new file mode 100644 index 0000000..2873b3a --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/index.vue @@ -0,0 +1,2043 @@ + + + + + diff --git a/subPackages/sub_workbench/prescription_v2/utils/prescriptionCalculator.js b/subPackages/sub_workbench/prescription_v2/utils/prescriptionCalculator.js new file mode 100644 index 0000000..ab4fc52 --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/utils/prescriptionCalculator.js @@ -0,0 +1,116 @@ +/** + * 处方价格计算工具类 + * 职责:统一管理处方价格计算逻辑 + */ +export class PrescriptionCalculator { + /** + * 计算中药商品总价 + * @param {Array} drugs - 药品列表 + * @param {number} dosage - 剂数(天数) + * @returns {number} 商品总价 + */ + static calculateChineseMedicineProductPrice(drugs, dosage = 7) { + if (!drugs || drugs.length === 0) { + return 0; + } + return drugs.reduce((sum, drug) => { + const price = parseFloat(drug.price || 0); + const quantity = parseFloat(drug.number || 1); + return sum + (price * quantity * dosage); + }, 0); + } + + /** + * 计算西药商品总价 + * @param {Array} drugs - 药品列表 + * @returns {number} 商品总价 + */ + static calculateWesternMedicineProductPrice(drugs) { + if (!drugs || drugs.length === 0) { + return 0; + } + return drugs.reduce((sum, drug) => { + const price = parseFloat(drug.price || 0); + const quantity = parseFloat(drug.select_number || drug.number || 1); + return sum + (price * quantity); + }, 0); + } + + /** + * 计算简单产品总价(保健食品、服务包、非药品、医疗器械) + * @param {Array} products - 产品列表 + * @returns {number} 商品总价 + */ + static calculateSimpleProductPrice(products) { + if (!products || products.length === 0) { + return 0; + } + return products.reduce((sum, product) => { + const price = parseFloat(product.price || 0); + const quantity = parseFloat(product.select_number || product.number || 1); + return sum + (price * quantity); + }, 0); + } + + /** + * 计算加工费(委托调剂) + * @param {Object} processRule - 加工规则对象 + * @param {number} dosage - 剂数 + * @param {number} totalDrugQuantity - 总药品数量(用于按数量计算) + * @returns {number} 加工费 + */ + static calculateProcessingFee(processRule, dosage = 7, totalDrugQuantity = 0) { + if (!processRule || !processRule.calc_method) { + return 0; + } + + const calcMethod = processRule.calc_method; // 1=固定价格,2=按剂数,3=按数量 + const price = parseFloat(processRule.price || 0); + + switch (calcMethod) { + case 1: + // 固定价格 + return price; + case 2: + // 按剂数计算 + return price * dosage; + case 3: + // 按数量计算 + return price * dosage * totalDrugQuantity; + default: + return 0; + } + } + + /** + * 计算处方总价 + * @param {Object} params - 计算参数 + * @param {number} params.category - 处方类型 + * @param {Array} params.drugs - 药品列表 + * @param {number} params.dosage - 剂数(中药需要) + * @param {number} params.processingFee - 加工费(委托调剂需要) + * @param {number} params.treatmentPrice - 诊疗费 + * @returns {number} 总价 + */ + static calculateTotalPrice(params) { + const { category, drugs, dosage = 7, processingFee = 0, treatmentPrice = 0 } = params; + + let productPrice = 0; + + // 根据类型计算商品价格 + if (category === 1) { + // 中药 + productPrice = this.calculateChineseMedicineProductPrice(drugs, dosage); + } else if (category === 2) { + // 西药 + productPrice = this.calculateWesternMedicineProductPrice(drugs); + } else if ([3, 5, 6, 7].includes(category)) { + // 简单产品 + productPrice = this.calculateSimpleProductPrice(drugs); + } + + // 总价 = 商品价格 + 加工费 + 诊疗费 + const total = productPrice + processingFee + treatmentPrice; + return parseFloat(total.toFixed(2)); + } +} diff --git a/subPackages/sub_workbench/prescription_v2/utils/prescriptionStorage.js b/subPackages/sub_workbench/prescription_v2/utils/prescriptionStorage.js new file mode 100644 index 0000000..f958525 --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/utils/prescriptionStorage.js @@ -0,0 +1,129 @@ +/** + * 处方数据存储工具类 + * 职责:统一管理处方数据的本地存储操作 + */ +export class PrescriptionStorage { + /** + * 存储前缀 + */ + static STORAGE_PREFIX = 'prescriptionV2-'; + + /** + * 获取存储键名 + * @param {number} category - 处方类型:1=中药,2=西药,3=保健食品,5=产品服务包,6=非药品,7=医疗器械 + * @param {number} registerId - 挂号ID + * @returns {string} 存储键名 + */ + static getStorageKey(category, registerId) { + return `${this.STORAGE_PREFIX}prescriptionData_${category}_${registerId}`; + } + + /** + * 获取激活分类的存储键名 + * @param {number} registerId - 挂号ID + * @returns {string} 存储键名 + */ + static getActiveCategoryKey(registerId) { + return `${this.STORAGE_PREFIX}activeCategory_${registerId}`; + } + + /** + * 保存处方数据到本地存储 + * @param {number} category - 处方类型 + * @param {number} registerId - 挂号ID + * @param {Object} data - 处方数据(药品列表、诊断、医嘱等) + */ + static savePrescriptionData(category, registerId, data) { + try { + const key = this.getStorageKey(category, registerId); + uni.setStorageSync(key, JSON.stringify(data)); + console.log('保存处方数据成功:', key, data); + } catch (error) { + console.error('保存处方数据失败:', error); + } + } + + /** + * 从本地存储加载处方数据 + * @param {number} category - 处方类型 + * @param {number} registerId - 挂号ID + * @returns {Object|null} 处方数据 + */ + static loadPrescriptionData(category, registerId) { + try { + const key = this.getStorageKey(category, registerId); + const data = uni.getStorageSync(key); + if (data) { + return JSON.parse(data); + } + return null; + } catch (error) { + console.error('加载处方数据失败:', error); + return null; + } + } + + /** + * 清除指定处方的本地存储数据 + * @param {number} category - 处方类型 + * @param {number} registerId - 挂号ID + */ + static clearPrescriptionData(category, registerId) { + try { + const key = this.getStorageKey(category, registerId); + uni.removeStorageSync(key); + } catch (error) { + console.error('清除处方数据失败:', error); + } + } + + /** + * 清除所有处方的本地存储数据 + * @param {number} registerId - 挂号ID + */ + static clearAllPrescriptionData(registerId) { + try { + const categories = [1, 2, 3, 5, 6, 7]; + categories.forEach(category => { + this.clearPrescriptionData(category, registerId); + }); + // 清除激活分类 + uni.removeStorageSync(this.getActiveCategoryKey(registerId)); + } catch (error) { + console.error('清除所有处方数据失败:', error); + } + } + + /** + * 保存当前激活的分类 + * @param {number} category - 处方类型 + * @param {number} registerId - 挂号ID + */ + static saveActiveCategory(category, registerId) { + try { + const key = this.getActiveCategoryKey(registerId); + uni.setStorageSync(key, category.toString()); + } catch (error) { + console.error('保存激活分类失败:', error); + } + } + + /** + * 获取当前激活的分类 + * @param {number} registerId - 挂号ID + * @returns {number|null} 激活的分类,如果没有则返回null + */ + static getActiveCategory(registerId) { + try { + const key = this.getActiveCategoryKey(registerId); + const category = uni.getStorageSync(key); + if (category) { + return parseInt(category); + } + return null; + } catch (error) { + console.error('获取激活分类失败:', error); + return null; + } + } +} diff --git a/subPackages/sub_workbench/prescription_v2/utils/prescriptionValidator.js b/subPackages/sub_workbench/prescription_v2/utils/prescriptionValidator.js new file mode 100644 index 0000000..03a1210 --- /dev/null +++ b/subPackages/sub_workbench/prescription_v2/utils/prescriptionValidator.js @@ -0,0 +1,139 @@ +/** + * 处方数据验证工具类 + * 职责:统一管理处方数据验证逻辑 + */ +export class PrescriptionValidator { + /** + * 验证处方数据 + * @param {Object} params - 验证参数 + * @param {number} params.category - 处方类型 + * @param {Array} params.drugs - 药品列表 + * @param {Array} params.diagnoses - 诊断列表 + * @param {string} params.medicalAdvice - 医嘱 + * @param {Object} params.chineseConfig - 中药配置(仅中药需要) + * @returns {Object} { valid: boolean, message: string } + */ + static validatePrescriptionData(params) { + const { category, drugs, diagnoses, medicalAdvice, chineseConfig } = params; + + // 验证药品 + if (!drugs || drugs.length === 0) { + return { + valid: false, + message: '请添加药品' + }; + } + + // 验证诊断 + if (!diagnoses || diagnoses.length === 0) { + return { + valid: false, + message: '请添加诊断' + }; + } + + // 验证医嘱 + if (!medicalAdvice || medicalAdvice.trim() === '') { + return { + valid: false, + message: '请输入医嘱' + }; + } + + // 验证中药配置 + if (category === 1 && chineseConfig) { + const chineseValidation = this.validateChineseMedicineConfig(chineseConfig); + if (!chineseValidation.valid) { + return chineseValidation; + } + } + + return { + valid: true, + message: '验证通过' + }; + } + + /** + * 验证中药配置 + * @param {Object} config - 中药配置 + * @param {number} config.ruleType - 规则类型:1=自制剂,2=委托调剂 + * @param {number} config.packageMethodId - 包法ID(自制剂需要) + * @param {number} config.processRuleId - 制剂ID(委托调剂需要) + * @param {number} config.childProcessRuleId - 煎法ID(委托调剂需要) + * @param {number} config.processRuleNoteId - 备注ID(委托调剂需要) + * @returns {Object} { valid: boolean, message: string } + */ + static validateChineseMedicineConfig(config) { + const { ruleType, packageMethodId, processRuleId, childProcessRuleId, processRuleNoteId } = config; + + if (ruleType === 1) { + // 自制剂:需要包法 + if (!packageMethodId) { + return { + valid: false, + message: '请选择包法' + }; + } + } else if (ruleType === 2) { + // 委托调剂:需要制剂、煎法、备注 + if (!processRuleId) { + return { + valid: false, + message: '请选择制剂' + }; + } + if (!childProcessRuleId) { + return { + valid: false, + message: '请选择煎法' + }; + } + if (!processRuleNoteId) { + return { + valid: false, + message: '请选择备注' + }; + } + } + + return { + valid: true, + message: '验证通过' + }; + } + + /** + * 验证药品是否已存在 + * @param {Array} drugs - 当前药品列表 + * @param {Object} newDrug - 新药品 + * @returns {boolean} 是否已存在 + */ + static isDrugExists(drugs, newDrug) { + if (!drugs || !newDrug) { + return false; + } + // 通过药品ID判断 + const drugId = newDrug.id || newDrug.drug_id; + return drugs.some(drug => (drug.id || drug.drug_id) === drugId); + } + + /** + * 验证诊断是否已存在 + * @param {Array} diagnoses - 当前诊断列表 + * @param {Object} newDiagnosis - 新诊断 + * @returns {boolean} 是否已存在 + */ + static isDiagnosisExists(diagnoses, newDiagnosis) { + if (!diagnoses || !newDiagnosis) { + return false; + } + // 通过诊断ID或名称判断 + const diagnosisId = newDiagnosis.id || newDiagnosis.disease_id; + const diagnosisName = newDiagnosis.name; + return diagnoses.some(diagnosis => + (diagnosis.id || diagnosis.disease_id) === diagnosisId || + diagnosis.name === diagnosisName + ); + } +} diff --git a/subPackages/sub_workbench/workbench_infoPatient.vue b/subPackages/sub_workbench/workbench_infoPatient.vue index 6db84ab..2b6b47d 100644 --- a/subPackages/sub_workbench/workbench_infoPatient.vue +++ b/subPackages/sub_workbench/workbench_infoPatient.vue @@ -168,6 +168,15 @@ + + + @@ -187,7 +196,7 @@ plain :ripple="true">结束问诊 开处方 @@ -212,7 +221,21 @@ id: "", re_id: "", show: false, - content: '确认结束本次问诊吗?' + content: '确认结束本次问诊吗?', + showVersionModal: false, + versionModalTitle: '选择开方版本', + versionList: [ + { + text: '开方2.0(新模块,推荐)', + value: 'v2', + color: '#6ACDBB' + }, + { + text: '旧版开方(原有模块)', + value: 'old', + color: '#333' + } + ] }; }, computed: { @@ -281,6 +304,31 @@ } }); }, + // 打开版本选择弹窗 + openPrescriptionVersionModal() { + this.showVersionModal = true; + }, + // 处理版本选择 + handleVersionSelect(index) { + const version = this.versionList[index]; + if (version.value === 'v2') { + this.goToPrescriptionV2(); + } else if (version.value === 'old') { + this.goToOldPrescription(); + } + }, + // 跳转到开方2.0页面 + goToPrescriptionV2() { + const registerId = this.infoList.id; + const patientId = this.infoList.user_patient_id; + this.$go(`/subPackages/sub_workbench/prescription_v2/index?register_id=${registerId}&patient_id=${patientId}`); + }, + // 跳转到旧版开方页面 + goToOldPrescription() { + const patientId = this.infoList.user_patient_id; + const registerId = this.infoList.id; + this.$go(`../../subPackages/sub_workbench/workbench_recipe/index?id=${patientId}&r_id=${registerId}`); + }, }, onShow() { this.getList(); diff --git a/utils/chat/chatRoomManager.js b/utils/chat/chatRoomManager.js new file mode 100644 index 0000000..7bc9483 --- /dev/null +++ b/utils/chat/chatRoomManager.js @@ -0,0 +1,424 @@ +/** + * 聊天室管理器 + * 封装聊天室相关操作,包括进入房间、离开房间、发送消息、接收消息、管理未读数等 + */ +import { chatConfig } from '@/config/chat.js'; +import { sendWebSocketMessage, isWebSocketConnected } from '@/utils/ws/initWebSocket.js'; +import { getChatRegisterInfoApi, getMessagesByRoomIdApi } from '@/request/api/im.js'; + +/** + * 聊天室管理器类 + */ +class ChatRoomManager { + constructor() { + // 当前房间ID + this.currentRoomId = null; + + // 最后一条消息ID(用于分页加载) + this.lastMessageId = 0; + + // 未读数(按房间ID存储) + this.unreadCounts = {}; + + // 消息缓存(按房间ID存储) + this.roomMessages = {}; + + // 消息处理器列表 + this.messageHandlers = []; + } + + /** + * 进入聊天室 + * @param {string} roomId - 房间ID + * 职责:设置当前房间,重置未读数,保存到本地存储 + */ + enterRoom(roomId) { + if (!roomId) { + console.warn('房间ID不能为空'); + return; + } + + this.currentRoomId = roomId; + uni.setStorageSync('currentRoomId', roomId); + this.resetUnreadCount(roomId); + + console.log('进入聊天室:', roomId); + } + + /** + * 离开聊天室 + * 职责:清空当前房间,移除本地存储 + */ + leaveRoom() { + this.currentRoomId = null; + uni.removeStorageSync('currentRoomId'); + console.log('离开聊天室'); + } + + /** + * 获取当前房间ID + * @returns {string|null} 当前房间ID + */ + getCurrentRoomId() { + return this.currentRoomId || uni.getStorageSync('currentRoomId'); + } + + /** + * 增加未读数 + * @param {string} roomId - 房间ID + * 职责:如果房间不是当前房间,则增加未读数并通知 + */ + increaseUnreadCount(roomId) { + if (!roomId) return; + + // 如果是当前房间,不增加未读数 + if (this.getCurrentRoomId() === roomId) { + return; + } + + this.unreadCounts[roomId] = (this.unreadCounts[roomId] || 0) + 1; + this.notifyUnreadChange(); + } + + /** + * 重置未读数 + * @param {string} roomId - 房间ID + * 职责:将指定房间的未读数重置为0并通知 + */ + resetUnreadCount(roomId) { + if (this.unreadCounts[roomId]) { + this.unreadCounts[roomId] = 0; + this.notifyUnreadChange(); + } + } + + /** + * 获取未读数 + * @param {string} roomId - 房间ID + * @returns {number} 未读数 + */ + getUnreadCount(roomId) { + return this.unreadCounts[roomId] || 0; + } + + /** + * 获取所有未读数总和 + * @returns {number} 总未读数 + */ + getAllUnreadCount() { + return Object.values(this.unreadCounts).reduce((sum, count) => sum + count, 0); + } + + /** + * 格式化未读数显示 + * @param {string} roomId - 房间ID + * @returns {string} 格式化后的未读数 + */ + getFormattedUnreadCount(roomId) { + const count = this.getUnreadCount(roomId); + return chatConfig.formatUnreadCount(count); + } + + /** + * 通知未读数变化 + * 职责:触发未读数更新事件 + */ + notifyUnreadChange() { + uni.$emit('unread-message-update', { + total: this.getAllUnreadCount(), + byRoom: { ...this.unreadCounts } + }); + } + + /** + * 添加消息到房间 + * @param {string} roomId - 房间ID + * @param {Object} message - 消息对象 + * 职责:将消息添加到指定房间的消息列表 + */ + addMessageToRoom(roomId, message) { + if (!roomId || !message) return; + + if (!this.roomMessages[roomId]) { + this.roomMessages[roomId] = []; + } + + // 检查消息是否已存在(避免重复) + const existingIndex = this.roomMessages[roomId].findIndex(m => m.id === message.id); + if (existingIndex !== -1) { + this.roomMessages[roomId][existingIndex] = message; + } else { + this.roomMessages[roomId].push(message); + } + } + + /** + * 获取房间消息列表 + * @param {string} roomId - 房间ID + * @returns {Array} 消息列表 + */ + getRoomMessages(roomId) { + return this.roomMessages[roomId] || []; + } + + /** + * 加载房间消息 + * @param {string} roomId - 房间ID + * @param {number} lastMessageId - 最后一条消息ID(可选,用于分页) + * @returns {Promise} 消息列表 + */ + async loadRoomMessages(roomId, lastMessageId = 0) { + if (!roomId) { + console.warn('房间ID不能为空'); + return []; + } + + try { + const res = await getMessagesByRoomIdApi({ + room_id: roomId, + last_message_id: lastMessageId || 0 + }); + + if (res && res.data && res.data.result) { + const messages = this.processMessages(res.data.result.list || []); + this.lastMessageId = res.data.result.last_message_id || 0; + + // 如果是首次加载,替换消息列表;否则追加到前面 + if (lastMessageId === 0) { + this.roomMessages[roomId] = messages; + } else { + this.roomMessages[roomId] = [...messages, ...(this.roomMessages[roomId] || [])]; + } + + uni.$emit('load-room-message', lastMessageId === 0 ? 1 : 0); + return messages; + } + + return []; + } catch (error) { + console.error('加载房间消息失败:', error); + return []; + } + } + + /** + * 处理消息数据 + * @param {Array|Object} messages - 消息数据(数组或单个消息) + * @returns {Array|Object} 处理后的消息 + */ + processMessages(messages) { + if (!Array.isArray(messages)) { + return this.processSingleMessage(messages); + } + + return messages.map((item) => { + const result = { + id: item.id, + sender_user_id: item.sender_user_id, + receiver_user_id: item.receiver_user_id, + message_type: item.message_type, + message_content: item.message_content, + duration: item.duration, + room_id: item.room_id, + created_at: item.created_at || item.send_time, + created_at_text: item.created_at_text || this.formatTime(item.send_time || item.created_at), + timestamp: item.send_time || item.created_at + }; + + // 如果是挂号消息(type=10),异步加载详细信息 + if (item.message_type === 10) { + this.loadRegisterInfo(result); + } + + return result; + }); + } + + /** + * 处理单个消息 + * @param {Object} message - 消息对象 + * @returns {Object} 处理后的消息 + */ + processSingleMessage(message) { + return { + id: message.id, + sender_user_id: message.sender_user_id, + receiver_user_id: message.receiver_user_id, + message_type: message.message_type, + message_content: message.message_content, + duration: message.duration, + room_id: message.room_id, + created_at: message.created_at || message.send_time, + created_at_text: message.created_at_text || this.formatTime(message.send_time || message.created_at), + timestamp: message.send_time || message.created_at + }; + } + + /** + * 格式化时间 + * @param {number} timestamp - 时间戳(秒) + * @returns {string} 格式化后的时间(HH:mm) + */ + formatTime(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + } + + /** + * 加载挂号信息(异步) + * @param {Object} message - 消息对象 + * 职责:为挂号消息加载详细信息 + */ + loadRegisterInfo(message) { + const parsedContent = this.parseMessageContent(message.message_content); + if (parsedContent._loaded || !parsedContent.id) return; + + getChatRegisterInfoApi({ + id: parsedContent.id, + message_id: message.id || '' + }).then((res) => { + if (res && res.data && res.data.result) { + const resultWithFlag = { ...res.data.result, _loaded: true }; + const roomId = message.room_id; + const roomMessages = this.roomMessages[roomId]; + + if (roomMessages) { + const index = roomMessages.findIndex(m => m.id === message.id); + if (index !== -1) { + roomMessages[index].message_content = JSON.stringify(resultWithFlag); + uni.$emit('message-updated', { roomId, messageId: message.id }); + } + } + } + }).catch(error => { + console.error('加载挂号信息失败:', error); + }); + } + + /** + * 解析消息内容 + * @param {string|Object} content - 消息内容 + * @returns {Object|string} 解析后的内容 + */ + parseMessageContent(content) { + try { + return typeof content === 'object' ? content : JSON.parse(content); + } catch (e) { + return content; + } + } + + /** + * 发送消息 + * @param {Object} messageData - 消息数据 + * @param {string} messageData.room_id - 房间ID + * @param {string} messageData.sender_user_id - 发送者ID + * @param {string} messageData.receiver_user_id - 接收者ID + * @param {number|string} messageData.message_type - 消息类型 + * @param {string} messageData.message_content - 消息内容 + * @param {number} messageData.duration - 语音时长(可选) + * @returns {boolean} 是否发送成功 + */ + sendMessage(messageData) { + if (!isWebSocketConnected()) { + console.error('WebSocket未连接,无法发送消息'); + return false; + } + + // 确保消息类型是数字 + let messageType = messageData.message_type; + if (typeof messageType === 'string') { + messageType = chatConfig.getMessageType(messageType); + } + + const message = { + request_type: 'send_message', + room_id: messageData.room_id, + sender_user_id: messageData.sender_user_id, + receiver_user_id: messageData.receiver_user_id, + message_type: messageType, + message_content: messageData.message_content || '', + duration: messageData.duration || 0 + }; + + return sendWebSocketMessage(message); + } + + /** + * 注册消息处理器 + * @param {Function} handler - 消息处理函数 + * 职责:注册消息处理器,当收到WebSocket消息时调用 + */ + onMessage(handler) { + if (typeof handler !== 'function') { + console.warn('消息处理器必须是函数'); + return; + } + + this.messageHandlers.push(handler); + } + + /** + * 移除消息处理器 + * @param {Function} handler - 消息处理函数 + */ + offMessage(handler) { + const index = this.messageHandlers.indexOf(handler); + if (index !== -1) { + this.messageHandlers.splice(index, 1); + } + } + + /** + * 处理收到的WebSocket消息 + * @param {Object} data - WebSocket消息数据 + * 职责:处理收到的消息,更新未读数,调用注册的处理器 + */ + handleWebSocketMessage(data) { + // 只处理聊天消息 + if (data.request_type !== 'receive_message' && !data.room_id) { + return; + } + + const roomId = data.room_id; + if (!roomId) return; + + // 处理消息 + const message = this.processSingleMessage(data); + + // 添加到房间消息列表 + this.addMessageToRoom(roomId, message); + + // 如果不是当前房间,增加未读数 + if (this.getCurrentRoomId() !== roomId) { + this.increaseUnreadCount(roomId); + } + + // 调用所有注册的处理器 + this.messageHandlers.forEach(handler => { + try { + handler(message, roomId); + } catch (error) { + console.error('消息处理器执行失败:', error); + } + }); + } + + /** + * 清空房间消息缓存 + * @param {string} roomId - 房间ID(可选,不传则清空所有) + */ + clearRoomCache(roomId = null) { + if (roomId) { + delete this.roomMessages[roomId]; + } else { + this.roomMessages = {}; + } + } +} + +// 导出单例 +export const chatRoomManager = new ChatRoomManager(); diff --git a/utils/ws/initWebSocket.js b/utils/ws/initWebSocket.js new file mode 100644 index 0000000..309b512 --- /dev/null +++ b/utils/ws/initWebSocket.js @@ -0,0 +1,118 @@ +/** + * WebSocket初始化工具 + * 统一管理WebSocket的初始化、连接、断开等操作 + */ +import { webSocketManager } from './websocket.js'; + +/** + * 检查是否已登录 + * @returns {boolean} 是否已登录 + */ +function checkLoginStatus() { + const token = uni.getStorageSync('token'); + const doctorId = uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id'); + return !!(token && doctorId); +} + +/** + * 初始化WebSocket + * 职责:检查登录状态,如果已登录则连接WebSocket并绑定用户 + * @returns {boolean} 是否初始化成功 + */ +export function initWebSocket() { + // 检查登录状态 + if (!checkLoginStatus()) { + console.log('未登录,跳过WebSocket初始化'); + return false; + } + + // 如果已经连接,不需要重复连接 + if (webSocketManager.isConnected) { + console.log('WebSocket已连接,跳过初始化'); + return true; + } + + try { + console.log('开始初始化WebSocket...'); + webSocketManager.connect(); + return true; + } catch (error) { + console.error('WebSocket初始化失败:', error); + return false; + } +} + +/** + * 断开WebSocket连接 + * 职责:断开WebSocket连接,清理资源 + */ +export function disconnectWebSocket() { + try { + console.log('断开WebSocket连接...'); + webSocketManager.disconnect(); + } catch (error) { + console.error('断开WebSocket失败:', error); + } +} + +/** + * 重新绑定用户 + * 职责:在token刷新或用户信息变更后,重新绑定用户 + * @returns {boolean} 是否绑定成功 + */ +export function rebindUser() { + if (!checkLoginStatus()) { + console.warn('未登录,无法重新绑定用户'); + return false; + } + + try { + console.log('重新绑定WebSocket用户...'); + return webSocketManager.rebindUser(); + } catch (error) { + console.error('重新绑定用户失败:', error); + return false; + } +} + +/** + * 获取WebSocket连接状态 + * @returns {boolean} 是否已连接 + */ +export function isWebSocketConnected() { + return webSocketManager.isConnected; +} + +/** + * 获取WebSocket认证状态 + * @returns {boolean} 是否已认证 + */ +export function isWebSocketAuthenticated() { + return webSocketManager.isAuthenticated; +} + +/** + * 添加消息处理器 + * @param {Function} handler - 消息处理函数 + */ +export function addMessageHandler(handler) { + webSocketManager.addMessageHandler(handler); +} + +/** + * 移除消息处理器 + * @param {Function} handler - 消息处理函数 + */ +export function removeMessageHandler(handler) { + webSocketManager.removeMessageHandler(handler); +} + +/** + * 发送WebSocket消息 + * @param {Object} message - 消息对象 + * @param {boolean} requireAuth - 是否需要认证 + * @returns {boolean} 是否发送成功 + */ +export function sendWebSocketMessage(message, requireAuth = true) { + return webSocketManager.send(message, requireAuth); +} diff --git a/utils/ws/websocket.js b/utils/ws/websocket.js new file mode 100644 index 0000000..1d1d294 --- /dev/null +++ b/utils/ws/websocket.js @@ -0,0 +1,314 @@ +/** + * WebSocket管理器 + * 负责WebSocket连接、消息收发、重连、心跳等功能 + */ +import { websocketConfig } from '@/config/websocket.js'; + +export class WebSocketManager { + constructor(url = null) { + // 使用配置文件中的URL,如果传入了url则使用传入的 + this.url = url || websocketConfig.getUrl(); + this.socket = null; + this.isConnected = false; + this.isAuthenticated = false; + this.reconnectAttempts = 0; + // 从配置文件读取重连配置 + this.maxReconnectAttempts = websocketConfig.reconnect.maxAttempts; + this.reconnectDelay = websocketConfig.reconnect.initialDelay; + this.maxReconnectDelay = websocketConfig.reconnect.maxDelay; + this.messageHandlers = []; + this.pingInterval = null; + this.cachedToken = null; + // 从配置文件读取用户配置 + this.userPrefix = websocketConfig.user.prefix; + this.platform = websocketConfig.user.platform; + } + + getToken() { + let token = uni.getStorageSync('token'); + if (token) { + token = token.replace('Bearer ', ''); + this.cachedToken = token; + } + return token || null; + } + + clearCachedToken() { + this.cachedToken = null; + } + + rebindUser() { + const userId = uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id'); + if (!userId) { + console.warn('重新绑定用户失败:doctor_id不存在'); + return false; + } + + this.clearCachedToken(); + + if (this.isConnected) { + return this.bindUser(userId, true); + } else { + this.connect(); + return true; + } + } + + connect() { + if (this.isConnected) return; + + console.log('正在连接WebSocket...'); + this.socket = uni.connectSocket({ + url: this.url, + success: () => { + console.log('WebSocket连接成功'); + }, + fail: (err) => { + console.error('WebSocket连接失败:', err); + this.handleReconnect(); + } + }); + + this.socket.onOpen(() => { + console.log('WebSocket连接已打开'); + this.isConnected = true; + this.reconnectAttempts = 0; + + this.startHeartbeat(); + + // 绑定当前用户(医生小程序使用doctor-前缀) + const userId = uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id'); + if (userId) { + this.bindUser(userId, true); + } + }); + + this.socket.onMessage((res) => { + try { + const data = JSON.parse(res.data); + console.log('收到消息:', data); + this.handleMessage(data); + } catch (e) { + console.error('解析消息失败:', e); + } + }); + + this.socket.onClose((res) => { + console.log('WebSocket连接关闭:', res); + this.isConnected = false; + this.stopHeartbeat(); + this.handleReconnect(); + }); + + this.socket.onError((err) => { + console.error('WebSocket错误:', err); + }); + } + + unbindUser(userId) { + const token = this.getToken(); + if (!token) { + console.warn('解绑用户失败:token不存在'); + return false; + } + // 使用配置文件中的用户前缀 + const normalizedUserId = userId.toString().startsWith(this.userPrefix) ? userId : `${this.userPrefix}${userId}`; + console.log('发送unbind请求,清理旧连接:', normalizedUserId); + return this.send({ + request_type: 'unbind', + user_type: 'doctor', + sender_user_id: normalizedUserId, + token: token + }, false); + } + + bindUser(userId, cleanOldConnections = true) { + const token = this.getToken(); + if (!token) { + console.error('绑定用户失败:token不存在'); + return false; + } + + // 使用配置文件中的用户前缀 + const normalizedUserId = userId.toString().startsWith(this.userPrefix) ? userId : `${this.userPrefix}${userId}`; + + // 构建bind请求对象 + const bindMessage = { + request_type: 'bind', + user_type: 'doctor', + sender_user_id: normalizedUserId, + token: token, + platform: this.platform, // 使用配置文件中的平台标识 + clean_old_connections: cleanOldConnections + }; + + // 输出详细的调试日志 + console.log('========== WebSocket Bind 请求 =========='); + console.log('用户ID:', normalizedUserId); + console.log('用户类型:', 'doctor'); + console.log('平台标识:', this.platform); + console.log('清理旧连接:', cleanOldConnections); + console.log('Token存在:', !!token); + console.log('完整请求内容:', JSON.stringify(bindMessage, null, 2)); + console.log('=========================================='); + + return this.send(bindMessage); + } + + send(message, requireAuth = true) { + if (!this.isConnected) { + console.error('WebSocket未连接'); + return false; + } + + try { + if (requireAuth && !message.token && message.request_type !== 'ping') { + const token = this.getToken(); + if (token) { + message.token = token; + } + } + + // 如果是发送消息,确保添加platform标识 + if (message.request_type === 'send_message' && !message.platform) { + message.platform = this.platform; + } + + const messageStr = JSON.stringify(message); + this.socket.send({ + data: messageStr, + success: () => { + console.log('消息发送成功:', message); + }, + fail: (err) => { + console.error('消息发送失败:', err); + } + }); + return true; + } catch (err) { + console.error('发送消息出错:', err); + return false; + } + } + + addMessageHandler(handler) { + this.messageHandlers.push(handler); + } + + removeMessageHandler(handler) { + const index = this.messageHandlers.indexOf(handler); + if (index !== -1) { + this.messageHandlers.splice(index, 1); + } + } + + handleMessage(data) { + if (data.auth_status !== undefined) { + if (data.auth_status === 'success') { + console.log('========== WebSocket 认证成功 =========='); + console.log('认证状态:', data.auth_status); + console.log('完整响应:', JSON.stringify(data, null, 2)); + console.log('========================================'); + this.isAuthenticated = true; + } else if (data.auth_status === 'failed') { + console.error('========== WebSocket 认证失败 =========='); + console.error('认证状态:', data.auth_status); + console.error('错误消息:', data.message || '未知错误'); + console.error('错误代码:', data.code || '无'); + console.error('完整响应:', JSON.stringify(data, null, 2)); + console.error('当前用户ID:', uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id')); + console.error('Token存在:', !!this.getToken()); + console.error('平台标识:', this.platform); + console.error('========================================'); + this.isAuthenticated = false; + this.cachedToken = null; + } else if (data.auth_status === 'token_expired') { + console.warn('========== Token 已过期 =========='); + console.warn('认证状态:', data.auth_status); + console.warn('错误消息:', data.message || 'Token已过期'); + console.warn('完整响应:', JSON.stringify(data, null, 2)); + console.warn('=================================='); + this.isAuthenticated = false; + this.cachedToken = null; + uni.$emit('ws-token-expired'); + const userId = uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id'); + if (userId) { + console.log('尝试重新绑定用户...'); + this.bindUser(userId, true); + } + } else { + console.warn('========== 未知的认证状态 =========='); + console.warn('认证状态:', data.auth_status); + console.warn('完整响应:', JSON.stringify(data, null, 2)); + console.warn('=================================='); + } + return; + } + + if (data.clientId) { + const userId = uni.getStorageSync('doctor_id') || uni.getStorageSync('user_id'); + if (userId) { + this.bindUser(userId, true); + } + } + + this.messageHandlers.forEach(handler => { + try { + handler(data); + } catch (e) { + console.error('消息处理出错:', e); + } + }); + } + + /** + * 启动心跳 + * 职责:按照配置的心跳间隔发送ping消息 + */ + startHeartbeat() { + this.pingInterval = setInterval(() => { + if (this.isConnected) { + this.send({ request_type: 'ping' }, false); + } + }, websocketConfig.heartbeat.interval); + } + + stopHeartbeat() { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + } + + /** + * 处理重连 + * 职责:按照配置的重连策略进行重连 + */ + handleReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log('已达到最大重连次数'); + return; + } + + setTimeout(() => { + this.reconnectAttempts++; + // 使用指数退避策略,但不超过最大延迟 + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); + console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`); + this.connect(); + }, this.reconnectDelay); + } + + disconnect() { + if (this.socket) { + this.socket.close(); + this.socket = null; + } + this.isConnected = false; + this.stopHeartbeat(); + } +} + +// 全局WebSocket实例 +// 使用配置文件中的URL +export const webSocketManager = new WebSocketManager();