feat: 合成语音
This commit is contained in:
parent
36fad9c1ca
commit
af2f9398b7
@ -11,4 +11,6 @@ VITE_API_URL = http://192.168.1.101:13080
|
|||||||
VITE_WS_URL = ws://192.168.1.101:13080/ws
|
VITE_WS_URL = ws://192.168.1.101:13080/ws
|
||||||
|
|
||||||
# explanation
|
# explanation
|
||||||
VITE_INSPECTION_TYPE = "inspection"
|
VITE_INSPECTION_TYPE = "inspection"
|
||||||
|
|
||||||
|
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"
|
||||||
@ -17,4 +17,6 @@ VITE_API_URL = http://192.168.28.10:13080
|
|||||||
VITE_WS_URL = ws://192.168.0.201:13080/ws
|
VITE_WS_URL = ws://192.168.0.201:13080/ws
|
||||||
|
|
||||||
# explanation
|
# explanation
|
||||||
VITE_INSPECTION_TYPE = "inspection"
|
VITE_INSPECTION_TYPE = "inspection"
|
||||||
|
|
||||||
|
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"
|
||||||
@ -19,6 +19,7 @@
|
|||||||
"@vueup/vue-quill": "1.2.0",
|
"@vueup/vue-quill": "1.2.0",
|
||||||
"@vueuse/core": "10.11.0",
|
"@vueuse/core": "10.11.0",
|
||||||
"ace-builds": "^1.43.0",
|
"ace-builds": "^1.43.0",
|
||||||
|
"autofit.js": "^3.2.8",
|
||||||
"axios": "0.28.1",
|
"axios": "0.28.1",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
|||||||
@ -43,4 +43,20 @@ export function regenerateApi(id) {
|
|||||||
url: `/tts/task/regenerate/${id}`,
|
url: `/tts/task/regenerate/${id}`,
|
||||||
method: 'post'
|
method: 'post'
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generalizationApi(data) {
|
||||||
|
return request({
|
||||||
|
url: '/tts/task/llm/query',
|
||||||
|
method: 'post',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchTaskApi(data) {
|
||||||
|
return request({
|
||||||
|
url: '/tts/task/batch',
|
||||||
|
method: 'post',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
#app {
|
#app {
|
||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
min-height: 100%;
|
// min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
transition: margin-left .28s;
|
transition: margin-left .28s;
|
||||||
margin-left: $base-sidebar-width;
|
margin-left: $base-sidebar-width;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
59
src/components/IPlayer/config.ts
Normal file
59
src/components/IPlayer/config.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
export const conf = {
|
||||||
|
/**
|
||||||
|
* 基础功能配置
|
||||||
|
* */
|
||||||
|
autoplay: false, // 是否自动播放,不自动播放,浏览器有限制规则
|
||||||
|
autoplayMuted: false, // 是否自动播放(静音播放)
|
||||||
|
videoInit: true, // 是否默认初始化video,默认初始化,默认true
|
||||||
|
playsinline: true, // 是否启用内联播放模式,仅移动端生效
|
||||||
|
defaultPlaybackRate: 1, // 默认播放速度(可选:0.5/0.75/1/1.5/2等)
|
||||||
|
volume: 0.72, // 播放音量(可选:0 ~ 1)
|
||||||
|
loop: false, // 是否循环播放,默认不循环播放
|
||||||
|
startTime: 0, // 点播模式下,初始起播时间
|
||||||
|
videoAttributes: {}, // video扩展属性,暂且不配置
|
||||||
|
lang: 'zh-cn', // 播放器初始显示语言,设置为中文
|
||||||
|
fluid: true, // 是否流式布局(宽高优先于流失布局,默认16:9)注掉上方宽高看效果
|
||||||
|
fitVideoSize: 'fixed', // 保持容器宽/高,不做适配,按照容器来
|
||||||
|
videoFillMode: 'auto', // 宽高不够自动底色填充(fill拉伸填充等...)
|
||||||
|
seekedStatus: 'play', // 跳转后继续播放
|
||||||
|
// 播放器进度条故事点信息数组
|
||||||
|
progressDot: [
|
||||||
|
{
|
||||||
|
id: 0, // 唯一标识,用于删除的时候索引
|
||||||
|
time: 30, // 展示的时间点,例子为在播放到10s钟的时候展示
|
||||||
|
text: '进度条信息提示...', // hover的时候展示文案,可以为空
|
||||||
|
duration: 1, // 展示时间跨度,单位为s
|
||||||
|
style: { // 指定样式
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
thumbnail: null, // 进度条预览图配置,普通业务用不到
|
||||||
|
marginControls: false, // 是否开启画面和控制栏分离模式,不开启空间多一些
|
||||||
|
domEventType: 'default', // 响应的事件类型,不用指定,用默认的即可
|
||||||
|
/**
|
||||||
|
* 交互功能配置(一般使用默认即可)
|
||||||
|
* */
|
||||||
|
/**
|
||||||
|
* 插件配置,根据需求自选
|
||||||
|
* */
|
||||||
|
icons: {}, // 使用默认的icon图标
|
||||||
|
i18n: [], // 使用默认的中文
|
||||||
|
// 自定义一些颜色
|
||||||
|
commonStyle: {
|
||||||
|
progressColor: 'green', // 整个进度条颜色
|
||||||
|
playedColor: 'chocolate', // 已播放的进度条颜色
|
||||||
|
volumeColor: 'pink', // 音量大小竖向滑块颜色
|
||||||
|
},
|
||||||
|
controls: true, // 是否使用底部控制栏,默认使用
|
||||||
|
miniprogress: true, // 是否使用mini进度条(当底部控制栏隐藏时生效)
|
||||||
|
screenShot: false, // 关闭截图功能
|
||||||
|
rotate: true, // 是否使用视频旋转插件,默认不使用
|
||||||
|
download: true, // 是否使用下载按钮,一般不用,一般自定义控制
|
||||||
|
pip: true, // 使用使用画中画模式,默认不用
|
||||||
|
mini: true, // 是否使用小屏幕控件
|
||||||
|
cssFullscreen: true, // 是否使用网页样式全屏按钮开关
|
||||||
|
playbackRate: [0.5, 1, 1.5, 2, 3], //传入倍速可选数组
|
||||||
|
// playbackRate: true, //false,禁用倍速播放(即控制栏不显示)
|
||||||
|
keyShortcut: false, // 是否开启快捷键模式
|
||||||
|
}
|
||||||
62
src/components/IPlayer/index.vue
Normal file
62
src/components/IPlayer/index.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="player_container">
|
||||||
|
<div ref="xgPlayerRef"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import Player, { Events } from 'xgplayer'; // 引入西瓜视频模块
|
||||||
|
import 'xgplayer/dist/index.min.css'; // 引入西瓜视频样式
|
||||||
|
|
||||||
|
const { videoUrl } = defineProps({
|
||||||
|
videoUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const xgPlayerRef = ref()
|
||||||
|
|
||||||
|
import { conf } from "./config"; // 配置文件单独拎出来一个js文件
|
||||||
|
|
||||||
|
onMounted(() => { init() })
|
||||||
|
|
||||||
|
let player = null // 实例
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
player = new Player({
|
||||||
|
el: xgPlayerRef.value,
|
||||||
|
// width: 600,
|
||||||
|
// height: 400, // 视频宽高尺寸
|
||||||
|
isLive: false,
|
||||||
|
url: videoUrl, // 视频源
|
||||||
|
// poster: "http://ashuai.work/static/img/avantar.png", // 视频封面
|
||||||
|
...conf,
|
||||||
|
});
|
||||||
|
player.on(Events.PLAY, (ev) => {
|
||||||
|
console.log('-播放开始-', ev);
|
||||||
|
})
|
||||||
|
player.on(Events.PAUSE, (ev) => {
|
||||||
|
console.log('-播放结束-', ev);
|
||||||
|
})
|
||||||
|
player.on('loadedmetadata', (ev) => {
|
||||||
|
console.log('-媒体数据加载好了-', ev);
|
||||||
|
})
|
||||||
|
player.on(Events.SEEKED, (ev) => {
|
||||||
|
console.log('-跳着播放-', ev);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.player_container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.xgplayer {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ function addIframe() {
|
|||||||
@import "@/assets/styles/variables.module";
|
@import "@/assets/styles/variables.module";
|
||||||
.app-main {
|
.app-main {
|
||||||
/* 50= navbar 50 */
|
/* 50= navbar 50 */
|
||||||
height: calc(100vh - 84px);
|
height: calc(100% - 84px);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -54,7 +54,7 @@ function addIframe() {
|
|||||||
.hasTagsView {
|
.hasTagsView {
|
||||||
.app-main {
|
.app-main {
|
||||||
/* 84 = navbar + tags-view = 50 + 34 */
|
/* 84 = navbar + tags-view = 50 + 34 */
|
||||||
height: calc(100vh - 84px);
|
height: calc(100% - 84px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixed-header + .app-main {
|
.fixed-header + .app-main {
|
||||||
|
|||||||
@ -1,19 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-card shadow="never" class="corpus-list">
|
<div class="corpus-list">
|
||||||
<!-- 工具栏 -->
|
<!-- 工具栏 -->
|
||||||
<div class="list-toolbar">
|
<div class="list-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<el-button type="primary" size="default">+新建语料</el-button>
|
<el-button type="primary" size="default" @click="openDialog(true)">+新建语料</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<el-tag
|
<el-tag v-for="tab in tabs" :key="tab.key" :type="activeTab === tab.key ? '' : 'info'"
|
||||||
v-for="tab in tabs"
|
:effect="activeTab === tab.key ? 'dark' : 'plain'" style="cursor:pointer;" @click="activeTab = tab.key">
|
||||||
:key="tab.key"
|
|
||||||
:type="activeTab === tab.key ? '' : 'info'"
|
|
||||||
:effect="activeTab === tab.key ? 'dark' : 'plain'"
|
|
||||||
style="cursor:pointer;"
|
|
||||||
@click="activeTab = tab.key"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
<span class="tab-count">{{ tab.count }}</span>
|
<span class="tab-count">{{ tab.count }}</span>
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@ -21,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 表格 -->
|
<!-- 表格 -->
|
||||||
<el-table :data="filteredData" stripe style="width:100%;" max-height="400">
|
<el-table :data="corpusData" stripe style="width:100%;" max-height="400">
|
||||||
<el-table-column prop="id" label="语料编号" width="180" />
|
<el-table-column prop="id" label="语料编号" width="180" />
|
||||||
<el-table-column prop="text" label="文本内容" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="text" label="文本内容" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="category" label="功能类别" width="150" show-overflow-tooltip />
|
<el-table-column prop="category" label="功能类别" width="150" show-overflow-tooltip />
|
||||||
@ -47,27 +41,151 @@
|
|||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div class="pagination-wrapper">
|
<div class="pagination-wrapper">
|
||||||
<span class="text-muted text-sm">共 {{ total }} 条</span>
|
<span class="text-muted text-sm">共 {{ total }} 条</span>
|
||||||
<el-pagination
|
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="10"
|
||||||
background
|
v-model:current-page="page" small />
|
||||||
layout="prev, pager, next, jumper"
|
|
||||||
:total="total"
|
|
||||||
:page-size="10"
|
|
||||||
v-model:current-page="page"
|
|
||||||
small
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="'新建语料'" :append-to-body="true" width="600px" @close="resetDialog">
|
||||||
|
<el-form ref="ruleFormRef" :model="ruleForm" label-width="auto">
|
||||||
|
<el-form-item label="模型" prop="model">
|
||||||
|
<el-select v-model="ruleForm.model" placeholder="模型">
|
||||||
|
<el-option label="科大讯飞" value="科大讯飞" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="文本" prop="text">
|
||||||
|
<el-input type="textarea" :rows="2" v-model="ruleForm.text" placeholder="请输入文本" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="语种" prop="language">
|
||||||
|
<el-select v-model="ruleForm.language" placeholder="语种">
|
||||||
|
<el-option v-for="item in langOption" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上传文件">
|
||||||
|
<el-upload
|
||||||
|
v-model:file-list="uploadFileList"
|
||||||
|
:action="`${data.baseUrl}/system/file/upload`"
|
||||||
|
:before-upload="beforeAvatarUpload"
|
||||||
|
:data="{fileType:uploadFileType}"
|
||||||
|
:headers="{
|
||||||
|
'authorization': getToken()
|
||||||
|
}"
|
||||||
|
:limit="1"
|
||||||
|
:on-error="uploadError"
|
||||||
|
:on-success="uploadSuccess"
|
||||||
|
class="file-uploader"
|
||||||
|
>
|
||||||
|
<el-button type="primary">选取文件</el-button>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
支持格式:'wav'
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="confirmDialog">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import {getToken} from "@/utils/auth.js";
|
||||||
|
|
||||||
const emit = defineEmits(['view-detail'])
|
const dialogVisible = ref(false)
|
||||||
|
|
||||||
|
const voiceOption = ref([
|
||||||
|
{
|
||||||
|
label: '女声',
|
||||||
|
value: 'xiaoyan'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const langOption = ref([
|
||||||
|
{
|
||||||
|
label: '中文(普通话)',
|
||||||
|
value: '普通话'
|
||||||
|
}, {
|
||||||
|
label: '英文',
|
||||||
|
value: '英文'
|
||||||
|
}, {
|
||||||
|
label: '粤语',
|
||||||
|
value: '粤语'
|
||||||
|
}, {
|
||||||
|
label: '四川话',
|
||||||
|
value: '四川话'
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const ruleForm = ref({
|
||||||
|
text: '',
|
||||||
|
model: '科大讯飞',
|
||||||
|
voice: 'xiaoyan',
|
||||||
|
speed: 50,
|
||||||
|
volume: 50,
|
||||||
|
pitch: 50,
|
||||||
|
sampleRate: '16',
|
||||||
|
outputFormat: 'wav'
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = ref({
|
||||||
|
text: [{ required: true, message: '请输入合成文本', trigger: 'blur' }],
|
||||||
|
language: [{ required: true, message: '请选择语种', trigger: 'blur' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadFileList = ref([])
|
||||||
|
const uploadFileType = ref(null)
|
||||||
|
const data = {
|
||||||
|
baseUrl: import.meta.env.VITE_APP_BASE_API
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetDialog = () => {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
const searchText = ref('')
|
|
||||||
const activeTab = ref('all')
|
const activeTab = ref('all')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
|
const beforeAvatarUpload = (rawFile) => {
|
||||||
|
const fileName = rawFile.name.split('.');
|
||||||
|
const fileExt = fileName[fileName.length - 1];
|
||||||
|
const isTypeOk = ['png', 'jpg', 'jpeg'].indexOf(fileExt) >= 0;
|
||||||
|
if (!isTypeOk) {
|
||||||
|
proxy.$modal.msgError(`文件格式不正确,请上传${['png', 'jpg', 'jpeg'].join("/")}格式文件!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const {label = undefined, value = undefined} = supportType?.find(item => item.label === rawFile.type)
|
||||||
|
if (label) {
|
||||||
|
uploadFileType.value = value
|
||||||
|
}
|
||||||
|
// 校检文件大小
|
||||||
|
|
||||||
|
const isLt = rawFile.size / 1024 / 1024 < 5;
|
||||||
|
if (!isLt) {
|
||||||
|
ElMessage.error(`上传文件大小不能超过 ${5} MB!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadSuccess = (e) => {
|
||||||
|
imgUrl.value = e.msg
|
||||||
|
ElMessage.success('上传成功')
|
||||||
|
}
|
||||||
|
const uploadError = (e) => {
|
||||||
|
console.log("uploadError", e)
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'all', label: '全部语料', count: '1,245,678' },
|
{ key: 'all', label: '全部语料', count: '1,245,678' },
|
||||||
{ key: 'pending', label: '待审核', count: '45,678' },
|
{ key: 'pending', label: '待审核', count: '45,678' },
|
||||||
@ -86,55 +204,40 @@ const corpusData = ref([
|
|||||||
{ id: 'CORP_20240517_0008', text: '关闭所有车窗', category: '控制指令-车窗控制', lang: '中文', dialect: '普通话', emotion: '中性', voice: '男声', status: '已通过', updateTime: '2024-05-17 10:06:20' }
|
{ id: 'CORP_20240517_0008', text: '关闭所有车窗', category: '控制指令-车窗控制', lang: '中文', dialect: '普通话', emotion: '中性', voice: '男声', status: '已通过', updateTime: '2024-05-17 10:06:20' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const filteredData = computed(() => {
|
const total = computed(() => corpusData.value.length)
|
||||||
let list = corpusData.value
|
|
||||||
if (activeTab.value === 'pending') {
|
|
||||||
list = list.filter(item => item.status === '待审核')
|
|
||||||
} else if (activeTab.value === 'published') {
|
|
||||||
list = list.filter(item => item.status === '已通过')
|
|
||||||
}
|
|
||||||
if (searchText.value) {
|
|
||||||
const s = searchText.value.toLowerCase()
|
|
||||||
list = list.filter(item =>
|
|
||||||
item.id.toLowerCase().includes(s) ||
|
|
||||||
item.text.includes(s) ||
|
|
||||||
item.voice.includes(s)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
})
|
|
||||||
|
|
||||||
const total = computed(() => filteredData.value.length)
|
|
||||||
|
|
||||||
const statusType = (status) => {
|
const statusType = (status) => {
|
||||||
if (status === '已通过') return 'success'
|
if (status === '已通过') return 'success'
|
||||||
if (status === '待审核') return 'warning'
|
if (status === '待审核') return 'warning'
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDetail = (row) => {
|
|
||||||
emit('view-detail', row)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.corpus-list {
|
.corpus-list {
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
.list-toolbar {
|
.list-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
||||||
.toolbar-left {
|
.toolbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-right {
|
.toolbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.tab-count {
|
.tab-count {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@ -142,13 +245,20 @@ const handleDetail = (row) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-wrapper {
|
.pagination-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
.text-muted { color: #94a3b8; }
|
|
||||||
.text-sm { font-size: 13px; }
|
.text-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -1,20 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-workspace">
|
<div class="main-workspace">
|
||||||
<div class="workspace-container">
|
<div class="workspace-container">
|
||||||
<!-- 左栏:文本与参数配置 -->
|
<div class="left">
|
||||||
<div class="left">
|
<div class="title">泛化语料</div>
|
||||||
|
<el-form ref="generalizationFormRef" :rules="generalizationRules" :model="generalizationForm" label-position="top" label-width="auto">
|
||||||
|
<el-row>
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="" prop="text">
|
||||||
|
<el-input type="textarea" :rows="2" v-model="generalizationForm.text" placeholder="请输入待合成文本" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="语种" prop="language">
|
||||||
|
<el-select v-model="generalizationForm.language" placeholder="语种">
|
||||||
|
<el-option v-for="item in languageOption" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="数量" prop="count">
|
||||||
|
<el-input-number v-model="generalizationForm.count" :min="1" :max="10" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form>
|
||||||
|
<el-row style="margin-top: 10px;" :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-button :loading="generalizationLoading" type="primary" :icon="VideoPlay" class="btn"
|
||||||
|
@click="generalization">开始泛化</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-button size="small" type="primary" plain :icon="Delete" class="btn"
|
||||||
|
@click="generalizationCancel">清空</el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div v-if="corpusList.length > 0" >
|
||||||
|
<el-row style="margin: 10px 0;">
|
||||||
|
<el-col :span="24">泛化语料列表</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div class="corpus-container">
|
||||||
|
<el-row :gutter="20" v-for="(item, index) in corpusList" style="margin-bottom: 4px;">
|
||||||
|
<el-col :span="22" >
|
||||||
|
<el-input v-model="corpusList[index]"></el-input>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="2">
|
||||||
|
<el-button icon="Plus" @click="addTag(item)"></el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
文本与参数配置
|
文本与参数配置
|
||||||
</div>
|
</div>
|
||||||
<el-form ref="formRef" :rules="rules" :model="form" label-position="top" label-width="auto">
|
<el-form ref="formRef" :rules="rules" :model="form" label-position="top" label-width="auto">
|
||||||
<el-row>
|
<el-row>
|
||||||
<el-col :span="24">
|
<el-form-item label="待合成语料" prop="tags">
|
||||||
<el-form-item label="" prop="text">
|
<el-tag v-for="tag in form.tags" :key="tag.name" closable @close="handleClose(tag)" class="tag">
|
||||||
<el-input type="textarea" :rows="3" v-model="form.text" placeholder="请输入待合成文本" />
|
{{ tag }}
|
||||||
</el-form-item>
|
</el-tag>
|
||||||
</el-col>
|
</el-form-item>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="模型" prop="voice">
|
||||||
|
<el-select v-model="form.model" placeholder="模型">
|
||||||
|
<el-option label="科大讯飞" value="科大讯飞" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="音色" prop="voice">
|
<el-form-item label="音色" prop="voice">
|
||||||
<el-select v-model="form.voice" placeholder="音色">
|
<el-select v-model="form.voice" placeholder="音色">
|
||||||
@ -29,9 +86,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="语速" prop="speed">
|
<el-form-item label="语速" prop="speed">
|
||||||
<el-select v-model="form.speed" placeholder="语速">
|
<el-slider v-model="form.speed" :min="1" :max="100" :step="1" />
|
||||||
<el-slider v-model="form.speed" :min="1" :max="100" :step="1" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
@ -41,9 +96,9 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="采样率">
|
<el-form-item label="采样率">
|
||||||
<el-select v-model="form.auf" placeholder="采样率">
|
<el-select v-model="form.sampleRate" placeholder="采样率">
|
||||||
<el-option label="8kHz" value="8" />
|
<el-option label="8kHz" value="8000" />
|
||||||
<el-option label="16kHz" value="16" />
|
<el-option label="16kHz" value="16000" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@ -51,14 +106,13 @@
|
|||||||
<el-form-item label="输出格式">
|
<el-form-item label="输出格式">
|
||||||
<el-select v-model="form.outputFormat" placeholder="输出格式">
|
<el-select v-model="form.outputFormat" placeholder="输出格式">
|
||||||
<el-option label="WAV" value="wav" />
|
<el-option label="WAV" value="wav" />
|
||||||
<el-option label="男声-沉稳" value="male-calm" />
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row style="margin-top: 20px;" :gutter="20">
|
<el-row style="margin-top: 20px;" :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-button size="small" type="primary" :icon="VideoPlay" class="btn"
|
<el-button :loading="loading" type="primary" :icon="VideoPlay" class="btn"
|
||||||
@click="synthesis">开始合成</el-button>
|
@click="synthesis">开始合成</el-button>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
@ -68,38 +122,6 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右栏:语音预览与波形 -->
|
|
||||||
<div class="right">
|
|
||||||
<div class="title">语音预览与波形</div>
|
|
||||||
<div class="wave-container">
|
|
||||||
<div class="wave">
|
|
||||||
<div v-for="(h, index) in waveform" :key="index" :style="{
|
|
||||||
width: '4px',
|
|
||||||
height: h + '%',
|
|
||||||
background: '#3b82f6',
|
|
||||||
borderRadius: '2px',
|
|
||||||
opacity: 0.6 + (h / 100) * 0.4
|
|
||||||
}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="audio-container">
|
|
||||||
<AudioPlayer :src="audioUrl" @audioStatus="audioStatus" />
|
|
||||||
</div>
|
|
||||||
<div class="card-container">
|
|
||||||
<div v-for="item in cardList" class="card">
|
|
||||||
<div class="title">{{ item.title }}</div>
|
|
||||||
<div class="value">{{ item.value }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-row style="margin-top: 10px;" :gutter="20">
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-button type="primary" plain class="btn" @click="regenerate">重新生成</el-button>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-button type="primary" class="btn" >保存</el-button>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -107,28 +129,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { Delete, VideoPlay } from '@element-plus/icons-vue'
|
import { Delete, VideoPlay } from '@element-plus/icons-vue'
|
||||||
import { taskApi, regenerateApi } from '@/api/corpusManger/speech_synthesis'
|
import { batchTaskApi, regenerateApi, generalizationApi } from '@/api/corpusManger/speech_synthesis'
|
||||||
import AudioPlayer from './AudioPlayer.vue';
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh'])
|
||||||
|
|
||||||
const form = ref({
|
const generalizationFormRef = ref()
|
||||||
|
const generalizationForm = ref({
|
||||||
text: '',
|
text: '',
|
||||||
voice: 'xiaoyan',
|
language: '普通话',
|
||||||
speed: 50,
|
count: 1
|
||||||
volume: 50,
|
|
||||||
pitch: 50,
|
|
||||||
auf: '16',
|
|
||||||
outputFormat: 'wav'
|
|
||||||
})
|
})
|
||||||
|
const generalizationRules = ref({
|
||||||
const formRef = ref(null)
|
|
||||||
|
|
||||||
const rules = reactive({
|
|
||||||
text: [{ required: true, message: '请输入合成文本', trigger: 'blur' }],
|
text: [{ required: true, message: '请输入合成文本', trigger: 'blur' }],
|
||||||
voice: [{ required: true, message: '请输入音色', trigger: 'blur' }]
|
language: [{ required: true, message: '请选择语种', trigger: 'blur' }]
|
||||||
})
|
})
|
||||||
|
|
||||||
const langOption = ref([
|
|
||||||
|
const languageOption = ref([
|
||||||
{
|
{
|
||||||
label: '中文(普通话)',
|
label: '中文(普通话)',
|
||||||
value: '普通话'
|
value: '普通话'
|
||||||
@ -141,9 +159,67 @@ const langOption = ref([
|
|||||||
}, {
|
}, {
|
||||||
label: '四川话',
|
label: '四川话',
|
||||||
value: '四川话'
|
value: '四川话'
|
||||||
},
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const corpusList = ref([])
|
||||||
|
const generalizationLoading = ref(false)
|
||||||
|
const generalization = async () => {
|
||||||
|
const result = await generalizationFormRef.value.validate()
|
||||||
|
if (result) {
|
||||||
|
generalizationLoading.value = true
|
||||||
|
const res = await generalizationApi(generalizationForm.value)
|
||||||
|
if (res.code === 200) {
|
||||||
|
corpusList.value = res.data
|
||||||
|
ElMessage.success(res.msg)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg)
|
||||||
|
}
|
||||||
|
generalizationLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generalizationCancel = () => {
|
||||||
|
generalizationFormRef.value.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTag = (data) => {
|
||||||
|
console.log('data', data)
|
||||||
|
form.value.tags.push(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
const handleClose = (tag) => {
|
||||||
|
form.value.tags.splice(form.value.tags.indexOf(tag), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
tags: [],
|
||||||
|
model: '科大讯飞',
|
||||||
|
voice: 'xiaoyan',
|
||||||
|
speed: 50,
|
||||||
|
volume: 50,
|
||||||
|
pitch: 50,
|
||||||
|
sampleRate: '16000',
|
||||||
|
outputFormat: 'wav'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRef = ref(null)
|
||||||
|
|
||||||
|
const validateArray = (rule, value, callback) => {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
callback(new Error('请至少添加一条语料'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = reactive({
|
||||||
|
model: [{ required: true, message: '请选择模型', trigger: 'blur' }],
|
||||||
|
tags: [{ required: true, validator: validateArray, trigger: 'change' }],
|
||||||
|
voice: [{ required: true, message: '请输入音色', trigger: 'blur' }]
|
||||||
|
})
|
||||||
|
|
||||||
const dialectOption = ref([
|
const dialectOption = ref([
|
||||||
{
|
{
|
||||||
label: '东北话',
|
label: '东北话',
|
||||||
@ -179,78 +255,34 @@ const emotionOption = ref([
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const currentSynthesis = ref({
|
|
||||||
id: null,
|
|
||||||
fileSize: null,
|
|
||||||
outputFormat: null,
|
|
||||||
text: '',
|
|
||||||
audioFilePath: ''
|
|
||||||
})
|
|
||||||
const synthesis = async () => {
|
const synthesis = async () => {
|
||||||
loading.value = true
|
|
||||||
const result = await formRef.value.validate()
|
const result = await formRef.value.validate()
|
||||||
if (result) {
|
if (result) {
|
||||||
const res = await taskApi(form.value)
|
const arr = []
|
||||||
console.log('res', res)
|
const obj = JSON.parse(JSON.stringify(form.value))
|
||||||
|
delete obj.tags
|
||||||
|
form.value.tags.forEach(item => {
|
||||||
|
arr.push({
|
||||||
|
...obj,
|
||||||
|
text: item
|
||||||
|
})
|
||||||
|
})
|
||||||
|
loading.value = true
|
||||||
|
const res = await batchTaskApi(arr)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
currentSynthesis.value = {
|
emit('refresh')
|
||||||
...res.data
|
ElMessage.success(res.msg)
|
||||||
}
|
} else {
|
||||||
|
ElMessage.error(res.msg)
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const regenerate = async () => {
|
|
||||||
const res = await regenerateApi(currentSynthesis.value.id)
|
|
||||||
console.log('res', res)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
formRef.value.resetFields()
|
formRef.value.resetFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardList = ref([
|
|
||||||
{
|
|
||||||
title: '预计时长',
|
|
||||||
value: '00:06'
|
|
||||||
}, {
|
|
||||||
title: '字符数',
|
|
||||||
value: '21'
|
|
||||||
}, {
|
|
||||||
title: '采样率',
|
|
||||||
value: '48kHz'
|
|
||||||
}, {
|
|
||||||
title: '文件大小',
|
|
||||||
value: '256KB'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// 波形数据
|
|
||||||
const waveform = ref(Array.from({ length: 80 }, () => Math.floor(Math.random() * 60 + 20)))
|
|
||||||
const timer = ref(null)
|
|
||||||
|
|
||||||
// 模拟波形动态变化
|
|
||||||
const updateWaveform = () => {
|
|
||||||
waveform.value = Array.from({ length: 80 }, () => Math.floor(Math.random() * 60 + 20))
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioUrl = ref('https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/music/audio.mp3')
|
|
||||||
|
|
||||||
const audioStatus = (isPlaying) => {
|
|
||||||
if (isPlaying) {
|
|
||||||
timer.value = setInterval(updateWaveform, 1000)
|
|
||||||
} else {
|
|
||||||
if (timer.value) clearInterval(timer.value)
|
|
||||||
cancelAnimationFrame(animationFrame)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (timer.value) clearInterval(timer.value)
|
|
||||||
cancelAnimationFrame(animationFrame)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@ -259,25 +291,6 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
|
||||||
.left {
|
|
||||||
flex: 1;
|
|
||||||
background: #fff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 32px;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@ -290,53 +303,44 @@ onBeforeUnmount(() => {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wave-container {
|
.tag {
|
||||||
border-radius: 12px;
|
margin-right: 8px;
|
||||||
padding: 16px;
|
|
||||||
min-height: 140px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.wave {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
height: 80px;
|
|
||||||
gap: 4px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-container {
|
|
||||||
width: 100%;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-container {
|
.btn {
|
||||||
display: grid;
|
width: 100%;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
height: 32px;
|
||||||
gap: 16px;
|
font-size: 18px;
|
||||||
|
|
||||||
.card {
|
|
||||||
border: 1px solid #d3d3d3;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item--default) {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corpus-container {
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item--default) {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@ -24,28 +24,28 @@ const stats = [
|
|||||||
{
|
{
|
||||||
iconName: 'task',
|
iconName: 'task',
|
||||||
label: '合成任务总数(条)',
|
label: '合成任务总数(条)',
|
||||||
value: '856,420',
|
value: '13',
|
||||||
trend: '+12.4%',
|
trend: '+12.4%',
|
||||||
trendClass: 'up'
|
trendClass: 'up'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconName: 'add',
|
iconName: 'add',
|
||||||
label: '今日新增合成(条)',
|
label: '今日新增合成(条)',
|
||||||
value: '12,680',
|
value: '3',
|
||||||
trend: '+8.7%',
|
trend: '+8.7%',
|
||||||
trendClass: 'up',
|
trendClass: 'up',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconName: 'language',
|
iconName: 'language',
|
||||||
label: '支持语种(种)',
|
label: '支持语种(种)',
|
||||||
value: '12',
|
value: '4',
|
||||||
trend: '—',
|
trend: '—',
|
||||||
trendClass: 'neutral'
|
trendClass: 'neutral'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconName: 'consuming',
|
iconName: 'consuming',
|
||||||
label: '可用音色(个)',
|
label: '可用音色(个)',
|
||||||
value: '36',
|
value: '3',
|
||||||
trend: '+3.2%',
|
trend: '+3.2%',
|
||||||
trendClass: 'up'
|
trendClass: 'up'
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-card shadow="never">
|
<div class="taskList-container">
|
||||||
<div class="flex-between mb-3">
|
<div class="flex-between mb-3">
|
||||||
<span class="stat-label">合成任务列表</span>
|
<span class="stat-label">合成任务列表</span>
|
||||||
</div>
|
</div>
|
||||||
@ -30,9 +30,31 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作">
|
<el-table-column align="center" class-name="small-padding fixed-width" label="操作">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link @click="play(scope.rows.audioFilePath)" type="primary" icon="VideoPlay"></el-button>
|
<el-button link @click="play(scope.row.audioFilePath)" type="primary"
|
||||||
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row.id)"></el-button>
|
icon="VideoPlay">播放</el-button>
|
||||||
<el-button icon="Download" link type="primary" @click="handleDownload(scope.row)"></el-button>
|
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row.id)">删除</el-button>
|
||||||
|
|
||||||
|
<el-dropdown>
|
||||||
|
<span class="el-dropdown-link">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item>
|
||||||
|
<el-button icon="Download" link type="primary"
|
||||||
|
@click="handleDownload(scope.row)">下载</el-button>
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item>
|
||||||
|
<el-button icon="Collection" link type="primary"
|
||||||
|
@click="handleSave(scope.row)">保存</el-button>
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item>
|
||||||
|
<el-button icon="Refresh" link type="primary"
|
||||||
|
@click="regenerate(scope.row.id)">重新生成</el-button>
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -41,15 +63,49 @@
|
|||||||
<el-pagination background layout="prev, pager, next, jumper" :total="totalTasks" :page-size="10"
|
<el-pagination background layout="prev, pager, next, jumper" :total="totalTasks" :page-size="10"
|
||||||
v-model:current-page="page" small />
|
v-model:current-page="page" small />
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
<el-dialog v-model="dialogVisible" title="播放音频" width="600px" :destroy-on-close="true" @close="colseDialog">
|
||||||
|
<div class="wave-container">
|
||||||
|
<div class="wave">
|
||||||
|
<div v-for="(h, index) in waveform" :key="index" :style="{
|
||||||
|
width: '4px',
|
||||||
|
height: h + '%',
|
||||||
|
background: '#3b82f6',
|
||||||
|
borderRadius: '2px',
|
||||||
|
opacity: 0.6 + (h / 100) * 0.4
|
||||||
|
}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="audio-container">
|
||||||
|
<AudioPlayer :src="audioUrl" @audioStatus="audioStatus" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- <el-dialog v-model="corpusDialogVisible" title="保存" :append-to-body="true" width="600px" :destroy-on-close="true" @close="colseDialog">
|
||||||
|
<div class="wave-container">
|
||||||
|
<div class="wave">
|
||||||
|
<div v-for="(h, index) in waveform" :key="index" :style="{
|
||||||
|
width: '4px',
|
||||||
|
height: h + '%',
|
||||||
|
background: '#3b82f6',
|
||||||
|
borderRadius: '2px',
|
||||||
|
opacity: 0.6 + (h / 100) * 0.4
|
||||||
|
}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="audio-container">
|
||||||
|
<AudioPlayer :src="audioUrl" @audioStatus="audioStatus" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog> -->
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { taskListApi, deleteTask, downloadWavApi } from '@/api/corpusManger/speech_synthesis'
|
import { taskListApi, deleteTask, downloadWavApi, regenerateApi } from '@/api/corpusManger/speech_synthesis'
|
||||||
import { usePlayAudio } from '@/hooks/usePlayAudio'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import Download from '@/plugins/download'
|
import Download from '@/plugins/download'
|
||||||
|
import AudioPlayer from './AudioPlayer.vue';
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const activeTab = ref('')
|
const activeTab = ref('')
|
||||||
@ -68,7 +124,7 @@ const totalTasks = computed(() => tasks.value.length)
|
|||||||
|
|
||||||
const changeTab = (key) => {
|
const changeTab = (key) => {
|
||||||
activeTab.value = key
|
activeTab.value = key
|
||||||
getTaskList(key)
|
getTaskList()
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusMap = { '2': 'success', '1': 'warning', '0': 'info', '3': 'danger' }
|
const statusMap = { '2': 'success', '1': 'warning', '0': 'info', '3': 'danger' }
|
||||||
@ -89,8 +145,8 @@ const getTaskList = async (status = null) => {
|
|||||||
pageNum: page.value,
|
pageNum: page.value,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
}
|
}
|
||||||
if (status) {
|
if (activeTab.value) {
|
||||||
obj.status = status
|
obj.status = activeTab.value
|
||||||
}
|
}
|
||||||
const res = await taskListApi(obj)
|
const res = await taskListApi(obj)
|
||||||
if (res.code == 0) {
|
if (res.code == 0) {
|
||||||
@ -99,9 +155,39 @@ const getTaskList = async (status = null) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
|
||||||
|
// 波形数据
|
||||||
|
const waveform = ref(Array.from({ length: 60 }, () => Math.floor(Math.random() * 60 + 20)))
|
||||||
|
const timer = ref(null)
|
||||||
|
|
||||||
|
// 模拟波形动态变化
|
||||||
|
const updateWaveform = () => {
|
||||||
|
waveform.value = Array.from({ length: 60 }, () => Math.floor(Math.random() * 60 + 20))
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioUrl = ref('https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/music/audio.mp3')
|
||||||
|
|
||||||
|
const audioStatus = (isPlaying) => {
|
||||||
|
if (isPlaying) {
|
||||||
|
timer.value = setInterval(updateWaveform, 100)
|
||||||
|
} else {
|
||||||
|
if (timer.value) clearInterval(timer.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer.value) clearInterval(timer.value)
|
||||||
|
})
|
||||||
|
|
||||||
const play = (url) => {
|
const play = (url) => {
|
||||||
const { playAudio } = usePlayAudio()
|
dialogVisible.value = true
|
||||||
playAudio(url)
|
audioUrl.value = import.meta.env.VITE_MINIO_URL + url
|
||||||
|
}
|
||||||
|
|
||||||
|
const colseDialog = () => {
|
||||||
|
dialogVisible.value = false
|
||||||
|
if (timer.value) clearInterval(timer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (id) => {
|
const handleDelete = (id) => {
|
||||||
@ -134,12 +220,34 @@ const handleDownload = async (row) => {
|
|||||||
URL.revokeObjectURL(blobUrl)
|
URL.revokeObjectURL(blobUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerate = async (id) => {
|
||||||
|
const res = await regenerateApi(id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
getTaskList()
|
||||||
|
ElMessage.success(res.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getTaskList()
|
getTaskList()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getTaskList
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.taskList-container {
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-between {
|
.flex-between {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -177,4 +285,25 @@ onMounted(() => {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.wave-container {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 140px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.wave {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 80px;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-container {
|
||||||
|
width: 100%;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -7,17 +7,28 @@
|
|||||||
|
|
||||||
<!-- 主工作区 -->
|
<!-- 主工作区 -->
|
||||||
<div class="mainWorkspace">
|
<div class="mainWorkspace">
|
||||||
<MainWorkspace />
|
<MainWorkspace @refresh="refresh" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 任务列表 -->
|
<!-- 任务列表 -->
|
||||||
<TaskList />
|
<TaskList ref="taskListRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import StatCards from './components/StatCards.vue'
|
import StatCards from './components/StatCards.vue'
|
||||||
import MainWorkspace from './components/MainWorkspace.vue'
|
import MainWorkspace from './components/MainWorkspace.vue'
|
||||||
import TaskList from './components/TaskList.vue'
|
import TaskList from './components/TaskList.vue'
|
||||||
|
|
||||||
|
import autofit from 'autofit.js'
|
||||||
|
autofit.init({
|
||||||
|
el: "#app",
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskListRef = ref()
|
||||||
|
const refresh = () => {
|
||||||
|
taskListRef.value.getTaskList()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<script setup></script>
|
<script setup></script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user