hn-hlw-app/pages/chat/index.vue

1395 lines
38 KiB
Vue
Raw Normal View History

2026-07-27 11:26:39 +08:00
<template>
<info-window :elderMode="elderMode" :visible="infoVisible" :order="consult" @close="infoVisible = false" />
<full-page ref="pageRef" mainStyle="background:#fff">
<template #header>
<consult-head v-if="consult" :consult="consult" @viewSummary="viewSummary" />
</template>
<scroll-view scroll-y class="message-scroll-view" :scroll-into-view="scrollToMessageID"
:scroll-with-animation="true" :enable-back-to-top="true" @scrolltoupper="onScrollToUpper"
@refresherrefresh="onRefresh" refresher-enabled :refresher-triggered="isRefreshing" ref="scrollViewRef">
<!-- 将网络错误提示移到scroll-view内部顶部 -->
<view v-if="!networkAvailable" class="network-error" :class="{ 'network-error--elder': elderMode }">
<view class="network-error-content" :class="{ 'network-error-content--elder': elderMode }">
<text :class="{ 'text--elder': elderMode }">网络连接异常请检查网络</text>
<button class="retry-btn" :class="{ 'retry-btn--elder': elderMode }" @click="retryConnection">重试</button>
</view>
</view>
<!-- 消息接收超时提示 -->
<view v-if="messageReceiveTimeout && networkAvailable" class="message-timeout-error"
:class="{ 'message-timeout-error--elder': elderMode }">
<view class="message-timeout-content" :class="{ 'message-timeout-content--elder': elderMode }">
<text :class="{ 'text--elder': elderMode }">长时间未收到消息连接可能已断开</text>
<button class="retry-btn" :class="{ 'retry-btn--elder': elderMode }" @click="handleMessageTimeout">
重连
</button>
</view>
</view>
<view style="padding: 20rpx 30rpx" :class="{ 'message-container--elder': elderMode }">
<view v-if="isMore" class="system-message" :class="{ 'system-message--elder': elderMode }"
style="margin-top: 20rpx" @click="loadMoreMessages">
<text v-if="loading" :class="{ 'text--elder': elderMode }">加载中...</text>
<text v-else :class="{ 'text--elder': elderMode }">查看更多</text>
</view>
<view v-for="(item, index) in messageList" :id="`msg-${item.ID}`" :key="item.ID || index"
:class="'message-li ' + item.flow + (elderMode ? ' message-li--elder' : '')">
<view v-if="messageTime[item.ID]" class="time-message" :class="{ 'time-message--elder': elderMode }">
{{ messageTime[item.ID] }}
</view>
<chat-message v-if="item.type === 'TIMImageElem' || item.type === 'TIMTextElem'" :message="item" />
<ConsultMessageCustom v-else-if="item.type === 'TIMCustomElem'" :content="item" :messageItem="item"
:partnerAvatar="partnerInfo && partnerInfo.avatar" />
</view>
<!-- 添加一个底部元素用于滚动到最底部 -->
<view id="msg-bottom" style="height: 20px"></view>
</view>
</scroll-view>
<template #footer>
<consult-countdown :expireTime="consult?.expireTime" @countdown-finished="closeChat" v-if="
consult?.orderStatus === 'processing' ||
consult?.orderStatus === 'completed'
">
</consult-countdown>
<view v-if="canSendMessage && footerBtnMode === 'settle'" class="bg-light-gray px-15 pb-10"
:style="elderVarStyle">
<view class="flex mb-10">
<view class="border bg-white inline px-10 py-5 rounded-full text-base text-dark"
@click="footerBtnMode = 'input'">回复医生</view>
</view>
<view class="w-full py-12 text-center text-base text-white bg-primary rounded" @click="settleRx()">
{{ orderRx.btnText }}
</view>
</view>
<template v-else-if="canSendMessage && footerBtnMode === 'input'">
<view v-if="orderRx && orderRx.prescriptionType === 'onlineMedicinePurchase'" class="px-15 flex bg-light-gray"
:style="elderVarStyle">
<view class="px-10 py-5 rounded-full bg-primary text-base text-white" @click="settleRx()">
{{ orderRx.btnText }}
</view>
</view>
<!-- 若处于接听模式则不显示输入框 -->
<input-bar v-if="!rtcPendingOrderId && !rtcConnected && !rtcConfig" @sendImage="chooseImage"
@sendMessage="sendTextMessage" />
</template>
</template>
</full-page>
</template>
<script setup>
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import { onLoad, onUnload, onHide,onShow } from "@dcloudio/uni-app";
import dayjs from "dayjs";
import { useIMStore } from "@/store/tim";
import useElder from "@/hooks/useElder";
import useUser from "@/hooks/useUser";
import {
getPatientOrder,
getMrtcConfig,
notifyVideoRoomEnded,
getRxMedicinePurchaseOrderId,
getRxMedicineOrderStatus
} from "@/utils/api";
import { toast, loading as showLoading, hideLoading } from "@/utils/widget";
import formatMessageTime from "./formatMessageTime";
import fullPage from "@/components/full-page.vue";
import chatMessage from "./chat-message.vue";
import consultHead from "./consult-head.vue";
import consultCountdown from "@/components/consult-countdown.vue";
import inputBar from "./input-bar.vue";
import infoWindow from "./info-window.vue";
import ConsultMessageCustom from "@/components/custom-message/consult-message-custom.vue";
// 长辈模式状态
const { elderMode, elderVarStyle } = storeToRefs(useElder());
const { userInfo } = useUser();
const imStore = useIMStore();
const conversationID = ref("");
const scrollToMessageID = ref("");
const partnerInfo = ref(null);
const consult = ref(null);
const pageRef = ref();
const orderId = ref("");
const loading = ref(false);
const isCompleted = ref(false); // 是否已加载完所有历史消息
const networkAvailable = ref(true); // 网络状态
const isRefreshing = ref(false); // 下拉刷新状态
const pollingTimer = ref(null); // 轮询定时器
const reconnectAttempts = ref(0); // 重连尝试次数
const MAX_RECONNECT_ATTEMPTS = 5; // 最大重连次数
const POLLING_INTERVAL = 10000; // 轮询间隔 10 秒
const infoVisible = ref(false);
// 消息接收超时检测相关变量
const lastMessageTime = ref(Date.now()); // 最后一次收到消息的时间
const messageTimeoutTimer = ref(null); // 消息超时检测定时器
const messageReceiveTimeout = ref(false); // 是否消息接收超时
const MESSAGE_TIMEOUT = 30000; // 30秒无消息判定为超时
const orderRx = ref(null);
const footerBtnMode = ref('input'); // input settle
// 来电铃声(静态资源,支持循环播放;部分机型可能需要用户交互后才能播放)
let rtcRingtoneCtx = null;
function startRtcRingtone() {
try {
if (!uni || typeof uni.createInnerAudioContext !== "function") return;
if (!rtcRingtoneCtx) {
rtcRingtoneCtx = uni.createInnerAudioContext();
rtcRingtoneCtx.loop = true;
rtcRingtoneCtx.src = "/static/audio/phone_ringing.mp3";
}
if (typeof rtcRingtoneCtx.play === "function") {
rtcRingtoneCtx.play();
}
} catch (e) {
console.warn("[VIDEO][APP] startRtcRingtone failed", e);
}
}
function stopRtcRingtone() {
try {
if (!rtcRingtoneCtx) return;
if (typeof rtcRingtoneCtx.stop === "function") {
rtcRingtoneCtx.stop();
}
} catch (e) {
console.warn("[VIDEO][APP] stopRtcRingtone failed", e);
}
}
// 音视频问诊相关状态
const rtcVisible = ref(false); // 兼容旧逻辑,当前不再控制界面显示
const rtcConnected = ref(false); // 是否已经接通
const rtcMinimized = ref(false); // 是否处于小窗模式(当前不再使用)
const rtcConfig = ref(null); // 当前通话使用的 Config
const rtcPendingConfig = ref(null); // 来电待接通时保存的 Config
const rtcPendingOrderId = ref(""); // 来电对应的订单号
const rtcSessionActive = ref(false); // 是否存在进行中/待接通的通话会话(用于兜底同步挂断)
const rtcCallMode = ref("join"); // 'start' | 'join',默认患者加入房间
const rtcCurrentUserId = ref(""); // 当前用户在音视频服务中的 userId
const rtcCallDuration = ref(0); // 通话时长(秒)
let rtcDurationTimer = null;
const rtcMiniPosition = ref({ x: 12, y: 120 }); // 小窗位置px
const rtcDragging = ref(false);
const rtcDragStart = ref({ x: 0, y: 0, originX: 0, originY: 0 });
let rtcMiniBoundsInited = false;
const rtcMiniBounds = {
maxX: 0,
maxY: 0,
};
const RTC_MINI_WIDTH = 160; // px
const RTC_MINI_HEIGHT = 220; // px
// 通话时长展示
const rtcDurationText = computed(() => {
const total = rtcCallDuration.value || 0;
const minutes = String(Math.floor(total / 60)).padStart(2, "0");
const seconds = String(total % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
});
// 小窗样式
const rtcMiniStyle = computed(() => ({
top: `${rtcMiniPosition.value.y}px`,
left: `${rtcMiniPosition.value.x}px`,
}));
// 规范化医生展示名称:避免显示类似 30127 这种纯工号
function normalizeDoctorName(raw) {
const name = (raw || "").trim();
if (!name) return "互联网医院医生";
// 纯数字视为工号,用统一文案替代
if (/^\d+$/.test(name)) return "互联网医院医生";
return name;
}
// 来电界面展示的医生名称与医院名称
const doctorDisplayName = computed(() => {
if (!consult.value) return "";
const raw =
consult.value.doctorName ||
consult.value.doctorRealName ||
consult.value.doctor ||
"";
return normalizeDoctorName(raw);
});
const doctorHospitalText = computed(() => {
// 如后续有真实医院字段,可替换为 consult.value.xxx
return "震元堂互联网医院";
});
// 视频问诊信令类型
const VIDEO_SIGNAL = {
INVITE: "VIDEO_CONSULT_INVITE",
CANCEL: "VIDEO_CONSULT_CANCEL",
REJECT: "VIDEO_CONSULT_REJECT",
ACCEPT: "VIDEO_CONSULT_ACCEPT",
HANGUP: "VIDEO_CONSULT_HANGUP",
BUSY: "VIDEO_CONSULT_BUSY",
};
function isHiddenRtcSignalMessage(msg) {
try {
if (!msg || msg.type !== "TIMCustomElem") return false;
const p = msg.payload || {};
if (p.description !== "VIDEO_CONSULT") return false;
const ext = typeof p.extension === "string" ? p.extension.trim() : "";
return !ext;
} catch (e) {
return false;
}
}
// 消息列表raw 用于业务处理display 用于列表展示(过滤掉仅用于信令的空消息)
const messageListRaw = computed(() => imStore.messageList);
const messageList = computed(() => {
const list = messageListRaw.value || [];
return list.filter((m) => !isHiddenRtcSignalMessage(m));
});
const messageTime = computed(() =>
messageList.value.reduce((acc, item, index) => {
const preTime = messageList.value[index - 1]
? messageList.value[index - 1].time
: 0;
acc[item.ID] = formatMessageTime(item.time, preTime);
return acc;
}, {})
);
// 是否显示"查看更多"按钮
const isMore = computed(() => {
return messageList.value.length >= 15 && !isCompleted.value;
});
// 监听收到的消息
watch(
() => messageListRaw.value.length,
async (newLength, oldLength) => {
if (newLength > 0) {
// 如果是新消息(消息长度增加)
if (newLength > oldLength) {
// 获取最新的一条消息
const lastMessage = messageListRaw.value[newLength - 1];
await scrollToLatestMessage();
if (lastMessage && lastMessage.type === "TIMCustomElem") {
handleCustomMessage(lastMessage);
getOrderDetail();
}
// 成功接收到消息,重置重连尝试次数和更新最后消息时间
reconnectAttempts.value = 0;
lastMessageTime.value = Date.now();
messageReceiveTimeout.value = false; // 重置超时状态
}
}
}
);
const canSendMessage = computed(
() => consult.value && !["cancelled", "finished"].includes(consult.value.orderStatus)
);
// 处理 TIM 自定义消息(包含视频问诊信令)
function handleCustomMessage(message) {
const payload = message.payload || {};
const type = payload.data;
console.log("[VIDEO][APP] handleCustomMessage", {
id: message && message.ID,
type: message && message.type,
payload,
});
if (!type) return;
if (payload.description !== "VIDEO_CONSULT") return;
// 只处理来自对端(医生)的信令,避免自己发送的信令触发本地逻辑
if (message && message.flow && message.flow !== "in") return;
switch (type) {
case VIDEO_SIGNAL.INVITE:
handleRtcIncomingInvite(payload);
break;
case VIDEO_SIGNAL.CANCEL:
// 只有当前仍存在会话时才触发关闭,避免历史通话记录再次触发
if (rtcSessionActive.value) {
handleRtcRemoteHangup("通话已结束,返回聊天界面");
}
break;
case VIDEO_SIGNAL.HANGUP:
if (rtcSessionActive.value) {
handleRtcRemoteHangup("通话已结束,返回聊天界面");
}
break;
default:
console.log("[VIDEO][APP] custom message not video signal", type);
break;
}
}
// 收到医生发起的视频问诊邀请
function handleRtcIncomingInvite(payload) {
// 已有通话或待接通时通知医生忙线(不区分界面是否可见)
if (rtcConnected.value || rtcPendingOrderId.value) {
sendRtcSignal(VIDEO_SIGNAL.BUSY).catch((error) => {
console.error("发送忙线信令失败:", error);
});
return;
}
// 使用当前页面的 orderId 记录待接通会话
rtcPendingOrderId.value = orderId.value || "";
rtcPendingConfig.value = null;
rtcCallMode.value = "join";
rtcSessionActive.value = true;
// 不再自动接听,交给自研来电 UI 中的“接听/拒绝”按钮触发 handleRtcAccept/handleRtcReject
startRtcRingtone();
}
// 发送视频问诊信令消息
const sendRtcSignal = async (signal) => {
if (!conversationID.value) {
console.warn("[VIDEO][APP] sendRtcSignal no conversationID", signal);
return;
}
// 信令消息仅用于驱动通话状态,不在聊天列表中展示(通话记录由医生端统一发送)
const extensionText = "";
console.log("[VIDEO][APP] sendRtcSignal", {
signal,
conversationID: conversationID.value,
extensionText,
});
try {
await imStore.sendCustomMessage(
{
data: signal,
extension: extensionText,
description: "VIDEO_CONSULT",
},
conversationID.value
);
} catch (error) {
console.error("发送视频问诊信令失败:", error);
}
};
// 构建音视频通话 Config补齐 displayInfo 等满足业务要求
function buildRtcConfig(baseConfig) {
const config = { ...(baseConfig || {}) };
// 基础必填参数userId / appId
if (!config.userId) {
// 优先用当前通话 userId否则用 IM 的 userId再退回占位
config.userId =
rtcCurrentUserId.value ||
(imStore.userInfo &&
(imStore.userInfo.userID || imStore.userInfo.userId)) ||
"mockUser";
}
if (!config.appId) {
// 使用当前小程序 appId来自 manifest.json mp-alipay.appid
config.appId = "2021005110656858";
}
// 一些默认的媒体参数
if (typeof config.resolution === "undefined") {
// 2 = 1280x720测试 720P 画质
config.resolution = 2;
}
if (typeof config.fps === "undefined") {
config.fps = 30;
}
// streamInfo 默认参数(只在缺失时补充,真实环境请用后端下发的参数覆盖)
const streamInfo = config.streamInfo || {};
streamInfo.serverUrl =
streamInfo.serverUrl || "wss://cn-hangzhou-mrtc.cloud.alipay.com/ws";
config.streamInfo = streamInfo;
// 使用插件作为媒体组件:关闭接听页,自定义通话中控制按钮
const displayInfo = config.displayInfo || {};
displayInfo.showFullScreen = {
visible: true, // 显示全部画面,不裁切
};
displayInfo.callPage = {
...(displayInfo.callPage || {}),
// 关闭插件接听页,避免与自研来电弹窗冲突
visible: false,
};
displayInfo.answer = {
...(displayInfo.answer || {}),
// 默认全屏展示医生画面player本地画面pusher以小窗显示
pusher: {
visible: true,
isMini: true,
...(displayInfo.answer && displayInfo.answer.pusher),
},
player: {
visible: true,
isMini: false,
...(displayInfo.answer && displayInfo.answer.player),
},
// 通话中按钮权限:只保留翻转摄像头和挂断,其余全部隐藏
actions: {
...(displayInfo.answer && displayInfo.answer.actions),
hangup: {
...(displayInfo.answer && displayInfo.answer.actions && displayInfo.answer.actions.hangup),
visible: true,
},
mute: {
visible: false,
},
screenshot: {
visible: false,
},
enableCamera: {
visible: false,
},
switchCamera: {
...(displayInfo.answer && displayInfo.answer.actions && displayInfo.answer.actions.switchCamera),
visible: true,
},
switchScreenCapture: {
visible: false,
},
},
// 不展示插件自带的用户列表,避免在通话界面露出纯数字 uid如 30127
users: {
...(displayInfo.answer && displayInfo.answer.users),
visible: false,
},
};
// 挂断后的 end 页面仅配置空文案,实际 UI 通过本地浮层控制,不再依赖插件页
displayInfo.end = {
...(displayInfo.end || {}),
visible: false,
information: "视频问诊已结束,请返回聊天页面",
};
config.displayInfo = displayInfo;
// 兼容文档中的 users_visible 开关,进一步关闭用户列表
config.users_visible = false;
config.controlInfo = {
...(config.controlInfo || {}),
enableCamera: true,
};
// 启动时默认打开摄像头(对应文档中的 controlInfo_enableCamera
config.controlInfo_enableCamera = true;
// 接听界面提示文案,使用医生名称
if (consult.value) {
const rawDoctorName =
consult.value.doctorName ||
consult.value.doctorRealName ||
consult.value.doctor ||
"";
const doctorName = normalizeDoctorName(rawDoctorName);
if (doctorName) {
const text = `${doctorName}发起的视频问诊`;
// 兼容 calling_information 与 displayInfo.calling.information 两种配置方式
if (!config.calling_information) {
config.calling_information = text;
}
config.displayInfo.calling = {
...(config.displayInfo.calling || {}),
information: text,
};
}
}
return config;
}
// 启动通话时长计时
function startRtcDurationTimer() {
stopRtcDurationTimer();
rtcCallDuration.value = 0;
rtcDurationTimer = setInterval(() => {
rtcCallDuration.value += 1;
}, 1000);
}
function stopRtcDurationTimer() {
if (rtcDurationTimer) {
clearInterval(rtcDurationTimer);
rtcDurationTimer = null;
}
}
// 初始化小窗边界与默认位置
function initRtcMiniBounds() {
if (rtcMiniBoundsInited) return;
try {
const info = uni.getSystemInfoSync();
rtcMiniBounds.maxX = Math.max(0, info.windowWidth - RTC_MINI_WIDTH - 12);
rtcMiniBounds.maxY = Math.max(
0,
info.windowHeight - RTC_MINI_HEIGHT - 120
);
rtcMiniPosition.value = {
x: rtcMiniBounds.maxX,
y: rtcMiniBounds.maxY,
};
rtcMiniBoundsInited = true;
} catch (error) {
console.error("获取窗口信息失败:", error);
}
}
// 结束通话统一清理
function endRtcSession() {
stopRtcDurationTimer();
rtcConnected.value = false;
rtcMinimized.value = false;
rtcPendingConfig.value = null;
rtcPendingOrderId.value = "";
rtcVisible.value = false;
rtcConfig.value = null;
rtcSessionActive.value = false;
stopRtcRingtone();
}
// 页面加载
onLoad(async (options) => {
uni.$on('rxHasAuditPassed', (rx) => {
orderRx.value = rx;
orderRx.value.btnText = '医保结算';
footerBtnMode.value = 'settle';
getMedicineOrderStatus()
});
if (!imStore.isLogin || !imStore.isSDKReady) {
uni.reLaunch({
url: "/pages/home/home",
});
return;
}
// 监听全局长辈模式变化事件
orderId.value = options.orderId;
getOrderDetail(true);
conversationID.value = options.conversationID;
// 监听网络状态
startNetworkListener();
try {
// 进入会话
await imStore.enterConversation(conversationID.value);
// 获取对方信息
if (conversationID.value.startsWith("C2C")) {
const userID = conversationID.value.replace("C2C", "");
try {
const { data } = await imStore.tim.getUserProfile({
userIDList: [userID],
});
if (data.length > 0) {
partnerInfo.value = data[0];
}
} catch (error) {
console.error("获取用户资料失败:", error);
uni.showToast({
title: "获取用户资料失败,请重试",
icon: "none",
});
}
}
// 初始加载完成后,滚动到最新消息
setTimeout(() => {
scrollToLatestMessage();
}, 500);
// 启动轮询获取新消息
startPollingMessages();
// 启动消息超时检测
startMessageTimeoutDetection();
} catch (error) {
console.error("聊天室初始化失败:", error);
networkAvailable.value = false;
uni.showToast({
title: "聊天室初始化失败,请检查网络",
icon: "none",
});
}
});
onShow(() => {
getMedicineOrderStatus();
})
async function settleRx() {
showLoading();
const res = await getRxMedicinePurchaseOrderId({
accountId: userInfo.value.userId,
rpNo: orderRx.value.id
});
hideLoading()
if (res && res.success) {
if (res.status === 'unpay') {
uni.navigateTo({
url: `/pages/consult/drug-purchase-order/drug-purchase-order?id=${res.id}`
})
}
else if (res.status === 'expired') {
toast('订单已过期');
}
else {
uni.navigateTo({
url: `/pages/consult/drug-purchase-list/detail?id=${res.id}`
})
}
} else {
toast(res.message || '获取在线配药订单失败');
}
}
async function getMedicineOrderStatus() {
if (orderRx.value && orderRx.value.id) {
const res = await getRxMedicineOrderStatus({
accountId: userInfo.value.userId,
id: orderRx.value.id
});
if (res && res.success) {
const { expired, orderStatus } = res.data;
if (!expired && ['none', 'unpay'].includes(orderStatus)) {
orderRx.value.btnText = '医保结算';
} else {
orderRx.value.btnText = '查看订单';
}
}
}
}
// 在页面挂载完成后也尝试滚动到底部
onMounted(() => {
setTimeout(() => {
scrollToLatestMessage();
}, 1000);
});
// 页面卸载
onUnload(() => {
uni.$off('rxHasAuditPassed');
// 清空当前会话
imStore.currentConversation = null;
// 停止轮询
stopPollingMessages();
// 移除网络状态监听
stopNetworkListener();
// 停止消息超时检测
stopMessageTimeoutDetection();
// 结束可能存在的视频问诊:交由插件自身的 onHangup/onError 事件处理,不在页面卸载时强制挂断,
// 避免与插件的接听/通话流程冲突导致刚接通就被立即挂断。
endRtcSession();
});
// 页面隐藏或返回上一页时,也确保挂断并清理通话状态
onHide(() => {
// 避免通话中页面隐藏导致状态被清空,从而无法处理“医生挂断”的同步信令
if (!rtcSessionActive.value) {
endRtcSession();
}
});
// 组件销毁时清理
onUnmounted(() => {
stopPollingMessages();
stopNetworkListener();
stopMessageTimeoutDetection();
endRtcSession();
});
// 滚动到顶部时触发加载更多
const onScrollToUpper = () => {
if (isMore.value && !loading.value) {
loadMoreMessages();
}
};
// 滚动到最新消息 - 改进版
const scrollToLatestMessage = async () => {
await nextTick();
return new Promise((resolve) => {
setTimeout(() => {
if (messageList.value.length > 0) {
// 优先尝试滚动到底部元素
scrollToMessageID.value = "msg-bottom";
// 如果有消息,也可以尝试滚动到最后一条消息
const lastMessage = messageList.value[messageList.value.length - 1];
if (lastMessage) {
scrollToMessageID.value = `msg-${lastMessage.ID}`;
}
// 使用 pageRef 的 scrollToBottom 方法作为备选
if (pageRef.value && pageRef.value.scrollToBottom) {
pageRef.value.scrollToBottom();
}
// 在小程序/app环境下尝试使用uni的API滚动
// #ifdef MP || APP-PLUS
const query = uni.createSelectorQuery();
query
.select("#msg-bottom")
.boundingClientRect((data) => {
if (data) {
uni.pageScrollTo({
scrollTop: data.bottom,
duration: 300,
});
}
})
.exec();
// #endif
}
resolve();
}, 100);
});
};
// 加载更多消息
const loadMoreMessages = async () => {
if (messageList.value.length === 0 || loading.value || isCompleted.value)
return;
loading.value = true;
try {
const firstMessage = messageList.value[0];
const res = await imStore.getMessageList(
conversationID.value,
50,
firstMessage.ID
);
// 更新是否已加载完所有消息的状态
isCompleted.value = res.data?.isCompleted || false;
if (res.data && res.data.messageList && res.data.messageList.length > 0) {
// 滚动到新加载的第一条消息
setTimeout(() => {
const firstVisible = res.data.messageList.find((m) => !isHiddenRtcSignalMessage(m)) || res.data.messageList[0];
if (firstVisible && firstVisible.ID) {
scrollToMessageID.value = `msg-${firstVisible.ID}`;
}
}, 100);
} else {
// 没有更多消息了
isCompleted.value = true;
uni.showToast({
title: "没有更多消息了",
icon: "none",
});
}
} catch (error) {
console.error("加载更多消息失败:", error);
uni.showToast({
title: "加载失败,请重试",
icon: "none",
});
} finally {
loading.value = false;
}
};
// 发送文本消息
const sendTextMessage = async (inputMessage) => {
if (!inputMessage || inputMessage.trim() === "") return;
try {
await imStore.sendTextMessage(inputMessage, conversationID.value);
// 发送成功后滚动到底部
await scrollToLatestMessage();
} catch (error) {
console.error("发送文本消息失败:", error);
uni.showToast({
title: "发送失败",
icon: "none",
});
}
};
// 选择图片
const chooseImage = (source) => {
uni.chooseImage({
count: 1,
sizeType: ["compressed"],
sourceType: [source],
success: async (res) => {
try {
if (res.tempFiles && res.tempFiles.length > 0) {
const file = res;
await imStore.sendImageMessage(file, conversationID.value);
// 发送成功后滚动到底部
await scrollToLatestMessage();
} else {
uni.showToast({
title: "未选择图片",
icon: "none",
});
}
} catch (error) {
console.error("发送图片消息失败:", error);
uni.showToast({
title: "发送失败",
icon: "none",
});
}
},
});
};
function viewSummary() {
uni.setStorageSync("chat-room-consult", JSON.stringify(consult.value));
uni.navigateTo({
url: "/pages/record/disease-summary",
});
}
function closeChat() {
uni.reLaunch({
url: "/pages/home/home",
});
}
async function getOrderDetail(showInfoWindow = false) {
const { success, data, message } = await getPatientOrder({
orderId: orderId.value,
});
if (success) {
consult.value = data;
if (showInfoWindow) {
judgeDisplayInfoWindow(data);
}
} else {
consult.value = null;
}
}
function judgeDisplayInfoWindow(order) {
let list = [];
try {
let displayRecord = JSON.parse(uni.getStorageSync("info-window-display-record") || '[]');
list = Array.isArray(displayRecord) ? displayRecord : [];
} catch (e) { };
const displayed = list.some(item => item.orderId === order.orderId && order.orderId);
const shouldDisplay = ['pending', 'processing', 'completed'].includes(order.orderStatus);
if (displayed || !shouldDisplay) return;
const date = dayjs().format('YYYY-MM-DD');
list.push({ date, orderId: order.orderId });
uni.setStorageSync("info-window-display-record", JSON.stringify(list.filter(item => item.date === date)));
infoVisible.value = true;
}
// 启动网络状态监听
const startNetworkListener = () => {
uni.onNetworkStatusChange(({ isConnected }) => {
networkAvailable.value = isConnected;
if (isConnected) {
// 网络恢复时自动重试连接
retryConnection();
} else {
// 网络断开时停止轮询
stopPollingMessages();
uni.showToast({
title: "网络连接已断开",
icon: "none",
});
}
});
// 初始检查网络状态
uni.getNetworkType({
success: ({ networkType }) => {
networkAvailable.value = networkType !== "none";
},
});
};
// 停止网络状态监听
const stopNetworkListener = () => {
uni.offNetworkStatusChange();
};
// 启动轮询获取新消息
const startPollingMessages = () => {
// 清除可能存在的旧定时器
stopPollingMessages();
// 创建新的轮询定时器
pollingTimer.value = setInterval(async () => {
if (networkAvailable.value && conversationID.value) {
try {
// 刷新消息列表
await imStore.enterConversation(conversationID.value);
// 检查是否长时间没有新消息
checkMessageReceiveTimeout();
} catch (error) {
console.error("轮询获取消息失败:", error);
reconnectAttempts.value++;
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
stopPollingMessages();
networkAvailable.value = false;
uni.showToast({
title: "消息同步失败,请手动刷新",
icon: "none",
});
}
}
}
}, POLLING_INTERVAL);
};
// 停止轮询
const stopPollingMessages = () => {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
};
// 重试连接
const retryConnection = async () => {
uni.showLoading({ title: "正在重新连接..." });
try {
// 检查网络状态
const { networkType } = await new Promise((resolve) => {
uni.getNetworkType({ success: resolve });
});
if (networkType !== "none") {
networkAvailable.value = true;
// 重新初始化 IM 连接
if (!imStore.isSDKReady || !imStore.isLogin) {
await imStore.initTIM();
}
// 重新进入会话
await imStore.enterConversation(conversationID.value);
// 重新获取订单信息
await getOrderDetail();
// 重新启动轮询
startPollingMessages();
// 重新启动消息超时检测
startMessageTimeoutDetection();
// 重置状态
reconnectAttempts.value = 0;
messageReceiveTimeout.value = false;
uni.showToast({
title: "连接已恢复",
icon: "success",
});
} else {
networkAvailable.value = false;
uni.showToast({
title: "网络连接失败,请检查网络设置",
icon: "none",
});
}
} catch (error) {
console.error("重连失败:", error);
networkAvailable.value = false;
uni.showToast({
title: "重连失败,请稍后再试",
icon: "none",
});
} finally {
uni.hideLoading();
}
};
// 下拉刷新处理
const onRefresh = async () => {
isRefreshing.value = true;
try {
// 尝试重新连接并刷新消息
await retryConnection();
// 再次获取消息列表
await imStore.enterConversation(conversationID.value);
uni.showToast({
title: "刷新成功",
icon: "success",
});
} catch (error) {
console.error("刷新失败:", error);
uni.showToast({
title: "刷新失败,请重试",
icon: "none",
});
} finally {
isRefreshing.value = false;
}
};
// 启动消息超时检测
const startMessageTimeoutDetection = () => {
// 停止可能存在的旧定时器
stopMessageTimeoutDetection();
// 设置新消息的最后接收时间
lastMessageTime.value = Date.now();
// 创建新的检测定时器
messageTimeoutTimer.value = setInterval(() => {
checkMessageReceiveTimeout();
}, MESSAGE_TIMEOUT / 2); // 每隔半个超时时间检查一次
};
// 停止消息超时检测
const stopMessageTimeoutDetection = () => {
if (messageTimeoutTimer.value) {
clearInterval(messageTimeoutTimer.value);
messageTimeoutTimer.value = null;
}
};
// 检查消息接收是否超时
const checkMessageReceiveTimeout = () => {
// 只有在网络可用但长时间没收到消息的情况下才判定为超时
if (
networkAvailable.value &&
Date.now() - lastMessageTime.value > MESSAGE_TIMEOUT
) {
messageReceiveTimeout.value = true;
console.log("消息接收超时,可能需要重连");
}
};
// 处理消息超时
const handleMessageTimeout = async () => {
uni.showLoading({ title: "正在重新连接..." });
try {
// 重新进入会话获取最新消息
await imStore.enterConversation(conversationID.value);
// 更新最后消息时间
lastMessageTime.value = Date.now();
// 重置超时状态
messageReceiveTimeout.value = false;
uni.showToast({
title: "连接已恢复",
icon: "success",
});
} catch (error) {
console.error("重连失败:", error);
uni.showToast({
title: "重连失败,请稍后再试",
icon: "none",
});
// 如果连续失败次数过多,可能是网络问题
reconnectAttempts.value++;
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
networkAvailable.value = false;
}
} finally {
uni.hideLoading();
}
};
</script>
<style>
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.message-scroll-view {
height: 100%;
width: 100%;
}
.chat-header {
height: 90rpx;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #eee;
background-color: #fff;
}
.title {
font-size: 34rpx;
font-weight: bold;
}
.message-list {
flex: 1;
padding: 20rpx;
background-color: #f5f5f5;
}
.message-item {
display: flex;
margin-bottom: 30rpx;
}
.message-item.self {
flex-direction: row-reverse;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin: 0 20rpx;
}
.message-content {
max-width: 60%;
}
.text-message {
background-color: #fff;
padding: 20rpx;
border-radius: 10rpx;
word-break: break-all;
}
.message-item.self .text-message {
background-color: #95ec69;
}
.image-message {
background-color: #fff;
padding: 10rpx;
border-radius: 10rpx;
overflow: hidden;
}
.image-message image {
max-width: 100%;
border-radius: 5rpx;
}
.unsupported-message {
background-color: #fff;
padding: 20rpx;
border-radius: 10rpx;
color: #999;
}
.input-area {
display: flex;
padding: 20rpx;
background-color: #f5f5f5;
border-top: 1px solid #eee;
}
.input-box {
flex: 1;
display: flex;
background-color: #fff;
border-radius: 10rpx;
padding: 0 20rpx;
align-items: center;
}
input {
flex: 1;
height: 80rpx;
}
.tools {
display: flex;
}
.tool-item {
padding: 0 20rpx;
font-size: 40rpx;
}
.send-btn {
width: 120rpx;
height: 80rpx;
margin-left: 20rpx;
background-color: #07c160;
color: #fff;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10rpx;
}
.time-message {
font-size: 24rpx;
color: 999;
padding: 24rpx 30rpx;
text-align: center;
}
.system-message {
font-size: 28rpx;
color: #666;
padding: 24rpx 30rpx;
text-align: center;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 10rpx;
margin-bottom: 20rpx;
}
.system-message:active {
background-color: rgba(0, 0, 0, 0.1);
}
.network-error {
width: 100%;
background-color: rgba(255, 0, 0, 0.1);
z-index: 100;
padding: 20rpx;
box-sizing: border-box;
/* 取消fixed定位改用相对定位 */
position: relative;
}
.network-error-content {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff1f0;
border: 1px solid #ffccc7;
border-radius: 8rpx;
padding: 20rpx 30rpx;
flex-wrap: nowrap;
}
.retry-btn {
background-color: #ff4d4f;
color: white;
font-size: 24rpx;
padding: 12rpx 24rpx;
margin-left: 20rpx;
border-radius: 8rpx;
line-height: 1.4;
min-width: 100rpx;
white-space: nowrap;
border: none;
display: inline-block;
text-align: center;
flex-shrink: 0;
}
.message-timeout-error {
width: 100%;
background-color: rgba(255, 165, 0, 0.1);
z-index: 100;
padding: 20rpx;
box-sizing: border-box;
position: relative;
}
.message-timeout-content {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff7e6;
border: 1px solid #ffe7ba;
border-radius: 8rpx;
padding: 20rpx 30rpx;
flex-wrap: nowrap;
}
.message-timeout-error .retry-btn {
background-color: #fa8c16;
}
/* 长辈模式样式 */
.text--elder {
font-size: 32rpx !important;
/* 28rpx * 1.14 */
font-weight: 500 !important;
}
.message-container--elder {
padding: 28rpx 40rpx !important;
/* 增加内边距 */
}
.system-message--elder {
font-size: 36rpx !important;
/* 28rpx * 1.3 */
padding: 32rpx 40rpx !important;
/* 增加内边距 */
border-radius: 12rpx !important;
font-weight: 500 !important;
}
.time-message--elder {
font-size: 30rpx !important;
/* 24rpx * 1.25 */
padding: 30rpx 40rpx !important;
font-weight: 500 !important;
}
.message-li--elder {
margin-bottom: 40rpx !important;
/* 增加消息间距 */
}
.network-error--elder {
padding: 28rpx !important;
}
.network-error-content--elder {
padding: 28rpx 40rpx !important;
border-radius: 12rpx !important;
}
.message-timeout-error--elder {
padding: 28rpx !important;
}
.message-timeout-content--elder {
padding: 28rpx 40rpx !important;
border-radius: 12rpx !important;
}
.retry-btn--elder {
font-size: 30rpx !important;
/* 24rpx * 1.25 */
padding: 18rpx 40rpx !important;
/* 增加按钮内边距 */
border-radius: 12rpx !important;
line-height: 1.4 !important;
font-weight: bold !important;
min-width: 140rpx !important;
white-space: nowrap !important;
text-align: center !important;
flex-shrink: 0 !important;
}
.bg-light-gray {
background: #f0f0f0;
}
</style>