Files
zyt/TUICallKit-Vue3/TUIKit/pages/chat/chat.vue
T
2026-05-07 16:42:29 +08:00

2508 lines
66 KiB
Vue
Raw 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>
<view
class="chat-container"
@touchend.capture="onRecordingTouchEnd"
@touchcancel.capture="onRecordingTouchCancel"
>
<!-- 顶部导航栏 -->
<!-- 消息列表 -->
<scroll-view
class="message-list"
scroll-y
:scroll-top="scrollTop"
scroll-with-animation
@scroll="onScroll"
>
<view class="message-container">
<!-- 医生卡片视频通话开始或结束时自动隐藏 -->
<view v-if="doctorInfo && showDoctorCard" class="doctor-card" @click="goDoctorDetail">
<view class="doctor-card-topbar">
<text class="doctor-card-label">接诊医生</text>
<view class="doctor-card-status-pill" :class="{ 'doctor-card-status-pill--entered': doctorEnteredConsultRoom }">
<view class="doctor-card-status-pulse" :class="{ 'doctor-card-status-pulse--entered': doctorEnteredConsultRoom }"></view>
<text class="doctor-card-status-text">{{ doctorEnteredConsultRoom ? '已进入诊室' : '等待接诊' }}</text>
</view>
</view>
<view class="doctor-card-main">
<view class="doctor-card-avatar-wrap">
<view class="doctor-card-avatar-ring">
<image :src="doctorInfo.avatar || '/static/user/user.png'" class="doctor-card-avatar" mode="aspectFill" />
</view>
</view>
<view class="doctor-card-info">
<text class="doctor-card-name">{{ doctorInfo.name || '医生' }}</text>
<view class="doctor-card-meta">
<text class="doctor-card-title">{{ doctorInfo.title || '主任医师' }}</text>
<text class="doctor-card-dot">·</text>
<text class="doctor-card-hospital">{{ doctorInfo.hospital || '甄养堂医院' }}</text>
</view>
</view>
<view class="doctor-card-chevron" aria-hidden="true">
<text class="doctor-card-chevron-icon"></text>
</view>
</view>
<view class="doctor-card-tip">
<view class="doctor-card-tip-bar"></view>
<view class="doctor-card-tip-inner">
<text class="doctor-card-tip-title">{{ doctorEnteredConsultRoom ? '面诊提示' : '温馨提示' }}</text>
<text class="doctor-card-tip-text">{{ doctorEnteredConsultRoom ? '医生已进入诊室,即将为您视频面诊,请保持网络畅通' : '请稍候,医生进入诊室后将与您在线沟通' }}</text>
</view>
</view>
</view>
<view v-for="(msg, index) in messages" :key="index" class="message-wrapper" :class="[msg.flow === 'out' ? 'message-wrapper-out' : 'message-wrapper-in', msg.type === 'confirmation' ? 'message-wrapper-confirmation' : '']">
<!-- 头像 -->
<!-- 头像 -->
<view class="avatar" :class="msg.flow === 'out' ? 'avatar-out' : 'avatar-in'">
<image
v-if="msg.avatar"
:src="msg.avatar"
class="avatar-image"
mode="aspectFill"
/>
<text v-else class="avatar-text">{{ msg.flow === 'out' ? '我' : '医' }}</text>
</view>
<!-- 消息内容 -->
<view class="message-bubble" :class="[msg.flow === 'out' ? 'bubble-out' : 'bubble-in', msg.type === 'confirmation' ? 'bubble-confirmation' : '']">
<!-- 知情确认独立卡片样式 -->
<view v-if="msg.type === 'confirmation'" class="confirmation-card">
<view class="confirmation-card-head">
<view class="confirmation-card-badge">
<text class="confirmation-card-badge-icon"></text>
</view>
<view class="confirmation-card-head-text">
<text class="confirmation-card-title">知情同意确认</text>
<text class="confirmation-card-sub">请确认您已阅读并同意以下内容</text>
</view>
</view>
<view class="confirmation-card-divider"></view>
<view class="confirmation-card-lines">
<text v-for="(line, li) in getConfirmationLines(msg.text)" :key="li" class="confirmation-line">{{ line }}</text>
</view>
</view>
<!-- 文本消息 -->
<text v-if="msg.type === 'text'" class="message-text">{{ msg.text }}</text>
<!-- 图片消息 -->
<image v-if="msg.type === 'image'" class="message-image" :src="msg.imageUrl" mode="widthFix" @click="previewImage(msg.imageUrl)" />
<!-- 文件消息 -->
<view v-if="msg.type === 'file'" class="message-file" @click="downloadFile(msg)">
<view class="file-icon-wrapper">
<text class="file-icon">*</text>
</view>
<view class="file-details">
<text class="file-name">{{ msg.fileName }}</text>
<text class="file-size">{{ formatFileSize(msg.fileSize) }}</text>
</view>
<text class="download-icon"></text>
</view>
<!-- 表情消息 -->
<text v-if="msg.type === 'face'" class="message-emoji">{{ msg.emoji }}</text>
<!-- 语音消息 -->
<view v-if="msg.type === 'audio'" class="message-audio" @click="playAudio(msg)">
<text class="audio-icon">{{ playingAudioId === msg.audioId ? '⏸' : '▶' }}</text>
<text class="audio-duration">{{ msg.duration || 0 }}</text>
</view>
<!-- 消息时间 -->
<view class="message-meta">
<text class="message-time">{{ formatTime(msg.time) }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="messages.length === 0" class="empty-state">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无消息</text>
<text class="empty-hint">开始聊天吧</text>
</view>
<!-- 底部占位确保最后一条消息不被遮挡 -->
<view class="bottom-placeholder"></view>
</view>
</scroll-view>
<!-- 输入区域 -->
<view class="input-container" :style="{ paddingBottom: keyboardHeight + 'px' }">
<!-- 工具栏 -->
<view class="toolbar">
<!-- <view class="tool-item" @click="showEmojiPicker">
<uni-icons type="tool " size="30" color="#999"></uni-icons>
<text class="tool-label">表情</text>
</view> -->
<view class="tool-item tool-item-left" @click="toggleInputMode">
<uni-icons :type="inputMode === 'voice' ? 'compose' : 'mic'" size="28" color="#576b95"></uni-icons>
<text class="tool-label" v-if="inputMode === 'voice'">文字</text>
<text class="tool-label" v-else>语音</text>
</view>
<view class="tool-item" @click="chooseImage">
<uni-icons type="image " size="30" color="#576b95"></uni-icons>
<text class="tool-label">图片</text>
</view>
<view class="tool-item" @click="chooseFile">
<uni-icons type="paperclip " size="30" color="#576b95"></uni-icons>
<text class="tool-label">文件</text>
</view>
</view>
<!-- 输入区域文字模式 -->
<view v-if="inputMode === 'text'" class="input-wrapper">
<input
class="message-input"
v-model="inputText"
placeholder="请输入消息内容..."
:adjust-position="false"
:hold-keyboard="true"
@focus="onInputFocus"
@blur="onInputBlur"
@confirm="sendTextMessage"
/>
<view class="send-button" :class="{ 'send-button-active': inputText.trim() }" @click="sendTextMessage">
<text class="send-text">发送</text>
</view>
</view>
<!-- 语音模式按住说话 -->
<view
v-else
class="voice-input-wrapper"
:class="{ 'voice-input-recording': isRecording }"
@touchstart="onVoiceTouchStart"
@touchmove="onVoiceTouchMove"
@touchend="onVoiceTouchEnd"
@touchcancel="onVoiceTouchCancel"
>
<text class="voice-input-text">{{ isRecording ? (willCancel ? '松开 取消' : '松开 发送') : '按住 说话' }}</text>
</view>
</view>
<!-- 录音浮层 - 微信风格 -->
<view
v-if="isRecording"
class="recording-overlay"
@click="forceCancelRecording"
@touchend="onVoiceTouchEnd"
@touchcancel="onVoiceTouchCancel"
>
<view
class="recording-popup"
:class="{ 'recording-cancel': willCancel }"
@click.stop
@touchend.stop="onVoiceTouchEnd"
@touchcancel.stop="onVoiceTouchCancel"
>
<view class="recording-icon">
<view v-if="!willCancel" class="recording-waves">
<view class="wave"></view>
<view class="wave"></view>
<view class="wave"></view>
<view class="wave"></view>
<view class="wave"></view>
</view>
<text v-else class="cancel-icon"></text>
</view>
<text class="recording-hint">{{ willCancel ? '松开手指,取消发送' : '上滑取消' }}</text>
<text class="recording-duration">{{ recordingDuration }}"</text>
</view>
</view>
<!-- 表情选择器 -->
<view v-if="showEmoji" class="emoji-panel" @click.self="showEmoji = false">
<view class="emoji-content">
<view class="emoji-header">
<text class="emoji-title">选择表情</text>
<view class="emoji-close" @click="showEmoji = false">
<text class="close-icon">✕</text>
</view>
</view>
<scroll-view class="emoji-scroll" scroll-y>
<view class="emoji-grid">
<view v-for="(emoji, index) in emojiList" :key="index" class="emoji-cell" @click="sendEmoji(emoji)">
<text class="emoji-icon">{{ emoji }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, nextTick, getCurrentInstance } from 'vue';
import { onLoad, onShow, onUnload, onHide } from '@dcloudio/uni-app';
import TencentCloudChat from '@tencentcloud/lite-chat/basic';
import conversationPlugin from '@tencentcloud/lite-chat/plugins/conversation';
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
// CallManager 在 App.vue 已挂载到 uni.CallManager,勿在此重复 new,否则会丢失通话状态
const { proxy } = getCurrentInstance()
const messages = ref([]);
const inputText = ref('');
const scrollTop = ref(0);
const showEmoji = ref(false);
const keyboardHeight = ref(0);
const userAvatar = ref(''); // 当前用户头像
const doctorInfo = ref(null); // 医生信息(对方为医生时展示)
const showDoctorCard = ref(true); // 是否显示医生卡片(通话开始/结束时隐藏)
/** 收到医生端「已进入诊室」信令后为 true(聊天列表不展示该条自定义消息) */
const doctorEnteredConsultRoom = ref(false);
const doctorWaitSeconds = ref(0); // 等待医生计时(秒)
let doctorWaitTimer = null; // 等待计时器
const inputMode = ref('text'); // 'text' | 'voice'
const isRecording = ref(false);
const recordingDuration = ref(0);
const willCancel = ref(false); // 上滑取消
let voiceModeSwitchTime = 0; // 切换语音模式的时间戳,用于防误触
let voiceHoldTimer = null; // 按住计时器,必须按住超过此时间才真正开始录音
let isVoiceTouchDown = false; // 手指是否仍在按下
const playingAudioId = ref('');
let innerAudioContext = null;
let chat = null;
let recorderManager = null;
let recordingTimer = null;
let conversationID = '';
let targetUserID = '';
let currentUserID = ''; // 当前登录用户ID
let scrollTimer = null; // 滚动定时器
/** 微信小程序:从系统后台回前台时刷新 IM/TUICall,避免无法接通视频 */
let unsubscribeWxAppShow = null;
let allowChatForegroundRelogin = false;
let foregroundReloginTimer = null;
/** 避免 onUnload 重复上报离开 */
let patientLeaveNotified = false;
const CONFIRMATION_MESSAGE = `我已确认以下信息:
1、本人确认已在线下确诊;
2、本人确认问诊单信息无误;
3、本人确认已知晓病情评估风险告知;
4、本人确认已阅读并同意《互联网诊疗知情同意书》。`;
function normalizeConfirmText(t) {
return String(t || '').replace(/\r\n/g, '\n').trim();
}
function isConfirmationMessageText(text) {
return normalizeConfirmText(text) === normalizeConfirmText(CONFIRMATION_MESSAGE);
}
function getConfirmationLines(text) {
return normalizeConfirmText(text).split('\n').filter((line) => line.length > 0);
}
const emojiList = [
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
'😘', '😗', '😚', '😙', '😋', '😛', '😜', '🤪',
'😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨',
'😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥',
'😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕',
'🤢', '🤮', '🤧', '🥵', '🥶', '😵', '🤯', '🤠',
'🥳', '😎', '🤓', '🧐', '😕', '😟', '🙁', '☹️',
'😮', '😯', '😲', '😳', '🥺', '😦', '😧', '😨',
'😰', '😥', '😢', '😭', '😱', '😖', '😣', '😞',
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙',
'👏', '🙌', '👐', '🤲', '🤝', '🙏', '💪', '❤️'
];
// 切换输入模式(文字/语音)
function toggleInputMode() {
const isSwitchToVoice = inputMode.value === 'text';
inputMode.value = isSwitchToVoice ? 'voice' : 'text';
// 切换到语音时:重置录音状态,防止卡住;记录时间用于防误触
if (isSwitchToVoice) {
isVoiceTouchDown = false;
if (voiceHoldTimer) {
clearTimeout(voiceHoldTimer);
voiceHoldTimer = null;
}
isRecording.value = false;
willCancel.value = false;
recordingDuration.value = 0;
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
if (recorderManager) {
try { recorderManager.stop(); } catch (_) {}
}
voiceModeSwitchTime = Date.now();
}
}
// 初始化录音管理器
function initRecorderManager() {
// #ifdef MP-WEIXIN
recorderManager = uni.getRecorderManager();
recorderManager.onStop((res) => {
if (willCancel.value) {
willCancel.value = false;
} else if (res.tempFilePath && res.duration >= 1000) {
sendAudioMessage(res);
} else if (res.duration < 1000) {
uni.showToast({ title: '录音时间太短', icon: 'none' });
}
isRecording.value = false;
recordingDuration.value = 0;
if (recordingTimer) clearInterval(recordingTimer);
});
recorderManager.onError((err) => {
console.error('录音错误:', err);
isRecording.value = false;
recordingDuration.value = 0;
if (recordingTimer) clearInterval(recordingTimer);
uni.showToast({ title: '录音失败', icon: 'none' });
});
// #endif
}
// 按住说话 - 开始(必须按住超过 300ms 才真正开始录音,点击一下不会触发)
function onVoiceTouchStart(e) {
// #ifdef MP-WEIXIN
// 防误触:刚切换语音模式 400ms 内忽略触摸
if (Date.now() - voiceModeSwitchTime < 400) return;
e.preventDefault();
isVoiceTouchDown = true;
willCancel.value = false;
touchStartY = e.touches[0]?.clientY || 0;
if (!recorderManager) initRecorderManager();
// 必须按住 300ms 才真正开始录音,点击一下不会触发
voiceHoldTimer = setTimeout(() => {
voiceHoldTimer = null;
if (isVoiceTouchDown) {
startRecord();
}
}, 300);
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '当前平台暂不支持语音', icon: 'none' });
// #endif
}
// 松开 - 发送或取消
function onVoiceTouchEnd(e) {
// #ifdef MP-WEIXIN
e.preventDefault();
isVoiceTouchDown = false;
if (voiceHoldTimer) {
clearTimeout(voiceHoldTimer);
voiceHoldTimer = null;
// 未按住满 300ms 就松开了,不开始录音
return;
}
if (isRecording.value && recorderManager) {
recorderManager.stop();
}
// #endif
}
// 触摸取消(如滑出区域)
function onVoiceTouchCancel(e) {
onVoiceTouchEnd(e);
}
// 捕获阶段监听:录音时任意位置松开都能结束(解决浮层遮挡导致 touchend 收不到的问题)
function onRecordingTouchEnd(e) {
if (isRecording.value) {
e.stopPropagation();
onVoiceTouchEnd(e);
}
}
function onRecordingTouchCancel(e) {
if (isRecording.value) {
e.stopPropagation();
onVoiceTouchCancel(e);
}
}
// 强制取消录音(点击蒙层逃生,防止卡住)
function forceCancelRecording() {
isVoiceTouchDown = false;
if (voiceHoldTimer) {
clearTimeout(voiceHoldTimer);
voiceHoldTimer = null;
}
if (!isRecording.value) return;
willCancel.value = true;
if (recorderManager) {
try { recorderManager.stop(); } catch (_) {}
}
isRecording.value = false;
recordingDuration.value = 0;
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
}
let touchStartY = 0;
// 监听上滑取消
function onVoiceTouchMove(e) {
if (!isRecording.value) return;
const touch = e.touches[0];
if (touch && touchStartY - touch.clientY > 80) {
willCancel.value = true;
}
}
function startRecord() {
// #ifdef MP-WEIXIN
uni.authorize({
scope: 'scope.record',
success: () => {
isRecording.value = true;
recordingDuration.value = 0;
recorderManager.start({
duration: 60000,
sampleRate: 44100,
numberOfChannels: 1,
encodeBitRate: 192000,
format: 'aac'
});
recordingTimer = setInterval(() => {
recordingDuration.value++;
}, 1000);
},
fail: () => {
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限以发送语音消息',
confirmText: '去设置',
success: (res) => {
if (res.confirm) uni.openSetting();
}
});
}
});
// #endif
}
async function sendAudioMessage(recordRes) {
try {
uni.showLoading({ title: '发送中...' });
const durationSec = Math.max(1, Math.round(recordRes.duration / 1000));
const message = chat.createAudioMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { file: recordRes },
onProgress: () => {}
});
const sendResult = await chat.sendMessage(message);
const payload = sendResult?.data?.message?.payload || {};
const audioUrl = payload.url || payload.file?.url || recordRes.tempFilePath;
messages.value.push({
type: 'audio',
audioUrl,
duration: durationSec,
audioId: sendResult?.data?.message?.ID || 'local-' + Date.now(),
time: Date.now(),
flow: 'out',
avatar: userAvatar.value
});
uni.hideLoading();
uni.showToast({ title: '发送成功', icon: 'success' });
scrollToBottom();
setTimeout(() => scrollToBottom(), 150);
} catch (error) {
console.error('发送语音失败:', error);
uni.hideLoading();
uni.showToast({ title: '发送失败', icon: 'none' });
}
}
function playAudio(msg) {
if (!msg.audioUrl) {
uni.showToast({ title: '无法播放', icon: 'none' });
return;
}
if (playingAudioId.value === msg.audioId) {
if (innerAudioContext) {
innerAudioContext.stop();
innerAudioContext.destroy();
innerAudioContext = null;
}
playingAudioId.value = '';
return;
}
if (innerAudioContext) {
innerAudioContext.stop();
innerAudioContext.destroy();
}
innerAudioContext = uni.createInnerAudioContext();
innerAudioContext.src = msg.audioUrl;
innerAudioContext.play();
playingAudioId.value = msg.audioId;
innerAudioContext.onEnded(() => {
playingAudioId.value = '';
innerAudioContext = null;
});
innerAudioContext.onError(() => {
playingAudioId.value = '';
innerAudioContext = null;
uni.showToast({ title: '播放失败', icon: 'none' });
});
}
let patient_data=ref();
const video =async (userid)=>{
const uid=userid.replace('patient_', '')
console.log('userid',userid,uid)
const res = await proxy.apiUrl({
url: '/api/tcm/getPatientSignature',
method: 'GET',
data: {
patient_id: uid
},
}, false)
const {userId } = res.data;
patient_data.value=res.data.data
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID;
getApp().globalData.secretKey = secretKey;
uni.setStorageSync('CallManager', userId)
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
}
/** 与当前页 IM 实例绑定后初始化音视频(TUICallEngine 依赖 tim 收发自定义信令) */
async function initTUICallKitWithTim(imChat) {
const app = getApp();
const sdkAppID = app.globalData.SDKAppID;
const userSig = app.globalData.userSig;
// 必须与 chat.login 的 userID 一致(globalData.userId 与路由可能不同步)
const userID = currentUserID || app.globalData.userID;
if (!imChat || !sdkAppID || !userSig || !userID) {
console.warn('initTUICallKitWithTim: 缺少 im / SDKAppID / userSig / userID');
return;
}
// 非 IDLE 时 destroyed 会抛错,随后 _doInit 会因旧 engine 仍存在而直接 return,导致未绑定新 tim、接不到来电
try {
await TUICallKitAPI.handleExceptionExit();
} catch (_) {}
try {
await uni.CallManager.destroyed();
} catch (e) {
console.warn('CallManager.destroyed:', e);
try {
await TUICallKitAPI.handleExceptionExit();
} catch (_) {}
try {
await uni.CallManager.destroyed();
} catch (e2) {
console.warn('CallManager.destroyed retry:', e2);
}
}
await uni.CallManager.init({
sdkAppID: Number(sdkAppID),
userID,
userSig,
globalCallPagePath: 'TUICallKit/src/Components/TUICallKit',
tim: imChat
});
}
onLoad(async (options) => {
try {
patientLeaveNotified = false;
console.log('进入onMounted:', options);
const app = getApp();
const userID = options.userID|| app.globalData.userID;
if(!userID.includes('patient')){
userID='patient_'+userID
}
await video(userID)
const userSig = options.userSig||app.globalData.userSig;
const SDKAppID = options.SDKAppID|| app.globalData.SDKAppID ;
targetUserID = options.targetUserID;
currentUserID = userID; // 保存当前用户ID
console.log('currentUserID',currentUserID)
// 获取用户头像
const userData = uni.getStorageSync('userData');
if (userData && userData.avatar) {
userAvatar.value = userData.avatar;
}
if (!userID || !userSig || !SDKAppID) {
uni.showToast({ title: '登录信息缺失', icon: 'none' });
setTimeout(() => uni.navigateBack(), 1500);
return;
}
conversationID = `C2C${targetUserID}`;
doctorEnteredConsultRoom.value = false;
console.log('currentUserID:', currentUserID, '对方用户:', targetUserID,'conversationID'+conversationID);
console.log('onMounted',userID,userSig,SDKAppID,conversationID)
// 切换账户前先登出,避免 IM 登录用户不匹配
const prevUserID = app.globalData.imChatUserID;
if (app.globalData.imChat && prevUserID !== userID) {
try {
app.globalData.imChat.off?.(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
await app.globalData.imChat.logout();
await app.globalData.imChat.destroy?.();
} catch (e) {
console.warn('IM logout error:', e);
}
app.globalData.imChat = null;
app.globalData.imChatUserID = null;
}
if (!app.globalData.imChat) {
chat = TencentCloudChat.create({
SDKAppID: Number(SDKAppID),
scene: 'uniapp-wx'
});
chat.use(conversationPlugin);
chat.use(messageEnhancerPlugin);
chat.use(richMediaMessagePlugin);
chat.use(groupPlugin);
const doLogin = () => chat.login({ userID, userSig });
const getLoginCode = (err) => {
const c = err?.code ?? err?.error?.code;
if (c != null) return c;
const msg = String(err?.message ?? err?.error?.message ?? '');
const m = msg.match(/["']?code["']?\s*:\s*(\d+)/);
return m ? parseInt(m[1], 10) : null;
};
let loginOk = false;
for (let retry = 0; retry <= 2; retry++) {
try {
await doLogin();
loginOk = true;
break;
} catch (loginErr) {
const code = getLoginCode(loginErr);
const msg = String(loginErr?.message ?? loginErr?.error?.message ?? '');
const isDuplicateLogin = code === 2025 || msg.includes('重复登录');
if (isDuplicateLogin && retry < 2) {
// 2025 重复登录:销毁当前实例,等待后重建并重试
try {
await chat.destroy?.();
} catch (_) {}
chat = null;
await new Promise(r => setTimeout(r, 600));
chat = TencentCloudChat.create({
SDKAppID: Number(SDKAppID),
scene: 'uniapp-wx'
});
chat.use(conversationPlugin);
chat.use(messageEnhancerPlugin);
chat.use(richMediaMessagePlugin);
chat.use(groupPlugin);
} else {
throw loginErr;
}
}
}
if (!loginOk) throw new Error('IM 登录失败');
// updateMyProfile 非必需,失败不阻塞聊天(如 2024 用户未登录时可忽略)
try {
await chat.updateMyProfile({
avatar: userData?.avatar,
gender: TencentCloudChat.TYPES.GENDER_MALE,
});
} catch (profileErr) {
console.warn('updateMyProfile error:', profileErr);
}
app.globalData.imChat = chat;
app.globalData.imChatUserID = userID;
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
} else {
chat = app.globalData.imChat;
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
}
await initTUICallKitWithTim(chat);
await loadHistoryMessages();
// 打开会话时通知医生(IM自定义消息 + 后端全局事件)
await notifyDoctorOnChatOpen();
if(options.msg==1){
console.log('msg我进来了',options.msg)
await waitForIMReady(); // 确保 SDK 网络就绪后再发消息
await sendConfirmationMessage(); // 进入聊天时自动发送知情确认
}
loadDoctorInfo(); // 加载医生卡片信息(对方为医生时)
uni.$on('TUICall:callStart', onVideoCallStart);
uni.$on('TUICall:callEnd', onVideoCallEnd);
// 提前申请音视频权限,避免来电接通时才弹「去设置」提示
requestAVPermissions({ silent: true, scene: 'onLoad' });
//uni.showToast({ title: '聊天已就绪', icon: 'success' });
// #ifdef MP-WEIXIN
// 系统级前后台切换时页面 onShow 不一定等价于「回前台」,用 wx.onAppShow 刷新音视频信令链路
if (typeof wx !== 'undefined' && wx.onAppShow) {
unsubscribeWxAppShow = wx.onAppShow(() => {
if (!allowChatForegroundRelogin) return;
const pages = getCurrentPages();
const top = pages[pages.length - 1];
if (!(top?.route || '').includes('pages/chat/chat')) return;
reloginAfterMiniProgramForeground();
});
}
setTimeout(() => {
allowChatForegroundRelogin = true;
}, 800);
// #endif
// 监听键盘高度变化
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height;
if (res.height > 0) {
// 键盘弹起时滚动到底部
setTimeout(() => scrollToBottom(), 100);
}
});
} catch (error) {
console.error('初始化失败:', error);
uni.showModal({
title: '初始化失败',
content: error.message || '聊天功能初始化失败',
showCancel: false,
success: () => uni.navigateBack()
});
}
});
onHide(() => {
const pages = getCurrentPages();
const top = pages[pages.length - 1];
if (top?.route?.includes('TUICallKit')) {
getApp().globalData._chatFromCallPage = true;
}
});
onShow(() => {
if (getApp().globalData._chatFromCallPage) {
getApp().globalData._chatFromCallPage = false;
hideDoctorCardByCall();
}
});
onUnload(() => {
// 患者退出聊天页:通知后端(与 notifyOpen 对称,供管理端轮询)
if (!patientLeaveNotified && targetUserID && String(targetUserID).startsWith('doctor_')) {
patientLeaveNotified = true;
void notifyDoctorOnChatClose();
}
allowChatForegroundRelogin = false;
// #ifdef MP-WEIXIN
if (typeof unsubscribeWxAppShow === 'function') {
unsubscribeWxAppShow();
unsubscribeWxAppShow = null;
}
// #endif
if (foregroundReloginTimer) {
clearTimeout(foregroundReloginTimer);
foregroundReloginTimer = null;
}
uni.$off('TUICall:callStart', onVideoCallStart);
uni.$off('TUICall:callEnd', onVideoCallEnd);
stopDoctorWaitTimer();
if (chat) chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
if (scrollTimer) clearTimeout(scrollTimer);
if (innerAudioContext) {
innerAudioContext.stop();
innerAudioContext.destroy();
}
if (isRecording.value && recorderManager) {
recorderManager.stop();
}
});
/** 打开会话时通知医生:发送IM自定义消息 + 调用后端全局事件 */
async function notifyDoctorOnChatOpen() {
if (!targetUserID || !targetUserID.startsWith('doctor_')) return;
const doctorId = targetUserID.replace('doctor_', '');
const userData = uni.getStorageSync('userData') || {};
const patientName = userData.name || patient_data.value?.patient_name || '患者';
const patientId = (currentUserID || '').replace('patient_', '') || '';
try {
// 1. 发送 IM 自定义消息给医生
const customPayload = {
businessID: 'patient_opened_chat',
patientId,
patientName,
doctorId,
time: Date.now()
};
const imMsg = chat.createCustomMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { data: JSON.stringify(customPayload) }
});
await chat.sendMessage(imMsg);
} catch (e) {
console.warn('IM 自定义消息发送失败:', e);
}
try {
// 2. 调用后端 API 触发全局事件(供 admin 轮询)
await proxy.apiUrl({
url: '/api/chat/notifyOpen',
method: 'POST',
data: {
doctor_id: doctorId,
patient_id: patientId,
patient_name: patientName
}
}, false);
} catch (e) {
console.warn('后端通知失败:', e);
}
}
/** 离开会话页时通知医生:IM 自定义消息 + 后端全局事件 */
async function notifyDoctorOnChatClose() {
if (!targetUserID || !targetUserID.startsWith('doctor_')) return;
const doctorId = targetUserID.replace('doctor_', '');
const userData = uni.getStorageSync('userData') || {};
const patientName = userData.name || patient_data.value?.patient_name || '患者';
const patientId = (currentUserID || '').replace('patient_', '') || '';
try {
if (chat) {
const customPayload = {
businessID: 'patient_closed_chat',
patientId,
patientName,
doctorId,
time: Date.now()
};
const imMsg = chat.createCustomMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { data: JSON.stringify(customPayload) }
});
await chat.sendMessage(imMsg);
}
} catch (e) {
console.warn('IM 离开通知发送失败:', e);
}
try {
await proxy.apiUrl({
url: '/api/chat/notifyClose',
method: 'POST',
data: {
doctor_id: doctorId,
patient_id: patientId,
patient_name: patientName
}
}, false);
} catch (e) {
console.warn('后端离开通知失败:', e);
}
}
function applyDoctorEnteredSignalFromRawMessage(msg) {
if (!msg || msg.type !== TencentCloudChat.TYPES.MSG_CUSTOM) return;
try {
const d = JSON.parse(msg.payload.data);
if (d.businessID === 'doctor_entered_consult_room' && msg.from !== currentUserID) {
doctorEnteredConsultRoom.value = true;
}
} catch (_) {}
}
async function loadHistoryMessages() {
try {
const res = await chat.getMessageList({ conversationID, count: 15 });
console.log('消息',conversationID);
if (res.data && res.data.messageList) {
for (const raw of res.data.messageList) {
applyDoctorEnteredSignalFromRawMessage(raw);
}
// SDK返回的消息是从新到旧,reverse后变成从旧到新,最新消息在底部
const parsedMessages = res.data.messageList
.map(parseMessage)
.filter(msg => msg !== null) // 过滤掉null消息
messages.value = parsedMessages;
console.log('加载历史消息:', parsedMessages.length, '条');
scrollToBottom();
}
} catch (error) {
console.error('加载历史消息失败:', error);
}
}
function parseMessage(msg) {
// 根据消息的 from 字段判断是发出还是接收
const actualFlow = msg.from === currentUserID ? 'out' : 'in';
// 获取头像:发出的消息使用当前用户头像,接收的消息从消息对象中获取
let avatar = '';
if (actualFlow === 'out') {
avatar = userAvatar.value;
} else {
// 尝试从消息对象中获取对方的头像
// 消息对象可能包含 fromUserProfile 或其他头像字段
avatar = msg.fromUserProfile?.avatar ||
msg.fromProfile?.avatar ||
msg.senderProfile?.avatar ||
msg.avatar ||
'';
}
const baseMsg = {
time: msg.time * 1000,
flow: actualFlow,
avatar: avatar
};
switch (msg.type) {
case TencentCloudChat.TYPES.MSG_TEXT: {
const text = msg.payload.text;
if (isConfirmationMessageText(text)) {
return { ...baseMsg, type: 'confirmation', text };
}
return { ...baseMsg, type: 'text', text };
}
case TencentCloudChat.TYPES.MSG_IMAGE:
// 优先使用原图,如果没有则使用缩略图
const imageUrl = msg.payload.imageInfoArray?.[0]?.url ||
msg.payload.imageInfoArray?.[1]?.url ||
msg.payload.imageInfoArray?.[2]?.url;
return { ...baseMsg, type: 'image', imageUrl };
case TencentCloudChat.TYPES.MSG_FILE:
return {
...baseMsg,
type: 'file',
fileName: msg.payload.fileName,
fileSize: msg.payload.fileSize,
fileUrl: msg.payload.fileUrl
};
case TencentCloudChat.TYPES.MSG_FACE:
// 表情消息,data字段包含表情符号
return { ...baseMsg, type: 'face', emoji: msg.payload.data };
case TencentCloudChat.TYPES.MSG_CUSTOM:
// 自定义消息(如通话消息)
try {
const customData = JSON.parse(msg.payload.data);
console.log('自定义消息:', customData);
// 过滤掉输入状态消息
if (customData.businessID === 'user_typing_status') {
console.log('过滤输入状态消息');
return null;
}
// 过滤掉患者打开会话通知(仅用于医生端提醒,不在聊天列表展示)
if (customData.businessID === 'patient_opened_chat') {
return null; // 返回null表示不显示此消息
}
if (customData.businessID === 'patient_closed_chat') {
return null;
}
// 医生进入诊室信令(admin 发送):仅更新顶部卡片,不在聊天列表展示
if (customData.businessID === 'doctor_entered_consult_room') {
return null;
}
// 通话消息
if (customData.businessID === 1 || customData.businessID === 'av_call') {
const callType = customData.call_type === 1 ? '语音通话' : '视频通话';
return { ...baseMsg, type: 'text', text: `[${callType}]` };
}
// 其他自定义消息,如果有text字段则显示
if (customData.text) {
const t = customData.text;
if (isConfirmationMessageText(t)) {
return { ...baseMsg, type: 'confirmation', text: t };
}
return { ...baseMsg, type: 'text', text: t };
}
// 没有可显示内容的自定义消息,不显示
console.log('过滤无内容的自定义消息');
return null;
} catch (e) {
console.error('解析自定义消息失败:', e);
return null; // 解析失败的消息不显示
}
case TencentCloudChat.TYPES.MSG_AUDIO: {
const audioUrl = msg.payload?.url || msg.payload?.file?.url || '';
const audioDuration = msg.payload?.second ?? (msg.payload?.duration ? Math.round(msg.payload.duration / 1000) : 0);
return { ...baseMsg, type: 'audio', audioUrl, duration: Math.round(audioDuration), audioId: msg.ID || msg.id || '' };
}
case TencentCloudChat.TYPES.MSG_VIDEO:
return { ...baseMsg, type: 'text', text: '[视频消息]' };
case TencentCloudChat.TYPES.MSG_LOCATION:
return { ...baseMsg, type: 'text', text: '[位置消息]' };
default:
console.log('未知消息类型:', msg.type);
return null; // 未知类型不显示
}
}
function onMessageReceived(event) {
event.data.forEach(msg => {
if (msg.conversationID === conversationID) {
applyDoctorEnteredSignalFromRawMessage(msg);
const parsedMsg = parseMessage(msg);
// 只添加非null的消息
if (parsedMsg !== null) {
messages.value.push(parsedMsg);
scrollToBottom();
setTimeout(() => scrollToBottom(), 150);
}
}
});
}
function imGetLoginCode(err) {
const c = err?.code ?? err?.error?.code;
if (c != null) return c;
const msg = String(err?.message ?? err?.error?.message ?? '');
const m = msg.match(/["']?code["']?\s*:\s*(\d+)/);
return m ? parseInt(m[1], 10) : null;
}
/** 拉取最新 userSig 并重新登录 IM,必要时重建 chat 实例(与 onLoad 逻辑一致) */
async function reloginIMChat() {
const app = getApp();
await video(currentUserID);
const userSig = app.globalData.userSig;
const SDKAppID = app.globalData.SDKAppID;
const userID = currentUserID;
if (!userSig || !SDKAppID) {
throw new Error('IM 凭证缺失');
}
if (!chat) {
throw new Error('IM 未初始化');
}
try {
chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
} catch (_) {}
try {
await chat.logout();
} catch (e) {
console.warn('IM logout:', e);
}
const attachPlugins = (instance) => {
instance.use(conversationPlugin);
instance.use(messageEnhancerPlugin);
instance.use(richMediaMessagePlugin);
instance.use(groupPlugin);
};
const doLogin = async (instance) => instance.login({ userID, userSig });
try {
await doLogin(chat);
} catch (loginErr) {
const code = imGetLoginCode(loginErr);
const msg = String(loginErr?.message ?? loginErr?.error?.message ?? '');
const isDuplicateLogin = code === 2025 || msg.includes('重复登录');
if (!isDuplicateLogin) throw loginErr;
try {
await chat.destroy?.();
} catch (_) {}
chat = TencentCloudChat.create({
SDKAppID: Number(SDKAppID),
scene: 'uniapp-wx'
});
attachPlugins(chat);
await doLogin(chat);
}
app.globalData.imChat = chat;
app.globalData.imChatUserID = userID;
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
await initTUICallKitWithTim(chat);
}
/** 小程序切后台再回前台:拉取新 UserSig 并重登 IM、重绑 TUICallEngine(与发消息失败时的 relogin 一致) */
async function reloginAfterMiniProgramForeground() {
if (!chat || !currentUserID) return;
if (foregroundReloginTimer) clearTimeout(foregroundReloginTimer);
foregroundReloginTimer = setTimeout(async () => {
foregroundReloginTimer = null;
// reloginIMChat 会先 IM logout 再 initTUICallKit;若非 IDLEdestroyed 会失败且 IM 已断开,故仅 IDLE 时刷新
try {
const status = TUIStore?.getData?.(StoreName.CALL, NAME.CALL_STATUS);
if (status != null && status !== CallStatus.IDLE) {
console.warn('前台恢复: 通话未结束,跳过 IM/TUICall 重绑');
return;
}
await reloginIMChat();
} catch (e) {
console.warn('前台恢复后 IM/TUICall 刷新失败:', e);
}
}, 400);
}
async function sendTextMessage() {
if (!inputText.value.trim()) return;
const text = inputText.value.trim();
const pushSentAndClear = () => {
messages.value.push({
type: isConfirmationMessageText(text) ? 'confirmation' : 'text',
text,
time: Date.now(),
flow: 'out',
avatar: userAvatar.value
});
inputText.value = '';
uni.hideKeyboard();
scrollToBottom();
setTimeout(() => scrollToBottom(), 150);
};
const doSend = async () => {
const message = chat.createTextMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { text }
});
await chat.sendMessage(message);
};
try {
await doSend();
pushSentAndClear();
} catch (error) {
console.error('发送消息失败:', error);
try {
uni.showLoading({ title: '重新登录中', mask: true });
await reloginIMChat();
await doSend();
pushSentAndClear();
} catch (e2) {
console.error('重登后仍发送失败:', e2);
uni.showToast({ title: '发送失败', icon: 'none' });
} finally {
uni.hideLoading();
}
}
}
/** 等待 IM SDK 网络握手完成,给 SDK 一段时间确保就绪后再发消息 */
function waitForIMReady() {
// 使用固定延迟代替事件监听:SDK_READY 事件可能在注册前已触发,
// 事件监听会等满 maxMs 超时,在微信小程序中引发 "Error: timeout"。
return new Promise((resolve) => setTimeout(resolve, 800));
}
async function sendConfirmationMessage() {
const alreadySent = messages.value.some(
(m) => m.type === 'confirmation' || (m.type === 'text' && isConfirmationMessageText(m.text))
);
if (alreadySent) return;
const pushSent = () => {
// 防止重复 push(relogin 后重试时再次检查)
const already = messages.value.some(
(m) => m.type === 'confirmation' || (m.type === 'text' && isConfirmationMessageText(m.text))
);
if (already) return;
messages.value.push({
type: 'confirmation',
text: CONFIRMATION_MESSAGE,
time: Date.now(),
flow: 'out',
avatar: userAvatar.value
});
scrollToBottom();
setTimeout(() => scrollToBottom(), 150);
};
const doSend = async () => {
const message = chat.createTextMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { text: CONFIRMATION_MESSAGE }
});
await chat.sendMessage(message);
};
try {
await doSend();
pushSent();
} catch (error) {
console.error('发送确认信息失败,尝试重新登录后重试:', error);
try {
await reloginIMChat();
await doSend();
pushSent();
} catch (e2) {
console.error('重登后确认信息仍发送失败:', e2);
}
}
}
function chooseImage() {
// #ifdef MP-WEIXIN
uni.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
console.log('选择图片成功:', res);
sendImageMessage(res);
},
fail: (err) => {
console.error('选择图片失败:', err);
uni.showToast({ title: '选择图片失败', icon: 'none' });
}
});
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage({
count: 1,
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
console.log('选择图片成功:', res);
sendImageMessage(res);
},
fail: (err) => {
console.error('选择图片失败:', err);
uni.showToast({ title: '选择图片失败', icon: 'none' });
}
});
// #endif
}
async function sendImageMessage(res) {
try {
console.log('开始发送图片,res对象:', res);
uni.showLoading({ title: '发送中...' });
// 根据文档,直接传入 uni.chooseMedia 或 uni.chooseImage 的 success 回调参数
const message = chat.createImageMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: {
file: res // 直接传入整个res对象
},
onProgress: (event) => {
console.log('图片上传进度:', event);
}
});
console.log('图片消息创建成功,开始发送');
const sendResult = await chat.sendMessage(message);
console.log('图片发送成功:', sendResult);
// 从发送结果中获取图片URL
const imageUrl = sendResult.data.message.payload.imageInfoArray?.[0]?.url || res.tempFilePaths?.[0] || res.tempFiles?.[0]?.tempFilePath;
messages.value.push({ type: 'image', imageUrl, time: Date.now(), flow: 'out', avatar: userAvatar.value });
uni.hideLoading();
uni.showToast({ title: '发送成功', icon: 'success' });
scrollToBottom();
setTimeout(() => scrollToBottom(), 150);
} catch (error) {
console.error('发送图片失败:', error);
uni.hideLoading();
uni.showToast({ title: '发送失败: ' + (error.message || '未知错误'), icon: 'none', duration: 3000 });
}
}
function chooseFile() {
// #ifdef MP-WEIXIN
wx.chooseMessageFile({
count: 1,
type: 'all',
success: (res) => {
console.log('选择文件成功:', res);
sendFileMessage(res);
},
fail: (err) => {
console.error('选择文件失败:', err);
uni.showToast({ title: '选择文件失败', icon: 'none' });
}
});
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '当前平台不支持', icon: 'none' });
// #endif
}
async function sendFileMessage(res) {
try {
console.log('开始发送文件,res对象:', res);
uni.showLoading({ title: '发送中...' });
// 根据文档,直接传入 wx.chooseMessageFile 的 success 回调参数
const message = chat.createFileMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: {
file: res // 直接传入整个res对象
},
onProgress: (event) => {
console.log('文件上传进度:', event);
}
});
console.log('文件消息创建成功,开始发送');
const sendResult = await chat.sendMessage(message);
console.log('文件发送成功:', sendResult);
const file = res.tempFiles?.[0] || {};
messages.value.push({
type: 'file',
fileName: file.name || '文件',
fileSize: file.size || 0,
time: Date.now(),
flow: 'out',
avatar: userAvatar.value
});
uni.hideLoading();
uni.showToast({ title: '发送成功', icon: 'success' });
scrollToBottom();
setTimeout(() => scrollToBottom(), 150);
} catch (error) {
console.error('发送文件失败:', error);
uni.hideLoading();
uni.showToast({ title: '发送失败: ' + (error.message || '未知错误'), icon: 'none', duration: 3000 });
}
}
function showEmojiPicker() {
showEmoji.value = !showEmoji.value;
}
async function sendEmoji(emoji) {
try {
const message = chat.createFaceMessage({
to: targetUserID,
conversationType: TencentCloudChat.TYPES.CONV_C2C,
payload: { index: 0, data: emoji }
});
await chat.sendMessage(message);
messages.value.push({ type: 'face', emoji, time: Date.now(), flow: 'out', avatar: userAvatar.value });
showEmoji.value = false;
scrollToBottom();
setTimeout(() => scrollToBottom(), 150);
} catch (error) {
console.error('发送表情失败:', error);
uni.showToast({ title: '发送失败', icon: 'none' });
}
}
function previewImage(url) {
uni.previewImage({ urls: [url], current: url });
}
function downloadFile(msg) {
uni.showModal({
title: '下载文件',
content: `是否下载文件:${msg.fileName}`,
success: (res) => {
if (res.confirm) {
uni.downloadFile({
url: msg.fileUrl,
success: (res) => {
if (res.statusCode === 200) uni.showToast({ title: '下载成功', icon: 'success' });
}
});
}
}
});
}
/**
* 申请摄像头 + 麦克风权限。
* silent=true 时仅静默尝试,不弹引导框(用于页面加载时提前申请)。
* silent=false 时若被拒绝主动引导用户去设置页开启。
* scene 标识触发场景(makeCall / incoming),用于日志分析。
*/
// #ifdef MP-WEIXIN
function requestAVPermissions({ silent = false, scene = 'unknown' } = {}) {
/** 上报权限被拒日志到后端 */
const logDenied = (deniedScope, action) => {
try {
const wxVersion = wx.getSystemInfoSync?.()?.SDKVersion || '';
proxy.apiUrl({
url: '/api/chat/logAvPermission',
method: 'POST',
data: {
patient_id: currentUserID || '',
doctor_id: targetUserID || '',
denied_scope: deniedScope,
scene,
action,
wx_version: wxVersion
}
}, false).catch(() => {});
} catch (_) {}
};
const guideToSetting = (deniedScope) => {
if (silent) {
// 静默模式:仅记录被拒,不弹弹窗
logDenied(deniedScope, 'silent_denied');
return;
}
uni.showModal({
title: '需要摄像头和麦克风权限',
content: '视频通话需要摄像头和麦克风权限,请点击「去设置」开启后重试',
confirmText: '去设置',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
logDenied(deniedScope, 'open_setting');
wx.openSetting();
} else {
// 用户选择「取消」,记录日志
logDenied(deniedScope, 'cancel');
}
}
});
};
return new Promise((resolve) => {
wx.authorize({
scope: 'scope.camera',
success: () => {
wx.authorize({
scope: 'scope.record',
success: () => resolve(true),
fail: () => { guideToSetting('record'); resolve(false); }
});
},
fail: () => { guideToSetting('camera'); resolve(false); }
});
});
}
// #endif
// #ifndef MP-WEIXIN
function requestAVPermissions() { return Promise.resolve(true); }
// #endif
async function makeVideoCall() {
// 发起通话前先检查权限,若被拒引导去设置
const granted = await requestAVPermissions({ silent: false, scene: 'makeCall' });
if (!granted) return;
try {
hideDoctorCardByCall();
if (!uni.CallManager) {
uni.showToast({ title: '通话功能未初始化', icon: 'none' });
return;
}
await uni.CallManager.call({ userID: targetUserID, type: 2 });
} catch (error) {
console.error('发起通话失败:', error);
uni.showToast({ title: '发起通话失败', icon: 'none' });
}
}
function startVideoCall() {
makeVideoCall();
}
async function loadDoctorInfo() {
if (!targetUserID || !targetUserID.startsWith('doctor_')) return;
const doctorId = targetUserID.replace('doctor_', '');
try {
const res = await proxy.apiUrl({
url: '/api/doctor/detail',
method: 'GET',
data: { id: doctorId }
}, false);
if (res && res.code === 1 && res.data) {
doctorInfo.value = res.data;
startDoctorWaitTimer();
}
} catch (e) {
console.warn('加载医生信息失败:', e);
}
}
function formatDoctorWaitTime(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function startDoctorWaitTimer() {
stopDoctorWaitTimer();
doctorWaitSeconds.value = 0;
doctorWaitTimer = setInterval(() => {
doctorWaitSeconds.value++;
}, 1000);
}
function stopDoctorWaitTimer() {
if (doctorWaitTimer) {
clearInterval(doctorWaitTimer);
doctorWaitTimer = null;
}
}
function goDoctorDetail() {
if (!doctorInfo.value?.id) return;
uni.navigateTo({ url: `/doctor/pages/doctor/doctor?id=${doctorInfo.value.id}` });
}
function hideDoctorCardByCall() {
showDoctorCard.value = false;
stopDoctorWaitTimer();
}
function onVideoCallStart() {
hideDoctorCardByCall();
}
async function onVideoCallEnd() {
hideDoctorCardByCall();
uni.showToast({ title: '视频通话已结束', icon: 'success' });
}
function onInputFocus() {
console.log('输入框获得焦点');
showEmoji.value = false;
}
function onInputBlur() {
console.log('输入框失去焦点');
}
function onScroll(e) {
// 监听滚动事件,可用于判断是否在底部
}
function scrollToBottom() {
if (scrollTimer) {
clearTimeout(scrollTimer);
}
nextTick(() => {
// 使用查询节点的方式获取准确高度
const query = uni.createSelectorQuery();
query.select('.message-container').boundingClientRect();
query.exec((res) => {
if (res && res[0]) {
const height = res[0].height;
console.log('消息容器高度:', height);
// 设置一个足够大的值确保滚动到底部
scrollTop.value = height + 1000;
// 再次确认滚动
scrollTimer = setTimeout(() => {
scrollTop.value = height + 1000 + 1;
}, 100);
}
});
});
}
function goBack() {
uni.navigateBack();
}
function formatTime(timestamp) {
const date = new Date(timestamp);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
</script>
<style scoped>
/* 适老化设计:50-80岁 - 大字号、高对比、大触控区 */
.chat-container {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
background: #e8eaed;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 100rpx;
padding: 0 28rpx;
background: #fff;
border-bottom: 4rpx solid #e0e0e0;
}
.header-left {
width: 100rpx;
height: 100rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-icon {
font-size: 52rpx;
color: #1890ff;
font-weight: 500;
}
.header-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.header-title {
font-size: 40rpx;
font-weight: 500;
color: #1a1a1a;
}
.header-status {
font-size: 28rpx;
color: #555;
margin-top: 4rpx;
}
.header-right {
width: 100rpx;
display: flex;
justify-content: flex-end;
}
.video-call-btn {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.video-icon {
font-size: 52rpx;
color: #1890ff;
}
.message-list {
flex: 1;
overflow: hidden;
}
.message-container {
padding: 28rpx 22rpx;
padding-bottom: 320rpx;
min-height: 100%;
}
/* 医生卡片:问诊场景、层次清晰 */
.doctor-card {
display: flex;
flex-direction: column;
margin-bottom: 32rpx;
border-radius: 20rpx;
overflow: hidden;
background: #fff;
box-shadow:
0 2rpx 8rpx rgba(15, 23, 42, 0.04),
0 12rpx 40rpx rgba(13, 148, 136, 0.08);
border: 1rpx solid rgba(13, 148, 136, 0.12);
position: relative;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.doctor-card:active {
transform: scale(0.992);
box-shadow:
0 2rpx 6rpx rgba(15, 23, 42, 0.06),
0 8rpx 28rpx rgba(13, 148, 136, 0.1);
}
.doctor-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
height: 6rpx;
background: linear-gradient(90deg, #0d9488 0%, #14b8a6 45%, #5eead4 100%);
}
.doctor-card-topbar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 22rpx 28rpx 12rpx;
padding-top: 28rpx;
}
.doctor-card-label {
font-size: 24rpx;
font-weight: 600;
color: #64748b;
letter-spacing: 2rpx;
}
.doctor-card-status-pill {
display: flex;
flex-direction: row;
align-items: center;
gap: 10rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
border: 1rpx solid rgba(251, 146, 60, 0.35);
}
.doctor-card-status-pulse {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #ea580c;
box-shadow: 0 0 0 0 rgba(234, 88, 12, 0.45);
animation: doctorWaitPulse 1.8s ease-out infinite;
}
@keyframes doctorWaitPulse {
0% {
box-shadow: 0 0 0 0 rgba(234, 88, 12, 0.5);
}
70% {
box-shadow: 0 0 0 10rpx rgba(234, 88, 12, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(234, 88, 12, 0);
}
}
.doctor-card-status-text {
font-size: 22rpx;
font-weight: 600;
color: #c2410c;
letter-spacing: 0.5rpx;
}
.doctor-card-status-pill--entered {
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
border: 1rpx solid rgba(16, 185, 129, 0.45);
}
.doctor-card-status-pill--entered .doctor-card-status-text {
color: #047857;
}
.doctor-card-status-pulse--entered {
background: #10b981;
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.45);
animation: doctorEnteredPulse 1.6s ease-out infinite;
}
@keyframes doctorEnteredPulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55);
}
70% {
box-shadow: 0 0 0 10rpx rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
.doctor-card-main {
display: flex;
flex-direction: row;
align-items: center;
padding: 8rpx 24rpx 28rpx;
gap: 20rpx;
}
.doctor-card-avatar-wrap {
flex-shrink: 0;
}
.doctor-card-avatar-ring {
width: 112rpx;
height: 112rpx;
padding: 4rpx;
border-radius: 50%;
background: linear-gradient(145deg, #ccfbf1 0%, #99f6e4 50%, #5eead4 100%);
box-shadow: inset 0 1rpx 2rpx rgba(255, 255, 255, 0.8);
}
.doctor-card-avatar {
width: 104rpx;
height: 104rpx;
border-radius: 50%;
display: block;
background: #e2e8f0;
border: 4rpx solid #fff;
box-sizing: border-box;
}
.doctor-card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.doctor-card-name {
font-size: 36rpx;
font-weight: 700;
color: #0f172a;
letter-spacing: 0.5rpx;
line-height: 1.3;
}
.doctor-card-meta {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 6rpx;
}
.doctor-card-title {
font-size: 24rpx;
color: #0d9488;
font-weight: 500;
}
.doctor-card-dot {
font-size: 24rpx;
color: #cbd5e1;
}
.doctor-card-hospital {
font-size: 24rpx;
color: #64748b;
max-width: 100%;
}
.doctor-card-chevron {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f1f5f9;
}
.doctor-card-chevron-icon {
font-size: 40rpx;
line-height: 1;
color: #94a3b8;
font-weight: 300;
}
.doctor-card-tip {
display: flex;
flex-direction: row;
align-items: stretch;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border-top: 1rpx solid rgba(148, 163, 184, 0.2);
}
.doctor-card-tip-bar {
width: 6rpx;
flex-shrink: 0;
background: linear-gradient(180deg, #14b8a6 0%, #0d9488 100%);
}
.doctor-card-tip-inner {
flex: 1;
padding: 20rpx 24rpx 22rpx 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.doctor-card-tip-title {
font-size: 22rpx;
font-weight: 600;
color: #475569;
letter-spacing: 1rpx;
}
.doctor-card-tip-text {
font-size: 26rpx;
color: #64748b;
line-height: 1.55;
}
.message-wrapper {
display: flex;
margin-bottom: 32rpx;
animation: fadeIn 0.25s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10rpx); }
to { opacity: 1; transform: translateY(0); }
}
.message-wrapper-in {
flex-direction: row;
}
.message-wrapper-out {
flex-direction: row-reverse;
}
/* 头像 - 更大 */
.avatar {
width:80rpx;
height: 80rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 500;
}
.avatar-in {
margin-right: 20rpx;
}
.avatar-out {
margin-left: 10rpx;
}
.avatar-text {
font-size: 36rpx;
color: #fff;
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 12rpx;
}
/* 消息气泡 - 更大内边距 */
.message-bubble {
max-width: 540rpx;
padding: 28rpx 32rpx;
border-radius: 16rpx;
position: relative;
}
.bubble-in {
background: #fff;
}
.bubble-out {
background: #95ec69;
}
/* 知情确认:全宽略增、与绿气泡区分 */
.message-wrapper-confirmation .message-bubble {
max-width: 620rpx;
}
.bubble-confirmation.bubble-out {
background: linear-gradient(165deg, #f0faf4 0%, #e3f2e8 55%, #d8ebe3 100%);
border: 1rpx solid rgba(46, 139, 87, 0.28);
box-shadow: 0 4rpx 20rpx rgba(34, 100, 70, 0.08);
}
.bubble-confirmation.bubble-in {
background: linear-gradient(165deg, #f7fbff 0%, #eef6fc 100%);
border: 1rpx solid rgba(87, 107, 149, 0.22);
box-shadow: 0 4rpx 20rpx rgba(50, 80, 120, 0.06);
}
.confirmation-card {
width: 100%;
}
.confirmation-card-head {
display: flex;
flex-direction: row;
align-items: center;
gap: 20rpx;
}
.confirmation-card-badge {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: linear-gradient(135deg, #2e8b57 0%, #3cb371 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2rpx 8rpx rgba(46, 139, 87, 0.35);
}
.confirmation-card-badge-icon {
font-size: 28rpx;
color: #fff;
font-weight: 700;
line-height: 1;
}
.confirmation-card-head-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.confirmation-card-title {
font-size: 32rpx;
font-weight: 600;
color: #1a4d2e;
letter-spacing: 0.5rpx;
}
.bubble-in .confirmation-card-title {
color: #1a3a5c;
}
.confirmation-card-sub {
font-size: 20rpx;
color: rgba(26, 77, 46, 0.45);
text-transform: uppercase;
letter-spacing: 1rpx;
}
.bubble-in .confirmation-card-sub {
color: rgba(26, 58, 92, 0.4);
}
.confirmation-card-divider {
height: 1rpx;
background: linear-gradient(90deg, transparent, rgba(46, 139, 87, 0.25), transparent);
margin: 20rpx 0 16rpx;
}
.bubble-in .confirmation-card-divider {
background: linear-gradient(90deg, transparent, rgba(87, 107, 149, 0.22), transparent);
}
.confirmation-card-lines {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.confirmation-line {
font-size: 28rpx;
line-height: 1.65;
color: #2d4a3a;
display: block;
padding-left: 4rpx;
}
.bubble-in .confirmation-line {
color: #2c3d52;
}
/* 文本消息 - 大字号 */
.message-text {
font-size: 30rpx;
line-height: 1.6;
color: #1a1a1a;
word-break: break-all;
}
.bubble-out .message-text {
color: #1a1a1a;
}
.message-image {
max-width: 100%;
border-radius: 12rpx;
display: block;
}
/* 文件消息 - 大触控区 */
.message-file {
display: flex;
align-items: center;
gap: 24rpx;
padding: 20rpx;
background: rgba(0, 0, 0, 0.05);
border-radius: 12rpx;
}
.bubble-out .message-file {
background: rgba(0, 0, 0, 0.08);
}
.file-icon-wrapper {
width: 88rpx;
height: 88rpx;
background: #1890ff;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
}
.bubble-out .file-icon-wrapper {
background: rgba(0, 0, 0, 0.2);
}
.file-icon {
font-size: 48rpx;
}
.file-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.file-name {
font-size: 34rpx;
color: #1a1a1a;
font-weight: 500;
}
.bubble-out .file-name {
color: #1a1a1a;
}
.file-size {
font-size: 28rpx;
color: #555;
}
.bubble-out .file-size {
color: #555;
}
.download-icon {
font-size: 36rpx;
color: #1890ff;
}
.bubble-out .download-icon {
color: #1a1a1a;
}
.message-emoji {
font-size: 112rpx;
line-height: 1;
}
.message-meta {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: flex-end;
}
.message-time {
font-size: 26rpx;
color: #666;
}
.bubble-out .message-time {
color: #555;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 600rpx;
gap: 24rpx;
}
.empty-icon {
font-size: 120rpx;
opacity: 0.3;
}
.empty-text {
font-size: 38rpx;
color: #555;
font-weight: 500;
}
.empty-hint {
font-size: 32rpx;
color: #888;
}
.bottom-placeholder {
height: 56rpx;
}
/* 输入区域 - 大触控区 */
.input-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ededed;
border-top: 1rpx solid #d9d9d9;
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
z-index: 100;
transition: padding-bottom 0.3s ease;
}
.toolbar {
display: flex;
gap: 48rpx;
margin-bottom: 24rpx;
padding: 0 12rpx;
}
.tool-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
min-height: 100rpx;
}
.tool-item-left {
margin-right: 16rpx;
}
.tool-icon {
font-size: 56rpx;
}
.tool-label {
font-size: 30rpx;
color: #555;
font-weight: 500;
}
/* 语音输入 - 微信风格 */
.voice-input-wrapper {
height: 96rpx;
background: #f7f7f7;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 56rpx;
border: 2rpx solid #e0e0e0;
}
.voice-input-recording {
background: #e8e8e8;
border-color: #c0c0c0;
}
.voice-input-text {
font-size: 34rpx;
color: #333;
}
/* 录音浮层 - 微信风格 */
.recording-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.recording-popup {
width: 320rpx;
padding: 48rpx;
background: rgba(0, 0, 0, 0.75);
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.recording-popup.recording-cancel {
background: rgba(0, 0, 0, 0.6);
}
.recording-icon {
width: 120rpx;
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
}
.recording-waves {
display: flex;
align-items: flex-end;
gap: 8rpx;
height: 80rpx;
}
.recording-waves .wave {
width: 12rpx;
height: 24rpx;
background: #07c160;
border-radius: 6rpx;
animation: waveAnim 0.6s ease-in-out infinite;
}
.recording-waves .wave:nth-child(1) { animation-delay: 0s; }
.recording-waves .wave:nth-child(2) { animation-delay: 0.1s; }
.recording-waves .wave:nth-child(3) { animation-delay: 0.2s; }
.recording-waves .wave:nth-child(4) { animation-delay: 0.3s; }
.recording-waves .wave:nth-child(5) { animation-delay: 0.4s; }
@keyframes waveAnim {
0%, 100% { height: 24rpx; }
50% { height: 60rpx; }
}
.cancel-icon {
font-size: 80rpx;
color: #ff4d4f;
}
.recording-hint {
font-size: 28rpx;
color: #fff;
}
.recording-popup .recording-duration {
font-size: 32rpx;
color: rgba(255, 255, 255, 0.9);
}
.message-audio {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 28rpx;
background: rgba(0, 0, 0, 0.05);
border-radius: 12rpx;
min-width: 120rpx;
}
.bubble-out .message-audio {
background: rgba(0, 0, 0, 0.08);
}
.audio-icon {
font-size: 36rpx;
color: #1890ff;
}
.audio-duration {
font-size: 34rpx;
color: #1a1a1a;
font-weight: 500;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 56rpx;
}
.message-input {
flex: 1;
height: 96rpx;
padding: 0 32rpx;
background: #fff;
border-radius: 16rpx;
border: 4rpx solid #e0e0e0;
font-size: 36rpx;
color: #1a1a1a;
}
.send-button {
width: 140rpx;
height: 96rpx;
background: #e0e0e0;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.send-button-active {
background: #07c160;
}
.send-text {
font-size: 36rpx;
color: #888;
font-weight: 500;
}
.send-button-active .send-text {
color: #fff;
}
.emoji-panel {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: flex-end;
z-index: 1000;
animation: fadeIn 0.25s ease;
}
.emoji-content {
width: 100%;
max-height: 65vh;
background: #e8eaed;
border-radius: 28rpx 28rpx 0 0;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.emoji-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 36rpx 36rpx 28rpx;
border-bottom: 4rpx solid #e0e0e0;
background: #fff;
}
.emoji-title {
font-size: 38rpx;
font-weight: 500;
color: #1a1a1a;
}
.emoji-close {
width: 72rpx;
height: 72rpx;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.close-icon {
font-size: 36rpx;
color: #555;
}
.emoji-scroll {
max-height: 55vh;
padding: 28rpx;
background: #e8eaed;
}
.emoji-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.emoji-cell {
width: 112rpx;
height: 112rpx;
background: #fff;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.emoji-cell:active {
transform: scale(0.92);
background: #f0f0f0;
}
.emoji-icon {
font-size: 60rpx;
}
</style>