Files
zyt/TUICallKit-Vue3/TUIKit/pages/chat/chat.vue
T
2026-03-20 13:56:40 +08:00

1835 lines
46 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-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>