2026-07-27 11:26:39 +08:00

1395 lines
38 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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