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

1587 lines
36 KiB
Vue
Raw Normal View History

2026-03-05 14:29:21 +08:00
<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>
2026-03-05 14:29:21 +08:00
</view>
</view>
<!-- 通话中阶段 -->
<view v-else class="call-active-section">
<!-- 视频通话卡片 -->
<view class="video-call-card">
<!-- 视频播放器 -->
<view class="video-container">
<video
2026-03-05 14:29:21 +08:00
v-if="selectedVideoUrl"
:src="selectedVideoUrl"
2026-03-05 14:29:21 +08:00
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">
2026-03-05 14:29:21 +08:00
<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;
2026-03-05 14:29:21 +08:00
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;
2026-03-05 14:29:21 +08:00
}
/* 通话中阶段 */
.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;
}
2026-03-05 14:29:21 +08:00
.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>