2026-02-01 14:19:11 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<view class="voice-call-container">
|
|
|
|
|
|
<!-- 状态栏占位 -->
|
|
|
|
|
|
<view :style="{ height: statusBarHeight + 'px' }"></view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 返回按钮 -->
|
|
|
|
|
|
<view class="back-btn" @click="endCall">
|
|
|
|
|
|
<image src="/static/images/back.png" mode="aspectFit"></image>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 恋人形象 -->
|
|
|
|
|
|
<view class="lover-avatar-container">
|
|
|
|
|
|
<image
|
|
|
|
|
|
class="lover-avatar"
|
|
|
|
|
|
:class="{ 'speaking': isSpeaking, 'listening': isListening }"
|
|
|
|
|
|
:src="loverAvatar"
|
|
|
|
|
|
mode="aspectFill">
|
|
|
|
|
|
</image>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 状态指示器 -->
|
|
|
|
|
|
<view class="status-indicator">
|
|
|
|
|
|
<view class="status-dot" :class="callStatus"></view>
|
|
|
|
|
|
<text class="status-text">{{ statusText }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 音频波形动画 -->
|
|
|
|
|
|
<view class="audio-wave" v-if="isSpeaking">
|
|
|
|
|
|
<view class="wave-bar" v-for="i in 5" :key="i"></view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 对话文本显示 -->
|
|
|
|
|
|
<view class="conversation-text">
|
|
|
|
|
|
<view class="text-bubble user" v-if="userText">
|
|
|
|
|
|
<text>{{ userText }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<view class="text-bubble ai" v-if="aiText">
|
|
|
|
|
|
<text>{{ aiText }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 控制按钮 -->
|
|
|
|
|
|
<view class="control-buttons">
|
|
|
|
|
|
<view class="control-btn" @touchstart="startTalk" @touchend="stopTalk" @touchcancel="stopTalk">
|
|
|
|
|
|
<image src="/static/images/mic.png" mode="aspectFit"></image>
|
|
|
|
|
|
<text>{{ micEnabled ? '松开结束' : '按住说话' }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view class="control-btn end-call" @click="endCall">
|
|
|
|
|
|
<image src="/static/images/phone_end.png" mode="aspectFit"></image>
|
|
|
|
|
|
<text>挂断</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
import { LoverBasic } from '@/utils/api.js';
|
2026-02-01 17:46:31 +08:00
|
|
|
|
import { baseURLPy } from '@/utils/request.js';
|
2026-02-01 14:19:11 +08:00
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
|
data() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
statusBarHeight: uni.getWindowInfo().statusBarHeight,
|
|
|
|
|
|
websocket: null,
|
|
|
|
|
|
loverAvatar: '',
|
|
|
|
|
|
callStatus: 'connecting', // connecting, connected, speaking, listening
|
|
|
|
|
|
isSpeaking: false,
|
|
|
|
|
|
isListening: false,
|
|
|
|
|
|
micEnabled: false,
|
|
|
|
|
|
userText: '',
|
|
|
|
|
|
aiText: '',
|
|
|
|
|
|
recorderManager: null,
|
|
|
|
|
|
audioContext: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
computed: {
|
|
|
|
|
|
statusText() {
|
|
|
|
|
|
const statusMap = {
|
|
|
|
|
|
connecting: '连接中...',
|
|
|
|
|
|
connected: '已连接',
|
|
|
|
|
|
speaking: 'AI正在说话',
|
|
|
|
|
|
listening: '正在聆听',
|
|
|
|
|
|
};
|
|
|
|
|
|
return statusMap[this.callStatus] || '未知状态';
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
onLoad() {
|
|
|
|
|
|
this.initCall();
|
|
|
|
|
|
},
|
|
|
|
|
|
onUnload() {
|
|
|
|
|
|
this.cleanup();
|
|
|
|
|
|
},
|
|
|
|
|
|
methods: {
|
|
|
|
|
|
async initCall() {
|
|
|
|
|
|
// 获取恋人信息
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await LoverBasic();
|
|
|
|
|
|
if (res.code === 1 && res.data) {
|
|
|
|
|
|
this.loverAvatar = res.data.avatar_url || '/static/images/default_avatar.png';
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('获取恋人信息失败', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化录音管理器
|
|
|
|
|
|
// #ifndef H5
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.recorderManager = uni.getRecorderManager();
|
2026-03-02 18:57:11 +08:00
|
|
|
|
|
|
|
|
|
|
// 录音停止时处理
|
2026-02-01 14:19:11 +08:00
|
|
|
|
this.recorderManager.onStop((res) => {
|
2026-03-02 18:57:11 +08:00
|
|
|
|
console.log('📝 录音结束', res);
|
|
|
|
|
|
|
|
|
|
|
|
if (!res.tempFilePath) {
|
|
|
|
|
|
console.error('❌ 没有录音文件路径');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 读取录音文件并发送
|
|
|
|
|
|
const fs = uni.getFileSystemManager();
|
|
|
|
|
|
fs.readFile({
|
|
|
|
|
|
filePath: res.tempFilePath,
|
|
|
|
|
|
// 不指定 encoding,返回 ArrayBuffer
|
|
|
|
|
|
success: (fileRes) => {
|
|
|
|
|
|
console.log('✅ 文件读取成功');
|
|
|
|
|
|
console.log('📊 数据类型:', typeof fileRes.data);
|
|
|
|
|
|
console.log('📊 是否为 ArrayBuffer:', fileRes.data instanceof ArrayBuffer);
|
|
|
|
|
|
|
|
|
|
|
|
// 验证数据类型
|
|
|
|
|
|
if (!(fileRes.data instanceof ArrayBuffer)) {
|
|
|
|
|
|
console.error('❌ 数据不是 ArrayBuffer');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const actualSize = fileRes.data.byteLength;
|
|
|
|
|
|
console.log('📊 文件大小:', actualSize, 'bytes');
|
|
|
|
|
|
console.log('📊 预计录音时长:', (actualSize / 32000).toFixed(2), '秒');
|
|
|
|
|
|
|
|
|
|
|
|
// 验证文件大小(至少1秒)
|
|
|
|
|
|
if (actualSize < 32000) {
|
|
|
|
|
|
console.error('❌ 文件太小(< 1秒),可能录音失败');
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: '录音太短,请重试',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 分片发送音频数据
|
|
|
|
|
|
this.sendAudioInChunks(fileRes.data);
|
|
|
|
|
|
},
|
|
|
|
|
|
fail: (err) => {
|
|
|
|
|
|
console.error('❌ 文件读取失败:', err);
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: '文件读取失败',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-02-01 14:19:11 +08:00
|
|
|
|
});
|
2026-03-02 18:57:11 +08:00
|
|
|
|
|
|
|
|
|
|
// 录音错误处理
|
|
|
|
|
|
this.recorderManager.onError((err) => {
|
|
|
|
|
|
console.error('❌ 录音错误:', err);
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: '录音失败',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-01 14:19:11 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('录音管理器初始化失败', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
// #endif
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化音频播放器
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.audioContext = uni.createInnerAudioContext();
|
|
|
|
|
|
this.audioContext.onPlay(() => {
|
|
|
|
|
|
this.isSpeaking = true;
|
|
|
|
|
|
this.callStatus = 'speaking';
|
|
|
|
|
|
});
|
|
|
|
|
|
this.audioContext.onEnded(() => {
|
|
|
|
|
|
this.isSpeaking = false;
|
|
|
|
|
|
this.callStatus = 'connected';
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('音频播放器初始化失败', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 连接WebSocket
|
|
|
|
|
|
this.connectWebSocket();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
connectWebSocket() {
|
|
|
|
|
|
const token = uni.getStorageSync('token');
|
2026-02-01 17:46:31 +08:00
|
|
|
|
// 将 http:// 替换为 ws://,https:// 替换为 wss://
|
|
|
|
|
|
const wsBaseUrl = baseURLPy.replace('http://', 'ws://').replace('https://', 'wss://');
|
|
|
|
|
|
const wsUrl = `${wsBaseUrl}/voice/call?token=${token}&ptt=true`;
|
|
|
|
|
|
|
|
|
|
|
|
console.log('WebSocket URL:', wsUrl);
|
2026-02-01 14:19:11 +08:00
|
|
|
|
|
|
|
|
|
|
this.websocket = uni.connectSocket({
|
|
|
|
|
|
url: wsUrl,
|
|
|
|
|
|
success: () => {
|
|
|
|
|
|
console.log('WebSocket连接成功');
|
|
|
|
|
|
},
|
|
|
|
|
|
fail: (err) => {
|
|
|
|
|
|
console.error('WebSocket连接失败', err);
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: '连接失败',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.websocket.onOpen(() => {
|
|
|
|
|
|
console.log('WebSocket已打开');
|
|
|
|
|
|
this.callStatus = 'connected';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.websocket.onMessage((res) => {
|
|
|
|
|
|
console.log('收到消息', res);
|
|
|
|
|
|
if (typeof res.data === 'string') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = JSON.parse(res.data);
|
|
|
|
|
|
this.handleSignal(data);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('解析消息失败', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 二进制音频数据
|
|
|
|
|
|
this.playAudio(res.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.websocket.onError((err) => {
|
|
|
|
|
|
console.error('WebSocket错误', err);
|
|
|
|
|
|
this.callStatus = 'error';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.websocket.onClose(() => {
|
|
|
|
|
|
console.log('WebSocket已关闭');
|
|
|
|
|
|
this.callStatus = 'disconnected';
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
handleSignal(data) {
|
|
|
|
|
|
switch (data.type) {
|
|
|
|
|
|
case 'ready':
|
|
|
|
|
|
console.log('通话准备就绪');
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'partial_asr':
|
|
|
|
|
|
this.userText = data.text;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'reply_text':
|
|
|
|
|
|
this.aiText = data.text;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'reply_end':
|
|
|
|
|
|
this.isSpeaking = false;
|
|
|
|
|
|
this.callStatus = 'connected';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'interrupt':
|
|
|
|
|
|
console.log('AI被打断');
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'error':
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: data.msg || '发生错误',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
});
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
startTalk() {
|
|
|
|
|
|
this.micEnabled = true;
|
|
|
|
|
|
this.isListening = true;
|
|
|
|
|
|
this.callStatus = 'listening';
|
|
|
|
|
|
this.userText = '';
|
|
|
|
|
|
|
|
|
|
|
|
// 发送PTT开始信号
|
|
|
|
|
|
if (this.websocket) {
|
|
|
|
|
|
this.websocket.send({
|
|
|
|
|
|
data: 'ptt_on'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// #ifndef H5
|
|
|
|
|
|
if (this.recorderManager) {
|
|
|
|
|
|
this.recorderManager.start({
|
|
|
|
|
|
format: 'pcm',
|
|
|
|
|
|
sampleRate: 16000,
|
|
|
|
|
|
numberOfChannels: 1,
|
|
|
|
|
|
encodeBitRate: 48000,
|
|
|
|
|
|
frameSize: 5
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
// #endif
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
stopTalk() {
|
|
|
|
|
|
this.micEnabled = false;
|
|
|
|
|
|
this.isListening = false;
|
|
|
|
|
|
this.callStatus = 'connected';
|
|
|
|
|
|
|
2026-03-02 18:57:11 +08:00
|
|
|
|
// 停止录音(会触发 onStop 回调,在那里发送音频和 ptt_off)
|
2026-02-01 14:19:11 +08:00
|
|
|
|
// #ifndef H5
|
|
|
|
|
|
if (this.recorderManager) {
|
|
|
|
|
|
this.recorderManager.stop();
|
|
|
|
|
|
}
|
|
|
|
|
|
// #endif
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
playAudio(audioData) {
|
|
|
|
|
|
// 播放接收到的音频数据
|
|
|
|
|
|
// 注意:实际实现需要处理音频格式转换
|
|
|
|
|
|
console.log('播放音频', audioData);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-02 18:57:11 +08:00
|
|
|
|
// 分片发送音频数据(按照官方推荐参数)
|
|
|
|
|
|
async sendAudioInChunks(audioData) {
|
|
|
|
|
|
// 官方推荐:每包3200字节(约100ms音频),延迟100ms
|
|
|
|
|
|
// PCM 16kHz 单声道:16000 * 2 * 0.1 = 3200 bytes/100ms
|
|
|
|
|
|
const chunkSize = 3200; // 3.2KB per chunk(官方推荐)
|
|
|
|
|
|
const chunkDelay = 100; // 100ms(官方推荐)
|
|
|
|
|
|
|
|
|
|
|
|
// 确保 audioData 是 ArrayBuffer
|
|
|
|
|
|
if (!(audioData instanceof ArrayBuffer)) {
|
|
|
|
|
|
console.error('❌ audioData 不是 ArrayBuffer,类型:', typeof audioData);
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: '音频数据格式错误',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const totalSize = audioData.byteLength;
|
|
|
|
|
|
let offset = 0;
|
|
|
|
|
|
let chunkCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
console.log('📦 开始分片发送(官方推荐参数)');
|
|
|
|
|
|
console.log('📊 总大小:', totalSize, 'bytes');
|
|
|
|
|
|
console.log('📊 每片大小:', chunkSize, 'bytes');
|
|
|
|
|
|
console.log('📊 发送间隔:', chunkDelay, 'ms');
|
|
|
|
|
|
console.log('📊 预计录音时长:', (totalSize / 32000).toFixed(2), '秒');
|
|
|
|
|
|
|
|
|
|
|
|
// 显示加载提示
|
|
|
|
|
|
uni.showLoading({
|
|
|
|
|
|
title: '发送中...',
|
|
|
|
|
|
mask: true
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 Promise 确保顺序发送
|
|
|
|
|
|
const sendChunk = (chunk, index) => {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
console.log(`📤 发送第 ${index} 片,大小: ${chunk.byteLength} bytes`);
|
|
|
|
|
|
|
|
|
|
|
|
this.websocket.send({
|
|
|
|
|
|
data: chunk,
|
|
|
|
|
|
success: () => {
|
|
|
|
|
|
console.log(`✅ 第 ${index} 片发送成功`);
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
},
|
|
|
|
|
|
fail: (err) => {
|
|
|
|
|
|
console.error(`❌ 第 ${index} 片发送失败:`, err);
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 顺序发送所有片段
|
|
|
|
|
|
while (offset < totalSize) {
|
|
|
|
|
|
const end = Math.min(offset + chunkSize, totalSize);
|
|
|
|
|
|
const chunk = audioData.slice(offset, end);
|
|
|
|
|
|
chunkCount++;
|
|
|
|
|
|
|
|
|
|
|
|
await sendChunk(chunk, chunkCount);
|
|
|
|
|
|
|
|
|
|
|
|
offset = end;
|
|
|
|
|
|
|
|
|
|
|
|
// 延迟,模拟实时流
|
|
|
|
|
|
if (offset < totalSize) {
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, chunkDelay));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('✅ 所有音频片段发送完成,共', chunkCount, '片');
|
|
|
|
|
|
|
|
|
|
|
|
// 发送 ptt_off 信号,告诉服务器录音结束
|
|
|
|
|
|
this.websocket.send({
|
|
|
|
|
|
data: 'ptt_off',
|
|
|
|
|
|
success: () => {
|
|
|
|
|
|
console.log('✅ ptt_off 信号发送成功');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
uni.hideLoading();
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('❌ 发送音频失败:', err);
|
|
|
|
|
|
uni.hideLoading();
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: '发送失败',
|
|
|
|
|
|
icon: 'none'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-01 14:19:11 +08:00
|
|
|
|
endCall() {
|
|
|
|
|
|
this.cleanup();
|
|
|
|
|
|
uni.navigateBack();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
cleanup() {
|
|
|
|
|
|
if (this.websocket) {
|
|
|
|
|
|
this.websocket.close();
|
|
|
|
|
|
this.websocket = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.recorderManager) {
|
|
|
|
|
|
this.recorderManager.stop();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.audioContext) {
|
|
|
|
|
|
this.audioContext.stop();
|
|
|
|
|
|
this.audioContext.destroy();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.voice-call-container {
|
|
|
|
|
|
width: 100vw;
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.back-btn {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 60rpx;
|
|
|
|
|
|
left: 30rpx;
|
|
|
|
|
|
width: 60rpx;
|
|
|
|
|
|
height: 60rpx;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.back-btn image {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
filter: brightness(0) invert(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lover-avatar-container {
|
|
|
|
|
|
margin-top: 150rpx;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lover-avatar {
|
|
|
|
|
|
width: 400rpx;
|
|
|
|
|
|
height: 400rpx;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: 8rpx solid rgba(255, 255, 255, 0.3);
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lover-avatar.speaking {
|
|
|
|
|
|
transform: scale(1.05);
|
|
|
|
|
|
border-color: rgba(255, 255, 255, 0.8);
|
|
|
|
|
|
box-shadow: 0 0 40rpx rgba(255, 255, 255, 0.6);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lover-avatar.listening {
|
|
|
|
|
|
border-color: rgba(102, 126, 234, 0.8);
|
|
|
|
|
|
box-shadow: 0 0 40rpx rgba(102, 126, 234, 0.6);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-indicator {
|
|
|
|
|
|
margin-top: 40rpx;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 15rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-dot {
|
|
|
|
|
|
width: 20rpx;
|
|
|
|
|
|
height: 20rpx;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-dot.connecting {
|
|
|
|
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-dot.connected {
|
|
|
|
|
|
background: #4ade80;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-dot.speaking {
|
|
|
|
|
|
background: #f59e0b;
|
|
|
|
|
|
animation: pulse 0.8s ease-in-out infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-dot.listening {
|
|
|
|
|
|
background: #3b82f6;
|
|
|
|
|
|
animation: pulse 1s ease-in-out infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-text {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.audio-wave {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10rpx;
|
|
|
|
|
|
margin-top: 30rpx;
|
|
|
|
|
|
height: 80rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wave-bar {
|
|
|
|
|
|
width: 8rpx;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.8);
|
|
|
|
|
|
border-radius: 4rpx;
|
|
|
|
|
|
animation: wave 1s ease-in-out infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wave-bar:nth-child(1) { animation-delay: 0s; }
|
|
|
|
|
|
.wave-bar:nth-child(2) { animation-delay: 0.1s; }
|
|
|
|
|
|
.wave-bar:nth-child(3) { animation-delay: 0.2s; }
|
|
|
|
|
|
.wave-bar:nth-child(4) { animation-delay: 0.3s; }
|
|
|
|
|
|
.wave-bar:nth-child(5) { animation-delay: 0.4s; }
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes wave {
|
|
|
|
|
|
0%, 100% { height: 20rpx; }
|
|
|
|
|
|
50% { height: 80rpx; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
|
|
|
|
50% { opacity: 0.5; transform: scale(1.2); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversation-text {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
width: 90%;
|
|
|
|
|
|
margin-top: 60rpx;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 20rpx;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.text-bubble {
|
|
|
|
|
|
padding: 20rpx 30rpx;
|
|
|
|
|
|
border-radius: 20rpx;
|
|
|
|
|
|
max-width: 80%;
|
|
|
|
|
|
word-wrap: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.text-bubble.user {
|
|
|
|
|
|
align-self: flex-end;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.9);
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.text-bubble.ai {
|
|
|
|
|
|
align-self: flex-start;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.2);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
backdrop-filter: blur(10rpx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-buttons {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 40rpx;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-around;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 40rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-btn {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 15rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-btn image {
|
|
|
|
|
|
width: 120rpx;
|
|
|
|
|
|
height: 120rpx;
|
|
|
|
|
|
padding: 30rpx;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.2);
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
backdrop-filter: blur(10rpx);
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-btn:active image {
|
|
|
|
|
|
transform: scale(0.95);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-btn.end-call image {
|
|
|
|
|
|
background: rgba(239, 68, 68, 0.8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-btn text {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 24rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|