Ai_GirlFriend/xuniYou/pages/create/timbre.vue
xiao12feng8 b4f4800e77 测试通过:
- 自定义恋人历史消息
- 克隆音色
- 回复消息增加“思考中”
2026-02-01 15:39:13 +08:00

1045 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<view>
<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="请输入音频 URLhttp:// 或 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>