Files
zyt/TUICallKit-Vue3/TUIKit/pages/chat/chat.vue
T
2026-03-19 12:19:00 +08:00

1531 lines
37 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-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="38" 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="40" color="#576b95"></uni-icons>
<text class="tool-label">图片</text>
</view>
<view class="tool-item" @click="chooseFile">
<uni-icons type="paperclip " size="40" 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 } from 'vue';
import { onLoad, onUnload } 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';
const messages = ref([]);
const inputText = ref('');
const scrollTop = ref(0);
const showEmoji = ref(false);
const keyboardHeight = ref(0);
const userAvatar = ref(''); // 当前用户头像
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 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' });
});
}
onLoad(async (options) => {
try {
const app = getApp();
const userID = app.globalData.userID || options.userID;
const userSig = app.globalData.userSig || options.userSig;
const SDKAppID = app.globalData.SDKAppID || options.SDKAppID;
targetUserID = options.targetUserID;
currentUserID = userID; // 保存当前用户ID
console.log('-------',userID,userSig,SDKAppID)
// 获取用户头像
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, '对方用户:', targetUserID,'conversationID'+conversationID);
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);
await chat.login({ userID, userSig });
let promise = chat.updateMyProfile({
nick: userData.nickname,
avatar: userData.avatar,
gender: TencentCloudChat.TYPES.GENDER_MALE,
})
promise.then(function(imResponse) {
console.log('sssssssssssssssssssss',imResponse.data,userData.avatar); // 更新资料成功
}).catch(function(imError) {
console.warn('updateMyProfile error:', imError); // 更新资料失败的相关信息
});
app.globalData.imChat = chat;
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
} else {
chat = app.globalData.imChat;
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
}
await loadHistoryMessages();
//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()
});
}
});
onUnload(() => {
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 });
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' });
}
}
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 {
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 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%;
}
.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: 38rpx;
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>