ai-clone/frontend-ai/pages/revival/revival-original.vue

2460 lines
65 KiB
Vue
Raw Normal View History

2026-03-05 14:29:21 +08:00
<template>
<view class="revival-container">
<!-- 顶部标题与历史入口 -->
<view class="hero">
<view class="hero-text">
<text class="hero-title">照片复活</text>
<text class="hero-subtitle">让记忆中的人再次开口说话</text>
</view>
<button class="history-btn" @click="goToHistory">
📜 历史
</button>
</view>
<!-- 帮助弹窗 -->
<view v-if="showHelpModal" class="help-modal" @click="showHelpModal = false">
<view class="help-content" @click.stop>
<view class="help-header">
<text class="help-title">💡 如何获取音色</text>
<text class="close-btn" @click="showHelpModal = false"></text>
</view>
<scroll-view scroll-y class="help-body">
<view class="help-step">
<text class="help-step-title">1 点击底部导航"声音克隆"</text>
<image class="help-image" :src="`${API_BASE}/static/1.jpg`" mode="widthFix"></image>
</view>
<view class="help-step">
<text class="help-step-title">2 点击"上传音频"按钮</text>
<image class="help-image" :src="`${API_BASE}/static/2.jpg`" mode="widthFix"></image>
</view>
<view class="help-step">
<text class="help-step-title">3 录制或上传10-20秒清晰人声</text>
<text class="help-step-desc">建议录制清晰的普通话朗读避免背景噪音</text>
</view>
<view class="help-step">
<text class="help-step-title">4 创建成功后即可在此选择使用</text>
<text class="help-step-desc">💡 提示音色质量越好合成效果越自然</text>
</view>
</scroll-view>
<view class="help-footer">
<button class="help-btn secondary" @click="showHelpModal = false">知道了</button>
<button class="help-btn primary" @click="goToUpload">去创建</button>
</view>
</view>
</view>
<scroll-view scroll-y class="content">
<view class="tips-card">
<view class="tips-title">合规提示</view>
<view class="tips-item">照片中避免未成年人明显真人正脸/疑似公众人物裸露低俗血腥暴力涉政涉恐等内容</view>
<view class="tips-item">避免平台截图/水印/二维码/账号昵称/大量文字图片中的文字也会被审核</view>
<view class="tips-item">建议使用清晰无水印无UI背景干净的照片台词避免敏感词</view>
</view>
<!-- 步骤1: 上传照片 -->
<view class="step-card">
<view class="step-header">
<view class="step-number">1</view>
<text class="step-title">上传照片</text>
</view>
<view class="upload-btn" @click="choosePhoto">
<image
v-show="photoPreview"
:src="photoPreview"
class="preview-img"
mode="aspectFill"
style="width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 10;"
></image>
<view v-show="!photoPreview" class="upload-placeholder">
<text class="icon">📷</text>
<text>点击选择照片</text>
</view>
</view>
</view>
<!-- 步骤2: 输入标题 -->
<view class="step-card">
<view class="step-header">
<view class="step-number">2</view>
<text class="step-title">输入标题</text>
</view>
<input class="input-field" v-model="videoTitle" placeholder="给这个视频起个名字..." maxlength="20" />
<view class="char-count">{{ videoTitle.length }} / 20</view>
<view class="input-hint">💡 标题将显示在历史记录和AI通话页面</view>
</view>
<!-- 步骤3: 选择模型 -->
<view class="step-card">
<view class="step-header">
<view class="step-number">3</view>
<text class="step-title">选择模型</text>
</view>
<picker mode="selector" :range="modelOptions" range-key="label" @change="onModelChange" class="picker-large">
<view class="picker">
{{ selectedModelName || '请选择模型' }}
</view>
</picker>
<view v-if="getCurrentModelMaintenanceMsg()" class="maintenance-warning">
{{ getCurrentModelMaintenanceMsg() }}
</view>
<view v-else class="input-hint">💡 {{ modelHint }}</view>
</view>
<!-- 步骤4: 选择音色 -->
<view class="step-card">
<view class="step-header">
<view class="step-number">4</view>
<text class="step-title">选择音色</text>
<text class="help-icon" @click="showVoiceHelp"></text>
</view>
<!-- 使用已有音色 -->
<view class="voice-section">
<picker mode="selector" :range="voiceTypeOptions" range-key="label" @change="onVoiceTypeChange" class="picker-large">
<view class="picker">
{{ selectedVoiceTypeLabel }}
</view>
</picker>
<picker mode="selector" :range="displayVoices" range-key="voice_name" @change="onVoiceChange" class="picker-large">
<view class="picker">
{{ selectedVoiceName || '请选择音色' }}
</view>
</picker>
<view class="input-hint">💡 请先在"声音克隆"页面创建音色</view>
</view>
<view v-if="selectedSupportsDialect && (selectedVoiceType || 'CLONE') !== 'OFFICIAL'" class="voice-section">
<view class="input-hint">方言</view>
<picker mode="selector" :range="dialectOptions" @change="onDialectChange" class="picker-large">
<view class="picker">
{{ selectedDialect || '请选择方言(可选)' }}
</view>
</picker>
</view>
<view v-if="selectedSupportsLanguageHints && (selectedVoiceType || 'CLONE') !== 'OFFICIAL'" class="voice-section">
<view class="input-hint">语言提示可选</view>
<picker mode="selector" :range="languageHintOptions" @change="onLanguageHintChange" class="picker-large">
<view class="picker">
{{ selectedLanguageHintLabel || '请选择语言(可选)' }}
</view>
</picker>
</view>
</view>
<!-- 步骤5: 输入文本 -->
<view class="step-card">
<view class="step-header">
<view class="step-number">5</view>
<text class="step-title">输入台词</text>
</view>
<textarea
class="textarea"
v-model="text"
placeholder="输入想让照片中的人说的话..."
maxlength="500"
/>
<view class="char-count">{{ text.length }} / 500</view>
</view>
<!-- 生成按钮 -->
<button class="generate-btn" :disabled="loading || !canGenerate" @click="handleGenerate">
<text v-if="loading">{{ loadingText }}</text>
<text v-else>🎬 开始生成视频</text>
</button>
<text class="ai-disclaimer">本服务为AI生成内容结果仅供参考</text>
2026-03-05 14:29:21 +08:00
<!-- 进度显示 -->
<view v-if="progress.length > 0" class="progress-section">
<view class="progress-title">生成进度</view>
<view v-for="(item, index) in progress" :key="index" class="progress-item">
<text :class="['progress-icon', item.status]">
{{ item.status === 'done' ? '✅' : item.status === 'error' ? '❌' : '⏳' }}
</text>
<text class="progress-text">{{ item.text }}</text>
</view>
</view>
<!-- 结果展示 -->
2026-03-05 14:29:21 +08:00
<view v-if="videoUrl" class="result-section">
<view class="result-title">🎉 生成成功</view>
<!-- 视频播放器静音 -->
<view class="video-container">
<video
id="resultVideo"
:src="videoUrl"
class="result-video"
:show-center-play-btn="false"
:controls="false"
:muted="true"
:autoplay="false"
:loop="false"
:enable-play-gesture="true"
:object-fit="'contain'"
:show-loading="true"
:enable-progress-gesture="false"
:poster="photoPreview"
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
@error="onVideoError"
@loadedmetadata="onVideoLoaded"
@waiting="onVideoWaiting"
@timeupdate="onVideoTimeUpdate"
></video>
<!-- AI生成提示标签 -->
<view class="ai-tag">
<text class="ai-tag-text">AI生成</text>
</view>
</view>
2026-03-05 14:29:21 +08:00
<!-- 音频播放器隐藏 -->
<audio
id="resultAudio"
:src="audioUrl"
style="display: none;"
></audio>
<!-- 自定义播放控制 -->
<view class="play-controls">
<button class="play-btn" @click="viewVideo">
播放
</button>
</view>
<button class="download-btn" @click="downloadVideo">
💾 保存到相册
</button>
</view>
</scroll-view>
<!-- 支付弹窗 -->
<PaymentModal
ref="paymentModal"
:show="paymentModalData.show"
:serviceType="paymentModalData.serviceType"
:serviceName="paymentModalData.serviceName"
:serviceDesc="paymentModalData.serviceDesc"
:price="paymentModalData.price"
:orderNo="paymentModalData.orderNo"
:paymentTips="paymentModalData.paymentTips"
@close="handlePaymentClose"
@confirm="handlePaymentConfirm"
/>
</view>
</template>
<script>
import { API_BASE, API_ENDPOINTS, buildURL } from '@/config/api.js';
import PaymentModal from '@/components/PaymentModal.vue';
import { SERVICE_TYPES, showPaymentModal, handlePaymentConfirm as processPayment, mapOfficialVoiceDisplay } from '@/utils/payment.js';
import PermissionManager from '@/utils/permission.js';
export default {
components: {
PaymentModal
},
data() {
return {
API_BASE,
// 照片
photoFile: null,
photoPreview: '',
// 模型选择
modelOptions: [
{ value: 'veo', label: 'Veo', hint: '需要音色,生成说话人视频', disabled: false, maintenanceMsg: '' },
{ value: 'volcengine', label: '火山引擎', hint: '需要音色,生成说话人视频', disabled: false, maintenanceMsg: '' }
],
selectedModel: 'veo',
selectedModelName: 'Veo',
modelHint: '需要音色,生成说话人视频',
// 音色
voices: [],
voiceTypeOptions: [
{ value: 'CLONE', label: '克隆音色' },
{ value: 'OFFICIAL', label: '官方音色Flash' }
],
selectedVoiceType: 'CLONE',
selectedVoiceTypeLabel: '克隆音色',
selectedVoiceId: '',
selectedVoiceName: '',
selectedDialect: '',
selectedLanguageHint: '',
selectedLanguageHintLabel: '',
selectedSupportsDialect: false,
selectedSupportsLanguageHints: false,
languageHintOptions: ['中文(zh)', '英文(en)', '法语(fr)', '德语(de)', '日语(ja)', '韩语(ko)', '俄语(ru)'],
dialectOptions: ['广东话', '东北话', '甘肃话', '贵州话', '河南话', '湖北话', '江西话', '闽南话', '宁夏话', '山西话', '陕西话', '山东话', '上海话', '四川话', '天津话', '云南话'],
// 标题
videoTitle: '',
// 文本
text: '',
// 帮助弹窗
showHelpModal: false,
// 状态
loading: false,
loadingText: '',
progress: [],
// 结果
videoUrl: '',
audioUrl: '',
localVideoPath: '', // 本地视频路径
localAudioPath: '', // 本地音频路径
isPlaying: false,
videoRetried: false, // 视频重试标志
// 支付相关
paymentModalData: {
show: false,
serviceType: '',
serviceName: '',
serviceDesc: '',
price: 0,
orderNo: '',
paymentTips: '点击确认支付后将开始生成视频'
},
_paymentResolve: null,
_paymentReject: null,
_paymentOnSuccess: null,
_paymentOnFailed: null
};
},
computed: {
displayVoices() {
const list = (this.voices || []).filter(v => (v && (v.voice_type || 'CLONE') === this.selectedVoiceType));
if ((this.selectedVoiceType || 'CLONE') !== 'OFFICIAL') return list;
const regionPrefixMap = {
'湾区大叔': '台湾',
'台湾小何': '台湾',
'双节棍小哥': '台湾',
'广州德哥': '广州',
'浩宇小哥': '大陆'
};
return list.map(v => {
const rawName = v && v.voice_name ? String(v.voice_name) : '';
const prefix = rawName && regionPrefixMap[rawName] ? regionPrefixMap[rawName] : '';
if (!prefix) return v;
return { ...v, voice_name: `${prefix}-${rawName}` };
});
},
canGenerate() {
const currentModel = this.modelOptions.find(m => m.value === this.selectedModel);
if (currentModel && currentModel.disabled) {
return false;
}
return this.photoFile && this.videoTitle && this.text && this.selectedVoiceId;
}
},
onLoad() {
this.loadVoices();
this.loadModelStatus();
},
methods: {
onVoiceTypeChange(e) {
const option = this.voiceTypeOptions[e.detail.value];
this.selectedVoiceType = option ? option.value : 'CLONE';
this.selectedVoiceTypeLabel = option ? option.label : '克隆音色';
this.selectedVoiceId = '';
this.selectedVoiceName = '';
this.selectedDialect = '';
this.selectedLanguageHint = '';
this.selectedLanguageHintLabel = '';
this.selectedSupportsDialect = false;
this.selectedSupportsLanguageHints = false;
},
// 显示音色获取帮助
showVoiceHelp() {
this.showHelpModal = true;
},
// 跳转到上传音频页面
goToUpload() {
this.showHelpModal = false;
uni.navigateTo({
url: '/pages/upload-audio/upload-audio'
});
},
// 获取当前模型的维护信息
getCurrentModelMaintenanceMsg() {
const currentModel = this.modelOptions.find(m => m.value === this.selectedModel);
return currentModel && currentModel.disabled ? currentModel.maintenanceMsg : '';
},
// 选择模型
onModelChange(e) {
const index = e.detail.value;
const model = this.modelOptions[index];
this.selectedModel = model.value;
this.selectedModelName = model.label;
this.modelHint = model.hint;
console.log('[Revival] 选择模型:', this.selectedModel);
},
// 选择照片
choosePhoto() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
this.photoFile = {
path: res.tempFilePaths[0],
name: 'photo.jpg'
};
this.photoPreview = res.tempFilePaths[0];
},
fail: (err) => {
uni.showToast({
title: '选择照片失败',
icon: 'none'
});
}
});
},
// 选择音频
chooseAudio() {
// #ifdef H5
// H5环境使用文件选择器
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*,.mp3,.wav,.m4a';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
// 创建临时URL
const tempPath = URL.createObjectURL(file);
this.audioFile = {
path: tempPath,
name: file.name,
file: file // 保存原始文件对象
};
console.log('[Revival] 已选择音频:', file.name);
uni.showToast({
title: '已选择: ' + file.name,
icon: 'none'
});
}
};
input.click();
// #endif
// #ifdef APP-PLUS
// APP环境使用原生文件选择
uni.showActionSheet({
itemList: ['选择音频文件', '录音'],
success: (res) => {
if (res.tapIndex === 0) {
// 选择音频文件
this.chooseAudioFileFromSystem();
} else if (res.tapIndex === 1) {
// 使用录音功能
this.startRecordAudio();
}
}
});
// #endif
},
// 从系统选择音频文件APP专用
chooseAudioFileFromSystem() {
// #ifdef APP-PLUS
// Android使用Intent打开文件选择器
if (plus.os.name === 'Android') {
const Intent = plus.android.importClass('android.content.Intent');
const main = plus.android.runtimeMainActivity();
const intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType('audio/*');
intent.addCategory(Intent.CATEGORY_OPENABLE);
main.startActivityForResult(intent, 1001);
// 监听返回结果
main.onActivityResult = (requestCode, resultCode, data) => {
if (requestCode === 1001 && resultCode === -1) {
const uri = data.getData();
const uriString = uri.toString();
console.log('[Revival] URI:', uriString);
// 如果是content://格式的URI需要特殊处理
if (uriString.startsWith('content://')) {
this.copyContentUriToLocalForAudio(uri, uriString);
} else {
// 使用plus.io转换URI到真实路径
plus.io.resolveLocalFileSystemURL(uriString, (entry) => {
const path = entry.fullPath;
console.log('[Revival] 真实路径:', path);
// 检查文件扩展名
const fileName = entry.name;
const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
if (['.mp3', '.wav', '.m4a', '.aac'].indexOf(ext) === -1) {
uni.showToast({
title: '请选择音频文件',
icon: 'none'
});
return;
}
this.audioFile = {
path: entry.fullPath, // 使用绝对路径
name: fileName
};
console.log('[Revival] 已选择音频:', this.audioFile);
uni.showToast({
title: '已选择: ' + fileName,
icon: 'success',
duration: 2000
});
}, (error) => {
console.error('[Revival] 路径转换失败:', error);
uni.showToast({
title: '文件路径获取失败',
icon: 'none'
});
});
}
}
};
} else {
// iOS暂不支持提示使用录音
uni.showModal({
title: '提示',
content: 'iOS暂不支持选择文件请使用录音功能',
showCancel: false,
success: () => {
this.startRecordAudio();
}
});
}
// #endif
},
// 复制content URI到本地临时目录Android专用
copyContentUriToLocalForAudio(uri, uriString) {
// #ifdef APP-PLUS
try {
console.log('[Revival] 开始处理content URI...');
const main = plus.android.runtimeMainActivity();
const contentResolver = main.getContentResolver();
// 获取文件名
let fileName = 'audio_' + Date.now() + '.mp3';
// 尝试从ContentResolver获取文件名
try {
const cursor = plus.android.invoke(contentResolver, 'query', uri, null, null, null, null);
if (cursor) {
const moveToFirst = plus.android.invoke(cursor, 'moveToFirst');
if (moveToFirst) {
const nameIndex = plus.android.invoke(cursor, 'getColumnIndex', '_display_name');
if (nameIndex >= 0) {
fileName = plus.android.invoke(cursor, 'getString', nameIndex);
}
}
plus.android.invoke(cursor, 'close');
}
} catch (e) {
console.log('[Revival] 无法获取文件名,使用默认名称:', e.message);
}
// 确保文件名有扩展名
if (fileName.indexOf('.') === -1) {
fileName += '.mp3';
}
console.log('[Revival] 文件名:', fileName);
// 检查文件扩展名
const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
if (['.mp3', '.wav', '.m4a', '.aac'].indexOf(ext) === -1) {
uni.showToast({
title: '请选择音频文件',
icon: 'none'
});
return;
}
// 创建临时文件路径
const tempDir = plus.io.convertLocalFileSystemURL('_doc/');
const tempFilePath = tempDir + fileName;
console.log('[Revival] 临时文件路径:', tempFilePath);
// 导入必要的Java类
const FileOutputStream = plus.android.importClass('java.io.FileOutputStream');
const File = plus.android.importClass('java.io.File');
// 打开输入流
const inputStream = plus.android.invoke(contentResolver, 'openInputStream', uri);
if (!inputStream) {
throw new Error('无法打开输入流');
}
// 创建输出流
const outputFile = new File(tempFilePath);
const outputStream = new FileOutputStream(outputFile);
// 复制文件
const buffer = plus.android.newObject('byte[]', 4096);
let length;
while ((length = plus.android.invoke(inputStream, 'read', buffer)) > 0) {
plus.android.invoke(outputStream, 'write', buffer, 0, length);
}
// 关闭流
plus.android.invoke(outputStream, 'flush');
plus.android.invoke(outputStream, 'close');
plus.android.invoke(inputStream, 'close');
console.log('[Revival] 文件复制成功');
// 直接使用绝对路径不使用file://协议
this.audioFile = {
path: tempFilePath,
name: fileName
};
console.log('[Revival] 文件选择成功:', this.audioFile);
uni.showToast({
title: '已选择: ' + fileName,
icon: 'success',
duration: 2000
});
} catch (e) {
console.error('[Revival] 处理content URI异常:', e);
uni.showToast({
title: '文件处理失败: ' + e.message,
icon: 'none'
});
}
// #endif
},
// 录音功能APP专用
async startRecordAudio() {
// #ifdef APP-PLUS
// 检查麦克风权限
const hasPermission = await PermissionManager.ensurePermission('record', {
permissionName: '麦克风',
reason: '创建音色录音功能',
showGuide: true
});
if (!hasPermission) {
console.log('[Revival] 麦克风权限未授权');
return;
}
const recorderManager = uni.getRecorderManager();
recorderManager.onStart(() => {
console.log('[Revival] 开始录音');
uni.showToast({
title: '正在录音...',
icon: 'none',
duration: 10000
});
});
recorderManager.onStop((res) => {
console.log('[Revival] 录音完成:', res.tempFilePath);
this.audioFile = {
path: res.tempFilePath,
name: 'record_' + Date.now() + '.mp3'
};
uni.showToast({
title: '录音完成',
icon: 'success'
});
});
recorderManager.onError((err) => {
console.error('[Revival] 录音失败:', err);
// 判断是否为权限问题
const isPermissionError = err.errMsg && (
err.errMsg.includes('permission') ||
err.errMsg.includes('authorize') ||
err.errMsg.includes('denied') ||
err.errMsg.includes('占用')
);
if (isPermissionError) {
// 权限问题,引导用户去设置
uni.showModal({
title: '需要麦克风权限',
content: '录音功能需要麦克风权限才能使用。\n\n请前往\n手机设置 → 应用管理 → 时光意境 → 权限管理 → 开启麦克风权限',
confirmText: '我知道了',
showCancel: false
});
} else {
uni.showToast({
title: '录音失败: ' + (err.errMsg || '未知错误'),
icon: 'none',
duration: 3000
});
}
});
// 显示录音对话框
uni.showModal({
title: '录音',
content: '点击确定开始录音录音时长10-20秒',
success: (modalRes) => {
if (modalRes.confirm) {
recorderManager.start({
format: 'mp3',
sampleRate: 16000
});
// 20秒后自动停止
setTimeout(() => {
recorderManager.stop();
}, 20000);
}
}
});
// #endif
},
// 加载模型状态
loadModelStatus() {
console.log('[Revival] 开始加载模型状态...');
uni.request({
url: `${this.API_BASE}/api/system/model-status`,
method: 'GET',
success: (res) => {
console.log('[Revival] 模型状态响应:', res);
if (res.statusCode === 200 && res.data && res.data.success) {
const statusData = res.data.data;
// 更新Veo模型状态
if (statusData.veo_disabled === 'true') {
const veoModel = this.modelOptions.find(m => m.value === 'veo');
if (veoModel) {
veoModel.disabled = true;
veoModel.maintenanceMsg = statusData.veo_message || '官方接口参数更新,正在处理中,暂不可用';
}
}
// 更新火山引擎模型状态
if (statusData.volcengine_disabled === 'true') {
const volcengineModel = this.modelOptions.find(m => m.value === 'volcengine');
if (volcengineModel) {
volcengineModel.disabled = true;
volcengineModel.maintenanceMsg = statusData.volcengine_message || '官方接口参数更新,正在处理中,暂不可用';
}
}
console.log('[Revival] 模型状态已更新:', this.modelOptions);
}
},
fail: (error) => {
console.error('[Revival] 加载模型状态失败:', error);
}
});
},
// 加载音色列表
loadVoices() {
console.log('[Revival] 开始加载音色列表...');
console.log('[Revival] API地址:', `${this.API_BASE}/api/voice/list`);
// 获取用户ID和Token
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
console.log('[Revival] 用户ID:', userId);
uni.request({
url: `${this.API_BASE}/api/voice/list`,
method: 'GET',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
console.log('[Revival] API响应状态码:', res.statusCode);
console.log('[Revival] API响应数据:', res.data);
if (res.statusCode === 200) {
if (res.data && res.data.voices) {
const hiddenVoices = ['Cherry', 'Kai', 'Mochi', 'Bunny'];
this.voices = (res.data.voices || [])
.filter(v => !(v && v.voice && hiddenVoices.includes(v.voice)));
console.log('[Revival] 音色列表:', this.voices);
console.log('[Revival] 音色数量:', this.voices.length);
if (this.voices.length > 0) {
this.selectedVoiceId = this.voices[0].voice;
this.selectedVoiceName = this.voices[0].voice_name || this.voices[0].voice;
this.selectedDialect = '';
console.log('[Revival] 默认选中音色:', this.selectedVoiceId);
} else {
console.log('[Revival] 音色列表为空');
uni.showToast({
title: '暂无可用音色,请先创建',
icon: 'none'
});
}
} else {
console.error('[Revival] 响应数据格式错误:', res.data);
this.voices = [];
}
} else {
console.error('[Revival] 请求失败,状态码:', res.statusCode);
}
},
fail: (error) => {
console.error('[Revival] 加载音色列表失败:', error);
uni.showToast({
title: '加载音色列表失败',
icon: 'none'
});
}
});
},
// 选择音色
onVoiceChange(e) {
const voice = this.displayVoices[e.detail.value];
this.selectedVoiceId = voice.voice;
this.selectedVoiceName = voice.voice_name || voice.voice;
this.selectedVoiceType = voice.voice_type || this.selectedVoiceType || 'CLONE';
this.selectedVoiceTypeLabel = this.selectedVoiceType === 'OFFICIAL' ? '官方音色Flash' : '克隆音色';
this.selectedDialect = '';
this.selectedLanguageHint = '';
this.selectedLanguageHintLabel = '';
this.selectedSupportsDialect = !!voice.supportsDialect;
this.selectedSupportsLanguageHints = !!voice.supportsLanguageHints;
if ((this.selectedVoiceType || 'CLONE') === 'OFFICIAL') {
this.selectedDialect = '';
this.selectedLanguageHint = '';
this.selectedLanguageHintLabel = '';
this.selectedSupportsDialect = false;
this.selectedSupportsLanguageHints = false;
}
},
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] : '';
},
// 生成视频
async handleGenerate() {
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
if (!userId) {
uni.showModal({
title: '提示',
content: '请先登录后再继续',
confirmText: '去登录',
cancelText: '取消',
success: (m) => {
if (m.confirm) {
uni.navigateTo({ url: '/pages/login/login' });
}
}
});
return;
}
// 按模型区分 serviceTypeVeo -> PHOTO_REVIVAL火山 -> VOLCENGINE_VIDEO
const backendServiceType = (this.selectedModel === 'volcengine') ? 'VOLCENGINE_VIDEO' : 'PHOTO_REVIVAL';
const frontendServiceType = (this.selectedModel === 'volcengine') ? SERVICE_TYPES.VOLCENGINE_VIDEO.type : SERVICE_TYPES.PHOTO_REVIVAL.type;
// 强制预检:必须成功拿到剩余次数,否则不允许继续
let preview = null;
try {
preview = await new Promise((resolve, reject) => {
uni.request({
url: `${API_BASE}/api/pay/usage-preview?serviceType=${backendServiceType}`,
method: 'GET',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.success) {
resolve(res.data);
return;
}
reject(new Error((res.data && res.data.message) ? res.data.message : ('预检失败: HTTP ' + res.statusCode)));
},
fail: (err) => reject(new Error((err && err.errMsg) ? err.errMsg : '预检失败'))
});
});
} catch (e) {
uni.showModal({
title: '检查次数失败',
content: (e && e.message) ? e.message : '检查剩余次数失败,请稍后重试',
showCancel: false,
confirmText: '我知道了'
});
return;
}
const remaining = (preview && typeof preview.remainingTotalCount === 'number') ? preview.remainingTotalCount : 0;
if (remaining <= 0) {
showPaymentModal(
this,
frontendServiceType,
() => {
this.executeGenerate();
},
(error) => {
console.error('[Payment] 支付失败:', error);
}
);
return;
}
this.executeGenerate();
},
async executeGenerate() {
this.loading = true;
this.progress = [];
this.videoUrl = '';
this.audioUrl = ''; // 清空之前的音频URL
try {
// 根据选择的模型调用不同的生成方法
if (this.selectedModel === 'volcengine') {
await this.generateWithVolcengine();
} else {
await this.generateWithExistingVoice();
}
} catch (error) {
const isTimeout = error.message.includes('响应超时') || error.message.includes('历史记录');
if (isTimeout) {
// 超时错误,提示用户查看历史记录
this.addProgress('⚠️ ' + error.message, 'warning');
uni.showModal({
title: '提示',
content: error.message,
showCancel: true,
cancelText: '知道了',
confirmText: '查看历史',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: '/pages/revival/revival-history'
});
}
}
});
} else {
// 其他错误
this.addProgress('生成失败: ' + error.message, 'error');
uni.showModal({
title: '生成失败',
content: (error.message || '生成失败') + '\n\n你可以点击“重试”再次生成。\n若已支付但多次生成失败请联系客服补发提供订单号/支付时间截图)。',
confirmText: '重试',
cancelText: '我知道了',
success: (m) => {
if (m.confirm) {
this.executeGenerate();
}
}
});
}
} finally {
this.loading = false;
}
},
// 使用火山引擎生成视频
async generateWithVolcengine() {
return new Promise((resolve, reject) => {
this.addProgress('📤 正在上传照片...', 'loading');
// 显示提示
uni.showModal({
title: '🎬 视频生成中',
content: '火山引擎视频生成通常需要1-3分钟。\n\n您可以最小化APP做其他事情生成完成后会弹出提示。\n\n请保持网络连接稳定。',
showCancel: false,
confirmText: '我知道了'
});
// 延迟添加后续进度提示
setTimeout(() => {
this.addProgress('🎬 正在生成视频通常1-3分钟...', 'loading');
}, 2000);
// 获取用户token
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
console.log('[Revival-Volcengine] 准备上传照片:', this.photoFile);
console.log('[Revival-Volcengine] 用户ID:', userId);
console.log('[Revival-Volcengine] API地址:', `${this.API_BASE}/api/photo-revival/volcengine-video`);
uni.uploadFile({
url: `${this.API_BASE}/api/photo-revival/volcengine-video`,
filePath: this.photoFile.path,
name: 'photo',
timeout: 3600000, // 60分钟超时
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
formData: (() => {
const data = {
voiceId: this.selectedVoiceId,
voiceType: this.selectedVoiceType,
text: this.text,
duration: 8, // 默认8秒
name: this.videoTitle
};
if (this.selectedSupportsDialect && this.selectedDialect) {
data.dialect = this.selectedDialect;
}
if (this.selectedSupportsLanguageHints && this.selectedLanguageHint) {
data.languageHints = this.selectedLanguageHint;
}
return data;
})(),
success: (res) => {
console.log('[Revival-Volcengine] 上传响应:', res);
let parsed = null;
try {
parsed = JSON.parse(res.data);
} catch (e) {
parsed = null;
}
if (res.statusCode === 402) {
console.warn('[Revival-Volcengine] HTTP 402次数不足需要付费');
this.addProgress('⚠️ 次数不足,请先付费', 'warning');
uni.showModal({
title: '需要付费',
content: (parsed && parsed.message) ? parsed.message : '次数不足,请先完成支付后再生成',
confirmText: '去支付',
cancelText: '取消',
success: (m) => {
if (m.confirm) {
showPaymentModal(
this,
SERVICE_TYPES.PHOTO_REVIVAL.type,
() => {
uni.showToast({ title: '支付成功,请重新点击生成', icon: 'none' });
},
() => {}
);
}
}
});
reject(new Error((parsed && parsed.message) ? parsed.message : '次数不足,请先付费'));
return;
}
if (res.statusCode === 504) {
console.warn('[Revival-Volcengine] HTTP 504请求超时但后端可能仍在生成中');
setTimeout(() => {
uni.showModal({
title: '⏰ 响应超时',
content: '网络响应超时HTTP 504但视频可能仍在后台生成中。\n\n是否立即查看历史记录',
confirmText: '查看历史',
cancelText: '稍后查看',
success: (modalRes) => {
if (modalRes.confirm) {
uni.navigateTo({
url: '/pages/revival/revival-history'
});
}
}
});
}, 800);
this.addProgress('⏳ 网络响应超时HTTP 504视频可能仍在后台生成中请稍后到历史记录查看', 'loading');
resolve();
return;
}
if (res.statusCode === 200) {
if (parsed && parsed.status === 'success') {
this.addProgress('✅ 视频生成完成', 'done');
this.videoRetried = false;
uni.showToast({
title: '生成成功!',
icon: 'success'
});
resolve();
return;
}
const errorMsg = (parsed && parsed.message) ? parsed.message : '生成失败';
console.error('[Revival-Volcengine] 后端返回错误:', errorMsg);
reject(new Error(errorMsg));
return;
}
const errorMsg = (parsed && parsed.message)
? parsed.message
: ('上传失败: HTTP ' + res.statusCode);
reject(new Error(errorMsg));
},
fail: (err) => {
console.error('[Revival-Volcengine] 上传响应超时:', err);
// 超时不一定是失败,后端可能仍在处理
// 延迟3秒后自动刷新历史记录检查视频是否已生成
setTimeout(() => {
uni.showModal({
title: '⏰ 响应超时',
content: '网络响应超时,但视频可能已经生成成功。\n\n是否立即查看历史记录',
confirmText: '查看历史',
cancelText: '稍后查看',
success: (modalRes) => {
if (modalRes.confirm) {
uni.navigateTo({
url: '/pages/revival/revival-history'
});
}
}
});
}, 3000);
reject(new Error('网络响应超时,视频可能正在生成中,请稍后在历史记录中查看'));
}
});
});
},
// 使用新音色生成
async generateWithNewVoice() {
this.addProgress('正在创建音色...', 'loading');
// #ifdef APP-PLUS
// APP环境暂不支持创建新音色需要同时上传两个文件
uni.showModal({
title: '提示',
content: 'APP环境暂不支持创建新音色请使用"已有音色"模式,或在电脑端创建音色后使用',
showCancel: false
});
throw new Error('APP环境暂不支持创建新音色');
// #endif
// #ifdef H5
try {
// H5环境使用FormData
const formData = new FormData();
// 添加照片文件
if (this.photoFile.file) {
formData.append('photo', this.photoFile.file);
} else {
const photoBlob = await fetch(this.photoFile.path).then(r => r.blob());
formData.append('photo', photoBlob, 'photo.jpg');
}
// 添加音频文件
if (this.audioFile.file) {
formData.append('audioSample', this.audioFile.file);
}
// 添加其他参数
formData.append('voiceId', this.selectedVoiceId);
formData.append('voiceType', this.selectedVoiceType);
formData.append('text', this.text);
formData.append('voiceName', this.videoTitle);
if (this.selectedSupportsDialect && this.selectedDialect) {
formData.append('dialect', this.selectedDialect);
}
if (this.selectedSupportsLanguageHints && this.selectedLanguageHint) {
formData.append('languageHints', this.selectedLanguageHint);
}
formData.append('serverUrl', this.API_BASE);
// 发送请求
const response = await fetch(`${this.API_BASE}/api/photo-revival/revive`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('上传失败: ' + response.status);
}
const result = await response.json();
this.handleResult(result);
} catch (error) {
console.error('[Revival] 生成失败:', error);
throw error;
}
// #endif
},
// 使用已有音色生成
generateWithExistingVoice() {
return new Promise((resolve, reject) => {
this.addProgress('⏳ 正在上传照片...', 'loading');
// 显示详细的等待提示
uni.showModal({
title: '⏳ 视频生成中',
content: '视频生成通常需要2-5分钟高峰期可能需要10-30分钟。\n\n您可以最小化APP做其他事情生成完成后会弹出提示。\n\n请保持网络连接稳定。',
showCancel: false,
confirmText: '我知道了'
});
// 延迟添加后续进度提示
setTimeout(() => {
this.addProgress('🎵 正在合成语音...', 'loading');
}, 2000);
setTimeout(() => {
this.addProgress('🎬 正在生成视频通常2-5分钟高峰期可能更久...', 'loading');
}, 5000);
// 获取用户token
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
console.log('[Revival] 准备上传照片:', this.photoFile);
console.log('[Revival] 照片路径:', this.photoFile.path);
console.log('[Revival] 用户ID:', userId);
console.log('[Revival] Token:', token ? '已设置' : '未设置');
console.log('[Revival] API地址:', `${this.API_BASE}/api/photo-revival/revive-quick`);
// 检查userId是否存在
if (!userId) {
console.warn('[Revival] ⚠️ 警告用户ID为空视频可能无法正确保存到数据库');
uni.showModal({
title: '提示',
content: '检测到您未登录,视频将无法保存到历史记录。是否继续?',
success: (modalRes) => {
if (!modalRes.confirm) {
reject(new Error('用户取消操作'));
return;
}
}
});
}
uni.uploadFile({
url: `${this.API_BASE}/api/photo-revival/revive-quick`,
filePath: this.photoFile.path,
name: 'photo',
timeout: 3600000, // 60分钟超时视频生成需要较长时间
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
formData: (() => {
const data = {
voiceId: this.selectedVoiceId,
voiceType: this.selectedVoiceType,
text: this.text,
name: this.videoTitle,
serverUrl: this.API_BASE
};
if (this.selectedSupportsDialect && this.selectedDialect) {
data.dialect = this.selectedDialect;
}
if (this.selectedSupportsLanguageHints && this.selectedLanguageHint) {
data.languageHints = this.selectedLanguageHint;
}
return data;
})(),
success: (res) => {
console.log('[Revival] 上传响应:', res);
let parsed = null;
try {
parsed = JSON.parse(res.data);
} catch (e) {
parsed = null;
}
if (res.statusCode === 504) {
console.warn('[Revival] HTTP 504请求超时但后端可能仍在生成中');
setTimeout(() => {
uni.showModal({
title: '⏰ 响应超时',
content: '网络响应超时HTTP 504但视频可能仍在后台生成中。\n\n是否立即查看历史记录',
confirmText: '查看历史',
cancelText: '稍后查看',
success: (modalRes) => {
if (modalRes.confirm) {
uni.navigateTo({
url: '/pages/revival/revival-history'
});
}
}
});
}, 800);
this.addProgress('⏳ 网络响应超时HTTP 504视频可能仍在后台生成中请稍后到历史记录查看', 'loading');
resolve();
return;
}
if (res.statusCode === 200) {
if (parsed && parsed.status === 'success') {
this.handleResult(parsed);
resolve();
return;
}
const errorMsg = (parsed && parsed.message) ? parsed.message : '生成失败';
console.error('[Revival] 后端返回错误:', errorMsg);
reject(new Error(errorMsg));
return;
}
// 非 200尽量解析后端 message
const errorMsg = (parsed && parsed.message)
? parsed.message
: ('上传失败: HTTP ' + res.statusCode);
reject(new Error(errorMsg));
},
fail: (err) => {
console.error('[Revival] 上传响应超时:', err);
// 超时不一定是失败,后端可能仍在处理
// 延迟3秒后提示用户查看历史记录
setTimeout(() => {
uni.showModal({
title: '⏰ 响应超时',
content: '网络响应超时,但视频可能已经生成成功。\n\n是否立即查看历史记录',
confirmText: '查看历史',
cancelText: '稍后查看',
success: (modalRes) => {
if (modalRes.confirm) {
uni.navigateTo({
url: '/pages/revival/revival-history'
});
}
}
});
}, 3000);
// 上传超时不一定是失败,可能是后端正在处理
// 提示用户稍后在历史记录中查看
reject(new Error('网络响应超时,视频可能正在生成中,请稍后在历史记录中查看'));
}
});
});
},
// 处理结果
async handleResult(result) {
if (result.status === 'success') {
if (this.mode === 'new') {
this.addProgress('✅ 音色创建完成', 'done');
}
this.addProgress('✅ 语音合成完成', 'done');
this.addProgress('✅ 视频生成完成', 'done');
// 保存远程URL
this.videoUrl = result.videoUrl;
this.audioUrl = result.audioUrl;
this.videoRetried = false;
// 保存到历史记录直接保存可播放的URL
this.saveToHistory(result);
// 显示成功弹窗
uni.showModal({
title: '🎉 生成成功!',
content: '视频已生成完成!\n\n点击"查看视频"可以立即查看和播放视频。',
cancelText: '稍后查看',
confirmText: '查看视频',
success: (modalRes) => {
if (modalRes.confirm) {
this.scrollToResult();
}
}
});
// 刷新音色列表
if (this.mode === 'new') {
this.loadVoices();
}
} else {
throw new Error(result.message || '生成失败');
}
},
// 播放/暂停控制
togglePlay() {
if (this.isPlaying) {
this.pausePlayback();
} else {
this.startPlayback();
}
},
// 开始播放
startPlayback() {
// 获取视频和音频元素
const videoContext = uni.createVideoContext('resultVideo', this);
const audioContext = uni.createInnerAudioContext();
audioContext.src = this.audioUrl;
// 同时播放视频和音频
videoContext.play();
audioContext.play();
this.isPlaying = true;
this.audioContext = audioContext; // 保存音频上下文
},
// 暂停播放
pausePlayback() {
const videoContext = uni.createVideoContext('resultVideo', this);
videoContext.pause();
if (this.audioContext) {
this.audioContext.pause();
}
this.isPlaying = false;
},
// 视频播放事件
onVideoPlay() {
this.isPlaying = true;
},
// 视频暂停事件
onVideoPause() {
this.isPlaying = false;
if (this.audioContext) {
this.audioContext.pause();
}
},
// 视频结束事件
onVideoEnded() {
this.isPlaying = false;
if (this.audioContext) {
this.audioContext.stop();
}
},
// 视频加载成功
onVideoLoaded(e) {
console.log('[Revival] 视频加载成功:', e);
},
// 视频加载错误
onVideoError(e) {
console.error('[Revival] 视频加载失败:', e);
console.error('[Revival] 视频URL:', this.videoUrl);
console.error('[Revival] 错误详情:', e.detail);
// 尝试重新加载视频(仅尝试一次)
if (!this.videoRetried) {
console.log('[Revival] 尝试重新加载视频...');
this.videoRetried = true;
setTimeout(() => {
const videoContext = uni.createVideoContext('resultVideo', this);
videoContext.stop();
// 强制刷新视频URL
const currentUrl = this.videoUrl;
this.videoUrl = '';
this.$nextTick(() => {
this.videoUrl = currentUrl + '?t=' + Date.now();
});
}, 500);
return;
}
uni.showModal({
title: '视频加载失败',
content: '无法播放视频,可能是网络或格式问题。\n\n您可以尝试\n1. 检查网络连接\n2. 返回后重新进入播放\n3. 重新生成视频或稍后再试',
showCancel: false
});
},
// 视频等待加载
onVideoWaiting(e) {
console.log('[Revival] 视频缓冲中...', e);
},
// 视频时间更新
onVideoTimeUpdate(e) {
// 可以在这里更新播放进度
},
// 滚动到结果区域
scrollToResult() {
// 使用nextTick确保DOM已更新
this.$nextTick(() => {
// 创建选择器查询
const query = uni.createSelectorQuery().in(this);
query.select('.result-section').boundingClientRect(data => {
if (data) {
// 滚动到结果区域
uni.pageScrollTo({
scrollTop: data.top - 100,
duration: 300
});
}
}).exec();
});
},
// 添加进度
addProgress(text, status) {
this.progress.push({ text, status });
this.loadingText = text;
},
// 跳转到历史记录
goToHistory() {
uni.navigateTo({
url: '/pages/revival/revival-history'
});
},
// 播放视频(跳转到播放页面,与历史记录逻辑一致)
viewVideo() {
console.log('[Revival] 准备播放视频');
console.log('[Revival] videoUrl:', this.videoUrl);
console.log('[Revival] audioUrl:', this.audioUrl);
if (!this.videoUrl) {
uni.showToast({
title: '视频地址不存在',
icon: 'none'
});
return;
}
let targetUrl = `/pages/video-player/video-player?url=${encodeURIComponent(this.videoUrl)}&title=${encodeURIComponent('复活视频')}`;
targetUrl += `&audioInVideo=1`;
console.log('[Revival] 跳转播放页:', targetUrl);
// 跳转到视频播放页面
uni.navigateTo({
url: targetUrl,
success: () => {
console.log('[Revival] 跳转播放页面成功');
},
fail: (err) => {
console.error('[Revival] 跳转播放页面失败:', err);
uni.showToast({
title: '跳转失败',
icon: 'none'
});
}
});
},
// 支付相关方法
handlePaymentClose() {
this.paymentModalData.show = false;
if (this._paymentReject) {
this._paymentReject(new Error('用户取消支付'));
}
},
async handlePaymentConfirm(paymentData) {
await processPayment(this, paymentData);
},
// 手动保存到历史记录(用于测试)
manualSaveToHistory() {
if (!this.videoUrl) {
uni.showToast({
title: '没有可保存的记录',
icon: 'none'
});
return;
}
const result = {
photoUrl: this.photoPreview,
videoUrl: this.videoUrl,
audioUrl: this.audioUrl,
voiceId: this.selectedVoiceId
};
this.saveToHistory(result);
uni.showToast({
title: '已保存到历史',
icon: 'success'
});
},
// 下载视频到本地
async downloadVideoToLocal(videoUrl) {
try {
console.log('[Revival] 开始下载视频到本地:', videoUrl);
// 使用uni.downloadFile下载视频
const downloadResult = await new Promise((resolve, reject) => {
uni.downloadFile({
url: videoUrl,
success: (res) => {
if (res.statusCode === 200) {
console.log('[Revival] 视频下载成功:', res.tempFilePath);
resolve(res.tempFilePath);
} else {
reject(new Error('下载失败,状态码: ' + res.statusCode));
}
},
fail: (err) => {
console.error('[Revival] 视频下载失败:', err);
reject(err);
}
});
});
// 保存到永久存储
const savedPath = await this.saveFilePermanently(downloadResult, 'video');
this.localVideoPath = savedPath;
console.log('[Revival] 视频已保存到:', savedPath);
} catch (e) {
console.error('[Revival] 下载视频失败:', e);
uni.showToast({
title: '视频下载失败,将使用在线播放',
icon: 'none'
});
}
},
// 保存文件到永久存储
async saveFilePermanently(tempPath, type) {
return new Promise((resolve, reject) => {
const fs = uni.getFileSystemManager();
const savedDir = `${uni.env.USER_DATA_PATH}/revival_${type}s/`;
const fileName = `${type}_${Date.now()}.${type === 'video' ? 'mp4' : 'mp3'}`;
const savedPath = savedDir + fileName;
// 创建目录
fs.mkdir({
dirPath: savedDir,
recursive: true,
success: () => {
// 复制文件
fs.copyFile({
srcPath: tempPath,
destPath: savedPath,
success: () => {
console.log(`[Revival] ${type}已保存到永久存储:`, savedPath);
resolve(savedPath);
},
fail: (err) => {
console.error(`[Revival] 保存${type}失败:`, err);
// 如果复制失败,直接使用临时路径
resolve(tempPath);
}
});
},
fail: (err) => {
console.error('[Revival] 创建目录失败:', err);
// 如果创建目录失败,直接使用临时路径
resolve(tempPath);
}
});
});
},
// 保存到历史记录
saveToHistory(result) {
try {
console.log('[Revival] 开始保存历史记录');
console.log('[Revival] 结果数据:', result);
// 读取现有历史记录
let history = [];
const historyData = uni.getStorageSync('generation_history');
if (historyData) {
history = JSON.parse(historyData);
}
// 添加新记录直接保存可播放的URL
const record = {
type: 'revival',
timestamp: Date.now(),
text: this.text,
voiceId: this.mode === 'existing' ? this.selectedVoiceId : (result.voiceId || this.selectedVoiceId),
photoUrl: this.photoPreview, // 使用本地预览URL
videoUrl: result.videoUrl,
audioUrl: result.audioUrl
};
console.log('[Revival] 保存记录:', record);
history.unshift(record); // 添加到开头
// 限制历史记录数量最多保存100条
if (history.length > 100) {
history = history.slice(0, 100);
}
// 保存到本地存储
uni.setStorageSync('generation_history', JSON.stringify(history));
console.log('[Revival] 已保存到历史记录,当前共', history.length, '条');
} catch (e) {
console.error('[Revival] 保存历史记录失败:', e);
}
},
// 下载视频
downloadVideo() {
uni.showLoading({
title: '正在保存...'
});
uni.downloadFile({
url: this.videoUrl,
success: (res) => {
if (res.statusCode === 200) {
uni.saveVideoToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
uni.hideLoading();
uni.showToast({
title: '保存成功',
icon: 'success'
});
},
fail: (err) => {
uni.hideLoading();
uni.showToast({
title: '保存失败: ' + err.errMsg,
icon: 'none'
});
}
});
} else {
uni.hideLoading();
uni.showToast({
title: '下载失败',
icon: 'none'
});
}
},
fail: (err) => {
uni.hideLoading();
uni.showToast({
title: '下载失败: ' + err.errMsg,
icon: 'none'
});
}
});
},
// 开始视频通话
startVideoCall() {
if (!this.videoUrl || !this.selectedVoiceId) {
uni.showToast({
title: '缺少必要信息',
icon: 'none'
});
return;
}
const callerName = this.mode === 'new' ? this.voiceName : '对方';
uni.navigateTo({
url: `/pages/video-call/video-call?idleVideo=${encodeURIComponent(this.videoUrl)}&talkingVideo=${encodeURIComponent(this.videoUrl)}&voiceId=${this.selectedVoiceId}&callerName=${encodeURIComponent(callerName)}`
});
}
}
};
</script>
<style lang="scss" scoped>
.revival-container {
min-height: 100vh;
background: #FDF8F2;
background-image:
radial-gradient(circle at 10% 20%, rgba(212, 185, 150, 0.1) 0%, transparent 20%),
radial-gradient(circle at 90% 80%, rgba(109, 139, 139, 0.1) 0%, transparent 20%);
display: flex;
flex-direction: column;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32upx 32upx 12upx;
gap: 20upx;
max-width: 1000upx;
margin: 0 auto;
}
.hero-text {
display: flex;
flex-direction: column;
gap: 8upx;
}
.hero-title {
font-size: 44upx;
font-weight: 700;
color: #8B7355;
letter-spacing: 2upx;
}
.hero-subtitle {
font-size: 28upx;
color: #666;
}
.history-btn {
padding: 16rpx 32rpx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
border: none;
border-radius: 36rpx;
font-size: 26rpx;
box-shadow: 0 4upx 12upx rgba(139, 115, 85, 0.3);
transition: all 0.3s;
}
.history-btn:active {
transform: translateY(-2upx);
box-shadow: 0 6upx 16upx rgba(139, 115, 85, 0.4);
}
.content {
flex: 1;
background: transparent;
padding: 32upx;
max-width: 650upx;
margin: 0 auto 40upx auto;
display: flex;
flex-direction: column;
gap: 28upx;
align-items: center;
}
.tips-card {
width: 100%;
max-width: 580upx;
background: rgba(255, 255, 255, 0.95);
border-radius: 20upx;
padding: 22upx 24upx;
box-shadow: 0 6upx 20upx rgba(0, 0, 0, 0.06);
border: 2upx solid rgba(139, 115, 85, 0.12);
}
.tips-title {
font-size: 28upx;
font-weight: 700;
color: #8B7355;
margin-bottom: 12upx;
}
.tips-item {
font-size: 24upx;
color: #666;
line-height: 1.6;
margin-top: 8upx;
}
.step-card {
width: 100%;
max-width: 580upx;
background: rgba(255, 255, 255, 0.95);
border-radius: 20upx;
padding: 28upx 24upx;
margin: 0;
box-shadow: 0 6upx 20upx rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12upx);
border: 2upx solid rgba(139, 115, 85, 0.12);
}
.step-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.step-number {
width: 44upx;
height: 44upx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 14upx;
font-size: 26upx;
box-shadow: 0 3upx 10upx rgba(139, 115, 85, 0.25);
}
.step-title {
font-size: 30rpx;
font-weight: bold;
color: #2C2C2C;
flex: 1;
}
.help-icon {
font-size: 32upx;
color: #8B7355;
padding: 10upx;
margin-left: 10upx;
transition: all 0.3s;
}
.help-icon:active {
transform: scale(1.2);
color: #6D8B8B;
}
.upload-btn {
width: 100%;
min-height: 700upx;
border: 3upx dashed #D4B996;
border-radius: 16upx;
background: rgba(212, 185, 150, 0.05);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
overflow: hidden;
position: relative;
transition: all 0.3s;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 30% 30%, rgba(139, 115, 85, 0.08) 0%, transparent 50%),
radial-gradient(circle at 70% 70%, rgba(109, 139, 139, 0.08) 0%, transparent 50%);
z-index: 0;
}
&:active {
border-color: #8B7355;
background: rgba(212, 185, 150, 0.1);
}
}
.preview-img {
width: 100%;
height: 700upx;
border-radius: 14rpx;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
color: #8B7355;
gap: 12upx;
}
.icon {
font-size: 72rpx;
}
.mode-selector {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
}
.mode-btn {
flex: 1;
padding: 24upx;
border: 2upx solid rgba(212, 185, 150, 0.3);
border-radius: 30upx;
background: rgba(255, 255, 255, 0.7);
font-size: 28upx;
color: #666;
transition: all 0.3s;
box-shadow: 0 2upx 8upx rgba(0, 0, 0, 0.05);
}
.mode-btn.active {
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
border-color: transparent;
box-shadow: 0 8upx 24upx rgba(139, 115, 85, 0.3);
}
.voice-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.upload-btn-small {
padding: 24rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
background: #f8f9ff;
font-size: 28rpx;
color: #666;
}
.input,
.picker {
padding: 24rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
font-size: 28rpx;
background: white;
}
.picker-large {
width: 100%;
}
.picker-large .picker {
padding: 32rpx 24rpx;
font-size: 32rpx;
font-weight: 500;
}
.input-hint {
font-size: 24rpx;
color: #999;
margin-top: 16rpx;
line-height: 1.6;
}
.maintenance-warning {
font-size: 24rpx;
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
padding: 16rpx;
border-radius: 8rpx;
margin-top: 16rpx;
line-height: 1.6;
border: 1rpx solid rgba(255, 107, 107, 0.3);
}
.input-field {
width: 100%;
height: 90rpx;
padding: 24rpx;
border: 2rpx solid rgba(139, 115, 85, 0.2);
border-radius: 12rpx;
font-size: 32rpx;
box-sizing: border-box;
background: white;
transition: border-color 0.3s;
&:focus {
border-color: #8B7355;
}
}
.textarea {
width: 100%;
min-height: 150rpx;
padding: 20rpx;
border: 2rpx solid rgba(139, 115, 85, 0.2);
border-radius: 12rpx;
font-size: 28rpx;
line-height: 1.6;
box-sizing: border-box;
background: white;
transition: border-color 0.3s;
&:focus {
border-color: #8B7355;
}
}
.char-count {
text-align: right;
font-size: 24rpx;
color: #999;
margin-top: 16rpx;
}
.generate-btn {
width: 520upx;
max-width: 580upx;
padding: 28upx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: #ffffff !important;
2026-03-05 14:29:21 +08:00
border: none;
border-radius: 20upx;
font-size: 30upx;
font-weight: bold;
margin: 12upx auto 0 auto; /* 居中 */
display: block;
box-shadow: 0 10upx 28upx rgba(139, 115, 85, 0.3);
transition: all 0.3s;
&:active {
transform: translateY(-2upx);
box-shadow: 0 14upx 36upx rgba(139, 115, 85, 0.4);
}
}
.generate-btn[disabled] {
opacity: 0.5;
box-shadow: 0 8upx 20upx rgba(0, 0, 0, 0.1);
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
}
.progress-section {
width: 100%;
max-width: 580upx;
background: rgba(255, 255, 255, 0.96);
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
border: 2upx solid rgba(139, 115, 85, 0.12);
}
.progress-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 24rpx;
color: #333;
}
.progress-item {
display: flex;
align-items: center;
padding: 18rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.progress-item:last-child {
border-bottom: none;
}
.progress-icon {
font-size: 32rpx;
margin-right: 16rpx;
}
.progress-text {
font-size: 28rpx;
color: #666;
}
.result-section {
width: 100%;
max-width: 580upx;
background: rgba(255, 255, 255, 0.96);
border-radius: 16rpx;
padding: 24rpx;
margin-top: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
border: 2upx solid rgba(139, 115, 85, 0.12);
}
.result-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
text-align: center;
margin-bottom: 24rpx;
}
.result-section {
position: relative;
}
.video-container {
position: relative;
2026-03-05 14:29:21 +08:00
width: 100%;
height: 400rpx;
border-radius: 16rpx;
overflow: hidden;
2026-03-05 14:29:21 +08:00
margin-bottom: 16rpx;
background: #000;
}
.result-video {
width: 100%;
height: 100%;
border-radius: 16rpx;
background: #000;
}
/* AI生成提示标签 */
.ai-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
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
.play-controls {
display: flex;
justify-content: center;
margin-bottom: 24rpx;
}
.play-btn {
padding: 20rpx 72rpx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
border: none;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: bold;
box-shadow: 0 6rpx 16rpx rgba(139, 115, 85, 0.3);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.play-btn:active {
transform: scale(0.96);
box-shadow: 0 3rpx 10rpx rgba(139, 115, 85, 0.25);
}
.result-info {
background: #f5f5f5;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 24rpx;
}
.info-label {
font-size: 24rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.info-value {
font-size: 22rpx;
color: #666;
word-break: break-all;
}
.video-call-btn {
width: 100%;
padding: 24rpx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
border: none;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: bold;
margin-bottom: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(139, 115, 85, 0.3);
transition: all 0.3s;
&:active {
transform: translateY(-1rpx);
box-shadow: 0 6rpx 16rpx rgba(139, 115, 85, 0.4);
}
}
.download-btn {
width: 100%;
padding: 24rpx;
background: linear-gradient(135deg, #6D8B8B 0%, #5A7A7A 100%);
color: white;
border: none;
border-radius: 16rpx;
font-size: 28rpx;
margin-bottom: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(109, 139, 139, 0.3);
transition: all 0.3s;
&:active {
transform: translateY(-1rpx);
box-shadow: 0 6rpx 16rpx rgba(109, 139, 139, 0.4);
}
}
.save-history-btn {
width: 100%;
padding: 24rpx;
background: linear-gradient(135deg, #D4B996 0%, #C4A986 100%);
color: white;
border: none;
border-radius: 16rpx;
font-size: 28rpx;
box-shadow: 0 4rpx 12rpx rgba(212, 185, 150, 0.3);
transition: all 0.3s;
&:active {
transform: translateY(-1rpx);
box-shadow: 0 6rpx 16rpx rgba(212, 185, 150, 0.4);
}
}
/* 帮助弹窗样式 */
.help-modal {
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;
padding: 40upx;
}
.help-content {
width: 100%;
max-width: 640upx;
max-height: 85vh;
background: white;
border-radius: 24upx;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8upx 32upx rgba(0, 0, 0, 0.3);
}
.help-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32upx;
border-bottom: 2upx solid #f0f0f0;
}
.help-title {
font-size: 32upx;
font-weight: bold;
color: #333;
}
.close-btn {
font-size: 40upx;
color: #999;
padding: 10upx;
line-height: 1;
}
.help-body {
flex: 1;
padding: 24upx 32upx;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.help-step {
margin-bottom: 40upx;
}
.help-step:last-child {
margin-bottom: 0;
}
.help-step-title {
display: block;
font-size: 28upx;
font-weight: 600;
color: #333;
margin-bottom: 16upx;
}
.help-step-desc {
display: block;
font-size: 24upx;
color: #666;
line-height: 1.6;
padding-left: 32upx;
}
.help-image {
width: calc(100% + 48upx); /* 再左移一些 */
margin-left: -24upx;
height: auto;
border-radius: 12upx;
margin-top: 16upx;
box-shadow: 0 4upx 12upx rgba(0, 0, 0, 0.1);
display: block;
}
.help-footer {
display: flex;
gap: 20upx;
padding: 24upx 32upx;
border-top: 2upx solid #f0f0f0;
}
.help-btn {
flex: 1;
padding: 24upx;
border-radius: 12upx;
font-size: 28upx;
border: none;
transition: all 0.3s;
}
.help-btn.secondary {
background: #f5f5f5;
color: #666;
}
.help-btn.primary {
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
}
.help-btn:active {
transform: scale(0.98);
}
</style>