This commit is contained in:
Your Name
2026-03-18 14:53:09 +08:00
parent 338b104d50
commit 7832514f28
315 changed files with 7071 additions and 1136 deletions
+488 -138
View File
@@ -46,6 +46,12 @@
<!-- 表情消息 -->
<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>
@@ -73,22 +79,27 @@
<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="30" color="#999"></uni-icons>
<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="30" color="#999"></uni-icons>
<uni-icons type="paperclip " size="40" color="#576b95"></uni-icons>
<text class="tool-label">文件</text>
</view>
</view>
<!-- 输入 -->
<view class="input-wrapper">
<!-- 输入区域文字模式 -->
<view v-if="inputMode === 'text'" class="input-wrapper">
<input
class="message-input"
v-model="inputText"
placeholder="输入消息..."
placeholder="输入消息内容..."
:adjust-position="false"
:hold-keyboard="true"
@focus="onInputFocus"
@@ -99,6 +110,37 @@
<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">
<view class="recording-popup" :class="{ 'recording-cancel': willCancel }">
<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>
<!-- 表情选择器 -->
@@ -137,7 +179,15 @@
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); // 上滑取消
const playingAudioId = ref('');
let innerAudioContext = null;
let chat = null;
let recorderManager = null;
let recordingTimer = null;
let conversationID = '';
let targetUserID = '';
let currentUserID = ''; // 当前登录用户ID
@@ -158,6 +208,174 @@
'👏', '🙌', '👐', '🤲', '🤝', '🙏', '💪', '❤️'
];
// 切换输入模式(文字/语音)
function toggleInputMode() {
inputMode.value = inputMode.value === 'text' ? 'voice' : 'text';
}
// 初始化录音管理器
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
}
// 按住说话 - 开始
function onVoiceTouchStart(e) {
// #ifdef MP-WEIXIN
e.preventDefault();
willCancel.value = false;
touchStartY = e.touches[0]?.clientY || 0;
if (!recorderManager) initRecorderManager();
startRecord();
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '当前平台暂不支持语音', icon: 'none' });
// #endif
}
// 松开 - 发送或取消
function onVoiceTouchEnd(e) {
// #ifdef MP-WEIXIN
e.preventDefault();
if (isRecording.value && recorderManager) {
recorderManager.stop();
}
// #endif
}
// 触摸取消(如滑出区域)
function onVoiceTouchCancel(e) {
onVoiceTouchEnd(e);
}
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();
@@ -236,6 +454,13 @@
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() {
@@ -335,8 +560,11 @@
return null; // 解析失败的消息不显示
}
case TencentCloudChat.TYPES.MSG_AUDIO:
return { ...baseMsg, type: 'text', text: '[语音消息]' };
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: '[视频消息]' };
@@ -625,37 +853,36 @@
</script>
<style scoped>
/* 容器 */
/* 适老化设计:50-80岁 - 大字号、高对比、大触控区 */
.chat-container {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
background: #f7f8fa;
background: #e8eaed;
}
/* 顶部导航栏 */
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 20rpx;
height: 100rpx;
padding: 0 28rpx;
background: #fff;
border-bottom: 1rpx solid #e7e7e7;
border-bottom: 4rpx solid #e0e0e0;
}
.header-left {
width: 88rpx;
height: 88rpx;
width: 100rpx;
height: 100rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-icon {
font-size: 48rpx;
color: #576b95;
font-size: 52rpx;
color: #1890ff;
font-weight: 500;
}
@@ -667,51 +894,50 @@
}
.header-title {
font-size: 34rpx;
font-size: 40rpx;
font-weight: 500;
color: #000;
color: #1a1a1a;
}
.header-status {
font-size: 22rpx;
color: #888;
margin-top: 2rpx;
font-size: 28rpx;
color: #555;
margin-top: 4rpx;
}
.header-right {
width: 88rpx;
width: 100rpx;
display: flex;
justify-content: flex-end;
}
.video-call-btn {
width: 64rpx;
height: 64rpx;
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.video-icon {
font-size: 44rpx;
color: #576b95;
font-size: 52rpx;
color: #1890ff;
}
/* 消息列表 */
.message-list {
flex: 1;
overflow: hidden;
}
.message-container {
padding: 20rpx 24rpx;
padding-bottom: 280rpx;
padding: 28rpx 32rpx;
padding-bottom: 320rpx;
min-height: 100%;
}
.message-wrapper {
display: flex;
margin-bottom: 24rpx;
margin-bottom: 32rpx;
animation: fadeIn 0.25s ease;
}
@@ -728,45 +954,45 @@
flex-direction: row-reverse;
}
/* 头像 */
/* 头像 - 更大 */
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 8rpx;
width: 100rpx;
height: 100rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 500;
background: #e8e8e8;
background: #e0e0e0;
}
.avatar-in {
background: #e8e8e8;
margin-right: 16rpx;
background: #e0e0e0;
margin-right: 20rpx;
}
.avatar-out {
background: #95ec69;
margin-left: 16rpx;
margin-left: 20rpx;
}
.avatar-text {
font-size: 28rpx;
font-size: 36rpx;
color: #fff;
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 8rpx;
border-radius: 12rpx;
}
/* 消息气泡 */
/* 消息气泡 - 更大内边距 */
.message-bubble {
max-width: 500rpx;
padding: 20rpx 24rpx;
border-radius: 8rpx;
max-width: 540rpx;
padding: 28rpx 32rpx;
border-radius: 16rpx;
position: relative;
}
@@ -778,113 +1004,110 @@
background: #95ec69;
}
/* 文本消息 */
/* 文本消息 - 大字号 */
.message-text {
font-size: 30rpx;
line-height: 1.5;
color: #000;
font-size: 38rpx;
line-height: 1.6;
color: #1a1a1a;
word-break: break-all;
}
.bubble-out .message-text {
color: #000;
color: #1a1a1a;
}
/* 图片消息 */
.message-image {
max-width: 100%;
border-radius: 8rpx;
border-radius: 12rpx;
display: block;
}
/* 文件消息 */
/* 文件消息 - 大触控区 */
.message-file {
display: flex;
align-items: center;
gap: 16rpx;
padding: 12rpx;
background: rgba(0, 0, 0, 0.04);
border-radius: 8rpx;
gap: 24rpx;
padding: 20rpx;
background: rgba(0, 0, 0, 0.05);
border-radius: 12rpx;
}
.bubble-out .message-file {
background: rgba(0, 0, 0, 0.06);
background: rgba(0, 0, 0, 0.08);
}
.file-icon-wrapper {
width: 72rpx;
height: 72rpx;
background: #576b95;
border-radius: 8rpx;
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.15);
background: rgba(0, 0, 0, 0.2);
}
.file-icon {
font-size: 40rpx;
font-size: 48rpx;
}
.file-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
gap: 12rpx;
}
.file-name {
font-size: 28rpx;
color: #000;
font-weight: 400;
font-size: 34rpx;
color: #1a1a1a;
font-weight: 500;
}
.bubble-out .file-name {
color: #000;
color: #1a1a1a;
}
.file-size {
font-size: 22rpx;
color: #888;
font-size: 28rpx;
color: #555;
}
.bubble-out .file-size {
color: #666;
color: #555;
}
.download-icon {
font-size: 28rpx;
color: #576b95;
font-size: 36rpx;
color: #1890ff;
}
.bubble-out .download-icon {
color: #000;
color: #1a1a1a;
}
/* 表情消息 */
.message-emoji {
font-size: 96rpx;
font-size: 112rpx;
line-height: 1;
}
/* 消息元信息 */
.message-meta {
margin-top: 8rpx;
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: flex-end;
}
.message-time {
font-size: 20rpx;
color: #b2b2b2;
font-size: 26rpx;
color: #666;
}
.bubble-out .message-time {
color: #888;
color: #555;
}
/* 空状态 */
@@ -894,92 +1117,220 @@
align-items: center;
justify-content: center;
height: 600rpx;
gap: 16rpx;
gap: 24rpx;
}
.empty-icon {
font-size: 100rpx;
opacity: 0.25;
font-size: 120rpx;
opacity: 0.3;
}
.empty-text {
font-size: 28rpx;
color: #b2b2b2;
font-weight: 400;
font-size: 38rpx;
color: #555;
font-weight: 500;
}
.empty-hint {
font-size: 24rpx;
color: #d0d0d0;
font-size: 32rpx;
color: #888;
}
/* 底部占位 */
.bottom-placeholder {
height: 40rpx;
height: 56rpx;
}
/* 输入区域 */
/* 输入区域 - 大触控区 */
.input-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #f7f8fa;
border-top: 1rpx solid #e7e7e7;
padding: 16rpx 20rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
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: 32rpx;
margin-bottom: 16rpx;
padding: 0 8rpx;
gap: 48rpx;
margin-bottom: 24rpx;
padding: 0 12rpx;
}
.tool-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
gap: 12rpx;
min-height: 100rpx;
}
.tool-item-left {
margin-right: 16rpx;
}
.tool-icon {
font-size: 48rpx;
font-size: 56rpx;
}
.tool-label {
font-size: 20rpx;
color: #888;
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: 12rpx;
margin-bottom: 45rpx;
gap: 20rpx;
margin-bottom: 56rpx;
}
.message-input {
flex: 1;
height: 72rpx;
padding: 0 24rpx;
height: 96rpx;
padding: 0 32rpx;
background: #fff;
border-radius: 8rpx;
border: 1rpx solid #e7e7e7;
font-size: 28rpx;
color: #000;
border-radius: 16rpx;
border: 4rpx solid #e0e0e0;
font-size: 36rpx;
color: #1a1a1a;
}
.send-button {
width: 112rpx;
height: 72rpx;
background: #ededed;
border-radius: 8rpx;
width: 140rpx;
height: 96rpx;
background: #e0e0e0;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
@@ -991,16 +1342,15 @@
}
.send-text {
font-size: 28rpx;
font-size: 36rpx;
color: #888;
font-weight: 400;
font-weight: 500;
}
.send-button-active .send-text {
color: #fff;
}
/* 表情选择器 */
.emoji-panel {
position: fixed;
top: 0;
@@ -1017,8 +1367,8 @@
.emoji-content {
width: 100%;
max-height: 65vh;
background: #f7f8fa;
border-radius: 24rpx 24rpx 0 0;
background: #e8eaed;
border-radius: 28rpx 28rpx 0 0;
animation: slideUp 0.25s ease;
}
@@ -1031,20 +1381,20 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx 20rpx;
border-bottom: 1rpx solid #e7e7e7;
padding: 36rpx 36rpx 28rpx;
border-bottom: 4rpx solid #e0e0e0;
background: #fff;
}
.emoji-title {
font-size: 30rpx;
font-size: 38rpx;
font-weight: 500;
color: #000;
color: #1a1a1a;
}
.emoji-close {
width: 52rpx;
height: 52rpx;
width: 72rpx;
height: 72rpx;
background: #f0f0f0;
border-radius: 50%;
display: flex;
@@ -1053,27 +1403,27 @@
}
.close-icon {
font-size: 28rpx;
color: #888;
font-size: 36rpx;
color: #555;
}
.emoji-scroll {
max-height: 55vh;
padding: 20rpx;
background: #f7f8fa;
padding: 28rpx;
background: #e8eaed;
}
.emoji-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
gap: 16rpx;
}
.emoji-cell {
width: 96rpx;
height: 96rpx;
width: 112rpx;
height: 112rpx;
background: #fff;
border-radius: 12rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
@@ -1086,6 +1436,6 @@
}
.emoji-icon {
font-size: 52rpx;
font-size: 60rpx;
}
</style>