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>
|
2026-03-06 18:05:51 +08:00
|
|
|
|
<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-06 18:05:51 +08:00
|
|
|
|
<!-- 结果展示 -->
|
2026-03-05 14:29:21 +08:00
|
|
|
|
<view v-if="videoUrl" class="result-section">
|
|
|
|
|
|
<view class="result-title">🎉 生成成功!</view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 视频播放器(静音) -->
|
2026-03-06 18:05:51 +08:00
|
|
|
|
<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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按模型区分 serviceType:Veo -> 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%);
|
2026-03-06 18:05:51 +08:00
|
|
|
|
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;
|
2026-03-06 18:05:51 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 18:05:51 +08:00
|
|
|
|
.result-section {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.video-container {
|
|
|
|
|
|
position: relative;
|
2026-03-05 14:29:21 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 400rpx;
|
|
|
|
|
|
border-radius: 16rpx;
|
2026-03-06 18:05:51 +08:00
|
|
|
|
overflow: hidden;
|
2026-03-05 14:29:21 +08:00
|
|
|
|
margin-bottom: 16rpx;
|
|
|
|
|
|
background: #000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 18:05:51 +08:00
|
|
|
|
.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>
|