1395 lines
38 KiB
Vue
1395 lines
38 KiB
Vue
|
|
<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>
|