mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-27 09:33:55 +08:00
update:智控台音色克隆
This commit is contained in:
@@ -7,6 +7,10 @@ import model from './module/model.js'
|
||||
import ota from './module/ota.js'
|
||||
import timbre from "./module/timbre.js"
|
||||
import user from './module/user.js'
|
||||
import voiceClone from './module/voiceClone.js'
|
||||
import voiceResource from './module/voiceResource.js'
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 接口地址
|
||||
@@ -33,5 +37,7 @@ export default {
|
||||
model,
|
||||
timbre,
|
||||
ota,
|
||||
dict
|
||||
dict,
|
||||
voiceResource,
|
||||
voiceClone
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { getServiceUrl } from '../api';
|
||||
import RequestService from '../httpRequest';
|
||||
|
||||
export default {
|
||||
// 分页查询音色资源
|
||||
getVoiceCloneList(params, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceClone`)
|
||||
.method('GET')
|
||||
.data(params)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取音色列表失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getVoiceCloneList(params, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
|
||||
// 上传音频文件
|
||||
uploadVoice(formData, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceClone/upload`)
|
||||
.method('POST')
|
||||
.data(formData)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('上传音频失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.uploadVoice(formData, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
|
||||
// 更新音色名称
|
||||
updateName(params, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceClone/updateName`)
|
||||
.method('POST')
|
||||
.data(params)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('更新名称失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.updateName(params, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
|
||||
// 获取音频下载ID
|
||||
getAudioId(id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceClone/audio/${id}`)
|
||||
.method('POST')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取音频ID失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAudioId(id, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
|
||||
// 获取音频播放URL
|
||||
getPlayVoiceUrl(uuid) {
|
||||
return `${getServiceUrl()}/voiceClone/play/${uuid}`;
|
||||
},
|
||||
|
||||
// 复刻音频
|
||||
cloneAudio(params, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceClone/cloneAudio`)
|
||||
.method('POST')
|
||||
.data(params)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('上传失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.cloneAudio(params, callback);
|
||||
});
|
||||
}).send();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getServiceUrl } from '../api';
|
||||
import RequestService from '../httpRequest';
|
||||
|
||||
export default {
|
||||
// 分页查询音色资源
|
||||
getVoiceResourceList(params, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceResource`)
|
||||
.method('GET')
|
||||
.data(params)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取音色资源列表失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getVoiceResourceList(params, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取单个音色资源信息
|
||||
getVoiceResourceInfo(id, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceResource/${id}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取音色资源信息失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getVoiceResourceInfo(id, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 保存音色资源
|
||||
saveVoiceResource(entity, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceResource`)
|
||||
.method('POST')
|
||||
.data(entity)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('保存音色资源失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.saveVoiceResource(entity, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 删除音色资源
|
||||
deleteVoiceResource(ids, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceResource/${ids}`)
|
||||
.method('DELETE')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('删除音色资源失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.deleteVoiceResource(ids, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 根据用户ID获取音色资源列表
|
||||
getVoiceResourceByUserId(userId, callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceResource/user/${userId}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取用户音色资源列表失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getVoiceResourceByUserId(userId, callback);
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取TTS平台列表
|
||||
getTtsPlatformList(callback) {
|
||||
RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/voiceResource/ttsPlatforms`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取TTS平台列表失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getTtsPlatformList(callback);
|
||||
});
|
||||
}).send();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" @close="handleClose" @open="handleOpen">
|
||||
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" @close="handleClose"
|
||||
@open="handleOpen">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="auto">
|
||||
<el-form-item :label="$t('firmwareDialog.firmwareName')" prop="firmwareName">
|
||||
<el-input v-model="form.firmwareName" :placeholder="$t('firmwareDialog.firmwareNamePlaceholder')"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('firmwareDialog.firmwareType')" prop="type">
|
||||
<el-select v-model="form.type" :placeholder="$t('firmwareDialog.firmwareTypePlaceholder')" style="width: 100%;" filterable :disabled="isTypeDisabled">
|
||||
<el-select v-model="form.type" :placeholder="$t('firmwareDialog.firmwareTypePlaceholder')" style="width: 100%;"
|
||||
filterable :disabled="isTypeDisabled">
|
||||
<el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -14,7 +16,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('firmwareDialog.firmwareFile')" prop="firmwarePath">
|
||||
<el-upload ref="upload" class="upload-demo" action="#" :http-request="handleUpload"
|
||||
:before-upload="beforeUpload" :accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true"
|
||||
:before-upload="beforeUpload" :accept="'.bin,.apk,.wav'" :limit="1" :multiple="false" :auto-upload="true"
|
||||
:on-remove="handleRemove">
|
||||
<el-button size="small" type="primary">{{ $t('firmwareDialog.clickUpload') }}</el-button>
|
||||
<div slot="tip" class="el-upload__tip">{{ $t('firmwareDialog.uploadTip') }}</div>
|
||||
@@ -26,7 +28,8 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('firmwareDialog.remark')" prop="remark">
|
||||
<el-input type="textarea" v-model="form.remark" :placeholder="$t('firmwareDialog.remarkPlaceholder')"></el-input>
|
||||
<el-input type="textarea" v-model="form.remark"
|
||||
:placeholder="$t('firmwareDialog.remarkPlaceholder')"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
@@ -128,13 +131,13 @@ export default {
|
||||
const isValidType = ['.bin', '.apk'].some(ext => file.name.toLowerCase().endsWith(ext))
|
||||
|
||||
if (!isValidType) {
|
||||
this.$message.error(this.$t('firmwareDialog.invalidFileType'))
|
||||
return false
|
||||
}
|
||||
if (!isValidSize) {
|
||||
this.$message.error(this.$t('firmwareDialog.invalidFileSize'))
|
||||
return false
|
||||
}
|
||||
this.$message.error(this.$t('firmwareDialog.invalidFileType'))
|
||||
return false
|
||||
}
|
||||
if (!isValidSize) {
|
||||
this.$message.error(this.$t('firmwareDialog.invalidFileSize'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
handleUpload(options) {
|
||||
|
||||
@@ -16,6 +16,34 @@
|
||||
:style="{ filter: $route.path === '/home' || $route.path === '/role-config' || $route.path === '/device-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
<span class="nav-text">{{ $t('header.smartManagement') }}</span>
|
||||
</div>
|
||||
<!-- 普通用户显示音色克隆 -->
|
||||
<div v-if="!isSuperAdmin" class="equipment-management"
|
||||
:class="{ 'active-tab': $route.path === '/voice-clone-management' }" @click="goVoiceCloneManagement">
|
||||
<img loading="lazy" alt="" src="@/assets/header/voice.png"
|
||||
:style="{ filter: $route.path === '/voice-clone-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
<span class="nav-text">{{ $t('header.voiceCloneManagement') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 超级管理员显示音色克隆下拉菜单 -->
|
||||
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
|
||||
:class="{ 'active-tab': $route.path === '/voice-clone-management' || $route.path === '/voice-resource-management' }"
|
||||
@visible-change="handleVoiceCloneDropdownVisibleChange">
|
||||
<span class="el-dropdown-link">
|
||||
<img loading="lazy" alt="" src="@/assets/header/voice.png"
|
||||
:style="{ filter: $route.path === '/voice-clone-management' || $route.path === '/voice-resource-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
<span class="nav-text">{{ $t('header.voiceCloneManagement') }}</span>
|
||||
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': voiceCloneDropdownVisible }"></i>
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item @click.native="goVoiceCloneManagement">
|
||||
{{ $t('header.voiceCloneManagement') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="goVoiceResourceManagement">
|
||||
{{ $t('header.voiceResourceManagement') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
|
||||
<div v-if="isSuperAdmin" class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }"
|
||||
@click="goModelConfig">
|
||||
<img loading="lazy" alt="" src="@/assets/header/model_config.png"
|
||||
@@ -28,22 +56,19 @@
|
||||
:style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
<span class="nav-text">{{ $t('header.userManagement') }}</span>
|
||||
</div>
|
||||
<div v-if="isSuperAdmin" class="equipment-management"
|
||||
:class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement">
|
||||
<img loading="lazy" alt="" src="@/assets/header/firmware_update.png"
|
||||
:style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
<span class="nav-text">{{ $t('header.otaManagement') }}</span>
|
||||
</div>
|
||||
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
|
||||
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' }"
|
||||
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' || $route.path === '/ota-management' }"
|
||||
@visible-change="handleParamDropdownVisibleChange">
|
||||
<span class="el-dropdown-link">
|
||||
<img loading="lazy" alt="" src="@/assets/header/param_management.png"
|
||||
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' || $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
|
||||
<span class="nav-text">{{ $t('header.paramDictionary') }}</span>
|
||||
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': paramDropdownVisible }"></i>
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item @click.native="goOtaManagement">
|
||||
{{ $t('header.otaManagement') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="goParamManagement">
|
||||
{{ $t('header.paramManagement') }}
|
||||
</el-dropdown-item>
|
||||
@@ -91,37 +116,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 语言切换下拉菜单 -->
|
||||
<el-dropdown trigger="click" class="language-dropdown" @visible-change="handleLanguageDropdownVisibleChange">
|
||||
<span class="el-dropdown-link">
|
||||
<span class="current-language-text">{{ currentLanguageText }}</span>
|
||||
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': languageDropdownVisible }"></i>
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item @click.native="changeLanguage('zh_CN')">
|
||||
{{ $t('language.zhCN') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="changeLanguage('zh_TW')">
|
||||
{{ $t('language.zhTW') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="changeLanguage('en')">
|
||||
{{ $t('language.en') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
|
||||
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" />
|
||||
<el-dropdown trigger="click" class="user-dropdown" @visible-change="handleUserDropdownVisibleChange">
|
||||
<span class="el-dropdown-link">
|
||||
{{ userInfo.username || '加载中...' }}
|
||||
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': userDropdownVisible }"></i>
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item @click.native="showChangePasswordDialog">{{ $t('header.changePassword')
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="handleLogout">{{ $t('header.logout') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" @click="handleAvatarClick" />
|
||||
<span class="el-dropdown-link" @click="handleAvatarClick">
|
||||
{{ userInfo.username || '加载中...' }}
|
||||
<i class="el-icon-arrow-down el-icon--right"></i>
|
||||
</span>
|
||||
<el-cascader :options="userMenuOptions" trigger="click" :props="cascaderProps"
|
||||
style="width: 0px;overflow: hidden;" :show-all-levels="false" @change="handleCascaderChange"
|
||||
ref="userCascader">
|
||||
<template slot-scope="{ data }">
|
||||
<span>{{ data.label }}</span>
|
||||
</template>
|
||||
</el-cascader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,15 +156,21 @@ export default {
|
||||
mobile: ''
|
||||
},
|
||||
isChangePasswordDialogVisible: false, // 控制修改密码弹窗的显示
|
||||
userDropdownVisible: false,
|
||||
paramDropdownVisible: false,
|
||||
languageDropdownVisible: false,
|
||||
voiceCloneDropdownVisible: false,
|
||||
isSmallScreen: false,
|
||||
// 搜索历史相关
|
||||
searchHistory: [],
|
||||
showHistory: false,
|
||||
SEARCH_HISTORY_KEY: 'xiaozhi_search_history',
|
||||
MAX_HISTORY_COUNT: 3
|
||||
MAX_HISTORY_COUNT: 3,
|
||||
// Cascader 配置
|
||||
cascaderProps: {
|
||||
expandTrigger: 'click',
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children'
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -183,6 +195,37 @@ export default {
|
||||
default:
|
||||
return this.$t('language.zhCN');
|
||||
}
|
||||
},
|
||||
// 用户菜单选项
|
||||
userMenuOptions() {
|
||||
return [
|
||||
{
|
||||
label: this.currentLanguageText,
|
||||
value: 'language',
|
||||
children: [
|
||||
{
|
||||
label: this.$t('language.zhCN'),
|
||||
value: 'zh_CN'
|
||||
},
|
||||
{
|
||||
label: this.$t('language.zhTW'),
|
||||
value: 'zh_TW'
|
||||
},
|
||||
{
|
||||
label: this.$t('language.en'),
|
||||
value: 'en'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: this.$t('header.changePassword'),
|
||||
value: 'changePassword'
|
||||
},
|
||||
{
|
||||
label: this.$t('header.logout'),
|
||||
value: 'logout'
|
||||
}
|
||||
];
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -207,6 +250,9 @@ export default {
|
||||
goModelConfig() {
|
||||
this.$router.push('/model-config')
|
||||
},
|
||||
goVoiceCloneManagement() {
|
||||
this.$router.push('/voice-clone-management')
|
||||
},
|
||||
goParamManagement() {
|
||||
this.$router.push('/params-management')
|
||||
},
|
||||
@@ -222,6 +268,11 @@ export default {
|
||||
goServerSideManagement() {
|
||||
this.$router.push('/server-side-management')
|
||||
},
|
||||
|
||||
// 跳转到音色资源管理
|
||||
goVoiceResourceManagement() {
|
||||
this.$router.push('/voice-resource-management')
|
||||
},
|
||||
// 添加默认角色模板管理导航方法
|
||||
goAgentTemplateManagement() {
|
||||
this.$router.push('/agent-template-management')
|
||||
@@ -364,27 +415,55 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
handleUserDropdownVisibleChange(visible) {
|
||||
this.userDropdownVisible = visible;
|
||||
},
|
||||
// 监听第二个下拉菜单的可见状态变化
|
||||
// 监听参数字典下拉菜单的可见状态变化
|
||||
handleParamDropdownVisibleChange(visible) {
|
||||
this.paramDropdownVisible = visible;
|
||||
},
|
||||
// 监听语言下拉菜单的可见状态变化
|
||||
handleLanguageDropdownVisibleChange(visible) {
|
||||
this.languageDropdownVisible = visible;
|
||||
|
||||
// 监听音色克隆下拉菜单的可见状态变化
|
||||
handleVoiceCloneDropdownVisibleChange(visible) {
|
||||
this.voiceCloneDropdownVisible = visible;
|
||||
},
|
||||
// 处理 Cascader 选择变化
|
||||
handleCascaderChange(value) {
|
||||
if (!value || value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = value[value.length - 1];
|
||||
|
||||
// 处理语言切换
|
||||
if (value.length === 2 && value[0] === 'language') {
|
||||
this.changeLanguage(action);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理其他操作
|
||||
switch (action) {
|
||||
case 'changePassword':
|
||||
this.showChangePasswordDialog();
|
||||
break;
|
||||
case 'logout':
|
||||
this.handleLogout();
|
||||
break;
|
||||
}
|
||||
},
|
||||
// 切换语言
|
||||
changeLanguage(lang) {
|
||||
changeLanguage(lang);
|
||||
this.languageDropdownVisible = false;
|
||||
this.$message.success({
|
||||
message: this.$t('message.success'),
|
||||
showClose: true
|
||||
});
|
||||
},
|
||||
|
||||
// 点击头像触发cascader下拉菜单
|
||||
handleAvatarClick() {
|
||||
if (this.$refs.userCascader) {
|
||||
this.$refs.userCascader.toggleDropDownVisible();
|
||||
}
|
||||
},
|
||||
|
||||
// 使用 mapActions 引入 Vuex 的 logout action
|
||||
...mapActions(['logout'])
|
||||
}
|
||||
@@ -430,7 +509,7 @@ export default {
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
position: absolute;
|
||||
left: 44%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@@ -542,6 +621,13 @@ export default {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.more-dropdown .el-dropdown-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
|
||||
.search-history-item:hover .clear-item-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -579,41 +665,7 @@ export default {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
flex-shrink: 0;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.current-language-text {
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
color: #3d4566;
|
||||
}
|
||||
|
||||
.more-dropdown {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.more-dropdown .el-dropdown-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.rotate-down {
|
||||
transform: rotate(180deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.el-icon-arrow-down {
|
||||
transition: transform 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 导航文本样式 - 支持中英文换行 */
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
<template>
|
||||
<el-dialog :title="$t('voiceClone.dialogTitle')" :visible.sync="visible" width="900px" top="10vh"
|
||||
:before-close="handleClose" class="voice-clone-dialog">
|
||||
<div class="dialog-content">
|
||||
<!-- 步骤指示器 -->
|
||||
<div class="steps-header">
|
||||
<div class="step-item" :class="{ 'active': currentStep === 1, 'completed': currentStep > 1 }">
|
||||
<div class="step-number">
|
||||
<i class="el-icon-check" v-if="currentStep > 1"></i>
|
||||
<span v-else>1</span>
|
||||
</div>
|
||||
<div class="step-label">{{ $t('voiceClone.stepUpload') }}</div>
|
||||
<div class="step-line"></div>
|
||||
</div>
|
||||
<div class="step-item" :class="{ 'active': currentStep === 2, 'completed': currentStep > 2 }">
|
||||
<div class="step-number">
|
||||
<i class="el-icon-check" v-if="currentStep > 2"></i>
|
||||
<span v-else>2</span>
|
||||
</div>
|
||||
<div class="step-label">{{ $t('voiceClone.stepEdit') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤1: 音频上传 -->
|
||||
<div v-if="currentStep === 1" class="step-content">
|
||||
<div class="upload-area">
|
||||
<el-upload class="audio-uploader" drag :action="uploadAction" :auto-upload="false"
|
||||
:on-change="handleFileChange" :show-file-list="false" accept="audio/*">
|
||||
<i class="el-icon-upload"></i>
|
||||
<div class="el-upload__text">{{ $t('voiceClone.dragOrClick') }}</div>
|
||||
<div class="el-upload__tip">{{ $t('voiceClone.uploadTip') }}</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 音频编辑 -->
|
||||
<div v-if="currentStep === 2" class="step-content">
|
||||
<div class="audio-edit-area">
|
||||
<div class="edit-tips">
|
||||
<p>{{ $t('voiceClone.editTip1') }}</p>
|
||||
<p>{{ $t('voiceClone.editTip2') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 波形显示区域 -->
|
||||
<div class="waveform-container">
|
||||
<canvas ref="waveformCanvas" class="waveform-canvas" @mousedown="handleWaveformMouseDown"
|
||||
@mousemove="handleWaveformMouseMove" @mouseup="handleWaveformMouseUp"></canvas>
|
||||
<div class="selection-overlay" v-if="isSelecting || selectionStart !== null"
|
||||
:style="selectionStyle"></div>
|
||||
<div class="duration-display">
|
||||
{{ $t('voiceClone.selectedDuration', { duration: selectedDuration.toFixed(1) }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音频控制按钮 -->
|
||||
<div class="audio-controls">
|
||||
<el-button size="small" :icon="isPlaying ? 'el-icon-video-pause' : 'el-icon-video-play'"
|
||||
@click="togglePlay" type="primary">
|
||||
{{ isPlaying ? $t('voiceClone.pause') : $t('voiceClone.play') }}
|
||||
</el-button>
|
||||
<el-button size="small" icon="el-icon-scissors" @click="handleTrim"
|
||||
:disabled="selectionStart === null">
|
||||
{{ $t('voiceClone.trim') }}
|
||||
</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh-left" @click="handleReset">
|
||||
{{ $t('voiceClone.reset') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 音频元素 -->
|
||||
<audio ref="audioPlayer" @timeupdate="handleTimeUpdate" @ended="handleAudioEnded"
|
||||
style="display: none;"></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="handleClose">{{ currentStep === 1 ? $t('voiceClone.cancel') : $t('voiceClone.prevStep')
|
||||
}}</el-button>
|
||||
<el-button type="primary" @click="handleNext" :loading="uploading">
|
||||
{{ currentStep === 1 ? $t('voiceClone.nextStep') : $t('voiceClone.upload') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from "@/apis/api";
|
||||
|
||||
export default {
|
||||
name: 'VoiceCloneDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
voiceCloneData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentStep: 1,
|
||||
uploadAction: '',
|
||||
audioFile: null,
|
||||
originalAudioFile: null,
|
||||
audioBuffer: null,
|
||||
originalAudioBuffer: null,
|
||||
isPlaying: false,
|
||||
uploading: false,
|
||||
// 波形相关
|
||||
waveformData: [],
|
||||
// 选择相关
|
||||
isSelecting: false,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
mouseStartX: 0,
|
||||
// 音频上下文
|
||||
audioContext: null,
|
||||
audioSource: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectedDuration() {
|
||||
if (this.selectionStart !== null && this.selectionEnd !== null && this.audioBuffer) {
|
||||
const duration = this.audioBuffer.duration;
|
||||
const start = Math.min(this.selectionStart, this.selectionEnd) * duration;
|
||||
const end = Math.max(this.selectionStart, this.selectionEnd) * duration;
|
||||
return end - start;
|
||||
}
|
||||
return this.audioBuffer ? this.audioBuffer.duration : 0;
|
||||
},
|
||||
selectionStyle() {
|
||||
if (this.selectionStart === null) return {};
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
if (!canvas) return {};
|
||||
|
||||
const start = Math.min(this.selectionStart, this.selectionEnd || this.selectionStart);
|
||||
const end = Math.max(this.selectionStart, this.selectionEnd || this.selectionStart);
|
||||
|
||||
return {
|
||||
left: `${start * 100}%`,
|
||||
width: `${(end - start) * 100}%`
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
if (this.currentStep === 2) {
|
||||
this.currentStep = 1;
|
||||
} else {
|
||||
this.resetDialog();
|
||||
this.$emit('update:visible', false);
|
||||
}
|
||||
},
|
||||
resetDialog() {
|
||||
this.currentStep = 1;
|
||||
this.audioFile = null;
|
||||
this.originalAudioFile = null;
|
||||
this.audioBuffer = null;
|
||||
this.originalAudioBuffer = null;
|
||||
this.isPlaying = false;
|
||||
this.uploading = false;
|
||||
this.waveformData = [];
|
||||
this.selectionStart = null;
|
||||
this.selectionEnd = null;
|
||||
if (this.audioSource) {
|
||||
this.audioSource.stop();
|
||||
this.audioSource = null;
|
||||
}
|
||||
},
|
||||
async handleFileChange(file) {
|
||||
if (!file || !file.raw) return;
|
||||
|
||||
this.audioFile = file.raw;
|
||||
this.originalAudioFile = file.raw;
|
||||
|
||||
// 先进入第二步,确保DOM已渲染
|
||||
this.currentStep = 2;
|
||||
|
||||
// 等待DOM更新后再加载音频
|
||||
await this.$nextTick();
|
||||
await this.loadAudio(file.raw);
|
||||
},
|
||||
async loadAudio(file) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
this.audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer.slice(0));
|
||||
this.originalAudioBuffer = await this.audioContext.decodeAudioData(await file.arrayBuffer());
|
||||
|
||||
// 设置音频播放器
|
||||
if (this.$refs.audioPlayer) {
|
||||
const audioUrl = URL.createObjectURL(file);
|
||||
this.$refs.audioPlayer.src = audioUrl;
|
||||
// 加载音频元数据
|
||||
this.$refs.audioPlayer.load();
|
||||
}
|
||||
|
||||
// 生成波形数据
|
||||
await this.generateWaveform();
|
||||
} catch (error) {
|
||||
console.error('加载音频失败:', error);
|
||||
this.$message.error(this.$t('voiceClone.loadAudioFailed'));
|
||||
}
|
||||
},
|
||||
async generateWaveform() {
|
||||
if (!this.audioBuffer) return;
|
||||
|
||||
await this.$nextTick();
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
if (!canvas) {
|
||||
console.error('Canvas元素不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置canvas大小
|
||||
const containerWidth = canvas.parentElement.offsetWidth;
|
||||
const containerHeight = canvas.parentElement.offsetHeight;
|
||||
canvas.width = containerWidth || 800;
|
||||
canvas.height = containerHeight || 200;
|
||||
|
||||
const canvasWidth = canvas.width;
|
||||
const channelData = this.audioBuffer.getChannelData(0);
|
||||
const step = Math.floor(channelData.length / canvasWidth);
|
||||
const waveformData = [];
|
||||
|
||||
for (let i = 0; i < canvasWidth; i++) {
|
||||
let sum = 0;
|
||||
for (let j = 0; j < step; j++) {
|
||||
sum += Math.abs(channelData[i * step + j] || 0);
|
||||
}
|
||||
waveformData.push(sum / step);
|
||||
}
|
||||
|
||||
this.waveformData = waveformData;
|
||||
this.drawWaveform();
|
||||
},
|
||||
drawWaveform() {
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
if (!canvas) {
|
||||
console.error('绘制波形时Canvas不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 绘制背景
|
||||
ctx.fillStyle = '#e0f2ff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
if (this.waveformData.length === 0) {
|
||||
console.error('波形数据为空');
|
||||
return;
|
||||
}
|
||||
|
||||
// 找到最大值用于归一化
|
||||
const maxValue = Math.max(...this.waveformData);
|
||||
|
||||
// 绘制波形
|
||||
ctx.fillStyle = '#4ade80';
|
||||
ctx.strokeStyle = '#4ade80';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const barWidth = width / this.waveformData.length;
|
||||
|
||||
this.waveformData.forEach((value, index) => {
|
||||
// 归一化并放大,使用80%的高度
|
||||
const normalizedValue = maxValue > 0 ? value / maxValue : 0;
|
||||
const barHeight = Math.max(1, normalizedValue * height * 0.8);
|
||||
const x = index * barWidth;
|
||||
const y = (height - barHeight) / 2;
|
||||
|
||||
ctx.fillRect(x, y, Math.max(1, barWidth - 1), barHeight);
|
||||
});
|
||||
},
|
||||
handleWaveformMouseDown(e) {
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
this.mouseStartX = e.clientX - rect.left;
|
||||
this.selectionStart = this.mouseStartX / rect.width;
|
||||
this.selectionEnd = this.selectionStart;
|
||||
this.isSelecting = true;
|
||||
},
|
||||
handleWaveformMouseMove(e) {
|
||||
if (!this.isSelecting) return;
|
||||
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
this.selectionEnd = Math.max(0, Math.min(1, x / rect.width));
|
||||
},
|
||||
handleWaveformMouseUp() {
|
||||
this.isSelecting = false;
|
||||
},
|
||||
async handleTrim() {
|
||||
if (this.selectionStart === null || this.selectionEnd === null || !this.audioBuffer) return;
|
||||
|
||||
const start = Math.min(this.selectionStart, this.selectionEnd);
|
||||
const end = Math.max(this.selectionStart, this.selectionEnd);
|
||||
|
||||
// 创建新的音频buffer
|
||||
const duration = this.audioBuffer.duration;
|
||||
const startTime = start * duration;
|
||||
const endTime = end * duration;
|
||||
const startOffset = Math.floor(startTime * this.audioBuffer.sampleRate);
|
||||
const endOffset = Math.floor(endTime * this.audioBuffer.sampleRate);
|
||||
const newLength = endOffset - startOffset;
|
||||
|
||||
const newBuffer = this.audioContext.createBuffer(
|
||||
this.audioBuffer.numberOfChannels,
|
||||
newLength,
|
||||
this.audioBuffer.sampleRate
|
||||
);
|
||||
|
||||
for (let channel = 0; channel < this.audioBuffer.numberOfChannels; channel++) {
|
||||
const oldData = this.audioBuffer.getChannelData(channel);
|
||||
const newData = newBuffer.getChannelData(channel);
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
newData[i] = oldData[startOffset + i];
|
||||
}
|
||||
}
|
||||
|
||||
this.audioBuffer = newBuffer;
|
||||
|
||||
// 更新音频文件
|
||||
await this.bufferToFile(newBuffer);
|
||||
|
||||
// 重置选择
|
||||
this.selectionStart = null;
|
||||
this.selectionEnd = null;
|
||||
|
||||
// 重新生成波形
|
||||
this.generateWaveform();
|
||||
|
||||
this.$message.success(this.$t('voiceClone.trimSuccess'));
|
||||
},
|
||||
async handleReset() {
|
||||
if (!this.originalAudioFile) return;
|
||||
|
||||
this.audioFile = this.originalAudioFile;
|
||||
await this.loadAudio(this.originalAudioFile);
|
||||
this.selectionStart = null;
|
||||
this.selectionEnd = null;
|
||||
|
||||
this.$message.success(this.$t('voiceClone.resetSuccess'));
|
||||
},
|
||||
togglePlay() {
|
||||
const audio = this.$refs.audioPlayer;
|
||||
if (this.isPlaying) {
|
||||
audio.pause();
|
||||
this.isPlaying = false;
|
||||
} else {
|
||||
audio.play();
|
||||
this.isPlaying = true;
|
||||
}
|
||||
},
|
||||
handleTimeUpdate() {
|
||||
// 可以在这里更新播放进度
|
||||
},
|
||||
handleAudioEnded() {
|
||||
this.isPlaying = false;
|
||||
},
|
||||
async bufferToFile(buffer) {
|
||||
// 将AudioBuffer转换为WAV文件
|
||||
const wav = this.audioBufferToWav(buffer);
|
||||
const blob = new Blob([wav], { type: 'audio/wav' });
|
||||
this.audioFile = new File([blob], 'audio.wav', { type: 'audio/wav' });
|
||||
|
||||
// 更新播放器
|
||||
await this.$nextTick();
|
||||
if (this.$refs.audioPlayer) {
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
this.$refs.audioPlayer.src = audioUrl;
|
||||
}
|
||||
},
|
||||
audioBufferToWav(buffer) {
|
||||
const length = buffer.length * buffer.numberOfChannels * 2 + 44;
|
||||
const arrayBuffer = new ArrayBuffer(length);
|
||||
const view = new DataView(arrayBuffer);
|
||||
const channels = [];
|
||||
let offset = 0;
|
||||
let pos = 0;
|
||||
|
||||
// 写入WAV文件头
|
||||
const setUint16 = (data) => {
|
||||
view.setUint16(pos, data, true);
|
||||
pos += 2;
|
||||
};
|
||||
const setUint32 = (data) => {
|
||||
view.setUint32(pos, data, true);
|
||||
pos += 4;
|
||||
};
|
||||
|
||||
// "RIFF" chunk descriptor
|
||||
setUint32(0x46464952); // "RIFF"
|
||||
setUint32(length - 8); // file length - 8
|
||||
setUint32(0x45564157); // "WAVE"
|
||||
|
||||
// "fmt " sub-chunk
|
||||
setUint32(0x20746d66); // "fmt "
|
||||
setUint32(16); // SubChunk1Size = 16
|
||||
setUint16(1); // AudioFormat = 1 (PCM)
|
||||
setUint16(buffer.numberOfChannels);
|
||||
setUint32(buffer.sampleRate);
|
||||
setUint32(buffer.sampleRate * 2 * buffer.numberOfChannels); // byte rate
|
||||
setUint16(buffer.numberOfChannels * 2); // block align
|
||||
setUint16(16); // bits per sample
|
||||
|
||||
// "data" sub-chunk
|
||||
setUint32(0x61746164); // "data"
|
||||
setUint32(length - pos - 4); // SubChunk2Size
|
||||
|
||||
// 写入音频数据
|
||||
for (let i = 0; i < buffer.numberOfChannels; i++) {
|
||||
channels.push(buffer.getChannelData(i));
|
||||
}
|
||||
|
||||
while (pos < length) {
|
||||
for (let i = 0; i < buffer.numberOfChannels; i++) {
|
||||
let sample = Math.max(-1, Math.min(1, channels[i][offset]));
|
||||
sample = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
|
||||
view.setInt16(pos, sample, true);
|
||||
pos += 2;
|
||||
}
|
||||
offset++;
|
||||
}
|
||||
|
||||
return arrayBuffer;
|
||||
},
|
||||
async handleNext() {
|
||||
if (this.currentStep === 1) {
|
||||
// 验证是否已选择文件
|
||||
if (!this.audioFile) {
|
||||
this.$message.warning(this.$t('voiceClone.pleaseSelectAudio'));
|
||||
return;
|
||||
}
|
||||
this.currentStep = 2;
|
||||
} else {
|
||||
// 上传音频
|
||||
await this.uploadAudio();
|
||||
}
|
||||
},
|
||||
async uploadAudio() {
|
||||
if (!this.audioFile) {
|
||||
this.$message.warning(this.$t('voiceClone.pleaseSelectAudio'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证音频时长(8-60秒)
|
||||
if (this.audioBuffer) {
|
||||
const duration = this.audioBuffer.duration;
|
||||
if (duration < 8 || duration > 60) {
|
||||
this.$message.warning(this.$t('voiceClone.durationError'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('voiceFile', this.audioFile);
|
||||
formData.append('id', this.voiceCloneData.id);
|
||||
|
||||
await Api.voiceClone.uploadVoice(formData, (res) => {
|
||||
this.uploading = false;
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success(this.$t('voiceClone.uploadSuccess'));
|
||||
this.resetDialog();
|
||||
this.$emit('update:visible', false);
|
||||
this.$emit('success');
|
||||
} else {
|
||||
this.$message.error(res.msg || this.$t('voiceClone.uploadFailed'));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.uploading = false;
|
||||
console.error('上传音频失败:', error);
|
||||
this.$message.error(this.$t('voiceClone.uploadFailed'));
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 设置canvas大小
|
||||
this.$nextTick(() => {
|
||||
const canvas = this.$refs.waveformCanvas;
|
||||
if (canvas) {
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.voice-clone-dialog {
|
||||
::v-deep .el-dialog__body {
|
||||
padding: 20px 30px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.steps-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
.step-number {
|
||||
background: #6b8cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.completed {
|
||||
.step-number {
|
||||
background: #67c23a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
background: #67c23a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #e4e7ed;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
margin-left: 12px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
width: 200px;
|
||||
height: 2px;
|
||||
background: #e4e7ed;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.audio-uploader {
|
||||
::v-deep .el-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::v-deep .el-upload-dragger {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.el-icon-upload {
|
||||
font-size: 67px;
|
||||
color: #c0c4cc;
|
||||
margin: 0 0 16px;
|
||||
line-height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-upload__tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-edit-area {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.edit-tips {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #1890ff;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
|
||||
&:first-child {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.waveform-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.waveform-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.selection-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(107, 140, 255, 0.3);
|
||||
border: 1px solid #6b8cff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.duration-display {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audio-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" @close="handleClose"
|
||||
@open="handleOpen" width="500px">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item :label="$t('voiceClone.platformName')" prop="modelId">
|
||||
<el-select v-model="form.modelId" :placeholder="$t('voiceClone.platformNamePlaceholder')" filterable
|
||||
style="width: 100%" @change="handlePlatformChange">
|
||||
<el-option v-for="model in platformList" :key="model.id" :label="model.modelName" :value="model.id">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('voiceClone.voiceId')" prop="voiceIds">
|
||||
<el-select v-model="form.voiceIds" :placeholder="$t('voiceClone.voiceIdPlaceholder')" filterable multiple
|
||||
allow-create default-first-option style="width: 100%">
|
||||
<el-option v-for="voiceId in voiceIdList" :key="voiceId" :label="voiceId" :value="voiceId">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('voiceClone.userId')" prop="userId">
|
||||
<el-select v-model="form.userId" :placeholder="$t('voiceClone.userIdPlaceholder')" filterable remote
|
||||
:remote-method="remoteSearchUser" :loading="userLoading" style="width: 100%">
|
||||
<el-option v-for="user in userList" :key="user.userid" :label="user.mobile" :value="user.userid">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="handleCancel">{{ $t('voiceClone.operationCancelled') }}</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">{{ $t('voiceClone.addNew') }}</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from '@/apis/api';
|
||||
|
||||
export default {
|
||||
name: 'VoiceCloneDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
dialogVisible: this.visible,
|
||||
platformList: [],
|
||||
voiceIdList: [],
|
||||
userList: [],
|
||||
userLoading: false,
|
||||
rules: {
|
||||
modelId: [
|
||||
{ required: true, message: this.$t('voiceClone.platformNameRequired'), trigger: 'change' }
|
||||
],
|
||||
voiceIds: [
|
||||
{ required: true, message: this.$t('voiceClone.voiceIdRequired'), trigger: 'change' }
|
||||
],
|
||||
userId: [
|
||||
{ required: true, message: this.$t('voiceClone.userIdRequired'), trigger: 'change' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
visible(val) {
|
||||
this.dialogVisible = val;
|
||||
},
|
||||
dialogVisible(val) {
|
||||
this.$emit('update:visible', val);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.dialogVisible = false;
|
||||
this.$emit('cancel');
|
||||
},
|
||||
|
||||
handleCancel() {
|
||||
this.$refs.form.clearValidate();
|
||||
this.$emit('cancel');
|
||||
},
|
||||
|
||||
handleSubmit() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (valid) {
|
||||
this.$emit('submit', this.form);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleOpen() {
|
||||
// 对话框打开时加载平台列表
|
||||
this.fetchPlatformList();
|
||||
// 重置音色ID列表
|
||||
this.voiceIdList = [];
|
||||
},
|
||||
|
||||
handlePlatformChange(modelId) {
|
||||
// 清空音色ID选择
|
||||
this.form.voiceIds = [];
|
||||
},
|
||||
|
||||
// 获取TTS平台列表
|
||||
fetchPlatformList() {
|
||||
Api.voiceResource.getTtsPlatformList((res) => {
|
||||
if (res.data.code === 0) {
|
||||
this.platformList = res.data.data;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 远程搜索用户
|
||||
remoteSearchUser(query) {
|
||||
if (query !== '') {
|
||||
this.userLoading = true;
|
||||
const params = {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
mobile: query
|
||||
};
|
||||
Api.admin.getUserList(params, (res) => {
|
||||
this.userLoading = false;
|
||||
if (res.data.code === 0) {
|
||||
this.userList = res.data.data.list;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.userList = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .el-dialog {
|
||||
border-radius: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,10 @@ export default {
|
||||
// HeaderBar组件文本
|
||||
'header.smartManagement': 'Agents',
|
||||
'header.modelConfig': 'Models',
|
||||
'header.voiceCloneManagement': 'Voice Clone',
|
||||
'header.voiceResourceManagement': 'Voice Resource',
|
||||
'header.userManagement': 'Users',
|
||||
'header.otaManagement': 'OTA',
|
||||
'header.otaManagement': 'OTA Management',
|
||||
'header.paramDictionary': 'More',
|
||||
'header.paramManagement': 'Params Management',
|
||||
'header.dictManagement': 'Dict Management',
|
||||
@@ -1063,5 +1065,85 @@ export default {
|
||||
'sm2.invalidPublicKey': 'Invalid public key format',
|
||||
'sm2.encryptionError': 'Error occurred during encryption',
|
||||
'sm2.publicKeyRetry': 'Retrying to get public key...',
|
||||
'sm2.publicKeyRetryFailed': 'Public key retrieval retry failed'
|
||||
'sm2.publicKeyRetryFailed': 'Public key retrieval retry failed',
|
||||
|
||||
// 音色资源管理
|
||||
'voiceClone.title': 'Voice Clone',
|
||||
'voiceResource.title': 'Voice Resource',
|
||||
'voiceClone.platformName': 'Platform Name',
|
||||
'voiceClone.voiceId': 'Voice ID',
|
||||
'voiceClone.userId': 'Account Owner',
|
||||
'voiceClone.name': 'Voice Name',
|
||||
'voiceClone.clone': 'Clone Voice',
|
||||
'voiceClone.action': 'Action',
|
||||
'voiceClone.modelId': 'Model ID',
|
||||
'voiceClone.trainStatus': 'Training Status',
|
||||
'voiceClone.trainError': 'Training Error',
|
||||
'voiceClone.createdAt': 'Creation Time',
|
||||
'voiceClone.search': 'Search',
|
||||
'voiceClone.searchPlaceholder': 'Please enter voice name or voice ID',
|
||||
'voiceClone.addNew': 'Add',
|
||||
'voiceClone.delete': 'Delete',
|
||||
'voiceClone.selectAll': 'Select All',
|
||||
'voiceClone.deselectAll': 'Deselect All',
|
||||
'voiceClone.addVoiceClone': 'Add Voice Resource',
|
||||
'voiceClone.confirmDelete': 'Are you sure you want to delete the selected {count} voice resources?',
|
||||
'voiceClone.deleteSuccess': 'Successfully deleted {count} voice resources',
|
||||
'voiceClone.deleteFailed': 'Deletion failed',
|
||||
'voiceClone.addSuccess': 'Add successful',
|
||||
'voiceClone.addFailed': 'Add failed',
|
||||
'voiceClone.updateSuccess': 'Update successful',
|
||||
'voiceClone.updateFailed': 'Update failed',
|
||||
'voiceClone.selectFirst': 'Please select voice resources to delete first',
|
||||
'voiceClone.operationCancelled': 'Cancel',
|
||||
'voiceClone.operationClosed': 'Popup closed',
|
||||
'voiceClone.cloneSuccess': 'Clone successful',
|
||||
'voiceClone.cloneFailed': 'Clone failed',
|
||||
'voiceClone.confirmClone': 'Are you sure you want to clone this voice?',
|
||||
'voiceClone.onlySuccessCanClone': 'Only successfully trained voices can be cloned',
|
||||
'common.insufficient': 'Insufficient',
|
||||
'voiceClone.platformNameRequired': 'Please select platform name',
|
||||
'voiceClone.voiceIdRequired': 'Please select voice ID',
|
||||
'voiceClone.userIdRequired': 'Please select account owner',
|
||||
'voiceClone.platformNamePlaceholder': 'Please select platform name',
|
||||
'voiceClone.voiceIdPlaceholder': 'Please enter voice ID and press Enter',
|
||||
'voiceClone.userIdPlaceholder': 'Please enter keyword to select account owner',
|
||||
'voiceClone.waitingUpload': 'Waiting for upload',
|
||||
'voiceClone.waitingTraining': 'Waiting for clone',
|
||||
'voiceClone.training': 'Training',
|
||||
'voiceClone.trainSuccess': 'Training successful',
|
||||
'voiceClone.trainFailed': 'Training failed',
|
||||
'voiceClone.itemsPerPage': '{items} items per page',
|
||||
'voiceClone.firstPage': 'First Page',
|
||||
'voiceClone.prevPage': 'Previous Page',
|
||||
'voiceClone.nextPage': 'Next Page',
|
||||
'voiceClone.totalRecords': '{total} records in total',
|
||||
'voiceClone.noVoiceCloneAssigned': 'Your account has no voice resources assigned',
|
||||
'voiceClone.contactAdmin': 'Please contact administrator for voice resource assignment',
|
||||
'voiceClone.dialogTitle': 'Voice Clone',
|
||||
'voiceClone.stepUpload': 'Prepare Audio',
|
||||
'voiceClone.stepEdit': 'Audio Edit',
|
||||
'voiceClone.dragOrClick': 'Drag audio file here, or click to upload',
|
||||
'voiceClone.uploadTip': 'Support all mainstream audio formats, selected duration must be between 8-60 seconds',
|
||||
'voiceClone.editTip1': 'Please confirm if the uploaded audio is satisfactory',
|
||||
'voiceClone.editTip2': 'You can listen and trim the audio, if not satisfied you can go back to re-record or upload',
|
||||
'voiceClone.selectedDuration': 'Selected valid segment: {duration} seconds',
|
||||
'voiceClone.trim': 'Trim selected segment',
|
||||
'voiceClone.reset': 'Reset',
|
||||
'voiceClone.play': 'Play',
|
||||
'voiceClone.pause': 'Pause',
|
||||
'voiceClone.cancel': 'Cancel',
|
||||
'voiceClone.nextStep': 'Next',
|
||||
'voiceClone.prevStep': 'Previous',
|
||||
'voiceClone.upload': 'Upload Audio',
|
||||
'voiceClone.pleaseSelectAudio': 'Please select an audio file first',
|
||||
'voiceClone.durationError': 'Audio duration must be between 8-60 seconds',
|
||||
'voiceClone.loadAudioFailed': 'Failed to load audio',
|
||||
'voiceClone.trimSuccess': 'Trim successful',
|
||||
'voiceClone.resetSuccess': 'Reset successful',
|
||||
'voiceClone.uploadSuccess': 'Upload successful',
|
||||
'voiceClone.uploadFailed': 'Upload failed',
|
||||
'voiceClone.updateNameSuccess': 'Name updated successfully',
|
||||
'voiceClone.updateNameFailed': 'Failed to update name',
|
||||
'voiceClone.playFailed': 'Play failed',
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export default {
|
||||
|
||||
// HeaderBar组件文本
|
||||
'header.smartManagement': '智能体管理',
|
||||
'header.voiceCloneManagement': '音色克隆',
|
||||
'header.voiceResourceManagement': '音色资源',
|
||||
'header.modelConfig': '模型配置',
|
||||
'header.userManagement': '用户管理',
|
||||
'header.otaManagement': 'OTA管理',
|
||||
@@ -1064,5 +1066,85 @@ export default {
|
||||
'sm2.invalidPublicKey': '无效的公钥格式',
|
||||
'sm2.encryptionError': '加密过程中发生错误',
|
||||
'sm2.publicKeyRetry': '正在重试获取公钥...',
|
||||
'sm2.publicKeyRetryFailed': '公钥获取重试失败'
|
||||
'sm2.publicKeyRetryFailed': '公钥获取重试失败',
|
||||
|
||||
// 音色资源管理
|
||||
'voiceClone.title': '音色克隆',
|
||||
'voiceResource.title': '音色资源',
|
||||
'voiceClone.platformName': '平台名称',
|
||||
'voiceClone.voiceId': '声音ID',
|
||||
'voiceClone.userId': '归属账号',
|
||||
'voiceClone.name': '声音名称',
|
||||
'voiceClone.clone': '立即复刻',
|
||||
'voiceClone.modelId': '模型ID',
|
||||
'voiceClone.trainStatus': '训练状态',
|
||||
'voiceClone.trainError': '训练错误',
|
||||
'voiceClone.createdAt': '创建时间',
|
||||
'voiceClone.search': '搜索',
|
||||
'voiceClone.searchPlaceholder': '请输入声音名称或音色ID',
|
||||
'voiceClone.addNew': '新增',
|
||||
'voiceClone.delete': '删除',
|
||||
'voiceClone.selectAll': '全选',
|
||||
'voiceClone.deselectAll': '取消全选',
|
||||
'voiceClone.addVoiceClone': '新增音色资源',
|
||||
'voiceClone.confirmDelete': '确定要删除选中的 {count} 条音色资源吗?',
|
||||
'voiceClone.deleteSuccess': '成功删除 {count} 条音色资源',
|
||||
'voiceClone.deleteFailed': '删除失败',
|
||||
'voiceClone.addSuccess': '添加成功',
|
||||
'voiceClone.addFailed': '添加失败',
|
||||
'voiceClone.updateSuccess': '更新成功',
|
||||
'voiceClone.updateFailed': '更新失败',
|
||||
'voiceClone.selectFirst': '请先选择要删除的音色资源',
|
||||
'voiceClone.operationCancelled': '取消',
|
||||
'voiceClone.operationClosed': '已关闭弹窗',
|
||||
'voiceClone.action': '操作',
|
||||
'voiceClone.cloneSuccess': '复刻成功',
|
||||
'voiceClone.cloneFailed': '复刻失败',
|
||||
'voiceClone.confirmClone': '确定要复刻此音色吗?',
|
||||
'voiceClone.onlySuccessCanClone': '只有训练成功的音色才能复刻',
|
||||
'common.insufficient': '不足',
|
||||
'voiceClone.platformNameRequired': '请选择平台名称',
|
||||
'voiceClone.voiceIdRequired': '请选择音色ID',
|
||||
'voiceClone.userIdRequired': '请选择归属账号',
|
||||
'voiceClone.platformNamePlaceholder': '请选择平台名称',
|
||||
'voiceClone.voiceIdPlaceholder': '请输入音色ID并按回车',
|
||||
'voiceClone.userIdPlaceholder': '请输入关键词选择归属账号',
|
||||
'voiceClone.waitingUpload': '待上传',
|
||||
'voiceClone.waitingTraining': '待复刻',
|
||||
'voiceClone.training': '训练中',
|
||||
'voiceClone.trainSuccess': '训练成功',
|
||||
'voiceClone.trainFailed': '训练失败',
|
||||
'voiceClone.itemsPerPage': '每页 {items} 条',
|
||||
'voiceClone.firstPage': '首页',
|
||||
'voiceClone.prevPage': '上一页',
|
||||
'voiceClone.nextPage': '下一页',
|
||||
'voiceClone.totalRecords': '共 {total} 条',
|
||||
'voiceClone.noVoiceCloneAssigned': '您的账号暂无音色资源',
|
||||
'voiceClone.contactAdmin': '请联系管理员分配音色资源',
|
||||
'voiceClone.dialogTitle': '声音复刻',
|
||||
'voiceClone.stepUpload': '准备音频',
|
||||
'voiceClone.stepEdit': '音频编辑',
|
||||
'voiceClone.dragOrClick': '将音频文件拖到此处,或点击上传',
|
||||
'voiceClone.uploadTip': '支持所有主流音频格式,选区时长需要在8-60秒之间',
|
||||
'voiceClone.editTip1': '请确认上传音频是否满意',
|
||||
'voiceClone.editTip2': '您可以试听并裁剪音频,如果不满意可以返回重新录制或上传',
|
||||
'voiceClone.selectedDuration': '已选择有效片段:{duration}秒',
|
||||
'voiceClone.trim': '对选择区域进行裁剪',
|
||||
'voiceClone.reset': '重置',
|
||||
'voiceClone.play': '播放',
|
||||
'voiceClone.pause': '暂停',
|
||||
'voiceClone.cancel': '取消',
|
||||
'voiceClone.nextStep': '下一步',
|
||||
'voiceClone.prevStep': '上一步',
|
||||
'voiceClone.upload': '上传音频',
|
||||
'voiceClone.pleaseSelectAudio': '请先选择音频文件',
|
||||
'voiceClone.durationError': '音频时长必须在8-60秒之间',
|
||||
'voiceClone.loadAudioFailed': '加载音频失败',
|
||||
'voiceClone.trimSuccess': '裁剪成功',
|
||||
'voiceClone.resetSuccess': '重置成功',
|
||||
'voiceClone.uploadSuccess': '上传成功',
|
||||
'voiceClone.uploadFailed': '上传失败',
|
||||
'voiceClone.updateNameSuccess': '名称更新成功',
|
||||
'voiceClone.updateNameFailed': '名称更新失败',
|
||||
'voiceClone.playFailed': '播放失败',
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export default {
|
||||
'header.smartManagement': '智能體管理',
|
||||
'header.modelConfig': '模型配置',
|
||||
'header.userManagement': '用戶管理',
|
||||
'header.voiceCloneManagement': '音色克隆',
|
||||
'header.voiceResourceManagement': '音色資源',
|
||||
'header.otaManagement': 'OTA管理',
|
||||
'header.paramDictionary': '參數字典',
|
||||
'header.paramManagement': '參數管理',
|
||||
@@ -1065,5 +1067,85 @@ export default {
|
||||
'sm2.invalidPublicKey': '無效的公鑰格式',
|
||||
'sm2.encryptionError': '加密過程中發生錯誤',
|
||||
'sm2.publicKeyRetry': '正在重試獲取公鑰...',
|
||||
'sm2.publicKeyRetryFailed': '公鑰獲取重試失敗'
|
||||
'sm2.publicKeyRetryFailed': '公鑰獲取重試失敗',
|
||||
|
||||
// 音色資源管理
|
||||
'voiceClone.title': '音色克隆',
|
||||
'voiceResource.title': '音色資源',
|
||||
'voiceClone.platformName': '平台名稱',
|
||||
'voiceClone.voiceId': '聲音ID',
|
||||
'voiceClone.userId': '歸屬帳號',
|
||||
'voiceClone.name': '聲音名稱',
|
||||
'voiceClone.clone': '立即複刻',
|
||||
'voiceClone.action': '操作',
|
||||
'voiceClone.modelId': '模型ID',
|
||||
'voiceClone.trainStatus': '訓練狀態',
|
||||
'voiceClone.trainError': '訓練錯誤',
|
||||
'voiceClone.createdAt': '建立時間',
|
||||
'voiceClone.search': '搜尋',
|
||||
'voiceClone.searchPlaceholder': '請輸入聲音名稱或音色ID',
|
||||
'voiceClone.addNew': '新增',
|
||||
'voiceClone.delete': '刪除',
|
||||
'voiceClone.selectAll': '全選',
|
||||
'voiceClone.deselectAll': '取消全選',
|
||||
'voiceClone.addVoiceClone': '新增音色資源',
|
||||
'voiceClone.confirmDelete': '確定要刪除選中的 {count} 條音色資源嗎?',
|
||||
'voiceClone.deleteSuccess': '成功刪除 {count} 條音色資源',
|
||||
'voiceClone.deleteFailed': '刪除失敗',
|
||||
'voiceClone.addSuccess': '新增成功',
|
||||
'voiceClone.addFailed': '新增失敗',
|
||||
'voiceClone.updateSuccess': '更新成功',
|
||||
'voiceClone.updateFailed': '更新失敗',
|
||||
'voiceClone.selectFirst': '請先選擇要刪除的音色資源',
|
||||
'voiceClone.operationCancelled': '取消',
|
||||
'voiceClone.operationClosed': '已關閉彈窗',
|
||||
'voiceClone.cloneSuccess': '複刻成功',
|
||||
'voiceClone.cloneFailed': '複刻失敗',
|
||||
'voiceClone.confirmClone': '確定要複刻此音色嗎?',
|
||||
'voiceClone.onlySuccessCanClone': '只有訓練成功的音色才能複刻',
|
||||
'common.insufficient': '不足',
|
||||
'voiceClone.platformNameRequired': '請選擇平台名稱',
|
||||
'voiceClone.voiceIdRequired': '請選擇音色ID',
|
||||
'voiceClone.userIdRequired': '請選擇歸屬帳號',
|
||||
'voiceClone.platformNamePlaceholder': '請選擇平台名稱',
|
||||
'voiceClone.voiceIdPlaceholder': '請輸入音色ID並按回车',
|
||||
'voiceClone.userIdPlaceholder': '請輸入关键词選擇歸屬帳號',
|
||||
'voiceClone.waitingUpload': '待上傳',
|
||||
'voiceClone.waitingTraining': '待複刻',
|
||||
'voiceClone.training': '訓練中',
|
||||
'voiceClone.trainSuccess': '訓練成功',
|
||||
'voiceClone.trainFailed': '訓練失敗',
|
||||
'voiceClone.itemsPerPage': '每頁 {items} 條',
|
||||
'voiceClone.firstPage': '首頁',
|
||||
'voiceClone.prevPage': '上一頁',
|
||||
'voiceClone.nextPage': '下一頁',
|
||||
'voiceClone.totalRecords': '共 {total} 條',
|
||||
'voiceClone.noVoiceCloneAssigned': '您的帳號暂无音色資源',
|
||||
'voiceClone.contactAdmin': '請聯繫管理員分配音色資源',
|
||||
'voiceClone.dialogTitle': '聲音複刻',
|
||||
'voiceClone.stepUpload': '準備音頻',
|
||||
'voiceClone.stepEdit': '音頻編輯',
|
||||
'voiceClone.dragOrClick': '將音頻文件拖到此處,或點擊上傳',
|
||||
'voiceClone.uploadTip': '支持所有主流音頻格式,選區時長需要在8-60秒之間',
|
||||
'voiceClone.editTip1': '請確認上傳音頻是否滿意',
|
||||
'voiceClone.editTip2': '您可以試聽並裁剪音頻,如果不滿意可以返回重新錄製或上傳',
|
||||
'voiceClone.selectedDuration': '已選擇有效片段:{duration}秒',
|
||||
'voiceClone.trim': '對選擇區域進行裁剪',
|
||||
'voiceClone.reset': '重置',
|
||||
'voiceClone.play': '播放',
|
||||
'voiceClone.pause': '暫停',
|
||||
'voiceClone.cancel': '取消',
|
||||
'voiceClone.nextStep': '下一步',
|
||||
'voiceClone.prevStep': '上一步',
|
||||
'voiceClone.upload': '上傳音頻',
|
||||
'voiceClone.pleaseSelectAudio': '請先選擇音頻文件',
|
||||
'voiceClone.durationError': '音頻時長必須在8-60秒之間',
|
||||
'voiceClone.loadAudioFailed': '加載音頻失敗',
|
||||
'voiceClone.trimSuccess': '裁剪成功',
|
||||
'voiceClone.resetSuccess': '重置成功',
|
||||
'voiceClone.uploadSuccess': '上傳成功',
|
||||
'voiceClone.uploadFailed': '上傳失敗',
|
||||
'voiceClone.updateNameSuccess': '名稱更新成功',
|
||||
'voiceClone.updateNameFailed': '名稱更新失敗',
|
||||
'voiceClone.playFailed': '播放失敗',
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const routes = [
|
||||
return import('../views/roleConfig.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
path: '/voice-print',
|
||||
name: 'VoicePrint',
|
||||
component: function () {
|
||||
@@ -110,6 +110,28 @@ const routes = [
|
||||
title: 'OTA管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/voice-resource-management',
|
||||
name: 'VoiceResourceManagement',
|
||||
component: function () {
|
||||
return import('../views/VoiceResourceManagement.vue')
|
||||
},
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '音色资源开通'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/voice-clone-management',
|
||||
name: 'VoiceCloneManagement',
|
||||
component: function () {
|
||||
return import('../views/VoiceCloneManagement.vue')
|
||||
},
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '音色克隆管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/dict-management',
|
||||
name: 'DictManagement',
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<HeaderBar />
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">{{ $t('voiceClone.title') }}</h2>
|
||||
<div class="right-operations">
|
||||
<el-input :placeholder="$t('voiceClone.searchPlaceholder')" v-model="searchName" class="search-input"
|
||||
@keyup.enter.native="handleSearch" clearable />
|
||||
<el-button class="btn-search" @click="handleSearch">{{ $t('voiceClone.search') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<!-- 显示表格或空状态 -->
|
||||
<el-card class="params-card" shadow="never" v-if="total > 0">
|
||||
<el-table ref="paramsTable" :data="voiceCloneList" class="transparent-table" v-loading="loading"
|
||||
element-loading-text="Loading" element-loading-spinner="el-icon-loading"
|
||||
element-loading-background="rgba(255, 255, 255, 0.7)">
|
||||
<el-table-column :label="$t('voiceClone.voiceId')" prop="voiceId"
|
||||
align="center"></el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.name')" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-show="row.isEdit" v-model="row.name" size="mini" maxlength="64"
|
||||
show-word-limit @blur="onNameBlur(row)" @keyup.enter.native="onNameEnter(row)"
|
||||
ref="nameInput" />
|
||||
<span v-show="!row.isEdit" class="name-view">
|
||||
<i class="el-icon-edit" @click="handleEditName(row)"
|
||||
style="cursor: pointer;"></i>
|
||||
<span @click="handleEditName(row)">
|
||||
{{ row.name || '-' }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.trainStatus')" prop="trainStatus" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ getTrainStatusText(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.action')" align="center" width="180">
|
||||
<template slot-scope="scope">
|
||||
<el-button v-if="scope.row.hasVoice" size="mini" type="text"
|
||||
@click="handlePlay(scope.row)">
|
||||
{{ $t('voiceClone.play') }}
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" @click="handleUpload(scope.row)">
|
||||
{{ $t('voiceClone.upload') }}
|
||||
</el-button>
|
||||
<el-button v-if="scope.row.hasVoice" size="mini" type="text"
|
||||
@click="handleClone(scope.row)">
|
||||
{{ $t('voiceClone.clone') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table_bottom">
|
||||
<div class="ctrl_btn">
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||
<el-option v-for="item in pageSizeOptions" :key="item"
|
||||
:label="$t('voiceClone.itemsPerPage', { items: item })" :value="item">
|
||||
</el-option>
|
||||
</el-select>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
|
||||
{{ $t('voiceClone.firstPage') }}
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
|
||||
{{ $t('voiceClone.prevPage') }}
|
||||
</button>
|
||||
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
|
||||
:class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
|
||||
{{ $t('voiceClone.nextPage') }}
|
||||
</button>
|
||||
<span class="total-text">{{ $t('voiceClone.totalRecords', { total }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-else-if="!loading" class="empty-state-wrapper">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="el-icon-microphone" style="font-size: 48px;"></i>
|
||||
</div>
|
||||
<div class="empty-text">
|
||||
{{ $t('voiceClone.noVoiceCloneAssigned') }}
|
||||
</div>
|
||||
<div class="empty-desc">
|
||||
{{ $t('voiceClone.contactAdmin') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-footer>
|
||||
<version-footer />
|
||||
</el-footer>
|
||||
|
||||
<!-- 复刻弹框 -->
|
||||
<VoiceCloneDialog :visible.sync="cloneDialogVisible" :voiceCloneData="currentVoiceClone"
|
||||
@success="handleCloneSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from "@/apis/api";
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import VersionFooter from "@/components/VersionFooter.vue";
|
||||
import VoiceCloneDialog from "@/components/VoiceCloneDialog.vue";
|
||||
import { formatDate } from "@/utils/format";
|
||||
|
||||
export default {
|
||||
components: { HeaderBar, VersionFooter, VoiceCloneDialog },
|
||||
data() {
|
||||
return {
|
||||
searchName: "",
|
||||
loading: false,
|
||||
voiceCloneList: [],
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
total: 0,
|
||||
dialogVisible: false,
|
||||
cloneDialogVisible: false,
|
||||
currentVoiceClone: {},
|
||||
isAllSelected: false,
|
||||
voiceCloneForm: {
|
||||
modelId: "",
|
||||
voiceIds: [],
|
||||
userId: null
|
||||
}
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
|
||||
computed: {
|
||||
pageCount() {
|
||||
return Math.ceil(this.total / this.pageSize);
|
||||
},
|
||||
visiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 3;
|
||||
let start = Math.max(1, this.currentPage - 1);
|
||||
let end = Math.min(this.pageCount, start + maxVisible - 1);
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handlePageSizeChange(val) {
|
||||
this.pageSize = val;
|
||||
this.currentPage = 1;
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
fetchVoiceCloneList() {
|
||||
this.loading = true;
|
||||
const params = {
|
||||
pageNum: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
name: this.searchName || "",
|
||||
orderField: "create_date",
|
||||
order: "desc"
|
||||
};
|
||||
Api.voiceClone.getVoiceCloneList(params, (res) => {
|
||||
this.loading = false;
|
||||
res = res.data
|
||||
if (res.code === 0) {
|
||||
this.voiceCloneList = res.data.list;
|
||||
this.total = res.data.total || 0;
|
||||
} else {
|
||||
this.voiceCloneList = [];
|
||||
this.total = 0;
|
||||
this.$message.error({
|
||||
message: res?.data?.msg || this.$t('voiceClone.deleteFailed'),
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSearch() {
|
||||
this.currentPage = 1;
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
goFirst() {
|
||||
this.currentPage = 1;
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
goPrev() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.fetchVoiceCloneList();
|
||||
}
|
||||
},
|
||||
goNext() {
|
||||
if (this.currentPage < this.pageCount) {
|
||||
this.currentPage++;
|
||||
this.fetchVoiceCloneList();
|
||||
}
|
||||
},
|
||||
goToPage(page) {
|
||||
this.currentPage = page;
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
formatDate,
|
||||
getTrainStatusText(row) {
|
||||
if (!row.hasVoice) {
|
||||
return this.$t('voiceClone.waitingUpload');
|
||||
}
|
||||
switch (row.trainStatus) {
|
||||
case 0:
|
||||
return this.$t('voiceClone.waitingTraining');
|
||||
case 1:
|
||||
return this.$t('voiceClone.training');
|
||||
case 2:
|
||||
return this.$t('voiceClone.trainSuccess');
|
||||
case 3:
|
||||
return this.$t('voiceClone.trainFailed');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
// 处理复刻操作
|
||||
handleClone(row) {
|
||||
const params = {
|
||||
cloneId: row.id
|
||||
};
|
||||
Api.voiceClone.cloneAudio(params, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success(this.$t('message.success'));
|
||||
} else {
|
||||
this.$message.error(res.msg || this.$t('message.error'));
|
||||
}
|
||||
});
|
||||
},
|
||||
// 复刻成功后的回调
|
||||
handleCloneSuccess() {
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
// 进入编辑模式
|
||||
handleEditName(row) {
|
||||
this.$set(row, 'isEdit', true);
|
||||
this.$nextTick(() => {
|
||||
// 聚焦到输入框
|
||||
const input = this.$refs.nameInput;
|
||||
if (input) {
|
||||
// nameInput 可能是一个数组
|
||||
if (Array.isArray(input)) {
|
||||
const idx = this.voiceCloneList.indexOf(row);
|
||||
if (input[idx]) {
|
||||
input[idx].focus();
|
||||
}
|
||||
} else {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// 提交名称修改
|
||||
submitName(row) {
|
||||
// 防止重复提交
|
||||
if (row._submitting) {
|
||||
return;
|
||||
}
|
||||
row._submitting = true;
|
||||
|
||||
const params = {
|
||||
id: row.id,
|
||||
name: row.name
|
||||
};
|
||||
|
||||
Api.voiceClone.updateName(params, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success(this.$t('voiceClone.updateNameSuccess') || '名称更新成功');
|
||||
} else {
|
||||
this.$message.error(res.msg || this.$t('voiceClone.updateNameFailed') || '名称更新失败');
|
||||
// 失败时恢复原值
|
||||
this.fetchVoiceCloneList();
|
||||
}
|
||||
row._submitting = false;
|
||||
});
|
||||
},
|
||||
// 名称输入框:失焦时提交
|
||||
onNameBlur(row) {
|
||||
row.isEdit = false;
|
||||
setTimeout(() => {
|
||||
this.submitName(row);
|
||||
}, 100); // 延迟 100ms,避开 enter+blur 同时触发的窗口
|
||||
},
|
||||
// 名称输入框:按回车时提交
|
||||
onNameEnter(row) {
|
||||
row.isEdit = false;
|
||||
this.submitName(row);
|
||||
},
|
||||
// 播放音频
|
||||
handlePlay(row) {
|
||||
// 先获取音频下载ID
|
||||
Api.voiceClone.getAudioId(row.id, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
const uuid = res.data;
|
||||
// 使用获取到的uuid播放音频
|
||||
const audioUrl = Api.voiceClone.getPlayVoiceUrl(uuid);
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.play().catch(err => {
|
||||
console.error('播放失败:', err);
|
||||
this.$message.error(this.$t('voiceClone.playFailed') || '播放失败');
|
||||
});
|
||||
} else {
|
||||
this.$message.error(res.msg || this.$t('voiceClone.audioNotExist') || '音频不存在');
|
||||
}
|
||||
});
|
||||
},
|
||||
// 上传音频
|
||||
handleUpload(row) {
|
||||
this.currentVoiceClone = row;
|
||||
this.cloneDialogVisible = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.welcome {
|
||||
min-width: 900px;
|
||||
min-height: 506px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
background-size: cover;
|
||||
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
|
||||
-webkit-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
margin: 5px 22px;
|
||||
border-radius: 15px;
|
||||
min-height: calc(100vh - 24vh);
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
background: rgba(237, 242, 255, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.operation-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.right-operations {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: linear-gradient(135deg, #6b8cff, #a966ff);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
overflow: auto;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.params-card {
|
||||
background: white;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
|
||||
::v-deep .el-card__body {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.ctrl_btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.custom-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
.el-select {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.pagination-btn:first-child,
|
||||
.pagination-btn:nth-child(2),
|
||||
.pagination-btn:nth-last-child(2),
|
||||
.pagination-btn:nth-child(3) {
|
||||
min-width: 60px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #dee7ff;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #d7dce6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||
min-width: 28px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(245, 247, 250, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #5f70f3 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #5f70f3 !important;
|
||||
|
||||
&:hover {
|
||||
background: #6d7cf5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state-wrapper {
|
||||
margin-top: 20vh;
|
||||
}
|
||||
|
||||
.total-text {
|
||||
margin-left: 10px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
width: 100px;
|
||||
margin-right: 10px;
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #dee7ff;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.el-input__suffix) {
|
||||
right: 6px;
|
||||
width: 15px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
top: 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-input__suffix-inner) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-icon-arrow-up:before) {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 9px solid #606266;
|
||||
position: relative;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
background: white;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.el-table__body-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.el-table__header-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.el-table__header th {
|
||||
background: white !important;
|
||||
color: black;
|
||||
font-weight: 600;
|
||||
height: 40px;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.el-table__body tr {
|
||||
background-color: white;
|
||||
|
||||
td {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
padding: 8px 0;
|
||||
height: 40px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table__row:hover>td {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table .el-button--text) {
|
||||
color: #7079aa !important;
|
||||
}
|
||||
|
||||
:deep(.el-table .el-button--text:hover) {
|
||||
color: #5a64b5 !important;
|
||||
}
|
||||
|
||||
.name-view {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
i {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #5a64b5;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
&:hover {
|
||||
color: #5a64b5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
background-color: #eeeeee !important;
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner:hover) {
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background-color: #5f70f3 !important;
|
||||
border-color: #5f70f3 !important;
|
||||
}
|
||||
|
||||
:deep(.el-loading-mask) {
|
||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
:deep(.el-loading-spinner .path) {
|
||||
stroke: #6b8cff;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--table-max-height: calc(100vh - 40vh);
|
||||
max-height: var(--table-max-height);
|
||||
|
||||
.el-table__body-wrapper {
|
||||
max-height: calc(var(--table-max-height) - 40px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1144px) {
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
.el-table__body tr {
|
||||
td {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
&+tr {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,704 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<HeaderBar />
|
||||
|
||||
<div class="operation-bar">
|
||||
<h2 class="page-title">{{ $t('voiceResource.title') }}</h2>
|
||||
<div class="right-operations">
|
||||
<el-input :placeholder="$t('voiceClone.searchPlaceholder')" v-model="searchName" class="search-input"
|
||||
@keyup.enter.native="handleSearch" clearable />
|
||||
<el-button class="btn-search" @click="handleSearch">{{ $t('voiceClone.search') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<div class="content-panel">
|
||||
<div class="content-area">
|
||||
<el-card class="params-card" shadow="never">
|
||||
<el-table ref="paramsTable" :data="voiceCloneList" class="transparent-table" v-loading="loading"
|
||||
element-loading-text="Loading" element-loading-spinner="el-icon-loading"
|
||||
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||
:header-cell-class-name="headerCellClassName">
|
||||
<el-table-column :label="$t('modelConfig.select')" align="center" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.voiceId')" prop="voiceId"
|
||||
align="center"></el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.name')" prop="name"
|
||||
align="center"></el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.userId')" prop="userName"
|
||||
align="center"></el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.platformName')" prop="modelName"
|
||||
align="center"></el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.trainStatus')" prop="trainStatus" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ getTrainStatusText(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.createdAt')" prop="createdAt" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ formatDate(scope.row.createDate) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('voiceClone.action')" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="deleteVoiceClone(scope.row)">{{
|
||||
$t('voiceClone.delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table_bottom">
|
||||
<div class="ctrl_btn">
|
||||
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
|
||||
{{ isAllSelected ? $t('voiceClone.deselectAll') : $t('voiceClone.selectAll') }}
|
||||
</el-button>
|
||||
<el-button size="mini" type="success" @click="showAddDialog"
|
||||
style="background: #5bc98c;border: None;">{{
|
||||
$t('voiceClone.addNew') }}</el-button>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete"
|
||||
@click="deleteSelectedVoiceClones">{{
|
||||
$t('voiceClone.delete') }}</el-button>
|
||||
</div>
|
||||
<div class="custom-pagination">
|
||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||
<el-option v-for="item in pageSizeOptions" :key="item"
|
||||
:label="$t('voiceClone.itemsPerPage', { items: item })" :value="item">
|
||||
</el-option>
|
||||
</el-select>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
|
||||
{{ $t('voiceClone.firstPage') }}
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
|
||||
{{ $t('voiceClone.prevPage') }}
|
||||
</button>
|
||||
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
|
||||
:class="{ active: page === currentPage }" @click="goToPage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
|
||||
{{ $t('voiceClone.nextPage') }}
|
||||
</button>
|
||||
<span class="total-text">{{ $t('voiceClone.totalRecords', { total }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增音色资源对话框 -->
|
||||
<voice-clone-dialog :title="$t('voiceClone.addVoiceClone')" :visible.sync="dialogVisible" :form="voiceCloneForm"
|
||||
@submit="handleSubmit" @cancel="dialogVisible = false" />
|
||||
|
||||
<el-footer>
|
||||
<version-footer />
|
||||
</el-footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Api from "@/apis/api";
|
||||
import HeaderBar from "@/components/HeaderBar.vue";
|
||||
import VersionFooter from "@/components/VersionFooter.vue";
|
||||
import VoiceCloneDialog from "@/components/VoiceResourceDialog.vue";
|
||||
import { formatDate } from "@/utils/format";
|
||||
|
||||
export default {
|
||||
components: { HeaderBar, VoiceCloneDialog, VersionFooter },
|
||||
data() {
|
||||
return {
|
||||
searchName: "",
|
||||
loading: false,
|
||||
voiceCloneList: [],
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
total: 0,
|
||||
dialogVisible: false,
|
||||
isAllSelected: false,
|
||||
voiceCloneForm: {
|
||||
modelId: "",
|
||||
voiceIds: [],
|
||||
userId: null
|
||||
}
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
|
||||
computed: {
|
||||
pageCount() {
|
||||
return Math.ceil(this.total / this.pageSize);
|
||||
},
|
||||
visiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 3;
|
||||
let start = Math.max(1, this.currentPage - 1);
|
||||
let end = Math.min(this.pageCount, start + maxVisible - 1);
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handlePageSizeChange(val) {
|
||||
this.pageSize = val;
|
||||
this.currentPage = 1;
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
fetchVoiceCloneList() {
|
||||
this.loading = true;
|
||||
const params = {
|
||||
pageNum: this.currentPage,
|
||||
pageSize: this.pageSize,
|
||||
name: this.searchName || "",
|
||||
orderField: "create_date",
|
||||
order: "desc"
|
||||
};
|
||||
Api.voiceResource.getVoiceResourceList(params, (res) => {
|
||||
this.loading = false;
|
||||
res = res.data
|
||||
if (res.code === 0) {
|
||||
this.voiceCloneList = res.data.list.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
}));
|
||||
this.total = res.data.total || 0;
|
||||
} else {
|
||||
this.voiceCloneList = [];
|
||||
this.total = 0;
|
||||
this.$message.error({
|
||||
message: res?.data?.msg || this.$t('voiceClone.deleteFailed'),
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSearch() {
|
||||
this.currentPage = 1;
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
handleSelectAll() {
|
||||
this.isAllSelected = !this.isAllSelected;
|
||||
this.voiceCloneList.forEach(row => {
|
||||
row.selected = this.isAllSelected;
|
||||
});
|
||||
},
|
||||
showAddDialog() {
|
||||
this.voiceCloneForm = {
|
||||
modelId: "",
|
||||
voiceIds: [],
|
||||
userId: null
|
||||
};
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.voiceCloneForm) {
|
||||
this.$refs.voiceCloneForm.clearValidate();
|
||||
}
|
||||
});
|
||||
this.dialogVisible = true;
|
||||
},
|
||||
handleSubmit(formData) {
|
||||
Api.voiceResource.saveVoiceResource(formData, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success({
|
||||
message: this.$t('voiceClone.addSuccess'),
|
||||
showClose: true
|
||||
});
|
||||
this.dialogVisible = false;
|
||||
this.fetchVoiceCloneList();
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: res.msg || this.$t('voiceClone.addFailed'),
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
deleteSelectedVoiceClones() {
|
||||
const selectedRows = this.voiceCloneList.filter(row => row.selected);
|
||||
if (selectedRows.length === 0) {
|
||||
this.$message.warning({
|
||||
message: this.$t('voiceClone.selectFirst'),
|
||||
showClose: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.deleteVoiceClone(selectedRows);
|
||||
},
|
||||
deleteVoiceClone(row) {
|
||||
const items = Array.isArray(row) ? row : [row];
|
||||
|
||||
if (Array.isArray(row) && row.length === 0) {
|
||||
this.$message.warning({
|
||||
message: this.$t('voiceClone.selectFirst'),
|
||||
showClose: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const itemCount = items.length;
|
||||
this.$confirm(this.$t('voiceClone.confirmDelete', { count: itemCount }), 'Warning', {
|
||||
confirmButtonText: 'OK',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
distinguishCancelAndClose: true
|
||||
}).then(() => {
|
||||
const ids = items.map(item => item.id);
|
||||
if (ids.some(id => !id)) {
|
||||
this.$message.error({
|
||||
message: this.$t('voiceClone.deleteFailed'),
|
||||
showClose: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Api.voiceResource.deleteVoiceResource(ids, (res) => {
|
||||
res = res.data;
|
||||
if (res.code === 0) {
|
||||
this.$message.success({
|
||||
message: this.$t('voiceClone.deleteSuccess', { count: itemCount }),
|
||||
showClose: true
|
||||
});
|
||||
this.fetchVoiceCloneList();
|
||||
} else {
|
||||
this.$message.error({
|
||||
message: res.msg || this.$t('voiceClone.deleteFailed'),
|
||||
showClose: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch(action => {
|
||||
if (action === 'cancel') {
|
||||
this.$message({
|
||||
type: 'info',
|
||||
message: this.$t('voiceClone.operationCancelled'),
|
||||
duration: 1000
|
||||
});
|
||||
} else {
|
||||
this.$message({
|
||||
type: 'info',
|
||||
message: this.$t('voiceClone.operationClosed'),
|
||||
duration: 1000
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
headerCellClassName({ columnIndex }) {
|
||||
if (columnIndex === 0) {
|
||||
return "custom-selection-header";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
goFirst() {
|
||||
this.currentPage = 1;
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
goPrev() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.fetchVoiceCloneList();
|
||||
}
|
||||
},
|
||||
goNext() {
|
||||
if (this.currentPage < this.pageCount) {
|
||||
this.currentPage++;
|
||||
this.fetchVoiceCloneList();
|
||||
}
|
||||
},
|
||||
goToPage(page) {
|
||||
this.currentPage = page;
|
||||
this.fetchVoiceCloneList();
|
||||
},
|
||||
formatDate,
|
||||
getTrainStatusText(row) {
|
||||
if (!row.hasVoice) {
|
||||
return this.$t('voiceClone.waitingUpload');
|
||||
}
|
||||
switch (row.trainStatus) {
|
||||
case 0:
|
||||
return this.$t('voiceClone.waitingTraining');
|
||||
case 1:
|
||||
return this.$t('voiceClone.training');
|
||||
case 2:
|
||||
return this.$t('voiceClone.trainSuccess');
|
||||
case 3:
|
||||
return this.$t('voiceClone.trainFailed');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.welcome {
|
||||
min-width: 900px;
|
||||
min-height: 506px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
background-size: cover;
|
||||
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
|
||||
-webkit-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
margin: 5px 22px;
|
||||
border-radius: 15px;
|
||||
min-height: calc(100vh - 24vh);
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
background: rgba(237, 242, 255, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.operation-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.right-operations {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: linear-gradient(135deg, #6b8cff, #a966ff);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
overflow: auto;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.params-card {
|
||||
background: white;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
|
||||
::v-deep .el-card__body {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.ctrl_btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-left: 26px;
|
||||
|
||||
.el-button {
|
||||
min-width: 72px;
|
||||
height: 32px;
|
||||
padding: 7px 12px 7px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: #5f70f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
background: #fd5b63;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
.el-select {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.pagination-btn:first-child,
|
||||
.pagination-btn:nth-child(2),
|
||||
.pagination-btn:nth-last-child(2),
|
||||
.pagination-btn:nth-child(3) {
|
||||
min-width: 60px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #dee7ff;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #d7dce6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||
min-width: 28px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(245, 247, 250, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #5f70f3 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: #5f70f3 !important;
|
||||
|
||||
&:hover {
|
||||
background: #6d7cf5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.total-text {
|
||||
margin-left: 10px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-size-select {
|
||||
width: 100px;
|
||||
margin-right: 10px;
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #dee7ff;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.el-input__suffix) {
|
||||
right: 6px;
|
||||
width: 15px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
top: 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-input__suffix-inner) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-icon-arrow-up:before) {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 9px solid #606266;
|
||||
position: relative;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
background: white;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.el-table__body-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.el-table__header-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.el-table__header th {
|
||||
background: white !important;
|
||||
color: black;
|
||||
font-weight: 600;
|
||||
height: 40px;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.el-table__body tr {
|
||||
background-color: white;
|
||||
|
||||
td {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
padding: 8px 0;
|
||||
height: 40px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table__row:hover>td {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table .el-button--text) {
|
||||
color: #7079aa !important;
|
||||
}
|
||||
|
||||
:deep(.el-table .el-button--text:hover) {
|
||||
color: #5a64b5 !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
background-color: #eeeeee !important;
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner:hover) {
|
||||
border-color: #cccccc !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background-color: #5f70f3 !important;
|
||||
border-color: #5f70f3 !important;
|
||||
}
|
||||
|
||||
:deep(.el-loading-mask) {
|
||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
:deep(.el-loading-spinner .path) {
|
||||
stroke: #6b8cff;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--table-max-height: calc(100vh - 40vh);
|
||||
max-height: var(--table-max-height);
|
||||
|
||||
.el-table__body-wrapper {
|
||||
max-height: calc(var(--table-max-height) - 40px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1144px) {
|
||||
.table_bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
:deep(.transparent-table) {
|
||||
.el-table__body tr {
|
||||
td {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
&+tr {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user