ai-clone/frontend-ai/pages/video-call/video-call.vue
2026-03-05 14:29:21 +08:00

1014 lines
26 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="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>