diff --git a/.env.development b/.env.development index 9928b9d..9b23129 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,4 @@ MP_API_BASE_URL=http://localhost:8080 MP_CACHE_PREFIX=development MP_WX_APP_ID=wx93af55767423938e +MP_TIM_SDK_APP_ID=1600123876 diff --git a/.env.localhost b/.env.localhost index 24011fd..5058981 100644 --- a/.env.localhost +++ b/.env.localhost @@ -1,3 +1,4 @@ MP_API_BASE_URL=http://192.168.60.2:8080 MP_CACHE_PREFIX=development MP_WX_APP_ID=wx93af55767423938e +MP_TIM_SDK_APP_ID=1600123876 diff --git a/.hbuilderx/launch.json b/.hbuilderx/launch.json new file mode 100644 index 0000000..81f13f4 --- /dev/null +++ b/.hbuilderx/launch.json @@ -0,0 +1,16 @@ +{ // launch.json 配置了启动调试时相关设置,configurations下节点名称可为 app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/ + // launchtype项可配置值为local或remote, local代表前端连本地云函数,remote代表前端连云端云函数 + "version": "0.0", + "configurations": [{ + "default" : + { + "launchtype" : "local" + }, + "mp-weixin" : + { + "launchtype" : "local" + }, + "type" : "uniCloud" + } + ] +} diff --git a/package-lock.json b/package-lock.json index eae5086..db80b11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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==" } } } diff --git a/package.json b/package.json index e7519bb..aec6c20 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -20,7 +22,7 @@ "UNI_PLATFORM": "mp-weixin" } }, - "localhost": { + "localhost": { "title": "本地", "env": { "UNI_PLATFORM": "mp-weixin" @@ -29,4 +31,4 @@ } }, "devDependencies": {} -} +} \ No newline at end of file diff --git a/pages.json b/pages.json index d5f4544..fe4f1ed 100644 --- a/pages.json +++ b/pages.json @@ -7,6 +7,19 @@ "navigationStyle": "custom" } }, + { + "path": "pages/message/message", + "style": { + "navigationBarTitleText": "消息" + } + }, + { + "path": "pages/message/index", + "style": { + "navigationBarTitleText": "聊天", + "enablePullDownRefresh": false + } + }, { "path": "pages/login/login", "style": { diff --git a/pages/home/consult.vue b/pages/home/consult.vue new file mode 100644 index 0000000..a02c910 --- /dev/null +++ b/pages/home/consult.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/pages/home/customer-archive.vue b/pages/home/customer-archive.vue index 6b8157f..bf01873 100644 --- a/pages/home/customer-archive.vue +++ b/pages/home/customer-archive.vue @@ -86,6 +86,8 @@ const props = defineProps({ } }) +const emit = defineEmits(['update:customers']); + const { account } = storeToRefs(useAccount()); const current = ref(null); const customers = ref([]); @@ -144,6 +146,9 @@ async function getCustomers() { customers.value = res && Array.isArray(res.data) ? res.data : []; const customer = customers.value.find(i => current.value && i._id === current.value._id); current.value = customer || customers.value[0] || null; + + // 向父组件传递 customers 数据 + emit('update:customers', customers.value); } else { toast(res.message || '获取档案失败'); } diff --git a/pages/home/home.vue b/pages/home/home.vue index 89165a9..d84891e 100644 --- a/pages/home/home.vue +++ b/pages/home/home.vue @@ -5,7 +5,8 @@ - + + @@ -21,6 +22,7 @@ import { toast } from '@/utils/widget'; import FullPage from '@/components/full-page.vue'; import articleList from './article-list.vue'; +import consult from './consult.vue'; import customerArchive from './customer-archive.vue'; import teamHead from './team-head.vue'; import teamMate from './team-mate.vue'; @@ -32,10 +34,15 @@ const { account } = storeToRefs(useAccount()); const team = ref(null); const teams = ref([]); -const loading = ref(true) +const loading = ref(true); +const customers = ref([]); const corpId = computed(() => team.value?.corpId); +function handleCustomersUpdate(newCustomers) { + customers.value = newCustomers; +} + async function changeTeam({ teamId, corpId, corpName }) { loading.value = true; const res = await api('getTeamData', { teamId, corpId }); diff --git a/pages/home/select-consultant-popup.vue b/pages/home/select-consultant-popup.vue new file mode 100644 index 0000000..eee752a --- /dev/null +++ b/pages/home/select-consultant-popup.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/pages/message/article-detail.vue b/pages/message/article-detail.vue new file mode 100644 index 0000000..e6bfb76 --- /dev/null +++ b/pages/message/article-detail.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/pages/message/article-list.vue b/pages/message/article-list.vue new file mode 100644 index 0000000..3c54a8f --- /dev/null +++ b/pages/message/article-list.vue @@ -0,0 +1,588 @@ + + + + + diff --git a/pages/message/chat.scss b/pages/message/chat.scss new file mode 100644 index 0000000..d35fe0c --- /dev/null +++ b/pages/message/chat.scss @@ -0,0 +1,1360 @@ +// SCSS 变量定义 +$font-size-text: 28rpx; +$font-size-tip: 24rpx; +$font-size-title: 32rpx; +$text-color-sub: #999; +$primary-color: #0877F1; + +.chat-page { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + background-color: #f5f5f5; +} + +.chat-content { + flex: 1; + box-sizing: border-box; + overflow-x: hidden; +} + +.chat-content-compressed { + height: calc(100vh - 600rpx); +} + +.waiting-consultation-card { + position: relative; + z-index: 2; + margin-top: 32rpx; + background: #fff; + border-radius: 16rpx; + display: flex; + flex-direction: column; + align-items: center; +} + +.waiting-card-main { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.waiting-illust { + width: 72rpx; + height: 72rpx; + border-radius: 16rpx; + background: #f5f7fb; + object-fit: contain; + margin-right: 16rpx; +} + +.waiting-card-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-width: 0; +} + +.waiting-title { + font-size: $font-size-text; + color: #000; + font-weight: 600; + margin-bottom: 8rpx; + line-height: 1.2; + display: flex; + align-items: center; +} + +.waiting-desc { + color: #78808f; + font-size: $font-size-text; + margin: 32rpx; + line-height: 24px; +} + +.waiting-btn-wrap { + width: 100%; + box-sizing: border-box; + padding: 0 32rpx; + margin-bottom: 42rpx; + display: flex; + justify-content: center; +} + +.waiting-btn { + width: 100%; + background: linear-gradient(270deg, #1b5cc8 2.26%, #0877f1 94.33%); + color: #fff; + font-size: 28rpx; + font-weight: 600; + border-radius: 40rpx; + height: 80rpx; + line-height: 80rpx; + box-shadow: 0 2rpx 8rpx rgba(59, 124, 255, 0.08); + border: none; + text-align: center; + padding: 0; +} + +.doctor-avatar-outer { + margin-left: 48rpx; + position: relative; +} + +.doctor-avatar { + position: absolute; + bottom: -10rpx; + width: 140rpx; + height: 140rpx; + border-radius: 140rpx; + box-shadow: 0 4px 7.3px 0 #00000014; + background: #fff; + margin-left: 24rpx; +} + +.system-message { + border-radius: 16rpx; + padding: 12rpx; + margin: 20rpx 24rpx; + text-align: center; + position: relative; + z-index: 2; +} + +.system-text { + color: $text-color-sub; + font-size: $font-size-tip; + line-height: 1.4; +} + +.message-list { + padding: 0 16rpx; + padding-bottom: 60rpx; /* 增加底部内边距,防止被小程序底部横线遮挡 */ +} + +.message-item { + margin-bottom: 30rpx; +} + +.message-content { + display: flex; + justify-content: flex-start; + align-items: flex-start; + gap: 20rpx; + max-width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.message-right .message-content { + flex-direction: row-reverse; +} + +.doctor-msg-avatar { + width: 60rpx; + height: 60rpx; + border-radius: 50%; + margin-top: 10rpx; +} + +.user-msg-avatar { + width: 60rpx; + height: 60rpx; + border-radius: 50%; + margin-top: 10rpx; +} + +// 消息气泡容器 +.message-bubble-container { + display: flex; + flex-direction: column; + max-width: 70%; +} + +// 用户名标签样式 +.username-label { + margin-bottom: 6rpx; + + &.left { + text-align: left; + } + + &.right { + text-align: right; + } +} + +.username-text { + font-size: 22rpx; + color: #999; + line-height: 1.2; +} + +.message-bubble { + min-width: 60rpx; + padding: 12rpx 16rpx; + border-radius: 18rpx; + position: relative; + word-wrap: break-word; + word-break: break-all; + overflow-wrap: break-word; +} + +.doctor-bubble { + background-color: white; + color: #333; +} + +.doctor-bubble::before { + content: ""; + position: absolute; + top: 18rpx; + left: -18rpx; + width: 0; + height: 0; + border-left: 10rpx solid transparent; + border-bottom: 10rpx solid transparent; + border-right: 10rpx solid white; + border-top: 10rpx solid transparent; +} + +.user-bubble { + background-color: #0877F1; + color: white; +} + +.user-bubble::after { + content: ""; + position: absolute; + top: 18rpx; + right: -18rpx; + width: 0; + height: 0; + border-left: 10rpx solid #0877F1; + border-bottom: 10rpx solid transparent; + border-right: 10rpx solid transparent; + border-top: 10rpx solid transparent; +} + +/* 医生发送的蓝色气泡(用于消息卡片) */ +.doctor-bubble-blue { + background-color: #0877F1; + color: white; +} + +.doctor-bubble-blue::before { + content: ""; + position: absolute; + top: 18rpx; + left: -18rpx; + width: 0; + height: 0; + border-left: 10rpx solid transparent; + border-bottom: 10rpx solid transparent; + border-right: 10rpx solid #0877F1; + border-top: 10rpx solid transparent; +} + +.message-text { + font-size: $font-size-text; + line-height: 1.4; + word-wrap: break-word; + word-break: break-all; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.message-time { + font-size: $font-size-tip; + opacity: 0.7; + margin-top: 4rpx; + text-align: right; +} + +.message-system { + text-align: center; + margin: 8rpx 0; +} + +.input-section { + background: #fff; + border-top: 1rpx solid #e0e0e0; + position: relative; + z-index: 200; // 确保输入区域在评价卡片之上,但在弹窗之下 + // padding: 32rpx 16rpx 28rpx 16rpx; +} + +.input-toolbar { + display: flex; + align-items: center; + padding: 28rpx 0 28rpx 0; +} +.voice-toggle-btn{ + height: 56rpx; + display: flex; + align-items: center; + flex: none; + padding-left: 20rpx; + +} +.plus-btn { + width: 56rpx; + height: 56rpx; + display: flex; + align-items: center; + flex: none; + padding-right: 20rpx; +} + +.send-btn { + background: #3876f6; + color: #fff; + font-size: 28rpx; + font-weight: 600; + border: none; + border-radius: 40rpx; + height: 64rpx; + min-width: 112rpx; + padding: 0 32rpx; + margin-left: 16rpx; + box-shadow: 0 2rpx 8rpx rgba(56, 118, 246, 0.08); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + flex: none; +} + +.send-btn:active { + background: #2456c7; +} + +.input-area { + flex: 1; + margin: 0 8rpx; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; +} + +.text-input, +.voice-input-btn { + flex: 1; + padding: 0 46rpx; + background-color: #f3f5fa; + border-radius: 20rpx; + margin: 0 16rpx; + font-size: 28rpx; + height: 80rpx; + border: none; + outline: none; + box-sizing: border-box; + display: flex; + align-items: center; + line-height: 96rpx; + color: #333; +} + +.voice-input-btn { + text-align: center; +} + +.more-panel { + display: flex; + justify-content: flex-start; + background: #fff; + border-top: 1rpx solid #eee; + padding: 20rpx 0 40rpx 60rpx; + gap: 40rpx 50rpx; + flex-wrap: wrap; + background-color: #f5f5f5; +} + +.more-btn { + flex: none; + width: 120rpx; + display: flex; + flex-direction: column; + align-items: center; +} + +.more-icon { + width: 108rpx; + height: 108rpx; + border-radius: 16rpx; +} + +.more-btn text { + margin-top: 20rpx; + font-size: $font-size-text; + color: #6b7a8b; +} + +.consultation-bar { + background-color: white; + border-top: 1rpx solid #e0e0e0; + padding: 16rpx; + margin-bottom: env(safe-area-inset-bottom); + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 -2rpx 8rpx rgba(0, 0, 0, 0.1); + border-radius: 16rpx; + z-index: 10; // 降低 z-index,避免遮挡弹窗组件 +} + +.consultation-info { + display: flex; + align-items: center; + flex: 1; +} + +.consultation-icon { + margin-right: 12rpx; +} + +.consultation-details { + display: flex; + flex-direction: column; +} + +.consultation-title { + color: #333; + font-size: $font-size-text; + font-weight: 600; + margin-bottom: 4rpx; +} + +.consultation-price { + color: #ff3b30; + font-size: $font-size-tip; + font-weight: 500; +} + +.consultation-btn { + background-color: $primary-color; + color: white; + border: none; + border-radius: 16rpx; + height: 88rpx; + font-size: $font-size-title; + font-weight: 400; + transition: all 0.3s; +} + +.consultation-btn:active { + background-color: $primary-color; + transform: scale(0.98); +} + +.consultation-btn::after { + border: none; +} + +/* 评价弹窗包裹器 - 最高优先级 */ +.evaluation-popup-wrapper { + z-index: 100000 !important; +} + +/* 深度选择器,确保 uni-popup 内部元素也应用最高 z-index */ +:deep(.evaluation-popup-wrapper) { + z-index: 100000 !important; +} + +:deep(.evaluation-popup-wrapper .uni-popup) { + z-index: 100000 !important; +} + +:deep(.evaluation-popup-wrapper .uni-popup__wrapper) { + z-index: 100000 !important; +} + +:deep(.evaluation-popup-wrapper .uni-popup__mask) { + z-index: 99999 !important; +} + +.evaluation-card { + background-color: white; + border-radius: 12rpx; + margin: 16rpx; + padding: 16rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); + border: 1rpx solid #e3f2fd; + position: relative; + z-index: 10; // 确保卡片在聊天内容之上,但低于弹出框遮罩层 +} + +.evaluation-content { + display: flex; + align-items: center; +} + +.evaluation-icon { + margin-right: 12rpx; +} + +.evaluation-info { + flex: 1; +} + +.evaluation-title { + color: #333; + font-size: $font-size-text; + font-weight: 600; + display: block; + margin-bottom: 4rpx; +} + +.evaluation-subtitle { + color: #666; + font-size: $font-size-tip; +} + +.evaluation-btn { + background-color: $primary-color; + border-radius: 16rpx; + padding: 12rpx 20rpx; +} + +.btn-text { + color: white; + font-size: $font-size-text; + font-weight: 400; +} + +.evaluation-popup-container { + width: 100vw; + margin: 0; + padding: 0; + box-sizing: border-box; + position: relative; + z-index: 100001 !important; // 确保弹窗在最上层,使用 !important 覆盖其他样式 +} + +/* 评价弹窗样式 */ +.evaluation-popup { + background-color: white; + border-top-left-radius: 16rpx; + border-top-right-radius: 16rpx; + width: 100%; + max-height: 80vh; + padding: 20rpx; + padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); + position: relative; + box-sizing: border-box; + margin: 0; + z-index: 100002 !important; // 确保弹窗内容在最上层 +} + +.popup-indicator { + width: 40rpx; + height: 4rpx; + background-color: #e0e0e0; + border-radius: 2rpx; + margin: 0 auto 16rpx; +} + +.popup-close { + position: absolute; + top: 16rpx; + right: 16rpx; + z-index: 10; +} + +.doctor-info-section { + text-align: center; + margin-bottom: 24rpx; +} + +.doctor-avatar-large { + width: 60rpx; + height: 60rpx; + border-radius: 50%; + margin-bottom: 12rpx; +} + +.doctor-name { + display: block; + color: #333; + font-size: $font-size-text; + font-weight: 500; + margin-bottom: 4rpx; +} + +.doctor-dept { + color: $text-color-sub; + font-size: $font-size-tip; +} + +.rating-section { + text-align: center; + margin-bottom: 24rpx; + display: flex; + flex-direction: column; + align-items: center; +} + +/* uni-rate 包装器样式 */ +.rate-wrapper { + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +/* uni-rate 样式调整 */ +.rating-section .uni-rate { + display: flex !important; + justify-content: center !important; + align-items: center !important; +} + +.rating-text { + display: block; + font-size: $font-size-text; + font-weight: 600; + margin-top: 12rpx; + color: $primary-color; +} + +/* 当评分为0时的提示样式 */ +.rating-text.no-rating { + color: #999; +} + +.comment-section { + margin-bottom: 24rpx; + position: relative; +} + +.evaluation-textarea { + width: 100%; + min-height: 120rpx; + background-color: #f8f9fa; + border: 1rpx solid #e9ecef; + border-radius: 16rpx; + padding: 12rpx; + font-size: $font-size-tip; + line-height: 1.5; + box-sizing: border-box; + resize: none; + margin: 0; +} + +.char-count { + position: absolute; + bottom: 8rpx; + right: 12rpx; + color: #999; + font-size: $font-size-tip; +} + +.evaluation-footer { + text-align: center; +} + +.submit-evaluation-btn { + background-color: $primary-color; + color: white; + border: none; + border-radius: 16rpx; + padding: 12rpx 40rpx; + font-size: $font-size-text; + font-weight: 600; + width: 100%; + box-sizing: border-box; + margin: 0; +} + +/* 评价成功弹窗样式 */ +.success-popup { + background-color: white; + width: auto; + padding: 32rpx 20rpx 48rpx 20rpx; + padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); + text-align: center; + margin: 0 auto; + position: relative; + z-index: 100002 !important; // 确保弹窗在最上层 +} + +.success-icon { + margin-bottom: 16rpx; +} + +.success-title { + display: block; + color: #333; + font-size: $font-size-text; + font-weight: 500; + margin-bottom: 8rpx; +} + +.success-eval-box { + background: #f5f5f5; + border-radius: 16rpx; + box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08); + padding: 32rpx 20rpx 24rpx 20rpx; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; +} + +.success-subtitle { + color: #666; + font-size: $font-size-tip; + margin-bottom: 20rpx; +} + +.success-rating { + margin-bottom: 16rpx; +} + +.success-rating .uni-rate { + display: flex; + justify-content: center; + align-items: center; +} + +.success-rating-text { + display: block; + color: $primary-color; + font-size: $font-size-tip; + font-weight: 500; + margin-top: 8rpx; +} + +.success-comment { + background-color: #f8f9fa; + border-radius: 16rpx; + padding: 12rpx; + margin-top: 16rpx; + width: 100%; + box-sizing: border-box; + word-break: break-all; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.comment-text { + color: $text-color-sub; + font-size: $font-size-tip; + line-height: 1.4; + word-break: break-all; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.success-header-row { + display: flex; + align-items: center; + justify-content: center; + gap: 12rpx; + margin-bottom: 8rpx; +} + +.success-icon { + margin-bottom: 0; +} + +.success-title { + display: inline-block; + color: #333; + font-size: $font-size-text; + font-weight: 500; + margin-bottom: 0; +} + +/* 导航栏下方添加倒计时条 */ +.consult-countdown-bar { + position: fixed; + top: 0; // 导航栏高度,根据实际情况调整 + left: 0; + right: 0; + width: 100%; + background: #ffd6d6; + color: #e53935; + text-align: center; + padding: 12rpx 0; + font-size: $font-size-text; + font-weight: 500; + border-bottom: none; + z-index: 100; // 确保悬浮在聊天内容上方 + box-shadow: 0 2rpx 8rpx rgba(229, 57, 53, 0.1); // 添加轻微阴影,增强悬浮效果 +} + +.countdown-text { + color: #e53935; +} + +.waiting-section { + position: relative; + width: 100%; + overflow: hidden; + margin-top: -32rpx; +} + +.waiting-bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 200rpx; + background-color: #0976f0; + border-bottom-left-radius: 50rpx; + border-bottom-right-radius: 50rpx; + z-index: 1; +} + +.waiting-card-outer { + position: relative; + z-index: 2; + margin: 0 24rpx; + margin-top: 0; + padding-bottom: 20rpx; +} + +.hospital-name { + color: #fff; + font-size: $font-size-text; + font-weight: 500; + line-height: 48rpx; + margin: 32rpx 0 16rpx 8rpx; + position: relative; + z-index: 2; +} + +/* 加载更多提示样式 */ +.load-more-tip { + padding: 20rpx; + text-align: center; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12rpx; +} + +.loading-icon { + font-size: 28rpx; + animation: rotate 1s linear infinite; +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.loading-text { + font-size: 24rpx; + color: #999; +} + +.load-tip { + display: flex; + align-items: center; + justify-content: center; +} + +.tip-text { + font-size: 24rpx; + color: #999; +} + +.completed-text { + font-size: 24rpx; + color: #ccc; +} + +.waiting-card-row { + display: flex; + align-items: center; + width: 100%; + height: 140rpx; + background: linear-gradient(-30.3deg, #cbdaff 0%, #e8f3ff 100%); + border-top-left-radius: 16rpx; + border-top-right-radius: 16rpx; +} + +.waiting-illust { + width: 116rpx; + height: 120rpx; + background: transparent; + object-fit: contain; + margin: 10rpx 18rpx; +} + +.waiting-card-center { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0 8rpx; +} + +.waiting-desc { + color: #999; + font-size: 24rpx; + margin-bottom: 24rpx; + line-height: 1.4; + text-align: center; +} + +.text-input { + color: #000; +} + +.text-input::-webkit-input-placeholder, +.text-input:-moz-placeholder, +.text-input::-moz-placeholder, +.text-input:-ms-input-placeholder, +.text-input::placeholder { + line-height: 96rpx; +} + +/* 时间分割线 */ +.time-divider { + text-align: center; + margin: 20rpx 0; +} + +.time-text { + color: #999; + font-size: 22rpx; +} + +/* 消息状态 */ +.message-status { + margin-left: 12rpx; + align-self: flex-end; +} + +.status-text { + font-size: 22rpx; + color: #999; +} + +.status-text.failed { + color: #ff4d4f; +} + +.voice-message { + min-width: 100rpx; + padding: 0rpx 8rpx; + border-radius: 16rpx; + transition: all 0.2s; + display: flex; + + &.voice-playing { + transform: scale(1.02); + } +} + +.voice-message:active { + background-color: rgba(255, 255, 255, 0.2); +} + +.voice-content { + display: flex; + justify-content: center; + align-items: center; + gap: 8rpx; +} + +.voice-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + gap: 8rpx; + position: relative; +} + +.icon-animate { + animation: voice-icon-pulse 1.5s ease-in-out infinite; +} + +@keyframes voice-icon-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } +} + +.sound-wave { + display: flex; + align-items: center; + gap: 4rpx; + margin-left: 4rpx; +} + +.wave-bar { + width: 4rpx; + height: 24rpx; + border-radius: 2rpx; + animation: wave-animation 1s ease-in-out infinite; +} + +@keyframes wave-animation { + 0%, 100% { + height: 8rpx; + } + 50% { + height: 24rpx; + } +} + +.voice-duration { + font-size: 24rpx; + color: inherit; +} + +/* 图片消息样式 */ +.message-image { + max-width: 400rpx; /* 限制最大宽度 */ + max-height: 400rpx; /* 限制最大高度,参考微信 */ + min-height: 120rpx; /* 设置最小高度 */ + min-width: 200rpx; /* 设置最小宽度 */ + border-radius: 12rpx; + object-fit: cover; /* 保持图片比例,裁剪多余部分 */ +} + +/* 图片消息气泡 - 无背景色 */ +.image-bubble { + background: transparent !important; + padding: 0 !important; + border-radius: 0 !important; +} + +/* 移除图片消息气泡的小三角 */ +.image-bubble::before, +.image-bubble::after { + display: none !important; +} + +.message-right .message-card { + margin-right: 8rpx; +} + +.message-left .message-card { + margin-left: 8rpx; +} + +.card-avatar-row { + display: flex; + align-items: flex-start; + gap: 8rpx; +} + +.test-load-btn { + position: fixed; + bottom: 200rpx; + left: 50%; + transform: translateX(-50%); + background-color: $primary-color; + color: white; + font-size: 28rpx; + font-weight: 600; + border-radius: 40rpx; + height: 80rpx; + line-height: 80rpx; + padding: 0 40rpx; + box-shadow: 0 2rpx 8rpx rgba(56, 118, 246, 0.08); + z-index: 10; +} + +.test-btn-text { + color: white; +} + +/* 录音遮罩层样式 */ +.recording-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; // 录音遮罩层低于评价弹窗(99999) + animation: fadeIn 0.2s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.recording-modal { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(40, 40, 40, 0.95); + border-radius: 32rpx; + padding: 80rpx 100rpx; + backdrop-filter: blur(10rpx); + animation: scaleIn 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); + transition: all 0.3s ease; +} + +.recording-modal.cancel-mode { + background: rgba(60, 60, 60, 0.95); + border: 4rpx solid #ff4757; +} + +@keyframes scaleIn { + 0% { + transform: scale(0.5); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.recording-icon-container { + position: relative; + width: 200rpx; + height: 200rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 40rpx; +} + +/* 波纹动画 */ +.wave-circle { + position: absolute; + width: 200rpx; + height: 200rpx; + border-radius: 50%; + border: 4rpx solid rgba(255, 71, 87, 0.8); + animation: wave 1.5s ease-out infinite; +} + +.wave-1 { animation-delay: 0s; } +.wave-2 { animation-delay: 0.5s; } +.wave-3 { animation-delay: 1s; } + +@keyframes wave { + 0% { + transform: scale(0.5); + opacity: 1; + } + 100% { + transform: scale(1.5); + opacity: 0; + } +} + +.mic-icon-wrapper { + position: relative; + z-index: 10; + width: 120rpx; + height: 120rpx; + background: linear-gradient(135deg, #ff4757 0%, #ff6b7a 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 24rpx rgba(255, 71, 87, 0.4); + animation: pulse 1.5s ease-in-out infinite; + transition: all 0.3s ease; +} + +.mic-icon-wrapper.cancel-icon { + background: linear-gradient(135deg, #666 0%, #888 100%); + animation: shake 0.5s ease-in-out infinite; + box-shadow: 0 8rpx 24rpx rgba(102, 102, 102, 0.4); +} + +@keyframes shake { + 0%, 100% { transform: rotate(-5deg); } + 50% { transform: rotate(5deg); } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +.recording-text { + font-size: 32rpx; + color: #fff; + font-weight: 600; + margin-bottom: 20rpx; + letter-spacing: 2rpx; + transition: all 0.3s ease; +} + +.recording-text.cancel-text { + color: #ff4757; + font-size: 34rpx; +} + +.recording-hint { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 24rpx; +} + +.recording-duration { + font-size: 48rpx; + color: #ff4757; + font-weight: bold; + letter-spacing: 2rpx; + text-shadow: 0 2rpx 8rpx rgba(255, 71, 87, 0.3); +} + +.cancel-hint { + display: flex; + align-items: center; + gap: 12rpx; + font-size: 28rpx; + color: #ff4757; + font-weight: 600; + margin-top: 20rpx; + animation: bounce 0.8s ease-in-out infinite; +} + +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10rpx); } +} + +/* 文章卡片样式 */ +.article-card { + display: flex; + align-items: center; + background-color: transparent; + border-radius: 12rpx; + padding: 24rpx; + max-width: 500rpx; + box-shadow: none; + background-color: #fff; +} + +.article-content { + flex: 1; + display: flex; + flex-direction: column; + margin-right: 20rpx; + min-width: 0; +} + +.article-title { + font-size: 28rpx; + color: #333; + font-weight: 500; + line-height: 1.4; + margin-bottom: 8rpx; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.article-desc { + font-size: 24rpx; + color: #999; + line-height: 1.3; +} + +.article-image { + width: 120rpx; + height: 120rpx; + border-radius: 8rpx; + flex-shrink: 0; +} + +/* 文章卡片在不同消息流中的样式 */ +.message-right .article-card { + background-color: transparent; +} + +.message-left .article-card { + background-color: transparent; +} + +/* 问卷卡片样式 */ +.survey-card { + display: flex; + align-items: center; + background-color: #fff; + border-radius: 12rpx; + padding: 24rpx; + max-width: 500rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08); +} + +.survey-content { + flex: 1; + display: flex; + flex-direction: column; + margin-right: 20rpx; + min-width: 0; +} + +.survey-title { + font-size: 28rpx; + color: #333; + font-weight: 500; + line-height: 1.4; + margin-bottom: 8rpx; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.survey-desc { + font-size: 24rpx; + color: #999; + line-height: 1.3; +} + +.survey-image { + width: 120rpx; + height: 120rpx; + border-radius: 8rpx; + flex-shrink: 0; +} + +/* 问卷卡片在不同消息流中的样式 */ +.message-right .survey-card { + background-color: #e8f4ff; +} + +.message-left .survey-card { + background-color: #fff; +} diff --git a/pages/message/common-phrases.vue b/pages/message/common-phrases.vue new file mode 100644 index 0000000..3f5bde1 --- /dev/null +++ b/pages/message/common-phrases.vue @@ -0,0 +1,1058 @@ + + + + + diff --git a/pages/message/components/chat-input.vue b/pages/message/components/chat-input.vue new file mode 100644 index 0000000..72024ec --- /dev/null +++ b/pages/message/components/chat-input.vue @@ -0,0 +1,454 @@ + + + + + \ No newline at end of file diff --git a/pages/message/components/consult-accept.vue b/pages/message/components/consult-accept.vue new file mode 100644 index 0000000..c593baa --- /dev/null +++ b/pages/message/components/consult-accept.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/pages/message/components/consult-cancel.vue b/pages/message/components/consult-cancel.vue new file mode 100644 index 0000000..d3d334c --- /dev/null +++ b/pages/message/components/consult-cancel.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/pages/message/components/head-card.vue b/pages/message/components/head-card.vue new file mode 100644 index 0000000..568b64e --- /dev/null +++ b/pages/message/components/head-card.vue @@ -0,0 +1,59 @@ + + + \ No newline at end of file diff --git a/pages/message/components/message-types.vue b/pages/message/components/message-types.vue new file mode 100644 index 0000000..4c19114 --- /dev/null +++ b/pages/message/components/message-types.vue @@ -0,0 +1,282 @@ + + + + + \ No newline at end of file diff --git a/pages/message/components/reject-reason-modal.vue b/pages/message/components/reject-reason-modal.vue new file mode 100644 index 0000000..df589f8 --- /dev/null +++ b/pages/message/components/reject-reason-modal.vue @@ -0,0 +1,297 @@ + + + + + \ No newline at end of file diff --git a/pages/message/components/special-message/index.vue b/pages/message/components/special-message/index.vue new file mode 100644 index 0000000..f71641d --- /dev/null +++ b/pages/message/components/special-message/index.vue @@ -0,0 +1,40 @@ + + \ No newline at end of file diff --git a/pages/message/components/system-message.vue b/pages/message/components/system-message.vue new file mode 100644 index 0000000..de4a907 --- /dev/null +++ b/pages/message/components/system-message.vue @@ -0,0 +1,158 @@ + + + + + \ 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/pages/message/index.vue b/pages/message/index.vue new file mode 100644 index 0000000..8cb1442 --- /dev/null +++ b/pages/message/index.vue @@ -0,0 +1,931 @@ + + + + + diff --git a/pages/message/message.vue b/pages/message/message.vue index fb6b3e7..a9a9e3c 100644 --- a/pages/message/message.vue +++ b/pages/message/message.vue @@ -1,9 +1,539 @@ - - - - \ No newline at end of file + + diff --git a/pages/message/survey-list.vue b/pages/message/survey-list.vue new file mode 100644 index 0000000..53a5261 --- /dev/null +++ b/pages/message/survey-list.vue @@ -0,0 +1,446 @@ + + + + + diff --git a/store/account.js b/store/account.js index 58ddeed..5b5e04f 100644 --- a/store/account.js +++ b/store/account.js @@ -2,6 +2,7 @@ import { ref } from "vue"; import { defineStore } from "pinia"; import api from '@/utils/api'; import { toast } from '@/utils/widget'; +import { initGlobalTIM, globalTimChatManager } from "@/utils/tim-chat.js"; const env = __VITE_ENV__; @@ -9,8 +10,10 @@ export default defineStore("accountStore", () => { const appid = env.MP_WX_APP_ID; const account = ref(null); const loading = ref(false) - + const isIMInitialized = ref(false); + const openid = ref(""); async function login(phoneCode = '') { + if (loading.value) return; loading.value = true; @@ -28,6 +31,8 @@ export default defineStore("accountStore", () => { loading.value = false if (res.success && res.data && res.data.mobile) { account.value = res.data; + openid.value = res.data.openid; + initIMAfterLogin(openid.value) return res.data } } @@ -38,5 +43,37 @@ export default defineStore("accountStore", () => { loading.value = false } - return { account, login } + async function initIMAfterLogin(userID) { + if (isIMInitialized.value) { + return true; + } + try { + await initGlobalTIM(userID); + isIMInitialized.value = true; + return true; + } catch (error) { + console.error('IM初始化失败:', error); + return false; + } + } + // 退出登录 + async function logout() { + try { + // 退出腾讯IM + if (globalTimChatManager && globalTimChatManager.tim) { + console.log('开始退出腾讯IM'); + await globalTimChatManager.destroy(); + console.log('腾讯IM退出成功'); + } + } catch (error) { + console.error('退出腾讯IM失败:', error); + } + + // 清空账户信息 + account.value = null; + openid.value = ""; + isIMInitialized.value = false; + } + + return { account, login, initIMAfterLogin, logout, openid, isIMInitialized } }) \ No newline at end of file diff --git a/utils/api.js b/utils/api.js index 1c54db8..42e729c 100644 --- a/utils/api.js +++ b/utils/api.js @@ -14,7 +14,23 @@ const urlsConfig = { knowledgeBase: { getArticleByIds: 'getArticleByIds', - getPageDisease: "getPageDisease" + getCommonPhrases: 'getCommonPhrases', + saveCommonPhrase: 'saveCommonPhrase', + deleteCommonPhrase: 'deleteCommonPhrase', + getCommonPhraseCategories: 'getCommonPhraseCategories', + saveCommonPhraseCategory: 'saveCommonPhraseCategory', + // 个人常用语接口 + getPersonalPhrases: 'getPersonalPhrases', + savePersonalPhrase: 'savePersonalPhrase', + deletePersonalPhrase: 'deletePersonalPhrase', + getPersonalPhraseCategories: 'getPersonalPhraseCategories', + savePersonalPhraseCategory: 'savePersonalPhraseCategory', + deletePersonalPhraseCategory: 'deletePersonalPhraseCategory', + // 宣教文章接口 + getArticleCateList: 'getArticleCateList', + getArticleList: 'getArticleList', + getArticle: 'getArticle', + addArticleSendRecord: 'addArticleSendRecord' }, member: { addCustomer: 'add', @@ -32,6 +48,15 @@ const urlsConfig = { }, wecom: { addContactWay: 'addContactWay' + }, + im: { + getUserSig: 'getUserSig', + sendSystemMessage: "sendSystemMessage", + getChatRecordsByGroupId: "getChatRecordsByGroupId", + sendConsultRejectedMessage: "sendConsultRejectedMessage", + endConsultation: "endConsultation", + getGroupListByGroupId: "getGroupListByGroupId", + createConsultGroup: "createConsultGroup" } } const urls = Object.keys(urlsConfig).reduce((acc, path) => { @@ -61,4 +86,5 @@ export default async function api(urlId, data = {}, loading = true) { type, } }, loading) -} \ No newline at end of file +} + diff --git a/utils/chat-utils.js b/utils/chat-utils.js new file mode 100644 index 0000000..c2d5231 --- /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) { + // 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} 是否有权限 + */ +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 diff --git a/utils/im-status-manager.js b/utils/im-status-manager.js new file mode 100644 index 0000000..7fc2ee3 --- /dev/null +++ b/utils/im-status-manager.js @@ -0,0 +1,228 @@ +import { + checkGlobalIMStatus, + ensureGlobalIMConnection, + getGlobalIMLoginStatus +} 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 \ No newline at end of file diff --git a/utils/tim-chat.js b/utils/tim-chat.js new file mode 100644 index 0000000..4ed5d28 --- /dev/null +++ b/utils/tim-chat.js @@ -0,0 +1,2816 @@ +// 引入腾讯IM SDK +import TIM from 'tim-wx-sdk' +import TIMUploadPlugin from 'tim-upload-plugin' +import api from './api.js' + +const env = __VITE_ENV__; + +// 腾讯IM配置 - SDKAppID 必须是 number 类型,使用 Number() 转换 +const TIM_CONFIG = { + SDKAppID: Number(env.MP_TIM_SDK_APP_ID), // 患者端 IM SDKAppID +} + +// 验证配置 +if (!TIM_CONFIG.SDKAppID || isNaN(TIM_CONFIG.SDKAppID)) { + console.error('❌ TIM SDK配置错误: MP_TIM_SDK_APP_ID未定义或无效', { + envValue: env.MP_TIM_SDK_APP_ID, + parsedValue: TIM_CONFIG.SDKAppID + }) +} + +// IM连接配置常量 +const IM_CONNECTION_CONFIG = { + MAX_RECONNECT_ATTEMPTS: 10, // 最大重连次数 + RECONNECT_DELAYS: [2000, 4000, 8000, 16000, 30000], // 重连延迟(指数退避) + LOGIN_COOLDOWN: 5000, // 登录冷却时间 + SDK_READY_TIMEOUT: 15000, // SDK就绪超时时间 + LOGIN_CHECK_INTERVAL_STABLE: 60000, // 稳定状态检查间隔 + LOGIN_CHECK_INTERVAL_UNSTABLE: 15000, // 不稳定状态检查间隔 + LOGIN_CHECK_FIRST_DELAY: 30000, // 首次检查延迟 + HEARTBEAT_INTERVAL: 60000, // 心跳间隔(毫秒)60秒 + HEARTBEAT_MAX_FAIL: 3, // 心跳最大失败次数 + NETWORK_RECONNECT_DELAY: 2000, // 网络恢复后延迟重连 + MESSAGE_BATCH_COUNT: 20, // 每批消息数量 + MAX_MESSAGE_REQUESTS: 50, // 最大消息请求次数 + MAX_CACHE_SIZE: 1000, // 最大缓存消息数 + TIM_INSTANCE_READY_CHECK_INTERVAL: 100 // TIM实例就绪检查间隔 +} + +class TimChatManager { + constructor() { + // TIM实例和会话 + this.tim = null + this.conversation = null + + // 用户信息 + this.currentUserID = '' + this.currentUserSig = '' + + // 消息管理 + this.messageList = [] + this.currentConversationID = null + + // 分页加载相关状态 + this.nextReqMessageID = "" + this.isCompleted = false + this.isLoadingMore = false + + // 状态标志 + this.isLoading = false + this.isLoggedIn = false + this.isInitializing = false + this.isLoggingIn = false + + // 定时器 + this.loginCheckInterval = null + this.heartbeatInterval = null + this.networkReconnectTimer = null + + // 重连管理 + this.reconnectAttempts = 0 + this.maxReconnectAttempts = IM_CONNECTION_CONFIG.MAX_RECONNECT_ATTEMPTS + this.reconnectDelays = IM_CONNECTION_CONFIG.RECONNECT_DELAYS + this.lastLoginTime = 0 + this.loginCooldown = IM_CONNECTION_CONFIG.LOGIN_COOLDOWN + + // 心跳管理 + this.heartbeatFailCount = 0 + + // 回调函数 + this.callbacks = { + onMessageReceived: null, + onMessageSent: null, + onSDKReady: null, + onSDKNotReady: null, + onError: null, + onLoginStatusChanged: null, + onConversationListUpdated: null + } + + // 绑定事件处理函数 + this.boundEventHandlers = { + onSDKReady: this.onSDKReady.bind(this), + onSDKNotReady: this.onSDKNotReady.bind(this), + onMessageReceived: this.onMessageReceived.bind(this), + onMessageSentSucceeded: this.onMessageSentSucceeded.bind(this), + onMessageSentFailed: this.onMessageSentFailed.bind(this), + onConversationListUpdated: this.onConversationListUpdated.bind(this), + onNetStateChange: this.onNetStateChange.bind(this), + onKickedOut: this.onKickedOut.bind(this) + } + } + + // ============== 资源管理方法 ============== + + // 清理所有定时器 + clearAllTimers() { + if (this.loginCheckInterval) { + clearTimeout(this.loginCheckInterval) + clearInterval(this.loginCheckInterval) + this.loginCheckInterval = null + } + + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = null + } + + if (this.networkReconnectTimer) { + clearTimeout(this.networkReconnectTimer) + this.networkReconnectTimer = null + } + + console.log('所有定时器已清理') + } + + // 清理缓存(已弃用缓存功能) + cleanupCache() { + // 缓存功能已移除 + } + + // 重置所有状态 + resetAllStates() { + this.isLoggedIn = false + this.isLoggingIn = false + this.isInitializing = false + this.reconnectAttempts = 0 + this.lastLoginTime = 0 + this.heartbeatFailCount = 0 + } + + // ============== 初始化方法 ============== + + // 初始化腾讯IM + async initTIM(userID = null) { + if (this.isInitializing) { + console.log('IM正在初始化中,跳过重复初始化') + return false + } + + this.isInitializing = true + console.log('=== 开始初始化IM ===') + + try { + // 重置重连次数,允许重新登录 + this.reconnectAttempts = 0 + + // 如果存在旧的TIM实例,先完整清理 + if (this.tim) { + console.log('检测到旧的TIM实例,开始清理...') + await this.cleanupOldInstance() + } + + if (!TIM) { + throw new Error('TIM SDK 未正确导入') + } + // 验证 SDKAppID + if (!TIM_CONFIG.SDKAppID || isNaN(TIM_CONFIG.SDKAppID)) { + throw new Error(`TIM SDK配置错误: SDKAppID无效 (${TIM_CONFIG.SDKAppID}),请检查环境变量 MP_TIM_SDK_APP_ID`) + } + + console.log('创建TIM实例,SDKAppID:', TIM_CONFIG.SDKAppID) + this.tim = TIM.create({ SDKAppID: TIM_CONFIG.SDKAppID }) + + // 等待TIM实例初始化完成 + await this.waitForTIMInstanceReady() + + // 注册上传插件 + this.tim.registerPlugin({ "tim-upload-plugin": TIMUploadPlugin }) + console.log('上传插件已注册') + + // 注册事件监听器 + this.registerEventListeners() + + // 设置日志级别 + if (typeof this.tim.setLogLevel === 'function') { + this.tim.setLogLevel(0) + } + + // 获取用户信息并登录 + await this.getUserInfoAndLogin(userID) + + // 等待SDK Ready + console.log('等待SDK Ready...') + await this.waitForSDKReady(IM_CONNECTION_CONFIG.SDK_READY_TIMEOUT) + + console.log('=== IM初始化完成 ===') + return true + + } catch (error) { + console.error('=== IM初始化失败 ===', error) + this.triggerCallback('onError', `初始化失败: ${error.message || error}`) + + // 初始化失败时清理资源 + await this.cleanupOldInstance() + return false + } finally { + this.isInitializing = false + } + } + + // 清理旧的TIM实例 + async cleanupOldInstance() { + try { + // 清理所有定时器 + this.clearAllTimers() + + // 移除事件监听器 + this.removeEventListeners() + + // 登出 + if (this.tim && this.tim.isLoggedIn) { + try { + await this.tim.logout() + console.log('已登出旧实例') + } catch (err) { + console.warn('登出旧实例失败:', err) + } + } + + // 清理实例 + this.tim = null + + // 重置状态 + this.resetAllStates() + + console.log('旧实例清理完成') + } catch (error) { + console.error('清理旧实例时出错:', error) + } + } + + // 等待TIM实例准备就绪 + waitForTIMInstanceReady() { + return new Promise((resolve, reject) => { + const startTime = Date.now() + const timeout = 10000 // 10秒超时 + + const checkReady = () => { + if (this.tim && typeof this.tim.on === 'function') { + console.log('TIM实例已就绪') + resolve() + } else if (Date.now() - startTime > timeout) { + reject(new Error('等待TIM实例就绪超时')) + } else { + setTimeout(checkReady, IM_CONNECTION_CONFIG.TIM_INSTANCE_READY_CHECK_INTERVAL) + } + } + + checkReady() + }) + } + + // 等待SDK Ready(优化版:更好的超时处理) + waitForSDKReady(timeout = IM_CONNECTION_CONFIG.SDK_READY_TIMEOUT) { + return new Promise((resolve, reject) => { + const startTime = Date.now() + const checkInterval = 1000 // 每秒检查一次 + let checkCount = 0 + + const checkSDKReady = () => { + checkCount++ + const elapsed = Date.now() - startTime + + if (this.isLoggedIn) { + console.log(`✓ SDK已Ready(耗时${elapsed}ms,检查${checkCount}次)`) + resolve() + } else if (elapsed > timeout) { + const error = new Error(`等待SDK Ready超时(${timeout}ms)`) + console.error('✗', error.message) + // 超时不算致命错误,尝试继续 + resolve() + } else { + console.log(`等待SDK Ready... ${Math.floor(elapsed / 1000)}/${Math.floor(timeout / 1000)}秒`) + setTimeout(checkSDKReady, checkInterval) + } + } + + // 立即开始第一次检查 + checkSDKReady() + }) + } + + // 注册事件监听器 + registerEventListeners() { + if (!this.tim || !TIM.EVENT) return + + try { + // 检查TIM实例是否已经初始化完成 + if (!this.tim || typeof this.tim.on !== 'function') { + console.log('TIM实例未准备好,延迟注册事件监听器') + setTimeout(() => this.registerEventListeners(), 100) + return + } + + // 逐个注册事件监听器,如果某个注册失败则继续注册其他的 + const events = [ + { event: TIM.EVENT.SDK_READY, handler: this.boundEventHandlers.onSDKReady }, + { event: TIM.EVENT.SDK_NOT_READY, handler: this.boundEventHandlers.onSDKNotReady }, + { event: TIM.EVENT.MESSAGE_RECEIVED, handler: this.boundEventHandlers.onMessageReceived }, + { event: TIM.EVENT.MESSAGE_SENT_SUCCEEDED, handler: this.boundEventHandlers.onMessageSentSucceeded }, + { event: TIM.EVENT.MESSAGE_SENT_FAILED, handler: this.boundEventHandlers.onMessageSentFailed }, + { event: TIM.EVENT.CONVERSATION_LIST_UPDATED, handler: this.boundEventHandlers.onConversationListUpdated }, + { event: TIM.EVENT.NET_STATE_CHANGE, handler: this.boundEventHandlers.onNetStateChange }, + { event: TIM.EVENT.KICKED_OUT, handler: this.boundEventHandlers.onKickedOut } + ] + + events.forEach(({ event, handler }) => { + if (event && handler && typeof this.tim.on === 'function') { + try { + this.tim.on(event, handler) + } catch (error) { + console.error(`注册事件监听器失败 ${event}:`, error) + } + } + }) + + console.log('TIM事件监听器注册完成') + } catch (error) { + console.error('注册TIM事件监听器失败:', error) + } + } + + // 移除事件监听器 + removeEventListeners() { + if (!this.tim || !TIM.EVENT) return + + try { + // 检查TIM实例是否还有off方法 + if (typeof this.tim.off !== 'function') return + + // 逐个移除事件监听器,如果某个移除失败则继续移除其他的 + const events = [ + { event: TIM.EVENT.SDK_READY, handler: this.boundEventHandlers.onSDKReady }, + { event: TIM.EVENT.SDK_NOT_READY, handler: this.boundEventHandlers.onSDKNotReady }, + { event: TIM.EVENT.MESSAGE_RECEIVED, handler: this.boundEventHandlers.onMessageReceived }, + { event: TIM.EVENT.MESSAGE_SENT_SUCCEEDED, handler: this.boundEventHandlers.onMessageSentSucceeded }, + { event: TIM.EVENT.MESSAGE_SENT_FAILED, handler: this.boundEventHandlers.onMessageSentFailed }, + { event: TIM.EVENT.CONVERSATION_LIST_UPDATED, handler: this.boundEventHandlers.onConversationListUpdated }, + { event: TIM.EVENT.NET_STATE_CHANGE, handler: this.boundEventHandlers.onNetStateChange } + ] + + events.forEach(({ event, handler }) => { + if (event && handler && typeof this.tim.off === 'function') { + try { + this.tim.off(event, handler) + } catch (error) { + console.error(`移除事件监听器失败 ${event}:`, error) + } + } + }) + } catch (error) { + console.error('移除TIM事件监听器失败:', error) + } + } + + async getUserInfoAndLogin(userID) { + try { + if (userID) { + this.currentUserID = userID + uni.setStorageSync('userInfo', { userID }) + } else { + const userInfo = uni.getStorageSync('userInfo') + if (!userInfo?.userID) { + throw new Error('未找到用户信息,请先登录') + } + this.currentUserID = userInfo.userID + } + this.currentUserSig = await this.getUserSig(this.currentUserID) + await this.loginTIM() + } catch (error) { + console.error('获取用户信息失败:', error) + this.triggerCallback('onError', `登录失败: ${error.message || error}`) + throw error // 重新抛出错误,让调用者知道登录失败 + } + } + + // 获取 userSig + async getUserSig(userID) { + try { + const response = await api('getUserSig', { userId: userID }) + if (response?.success && response?.data) { + return response.data + } + throw new Error('获取 userSig 失败: 接口返回数据格式错误') + } catch (error) { + console.error('获取UserSig失败:', error) + throw error + } + } + + // 登录腾讯IM + loginTIM() { + return new Promise((resolve, reject) => { + if (this.isLoggingIn) { + reject(new Error('正在登录中')) + return + } + + const now = Date.now() + if (now - this.lastLoginTime < this.loginCooldown) { + reject(new Error('登录冷却中')) + return + } + + if (this.isLoggedIn) { + resolve() + return + } + + this.isLoggingIn = true + this.lastLoginTime = now + + console.log('开始登录腾讯IM,userID:', this.currentUserID) + this.tim.login({ + userID: this.currentUserID, + userSig: this.currentUserSig + }).then(() => { + console.log('腾讯IM登录请求成功,等待SDK_READY事件...') + this.isLoggingIn = false + // 不在这里设置 isLoggedIn = true,等待 onSDKReady 事件 + this.reconnectAttempts = 0 + + // 触发登录状态变化回调(登录中状态) + this.triggerCallback('onLoginStatusChanged', { + isLoggedIn: false, + userID: this.currentUserID, + reason: 'LOGIN_REQUESTED', + message: '登录请求成功,等待SDK就绪...' + }) + resolve() + }).catch(error => { + console.error('腾讯IM登录失败:', error) + this.isLoggingIn = false + this.triggerCallback('onLoginStatusChanged', { + isLoggedIn: false, + error, + reason: 'LOGIN_FAILED' + }) + reject(error) + }) + }) + } + + // ============== 连接监控方法 ============== + + // 启动登录状态检测(优化版:使用配置常量) + startLoginStatusCheck() { + this.stopLoginStatusCheck() + + // 根据连接稳定性动态调整检查间隔 + const getCheckInterval = () => { + if (this.reconnectAttempts > 0) { + return IM_CONNECTION_CONFIG.LOGIN_CHECK_INTERVAL_UNSTABLE + } + return IM_CONNECTION_CONFIG.LOGIN_CHECK_INTERVAL_STABLE + } + + const checkStatus = () => { + // 如果已达到最大重连次数(被踢下线),停止检查 + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log('⚠️ 已被踢下线或达到最大重连次数,停止登录状态检查') + return + } + + // 如果正在登录中,跳过本次检查 + if (this.isLoggingIn) { + this.loginCheckInterval = setTimeout(checkStatus, getCheckInterval()) + return + } + + const isLoggedIn = this.checkLoginStatus() + + // 如果未登录且有用户ID,尝试重连 + if (!isLoggedIn && this.currentUserID && !this.isLoggingIn) { + console.log('📡 登录状态检测:发现未登录,尝试重连') + this.attemptReconnect() + } else if (isLoggedIn) { + // 登录正常,重置重连计数 + if (this.reconnectAttempts > 0) { + console.log('✓ 登录状态检测:连接已恢复正常') + this.reconnectAttempts = 0 + } + + // 定期清理缓存 + this.cleanupCache() + } + + // 重新调度下一次检查(再次检查,防止在检查过程中被踢下线) + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.loginCheckInterval = setTimeout(checkStatus, getCheckInterval()) + } + } + + // 首次检查延迟 + const firstDelay = IM_CONNECTION_CONFIG.LOGIN_CHECK_FIRST_DELAY + this.loginCheckInterval = setTimeout(checkStatus, firstDelay) + console.log(`🔍 登录状态检测已启动,首次检查将在${firstDelay / 1000}秒后进行`) + } + + // 停止登录状态检测 + stopLoginStatusCheck() { + if (this.loginCheckInterval) { + clearTimeout(this.loginCheckInterval) + clearInterval(this.loginCheckInterval) + this.loginCheckInterval = null + } + } + + // 检查登录状态 + checkLoginStatus() { + if (!this.tim) return false + + try { + // 【修复】优先信任已设置的登录状态,只有在明确检测到未登录时才更新 + // 如果已经标记为登录状态,不应该被轻易覆盖 + if (this.isLoggedIn) { + // 只在已登录状态下,通过 SDK 再次验证 + let sdkLoginStatus = true // 默认保持登录状态 + + if (typeof this.tim.getLoginStatus === 'function') { + try { + const status = this.tim.getLoginStatus() + // 只有明确检测到未登录状态才更新 + if (status === TIM.TYPES.NOT_LOGGED_IN || status === TIM.TYPES.KICKED_OUT) { + sdkLoginStatus = false + console.warn('SDK 检测到未登录状态:', status) + } + } catch (e) { + console.error('获取 SDK 登录状态失败:', e) + // 出错时保持当前状态 + } + } + + // 只有 SDK 明确返回未登录时才更新状态 + if (!sdkLoginStatus) { + this.isLoggedIn = false + this.triggerCallback('onLoginStatusChanged', { + isLoggedIn: false, + userID: this.currentUserID, + reason: 'SDK_STATUS_CHECK' + }) + return false + } + + // 保持登录状态 + return true + } + + // 如果当前是未登录状态,检查 SDK 是否已登录 + let loginStatus = false + + if (typeof this.tim.isLoggedIn === 'boolean') { + loginStatus = this.tim.isLoggedIn + } else if (typeof this.tim.getLoginStatus === 'function') { + const status = this.tim.getLoginStatus() + loginStatus = status === TIM.TYPES.LOGINED || status === TIM.TYPES.LOGGING_IN + } + + // 只有在检测到登录状态变化时才更新 + if (loginStatus && !this.isLoggedIn) { + this.isLoggedIn = true + this.triggerCallback('onLoginStatusChanged', { + isLoggedIn: true, + userID: this.currentUserID, + reason: 'SDK_STATUS_CHECK' + }) + } + + return loginStatus + } catch (error) { + console.error('检查IM登录状态失败:', error) + // 出错时返回当前状态,不改变状态 + return this.isLoggedIn + } + } + + // 尝试重新连接(优化版:更智能的重连策略) + async attemptReconnect() { + // 如果已达到最大重连次数(包括被踢下线的情况),停止重连 + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log('⚠️ 已达到最大重连次数或被踢下线,停止自动重连') + return false + } + + if (this.isLoggingIn) { + console.log('正在登录中,跳过重连') + return false + } + + // 先检查是否真的需要重连 + if (this.tim && this.isLoggedIn) { + console.log('IM已登录,无需重连') + this.reconnectAttempts = 0 + return true + } + + const now = Date.now() + const delayIndex = Math.min(this.reconnectAttempts, this.reconnectDelays.length - 1) + const reconnectDelay = this.reconnectDelays[delayIndex] + const timeSinceLastLogin = now - this.lastLoginTime + + // 只有非首次重连才需要检查冷却时间 + if (this.reconnectAttempts > 0 && timeSinceLastLogin < reconnectDelay) { + const remainingTime = reconnectDelay - timeSinceLastLogin + console.log(`重连冷却中,剩余时间:${Math.ceil(remainingTime / 1000)}秒`) + + // 安排下次重连 + setTimeout(() => { + if (!this.isLoggedIn && !this.isLoggingIn && this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnect() + } + }, remainingTime) + + return false + } + + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('达到最大重连次数,停止自动重连') + this.triggerCallback('onError', 'IM连接失败,请检查网络或手动刷新页面') + return false + } + + this.reconnectAttempts++ + console.log(`第${this.reconnectAttempts}次重连尝试(最多${this.maxReconnectAttempts}次)`) + + try { + if (this.tim && !this.isLoggedIn) { + console.log('TIM实例存在但未登录,尝试登录...') + await this.loginTIM() + console.log('重连成功!') + this.reconnectAttempts = 0 // 重置重连次数 + return true + } + + if (!this.tim) { + console.log('TIM实例不存在,重新初始化...') + await this.initTIM(this.currentUserID) + console.log('重新初始化并登录成功!') + this.reconnectAttempts = 0 + return true + } + + return false + } catch (error) { + console.error(`第${this.reconnectAttempts}次重连失败:`, error) + + // 如果还有重连机会,自动安排下一次重连 + if (this.reconnectAttempts < this.maxReconnectAttempts) { + const nextDelay = this.reconnectDelays[Math.min(this.reconnectAttempts, this.reconnectDelays.length - 1)] + console.log(`${nextDelay / 1000}秒后进行第${this.reconnectAttempts + 1}次重连...`) + setTimeout(() => { + if (!this.isLoggedIn && !this.isLoggingIn && this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnect() + } + }, nextDelay) + } + + return false + } + } + + // SDK Ready 事件 + onSDKReady() { + console.log('腾讯IM SDK Ready') + this.isLoggedIn = true + this.triggerCallback('onSDKReady') + this.triggerCallback('onLoginStatusChanged', { + isLoggedIn: true, + reason: 'SDK_READY' + }) + this.startLoginStatusCheck() + this.startHeartbeat() // 启动心跳检测 + } + + // SDK Not Ready 事件 + onSDKNotReady() { + console.log('腾讯IM SDK Not Ready') + this.isLoggedIn = false + this.triggerCallback('onSDKNotReady') + this.triggerCallback('onLoginStatusChanged', { + isLoggedIn: false, + reason: 'SDK_NOT_READY' + }) + this.stopLoginStatusCheck() + this.stopHeartbeat() // 停止心跳检测 + } + + // 收到新消息 + onMessageReceived(event) { + event.data.forEach(message => { + const existingMessage = this.messageList.find(msg => msg.ID === message.ID) + if (existingMessage) return + if (!this.filterMessage(message)) return + const convertedMessage = this.convertMessageFormat(message) + if (!convertedMessage.conversationID) { + convertedMessage.conversationID = message.conversationID + } + + // 获取消息所属的会话ID + const messageConversationID = convertedMessage.conversationID + + // 检查是否为系统消息 + const isSystemMsg = this.isSystemMessage(convertedMessage) + + console.log('收到新消息:', { + messageID: convertedMessage.ID, + messageConversationID: messageConversationID, + currentConversationID: this.currentConversationID, + messageType: convertedMessage.type, + from: convertedMessage.from, + isSystemMessage: isSystemMsg + }) + + // 判断是否为当前会话的消息 + // 系统消息:只要会话ID匹配就显示(不要求必须有currentConversationID) + // 普通消息:必须有currentConversationID且匹配才显示 + const isCurrentConversation = isSystemMsg + ? messageConversationID === this.currentConversationID + : (this.currentConversationID && messageConversationID === this.currentConversationID) + + console.log('消息会话匹配检查:', { + isCurrentConversation, + isSystemMessage: isSystemMsg, + hasCurrentConversationID: !!this.currentConversationID, + conversationIDMatch: messageConversationID === this.currentConversationID + }) + + if (isCurrentConversation) { + // 当前会话的消息,触发回调 + console.log('✓ 消息属于当前会话,触发显示') + this.triggerCallback('onMessageReceived', convertedMessage) + // 处理已读状态(系统消息也标记为已读) + if (this.currentConversationID) { + this.markConversationAsRead(this.currentConversationID) + } + this.triggerCallback('onConversationListUpdated', { + reason: 'NEW_MESSAGE_RECEIVED_IN_CURRENT_CONVERSATION', + conversation: { + conversationID: messageConversationID || this.currentConversationID, + lastMessage: this.formatLastMessage(convertedMessage), + lastMessageTime: convertedMessage.lastTime, + unreadCount: 0, + messageType: convertedMessage.type, + messageFlow: convertedMessage.flow + }, + message: convertedMessage + }) + } else { + // 非当前会话的消息,只更新会话列表 + console.log('✗ 消息不属于当前会话,已过滤不显示,仅更新会话列表', { + messageConversationID, + currentConversationID: this.currentConversationID + }) + this.updateConversationListOnNewMessage(convertedMessage) + } + }) + } + + // 消息发送成功 + onMessageSentSucceeded(event) { + const messageID = event.data.ID + const sentMessage = event.data.message + + // 更新messageList中的消息状态 + const message = this.messageList.find(msg => msg.ID === messageID) + if (message) { + message.status = 'success' + // 如果服务器返回了真实的消息ID,更新它 + if (sentMessage && sentMessage.ID && sentMessage.ID !== messageID) { + message.ID = sentMessage.ID + } + } + + // 缓存功能已移除 + + this.triggerCallback('onMessageSent', { + messageID, + status: 'success' + }) + } + + // 消息发送失败 + onMessageSentFailed(event) { + const messageID = event.data.ID + + // 更新messageList中的消息状态 + const message = this.messageList.find(msg => msg.ID === messageID) + if (message) { + message.status = 'failed' + } + + // 缓存功能已移除 + + this.triggerCallback('onMessageSent', { + messageID, + status: 'failed' + }) + this.triggerCallback('onError', `发送失败: ${event.data.error || '未知错误'}`) + } + + // 会话列表更新 + onConversationListUpdated(event) { + this.triggerCallback('onConversationListUpdated', event.data) + } + + // 网络状态变化(优化版:更稳定的处理) + onNetStateChange(event) { + const netState = event.data.netState + + console.log('🌐 网络状态变化:', netState) + + // 清理之前的网络重连定时器 + if (this.networkReconnectTimer) { + clearTimeout(this.networkReconnectTimer) + this.networkReconnectTimer = null + } + + if (netState === TIM.TYPES.NET_STATE_CONNECTED) { + console.log('✓ 网络已连接,延迟检查IM状态以确保稳定') + + // 网络恢复后延迟再检查,避免网络还不稳定时立即重连 + const delay = IM_CONNECTION_CONFIG.NETWORK_RECONNECT_DELAY + this.networkReconnectTimer = setTimeout(() => { + if (this.tim && !this.isLoggedIn && !this.isLoggingIn) { + console.log('🔄 网络已稳定,开始重连') + this.ensureIMConnection() + } else if (this.isLoggedIn) { + console.log('✓ 网络已稳定,IM连接正常') + } + this.networkReconnectTimer = null + }, delay) + + // 重置重连次数(网络恢复后给更多机会) + if (this.reconnectAttempts > 0) { + console.log(`重置重连次数(之前: ${this.reconnectAttempts})`) + this.reconnectAttempts = 0 + } + + } else if (netState === TIM.TYPES.NET_STATE_CONNECTING) { + console.log('🔗 网络连接中,等待稳定...') + + } else if (netState === TIM.TYPES.NET_STATE_DISCONNECTED) { + console.log('⚠️ 网络断开(暂不标记为未登录,等待心跳检测判断)') + + // 网络断开时不立即设置 isLoggedIn = false + // 因为可能只是短暂的网络波动,让心跳检测来判断是否真的断线 + + this.triggerCallback('onLoginStatusChanged', { + isLoggedIn: this.isLoggedIn, + reason: 'NETWORK_DISCONNECTED', + message: '网络波动,将自动重连' + }) + } + } + + // 被踢下线处理 + onKickedOut(event) { + console.log('⚠️ 账号被踢下线:', event.data) + + // 更新登录状态 + this.isLoggedIn = false + this.isLoggingIn = false + + // 停止所有定时器(防止自动重连) + this.clearAllTimers() + this.stopLoginStatusCheck() + this.stopHeartbeat() + + // 清除重连相关状态,阻止重连 + this.reconnectAttempts = this.maxReconnectAttempts // 设置为最大值,阻止重连 + + // 触发登录状态变化回调 + this.triggerCallback('onLoginStatusChanged', { + isLoggedIn: false, + reason: 'KICKED_OUT', + message: '您的账号在其他设备登录' + }) + + // 显示提示弹框 + uni.showModal({ + title: '提示', + content: '您的账号在其他设备登录,您已被踢下线', + showCancel: false, + confirmText: '确定', + success: () => { + // 清理本地缓存并跳转到登录页 + uni.removeStorageSync('token') + uni.removeStorageSync('refreshToken') + uni.removeStorageSync('account') + uni.removeStorageSync('openid') + uni.reLaunch({ + url: '/pages-center/login/login' + }) + } + }) + } + + // 手动检测并重连IM + async ensureIMConnection() { + if (this.isLoggingIn || !this.tim) return true + + const isLoggedIn = this.checkLoginStatus() + if (!isLoggedIn && this.currentUserID) { + return await this.attemptReconnect() + } + return true + } + + startHeartbeat() { + this.stopHeartbeat() + + // 心跳失败计数器 + this.heartbeatFailCount = 0 + const MAX_HEARTBEAT_FAIL = IM_CONNECTION_CONFIG.HEARTBEAT_MAX_FAIL + const INTERVAL = IM_CONNECTION_CONFIG.HEARTBEAT_INTERVAL + + // 定时心跳检测 + this.heartbeatInterval = setInterval(() => { + // 如果已达到最大重连次数(被踢下线),停止心跳检测 + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log('⚠️ 已被踢下线或达到最大重连次数,停止心跳检测') + this.stopHeartbeat() + return + } + + // 只在已登录状态下进行心跳检测 + if (!this.tim || !this.isLoggedIn) { + console.log('⏸ 心跳检测:未登录,跳过检测') + return + } + + // 确保方法存在 + if (typeof this.tim.getConversationList !== 'function') { + console.log('⏸ 心跳检测:SDK方法不可用,跳过检测') + return + } + + this.tim.getConversationList() + .then(() => { + if (this.heartbeatFailCount > 0) { + console.log(`� 心跳恢复正常(之前失败${this.heartbeatFailCount}次)`) + } + this.heartbeatFailCount = 0 // 重置失败计数 + }) + .catch((error) => { + // 如果是SDK未就绪错误,不计入失败次数(这是临时状态) + if (error && error.message && error.message.includes('sdk not ready')) { + console.log('⏸ 心跳检测:SDK未就绪,跳过本次检测') + return + } + + this.heartbeatFailCount++ + console.error(`💔 心跳失败 (${this.heartbeatFailCount}/${MAX_HEARTBEAT_FAIL}):`, error.message) + + // 只有连续失败多次才认为真的断线 + if (this.heartbeatFailCount >= MAX_HEARTBEAT_FAIL) { + console.log('❌ 心跳连续失败,标记为未登录并尝试重连') + this.isLoggedIn = false + this.heartbeatFailCount = 0 + // 只有未被踢下线才尝试重连 + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnect() + } + } + }) + }, INTERVAL) + + console.log(`💓 心跳检测已启动(间隔${INTERVAL / 1000}秒,最多失败${MAX_HEARTBEAT_FAIL}次)`) + } + + // 停止心跳检测 + stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = null + console.log('IM心跳检测已停止') + } + } + + // 获取会话列表 + getConversationList() { + if (!this.tim) { + console.error('TIM实例不存在,无法获取会话列表') + return + } + + if (!this.isLoggedIn) { + console.log('SDK未ready,等待SDK初始化后再获取会话列表...') + // 等待SDK就绪 + const checkSDKReady = () => { + if (this.isLoggedIn) { + console.log('SDK已ready,开始获取会话列表') + this.getConversationList() + } else { + setTimeout(checkSDKReady, 500) + } + } + setTimeout(checkSDKReady, 500) + return + } + } + + // 获取群聊列表 + getGroupList() { + return new Promise((resolve, reject) => { + if (!this.tim) { + reject(new Error('TIM实例不存在')) + return + } + + if (!this.isLoggedIn) { + console.log('SDK未ready,等待SDK初始化...') + let waitTime = 0 + const maxWaitTime = 30000 // 最多等待30秒 + const checkInterval = 1000 // 每秒检查一次 + let timeoutHandle = null + + const checkSDKReady = () => { + if (this.isLoggedIn) { + console.log('SDK已ready,开始获取群聊列表') + if (timeoutHandle) clearTimeout(timeoutHandle) + this.getGroupListInternal().then(resolve).catch(reject) + } else if (waitTime >= maxWaitTime) { + console.error('等待SDK就绪超时') + if (timeoutHandle) clearTimeout(timeoutHandle) + reject(new Error('SDK初始化超时,请检查网络连接')) + } else { + waitTime += checkInterval + console.log(`等待SDK就绪... (${Math.floor(waitTime / 1000)}/${Math.floor(maxWaitTime / 1000)}秒)`) + timeoutHandle = setTimeout(checkSDKReady, checkInterval) + } + } + + checkSDKReady() + return + } + + this.getGroupListInternal().then(resolve).catch(reject) + }) + } + + // 内部获取群聊列表方法 + getGroupListInternal() { + return new Promise((resolve, reject) => { + console.log('开始获取群聊列表') + + // 直接调用,SDK就绪检查已在getGroupList()中完成 + this.tim.getConversationList() + .then(async (conversationResponse) => { + console.log('获取会话列表成功') + + const groupConversations = conversationResponse.data.conversationList.filter(conversation => { + return conversation.conversationID && conversation.conversationID.startsWith('GROUP') + }) + + console.log('群聊会话列表数量:', groupConversations.length) + + // 先获取一次群组列表,避免在循环中重复调用 + let allGroups = [] + try { + const groupListResponse = await this.tim.getGroupList() + allGroups = groupListResponse.data.groupList || [] + } catch (error) { + console.error('获取群组列表失败:', error) + } + + const groupsWithInfo = await Promise.all( + groupConversations.map(async (conversation) => { + try { + const groupName = conversation.groupProfile?.name || '' + const [doctorId, patientName] = groupName.split('|') + const groupID = conversation.conversationID.replace('GROUP', '') + + // 从已获取的群组列表中查找 + const group = allGroups.find(g => g.groupID === groupID) + const groupInfo = { + groupID: groupID, + name: group?.name || '问诊群聊', + avatar: '/static/home/avatar.svg', + memberCount: group?.memberCount || 0 + } + + const lastMessage = conversation.lastMessage + let lastMessageText = '暂无消息' + let lastMessageTime = Date.now() + + if (lastMessage) { + if (lastMessage.type === 'TIMTextElem') { + lastMessageText = lastMessage.payload.text + } else if (lastMessage.type === 'TIMImageElem') { + lastMessageText = '[图片]' + } else if (lastMessage.type === 'TIMSoundElem') { + lastMessageText = '[语音]' + } else if (lastMessage.type === 'TIMCustomElem') { + // 解析自定义消息 + try { + const customData = JSON.parse(lastMessage.payload.data) + const messageType = customData.messageType + // 根据消息类型返回不同的预览文本 + switch (messageType) { + case 'system_message': + lastMessageText = '[系统消息]' + break + case 'symptom': + lastMessageText = '[病情描述]' + break + case 'prescription': + lastMessageText = '[处方单]' + break + case 'refill': + lastMessageText = '[续方申请]' + break + case 'survey': + lastMessageText = '[问卷调查]' + break + case 'article': + lastMessageText = '[文章]' + break + case "consult_pending": + lastMessageText = '患者向团队发起咨询,请在1小时内接诊,超时将自动关闭会话' + break + case "consult_rejected": + lastMessageText = '患者向团队发起咨询,由于有紧急事务要处理暂时无法接受咨询.本次会话丿关闭' + break + case "consult_timeout": + lastMessageText = '患者向团队发起咨询,团队成员均未接受咨询,本次会话已自动关闭' + break + case "consult_accepted": + lastMessageText = '已接诊,会话已开始' + break + case "consult_ended": + lastMessageText = '已结束当前会话' + break + default: + lastMessageText = '[自定义消息]' + } + } catch (error) { + console.error('解析自定义消息失败:', error) + lastMessageText = '[自定义消息]' + } + } else { + lastMessageText = '[未知消息类型]' + } + + lastMessageTime = (lastMessage.lastTime || lastMessage.time || 0) * 1000 + } + + return { + ...groupInfo, + groupID: groupID, + doctorId, + patientName, + conversationID: conversation.conversationID, + lastMessage: lastMessageText, + lastMessageTime: lastMessageTime, + unreadCount: conversation.unreadCount || 0 + } + } catch (error) { + console.error(`处理群聊会话失败:`, error) + return { + groupID: conversation.conversationID, + name: conversation.groupProfile?.name || '问诊群聊', + doctorId: '', + patientName: '', + avatar: '/static/home/avatar.svg', + lastMessage: '获取失败', + lastMessageTime: Date.now(), + unreadCount: conversation.unreadCount || 0, + memberCount: 0 + } + } + }) + ) + + console.log('处理后的群聊列表数量:', groupsWithInfo.length) + resolve({ + success: true, + groupList: groupsWithInfo, + totalCount: groupsWithInfo.length, + data: conversationResponse.data + }) + }) + .catch((imError) => { + console.error('获取会话列表失败:', imError) + reject({ + success: false, + error: imError + }) + }) + }) + } + + // 创建问诊群聊 + createGroup({ type, name, groupID, memberList, timeout = 30000, retryCount = 2 }) { + return new Promise((resolve, reject) => { + if (!this.tim) { + reject(new Error('TIM实例不存在')) + return + } + + if (!this.isLoggedIn) { + console.log('SDK未ready,等待SDK初始化...') + const maxWaitTime = 30000 + const startTime = Date.now() + + const checkSDKReady = () => { + if (this.isLoggedIn) { + console.log('SDK已ready,开始创建群聊') + this.createGroupInternal({ type, name, groupID, memberList, timeout, retryCount }) + .then(resolve) + .catch(reject) + } else if (Date.now() - startTime > maxWaitTime) { + reject(new Error('等待SDK初始化超时')) + } else { + console.log('SDK仍未ready,继续等待...') + setTimeout(checkSDKReady, 1000) + } + } + checkSDKReady() + return + } + + this.createGroupInternal({ type, name, groupID, memberList, timeout, retryCount }) + .then(resolve) + .catch(reject) + }) + } + + // 内部创建群组方法 + async createGroupInternal({ type, name, groupID, memberList, timeout, retryCount }) { + let lastError = null + + for (let attempt = 0; attempt <= retryCount; attempt++) { + try { + console.log(`创建群聊尝试 ${attempt + 1}/${retryCount + 1}:`, { + type, + name, + groupID, + memberCount: memberList ? memberList.length : 0 + }) + + const result = await this.createGroupWithTimeout({ type, name, groupID, memberList, timeout }) + console.log(`创建群聊成功,尝试次数: ${attempt + 1}`) + return result + } catch (error) { + lastError = error + console.error(`第 ${attempt + 1} 次创建群聊失败:`, error) + + if (attempt === retryCount) break + + const retryDelay = Math.min(1000 * Math.pow(2, attempt), 5000) + console.log(`等待 ${retryDelay}ms 后重试...`) + await new Promise(resolve => setTimeout(resolve, retryDelay)) + } + } + + throw lastError + } + + // 带超时的创建群组方法 + createGroupWithTimeout({ type, name, groupID, memberList, timeout = 30000 }) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`创建群聊超时 (${timeout}ms)`)) + }, timeout) + + if (!groupID) { + clearTimeout(timeoutId) + reject(new Error('群聊ID不能为空')) + return + } + + if (!name) { + clearTimeout(timeoutId) + reject(new Error('群聊名称不能为空')) + return + } + + let groupType = TIM.TYPES.GRP_WORK + if (type === 'GRP_WORK') { + groupType = TIM.TYPES.GRP_WORK + } else if (type === 'GRP_PUBLIC') { + groupType = TIM.TYPES.GRP_PUBLIC + } else if (type === 'GRP_CHATROOM') { + groupType = TIM.TYPES.GRP_CHATROOM + } else if (type === 'GRP_AVCHATROOM') { + groupType = TIM.TYPES.GRP_AVCHATROOM + } else if (type === 'GRP_MEETING') { + groupType = TIM.TYPES.GRP_MEETING + } + + const createGroupParams = { + type: groupType, + name: name, + groupID: groupID + } + + if (memberList && memberList.length > 0) { + createGroupParams.memberList = memberList + } + + console.log('创建群组参数:', createGroupParams) + + this.tim.createGroup(createGroupParams).then(async (imResponse) => { + clearTimeout(timeoutId) + console.log('创建问诊群聊成功:', imResponse) + + try { + await api('sendSystemMessage', { groupID, status: 'pending' }) + console.log('pending系统消息发送成功') + } catch (error) { + console.error('pending系统消息发送失败:', error) + } + + resolve({ + success: true, + groupID: groupID, + data: imResponse.data + }) + }).catch((imError) => { + clearTimeout(timeoutId) + + const enhancedError = { + ...imError, + originalError: imError, + groupID: groupID, + name: name, + type: type, + timestamp: new Date().toISOString() + } + + console.error('创建问诊群聊失败:', enhancedError) + + const errorMessage = enhancedError.message || enhancedError.originalError?.message || '' + if (errorMessage.includes('group id has been used') || errorMessage.includes('group id has been used!')) { + console.log('群聊ID已存在,视为创建成功') + resolve({ + success: true, + groupID: groupID, + data: { groupID: groupID }, + message: '群聊已存在' + }) + return + } + + reject({ + success: false, + error: enhancedError, + userMessage: this.formatErrorMessage(enhancedError) + }) + }) + }) + } + + // 格式化错误信息 + formatErrorMessage(error) { + if (!error) return '创建群聊失败,请稍后重试' + + const errorCode = error.code || (error.originalError && error.originalError.code) + const errorMessage = error.message || (error.originalError && error.originalError.message) || '' + + if (errorMessage.includes('group id has been used') || errorMessage.includes('group id has been used!')) { + return 'SUCCESS_GROUP_EXISTS' + } + + switch (errorCode) { + case 2801: + return '网络连接超时,请检查网络连接后重试' + case 2800: + return '网络连接异常,请检查网络设置' + case 2802: + return '服务器连接失败,请稍后重试' + case 2803: + return '请求失败,请检查网络连接' + case 2804: + return '服务暂时不可用,请稍后重试' + case 10004: + return '群聊ID已存在,请使用其他ID' + case 10007: + return '群聊创建权限不足' + case 10010: + return '群聊数量已达上限' + default: + if (errorMessage.includes('超时') || errorMessage.includes('timeout')) { + return '操作超时,请检查网络连接后重试' + } else if (errorMessage.includes('网络') || errorMessage.includes('network')) { + return '网络连接异常,请检查网络设置' + } else if (errorMessage.includes('权限') || errorMessage.includes('permission')) { + return '操作权限不足,请联系管理员' + } else { + return `创建群聊失败:${errorMessage}` + } + } + } + + // 设置当前会话ID + setConversationID(conversationID) { + this.currentConversationID = conversationID + console.log('设置当前会话ID:', conversationID) + } + + // 进入会话 + enterConversation(conversationID) { + console.log("【enterConversation】进入会话:", conversationID) + + // 更新当前会话ID + this.currentConversationID = conversationID + + // 清空当前消息列表 + this.messageList = [] + + // 重置分页状态 + this.nextReqMessageID = "" + this.isCompleted = false + this.isLoadingMore = false + + console.log(" 会话ID已更新,消息列表已清空,分页状态已重置") + + // 进入群聊会话,默认加载20条消息 + this.enterGroupConversation(conversationID, 20) + } + + // 进入群聊会话 + async enterGroupConversation(groupID, count = 20) { + console.log("【enterGroupConversation】进入群聊会话, groupID:", groupID, "count:", count) + + let conversationID = groupID + let actualGroupID = groupID + + if (groupID.startsWith('GROUP')) { + actualGroupID = groupID.replace('GROUP', '') + conversationID = groupID + } else { + conversationID = `GROUP${groupID}` + actualGroupID = groupID + } + + console.log(" conversationID:", conversationID, "actualGroupID:", actualGroupID) + + // 清空消息列表,准备加载新会话的消息 + this.messageList = [] + + // 确保设置当前会话ID,防止消息混淆 + this.currentConversationID = conversationID + console.log('进入群聊会话,设置currentConversationID:', conversationID) + + this.conversation = { + conversationID: conversationID, + conversationType: TIM.TYPES.CONV_GROUP, + groupProfile: { + groupID: actualGroupID, + name: '问诊群聊', + type: TIM.TYPES.GRP_WORK + } + } + + this.markConversationAsRead(conversationID) + + // 首先从本地接口加载聊天记录 + console.log(" 开始从本地接口加载聊天记录,groupID:", actualGroupID) + await this.loadMessagesFromLocalAPI(actualGroupID, count) + } + + // 从本地API加载聊天记录 + async loadMessagesFromLocalAPI(groupID, count = 20, skip = 0, isPullUp = false) { + try { + console.log("【loadMessagesFromLocalAPI】开始从本地API加载聊天记录") + console.log(" groupID:", groupID, "count:", count, "skip:", skip, "isPullUp:", isPullUp) + + // 调用本地接口获取聊天记录 + const response = await api('getChatRecordsByGroupId', { groupID, count, skip }) + + if (response && response.success && response.data && response.data.records) { + const dbMessages = response.data.records + const hasMore = response.data.hasMore || false // 从后端获取hasMore字段 + const total = response.data.total || 0 + + console.log(` 成功获取 ${dbMessages.length} 条聊天记录`) + console.log(` hasMore: ${hasMore}, total: ${total}`) + + // 将数据库消息转换为IM消息格式 + const convertedMessages = dbMessages + .map(dbMsg => this.convertDBMessageToIMFormat(dbMsg)) + .filter(msg => msg !== null && this.filterMessage(msg)) + + console.log(` 转换后 ${convertedMessages.length} 条消息`) + + // 按时间排序(从早到晚) + convertedMessages.sort((a, b) => a.lastTime - b.lastTime) + + // 根据是否为上拉加载,决定如何更新消息列表 + if (isPullUp) { + // 上拉加载更多:将新消息插入到列表前面(历史消息) + this.messageList.unshift(...convertedMessages) + // 再次排序确保顺序正确 + this.messageList.sort((a, b) => a.lastTime - b.lastTime) + } else { + // 首次加载:直接替换消息列表 + this.messageList = convertedMessages + } + + // 设置分页状态 - 使用后端返回的 hasMore 字段 + this.isCompleted = !hasMore + + console.log(` 分页状态: isCompleted=${this.isCompleted}, hasMore=${hasMore}`) + + // 触发回调 + this.triggerCallback("onMessageListLoaded", { + messages: this.messageList, + isPullUp: isPullUp, + hasMore: hasMore, + isCompleted: this.isCompleted, + total: total, + }) + + return { + success: true, + count: convertedMessages.length, + hasMore: hasMore, + isCompleted: this.isCompleted, + messages: this.messageList, + total: total, + } + } else { + console.warn(" ⚠️ 本地API返回数据格式错误") + return { + success: false, + message: "本地API返回数据格式错误", + count: 0, + } + } + } catch (error) { + console.error(" ❌ 从本地API加载聊天记录失败:", error) + return { + success: false, + error: error, + message: "加载聊天记录失败", + count: 0, + } + } + } + + // 将数据库消息转换为IM消息格式 + convertDBMessageToIMFormat(dbMsg) { + try { + if (!dbMsg || !dbMsg.MsgBody || !Array.isArray(dbMsg.MsgBody) || dbMsg.MsgBody.length === 0) { + return null + } + + // 获取第一个消息体(通常一个消息只有一个消息体) + const msgBody = dbMsg.MsgBody[0] + const msgType = msgBody.MsgType + + // 确定消息的流向(in/out) + // 根据 From_Account 判断是否为当前用户发送的消息 + const flow = dbMsg.From_Account === this.currentUserID ? 'out' : 'in' + + // 计算时间戳(毫秒) + let lastTime = Date.now() + if (dbMsg.MsgTime) { + // MsgTime 是秒级时间戳 + lastTime = dbMsg.MsgTime * 1000 + } else if (dbMsg.createdAt) { + lastTime = new Date(dbMsg.createdAt).getTime() + } + + // 构建基础消息对象 + const message = { + ID: dbMsg.MsgSeq || dbMsg._id || `db_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + from: dbMsg.From_Account || '', + flow: flow, + type: msgType, + payload: this.convertDBPayloadToIMPayload(msgType, msgBody.MsgContent), + lastTime: lastTime, + status: 'success', + avatar: flow === 'in' ? '/static/home/avatar.svg' : '/static/center/user-avatar.png', + conversationID: this.currentConversationID, + MsgSeq: dbMsg.MsgSeq, // 保留 MsgSeq 用于分页 + } + + return message + } catch (error) { + console.error("转换数据库消息格式失败:", error, dbMsg) + return null + } + } + + // 将数据库消息体转换为IM payload格式 + convertDBPayloadToIMPayload(msgType, msgContent) { + try { + if (!msgContent) { + return {} + } + + switch (msgType) { + case 'TIMTextElem': + return { + text: msgContent.Text || '' + } + + case 'TIMImageElem': + return { + imageInfoArray: msgContent.ImageInfoArray || [] + } + + case 'TIMSoundElem': + return { + url: msgContent.Url || '', + second: msgContent.Second || 0, + downloadFlag: msgContent.Download_Flag || 2 + } + + case 'TIMCustomElem': + return { + data: msgContent.Data || '', + description: msgContent.Desc || '', + extension: msgContent.Ext || '' + } + + case 'TIMVideoFileElem': + return { + videoUrl: msgContent.VideoUrl || '', + videoSize: msgContent.VideoSize || 0, + videoSecond: msgContent.VideoSecond || 0, + videoFormat: msgContent.VideoFormat || '', + videoDownloadFlag: msgContent.VideoDownloadFlag || 2, + thumbUrl: msgContent.ThumbUrl || '', + thumbSize: msgContent.ThumbSize || 0, + thumbWidth: msgContent.ThumbWidth || 0, + thumbHeight: msgContent.ThumbHeight || 0, + thumbFormat: msgContent.ThumbFormat || '', + thumbDownloadFlag: msgContent.ThumbDownloadFlag || 2 + } + + case 'TIMFileElem': + return { + url: msgContent.Url || '', + fileSize: msgContent.FileSize || 0, + fileName: msgContent.FileName || '', + downloadFlag: msgContent.Download_Flag || 2 + } + + default: + return msgContent + } + } catch (error) { + console.error("转换消息体失败:", error, msgType, msgContent) + return {} + } + } + + // 分页加载消息(单次加载) + async loadMessagesPage(options = {}) { + if (!this.conversation) { + console.log("会话未初始化,无法加载消息") + return Promise.reject(new Error("会话未初始化")) + } + + const count = options.count || 20 + // 记录调用前是否已有nextReqMessageID,用于判断是否为首次加载 + const isLoadMore = !!(this.nextReqMessageID && this.nextReqMessageID !== "") + + try { + // 构建请求参数 + const requestParams = { + conversationID: this.conversation.conversationID, + count: Math.min(count, 15), // 腾讯IM SDK限制count在1-15之间 + } + + // 如果有nextReqMessageID,说明是加载更多 + if (isLoadMore) { + requestParams.nextReqMessageID = this.nextReqMessageID + console.log("加载更多消息,nextReqMessageID:", this.nextReqMessageID) + } else { + console.log("首次加载消息") + } + + console.log(" 📡 发送请求到腾讯IM:", requestParams) + const response = await this.tim.getMessageList(requestParams) + + const messageList = response.data.messageList + const oldNextReqMessageID = this.nextReqMessageID + const oldIsCompleted = this.isCompleted + + this.nextReqMessageID = response.data.nextReqMessageID + this.isCompleted = response.data.isCompleted + + console.log(" 📥 收到腾讯IM响应:") + console.log(` 消息数量: ${messageList.length}`) + console.log(` nextReqMessageID: ${oldNextReqMessageID} → ${this.nextReqMessageID}`) + console.log(` isCompleted: ${oldIsCompleted} → ${this.isCompleted}`) + + if (messageList && messageList.length > 0) { + // 过滤掉不需要显示的消息 + const filteredMessages = messageList.filter((message) => + this.filterMessage(message) + ) + + // 转换消息格式 + const messages = filteredMessages.map((message) => { + return this.convertMessageFormat(message) + }) + + // 将新消息插入到列表前面(历史消息在前) + this.messageList.unshift(...messages) + + // 按时间戳排序消息列表(从早到晚) + this.messageList.sort((a, b) => a.lastTime - b.lastTime) + } + + // 触发回调 - 使用 isLoadMore 来判断是否为上拉加载 + this.triggerCallback("onMessageListLoaded", { + messages: this.messageList, + isPullUp: isLoadMore, // 只有在加载更多时才是 true + hasMore: !this.isCompleted, + isCompleted: this.isCompleted, + }) + + return { + success: true, + count: messageList ? messageList.length : 0, + hasMore: !this.isCompleted, + isCompleted: this.isCompleted, + messages: this.messageList, + } + } catch (error) { + console.error("分页加载消息失败:", error) + return { + success: false, + error: error, + count: 0, + } + } + } + + // 加载更多消息(供页面调用) + async loadMoreMessages() { + console.log("【loadMoreMessages】开始加载更多消息") + console.log(" 当前状态: isLoadingMore=", this.isLoadingMore) + console.log(" 当前状态: isCompleted=", this.isCompleted) + console.log(" 当前消息数量:", this.messageList.length) + + if (this.isLoadingMore) { + console.log(" ⚠️ 正在加载中,跳过重复请求") + return { success: false, message: "正在加载中" } + } + + if (this.isCompleted) { + console.log(" ✅ 已加载全部消息") + return { success: false, message: "已加载全部消息" } + } + + this.isLoadingMore = true + console.log(" 🔄 设置 isLoadingMore = true") + + try { + // 从本地数据库加载更多历史消息 + // 使用当前消息列表的长度作为 skip 值 + const skip = this.messageList.length + + // 获取群组ID + let groupID = '' + if (this.conversation && this.conversation.groupProfile) { + groupID = this.conversation.groupProfile.groupID + } else if (this.currentConversationID) { + groupID = this.currentConversationID.replace('GROUP', '') + } + + if (!groupID) { + console.error(" ❌ 无法获取群组ID") + this.isLoadingMore = false + return { + success: false, + message: "无法获取群组ID", + } + } + + console.log(" 从本地数据库加载更多,groupID:", groupID, "skip:", skip) + + // 调用本地API加载更多消息 + const result = await this.loadMessagesFromLocalAPI(groupID, 20, skip, true) + + this.isLoadingMore = false + console.log(" 🔄 设置 isLoadingMore = false") + console.log(" 📥 loadMessagesFromLocalAPI 返回结果:", result) + + if (result.success) { + console.log(" ✅ 加载成功,新增", result.count, "条消息") + return { + success: true, + count: result.count, + hasMore: result.hasMore, + isCompleted: result.isCompleted, + } + } else { + console.log(" ❌ 加载失败:", result.message || result.error) + return { + success: false, + error: result.error, + message: result.message || "加载失败", + } + } + } catch (error) { + console.error(" ❌ 加载更多异常:", error) + this.isLoadingMore = false + return { + success: false, + error: error, + message: "加载失败", + } + } + } + + // 后台刷新服务器消息(已弃用缓存功能) + async refreshMessagesFromServer(conversationID, cachedMessages) { + try { + const result = await this.getMessages({ getAllMessages: true }) + if (result.success) { + // 严格过滤:只保留属于当前会话的消息 + const filteredMessages = result.messages.filter(msg => msg.conversationID === conversationID) + + this.messageList = filteredMessages + + console.log(`消息刷新完成,过滤后${filteredMessages.length}条消息`) + + // 触发更新回调 + this.triggerCallback('onMessageListLoaded', { + messages: this.messageList, + isPullUp: false, + hasMore: false, + isCompleted: true, + isRefresh: true // 标记这是刷新操作 + }) + } + } catch (error) { + console.error('后台刷新消息失败:', error) + } + } + + // 合并消息(保留本地消息,避免覆盖) + mergeMessages(serverMessages, cachedMessages) { + const messageMap = new Map() + + // 先添加服务器消息 + serverMessages.forEach(msg => { + if (msg.ID) { + messageMap.set(msg.ID, msg) + } + }) + + // 再添加缓存消息(覆盖服务器消息,保留本地最新状态) + cachedMessages.forEach(msg => { + if (msg.ID) { + // 如果是本地消息(ID以local_开头)或者是发送中/失败的消息,保留缓存中的版本 + if (msg.ID.startsWith('local_') || msg.status === 'sending' || msg.status === 'failed') { + messageMap.set(msg.ID, msg) + } else if (!messageMap.has(msg.ID)) { + // 服务器没有的消息也保留 + messageMap.set(msg.ID, msg) + } + } + }) + + // 转换为数组并按时间排序 + const mergedArray = Array.from(messageMap.values()) + mergedArray.sort((a, b) => a.lastTime - b.lastTime) + + console.log(`消息合并完成: 服务器${serverMessages.length}条, 缓存${cachedMessages.length}条, 合并后${mergedArray.length}条`) + + return mergedArray + } + + // 获取消息列表 + async getMessages(options = {}) { + if (!this.conversation) { + console.log('会话未初始化,跳过获取消息列表') + this.isLoading = false + return Promise.resolve({ success: false, error: '会话未初始化' }) + } + + this.isLoading = true + + const defaultOptions = { + count: 20, + nextReqMessageID: '', + isPullUp: false, + getAllMessages: false + } + + const requestOptions = { ...defaultOptions, ...options } + + try { + if (requestOptions.getAllMessages) { + return await this.getAllMessages(requestOptions) + } else { + return await this.getMessagesBatch(requestOptions) + } + } catch (error) { + console.error('获取消息列表失败:', error) + this.isLoading = false + this.messageList = [] + + this.triggerCallback('onMessageListLoaded', { + messages: this.messageList, + isPullUp: requestOptions.isPullUp, + hasMore: false, + error: error + }) + + return { success: false, error } + } + } + + // 获取单批消息 + async getMessagesBatch(options) { + const requestParams = { + conversationID: this.conversation.conversationID, + count: options.count + } + + if (options.nextReqMessageID) { + requestParams.nextReqMessageID = options.nextReqMessageID + } + + console.log('请求消息列表参数:', requestParams) + + const response = await this.tim.getMessageList(requestParams) + const messageList = response.data.messageList + const nextReqMessageID = response.data.nextReqMessageID + const isCompleted = response.data.isCompleted + + console.log(`TIM返回消息: ${messageList.length}条, isCompleted: ${isCompleted}`) + + // 处理消息 + const messages = this.processMessages(messageList) + + console.log(`处理后消息: ${messages.length}条`) + + // 更新消息列表 + if (options.isPullUp) { + this.messageList.unshift(...messages) + } else { + this.messageList = messages + } + + // 缓存消息 + this.cacheMessages() + + this.isLoading = false + + // 触发回调(仅在非getAllMessages调用时触发) + if (!options.skipCallback) { + this.triggerCallback('onMessageListLoaded', { + messages: this.messageList, + isPullUp: options.isPullUp, + hasMore: !isCompleted, + nextReqMessageID: nextReqMessageID, + isCompleted: isCompleted + }) + } + + // 返回当前批次的消息,而不是整个messageList + return { + success: true, + messages: messages, // 返回处理后的当前批次消息 + hasMore: !isCompleted, + nextReqMessageID: nextReqMessageID, + isCompleted: isCompleted + } + } + + // 获取全部历史消息 + async getAllMessages(options) { + console.log('开始获取全部历史消息...') + let allMessages = [] + let nextReqMessageID = options.nextReqMessageID || '' + let isCompleted = false + let requestCount = 0 + const maxRequests = 50 // 最多请求50次,避免无限循环 + + while (!isCompleted && requestCount < maxRequests) { + requestCount++ + console.log(`获取历史消息第${requestCount}批...`) + + const batchOptions = { + ...options, + nextReqMessageID: nextReqMessageID, + isPullUp: false, // 改为false,因为我们是在构建完整列表 + skipCallback: true // 跳过单批回调,最后统一回调 + } + + const result = await this.getMessagesBatch(batchOptions) + + if (!result.success) { + console.error(`第${requestCount}批消息获取失败:`, result.error) + throw new Error(result.error) + } + + console.log(`第${requestCount}批获取到${result.messages.length}条消息`) + + // 将当前批次的消息添加到开头(历史消息在前) + if (result.messages.length > 0) { + // 使用Set去重,避免重复消息 + const existingIds = new Set(allMessages.map(m => m.ID)) + const newMessages = result.messages.filter(m => !existingIds.has(m.ID)) + + if (newMessages.length > 0) { + allMessages.unshift(...newMessages) + console.log(`添加${newMessages.length}条新消息,当前总数: ${allMessages.length}`) + } + } + + nextReqMessageID = result.nextReqMessageID + isCompleted = result.isCompleted + + console.log(`isCompleted: ${isCompleted}, nextReqMessageID: ${nextReqMessageID}`) + + // 如果没有更多消息ID,说明已经获取完毕 + if (!nextReqMessageID) { + console.log('没有更多消息ID,获取完成') + break + } + } + + if (requestCount >= maxRequests) { + console.warn(`达到最大请求次数限制(${maxRequests}),停止获取`) + } + + // 更新消息列表 + this.messageList = allMessages + this.cacheMessages() + this.isLoading = false + + console.log(`全部历史消息获取完成,共${allMessages.length}条消息,请求了${requestCount}批`) + + // 触发回调 + this.triggerCallback('onMessageListLoaded', { + messages: this.messageList, + isPullUp: false, + hasMore: false, + nextReqMessageID: '', + isCompleted: true + }) + + return { + success: true, + messages: this.messageList, + hasMore: false, + nextReqMessageID: '', + isCompleted: true + } + } + + // 处理消息 - 统一过滤和转换 + processMessages(messageList) { + const filteredMessages = messageList.filter(message => this.filterMessage(message)) + return filteredMessages.map(message => this.convertMessageFormat(message)) + } + + // 缓存消息 - 统一缓存逻辑 + cacheMessages() { + // 缓存功能已移除 + } + + // 清理指定会话的缓存(已弃用) + clearConversationCache(conversationID) { + // 缓存功能已移除 + } + + // 清理所有缓存(已弃用) + clearAllCache() { + // 缓存功能已移除 + } + + // 获取消息列表(兼容旧方法) + getMessageList(options = {}) { + return this.getMessages(options) + } + + // 发送文本消息 + async sendTextMessage(text, cloudCustomData = '') { + if (!text.trim()) return + + if (!this.tim) { + this.triggerCallback('onError', 'IM未初始化') + return { success: false, error: 'IM未初始化' } + } + + // 检查登录状态 + if (!this.isLoggedIn) { + console.error('IM未登录,无法发送消息'); + this.triggerCallback('onError', 'IM未登录,请稍后重试') + return { success: false, error: 'IM未登录' } + } + + // 优先使用 currentConversationID,如果没有则尝试从 conversation 获取 + let conversationID = this.currentConversationID; + if (!conversationID && this.conversation) { + conversationID = this.conversation.conversationID; + } + + if (!conversationID) { + console.error('会话ID不存在'); + this.triggerCallback('onError', '会话不存在,请重新进入聊天') + return { success: false, error: '会话ID不存在' } + } + + // 从 conversationID 提取 groupID + let groupID = null; + if (conversationID.startsWith('GROUP')) { + groupID = conversationID.replace('GROUP', ''); + } else if (this.conversation?.groupProfile?.groupID) { + groupID = this.conversation.groupProfile.groupID; + } + + if (!groupID) { + console.error('无法获取群聊ID,conversationID:', conversationID); + this.triggerCallback('onError', '无法获取群聊ID') + return { success: false, error: '无法获取群聊ID' } + } + + const localMessage = { + ID: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + flow: 'out', + type: 'TIMTextElem', + payload: { text: text }, + lastTime: Date.now(), + status: 'sending', + avatar: '/static/center/user-avatar.png', + conversationID: conversationID, + from: this.currentUserID + } + + // 缓存功能已移除 + + this.triggerCallback('onMessageReceived', localMessage) + + const message = this.tim.createTextMessage({ + to: groupID, + conversationType: TIM.TYPES.CONV_GROUP, + cloudCustomData, + payload: { text: text } + }) + + try { + await this.tim.sendMessage(message) + localMessage.status = 'success' + return { success: true, message: localMessage } + } catch (error) { + console.error('文本消息发送失败:', error) + localMessage.status = 'failed' + + // 如果是因为未登录导致的失败,尝试重连 + if (error.message && (error.message.includes('not login') || error.message.includes('sdk not ready'))) { + console.log('检测到未登录错误,尝试重连...'); + this.isLoggedIn = false; + this.ensureIMConnection(); + } + + return { success: false, error } + } + } + + // 发送图片消息 + async sendImageMessage(imageFile) { + if (!this.tim) { + this.triggerCallback('onError', 'IM未初始化') + return + } + + if (!this.conversation) { + this.triggerCallback('onError', '群聊会话不存在') + return { success: false, error: '群聊会话不存在' } + } + + let groupID = null + if (this.conversation.groupProfile && this.conversation.groupProfile.groupID) { + groupID = this.conversation.groupProfile.groupID + } else if (this.conversation.conversationID) { + groupID = this.conversation.conversationID.replace('GROUP', '') + } + + if (!groupID) { + this.triggerCallback('onError', '无法获取群聊ID') + return { success: false, error: '无法获取群聊ID' } + } + + // 确保使用当前会话的conversationID + const conversationID = this.conversation.conversationID || this.currentConversationID + + // 获取图片尺寸信息 + const imageInfo = await this.getImageInfo(imageFile); + + const localMessage = { + ID: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + flow: 'out', + type: 'TIMImageElem', + payload: { + imageInfoArray: [{ + url: this.getImageUrl(imageFile), + width: imageInfo.width, + height: imageInfo.height + }] + }, + lastTime: Date.now(), + status: 'sending', + avatar: '', + conversationID: conversationID, + from: this.currentUserID + } + + console.log('创建本地图片消息:', localMessage) + + // 缓存功能已移除 + + // 触发消息接收回调,让UI立即显示 + this.triggerCallback('onMessageReceived', localMessage) + + const message = this.tim.createImageMessage({ + to: groupID, + conversationType: TIM.TYPES.CONV_GROUP, + payload: { file: imageFile } + }) + + try { + await this.tim.sendMessage(message) + localMessage.status = 'success' + return { success: true, message: localMessage } + } catch (error) { + console.error('图片消息发送失败:', error) + localMessage.status = 'failed' + return { success: false, error } + } + } + // 发送语音消息 + async sendVoiceMessage(voiceFile, duration) { + if (!this.tim) { + this.triggerCallback('onError', 'IM未初始化') + return { success: false, error: 'IM未初始化' } + } + + // 检查登录状态 + if (!this.isLoggedIn) { + console.error('IM未登录,无法发送消息'); + this.triggerCallback('onError', 'IM未登录,请稍后重试') + return { success: false, error: 'IM未登录' } + } + + // 优先使用 currentConversationID,如果没有则尝试从 conversation 获取 + let conversationID = this.currentConversationID; + if (!conversationID && this.conversation) { + conversationID = this.conversation.conversationID; + } + + if (!conversationID) { + console.error('会话ID不存在'); + this.triggerCallback('onError', '会话不存在,请重新进入聊天') + return { success: false, error: '会话ID不存在' } + } + + // 从 conversationID 提取 groupID + let groupID = null; + if (conversationID.startsWith('GROUP')) { + groupID = conversationID.replace('GROUP', ''); + } else if (this.conversation?.groupProfile?.groupID) { + groupID = this.conversation.groupProfile.groupID; + } + + if (!groupID) { + console.error('无法获取群聊ID,conversationID:', conversationID); + this.triggerCallback('onError', '无法获取群聊ID') + return { success: false, error: '无法获取群聊ID' } + } + + const localMessage = { + ID: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + flow: 'out', + type: 'TIMSoundElem', + payload: { + url: this.getVoiceUrl(voiceFile), + second: duration + }, + lastTime: Date.now(), + status: 'sending', + avatar: '/static/center/user-avatar.png', + conversationID: conversationID, + from: this.currentUserID + } + + // 缓存功能已移除 + + this.triggerCallback('onMessageReceived', localMessage) + + const message = this.tim.createAudioMessage({ + to: groupID, + conversationType: TIM.TYPES.CONV_GROUP, + payload: { file: voiceFile } + }) + + try { + await this.tim.sendMessage(message) + localMessage.status = 'success' + return { success: true, message: localMessage } + } catch (error) { + console.error('语音消息发送失败:', error) + localMessage.status = 'failed' + + // 如果是因为未登录导致的失败,尝试重连 + if (error.message && (error.message.includes('not login') || error.message.includes('sdk not ready'))) { + console.log('检测到未登录错误,尝试重连...'); + this.isLoggedIn = false; + this.ensureIMConnection(); + } + + return { success: false, error } + } + } + + // 发送自定义消息 + async sendCustomMessage(messageData) { + if (!this.tim) { + this.triggerCallback('onError', 'IM未初始化') + return { success: false, error: 'IM未初始化' } + } + + // 检查登录状态 + if (!this.isLoggedIn) { + console.error('IM未登录,无法发送消息'); + this.triggerCallback('onError', 'IM未登录,请稍后重试') + return { success: false, error: 'IM未登录' } + } + + // 优先使用 currentConversationID,如果没有则尝试从 conversation 获取 + let conversationID = this.currentConversationID; + if (!conversationID && this.conversation) { + conversationID = this.conversation.conversationID; + } + + if (!conversationID) { + console.error('会话ID不存在'); + this.triggerCallback('onError', '会话不存在,请重新进入聊天') + return { success: false, error: '会话ID不存在' } + } + + // 从 conversationID 提取 groupID + let groupID = null; + if (conversationID.startsWith('GROUP')) { + groupID = conversationID.replace('GROUP', ''); + } else if (this.conversation?.groupProfile?.groupID) { + groupID = this.conversation.groupProfile.groupID; + } + + if (!groupID) { + console.error('无法获取群聊ID,conversationID:', conversationID); + this.triggerCallback('onError', '无法获取群聊ID') + return { success: false, error: '无法获取群聊ID' } + } + + const localMessage = { + ID: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + flow: 'out', + type: 'TIMCustomElem', + payload: { + data: JSON.stringify(messageData), + description: messageData.content || '自定义消息', + extension: messageData.messageType || 'custom' + }, + lastTime: Date.now(), + status: 'sending', + avatar: '/static/center/user-avatar.png', + conversationID: conversationID, + from: this.currentUserID + } + + // 缓存功能已移除 + + this.triggerCallback('onMessageReceived', localMessage) + + const message = this.tim.createCustomMessage({ + to: groupID, + conversationType: TIM.TYPES.CONV_GROUP, + payload: { + data: JSON.stringify(messageData), + description: messageData.content || '自定义消息', + extension: messageData.messageType || 'custom' + } + }) + + try { + await this.tim.sendMessage(message) + localMessage.status = 'success' + return { success: true, message: localMessage } + } catch (error) { + console.error('自定义消息发送失败:', error) + localMessage.status = 'failed' + + // 如果是因为未登录导致的失败,尝试重连 + if (error.message && (error.message.includes('not login') || error.message.includes('sdk not ready'))) { + console.log('检测到未登录错误,尝试重连...'); + this.isLoggedIn = false; + this.ensureIMConnection(); + } + + return { success: false, error } + } + } + + // 工具方法 + // 判断是否为系统消息 + isSystemMessage(message) { + if (message.type !== 'TIMCustomElem') { + return false + } + + // 检查 payload.data 是否包含系统消息标记 + try { + if (message.payload && message.payload.data) { + const data = typeof message.payload.data === 'string' + ? JSON.parse(message.payload.data) + : message.payload.data + + // 检查是否为系统消息类型 + if (data.type === 'system_message') { + return true + } + } + + // 检查 description 是否为系统消息标记 + if (message.payload && message.payload.description === '系统消息标记') { + return true + } + + // 兼容旧的系统消息格式 + if (message.payload && message.payload.description === 'SYSTEM_NOTIFICATION') { + return true + } + } catch (error) { + console.error('判断系统消息失败:', error) + } + + return false + } + + filterMessage(message) { + if (message.type === 'TIMCustomElem' && message.payload && message.payload.data) { + if (message.payload.data === 'group_create' || message.payload.data === 'purchased') { + console.log('过滤消息:', message.ID, message.payload.data) + return false + } + } + return true + } + + convertMessageFormat(timMessage) { + let lastTime + if (timMessage.lastTime) { + lastTime = timMessage.lastTime * 1000 + } else if (timMessage.time) { + lastTime = timMessage.time * 1000 + } else if (timMessage.timestamp) { + lastTime = timMessage.timestamp * 1000 + } else { + lastTime = Date.now() + } + const message = { + ID: timMessage.ID, + from: timMessage.from, + flow: timMessage.flow, + type: timMessage.type, + payload: timMessage.payload, + lastTime: lastTime, + status: timMessage.status || 'success', + avatar: timMessage.flow === 'in' ? '/static/home/avatar.svg' : '/static/center/user-avatar.png', + // 优先使用消息本身的conversationID,确保消息归属正确的会话 + conversationID: timMessage.conversationID + } + + return message + } + + formatLastMessage(message) { + try { + switch (message.type) { + case 'TIMTextElem': + return message.payload.text || '[文本消息]' + case 'TIMImageElem': + return '[图片]' + case 'TIMSoundElem': + return '[语音]' + case 'TIMCustomElem': + try { + const customData = JSON.parse(message.payload.data) + if (customData.messageType === 'symptom') { + return '[病情描述]' + } else if (customData.messageType === 'prescription') { + return '[处方单]' + } else if (customData.messageType === 'refill') { + return '[续方申请]' + } else if (customData.messageType === 'survey') { + return '[问卷调查]' + } else { + return customData.content || '[自定义消息]' + } + } catch (error) { + return '[自定义消息]' + } + default: + return '[未知消息类型]' + } + } catch (error) { + console.error('格式化最后一条消息失败:', error) + return '[消息]' + } + } + + getImageUrl(imageFile) { + // 处理 tempFiles 数组格式 + if (imageFile?.tempFiles?.length > 0) { + return imageFile.tempFiles[0].tempFilePath + } + // 处理单个文件对象 + if (imageFile?.tempFilePath) { + return imageFile.tempFilePath + } + // 处理字符串路径 + if (typeof imageFile === 'string') { + return imageFile + } + console.warn('无法获取图片URL,使用默认图片:', imageFile); + return '/static/home/photo.png' + } + + // 获取图片尺寸信息 + getImageInfo(imageFile) { + return new Promise((resolve) => { + let imagePath = ''; + + // 获取图片路径 - 处理多种格式 + if (imageFile?.tempFilePaths?.length > 0) { + // wx.chooseImage 返回的对象 + imagePath = imageFile.tempFilePaths[0]; + } else if (imageFile?.tempFiles?.length > 0) { + // 从 tempFiles 中提取路径 + const tempFile = imageFile.tempFiles[0]; + imagePath = tempFile.path || tempFile.tempFilePath; + } else if (imageFile?.tempFilePath) { + imagePath = imageFile.tempFilePath; + } else if (typeof imageFile === 'string') { + imagePath = imageFile; + } else { + console.warn('无法获取图片路径,使用默认尺寸:', imageFile); + // 默认尺寸 + resolve({ width: 400, height: 300 }); + return; + } + + console.log('获取图片信息,路径:', imagePath); + + // 使用uni.getImageInfo获取图片尺寸 + uni.getImageInfo({ + src: imagePath, + success: (res) => { + console.log('获取图片尺寸成功:', res); + resolve({ + width: res.width, + height: res.height + }); + }, + fail: (err) => { + console.error('获取图片尺寸失败:', err); + // 失败时使用默认尺寸 + resolve({ width: 400, height: 300 }); + } + }); + }); + } + + getVoiceUrl(voiceFile) { + if (typeof voiceFile === 'string') { + return voiceFile + } + if (voiceFile && voiceFile.tempFilePath) { + return voiceFile.tempFilePath + } + if (voiceFile && voiceFile.filePath) { + return voiceFile.filePath + } + return '/static/voice/default.mp3' + } + + // 标记会话为已读 + markConversationAsRead(conversationID) { + if (!this.tim || !this.isLoggedIn) return + + try { + let formattedConversationID = conversationID + if (!conversationID.startsWith('GROUP')) { + formattedConversationID = `GROUP${conversationID}` + } + + this.tim.setMessageRead({ + conversationID: formattedConversationID + }).then(() => { + this.triggerCallback('onConversationListUpdated', { + conversationID: formattedConversationID, + unreadCount: 0 + }) + }).catch(error => { + console.error('标记会话已读失败:', error) + }) + } catch (error) { + console.error('标记会话已读异常:', error) + } + } + + // 更新会话列表 + updateConversationListOnNewMessage(message) { + try { + const conversationID = message.conversationID || this.currentConversationID + if (!conversationID) return + + const conversationUpdate = { + conversationID: conversationID, + lastMessage: this.formatLastMessage(message), + lastMessageTime: message.lastTime, + unreadCount: 1, + messageType: message.type, + messageFlow: message.flow + } + + this.triggerCallback('onConversationListUpdated', { + reason: 'NEW_MESSAGE_RECEIVED', + conversation: conversationUpdate, + message: message + }) + } catch (error) { + console.error('更新会话列表失败:', error) + } + } + + // 设置回调函数 + setCallback(event, callback) { + this.callbacks[event] = callback + } + + // 触发回调 + triggerCallback(event, data) { + if (this.callbacks[event]) { + this.callbacks[event](data) + } + } + + // 清理资源(优化版:更完整的清理) + destroy() { + console.log('=== 开始清理IM资源 ===') + + // 清理所有定时器(使用统一方法) + this.clearAllTimers() + + // 清理消息缓存(已弃用) + console.log('缓存功能已移除') + + // 清理TIM实例 + if (this.tim) { + try { + // 移除事件监听器 + this.removeEventListeners() + + // 退出登录(同步处理,不等待结果) + if (typeof this.tim.logout === 'function') { + this.tim.logout() + .then(() => console.log('✓ TIM登出成功')) + .catch(error => console.warn('TIM登出失败:', error)) + } + } catch (error) { + console.error('清理TIM时出错:', error) + } + } + + // 重置所有状态(使用统一方法) + this.resetAllStates() + + // 清理其他状态 + this.messageList = [] + this.conversation = null + this.currentUserID = '' + this.currentUserSig = '' + this.currentConversationID = null + this.tim = null + + console.log('=== IM资源清理完成 ===') + } +} + +// 创建全局IM管理器实例 +const globalTimChatManager = new TimChatManager() + +// 全局IM初始化函数 - 支持强制重新初始化 +let initPromise = null; +const initGlobalTIM = async (userID, forceReinit = false) => { + console.log('开始初始化全局IM,userID:', userID, 'forceReinit:', forceReinit) + + if (globalTimChatManager.isInitializing) { + console.log('全局IM正在初始化中,跳过重复初始化') + return true + } + + if (forceReinit && globalTimChatManager.tim) { + console.log('强制重新初始化,清理现有IM状态') + globalTimChatManager.isLoggedIn = false + globalTimChatManager.lastLoginTime = 0 + globalTimChatManager.reconnectAttempts = 0 + + if (globalTimChatManager.tim.isLoggedIn) { + await globalTimChatManager.tim.logout() + console.log('强制重新初始化:TIM登出成功') + } + + await globalTimChatManager.initTIM(userID) + console.log('强制重新初始化完成') + return true + } + + if (!forceReinit && globalTimChatManager.tim && globalTimChatManager.isLoggedIn) { + console.log('全局IM已经初始化并登录,跳过重复初始化') + return true + } + + await globalTimChatManager.initTIM(userID) + console.log('全局IM初始化成功') + return true +} +const getInitIMPromise = async (userID, forceReinit) => { + if (initPromise) return initPromise; + initPromise = initGlobalTIM(userID, forceReinit); + return initPromise; +} +const clearInitIMPromise = () => { + initPromise = null; +} + +// 获取群聊列表(全局函数) +const getGroupList = async () => { + return await globalTimChatManager.getGroupList() +} + +// 进入群聊 +const enterChatGroupRoom = async (chatGroup, navigateType = 'navigateTo', viewType) => { + const groupListResult = await globalTimChatManager.getGroupList(); + const groupList = groupListResult && Array.isArray(groupListResult.groupList) ? groupListResult.groupList : []; + let group = groupList.find(g => g.groupID === chatGroup.groupID); + + const navigateFn = ['navigateTo', 'redirectTo', 'reLaunch'].includes(navigateType) ? navigateType : 'navigateTo' + uni[navigateFn]({ + url: `/pages-im/IM/index?conversationID=GROUP${chatGroup.groupID}&groupID=${chatGroup.groupID}&conversationType=GROUP&viewType=${viewType}`, + }) +} + +// 检查全局IM状态 +const checkGlobalIMStatus = () => { + return globalTimChatManager.isLoggedIn +} + +// 清理指定会话的缓存 +const clearConversationCache = (conversationID) => { + return globalTimChatManager.clearConversationCache(conversationID) +} + +// 清理所有消息缓存 +const clearAllMessageCache = () => { + return globalTimChatManager.clearAllCache() +} + +// 确保全局IM连接 +const ensureGlobalIMConnection = async () => { + return await globalTimChatManager.ensureIMConnection() +} + +// 获取全局IM登录状态 +const getGlobalIMLoginStatus = () => { + return globalTimChatManager.isLoggedIn +} + +// 设置全局IM回调 +const setGlobalIMCallback = (event, callback) => { + globalTimChatManager.setCallback(event, callback) +} + +// 导出 +export { + TimChatManager, + globalTimChatManager, + initGlobalTIM, + getInitIMPromise, + clearInitIMPromise, + enterChatGroupRoom, + checkGlobalIMStatus, + ensureGlobalIMConnection, + getGlobalIMLoginStatus, + setGlobalIMCallback, + getGroupList, + clearConversationCache, + clearAllMessageCache +} + +export default globalTimChatManager