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

2460 lines
65 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="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>
<!-- 进度显示 -->
<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>
<!-- 结果展示 -->
<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>
<!-- 音频播放器(隐藏) -->
<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;
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;
}
.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;
width: 100%;
height: 400rpx;
border-radius: 16rpx;
overflow: hidden;
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;
}
.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>