ai-clone/frontend-ai/pages/phone-call/phone-call.vue
2026-03-06 18:05:51 +08:00

1587 lines
36 KiB
Vue
Raw Permalink 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="phone-call-container">
<view class="memorial-content">
<!-- 复活照片按钮 -->
<view class="revival-btn-section">
<button class="revival-btn" @click="goToRevival">
<text class="btn-icon">🎬</text>
<text class="btn-text">复活照片</text>
</button>
</view>
<!-- 选择视频阶段 -->
<view v-if="!callStarted" class="select-video-section">
<view class="section-card">
<view class="card-title">🎬 选择复活视频</view>
<view class="card-desc">选择一个已生成的复活视频开始视频通话</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-box">
<text class="loading-text">⏳ 加载视频列表...</text>
</view>
<!-- 视频列表 -->
<view v-else-if="videos.length > 0" class="video-list">
<view
v-for="video in videos"
:key="video.id"
:class="['video-card', selectedVideoId === video.id ? 'selected' : '']"
@click="selectVideo(video)"
>
<view class="video-card-icon">🎬</view>
<view class="video-card-content">
<text class="video-card-name">{{ video.name || '复活视频' }}</text>
<text class="video-card-desc">{{ video.text || '暂无描述' }}</text>
<text class="video-card-time">{{ formatTime(video.create_time) }}</text>
</view>
<view v-if="selectedVideoId === video.id" class="video-card-check">✓</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-box">
<text class="empty-icon">🎬</text>
<text class="empty-text">暂无复活视频</text>
<text class="empty-hint">请先点击上方"复活照片"生成视频</text>
</view>
<!-- 开始通话按钮 -->
<button
v-if="videos.length > 0"
class="start-call-btn"
:disabled="!selectedVideoId"
@click="startCall"
>
<text v-if="selectedVideoId">📞 开始视频通话</text>
<text v-else>请先选择视频</text>
</button>
<text v-if="videos.length > 0" class="ai-disclaimer">本服务为AI生成内容结果仅供参考</text>
</view>
</view>
<!-- 通话中阶段 -->
<view v-else class="call-active-section">
<!-- 视频通话卡片 -->
<view class="video-call-card">
<!-- 视频播放器 -->
<view class="video-container">
<video
v-if="selectedVideoUrl"
:src="selectedVideoUrl"
class="video-player"
:loop="true"
:autoplay="false"
:controls="false"
:show-center-play-btn="false"
object-fit="contain"
></video>
<!-- AI生成提示标签 -->
<view class="ai-tag">
<text class="ai-tag-text">AI生成</text>
</view>
<view v-if="!selectedVideoUrl" class="video-placeholder">
<text class="placeholder-icon">🎬</text>
<text class="placeholder-text">视频加载中...</text>
</view>
</view>
<text class="call-video-name">{{ selectedVideoName }}</text>
<text class="call-status" :class="{ 'status-active': isSpeaking || isRecording }">
{{ callStatus }}
</text>
<text class="call-duration">{{ callDuration }}</text>
</view>
<!-- 对话历史 -->
<scroll-view scroll-y class="chat-history" :scroll-top="scrollTop">
<view v-for="(msg, index) in messages" :key="index" :class="['message-item', msg.role]">
<view class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</view>
<view class="message-bubble">
<text class="message-text">{{ msg.content }}</text>
<text class="message-time">{{ msg.time }}</text>
</view>
</view>
<view v-if="messages.length === 0" class="empty-chat">
<text class="empty-chat-icon">💬</text>
<text class="empty-chat-text">按住下方按钮开始说话</text>
</view>
</scroll-view>
<!-- 控制按钮区 -->
<view class="controls">
<view v-if="selectedVoiceId && selectedVoiceId.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="selectedVoiceId && selectedVoiceId.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="record-control">
<button
v-if="!isRecording && !isProcessing"
class="record-btn idle"
@touchstart="startRecording"
@touchend="stopRecording"
>
<view class="record-btn-icon">🎤</view>
<text class="record-btn-text">按住说话</text>
</button>
<button
v-if="isRecording"
class="record-btn recording"
@touchend="stopRecording"
>
<view class="record-btn-icon pulse">🔴</view>
<text class="record-btn-text">松开发送</text>
</button>
<button
v-if="isProcessing"
class="record-btn processing"
disabled
>
<view class="record-btn-icon">⏳</view>
<text class="record-btn-text">{{ processingText }}</text>
</button>
</view>
<!-- 功能按钮 -->
<view class="action-buttons">
<button class="action-btn" @click="handleClearHistory">
<image src="/static/iconfont/delete.svg" class="delete-icon" mode="aspectFit"></image>
<text class="action-btn-text">清空</text>
</button>
<button class="action-btn end-call" @click="endCall">
<text class="action-btn-icon">📵</text>
<text class="action-btn-text">挂断</text>
</button>
</view>
</view>
</view>
</view>
<!-- 通话设置弹窗 -->
<view v-if="showSettingsDialog" class="settings-dialog-mask" @click="closeSettingsDialog">
<view class="settings-dialog" @click.stop>
<view class="dialog-header">
<text class="dialog-title">💭 通话设置</text>
<text class="dialog-close" @click="closeSettingsDialog">✕</text>
</view>
<view class="dialog-content">
<!-- 记忆设定 -->
<view class="dialog-section">
<view class="section-label">💭 记忆设定(可选)</view>
<text class="section-hint">设置对话记忆让AI更真实地模拟对方</text>
<view class="memory-item">
<text class="memory-label">身份</text>
<input
class="memory-input"
v-model="dialogMemoryIdentity"
placeholder="例如:我的母亲"
maxlength="50"
/>
</view>
<view class="memory-item">
<text class="memory-label">性格特点</text>
<textarea
class="memory-textarea"
v-model="dialogMemoryInfo"
placeholder="例如:温柔体贴,喜欢养花,做饭很拿手..."
maxlength="200"
/>
</view>
<view class="memory-item">
<text class="memory-label">口头禅</text>
<input
class="memory-input"
v-model="dialogMemoryCatchphrase"
placeholder="例如:哎呀、你说是不是"
maxlength="100"
/>
</view>
</view>
</view>
<view class="dialog-footer">
<button class="dialog-btn cancel" @click="closeSettingsDialog">取消</button>
<button class="dialog-btn confirm" @click="confirmSettings">开始通话</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { API_BASE } from '@/config/api.js';
export default {
data() {
return {
API_BASE: API_BASE,
// 用户ID缓存用于检测用户切换
lastUserId: null,
// 视频列表
loading: false,
videos: [],
selectedVideoId: '',
selectedVideoName: '',
selectedVideoUrl: '',
selectedVoiceId: '', // 视频关联的音色ID
selectedDialect: '',
selectedLanguageHint: '',
selectedLanguageHintLabel: '',
languageHintOptions: ['中文(zh)', '英文(en)', '法语(fr)', '德语(de)', '日语(ja)', '韩语(ko)', '俄语(ru)'],
dialectOptions: ['广东话', '东北话', '甘肃话', '贵州话', '河南话', '湖北话', '江西话', '闽南话', '宁夏话', '山西话', '陕西话', '山东话', '上海话', '四川话', '天津话', '云南话'],
// 通话状态
callStarted: false,
callStatus: '视频通话中',
callDuration: '00:00',
callStartTime: null,
durationTimer: null,
// 对话
messages: [],
scrollTop: 0,
// 录音
isRecording: false,
isProcessing: false,
isSpeaking: false, // AI是否正在说话
processingText: '处理中...',
recorderManager: null,
audioFilePath: '',
// 音频播放
audioContext: null,
// 设置弹窗
showSettingsDialog: false,
dialogMemoryIdentity: '',
dialogMemoryInfo: '',
dialogMemoryCatchphrase: ''
};
},
onLoad() {
console.log('[PhoneCall] 页面加载');
const userId = uni.getStorageSync('userId');
this.lastUserId = userId; // 记录当前用户ID
this.loadVideos();
this.initRecorder();
this.initAudioContext();
},
onShow() {
console.log('[PhoneCall] 页面显示');
// 检查用户是否切换
const currentUserId = uni.getStorageSync('userId');
this.lastUserId = currentUserId;
// 只要不在通话中,每次显示都刷新(切回 tab 时也能自动更新)
if (!this.callStarted) {
this.$nextTick(() => {
setTimeout(() => {
this.loadVideos();
}, 100);
});
}
},
onUnload() {
this.cleanup();
},
methods: {
// 加载视频列表
async loadVideos() {
this.loading = true;
// 获取用户ID和Token
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
uni.request({
url: `${this.API_BASE}/api/photo-revival/videos`,
method: 'GET',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
console.log('[PhoneCall] 视频列表响应状态码:', res.statusCode);
console.log('[PhoneCall] 视频列表响应数据:', res.data);
console.log('[PhoneCall] 数据类型:', typeof res.data);
if (res.statusCode === 200) {
// 处理不同的数据格式
let videoList = [];
if (Array.isArray(res.data)) {
videoList = res.data;
} else if (res.data && Array.isArray(res.data.data)) {
videoList = res.data.data;
} else if (res.data && res.data.videos && Array.isArray(res.data.videos)) {
videoList = res.data.videos;
}
// 过滤掉生成失败的视频
// 失败视频的特征name以"生成失败"开头或没有有效的视频URL
videoList = videoList.filter(video => {
// 检查name是否以"生成失败"开头(包括"生成失败:"
const isFailedByName = video.name && (
video.name.startsWith('生成失败') ||
video.name.startsWith('生成失败:')
);
// 检查是否有有效的视频URL不能为空字符串
const videoUrl = video.edited_video_url || video.video_url || video.local_video_path || video.videoUrl || video.localVideoPath;
const hasValidVideoUrl = videoUrl && videoUrl.trim() !== '';
// 只保留不是失败且有有效视频URL的视频
return !isFailedByName && hasValidVideoUrl;
});
this.videos = videoList;
console.log('[PhoneCall] 加载视频列表成功:', this.videos.length);
console.log('[PhoneCall] 视频列表:', this.videos);
} else {
console.error('[PhoneCall] 响应状态码异常:', res.statusCode);
}
},
fail: (err) => {
console.error('[PhoneCall] 加载视频列表失败:', err);
uni.showToast({
title: '加载失败',
icon: 'none'
});
},
complete: () => {
this.loading = false;
}
});
},
// 选择视频
selectVideo(video) {
console.log('[PhoneCall] 选择视频:', video);
this.selectedVideoId = video.id;
this.selectedVideoName = video.name || '复活视频';
this.selectedVideoUrl = video.edited_video_url || video.local_video_path || video.video_url;
this.selectedVoiceId = video.voice_id; // 视频关联的音色ID
this.selectedDialect = '';
this.selectedLanguageHint = '';
this.selectedLanguageHintLabel = '';
console.log('[PhoneCall] 选择视频:', this.selectedVideoId, '名称:', this.selectedVideoName);
console.log('[PhoneCall] 视频URL:', this.selectedVideoUrl);
},
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] : '';
},
// 开始通话
startCall() {
if (!this.selectedVideoId) {
uni.showToast({
title: '请先选择视频',
icon: 'none'
});
return;
}
console.log('[PhoneCall] 准备视频通话视频ID:', this.selectedVideoId);
// 获取选中的视频信息
const selectedVideo = this.videos.find(v => v.id === this.selectedVideoId);
if (!selectedVideo) {
uni.showToast({
title: '视频信息丢失',
icon: 'none'
});
return;
}
// 获取视频URL兼容多种字段名
const videoUrl = selectedVideo.edited_video_url || selectedVideo.videoUrl || selectedVideo.local_video_path || selectedVideo.video_url || selectedVideo.localVideoPath || '';
const videoName = selectedVideo.name || '复活视频';
const finalVoiceId = this.selectedVoiceId || '';
console.log('[PhoneCall] 跳转参数:', {
videoId: this.selectedVideoId,
videoName: videoName,
videoUrl: videoUrl,
voiceId: finalVoiceId
});
// 直接跳转到视频通话页面,传递参数
uni.navigateTo({
url: `/pages/video-call-new/video-call-new?videoId=${this.selectedVideoId}&videoName=${encodeURIComponent(videoName)}&videoUrl=${encodeURIComponent(videoUrl)}&voiceId=${finalVoiceId}`,
success: () => {
console.log('[PhoneCall] 跳转到视频通话页面成功');
},
fail: (err) => {
console.error('[PhoneCall] 跳转失败:', err);
uni.showToast({
title: '跳转失败',
icon: 'none'
});
}
});
},
// 关闭设置弹窗
closeSettingsDialog() {
this.showSettingsDialog = false;
},
// 确认设置并开始通话(已废弃,保留以防其他地方调用)
confirmSettings() {
this.showSettingsDialog = false;
},
// 开始计时
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);
},
// 初始化录音
initRecorder() {
this.recorderManager = uni.getRecorderManager();
this.recorderManager.onStart(() => {
console.log('[PhoneCall] 开始录音');
});
this.recorderManager.onStop((res) => {
console.log('[PhoneCall] 录音完成:', res.tempFilePath);
this.audioFilePath = res.tempFilePath;
this.processConversation();
});
this.recorderManager.onError((err) => {
console.error('[PhoneCall] 录音失败:', err);
this.isRecording = false;
uni.showToast({
title: '录音失败',
icon: 'none'
});
});
},
// 初始化音频播放
initAudioContext() {
this.audioContext = uni.createInnerAudioContext();
this.audioContext.onPlay(() => {
console.log('[PhoneCall] 开始播放AI回复');
this.isSpeaking = true;
this.callStatus = '对方正在说话...';
// 震动反馈
uni.vibrateShort({
type: 'light'
});
});
this.audioContext.onEnded(() => {
console.log('[PhoneCall] AI回复播放完成');
this.isSpeaking = false;
this.callStatus = '通话中';
});
this.audioContext.onError((err) => {
console.error('[PhoneCall] 音频播放失败:', err);
this.isSpeaking = false;
this.callStatus = '通话中';
});
},
// 开始录音
startRecording() {
if (this.isProcessing) return;
this.isRecording = true;
this.recorderManager.start({
format: 'mp3',
sampleRate: 16000
});
this.callStatus = '正在录音...';
// 震动反馈
uni.vibrateShort({
type: 'medium'
});
},
// 停止录音
stopRecording() {
if (!this.isRecording) return;
this.isRecording = false;
this.recorderManager.stop();
this.isProcessing = true;
this.processingText = '识别中...';
this.callStatus = '处理中...';
},
// 处理对话
async processConversation() {
try {
this.processingText = '正在识别...';
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.selectedVoiceId;
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.selectedDialect) {
data.dialect = this.selectedDialect;
}
if (this.selectedLanguageHint) {
data.languageHints = this.selectedLanguageHint;
}
return data;
})(),
success: (res) => {
console.log('[PhoneCall] 对话响应:', res);
if (res.statusCode === 200) {
const result = JSON.parse(res.data);
if (result.success) {
// 添加用户消息
this.addMessage('user', result.recognizedText);
// 添加AI回复
this.addMessage('ai', result.aiResponse);
// 播放AI回复
this.playAIResponse(result.audioFile);
} else {
throw new Error(result.message || '对话失败');
}
} else {
throw new Error('对话请求失败');
}
},
fail: (err) => {
console.error('[PhoneCall] 对话失败:', err);
uni.showToast({
title: '对话失败',
icon: 'none'
});
this.isProcessing = false;
this.callStatus = '通话中';
}
});
} catch (error) {
console.error('[PhoneCall] 对话失败:', error);
uni.showToast({
title: '对话失败: ' + error.message,
icon: 'none'
});
this.isProcessing = false;
this.callStatus = '通话中';
}
},
// 播放AI回复
playAIResponse(audioFile) {
this.processingText = '正在回复...';
// 播放音频
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;
});
},
// 清空历史
handleClearHistory() {
uni.showModal({
title: '确认清空',
content: '确定要清空对话历史吗?',
success: (res) => {
if (res.confirm) {
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
uni.request({
url: `${this.API_BASE}/api/conversation/clear-history`,
method: 'POST',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
if (res.data.success) {
this.messages = [];
uni.showToast({
title: '已清空',
icon: 'success'
});
}
}
});
}
}
});
},
// 挂断通话
endCall() {
uni.showModal({
title: '结束通话',
content: `通话时长:${this.callDuration}\n确定要结束通话吗`,
confirmColor: '#eb3349',
success: (res) => {
if (res.confirm) {
// 震动反馈
uni.vibrateShort({
type: 'heavy'
});
uni.showLoading({
title: '正在挂断...',
mask: true
});
setTimeout(() => {
uni.hideLoading();
this.cleanup();
uni.showToast({
title: '通话已结束',
icon: 'success',
duration: 1500,
success: () => {
setTimeout(() => {
uni.navigateBack();
}, 1500);
}
});
}, 800);
}
}
});
},
// 跳转到复活照片
goToRevival() {
console.log('[PhoneCall] 跳转到复活照片页面');
uni.navigateTo({
url: '/pages/revival/revival-original',
success: () => {
console.log('[PhoneCall] 跳转成功');
},
fail: (err) => {
console.error('[PhoneCall] 跳转失败:', err);
}
});
},
// 清理资源
cleanup() {
if (this.durationTimer) {
clearInterval(this.durationTimer);
}
if (this.audioContext) {
this.audioContext.destroy();
}
if (this.recorderManager) {
this.recorderManager.stop();
}
},
// 格式化时间
formatTime(timestamp) {
if (!timestamp) return '';
// 判断时间戳是秒级还是毫秒级
// 秒级时间戳通常是10位数字毫秒级是13位
let timestampMs = timestamp;
if (String(timestamp).length <= 10) {
// 秒级时间戳,需要转换为毫秒
timestampMs = timestamp * 1000;
}
const date = new Date(timestampMs);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return '时间格式错误';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}${month}${day}${hours}:${minutes}`;
}
}
};
</script>
<style lang="scss" scoped>
.phone-call-container {
min-height: 100vh;
background: linear-gradient(180deg, #F8F5F0 0%, #FDF8F2 100%);
background-image:
radial-gradient(circle at 10% 20%, rgba(212, 185, 150, 0.08) 0%, transparent 25%),
radial-gradient(circle at 90% 80%, rgba(109, 139, 139, 0.08) 0%, transparent 25%);
position: relative;
}
.memorial-content {
position: relative;
z-index: 2;
padding: 40upx 24upx 30upx;
max-width: 1000upx;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
/* 复活照片按钮区域 */
.revival-btn-section {
margin: 0 0 32upx;
padding: 0;
}
.revival-btn {
width: 100%;
padding: 32upx 40upx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
border: none;
border-radius: 32upx;
font-size: 32upx;
font-weight: 600;
box-shadow: 0 8upx 24upx rgba(139, 115, 85, 0.25);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
gap: 16upx;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, transparent 100%);
}
&:active {
transform: translateY(-2upx);
box-shadow: 0 12upx 32upx rgba(139, 115, 85, 0.35);
}
.btn-icon {
font-size: 40upx;
line-height: 1;
}
.btn-text {
font-size: 32upx;
font-weight: 600;
letter-spacing: 0.5upx;
}
}
/* 选择音色阶段 */
.select-voice-section {
flex: 1;
padding: 40rpx;
display: flex;
flex-direction: column;
}
.select-video-section {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
opacity: 1;
transition: opacity 0.2s ease;
}
.section-card {
background: rgba(255, 255, 255, 0.98);
border-radius: 28upx;
padding: 36upx 28upx;
box-shadow: 0 4upx 20upx rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10upx);
border: 1upx solid rgba(212, 185, 150, 0.2);
flex: 1;
display: flex;
flex-direction: column;
}
.card-title {
font-size: 34upx;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 12upx;
line-height: 1.4;
letter-spacing: 0.3upx;
}
.card-desc {
font-size: 26upx;
color: #666;
margin-bottom: 28upx;
line-height: 1.6;
opacity: 0.9;
}
/* 加载状态 */
.loading-box {
padding: 100upx 40upx;
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.3s ease;
}
.loading-text {
font-size: 28upx;
color: #999;
opacity: 0.8;
}
/* 视频列表 */
.video-list {
display: flex;
flex-direction: column;
gap: 16upx;
margin-bottom: 28upx;
flex: 1;
overflow-y: auto;
opacity: 1;
transition: opacity 0.3s ease;
}
.video-card {
background: rgba(255, 255, 255, 0.95);
border: 1.5upx solid rgba(212, 185, 150, 0.25);
border-radius: 20upx;
padding: 28upx 24upx;
display: flex;
align-items: center;
transition: all 0.25s ease;
box-shadow: 0 2upx 8upx rgba(0, 0, 0, 0.06);
position: relative;
&:active {
transform: translateY(-1upx);
box-shadow: 0 4upx 12upx rgba(0, 0, 0, 0.1);
}
}
.video-card.selected {
background: linear-gradient(135deg, rgba(139, 115, 85, 0.1) 0%, rgba(109, 139, 139, 0.08) 100%);
border-color: #8B7355;
box-shadow: 0 4upx 16upx rgba(139, 115, 85, 0.18);
}
.video-card-icon {
font-size: 44upx;
margin-right: 20upx;
flex-shrink: 0;
line-height: 1;
}
.video-card-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.video-card-name {
font-size: 30upx;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 8upx;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-card-desc {
font-size: 24upx;
color: #666;
margin-bottom: 6upx;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.9;
}
.video-card-time {
font-size: 22upx;
color: #999;
opacity: 0.85;
}
.video-card-check {
font-size: 36upx;
color: #8B7355;
font-weight: bold;
margin-left: 12upx;
flex-shrink: 0;
line-height: 1;
}
/* 空状态 */
.empty-box {
padding: 100upx 40upx;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
}
.empty-icon {
font-size: 100upx;
margin-bottom: 24upx;
opacity: 0.6;
}
.empty-text {
font-size: 28upx;
color: #666;
margin-bottom: 16upx;
font-weight: 500;
}
.empty-hint {
font-size: 24upx;
color: #999;
opacity: 0.8;
line-height: 1.5;
}
/* 开始通话按钮 */
.start-call-btn {
width: 100%;
padding: 32upx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: #ffffff !important;
border: none;
border-radius: 28upx;
font-size: 30upx;
font-weight: 600;
box-shadow: 0 6upx 20upx rgba(139, 115, 85, 0.25);
transition: all 0.3s ease;
margin-top: auto;
letter-spacing: 0.5upx;
&:active {
transform: translateY(-1upx);
box-shadow: 0 8upx 24upx rgba(139, 115, 85, 0.3);
}
}
.start-call-btn[disabled] {
background: #e5e5e5;
box-shadow: 0 2upx 8upx rgba(0, 0, 0, 0.08);
opacity: 0.65;
transform: none !important;
color: #ffffff !important;
}
.ai-disclaimer {
display: block;
text-align: center;
font-size: 22upx;
color: rgba(100, 100, 100, 0.6);
margin-top: 16upx;
letter-spacing: 0.5upx;
}
/* 通话中阶段 */
.call-active-section {
opacity: 1;
transition: opacity 0.2s ease;
flex: 1;
display: flex;
flex-direction: column;
padding: 24upx;
overflow: hidden;
}
/* 视频通话卡片 */
.video-call-card {
background: rgba(255, 255, 255, 0.98);
border-radius: 28upx;
padding: 32upx 28upx;
text-align: center;
margin-bottom: 20upx;
box-shadow: 0 4upx 20upx rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10upx);
border: 1upx solid rgba(212, 185, 150, 0.2);
}
.video-container {
width: 100%;
height: 480upx;
background: #000;
border-radius: 20upx;
overflow: hidden;
margin-bottom: 20upx;
position: relative;
box-shadow: 0 4upx 16upx rgba(0, 0, 0, 0.15);
}
.video-player {
width: 100%;
height: 100%;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
}
/* AI生成提示标签 */
.ai-tag {
position: absolute;
top: 20rpx;
right: 20rpx;
z-index: 100;
background: rgba(0, 0, 0, 0.6);
padding: 8rpx 20rpx;
border-radius: 30rpx;
backdrop-filter: blur(10rpx);
}
.ai-tag-text {
font-size: 22rpx;
color: #fff;
font-weight: 500;
}
.placeholder-icon {
font-size: 100rpx;
margin-bottom: 20rpx;
}
.placeholder-text {
font-size: 28rpx;
color: #fff;
opacity: 0.8;
}
.call-video-name {
display: block;
font-size: 30upx;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 12upx;
line-height: 1.4;
letter-spacing: 0.3upx;
}
.call-status {
display: block;
font-size: 26upx;
color: #666;
margin-bottom: 10upx;
transition: all 0.3s ease;
opacity: 0.9;
}
.call-status.status-active {
color: #11998e;
font-weight: 600;
opacity: 1;
animation: statusBlink 1.5s ease-in-out infinite;
}
.call-duration {
display: block;
font-size: 24upx;
color: #999;
opacity: 0.85;
}
/* 对话历史 */
.chat-history {
flex: 1;
background: rgba(255, 255, 255, 0.98);
border-radius: 28upx;
padding: 28upx 24upx;
margin-bottom: 20upx;
box-shadow: 0 4upx 20upx rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10upx);
border: 1upx solid rgba(212, 185, 150, 0.2);
overflow: hidden;
}
.message-item {
display: flex;
margin-bottom: 24upx;
animation: fadeIn 0.3s ease;
}
.message-item.user {
flex-direction: row-reverse;
}
.message-avatar {
font-size: 44upx;
margin: 0 16upx;
flex-shrink: 0;
line-height: 1;
}
.message-bubble {
max-width: 72%;
padding: 20upx 24upx;
border-radius: 20upx;
display: flex;
flex-direction: column;
box-shadow: 0 2upx 8upx rgba(0, 0, 0, 0.06);
}
.message-item.user .message-bubble {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.message-item.ai .message-bubble {
background: rgba(248, 248, 248, 0.95);
color: #2c2c2c;
border: 1upx solid rgba(0, 0, 0, 0.04);
}
.message-text {
font-size: 28upx;
line-height: 1.6;
margin-bottom: 8upx;
word-break: break-word;
}
.message-time {
font-size: 20upx;
opacity: 0.75;
text-align: right;
}
.empty-chat {
padding: 100rpx 40rpx;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.empty-chat-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
opacity: 0.5;
}
.empty-chat-text {
font-size: 26rpx;
color: #999;
}
/* 控制按钮区 */
.controls {
background: rgba(255, 255, 255, 0.98);
border-radius: 28upx;
padding: 28upx 24upx;
box-shadow: 0 -2upx 16upx rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10upx);
border: 1upx solid rgba(212, 185, 150, 0.2);
}
.record-control {
margin-bottom: 20upx;
}
.record-btn {
width: 100%;
padding: 36upx;
border: none;
border-radius: 28upx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-weight: 600;
transition: all 0.3s ease;
}
.record-btn.idle {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
box-shadow: 0 6upx 16upx rgba(17, 153, 142, 0.25);
}
.record-btn.recording {
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
color: white;
box-shadow: 0 6upx 16upx rgba(235, 51, 73, 0.25);
animation: recordingPulse 1s ease-in-out infinite;
}
.record-btn.processing {
background: #e5e5e5;
color: #666;
}
.record-btn-icon {
font-size: 56upx;
margin-bottom: 10upx;
line-height: 1;
}
.record-btn-icon.pulse {
animation: pulse 1s infinite;
}
.record-btn-text {
font-size: 26upx;
letter-spacing: 0.5upx;
}
.action-buttons {
display: flex;
gap: 16upx;
}
.action-btn {
flex: 1;
padding: 24upx 20upx;
background: rgba(248, 248, 248, 0.95);
border: none;
border-radius: 20upx;
display: flex;
flex-direction: column;
align-items: center;
color: #666;
transition: all 0.25s ease;
box-shadow: 0 2upx 6upx rgba(0, 0, 0, 0.04);
&:active {
transform: scale(0.97);
}
}
.action-btn.end-call {
background: linear-gradient(135deg, #d9534f 0%, #c9302c 100%);
color: white;
box-shadow: 0 4upx 16upx rgba(217, 83, 79, 0.25);
}
.action-btn-icon {
font-size: 32upx;
margin-bottom: 8upx;
line-height: 1;
}
.action-btn-text {
font-size: 24upx;
font-weight: 500;
letter-spacing: 0.3upx;
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes avatarPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes waveExpand {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes statusBlink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes recordingPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 8rpx 16rpx rgba(235, 51, 73, 0.3);
}
50% {
transform: scale(1.02);
box-shadow: 0 12rpx 24rpx rgba(235, 51, 73, 0.5),
0 0 0 8rpx rgba(235, 51, 73, 0.2);
}
}
/* 设置弹窗样式 */
.settings-dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.settings-dialog {
width: 90%;
max-width: 600rpx;
background: white;
border-radius: 24rpx;
overflow: hidden;
}
.dialog-header {
padding: 30rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-title {
font-size: 32rpx;
font-weight: bold;
color: white;
}
.dialog-close {
font-size: 40rpx;
color: white;
opacity: 0.8;
padding: 0 10rpx;
}
.dialog-content {
max-height: 60vh;
overflow-y: auto;
padding: 30rpx;
}
.dialog-section {
margin-bottom: 30rpx;
}
.section-label {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.section-hint {
font-size: 24rpx;
color: #999;
margin-bottom: 20rpx;
display: block;
}
.voice-selector {
background: #f5f5f5;
padding: 24rpx;
border-radius: 12rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.voice-selected {
font-size: 28rpx;
color: #333;
}
.voice-arrow {
font-size: 32rpx;
color: #999;
}
.memory-item {
margin-bottom: 20rpx;
}
.memory-label {
font-size: 26rpx;
color: #666;
margin-bottom: 10rpx;
display: block;
}
.memory-input {
width: 100%;
padding: 28rpx 20rpx;
min-height: 88rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
border: none;
}
.memory-textarea {
width: 100%;
min-height: 120rpx;
padding: 20rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
border: none;
}
.dialog-footer {
padding: 20rpx 30rpx 30rpx;
display: flex;
gap: 20rpx;
}
.dialog-btn {
flex: 1;
padding: 24rpx;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: bold;
border: none;
}
.dialog-btn.cancel {
background: #f5f5f5;
color: #666;
}
.dialog-btn.confirm {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
</style>