1045 lines
24 KiB
Vue
1045 lines
24 KiB
Vue
<template>
|
||
<view>
|
||
<view class="body">
|
||
<view class="list">
|
||
<view class="list_head fa sb">
|
||
<image src="/static/images/timbre_logo.png" mode="widthFix"></image>{{ voicesInfo }}
|
||
</view>
|
||
|
||
<!-- 克隆音色按钮 -->
|
||
<view class="clone-voice-btn" @click="showCloneModal">
|
||
<text>🎤 克隆我的音色</text>
|
||
</view>
|
||
|
||
<view class="list_content">
|
||
<view class="list_module fa sb" v-for="(item, index) in configVoicesList.voices" :key="index" v-show="item.price_gold == 0"
|
||
@click="selectTimbre(index)">
|
||
<view class="list_detail fa f1">
|
||
<view class="list_image">
|
||
<image class="list_avatar" :src="item.avatar_url?global + item.avatar_url:'/static/images/replacement_picture.png'" mode="aspectFill"></image>
|
||
<image class="list_voice" @click.stop="playVoice(item.sample_audio_url)"
|
||
src="/static/images/timbre_voice.png" mode="widthFix"></image>
|
||
</view>
|
||
<view class="list_item f1">
|
||
<view class="list_name h1">{{ item.name }}</view>
|
||
<view class="list_tag h2">{{ item.style_tag }}</view>
|
||
</view>
|
||
</view>
|
||
<view class="list_select faj" >
|
||
<image
|
||
:src="selectedItemIndex == index ? '/static/images/selectA.png' : '/static/images/select.png'"
|
||
mode="widthFix"></image>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="opt">
|
||
<view class="opt_module">
|
||
<view class="opt_data">
|
||
<view class="opt_btn faj" @click="confirmSelection">
|
||
确定选择
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 克隆音色弹窗 -->
|
||
<view class="clone-modal" v-if="cloneModalVisible" @click="cloneModalVisible = false">
|
||
<view class="clone-content" @click.stop>
|
||
<view class="clone-title">克隆音色</view>
|
||
<view class="clone-desc">上传一段清晰的音频(至少3秒),AI将克隆您的音色。可以使用中文名称。</view>
|
||
|
||
<input
|
||
v-model="cloneVoiceName"
|
||
class="clone-input"
|
||
placeholder="请输入音色名称(最多20个字符)"
|
||
maxlength="20">
|
||
</input>
|
||
|
||
<!-- 音频输入方式切换 -->
|
||
<view class="audio-mode-switch">
|
||
<view
|
||
class="mode-btn"
|
||
:class="{ active: audioInputMode === 'url' }"
|
||
@click="audioInputMode = 'url'">
|
||
输入 URL
|
||
</view>
|
||
<view
|
||
class="mode-btn"
|
||
:class="{ active: audioInputMode === 'file' }"
|
||
@click="audioInputMode = 'file'">
|
||
上传文件
|
||
</view>
|
||
</view>
|
||
|
||
<!-- URL 输入模式 -->
|
||
<view v-if="audioInputMode === 'url'" class="url-input-area">
|
||
<input
|
||
v-model="audioUrlInput"
|
||
class="clone-input"
|
||
placeholder="请输入音频 URL(http:// 或 https://)">
|
||
</input>
|
||
<view class="url-tip">示例:http://example.com/audio.mp3</view>
|
||
</view>
|
||
|
||
<!-- 文件上传模式 -->
|
||
<view v-else class="clone-upload-area" @click="chooseAudio">
|
||
<view v-if="!audioFile" class="upload-placeholder">
|
||
<text>📁 点击选择音频文件</text>
|
||
<text class="upload-tip">支持 mp3、wav 格式,最大 10MB</text>
|
||
</view>
|
||
<view v-else class="upload-success">
|
||
<text>✅ {{ audioFile.name }}</text>
|
||
<text class="file-size">{{ (audioFile.size / 1024 / 1024).toFixed(2) }} MB</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="clone-status" v-if="cloneStatus">
|
||
<text :class="cloneStatusClass">{{ cloneStatusText }}</text>
|
||
</view>
|
||
|
||
<view class="clone-buttons">
|
||
<view class="clone-btn cancel" @click="cancelClone">取消</view>
|
||
<view class="clone-btn confirm" @click="startClone" :class="{ disabled: cloning }">
|
||
{{ cloning ? '克隆中...' : '开始克隆' }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import {
|
||
ConfigVoices,
|
||
LoverVoice,
|
||
ConfigVoicesAvailable,
|
||
LoverVoiceSimple
|
||
} from '@/utils/api.js'
|
||
import notHave from '@/components/not-have.vue';
|
||
import topSafety from '@/components/top-safety.vue';
|
||
|
||
import { useVoiceStore } from '@/store/index.js'
|
||
export default {
|
||
components: {
|
||
notHave,
|
||
topSafety,
|
||
},
|
||
data() {
|
||
return {
|
||
global: this.baseURL,
|
||
form: {
|
||
gender: '',
|
||
voice_id: '',
|
||
},
|
||
selectedItemIndex: -1, // 默认不选择任何项
|
||
configVoicesList: { voices: [] }, // 修复:初始化为对象
|
||
voicesInfo: '',
|
||
currentAudioContext: null, // 添加当前音频上下文实例
|
||
// 克隆音色相关
|
||
cloneModalVisible: false,
|
||
cloneVoiceName: '',
|
||
audioFile: null,
|
||
audioUrl: '',
|
||
cloning: false,
|
||
cloneStatus: '',
|
||
cloneVoiceId: '',
|
||
baseURLPy: 'http://127.0.0.1:8000',
|
||
// 音频输入方式:'file' 或 'url'
|
||
audioInputMode: 'url', // 默认使用 URL 输入
|
||
audioUrlInput: '', // 用户输入的音频 URL
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
console.log('角色声音页面接参:',options)
|
||
console.log('options.sex:', options.sex, 'options.is_edit:', options.is_edit);
|
||
|
||
// 如果有传递性别参数,使用传递的参数
|
||
if (options.sex) {
|
||
console.log('使用传递的性别参数:', options.sex);
|
||
this.form.gender = options.sex;
|
||
this.configVoices();
|
||
}
|
||
// 如果是编辑模式且没有性别参数,从恋人信息中获取
|
||
else if (options.is_edit) {
|
||
console.log('编辑模式,调用 getLoverGender()');
|
||
this.getLoverGender();
|
||
}
|
||
// 其他情况,直接加载音色列表(使用可用音色接口)
|
||
else {
|
||
console.log('其他情况,直接加载音色列表');
|
||
this.configVoices();
|
||
}
|
||
|
||
console.log('onLoad 结束时的 form:', this.form);
|
||
},
|
||
methods: {
|
||
// 获取恋人性别信息
|
||
getLoverGender() {
|
||
console.log('getLoverGender() 开始执行');
|
||
console.log('请求 URL:', this.baseURLPy + '/lover/basic');
|
||
console.log('token:', uni.getStorageSync("token"));
|
||
|
||
uni.request({
|
||
url: this.baseURLPy + '/lover/basic',
|
||
method: 'GET',
|
||
header: {
|
||
'token': uni.getStorageSync("token") || "",
|
||
},
|
||
success: (res) => {
|
||
console.log('getLoverGender() 响应:', res);
|
||
console.log('res.data:', res.data);
|
||
console.log('res.data.data:', res.data.data);
|
||
|
||
if (res.data.code === 1 && res.data.data) {
|
||
// 设置性别
|
||
const gender = res.data.data.gender || 'female';
|
||
console.log('从 API 获取的性别:', gender);
|
||
this.form.gender = gender;
|
||
console.log('设置后的 form.gender:', this.form.gender);
|
||
// 获取音色列表
|
||
this.configVoices();
|
||
} else {
|
||
console.warn('API 返回失败或数据为空,使用默认值 female');
|
||
// 默认使用 female
|
||
this.form.gender = 'female';
|
||
this.configVoices();
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
console.error('getLoverGender() 请求失败:', error);
|
||
// 默认使用 female
|
||
this.form.gender = 'female';
|
||
this.configVoices();
|
||
}
|
||
});
|
||
},
|
||
|
||
configVoices() {
|
||
let api = ''
|
||
if(this.form.gender){
|
||
api = ConfigVoices
|
||
}else{
|
||
api = ConfigVoicesAvailable
|
||
}
|
||
api({
|
||
...this.form
|
||
}).then(res => {
|
||
if (res.code == 1) {
|
||
this.configVoicesList = res.data
|
||
if (this.configVoicesList.selected_voice_id != null) {
|
||
let found = false;
|
||
for (let i = 0; i < this.configVoicesList.voices.length; i++) {
|
||
if (this.configVoicesList.voices[i].id == this.configVoicesList.selected_voice_id) {
|
||
this.voicesInfo = this.configVoicesList.voices[i].name
|
||
this.selectedItemIndex = i
|
||
this.form.voice_id = this.configVoicesList.voices[i].id
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found) {
|
||
// 如果没有找到 selected_voice_id,则使用默认值
|
||
for (let i = 0; i < this.configVoicesList.voices.length; i++) {
|
||
if (this.configVoicesList.voices[i].id == this.configVoicesList.default_voice_id) {
|
||
this.voicesInfo = this.configVoicesList.voices[i].name
|
||
this.selectedItemIndex = i
|
||
this.form.voice_id = this.configVoicesList.voices[i].id
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
for (let i = 0; i < this.configVoicesList.voices.length; i++) {
|
||
if (this.configVoicesList.voices[i].id == this.configVoicesList.default_voice_id) {
|
||
this.voicesInfo = this.configVoicesList.voices[i].name
|
||
this.selectedItemIndex = i
|
||
this.form.voice_id = this.configVoicesList.voices[i].id
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
} else {
|
||
uni.showToast({
|
||
title: res.msg,
|
||
icon: 'none',
|
||
position: 'top'
|
||
})
|
||
}
|
||
})
|
||
},
|
||
// 设置声音
|
||
loverVoice() {
|
||
let api = ''
|
||
if(this.form.gender){
|
||
api = LoverVoice
|
||
}else{
|
||
api = LoverVoiceSimple
|
||
}
|
||
api(this.form).then(res => {
|
||
if (res.code == 1) {
|
||
uni.showToast({
|
||
title: '设置成功',
|
||
icon: 'none',
|
||
position: 'top'
|
||
})
|
||
const voiceStore = useVoiceStore()
|
||
voiceStore.setVoice(this.form.voice_id, this.voicesInfo)
|
||
setTimeout(() => {
|
||
uni.navigateBack({
|
||
delta: 1
|
||
});
|
||
}, 1000);
|
||
} else {
|
||
uni.showToast({
|
||
title: res.msg,
|
||
icon: 'none',
|
||
position: 'top'
|
||
})
|
||
}
|
||
})
|
||
},
|
||
// 选择音色
|
||
selectTimbre(index) {
|
||
this.selectedItemIndex = index;
|
||
this.voicesInfo = this.configVoicesList.voices[index].name
|
||
this.form.voice_id = this.configVoicesList.voices[index].id
|
||
},
|
||
playVoice(url) {
|
||
// 如果当前有正在播放的音频,先停止它
|
||
if (this.currentAudioContext) {
|
||
this.currentAudioContext.stop();
|
||
this.currentAudioContext.destroy(); // 释放资源
|
||
}
|
||
|
||
// 创建新的音频上下文
|
||
this.currentAudioContext = uni.createInnerAudioContext();
|
||
this.currentAudioContext.src = this.global + url; // 设置音频源
|
||
|
||
// 播放音频
|
||
this.currentAudioContext.play();
|
||
|
||
// 监听播放结束事件,播放结束后清空当前音频上下文
|
||
this.currentAudioContext.onEnded(() => {
|
||
console.log('音频播放结束');
|
||
if (this.currentAudioContext) {
|
||
this.currentAudioContext.destroy();
|
||
this.currentAudioContext = null;
|
||
}
|
||
});
|
||
|
||
// 监听播放错误事件
|
||
this.currentAudioContext.onError((err) => {
|
||
console.error('音频播放失败:', err);
|
||
if (this.currentAudioContext) {
|
||
this.currentAudioContext.destroy();
|
||
this.currentAudioContext = null;
|
||
}
|
||
uni.showToast({
|
||
title: '播放失败',
|
||
icon: 'none'
|
||
});
|
||
});
|
||
},
|
||
confirmSelection() {
|
||
console.log(this.form)
|
||
console.log(this.selectedItemIndex)
|
||
if (this.selectedItemIndex < 0) {
|
||
uni.showToast({
|
||
title: '请选择音色',
|
||
icon: 'none'
|
||
});
|
||
} else {
|
||
console.log(this.form)
|
||
this.loverVoice()
|
||
}
|
||
},
|
||
|
||
// ===== 克隆音色相关方法 =====
|
||
showCloneModal() {
|
||
this.cloneModalVisible = true;
|
||
this.cloneVoiceName = '';
|
||
this.audioFile = null;
|
||
this.audioUrl = '';
|
||
this.audioUrlInput = '';
|
||
this.cloneStatus = '';
|
||
this.audioInputMode = 'url'; // 默认使用 URL 输入
|
||
},
|
||
|
||
cancelClone() {
|
||
this.cloneModalVisible = false;
|
||
this.cloning = false;
|
||
},
|
||
|
||
chooseAudio() {
|
||
uni.chooseFile({
|
||
count: 1,
|
||
extension: ['.mp3', '.wav', '.m4a'],
|
||
success: (res) => {
|
||
const file = res.tempFiles[0];
|
||
const fileSize = file.size; // 字节
|
||
const fileSizeMB = (fileSize / 1024 / 1024).toFixed(2); // 转换为 MB
|
||
|
||
console.log('选择的文件:', file);
|
||
console.log('文件大小:', fileSizeMB, 'MB');
|
||
|
||
// 检查文件大小(限制 10MB)
|
||
if (fileSize > 10 * 1024 * 1024) {
|
||
uni.showToast({
|
||
title: `文件太大(${fileSizeMB}MB),请选择小于10MB的音频`,
|
||
icon: 'none',
|
||
duration: 3000
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.audioFile = {
|
||
path: res.tempFilePaths[0],
|
||
name: file.name || '音频文件',
|
||
size: fileSize
|
||
};
|
||
console.log('选择的音频:', this.audioFile);
|
||
},
|
||
fail: (err) => {
|
||
console.error('选择文件失败:', err);
|
||
uni.showToast({
|
||
title: '选择文件失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
});
|
||
},
|
||
|
||
async startClone() {
|
||
if (this.cloning) return;
|
||
|
||
if (!this.cloneVoiceName.trim()) {
|
||
uni.showToast({
|
||
title: '请输入音色名称',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 验证长度(最多20个字符,因为是显示名称)
|
||
if (this.cloneVoiceName.length > 20) {
|
||
uni.showToast({
|
||
title: '音色名称不能超过20个字符',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 根据输入方式验证
|
||
if (this.audioInputMode === 'url') {
|
||
// URL 输入模式
|
||
if (!this.audioUrlInput.trim()) {
|
||
uni.showToast({
|
||
title: '请输入音频 URL',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 验证 URL 格式
|
||
if (!this.audioUrlInput.startsWith('http://') && !this.audioUrlInput.startsWith('https://')) {
|
||
uni.showToast({
|
||
title: '请输入有效的 HTTP/HTTPS URL',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 直接使用输入的 URL
|
||
this.audioUrl = this.audioUrlInput;
|
||
} else {
|
||
// 文件上传模式
|
||
if (!this.audioFile) {
|
||
uni.showToast({
|
||
title: '请选择音频文件',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 确保有性别信息
|
||
if (!this.form.gender) {
|
||
console.warn('性别信息为空,使用默认值 female');
|
||
this.form.gender = 'female';
|
||
}
|
||
|
||
console.log('开始克隆,当前性别:', this.form.gender);
|
||
console.log('用户输入的名称:', this.cloneVoiceName);
|
||
console.log('音频输入模式:', this.audioInputMode);
|
||
console.log('音频 URL:', this.audioUrl);
|
||
|
||
this.cloning = true;
|
||
this.cloneStatus = 'cloning';
|
||
|
||
try {
|
||
// 如果是文件上传模式,先上传
|
||
if (this.audioInputMode === 'file') {
|
||
this.cloneStatus = 'uploading';
|
||
const uploadResult = await this.uploadAudio();
|
||
if (!uploadResult) {
|
||
throw new Error('上传失败');
|
||
}
|
||
this.audioUrl = uploadResult;
|
||
}
|
||
|
||
// 调用克隆 API
|
||
this.cloneStatus = 'cloning';
|
||
const cloneResult = await this.callCloneAPI();
|
||
if (!cloneResult) {
|
||
throw new Error('克隆失败');
|
||
}
|
||
|
||
this.cloneVoiceId = cloneResult.voice_id;
|
||
this.cloneStatus = 'polling';
|
||
|
||
// 轮询状态
|
||
await this.pollCloneStatus();
|
||
|
||
} catch (error) {
|
||
console.error('克隆失败:', error);
|
||
this.cloneStatus = 'failed';
|
||
this.cloning = false;
|
||
|
||
// 显示更友好的错误提示
|
||
let errorMsg = '克隆失败';
|
||
if (error.message) {
|
||
if (error.message.includes('上传失败')) {
|
||
errorMsg = '音频上传失败,请重试';
|
||
} else {
|
||
errorMsg = error.message;
|
||
}
|
||
}
|
||
|
||
uni.showToast({
|
||
title: errorMsg,
|
||
icon: 'none',
|
||
duration: 3000
|
||
});
|
||
}
|
||
},
|
||
|
||
uploadAudio() {
|
||
return new Promise((resolve, reject) => {
|
||
console.log('开始上传音频...');
|
||
console.log('上传 URL:', this.baseURL + '/api/common/upload');
|
||
console.log('音频文件:', this.audioFile);
|
||
|
||
uni.uploadFile({
|
||
url: this.baseURL + '/api/common/upload',
|
||
filePath: this.audioFile.path,
|
||
name: 'file',
|
||
header: {
|
||
token: uni.getStorageSync("token") || "",
|
||
},
|
||
success: (res) => {
|
||
console.log('上传响应:', res);
|
||
const data = JSON.parse(res.data);
|
||
console.log('解析后的数据:', data);
|
||
|
||
if (data.code === 1) {
|
||
let audioUrl = data.data.url;
|
||
|
||
// 如果返回的是相对路径,转换为完整 URL
|
||
if (!audioUrl.startsWith('http://') && !audioUrl.startsWith('https://')) {
|
||
// 拼接完整 URL
|
||
audioUrl = this.baseURL + audioUrl;
|
||
}
|
||
|
||
console.log('上传成功,音频 URL:', audioUrl);
|
||
resolve(audioUrl);
|
||
} else {
|
||
console.error('上传失败:', data.msg);
|
||
reject(new Error(data.msg));
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
console.error('上传请求失败:', error);
|
||
reject(error);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
callCloneAPI() {
|
||
return new Promise((resolve, reject) => {
|
||
// 确保性别不为空,使用多重保护
|
||
let gender = this.form.gender;
|
||
if (!gender || gender === 'undefined' || gender === undefined) {
|
||
console.warn('性别为空,尝试重新获取');
|
||
// 如果还是空,使用默认值
|
||
gender = 'female';
|
||
}
|
||
|
||
const requestData = {
|
||
audio_url: this.audioUrl,
|
||
voice_name: this.cloneVoiceName,
|
||
gender: gender
|
||
};
|
||
|
||
console.log('调用克隆 API,请求数据:', JSON.stringify(requestData));
|
||
console.log('性别值类型:', typeof gender, '值:', gender);
|
||
|
||
uni.request({
|
||
url: this.baseURLPy + '/config/voices/clone',
|
||
method: 'POST',
|
||
header: {
|
||
'Content-Type': 'application/json',
|
||
'token': uni.getStorageSync("token") || "",
|
||
},
|
||
data: requestData,
|
||
success: (res) => {
|
||
console.log('克隆 API 响应:', res);
|
||
if (res.data.code === 1) {
|
||
resolve(res.data.data);
|
||
} else {
|
||
reject(new Error(res.data.message || res.data.msg || '克隆失败'));
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
console.error('克隆 API 请求失败:', error);
|
||
reject(error);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
async pollCloneStatus() {
|
||
const maxAttempts = 30; // 最多轮询30次
|
||
const interval = 10000; // 每10秒轮询一次
|
||
|
||
for (let i = 0; i < maxAttempts; i++) {
|
||
await new Promise(resolve => setTimeout(resolve, interval));
|
||
|
||
try {
|
||
const status = await this.checkCloneStatus();
|
||
|
||
if (status === 'OK') {
|
||
// 克隆成功,保存到数据库
|
||
await this.saveClonedVoice();
|
||
this.cloneStatus = 'success';
|
||
this.cloning = false;
|
||
|
||
uni.showToast({
|
||
title: '克隆成功!',
|
||
icon: 'success'
|
||
});
|
||
|
||
// 刷新音色列表
|
||
setTimeout(() => {
|
||
this.cloneModalVisible = false;
|
||
this.configVoices();
|
||
}, 2000);
|
||
|
||
return;
|
||
} else if (status === 'FAILED') {
|
||
throw new Error('克隆失败');
|
||
}
|
||
// 继续轮询
|
||
} catch (error) {
|
||
console.error('查询状态失败:', error);
|
||
this.cloneStatus = 'failed';
|
||
this.cloning = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 超时
|
||
this.cloneStatus = 'timeout';
|
||
this.cloning = false;
|
||
},
|
||
|
||
checkCloneStatus() {
|
||
return new Promise((resolve, reject) => {
|
||
uni.request({
|
||
url: this.baseURLPy + `/config/voices/clone/${this.cloneVoiceId}/status`,
|
||
method: 'GET',
|
||
header: {
|
||
'token': uni.getStorageSync("token") || "",
|
||
},
|
||
success: (res) => {
|
||
if (res.data.code === 1) {
|
||
resolve(res.data.data.status);
|
||
} else {
|
||
reject(new Error(res.data.message));
|
||
}
|
||
},
|
||
fail: reject
|
||
});
|
||
});
|
||
},
|
||
|
||
saveClonedVoice() {
|
||
return new Promise((resolve, reject) => {
|
||
uni.request({
|
||
url: this.baseURLPy + `/config/voices/clone/${this.cloneVoiceId}/save`,
|
||
method: 'POST',
|
||
header: {
|
||
'Content-Type': 'application/json',
|
||
'token': uni.getStorageSync("token") || "",
|
||
},
|
||
data: {
|
||
display_name: this.cloneVoiceName // 传递用户输入的中文名称
|
||
},
|
||
success: (res) => {
|
||
if (res.data.code === 1) {
|
||
resolve(res.data.data);
|
||
} else {
|
||
reject(new Error(res.data.message));
|
||
}
|
||
},
|
||
fail: reject
|
||
});
|
||
});
|
||
}
|
||
},
|
||
|
||
computed: {
|
||
cloneStatusText() {
|
||
const statusMap = {
|
||
'uploading': '正在上传音频...',
|
||
'cloning': '正在克隆音色...',
|
||
'polling': '正在生成音色,请稍候...',
|
||
'success': '✅ 克隆成功!',
|
||
'failed': '❌ 克隆失败',
|
||
'timeout': '⏱️ 克隆超时,请稍后重试'
|
||
};
|
||
return statusMap[this.cloneStatus] || '';
|
||
},
|
||
|
||
cloneStatusClass() {
|
||
return {
|
||
'status-processing': ['uploading', 'cloning', 'polling'].includes(this.cloneStatus),
|
||
'status-success': this.cloneStatus === 'success',
|
||
'status-error': ['failed', 'timeout'].includes(this.cloneStatus)
|
||
};
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
page {
|
||
background: #F6F8FA;
|
||
}
|
||
</style>
|
||
<style>
|
||
.body {
|
||
position: relative;
|
||
padding: 0 40rpx 40rpx 40rpx;
|
||
}
|
||
|
||
.list {
|
||
position: relative;
|
||
margin: 30rpx 0 0 0;
|
||
}
|
||
|
||
.list_head {
|
||
position: relative;
|
||
margin: 0 0 30rpx 0;
|
||
padding: 28rpx 40rpx;
|
||
font-weight: 400;
|
||
font-size: 30rpx;
|
||
color: #9E9E9E;
|
||
line-height: 50rpx;
|
||
background: #FFFFFF;
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
.list_head image {
|
||
width: 68rpx;
|
||
height: 66rpx;
|
||
display: block;
|
||
border-radius: 100rpx;
|
||
}
|
||
|
||
.list_content {
|
||
position: relative;
|
||
}
|
||
|
||
.list_module {
|
||
position: relative;
|
||
margin: 0 0 30rpx 0;
|
||
padding: 28rpx 40rpx;
|
||
background: #FFFFFF;
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
.list_detail {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.list_image {
|
||
position: relative;
|
||
}
|
||
|
||
.list_avatar {
|
||
width: 88rpx;
|
||
height: 88rpx;
|
||
display: block;
|
||
flex-shrink: 0;
|
||
border-radius: 100rpx;
|
||
}
|
||
|
||
.list_voice {
|
||
position: absolute;
|
||
right: -10rpx;
|
||
bottom: -10rpx;
|
||
width: 48rpx;
|
||
height: 48rpx;
|
||
}
|
||
|
||
.list_item {
|
||
position: relative;
|
||
margin: 0 0 0 24rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.list_name {
|
||
font-weight: 500;
|
||
font-size: 32rpx;
|
||
color: #333333;
|
||
line-height: 50rpx;
|
||
}
|
||
|
||
.list_tag {
|
||
font-weight: 400;
|
||
font-size: 28rpx;
|
||
color: #9E9E9E;
|
||
line-height: 50rpx;
|
||
}
|
||
|
||
.list_select {
|
||
position: relative;
|
||
}
|
||
|
||
.list_select image {
|
||
width: 36rpx;
|
||
height: 36rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.opt {
|
||
position: relative;
|
||
height: 150rpx;
|
||
}
|
||
|
||
.opt_module {
|
||
position: fixed;
|
||
padding: 5rpx 0 70rpx 0;
|
||
height: 85rpx;
|
||
width: 100%;
|
||
background: #F6F8FA;
|
||
bottom: 0;
|
||
left: 0;
|
||
box-sizing: content-box;
|
||
border-top: 1px solid #f0f0f0;
|
||
z-index: 2;
|
||
}
|
||
|
||
.opt_data {
|
||
padding: 5rpx 50rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.opt_btn {
|
||
padding: 24rpx 0;
|
||
font-weight: 400;
|
||
font-size: 32rpx;
|
||
color: #FFFFFF;
|
||
line-height: 50rpx;
|
||
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
/* 克隆音色按钮 */
|
||
.clone-voice-btn {
|
||
margin: 0 0 30rpx 0;
|
||
padding: 24rpx 40rpx;
|
||
background: linear-gradient(135deg, #FF6B9D 0%, #C239B3 100%);
|
||
border-radius: 12rpx;
|
||
text-align: center;
|
||
color: #FFFFFF;
|
||
font-size: 32rpx;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 克隆音色弹窗 */
|
||
.clone-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;
|
||
}
|
||
|
||
.clone-content {
|
||
width: 85%;
|
||
max-width: 600rpx;
|
||
background: #FFFFFF;
|
||
border-radius: 20rpx;
|
||
padding: 40rpx;
|
||
}
|
||
|
||
.clone-title {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
text-align: center;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.clone-desc {
|
||
font-size: 26rpx;
|
||
color: #999;
|
||
text-align: center;
|
||
margin-bottom: 30rpx;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.clone-input {
|
||
width: 100%;
|
||
padding: 20rpx;
|
||
border: 2rpx solid #E0E0E0;
|
||
border-radius: 10rpx;
|
||
font-size: 28rpx;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.clone-upload-area {
|
||
width: 100%;
|
||
min-height: 200rpx;
|
||
border: 2rpx dashed #9F47FF;
|
||
border-radius: 10rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 30rpx;
|
||
background: #F9F9F9;
|
||
}
|
||
|
||
.upload-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
color: #666;
|
||
}
|
||
|
||
.upload-placeholder text:first-child {
|
||
font-size: 32rpx;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.upload-tip {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.upload-success {
|
||
color: #4CAF50;
|
||
font-size: 28rpx;
|
||
padding: 20rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.clone-status {
|
||
text-align: center;
|
||
margin-bottom: 30rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.status-processing {
|
||
color: #2196F3;
|
||
}
|
||
|
||
.status-success {
|
||
color: #4CAF50;
|
||
}
|
||
|
||
.status-error {
|
||
color: #F44336;
|
||
}
|
||
|
||
.clone-buttons {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.clone-btn {
|
||
flex: 1;
|
||
padding: 20rpx;
|
||
text-align: center;
|
||
border-radius: 10rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.clone-btn.cancel {
|
||
background: #f5f5f5;
|
||
color: #666;
|
||
}
|
||
|
||
.clone-btn.confirm {
|
||
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
.clone-btn.disabled {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* 音频输入方式切换 */
|
||
.audio-mode-switch {
|
||
display: flex;
|
||
margin-bottom: 20rpx;
|
||
border: 2rpx solid #E0E0E0;
|
||
border-radius: 10rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.mode-btn {
|
||
flex: 1;
|
||
padding: 20rpx;
|
||
text-align: center;
|
||
font-size: 28rpx;
|
||
color: #666;
|
||
background: #F9F9F9;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.mode-btn.active {
|
||
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
|
||
color: #fff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* URL 输入区域 */
|
||
.url-input-area {
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.url-tip {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
margin-top: 10rpx;
|
||
padding-left: 10rpx;
|
||
}
|
||
|
||
.file-size {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
margin-top: 5rpx;
|
||
}
|
||
</style> |