1835 lines
46 KiB
Vue
1835 lines
46 KiB
Vue
<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-main">
|
||
<view class="doctor-card-avatar-wrap">
|
||
<image :src="doctorInfo.avatar || '/static/user/user.png'" class="doctor-card-avatar" mode="aspectFill" />
|
||
<view class="doctor-card-status-dot"></view>
|
||
</view>
|
||
<view class="doctor-card-info">
|
||
<text class="doctor-card-name">{{ doctorInfo.name || '医生' }}</text>
|
||
<text class="doctor-card-title">{{ doctorInfo.title || '主任医师' }}</text>
|
||
<text class="doctor-card-hospital">{{ doctorInfo.hospital || '甄养堂医院' }}</text>
|
||
</view>
|
||
<view class="doctor-card-timer">
|
||
<!-- <text class="doctor-card-timer-value">{{ formatDoctorWaitTime(doctorWaitSeconds) }}</text> -->
|
||
<text class="doctor-card-timer-label">等待中</text>
|
||
</view>
|
||
</view>
|
||
<view class="doctor-card-tip">
|
||
<text class="doctor-card-tip-icon">⏳</text>
|
||
<text class="doctor-card-tip-text">请等待医生,医生即将进入诊室</text>
|
||
</view>
|
||
</view>
|
||
<view v-for="(msg, index) in messages" :key="index" class="message-wrapper" :class="msg.flow === 'out' ? 'message-wrapper-out' : 'message-wrapper-in'">
|
||
<!-- 头像 -->
|
||
<!-- 头像 -->
|
||
<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'">
|
||
<!-- 文本消息 -->
|
||
<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 { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||
uni.CallManager = new CallManager();
|
||
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); // 是否显示医生卡片(通话开始/结束时隐藏)
|
||
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; // 滚动定时器
|
||
|
||
const CONFIRMATION_MESSAGE = `我已确认以下信息:
|
||
1、本人确认已在线下确诊;
|
||
2、本人确认问诊单信息无误;
|
||
3、本人确认已知晓病情评估风险告知;
|
||
4、本人确认已阅读并同意《互联网诊疗知情同意书》。`;
|
||
|
||
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' });
|
||
});
|
||
}
|
||
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;
|
||
|
||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||
userID:userId,
|
||
});
|
||
|
||
getApp().globalData.userID = userId;
|
||
getApp().globalData.userSig = userSig;
|
||
getApp().globalData.SDKAppID = SDKAppID;
|
||
const cc={
|
||
sdkAppID: SDKAppID, // 替换为用户自己的 sdkAppID
|
||
userID: userId, // 替换为用户自己的 userID
|
||
userSig: userSig, // 替换为用户自己的 userSig
|
||
globalCallPagePath: "TUICallKit/src/Components/TUICallKit", // 替换为步骤一里注册的全局监听页面
|
||
}
|
||
uni.setStorageSync('CallManager', userId)
|
||
await uni.CallManager.init(cc);
|
||
}
|
||
onLoad(async (options) => {
|
||
try {
|
||
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}`;
|
||
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);
|
||
|
||
try {
|
||
await chat.login({ userID, userSig });
|
||
} catch (loginErr) {
|
||
const code = loginErr?.code ?? loginErr?.error?.code;
|
||
const msg = String(loginErr?.message ?? loginErr?.error?.message ?? '');
|
||
const isDuplicateLogin = code === 2025 || msg.includes('重复登录');
|
||
if (isDuplicateLogin) {
|
||
// 重复登录:SDK 可能已在内部处理,等待后继续;若仍不可用,后续 updateMyProfile/loadHistoryMessages 会静默失败
|
||
await new Promise(r => setTimeout(r, 300));
|
||
} else {
|
||
throw loginErr;
|
||
}
|
||
}
|
||
// 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 loadHistoryMessages();
|
||
|
||
if(options.msg==1){
|
||
console.log('msg我进来了',options.msg)
|
||
await sendConfirmationMessage(); // 进入聊天时自动发送知情确认
|
||
}
|
||
loadDoctorInfo(); // 加载医生卡片信息(对方为医生时)
|
||
uni.$on('TUICall:callStart', onVideoCallStart);
|
||
uni.$on('TUICall:callEnd', onVideoCallEnd);
|
||
//uni.showToast({ title: '聊天已就绪', icon: 'success' });
|
||
|
||
// 监听键盘高度变化
|
||
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(() => {
|
||
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();
|
||
}
|
||
});
|
||
|
||
async function loadHistoryMessages() {
|
||
try {
|
||
const res = await chat.getMessageList({ conversationID, count: 15 });
|
||
console.log('消息',conversationID);
|
||
if (res.data && res.data.messageList) {
|
||
// 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:
|
||
return { ...baseMsg, type: 'text', text: msg.payload.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; // 返回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) {
|
||
return { ...baseMsg, type: 'text', text: customData.text };
|
||
}
|
||
|
||
// 没有可显示内容的自定义消息,不显示
|
||
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) {
|
||
const parsedMsg = parseMessage(msg);
|
||
// 只添加非null的消息
|
||
if (parsedMsg !== null) {
|
||
messages.value.push(parsedMsg);
|
||
scrollToBottom();
|
||
setTimeout(() => scrollToBottom(), 150);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function sendTextMessage() {
|
||
if (!inputText.value.trim()) return;
|
||
try {
|
||
const message = chat.createTextMessage({
|
||
to: targetUserID,
|
||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||
payload: { text: inputText.value }
|
||
});
|
||
await chat.sendMessage(message);
|
||
messages.value.push({ type: 'text', text: inputText.value, time: Date.now(), flow: 'out', avatar: userAvatar.value });
|
||
inputText.value = '';
|
||
// 立即滚动,然后再延迟滚动一次确保成功
|
||
scrollToBottom();
|
||
setTimeout(() => scrollToBottom(), 150);
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error);
|
||
uni.showToast({ title: '发送失败', icon: 'none' });
|
||
}
|
||
}
|
||
|
||
async function sendConfirmationMessage() {
|
||
const alreadySent = messages.value.some(m => m.type === 'text' && m.text === CONFIRMATION_MESSAGE);
|
||
if (alreadySent) return;
|
||
try {
|
||
const message = chat.createTextMessage({
|
||
to: targetUserID,
|
||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||
payload: { text: CONFIRMATION_MESSAGE }
|
||
});
|
||
await chat.sendMessage(message);
|
||
messages.value.push({ type: 'text', text: CONFIRMATION_MESSAGE, time: Date.now(), flow: 'out', avatar: userAvatar.value });
|
||
scrollToBottom();
|
||
setTimeout(() => scrollToBottom(), 150);
|
||
} catch (error) {
|
||
console.error('发送确认信息失败:', error);
|
||
}
|
||
}
|
||
|
||
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' });
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function makeVideoCall() {
|
||
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();
|
||
}
|
||
|
||
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 32rpx;
|
||
padding-bottom: 320rpx;
|
||
min-height: 100%;
|
||
}
|
||
|
||
/* 医生卡片 */
|
||
.doctor-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-bottom: 32rpx;
|
||
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
||
border-radius: 24rpx;
|
||
border: 2rpx solid #e8ecf0;
|
||
box-shadow: 0 4rpx 20rpx rgba(24, 144, 255, 0.06);
|
||
overflow: hidden;
|
||
}
|
||
.doctor-card-main {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 28rpx 32rpx;
|
||
}
|
||
.doctor-card-avatar-wrap {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
.doctor-card-avatar {
|
||
width: 96rpx;
|
||
height: 96rpx;
|
||
border-radius: 20rpx;
|
||
background: #e8ecf0;
|
||
}
|
||
.doctor-card-status-dot {
|
||
position: absolute;
|
||
right: 4rpx;
|
||
bottom: 4rpx;
|
||
width: 20rpx;
|
||
height: 20rpx;
|
||
background: #52c41a;
|
||
border: 4rpx solid #fff;
|
||
border-radius: 50%;
|
||
animation: doctorStatusPulse 1.5s ease-in-out infinite;
|
||
}
|
||
@keyframes doctorStatusPulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.7; transform: scale(1.1); }
|
||
}
|
||
.doctor-card-info {
|
||
flex: 1;
|
||
margin-left: 24rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6rpx;
|
||
}
|
||
.doctor-card-name {
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
}
|
||
.doctor-card-title {
|
||
font-size: 26rpx;
|
||
color: #576b95;
|
||
}
|
||
.doctor-card-hospital {
|
||
font-size: 24rpx;
|
||
color: #94a3b8;
|
||
}
|
||
.doctor-card-timer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4rpx;
|
||
padding: 16rpx 28rpx;
|
||
background: linear-gradient(135deg, rgba(24, 144, 255, 0.12) 0%, rgba(24, 144, 255, 0.06) 100%);
|
||
border-radius: 20rpx;
|
||
min-width: 120rpx;
|
||
}
|
||
.doctor-card-timer-value {
|
||
font-size: 40rpx;
|
||
font-weight: 600;
|
||
color: #1890ff;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.doctor-card-timer-label {
|
||
font-size: 22rpx;
|
||
color: #94a3b8;
|
||
}
|
||
.doctor-card-tip {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12rpx;
|
||
padding: 20rpx 24rpx;
|
||
background: linear-gradient(90deg, rgba(24, 144, 255, 0.08) 0%, rgba(24, 144, 255, 0.04) 100%);
|
||
border-top: 2rpx dashed rgba(24, 144, 255, 0.15);
|
||
}
|
||
.doctor-card-tip-icon {
|
||
font-size: 32rpx;
|
||
}
|
||
.doctor-card-tip-text {
|
||
font-size: 28rpx;
|
||
color: #576b95;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.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: 100rpx;
|
||
height: 100rpx;
|
||
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: 20rpx;
|
||
}
|
||
|
||
.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-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>
|