feat: 语料文本

This commit is contained in:
zhanghao 2026-07-07 15:33:43 +08:00
parent af2f9398b7
commit a24733f6ea
6 changed files with 266 additions and 134 deletions

View File

@ -29,3 +29,11 @@ export function deleteCategory(id) {
method: 'delete',
})
}
export function getCorpusListApi(params) {
return request({
url: '/tts/corpus/list',
method: 'get',
params: params
})
}

View File

@ -60,3 +60,10 @@ export function batchTaskApi(data) {
data: data
})
}
export function saveTaskApi(data) {
return request({
url: `/tts/task/save-to-corpus/${data.taskId}/${data.categoryId}`,
method: 'post'
})
}

View File

@ -74,13 +74,20 @@ const defaultProps = {
label: 'label'
}
// count
const treeData = ref([])
const emit = defineEmits(['select-category'])
const treeData = ref([
{
categoryName: '全部',
children: [],
id: ''
}
])
const getCategoryTree = async () => {
const res = await getCategoryTreeApi()
if (res.code === 200) {
treeData.value = res.data
treeData.value[0].children = res.data
}
}
@ -103,6 +110,7 @@ const ruleForm = ref({
//
const handleNodeClick = (data) => {
selectedNode.value = data
emit('select-category', data)
}
//

View File

@ -5,47 +5,33 @@
<div class="toolbar-left">
<el-button type="primary" size="default" @click="openDialog(true)">新建语料</el-button>
</div>
<div class="toolbar-right">
<el-tag v-for="tab in tabs" :key="tab.key" :type="activeTab === tab.key ? '' : 'info'"
:effect="activeTab === tab.key ? 'dark' : 'plain'" style="cursor:pointer;" @click="activeTab = tab.key">
{{ tab.label }}
<span class="tab-count">{{ tab.count }}</span>
</el-tag>
</div>
</div>
<!-- 表格 -->
<el-table :data="corpusData" stripe style="width:100%;" max-height="400">
<el-table-column prop="id" label="语料编号" width="180" />
<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="lang" label="语种" width="80" />
<el-table-column prop="dialect" label="方言" width="80" />
<el-table-column prop="emotion" label="情绪" width="70" />
<el-table-column prop="voice" label="音色" width="100" />
<el-table-column prop="status" label="审核状态" width="100">
<el-table-column prop="id" label="语料编号" show-overflow-tooltip />
<el-table-column prop="text" label="文本内容" show-overflow-tooltip />
<el-table-column prop="categoryName" label="所属分类" show-overflow-tooltip />
<el-table-column prop="language" label="语种" />
<el-table-column prop="dialect" label="方言" />
<el-table-column prop="emotion" label="情绪" />
<el-table-column prop="voice" label="音色" />
<el-table-column prop="fileSize" label="文件大小" />
<el-table-column label="操作" fixed="right">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small" effect="plain">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="160" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="handleDetail(row)">详情</el-button>
<el-button link @click="play(row.audioFilePath)" type="primary" icon="VideoPlay">播放</el-button>
<el-button icon="Download" link type="primary" @click="handleDownload(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<span class="text-muted text-sm"> {{ total }} </span>
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="10"
v-model:current-page="page" small />
</div>
<el-dialog v-model="dialogVisible" :title="'新建语料'" :append-to-body="true" width="600px" @close="resetDialog">
<el-dialog v-model="addCorpusVisible" :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="模型">
@ -61,19 +47,10 @@
</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="{
<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"
>
}" :limit="1" :on-error="uploadError" :on-success="uploadSuccess" class="file-uploader">
<el-button type="primary">选取文件</el-button>
<template #tip>
<div class="el-upload__tip">
@ -86,18 +63,45 @@
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button @click="addCorpusVisible = false">取消</el-button>
<el-button type="primary" @click="confirmDialog">确定</el-button>
</template>
</el-dialog>
<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>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import {getToken} from "@/utils/auth.js";
import { ref, computed, onMounted, watch } from 'vue'
import { getToken } from "@/utils/auth.js";
import { getCorpusListApi } from '@/api/corpusManger/corpus.js'
import { downloadWavApi } from '@/api/corpusManger/speech_synthesis'
import AudioPlayer from '../../speech_synthesis/components/AudioPlayer.vue';
const dialogVisible = ref(false)
const props = defineProps({
categoryId: {
type: String,
default: ''
}
})
const addCorpusVisible = ref(false)
const voiceOption = ref([
{
@ -145,14 +149,13 @@ const data = {
}
const openDialog = () => {
dialogVisible.value = true
addCorpusVisible.value = true
}
const resetDialog = () => {
addCorpusVisible.value = false
}
const activeTab = ref('all')
const page = ref(1)
const beforeAvatarUpload = (rawFile) => {
@ -163,7 +166,7 @@ const beforeAvatarUpload = (rawFile) => {
proxy.$modal.msgError(`文件格式不正确,请上传${['png', 'jpg', 'jpeg'].join("/")}格式文件!`);
return false;
}
const {label = undefined, value = undefined} = supportType?.find(item => item.label === rawFile.type)
const { label = undefined, value = undefined } = supportType?.find(item => item.label === rawFile.type)
if (label) {
uploadFileType.value = value
}
@ -186,31 +189,87 @@ const uploadError = (e) => {
console.log("uploadError", e)
}
const tabs = [
{ key: 'all', label: '全部语料', count: '1,245,678' },
{ key: 'pending', label: '待审核', count: '45,678' },
{ key: 'published', label: '已发布', count: '987,654' }
]
//
const corpusData = ref([
{ id: 'CORP_20240517_0001', text: '打开天窗', category: '控制指令-车窗控制', lang: '中文', dialect: '普通话', emotion: '中性', voice: '男声', status: '已通过', updateTime: '2024-05-17 10:21:33' },
{ id: 'CORP_20240517_0002', text: '导航到最近的充电站', category: '导航交互-路线规划', lang: '中文', dialect: '普通话', emotion: '中性', voice: '女声', status: '已通过', updateTime: '2024-05-17 10:19:48' },
{ id: 'CORP_20240517_0003', text: '播放周杰伦的歌', category: '媒体娱乐-音乐播放', lang: '中文', dialect: '普通话', emotion: '愉悦', voice: '女声', status: '已通过', updateTime: '2024-05-17 10:18:02' },
{ id: 'CORP_20240517_0004', text: '把空调调到24度', category: '控制指令-空调控制', lang: '中文', dialect: '普通话', emotion: '中性', voice: '男声', status: '待审核', updateTime: '2024-05-17 10:15:26' },
{ id: 'CORP_20240517_0005', text: '给张三打电话', category: '电话消息-拨打电话', lang: '中文', dialect: '普通话', emotion: '中性', voice: '男声', status: '已通过', updateTime: '2024-05-17 10:12:11' },
{ id: 'CORP_20240517_0006', text: '今天天气怎么样', category: '场景对话-日常对话', lang: '中文', dialect: '普通话', emotion: '中性', voice: '女声', status: '已通过', updateTime: '2024-05-17 10:10:05' },
{ id: 'CORP_20240517_0007', text: '我有点冷', category: '场景对话-出行场景', lang: '中文', dialect: '普通话', emotion: '不满', voice: '女声', status: '待审核', updateTime: '2024-05-17 10:08:47' },
{ id: 'CORP_20240517_0008', text: '关闭所有车窗', category: '控制指令-车窗控制', lang: '中文', dialect: '普通话', emotion: '中性', voice: '男声', status: '已通过', updateTime: '2024-05-17 10:06:20' }
])
const corpusData = ref([])
const total = ref(0)
const getCorpusList = async () => {
const obj = {
pageNum: page.value,
pageSize: 10,
categoryId: props.categoryId
}
const res = await getCorpusListApi(obj)
if (res.code === 0) {
corpusData.value = res.rows
total.value = res.total
}
}
const total = computed(() => corpusData.value.length)
const statusType = (status) => {
if (status === '已通过') return 'success'
if (status === '待审核') return 'warning'
return 'info'
}
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)
}
}
const play = (url) => {
dialogVisible.value = true
audioUrl.value = import.meta.env.VITE_MINIO_URL + url
}
const colseDialog = () => {
dialogVisible.value = false
if (timer.value) clearInterval(timer.value)
}
const handleDownload = async (row) => {
const response = await fetch(import.meta.env.VITE_MINIO_URL + row.audioFilePath)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = `${row.id}.${row.audioFilePath.split('.')[1]}`
a.click()
URL.revokeObjectURL(blobUrl)
}
watch(() => props.categoryId,
() => {
getCorpusList()
})
onMounted(() => {
getCorpusList()
})
onBeforeUnmount(() => {
if (timer.value) clearInterval(timer.value)
})
</script>
<style lang="scss" scoped>
@ -231,19 +290,6 @@ const statusType = (status) => {
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.tab-count {
margin-left: 4px;
color: #94a3b8;
font-size: 12px;
}
}
}
.pagination-wrapper {
@ -261,4 +307,25 @@ const statusType = (status) => {
font-size: 13px;
}
}
.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>

View File

@ -9,16 +9,23 @@
<CategoryTree @select-category="onSelectCategory" />
</div>
<div class="right-panel">
<CorpusList @view-detail="onViewDetail" />
<CorpusList :categoryId="categoryId" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import StatCards from './components/StatCards.vue'
import CategoryTree from './components/CategoryTree.vue'
import CorpusList from './components/CorpusList.vue'
const categoryId = ref('')
const onSelectCategory = (node) => {
categoryId.value = node.id
}
</script>
<style lang="scss" scoped>
.page-container {

View File

@ -46,7 +46,7 @@
</el-dropdown-item>
<el-dropdown-item>
<el-button icon="Collection" link type="primary"
@click="handleSave(scope.row)">保存</el-button>
@click="handleSave(scope.row.id)">保存</el-button>
</el-dropdown-item>
<el-dropdown-item>
<el-button icon="Refresh" link type="primary"
@ -81,28 +81,35 @@
</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> -->
<el-dialog v-model="corpusDialogVisible" title="保存到语料库" :append-to-body="true" width="400px" @close="colseDialog">
<el-form ref="formRef" :model="form" label-width="auto">
<el-form-item label="语料分类" :rules="[{ required: true, message: '请选择分类', trigger: 'blur' }]">
<el-tree-select
v-model="form.categoryId"
:data="treeData"
node-key="id"
:props="{
label: 'categoryName',
value: 'id',
children: 'children'
}"
:render-after-expand="false"
style="width: 240px"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="corpusDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmCorpus">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { taskListApi, deleteTask, downloadWavApi, regenerateApi } from '@/api/corpusManger/speech_synthesis'
import { taskListApi, deleteTask, downloadWavApi, regenerateApi, saveTaskApi } from '@/api/corpusManger/speech_synthesis'
import { getCategoryTreeApi } from '@/api/corpusManger/corpus.js'
import { ElMessage, ElMessageBox } from 'element-plus'
import Download from '@/plugins/download'
import AudioPlayer from './AudioPlayer.vue';
@ -220,10 +227,6 @@ const handleDownload = async (row) => {
URL.revokeObjectURL(blobUrl)
}
const handleSave = () => {
}
const regenerate = async (id) => {
const res = await regenerateApi(id)
if (res.code === 200) {
@ -232,6 +235,38 @@ const regenerate = async (id) => {
}
}
const corpusDialogVisible = ref(false)
const formRef = ref()
const treeData = ref([])
const form = ref({
taskId: '',
categoryId: ''
})
const handleSave = (taskId) => {
corpusDialogVisible.value = true
form.value.taskId = taskId
getCategoryTree()
}
const getCategoryTree = async () => {
const res = await getCategoryTreeApi()
if (res.code === 200) {
treeData.value = res.data
}
}
const confirmCorpus = async () => {
const result = await formRef.value.validate()
if (result) {
const res = await saveTaskApi(form.value)
if (res.code === 200) {
corpusDialogVisible.value = false
ElMessage.success(res.msg)
}
}
}
onMounted(() => {
getTaskList()
})