1587 lines
36 KiB
Vue
1587 lines
36 KiB
Vue
<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>
|