Files
sionrui/frontend/app/web-gold/src/views/dh/VoiceCopy.vue

596 lines
16 KiB
Vue
Raw Normal View History

2025-11-19 00:12:47 +08:00
<template>
2026-01-17 19:33:59 +08:00
<BasicLayout
title="配音管理"
:show-back="false"
>
2025-11-19 00:12:47 +08:00
<!-- 搜索栏 -->
<div class="search-bar">
<a-space>
2026-01-17 19:33:59 +08:00
<a-button type="primary" @click="handleCreate">
<PlusOutlined />
新建配音
</a-button>
2025-11-19 00:12:47 +08:00
<a-input
v-model:value="searchParams.name"
placeholder="搜索配音名称"
style="width: 250px"
@press-enter="handleSearch"
>
2025-11-19 01:39:56 +08:00
<SearchOutlined />
2025-11-19 00:12:47 +08:00
</a-input>
2026-01-17 19:33:59 +08:00
<a-button @click="handleSearch">查询</a-button>
2025-11-19 00:12:47 +08:00
<a-button @click="handleReset">重置</a-button>
</a-space>
</div>
2025-11-10 00:59:40 +08:00
2025-11-19 00:12:47 +08:00
<!-- 列表表格 -->
<div class="table-container">
<a-table
:columns="columns"
:data-source="voiceList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
2025-11-19 01:39:56 +08:00
<div v-if="column.key === 'name'" class="voice-name">
{{ record.name || '未命名' }}
</div>
2026-02-25 22:06:13 +08:00
<a-tooltip v-else-if="column.key === 'note'" :title="record.note" placement="topLeft">
<span class="note-text">{{ record.note || '-' }}</span>
</a-tooltip>
2025-11-19 01:39:56 +08:00
<span v-else-if="column.key === 'createTime'">
{{ formatDateTime(record.createTime) }}
</span>
<a-button v-else-if="column.key === 'fileUrl'" type="link" size="small" @click="handlePlayAudio(record)">
<PlayCircleOutlined />
播放
</a-button>
<a-space v-else-if="column.key === 'actions'">
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
</a-space>
2025-11-19 00:12:47 +08:00
</template>
</a-table>
</div>
2025-11-10 00:59:40 +08:00
2025-11-19 01:39:56 +08:00
<!-- 表单 Modal -->
2025-11-19 00:12:47 +08:00
<a-modal
v-model:open="modalVisible"
:title="isCreateMode ? '新建配音' : '编辑配音'"
2026-02-25 22:06:13 +08:00
:width="480"
ok-text="保存"
2026-01-18 02:15:08 +08:00
cancel-text="取消"
2025-11-19 00:12:47 +08:00
:confirm-loading="submitting"
2026-03-01 20:44:29 +08:00
:ok-button-props="{ disabled: isSubmitDisabled }"
2026-02-25 22:06:13 +08:00
:mask-closable="false"
centered
2025-11-19 00:12:47 +08:00
@ok="handleSubmit"
@cancel="handleCancel"
>
2025-11-19 01:39:56 +08:00
<a-form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
2025-11-19 00:12:47 +08:00
<a-form-item label="配音名称" name="name">
2026-02-25 22:06:13 +08:00
<a-input v-model:value="formData.name" placeholder="请输入配音名称" allow-clear />
2025-11-19 00:12:47 +08:00
</a-form-item>
<a-form-item
v-if="isCreateMode"
label="音频文件"
name="fileId"
:rules="[{ required: true, message: '请上传音频文件' }]"
>
2026-02-25 22:06:13 +08:00
<div class="upload-area" :class="{ 'has-file': fileList.length > 0 }">
<a-upload-dragger
v-model:file-list="fileList"
:custom-request="handleCustomUpload"
:before-upload="handleBeforeUpload"
:max-count="1"
:show-upload-list="false"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
@remove="handleRemoveFile"
>
<template v-if="!uploadState.uploading && fileList.length === 0">
<div class="upload-icon">
<CloudUploadOutlined />
</div>
<p class="upload-text">点击或拖拽音频文件到此区域</p>
<p class="upload-hint-inline">支持 MP3WAVAAC 等格式最大 5MB</p>
</template>
<template v-else-if="uploadState.uploading">
<a-progress type="circle" :percent="50" :width="60" status="active" />
<p class="upload-text" style="margin-top: 12px">正在上传...</p>
</template>
2026-03-01 20:44:29 +08:00
<template v-else-if="extractingText">
<a-progress type="circle" :percent="50" :width="60" status="active" />
<p class="upload-text" style="margin-top: 12px">正在识别语音...</p>
</template>
2026-02-25 22:06:13 +08:00
<template v-else>
<div class="file-preview">
<SoundOutlined class="file-icon" />
<div class="file-info">
<span class="file-name">{{ fileList[0]?.name || '音频文件' }}</span>
2026-03-01 20:44:29 +08:00
<span v-if="formData.text" class="text-status success">
<CheckCircleOutlined /> 已识别语音文本
</span>
<span v-else class="text-status warning">
<ExclamationCircleOutlined /> 未识别到语音文本
</span>
2026-02-25 22:06:13 +08:00
<a-button type="link" size="small" danger @click.stop="handleRemoveFile">
<DeleteOutlined /> 移除
</a-button>
</div>
</div>
</template>
</a-upload-dragger>
2025-11-19 01:39:56 +08:00
</div>
2025-11-19 00:12:47 +08:00
</a-form-item>
<a-form-item label="备注" name="note">
2026-02-25 22:06:13 +08:00
<a-textarea
v-model:value="formData.note"
:rows="2"
placeholder="备注信息(选填)"
:auto-size="{ minRows: 2, maxRows: 4 }"
allow-clear
/>
2025-11-19 00:12:47 +08:00
</a-form-item>
</a-form>
</a-modal>
2025-11-19 01:39:56 +08:00
<audio ref="audioPlayer" style="display: none" />
2026-01-17 19:33:59 +08:00
</BasicLayout>
2025-11-19 00:12:47 +08:00
</template>
<script setup>
2026-02-04 01:18:16 +08:00
import { ref, reactive, computed, onMounted } from 'vue'
2025-11-19 00:12:47 +08:00
import { message, Modal } from 'ant-design-vue'
2026-03-01 20:44:29 +08:00
import { PlusOutlined, SearchOutlined, PlayCircleOutlined, CloudUploadOutlined, SoundOutlined, DeleteOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue'
2026-02-04 01:18:16 +08:00
import dayjs from 'dayjs'
import BasicLayout from '@/layouts/components/BasicLayout.vue'
2025-11-19 00:12:47 +08:00
import { MaterialService } from '@/api/material'
2026-02-04 01:18:16 +08:00
import { VoiceService } from '@/api/voice'
2026-01-17 14:43:42 +08:00
import { useUpload } from '@/composables/useUpload'
2026-03-01 20:44:29 +08:00
import useVoiceText from '@/hooks/web/useVoiceText'
2025-11-19 00:12:47 +08:00
2025-11-19 01:39:56 +08:00
// ========== 常量 ==========
2026-01-27 01:39:08 +08:00
2025-11-19 00:12:47 +08:00
const DEFAULT_FORM_DATA = {
id: null,
2025-11-10 00:59:40 +08:00
name: '',
2025-11-19 00:12:47 +08:00
fileId: null,
autoTranscribe: true,
language: 'zh-CN',
2025-11-10 00:59:40 +08:00
gender: 'female',
2026-02-02 02:39:40 +08:00
note: '',
2026-02-04 01:18:16 +08:00
text: '',
fileUrl: ''
2025-11-19 00:12:47 +08:00
}
2026-02-25 16:28:31 +08:00
const MAX_FILE_SIZE = 5 * 1024 * 1024 // SiliconFlow API 限制参考音频不超过 5MB
2026-02-04 01:18:16 +08:00
const VALID_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/wave', 'audio/x-wav', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg']
const VALID_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']
2025-11-19 00:12:47 +08:00
// ========== 响应式数据 ==========
const loading = ref(false)
const submitting = ref(false)
const voiceList = ref([])
const modalVisible = ref(false)
const formMode = ref('create')
const formRef = ref(null)
const audioPlayer = ref(null)
const fileList = ref([])
2026-03-01 20:44:29 +08:00
const extractingText = ref(false) // 语音识别中状态
2025-11-19 00:12:47 +08:00
const searchParams = reactive({
name: '',
pageNo: 1,
pageSize: 10
2025-11-10 00:59:40 +08:00
})
2025-11-19 00:12:47 +08:00
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total) => `${total}`
})
2025-11-10 00:59:40 +08:00
2025-11-19 00:12:47 +08:00
const formData = reactive({ ...DEFAULT_FORM_DATA })
2025-11-10 00:59:40 +08:00
2026-01-17 14:43:42 +08:00
// ========== Upload Hook ==========
const { state: uploadState, upload } = useUpload()
2026-02-02 02:39:40 +08:00
// ========== VoiceText Hook ==========
const { getVoiceText } = useVoiceText()
2025-11-19 00:12:47 +08:00
// ========== 计算属性 ==========
const isCreateMode = computed(() => formMode.value === 'create')
2025-11-10 00:59:40 +08:00
2026-03-01 20:44:29 +08:00
// 保存按钮是否禁用(新建模式下,文本为空或正在提取时禁用)
const isSubmitDisabled = computed(function() {
if (!isCreateMode.value) {
return false
}
// 正在提取文本时禁用
if (extractingText.value) {
return true
}
// 已上传文件但文本为空时禁用
if (formData.fileId && !formData.text) {
return true
}
return false
})
2025-11-19 00:12:47 +08:00
// ========== 表格配置 ==========
const columns = [
2026-02-25 22:06:13 +08:00
{ title: '配音名称', key: 'name', dataIndex: 'name', width: 160 },
{ title: '备注', key: 'note', dataIndex: 'note', ellipsis: true },
2025-11-19 00:12:47 +08:00
{ title: '创建时间', key: 'createTime', dataIndex: 'createTime', width: 180 },
2026-02-25 22:06:13 +08:00
{ title: '操作', key: 'actions', width: 100, fixed: 'right' }
2025-11-19 00:12:47 +08:00
]
2025-11-10 00:59:40 +08:00
2025-11-19 00:12:47 +08:00
// ========== 表单验证规则 ==========
const formRules = {
name: [{ required: true, message: '请输入配音名称' }],
fileId: [{ required: true, message: '请上传音频文件' }]
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
// ========== 工具函数 ==========
const formatDateTime = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
2025-11-10 00:59:40 +08:00
}
2025-11-19 00:12:47 +08:00
const fillFormData = (data) => {
Object.assign(formData, {
id: data.id || null,
name: data.name || '',
fileId: data.fileId || null,
language: data.language || 'zh-CN',
gender: data.gender || 'female',
2026-02-01 18:35:03 +08:00
note: data.note || ''
2025-11-19 00:12:47 +08:00
})
2025-11-10 00:59:40 +08:00
}
2025-11-19 00:12:47 +08:00
// ========== 数据加载 ==========
const loadVoiceList = async () => {
loading.value = true
2025-11-10 00:59:40 +08:00
try {
2025-11-19 01:39:56 +08:00
const res = await VoiceService.getPage({
2025-11-19 00:12:47 +08:00
pageNo: pagination.current,
pageSize: pagination.pageSize,
name: searchParams.name || undefined
2025-11-19 01:39:56 +08:00
})
if (res.code !== 0) return message.error(res.msg || '加载失败')
voiceList.value = res.data.list || []
pagination.total = res.data.total || 0
2025-11-19 00:12:47 +08:00
} catch (error) {
console.error('加载配音列表失败:', error)
message.error('加载失败,请稍后重试')
} finally {
loading.value = false
2025-11-10 00:59:40 +08:00
}
}
2025-11-19 00:12:47 +08:00
// ========== 搜索和分页 ==========
2026-02-04 01:18:16 +08:00
function handleSearch() {
2025-11-19 00:12:47 +08:00
pagination.current = 1
loadVoiceList()
2025-11-10 00:59:40 +08:00
}
2026-02-04 01:18:16 +08:00
function handleReset() {
2025-11-19 00:12:47 +08:00
searchParams.name = ''
pagination.current = 1
loadVoiceList()
2025-11-10 00:59:40 +08:00
}
2026-02-04 01:18:16 +08:00
function handleTableChange(pag) {
2025-11-19 00:12:47 +08:00
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadVoiceList()
2025-11-10 00:59:40 +08:00
}
2025-11-19 00:12:47 +08:00
// ========== CRUD 操作 ==========
2026-02-04 01:18:16 +08:00
function handleCreate() {
2025-11-19 00:12:47 +08:00
formMode.value = 'create'
resetForm()
modalVisible.value = true
}
2026-02-04 01:18:16 +08:00
async function handleEdit(record) {
2025-11-19 00:12:47 +08:00
formMode.value = 'edit'
try {
const res = await VoiceService.get(record.id)
2025-11-19 01:39:56 +08:00
fillFormData(res.code === 0 && res.data ? res.data : record)
2025-11-19 00:12:47 +08:00
} catch (error) {
console.error('获取配音详情失败:', error)
2025-11-19 01:39:56 +08:00
fillFormData(record)
2025-11-19 00:12:47 +08:00
}
modalVisible.value = true
2025-11-10 00:59:40 +08:00
}
2026-02-04 01:18:16 +08:00
function handleDelete(record) {
2025-11-10 00:59:40 +08:00
Modal.confirm({
2025-11-19 00:12:47 +08:00
title: '确认删除',
content: `确定要删除配音「${record.name}」吗?此操作不可恢复。`,
2025-11-10 00:59:40 +08:00
okButtonProps: { danger: true },
2026-02-01 18:35:03 +08:00
centered: true,
2026-02-04 01:18:16 +08:00
onOk: async function() {
2025-11-19 00:12:47 +08:00
try {
const res = await VoiceService.delete(record.id)
2025-11-19 01:39:56 +08:00
if (res.code !== 0) return message.error(res.msg || '删除失败')
message.success('删除成功')
loadVoiceList()
2025-11-19 00:12:47 +08:00
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败,请稍后重试')
}
2025-11-10 00:59:40 +08:00
}
})
}
2025-11-19 00:12:47 +08:00
// ========== 音频播放 ==========
2026-02-04 01:18:16 +08:00
function handlePlayAudio(record) {
2025-11-19 00:12:47 +08:00
if (record.fileUrl && audioPlayer.value) {
audioPlayer.value.src = record.fileUrl
audioPlayer.value.play()
} else {
message.warning('音频文件不存在')
}
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
// ========== 文件上传 ==========
2026-02-04 01:18:16 +08:00
function handleBeforeUpload(file) {
2025-11-19 00:12:47 +08:00
if (file.size > MAX_FILE_SIZE) {
2026-02-25 16:28:31 +08:00
message.error('文件大小不能超过 5MB')
2025-11-19 00:12:47 +08:00
return false
}
2025-11-16 19:35:55 +08:00
2025-11-19 00:12:47 +08:00
const fileName = file.name.toLowerCase()
const fileType = file.type.toLowerCase()
2025-11-19 01:39:56 +08:00
2026-02-04 01:18:16 +08:00
const isValidType = VALID_AUDIO_TYPES.some(function(type) {
return fileType.includes(type)
}) || VALID_AUDIO_EXTENSIONS.some(function(ext) {
return fileName.endsWith(ext)
})
2025-11-19 01:39:56 +08:00
2025-11-19 00:12:47 +08:00
if (!isValidType) {
message.error('请上传音频文件MP3、WAV、AAC、M4A、FLAC、OGG')
return false
}
2025-11-16 19:35:55 +08:00
2025-11-19 01:39:56 +08:00
return true
2025-11-16 19:35:55 +08:00
}
2026-02-04 01:18:16 +08:00
// ========== 文件上传相关 ==========
async function handleCustomUpload(options) {
2025-11-19 00:12:47 +08:00
const { file, onSuccess, onError } = options
2025-11-19 01:39:56 +08:00
2025-11-19 00:12:47 +08:00
try {
2026-01-17 14:43:42 +08:00
const fileId = await upload(file, {
fileCategory: 'voice',
2026-02-02 02:39:40 +08:00
groupId: null,
2026-02-04 01:18:16 +08:00
onSuccess: async function(id, fileUrl) {
2026-01-17 14:43:42 +08:00
formData.fileId = id
2026-02-04 01:18:16 +08:00
formData.fileUrl = fileUrl
2026-02-02 02:39:40 +08:00
await fetchAudioTextById(id)
2026-01-17 14:43:42 +08:00
onSuccess?.({ code: 0, data: id }, file)
},
2026-02-04 01:18:16 +08:00
onError: function(error) {
2026-01-17 14:43:42 +08:00
const errorMsg = error.message || '上传失败,请稍后重试'
message.error(errorMsg)
onError?.(error)
}
})
2025-11-19 01:39:56 +08:00
2026-01-17 14:43:42 +08:00
return fileId
2025-11-19 00:12:47 +08:00
} catch (error) {
console.error('上传失败:', error)
2025-11-19 01:39:56 +08:00
onError?.(error)
2025-11-19 00:12:47 +08:00
}
2025-11-16 19:35:55 +08:00
}
2026-02-02 02:39:40 +08:00
// 通过fileId获取音频文本
2026-02-04 01:18:16 +08:00
async function fetchAudioTextById(fileId) {
2026-02-02 02:39:40 +08:00
if (!fileId) return
2026-03-01 20:44:29 +08:00
extractingText.value = true
2026-02-02 02:39:40 +08:00
try {
const res = await MaterialService.getAudioPlayUrl(fileId)
if (res.code === 0 && res.data) {
const rawFileUrl = res.data
const results = await getVoiceText([{ audio_url: rawFileUrl }])
if (results && results.length > 0) {
const text = results[0].value
formData.text = text
}
}
} catch (error) {
console.error('获取音频文本失败:', error)
2026-03-01 20:44:29 +08:00
message.error('语音识别失败,请重试')
} finally {
extractingText.value = false
2026-02-02 02:39:40 +08:00
}
}
2026-02-04 01:18:16 +08:00
function handleRemoveFile() {
2025-11-19 00:12:47 +08:00
formData.fileId = null
2026-03-01 20:44:29 +08:00
formData.text = ''
2025-11-19 00:12:47 +08:00
fileList.value = []
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
// ========== 表单操作 ==========
2026-02-04 01:18:16 +08:00
async function handleSubmit() {
2025-11-19 00:12:47 +08:00
try {
await formRef.value.validate()
2025-11-19 01:39:56 +08:00
} catch {
return
}
submitting.value = true
2025-11-19 00:12:47 +08:00
2025-11-19 01:39:56 +08:00
const params = isCreateMode.value
? {
name: formData.name,
fileId: formData.fileId,
autoTranscribe: formData.autoTranscribe,
language: formData.language,
gender: formData.gender,
2026-02-02 02:39:40 +08:00
note: formData.note,
2026-02-04 01:18:16 +08:00
text: formData.text
2025-11-19 01:39:56 +08:00
}
: {
id: formData.id,
name: formData.name,
language: formData.language,
gender: formData.gender,
2025-11-19 21:57:16 +08:00
note: formData.note
2025-11-19 01:39:56 +08:00
}
try {
2025-11-19 00:12:47 +08:00
const res = isCreateMode.value
? await VoiceService.create(params)
: await VoiceService.update(params)
2025-11-19 01:39:56 +08:00
if (res.code !== 0) {
2025-11-19 00:12:47 +08:00
message.error(res.msg || '操作失败')
return
}
2025-11-19 01:39:56 +08:00
message.success(isCreateMode.value ? '创建成功' : '更新成功')
modalVisible.value = false
loadVoiceList()
} catch (error) {
2025-11-19 00:12:47 +08:00
console.error('提交失败:', error)
message.error('操作失败,请稍后重试')
} finally {
submitting.value = false
}
2025-11-16 19:35:55 +08:00
}
2026-02-04 01:18:16 +08:00
function handleCancel() {
2026-02-25 22:06:13 +08:00
modalVisible.value = false
resetForm()
2025-11-16 19:35:55 +08:00
}
2026-02-04 01:18:16 +08:00
function resetForm() {
2025-11-19 00:12:47 +08:00
Object.assign(formData, { ...DEFAULT_FORM_DATA })
fileList.value = []
formRef.value?.resetFields()
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
// ========== 生命周期 ==========
2026-02-04 01:18:16 +08:00
onMounted(function() {
2025-11-19 00:12:47 +08:00
loadVoiceList()
})
</script>
2025-11-16 19:35:55 +08:00
2026-02-25 22:06:13 +08:00
<style scoped lang="less">
2026-01-17 19:33:59 +08:00
.search-bar {
2025-11-19 01:39:56 +08:00
background: var(--color-surface);
border-radius: var(--radius-card);
2025-11-19 00:12:47 +08:00
margin-bottom: 16px;
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
.table-container {
2026-01-17 19:33:59 +08:00
background: var(--color-surface);
border-radius: var(--radius-card);
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
.voice-name {
font-weight: 500;
2025-11-16 19:35:55 +08:00
color: var(--color-text);
}
2026-02-25 22:06:13 +08:00
.note-text {
2025-11-16 19:35:55 +08:00
color: var(--color-text-secondary);
2026-02-25 22:06:13 +08:00
}
// 上传区域样式
.upload-area {
:deep(.ant-upload-drag) {
border: 2px dashed var(--color-border, #d9d9d9);
border-radius: 8px;
background: var(--color-bg-container, #fafafa);
transition: all 0.3s;
padding: 24px 16px;
&:hover {
border-color: var(--color-primary, #1890ff);
background: var(--color-primary-bg, #e6f7ff);
}
}
&.has-file :deep(.ant-upload-drag) {
border-style: solid;
border-color: var(--color-success-border, #b7eb8f);
background: var(--color-success-bg, #f6ffed);
}
}
.upload-icon {
font-size: 40px;
color: var(--color-primary, #1890ff);
margin-bottom: 12px;
}
.upload-text {
font-size: 14px;
color: var(--color-text, #333);
margin-bottom: 4px;
}
.upload-hint-inline {
font-size: 12px;
color: var(--color-text-secondary, #999);
}
.file-preview {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.file-icon {
font-size: 32px;
color: var(--color-primary, #1890ff);
}
.file-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.file-name {
font-size: 14px;
color: var(--color-text, #333);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
2026-03-01 20:44:29 +08:00
.text-status {
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
&.success {
color: var(--color-success, #52c41a);
}
&.warning {
color: var(--color-warning, #faad14);
}
}
2025-11-16 19:35:55 +08:00
}
</style>