Files
sionrui/frontend/app/web-gold/src/views/dh/VoiceCopy.vue
2026-02-04 01:18:16 +08:00

469 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<BasicLayout
title="配音管理"
:show-back="false"
>
<!-- 搜索栏 -->
<div class="search-bar">
<a-space>
<a-button type="primary" @click="handleCreate">
<PlusOutlined />
新建配音
</a-button>
<a-input
v-model:value="searchParams.name"
placeholder="搜索配音名称"
style="width: 250px"
@press-enter="handleSearch"
>
<SearchOutlined />
</a-input>
<a-button @click="handleSearch">查询</a-button>
<a-button @click="handleReset">重置</a-button>
</a-space>
</div>
<!-- 列表表格 -->
<div class="table-container">
<a-table
:columns="columns"
:data-source="voiceList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<div v-if="column.key === 'name'" class="voice-name">
{{ record.name || '未命名' }}
</div>
<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>
</template>
</a-table>
</div>
<!-- 表单 Modal -->
<a-modal
v-model:open="modalVisible"
:title="isCreateMode ? '新建配音' : '编辑配音'"
:width="600"
ok-text="确定"
cancel-text="取消"
:confirm-loading="submitting"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<a-form-item label="配音名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入配音名称" />
</a-form-item>
<a-form-item
v-if="isCreateMode"
label="音频文件"
name="fileId"
:rules="[{ required: true, message: '请上传音频文件' }]"
>
<a-upload
v-model:file-list="fileList"
:custom-request="handleCustomUpload"
:before-upload="handleBeforeUpload"
:max-count="1"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
@remove="handleRemoveFile"
@change="handleFileListChange"
>
<a-button type="primary" :loading="uploadState.uploading">
<UploadOutlined v-if="!uploadState.uploading" />
{{ uploadState.uploading ? '上传中...' : (fileList.length > 0 ? '重新上传' : '上传音频文件') }}
</a-button>
</a-upload>
<div class="upload-hint">
支持格式MP3WAVAACM4AFLACOGG单个文件不超过 50MB<br>
<span class="hint-text">🎤 配音建议使用 30 - 2 分钟的短配音效果更佳</span>
</div>
</a-form-item>
<a-form-item label="备注" name="note">
<a-textarea v-model="formData.note" :rows="3" placeholder="请输入备注信息" />
</a-form-item>
</a-form>
</a-modal>
<audio ref="audioPlayer" style="display: none" />
</BasicLayout>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, SearchOutlined, UploadOutlined, PlayCircleOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import BasicLayout from '@/layouts/components/BasicLayout.vue'
import { MaterialService } from '@/api/material'
import { VoiceService } from '@/api/voice'
import { useUpload } from '@/composables/useUpload'
import useVoiceText from '@gold/hooks/web/useVoiceText'
// ========== 常量 ==========
const DEFAULT_FORM_DATA = {
id: null,
name: '',
fileId: null,
autoTranscribe: true,
language: 'zh-CN',
gender: 'female',
note: '',
text: '',
fileUrl: ''
}
const MAX_FILE_SIZE = 50 * 1024 * 1024
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']
// ========== 响应式数据 ==========
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([])
const searchParams = reactive({
name: '',
pageNo: 1,
pageSize: 10
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total) => `${total}`
})
const formData = reactive({ ...DEFAULT_FORM_DATA })
// ========== Upload Hook ==========
const { state: uploadState, upload } = useUpload()
// ========== VoiceText Hook ==========
const { getVoiceText } = useVoiceText()
// ========== 计算属性 ==========
const isCreateMode = computed(() => formMode.value === 'create')
// ========== 表格配置 ==========
const columns = [
{ title: '配音名称', key: 'name', dataIndex: 'name', width: 200 },
{ title: '创建时间', key: 'createTime', dataIndex: 'createTime', width: 180 },
{ title: '操作', key: 'actions', width: 90, fixed: 'right' }
]
// ========== 表单验证规则 ==========
const formRules = {
name: [{ required: true, message: '请输入配音名称' }],
fileId: [{ required: true, message: '请上传音频文件' }]
}
// ========== 工具函数 ==========
const formatDateTime = (value) => {
if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
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',
note: data.note || ''
})
}
// ========== 数据加载 ==========
const loadVoiceList = async () => {
loading.value = true
try {
const res = await VoiceService.getPage({
pageNo: pagination.current,
pageSize: pagination.pageSize,
name: searchParams.name || undefined
})
if (res.code !== 0) return message.error(res.msg || '加载失败')
voiceList.value = res.data.list || []
pagination.total = res.data.total || 0
} catch (error) {
console.error('加载配音列表失败:', error)
message.error('加载失败,请稍后重试')
} finally {
loading.value = false
}
}
// ========== 搜索和分页 ==========
function handleSearch() {
pagination.current = 1
loadVoiceList()
}
function handleReset() {
searchParams.name = ''
pagination.current = 1
loadVoiceList()
}
function handleTableChange(pag) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadVoiceList()
}
// ========== CRUD 操作 ==========
function handleCreate() {
formMode.value = 'create'
resetForm()
modalVisible.value = true
}
async function handleEdit(record) {
formMode.value = 'edit'
try {
const res = await VoiceService.get(record.id)
fillFormData(res.code === 0 && res.data ? res.data : record)
} catch (error) {
console.error('获取配音详情失败:', error)
fillFormData(record)
}
modalVisible.value = true
}
function handleDelete(record) {
Modal.confirm({
title: '确认删除',
content: `确定要删除配音「${record.name}」吗?此操作不可恢复。`,
okButtonProps: { danger: true },
centered: true,
onOk: async function() {
try {
const res = await VoiceService.delete(record.id)
if (res.code !== 0) return message.error(res.msg || '删除失败')
message.success('删除成功')
loadVoiceList()
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败,请稍后重试')
}
}
})
}
// ========== 音频播放 ==========
function handlePlayAudio(record) {
if (record.fileUrl && audioPlayer.value) {
audioPlayer.value.src = record.fileUrl
audioPlayer.value.play()
} else {
message.warning('音频文件不存在')
}
}
// ========== 文件上传 ==========
function handleBeforeUpload(file) {
if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 50MB')
return false
}
const fileName = file.name.toLowerCase()
const fileType = file.type.toLowerCase()
const isValidType = VALID_AUDIO_TYPES.some(function(type) {
return fileType.includes(type)
}) || VALID_AUDIO_EXTENSIONS.some(function(ext) {
return fileName.endsWith(ext)
})
if (!isValidType) {
message.error('请上传音频文件MP3、WAV、AAC、M4A、FLAC、OGG')
return false
}
return true
}
// ========== 文件上传相关 ==========
async function handleCustomUpload(options) {
const { file, onSuccess, onError } = options
try {
const fileId = await upload(file, {
fileCategory: 'voice',
groupId: null,
coverBase64: null,
onSuccess: async function(id, fileUrl) {
formData.fileId = id
formData.fileUrl = fileUrl
message.success('文件上传成功')
await fetchAudioTextById(id)
onSuccess?.({ code: 0, data: id }, file)
},
onError: function(error) {
const errorMsg = error.message || '上传失败,请稍后重试'
message.error(errorMsg)
onError?.(error)
}
})
return fileId
} catch (error) {
console.error('上传失败:', error)
onError?.(error)
}
}
// 通过fileId获取音频文本
async function fetchAudioTextById(fileId) {
if (!fileId) return
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
if (text) {
message.success('音频文本获取成功')
}
}
}
} catch (error) {
console.error('获取音频文本失败:', error)
}
}
function handleFileListChange(info) {
const { fileList: newFileList } = info
if (newFileList) {
fileList.value = newFileList.filter(function(item) {
return item.status !== 'removed'
})
}
}
function handleRemoveFile() {
formData.fileId = null
fileList.value = []
}
// ========== 表单操作 ==========
async function handleSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
submitting.value = true
const params = isCreateMode.value
? {
name: formData.name,
fileId: formData.fileId,
autoTranscribe: formData.autoTranscribe,
language: formData.language,
gender: formData.gender,
note: formData.note,
text: formData.text
}
: {
id: formData.id,
name: formData.name,
language: formData.language,
gender: formData.gender,
note: formData.note
}
try {
const res = isCreateMode.value
? await VoiceService.create(params)
: await VoiceService.update(params)
if (res.code !== 0) {
message.error(res.msg || '操作失败')
return
}
message.success(isCreateMode.value ? '创建成功' : '更新成功')
modalVisible.value = false
loadVoiceList()
} catch (error) {
console.error('提交失败:', error)
message.error('操作失败,请稍后重试')
} finally {
submitting.value = false
}
}
function handleCancel() {
modalVisible.value = false
resetForm()
}
function resetForm() {
Object.assign(formData, { ...DEFAULT_FORM_DATA })
fileList.value = []
formRef.value?.resetFields()
}
// ========== 生命周期 ==========
onMounted(function() {
loadVoiceList()
})
</script>
<style scoped>
.search-bar {
background: var(--color-surface);
border-radius: var(--radius-card);
margin-bottom: 16px;
}
.table-container {
background: var(--color-surface);
border-radius: var(--radius-card);
}
.voice-name {
font-weight: 500;
color: var(--color-text);
}
.upload-hint {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 8px;
line-height: 1.5;
}
</style>