ai-clone/frontend-ai/pages/video-call/video-call.vue

1014 lines
26 KiB
Vue
Raw Normal View History

2026-03-05 14:29:21 +08:00
<template>
<view class="video-call-container">
<!-- 顶部信息 -->
<view class="header">
<text class="caller-name">📹 {{ callerName }}视频通话</text>
<text class="call-duration">{{ callDuration }}</text>
</view>
<!-- 视频播放区域 -->
<view class="video-section">
<video
v-if="currentVideoUrl"
:src="currentVideoUrl"
:loop="false"
:autoplay="true"
id="videoPlayer"
:show-center-play-btn="false"
:show-play-btn="false"
:controls="false"
:enable-progress-gesture="false"
:show-progress="false"
:show-fullscreen-btn="false"
:show-loading="true"
:object-fit="'contain'"
:poster="''"
class="video-player"
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
@error="onVideoError"
@loadedmetadata="onVideoLoaded"
@waiting="onVideoWaiting"
@canplay="onVideoCanPlay"
@timeupdate="onVideoTimeUpdate"
></video>
<!-- 视频加载提示 -->
<view v-if="isVideoLoading" class="video-loading">
<text>📹 视频加载中...</text>
</view>
<!-- 说话状态指示器 -->
<view v-if="isSpeaking" class="speaking-indicator">
<text class="pulse">🔊 正在说话...</text>
</view>
</view>
<!-- 对话历史 -->
<scroll-view scroll-y class="chat-history" :scroll-top="scrollTop" scroll-with-animation>
<view v-for="(msg, index) in messages" :key="index" :class="['message', msg.role === 'user' ? 'user-message' : 'ai-message']">
<view class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</view>
<view class="message-content">
<text class="message-text">{{ msg.content }}</text>
<text class="message-time">{{ msg.time }}</text>
</view>
</view>
<view v-if="messages.length === 0" class="empty-hint">
<text>👋 开始对话吧</text>
</view>
</scroll-view>
<!-- 控制按钮 -->
<view class="controls">
<view v-if="voiceId && voiceId.startsWith('cosyvoice-v3-plus-')" class="form-section">
<view class="form-label">方言</view>
<picker mode="selector" :range="dialectOptions" @change="onDialectChange">
<view class="picker-large">
{{ selectedDialect || '请选择方言(可选)' }}
</view>
</picker>
</view>
<view v-if="voiceId && voiceId.startsWith('cosyvoice-v3-plus-')" class="form-section">
<view class="form-label">语言提示可选</view>
<picker mode="selector" :range="languageHintOptions" @change="onLanguageHintChange">
<view class="picker-large">
{{ selectedLanguageHintLabel || '请选择语言(可选)' }}
</view>
</picker>
<view class="hint-text">💡 仅处理第一个值不设置不生效</view>
</view>
<view class="control-row">
<button v-if="!isRecording && !isProcessing" class="talk-btn" @click="startTalking">
🎤 开始说话
</button>
<button v-if="isRecording" class="talk-btn recording" @click="stopTalking">
停止录音
</button>
<button v-if="isProcessing" class="talk-btn processing" disabled>
{{ processingText }}
</button>
</view>
<view class="control-row">
<button class="hangup-btn" @click="hangUp">
📞 挂断
</button>
</view>
</view>
</view>
</template>
<script>
import { API_BASE, API_ENDPOINTS, buildURL } from '@/config/api.js';
export default {
data() {
return {
API_BASE,
// 视频资源
idleVideoUrl: '', // 待机视频URL
talkingVideoUrl: '', // 说话视频URL
currentVideoUrl: '', // 当前播放的视频URL
isVideoLoading: true, // 视频是否正在加载
videoContext: null, // 视频控制器
hasPlayedInitial: false, // 是否已播放初始1秒
// 通话信息
callerName: '对方',
voiceId: '',
selectedDialect: '',
selectedLanguageHint: '',
selectedLanguageHintLabel: '',
languageHintOptions: ['中文(zh)', '英文(en)', '法语(fr)', '德语(de)', '日语(ja)', '韩语(ko)', '俄语(ru)'],
dialectOptions: ['广东话', '东北话', '甘肃话', '贵州话', '河南话', '湖北话', '江西话', '闽南话', '宁夏话', '山西话', '陕西话', '山东话', '上海话', '四川话', '天津话', '云南话'],
callDuration: '00:00',
callStartTime: null,
durationTimer: null,
// 状态
isIdle: true, // 是否待机状态
isSpeaking: false, // AI是否在说话
isRecording: false,
isProcessing: false,
processingText: '处理中...',
// 对话
messages: [],
systemPrompt: '你是一位温暖的对话者,用亲切的语气与对方交流。',
// 录音
recorderManager: null,
audioFilePath: '',
// 音频播放
audioContext: null,
// 语音活动检测VAD
vadEnabled: true,
vadVoiceThreshold: 0.004, // 0~1 之间的音量阈值,更灵敏
vadSilenceMs: 2500, // 静音判定时长
vadMaxDurationMs: 60000, // 单次录音最大时长
vadSpeaking: false,
vadSilenceStart: null,
vadMaxTimer: null,
recordingStartTime: null,
vadLastSoundTime: null,
vadLastFrameTime: null,
vadWatchTimer: null,
autoListen: true,
autoLoopTimer: null,
// 滚动
scrollTop: 0
};
},
onLoad(options) {
console.log('[VideoCall] 页面参数:', options);
// 从参数获取视频和音色信息
this.idleVideoUrl = decodeURIComponent(options.idleVideo || '');
this.talkingVideoUrl = decodeURIComponent(options.talkingVideo || this.idleVideoUrl);
this.voiceId = options.voiceId || '';
this.callerName = decodeURIComponent(options.callerName || '对方');
console.log('[VideoCall] 待机视频URL:', this.idleVideoUrl);
console.log('[VideoCall] 说话视频URL:', this.talkingVideoUrl);
console.log('[VideoCall] 音色ID:', this.voiceId);
// 下载视频到本地并转换路径
this.downloadAndConvertVideo();
this.startCallTimer();
this.initRecorder();
this.initAudioContext();
this.initVideoContext();
this.startAutoLoop();
},
onUnload() {
// 清理定时器
if (this.durationTimer) {
clearInterval(this.durationTimer);
}
if (this.vadMaxTimer) {
clearTimeout(this.vadMaxTimer);
this.vadMaxTimer = null;
}
if (this.vadWatchTimer) {
clearInterval(this.vadWatchTimer);
this.vadWatchTimer = null;
}
if (this.autoLoopTimer) {
clearInterval(this.autoLoopTimer);
this.autoLoopTimer = null;
}
// 清理音频
if (this.audioContext) {
this.audioContext.destroy();
}
},
methods: {
// 下载视频并转换本地路径
async downloadAndConvertVideo() {
if (!this.idleVideoUrl) {
uni.showToast({
title: '视频URL无效',
icon: 'none'
});
return;
}
console.log('[VideoCall] ========== 视频加载 ==========');
console.log('[VideoCall] 原始URL:', this.idleVideoUrl);
// 从配置获取 API_BASE用于判断是否是服务器视频
const apiBaseUrl = new URL(API_BASE).hostname;
// 检查是否是本地服务器视频(包含 /static/videos/ 路径或包含 API_BASE 的域名)
const isLocalServerVideo = this.idleVideoUrl.includes('/static/videos/') ||
this.idleVideoUrl.includes(apiBaseUrl);
// 检查是否是外部视频链接grsai.com等
const isExternalVideo = this.idleVideoUrl.includes('grsai.com') ||
this.idleVideoUrl.includes('file49');
if (isExternalVideo) {
console.log('[VideoCall] 检测到外部视频链接直接使用URL播放');
this.currentVideoUrl = this.idleVideoUrl;
this.talkingVideoUrl = this.idleVideoUrl;
console.log('[VideoCall] ========== 视频准备完成 ==========');
return;
}
if (isLocalServerVideo) {
console.log('[VideoCall] 检测到本地服务器视频直接使用URL播放');
this.currentVideoUrl = this.idleVideoUrl;
this.talkingVideoUrl = this.idleVideoUrl;
console.log('[VideoCall] ========== 视频准备完成 ==========');
return;
}
// 本地服务器视频,尝试下载
console.log('[VideoCall] 本地服务器视频,开始下载...');
uni.showLoading({
title: '视频加载中...',
mask: true
});
// 下载视频到本地
uni.downloadFile({
url: this.idleVideoUrl,
success: (res) => {
console.log('[VideoCall] 下载响应状态码:', res.statusCode);
if (res.statusCode === 200) {
console.log('[VideoCall] ✅ 视频下载成功');
console.log('[VideoCall] 临时路径:', res.tempFilePath);
// #ifdef APP-PLUS
// APP环境转换本地路径为UniApp可识别格式
if (typeof plus !== 'undefined' && plus.io) {
try {
console.log('[VideoCall] 开始转换路径...');
const convertedPath = plus.io.convertLocalFileSystemURL(res.tempFilePath);
console.log('[VideoCall] ✅ 路径转换成功');
console.log('[VideoCall] 转换后路径:', convertedPath);
this.currentVideoUrl = convertedPath;
this.idleVideoUrl = convertedPath;
this.talkingVideoUrl = convertedPath;
uni.hideLoading();
console.log('[VideoCall] ========== 视频准备完成 ==========');
} catch (err) {
console.error('[VideoCall] ❌ 路径转换失败:', err);
console.log('[VideoCall] 使用临时路径作为备选方案');
// 转换失败,直接使用临时路径
this.currentVideoUrl = res.tempFilePath;
this.idleVideoUrl = res.tempFilePath;
this.talkingVideoUrl = res.tempFilePath;
uni.hideLoading();
}
} else {
console.log('[VideoCall] plus.io 不可用,使用临时路径');
this.currentVideoUrl = res.tempFilePath;
this.idleVideoUrl = res.tempFilePath;
this.talkingVideoUrl = res.tempFilePath;
uni.hideLoading();
}
// #endif
// #ifndef APP-PLUS
// 非APP环境直接使用临时路径
console.log('[VideoCall] 非APP环境使用临时路径');
this.currentVideoUrl = res.tempFilePath;
this.idleVideoUrl = res.tempFilePath;
this.talkingVideoUrl = res.tempFilePath;
uni.hideLoading();
// #endif
} else {
console.error('[VideoCall] ❌ 视频下载失败,状态码:', res.statusCode);
this.handleDownloadError();
}
},
fail: (err) => {
console.error('[VideoCall] ❌ 视频下载失败:', err);
console.error('[VideoCall] 错误详情:', JSON.stringify(err));
this.handleDownloadError();
}
});
},
// 处理下载失败
handleDownloadError() {
uni.hideLoading();
console.log('[VideoCall] 下载失败直接使用原URL播放');
this.currentVideoUrl = this.idleVideoUrl;
uni.showToast({
title: '将直接播放网络视频',
icon: 'none',
duration: 2000
});
},
// 初始化录音
initRecorder() {
this.recorderManager = uni.getRecorderManager();
this.recorderManager.onStart(() => {
console.log('[VideoCall] 开始录音');
});
this.recorderManager.onStop((res) => {
console.log('[VideoCall] 录音完成:', res.tempFilePath);
this.resetVADState();
const duration = res.duration || (Date.now() - (this.recordingStartTime || Date.now()));
if (duration < 400) {
console.warn('[VideoCall] 录音过短,忽略本次');
this.isProcessing = false;
return;
}
this.audioFilePath = res.tempFilePath;
this.processConversation();
});
// 帧回调用于VAD
this.recorderManager.onFrameRecorded && this.recorderManager.onFrameRecorded((res) => {
if (!this.vadEnabled || this.isProcessing) return;
this.handleVADFrame(res.frameBuffer);
});
this.recorderManager.onError((err) => {
console.error('[VideoCall] 录音失败:', err);
console.error('[VideoCall] 错误详情:', JSON.stringify(err));
this.isRecording = false;
this.resetVADState();
let errorMsg = '录音失败';
if (err.errMsg) {
if (err.errMsg.includes('permission')) {
errorMsg = '录音权限被拒绝,请在设置中允许录音权限';
} else if (err.errMsg.includes('busy')) {
errorMsg = '录音设备忙,请稍后再试';
} else {
errorMsg = `录音失败: ${err.errMsg}`;
}
}
uni.showModal({
title: '录音失败',
content: errorMsg + '\n\n请检查:\n1. 是否授予录音权限\n2. 麦克风是否被其他应用占用\n3. 设备是否支持录音',
showCancel: false,
confirmText: '知道了'
});
});
},
// 初始化视频控制器
initVideoContext() {
this.$nextTick(() => {
this.videoContext = uni.createVideoContext('videoPlayer', this);
console.log('[VideoCall] 视频控制器初始化完成');
});
},
// 初始化音频播放器
initAudioContext() {
this.audioContext = uni.createInnerAudioContext();
this.audioContext.onPlay(() => {
console.log('[VideoCall] 开始播放AI回复');
});
this.audioContext.onEnded(() => {
console.log('[VideoCall] AI回复播放完成');
// 恢复待机状态
this.isSpeaking = false;
this.isIdle = true;
});
this.audioContext.onError((err) => {
console.error('[VideoCall] 音频播放失败:', err);
this.isSpeaking = false;
this.isIdle = true;
});
},
// 开始通话计时
startCallTimer() {
this.callStartTime = Date.now();
this.durationTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - this.callStartTime) / 1000);
const minutes = Math.floor(elapsed / 60)
.toString()
.padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
this.callDuration = `${minutes}:${seconds}`;
}, 1000);
},
// 开始说话
startTalking() {
if (this.isProcessing) return;
this.resetVADState();
this.recordingStartTime = Date.now();
this.vadLastSoundTime = this.recordingStartTime;
this.vadLastFrameTime = this.recordingStartTime;
this.isRecording = true;
this.vadMaxTimer = setTimeout(() => {
this.stopTalkingFromVAD('max_duration');
}, this.vadMaxDurationMs);
this.vadWatchTimer = setInterval(() => {
if (!this.isRecording) return;
const now = Date.now();
const elapsed = now - (this.recordingStartTime || now);
// 如果没有帧回调4s 兜底停止
if (now - (this.vadLastFrameTime || now) > 12000) {
this.stopTalkingFromVAD('no_frame');
return;
}
// 静音超时(需已检测到说话)
if (elapsed > 1500 && this.vadSpeaking && now - (this.vadLastSoundTime || now) > this.vadSilenceMs) {
this.stopTalkingFromVAD('silence');
return;
}
}, 300);
this.recorderManager.start({
format: 'mp3',
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
frameSize: 32 // KB触发帧回调用于VAD
});
uni.showToast({
title: '正在聆听(自动识别说话)',
icon: 'none',
duration: 10000
});
},
// 停止说话
stopTalking() {
this.stopTalkingFromVAD('manual');
},
// VAD/手动统一停止
stopTalkingFromVAD(reason = 'vad') {
this.isRecording = false;
this.recorderManager.stop();
this.isProcessing = true;
this.processingText = '识别中...';
this.resetVADState();
console.log('[VideoCall] 停止录音,原因:', reason);
},
resetVADState() {
this.vadSpeaking = false;
this.vadSilenceStart = null;
this.vadLastSoundTime = null;
this.vadLastFrameTime = null;
if (this.vadMaxTimer) {
clearTimeout(this.vadMaxTimer);
this.vadMaxTimer = null;
}
if (this.vadWatchTimer) {
clearInterval(this.vadWatchTimer);
this.vadWatchTimer = null;
}
},
onDialectChange(e) {
this.selectedDialect = this.dialectOptions[e.detail.value] || '';
},
onLanguageHintChange(e) {
const label = this.languageHintOptions[e.detail.value] || '';
this.selectedLanguageHintLabel = label;
const match = label.match(/\(([^)]+)\)/);
this.selectedLanguageHint = match && match[1] ? match[1] : '';
},
handleVADFrame(frameBuffer) {
if (!frameBuffer || frameBuffer.byteLength === 0) return;
this.vadLastFrameTime = Date.now();
const volume = this.calculateRms(frameBuffer);
// 检测到声音
if (volume > this.vadVoiceThreshold) {
this.vadSpeaking = true;
this.vadSilenceStart = null;
this.vadLastSoundTime = Date.now();
return;
}
// 已在说话,出现静音
if (this.vadSpeaking) {
if (!this.vadSilenceStart) {
this.vadSilenceStart = Date.now();
} else if (Date.now() - this.vadSilenceStart > this.vadSilenceMs) {
this.stopTalkingFromVAD('silence');
}
}
},
calculateRms(frameBuffer) {
const dataView = new DataView(frameBuffer);
const len = dataView.byteLength / 2; // 16-bit
if (len === 0) return 0;
let sum = 0;
for (let i = 0; i < len; i++) {
const sample = dataView.getInt16(i * 2, true); // little-endian
sum += sample * sample;
}
return Math.sqrt(sum / len) / 32768; // 归一化
},
startAutoLoop() {
if (!this.autoListen) return;
if (this.autoLoopTimer) {
clearInterval(this.autoLoopTimer);
}
this.autoLoopTimer = setInterval(() => {
if (!this.autoListen) return;
// 空闲才自动开始
if (!this.isRecording && !this.isProcessing) {
this.startTalking();
}
}, 2000);
},
// 处理对话
async processConversation() {
try {
this.processingText = '正在识别...';
console.log('[VideoCall] 开始对话请求');
console.log('[VideoCall] 音频文件:', this.audioFilePath);
console.log('[VideoCall] 音色ID:', this.voiceId);
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
// 上传音频并处理对话
uni.uploadFile({
url: `${this.API_BASE}/api/conversation/talk`,
filePath: this.audioFilePath,
name: 'audio',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
formData: (() => {
const voiceId = this.voiceId || '';
const voiceType = (() => {
const v = (voiceId || '').trim();
if (!v) return 'CLONE';
const knownOfficial = ['Cherry','Kai','Mochi','Bunny','Jada','Dylan','Li','Marcus','Roy','Peter','Sunny','Eric','Rocky','Kiki'];
if (knownOfficial.includes(v)) return 'OFFICIAL';
if (v.startsWith('BV') || v.endsWith('_streaming') || v.endsWith('_offline') || v.endsWith('_bigtts')) return 'OFFICIAL';
return 'CLONE';
})();
const data = { voiceId, voiceType };
if (this.voiceId && this.voiceId.startsWith('cosyvoice-v3-plus-') && this.selectedDialect) {
data.dialect = this.selectedDialect;
}
if (this.voiceId && this.voiceId.startsWith('cosyvoice-v3-plus-') && this.selectedLanguageHint) {
data.languageHints = this.selectedLanguageHint;
}
return data;
})(),
success: (res) => {
console.log('[VideoCall] 对话响应状态码:', res.statusCode);
console.log('[VideoCall] 对话响应数据:', res.data);
try {
const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
if (result.success) {
console.log('[VideoCall] 对话成功');
console.log('[VideoCall] 识别文本:', result.recognizedText);
console.log('[VideoCall] AI回复:', result.aiResponse);
console.log('[VideoCall] 音频文件:', result.audioFile);
// 添加用户消息
this.addMessage('user', result.recognizedText);
// 添加AI回复
this.addMessage('ai', result.aiResponse);
// 播放AI回复
this.playAIResponse(result.audioFile);
} else {
console.error('[VideoCall] 对话失败:', result.message);
uni.showToast({
title: result.message || '对话失败',
icon: 'none'
});
this.isProcessing = false;
}
} catch (e) {
console.error('[VideoCall] JSON解析失败:', e);
console.error('[VideoCall] 原始响应:', res.data);
uni.showToast({
title: '服务器响应格式错误',
icon: 'none'
});
this.isProcessing = false;
}
},
fail: (err) => {
console.error('[VideoCall] 对话失败:', err);
uni.showToast({
title: '对话失败',
icon: 'none'
});
this.isProcessing = false;
}
});
} catch (error) {
console.error('[VideoCall] 对话失败:', error);
uni.showToast({
title: '对话失败: ' + error.message,
icon: 'none'
});
this.isProcessing = false;
}
},
// 播放AI回复
playAIResponse(audioFile) {
this.processingText = '正在回复...';
// 标记AI正在说话视频会在音频播放时自动播放
this.isIdle = false;
this.isSpeaking = true;
// 播放音频(音频播放时会触发视频播放)
this.audioContext.src = `${this.API_BASE}/api/conversation/audio/${audioFile}`;
this.audioContext.play();
this.isProcessing = false;
},
// 添加消息
addMessage(role, content) {
const now = new Date();
const time = `${now.getHours()}:${now
.getMinutes()
.toString()
.padStart(2, '0')}`;
this.messages.push({
role,
content,
time
});
// 滚动到底部
this.$nextTick(() => {
this.scrollTop = 999999;
});
},
// 视频播放事件
onVideoPlay() {
console.log('[VideoCall] 视频开始播放');
this.isVideoLoading = false;
},
// 视频暂停事件
onVideoPause() {
console.log('[VideoCall] 视频暂停');
},
// 视频结束事件
onVideoEnded() {
console.log('[VideoCall] 视频播放结束');
if (!this.isIdle && this.isSpeaking) {
// 说话视频结束但音频还在播放,继续循环视频
console.log('[VideoCall] 视频循环播放');
}
},
// 视频加载成功
onVideoLoaded(e) {
console.log('[VideoCall] 视频元数据加载成功:', e);
this.isVideoLoading = false;
},
// 视频可以播放
onVideoCanPlay() {
console.log('[VideoCall] 视频可以播放');
this.isVideoLoading = false;
},
// 视频缓冲中
onVideoWaiting() {
console.log('[VideoCall] 视频缓冲中...');
this.isVideoLoading = true;
},
// 视频时间更新
onVideoTimeUpdate(e) {
if (!this.hasPlayedInitial && e.detail && e.detail.currentTime >= 1) {
console.log('[VideoCall] 视频已播放1秒暂停等待AI回复');
this.hasPlayedInitial = true;
if (this.videoContext) {
this.videoContext.pause();
}
}
},
// 视频加载错误
onVideoError(e) {
console.error('[VideoCall] 视频加载失败:', e);
console.error('[VideoCall] 视频URL:', this.currentVideoUrl);
console.error('[VideoCall] 错误详情:', JSON.stringify(e));
this.isVideoLoading = false;
const errMsg = e.detail && e.detail.errMsg ? e.detail.errMsg : '未知错误';
const errCode = e.detail && e.detail.errCode ? e.detail.errCode : 'N/A';
uni.showModal({
title: '视频加载失败',
content: `错误代码: ${errCode}\n错误信息: ${errMsg}\n\n视频URL:\n${this.currentVideoUrl}\n\n请检查:\n1. URL是否有效\n2. 网络连接\n3. 视频格式是否支持\n4. HTTP权限配置`,
showCancel: false,
confirmText: '知道了'
});
},
// 挂断通话
hangUp() {
uni.showModal({
title: '结束通话',
content: '确定要结束通话吗?',
success: (res) => {
if (res.confirm) {
// 停止录音
if (this.isRecording) {
this.recorderManager.stop();
}
// 停止音频播放
if (this.audioContext) {
this.audioContext.stop();
}
// 返回上一页
uni.navigateBack();
}
}
});
}
}
};
</script>
<style scoped>
.video-call-container {
height: 100vh;
display: flex;
flex-direction: column;
background: #000;
}
.header {
padding: 40rpx 40rpx 40rpx 24rpx; /* 左侧缩进,整体左移 */
padding-top: calc(40rpx + constant(safe-area-inset-top));
padding-top: calc(40rpx + env(safe-area-inset-top));
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 10;
}
.caller-name {
font-size: 32rpx;
font-weight: bold;
color: white;
}
.call-duration {
font-size: 28rpx;
color: #4caf50;
font-family: monospace;
}
.video-section {
flex: 1;
position: relative;
background: #000;
overflow: hidden;
z-index: 1;
}
.video-player {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
pointer-events: none;
}
.video-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
padding: 30rpx 50rpx;
border-radius: 20rpx;
color: white;
font-size: 28rpx;
z-index: 20;
pointer-events: auto;
}
.speaking-indicator {
position: absolute;
top: 20rpx;
right: 20rpx;
background: rgba(76, 175, 80, 0.9);
padding: 16rpx 32rpx;
border-radius: 40rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
z-index: 20;
pointer-events: auto;
}
.pulse {
color: white;
font-size: 24rpx;
font-weight: bold;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.chat-history {
height: 300rpx;
background: rgba(255, 255, 255, 0.95);
padding: 20rpx 20rpx 20rpx 10rpx; /* 左侧缩进,内容左移 */
position: relative;
z-index: 10;
}
.empty-hint {
text-align: center;
padding: 60rpx 0;
color: #999;
font-size: 28rpx;
}
.message {
display: flex;
margin-bottom: 20rpx;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.user-message {
flex-direction: row-reverse;
}
.message-avatar {
font-size: 40rpx;
margin: 0 16rpx;
}
.message-content {
max-width: 70%;
padding: 20rpx;
border-radius: 16rpx;
background: #e3f2fd;
}
.user-message .message-content {
background: #c8e6c9;
}
.message-text {
font-size: 28rpx;
color: #333;
display: block;
margin-bottom: 8rpx;
line-height: 1.5;
}
.message-time {
font-size: 22rpx;
color: #999;
}
.controls {
padding: 30rpx 30rpx 30rpx 14rpx; /* 左侧缩进,按钮左移 */
background: rgba(0, 0, 0, 0.8);
position: relative;
z-index: 10;
}
.control-row {
margin-bottom: 20rpx;
}
.control-row:last-child {
margin-bottom: 0;
}
.talk-btn {
width: 100%;
padding: 32rpx;
background: #4caf50;
color: white;
border: none;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 8rpx 16rpx rgba(76, 175, 80, 0.3);
}
.talk-btn.recording {
background: #f44336;
animation: pulse 1s infinite;
}
.talk-btn.processing {
background: #999;
}
.talk-btn[disabled] {
opacity: 0.6;
}
.hangup-btn {
width: 100%;
padding: 32rpx;
background: #f44336;
color: white;
border: none;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 8rpx 16rpx rgba(244, 67, 54, 0.3);
}
</style>