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

650 lines
17 KiB
Vue
Raw Normal View History

2025-11-19 00:12:47 +08:00
<template>
<div class="voice-copy-page">
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">配音管理</h1>
<a-button type="primary" @click="handleCreate">
<template #icon>
<PlusOutlined />
</template>
<span>新建配音</span>
</a-button>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<a-space>
<a-input
v-model:value="searchParams.name"
placeholder="搜索配音名称"
style="width: 250px"
allow-clear
@press-enter="handleSearch"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-button type="primary" @click="handleSearch">查询</a-button>
<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 }">
<template v-if="column.key === 'name'">
<div class="voice-name">{{ record.name || '未命名' }}</div>
</template>
<template v-else-if="column.key === 'transcription'">
<div class="transcription-text">{{ formatTranscription(record.transcription) }}</div>
</template>
<template v-else-if="column.key === 'createTime'">
<span>{{ formatDateTime(record.createTime) }}</span>
</template>
<template v-else-if="column.key === 'fileUrl'">
<a-button type="link" size="small" @click="handlePlayAudio(record)">
<template #icon>
<PlayCircleOutlined />
</template>
播放
</a-button>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button
type="link"
size="small"
:loading="transcribingId === record.id"
:disabled="!!record.transcription"
@click="handleTranscribe(record)"
>
{{ record.transcription ? '已识别' : '识别' }}
</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
2025-11-10 00:59:40 +08:00
2025-11-19 00:12:47 +08:00
<!-- 新建/编辑表单 Modal -->
<a-modal
v-model:open="modalVisible"
:title="isCreateMode ? '新建配音' : '编辑配音'"
:width="600"
: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"
:show-upload-list="true"
:max-count="1"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
@remove="handleRemoveFile"
@change="handleFileListChange"
>
<a-button type="primary" :loading="uploading">
<template #icon>
<UploadOutlined v-if="!uploading" />
</template>
{{ uploading ? '上传中...' : (fileList.length > 0 ? '重新上传' : '上传音频文件') }}
</a-button>
</a-upload>
<div class="upload-hint">支持格式MP3WAVAACM4AFLACOGG单个文件不超过 100MB</div>
</a-form-item>
<a-form-item label="备注" name="note">
<a-textarea
v-model:value="formData.note"
:rows="3"
placeholder="请输入备注信息"
/>
</a-form-item>
<a-form-item
v-if="!isCreateMode"
label="识别内容"
name="transcription"
>
<a-textarea
v-model:value="formData.transcription"
:rows="4"
placeholder="识别内容,支持手动修改"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 音频播放器 -->
<audio ref="audioPlayer" style="display: none" controls />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
SearchOutlined,
UploadOutlined,
PlayCircleOutlined
} from '@ant-design/icons-vue'
import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import dayjs from 'dayjs'
// ========== 常量定义 ==========
const POLLING_CONFIG = {
interval: 10000, // 轮询间隔10秒
maxCount: 30, // 最大轮询次数30次5分钟
transcriptionMaxLength: 50 // 识别内容最大显示长度
}
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',
note: '',
2025-11-19 00:12:47 +08:00
transcription: ''
}
// ========== 响应式数据 ==========
const loading = ref(false)
const submitting = ref(false)
const uploading = ref(false)
const voiceList = ref([])
const transcribingId = ref(null)
const modalVisible = ref(false)
const formMode = ref('create')
const formRef = ref(null)
const audioPlayer = ref(null)
const fileList = ref([])
let pollingTimer = null
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
2025-11-19 00:12:47 +08:00
// ========== 计算属性 ==========
const isCreateMode = computed(() => formMode.value === 'create')
2025-11-10 00:59:40 +08:00
2025-11-19 00:12:47 +08:00
// ========== 表格配置 ==========
const columns = [
{ title: '配音名称', key: 'name', dataIndex: 'name', width: 200 },
{ title: '识别内容', key: 'transcription', dataIndex: 'transcription', width: 300 },
{ title: '创建时间', key: 'createTime', dataIndex: 'createTime', width: 180 },
{ title: '操作', key: 'actions', width: 200, fixed: 'right' }
]
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 formatTranscription = (transcription) => {
if (!transcription) return '未识别'
if (transcription.length <= POLLING_CONFIG.transcriptionMaxLength) return transcription
return transcription.substring(0, POLLING_CONFIG.transcriptionMaxLength) + '...'
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',
note: data.note || '',
transcription: data.transcription || ''
})
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 00:12:47 +08:00
const params = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
name: searchParams.name || undefined
}
const res = await VoiceService.getPage(params)
if (res.code === 0) {
voiceList.value = res.data.list || []
pagination.total = res.data.total || 0
2025-11-10 00:59:40 +08:00
} else {
2025-11-19 00:12:47 +08:00
message.error(res.msg || '加载失败')
2025-11-10 00:59:40 +08:00
}
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
// ========== 搜索和分页 ==========
const handleSearch = () => {
pagination.current = 1
loadVoiceList()
2025-11-10 00:59:40 +08:00
}
2025-11-19 00:12:47 +08:00
const handleReset = () => {
searchParams.name = ''
pagination.current = 1
loadVoiceList()
2025-11-10 00:59:40 +08:00
}
2025-11-19 00:12:47 +08:00
const handleTableChange = (pag) => {
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 操作 ==========
const handleCreate = () => {
formMode.value = 'create'
resetForm()
modalVisible.value = true
}
const handleEdit = async (record) => {
formMode.value = 'edit'
try {
const res = await VoiceService.get(record.id)
if (res.code === 0 && res.data) {
fillFormData(res.data)
} else {
fillFormData(record) // 获取失败时使用列表数据
}
} catch (error) {
console.error('获取配音详情失败:', error)
fillFormData(record) // 异常时使用列表数据
}
modalVisible.value = true
2025-11-10 00:59:40 +08:00
}
2025-11-19 00:12:47 +08:00
const 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
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
2025-11-19 00:12:47 +08:00
try {
const res = await VoiceService.delete(record.id)
if (res.code === 0) {
message.success('删除成功')
loadVoiceList()
} else {
message.error(res.msg || '删除失败')
}
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败,请稍后重试')
}
2025-11-10 00:59:40 +08:00
}
})
}
2025-11-19 00:12:47 +08:00
// ========== 语音识别 ==========
const handleTranscribe = async (record) => {
transcribingId.value = record.id
try {
const res = await VoiceService.transcribe(record.id)
if (res.code === 0) {
message.success('识别任务已提交,正在识别中...')
startPollingTranscription(record.id)
} else {
message.error(res.msg || '识别失败')
transcribingId.value = null
}
} catch (error) {
console.error('识别失败:', error)
message.error('识别失败,请稍后重试')
transcribingId.value = null
2025-11-16 19:35:55 +08:00
}
2025-11-10 00:59:40 +08:00
}
2025-11-19 00:12:47 +08:00
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
transcribingId.value = null
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
const startPollingTranscription = (voiceId) => {
stopPolling()
let pollCount = 0
pollingTimer = setInterval(async () => {
pollCount++
try {
const res = await VoiceService.get(voiceId)
if (res.code === 0 && res.data?.transcription) {
stopPolling()
message.success('识别完成')
loadVoiceList()
return
}
if (pollCount >= POLLING_CONFIG.maxCount) {
stopPolling()
message.warning('识别超时,请稍后手动刷新查看结果')
loadVoiceList()
}
} catch (error) {
console.error('轮询识别结果失败:', error)
if (pollCount >= POLLING_CONFIG.maxCount) {
stopPolling()
}
}
}, POLLING_CONFIG.interval)
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
// ========== 音频播放 ==========
const handlePlayAudio = (record) => {
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
// ========== 文件上传 ==========
const handleBeforeUpload = (file) => {
// 检查文件大小100MB
const MAX_FILE_SIZE = 100 * 1024 * 1024
if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 100MB')
return false
}
2025-11-16 19:35:55 +08:00
2025-11-19 00:12:47 +08:00
// 检查文件类型
const validTypes = ['audio/mpeg', 'audio/wav', 'audio/wave', 'audio/x-wav', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg']
const validExtensions = ['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']
const fileName = file.name.toLowerCase()
const fileType = file.type.toLowerCase()
const isValidType = validTypes.some(type => fileType.includes(type)) ||
validExtensions.some(ext => fileName.endsWith(ext))
if (!isValidType) {
message.error('请上传音频文件MP3、WAV、AAC、M4A、FLAC、OGG')
return false
}
2025-11-16 19:35:55 +08:00
2025-11-19 00:12:47 +08:00
return true // 允许添加到文件列表
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
const handleCustomUpload = async (options) => {
const { file, onSuccess, onError } = options
uploading.value = true
try {
const res = await MaterialService.uploadFile(file, 'voice', null)
if (res.code === 0) {
formData.fileId = res.data
message.success('文件上传成功')
// 使用 nextTick 确保 DOM 更新完成后再调用回调
await nextTick()
// 安全调用 onSuccess
if (onSuccess && typeof onSuccess === 'function') {
try {
onSuccess(res, file)
} catch (err) {
console.warn('onSuccess 回调执行失败:', err)
}
}
} else {
const errorMsg = res.msg || '上传失败'
message.error(errorMsg)
// 安全调用 onError
if (onError && typeof onError === 'function') {
try {
onError(new Error(errorMsg))
} catch (err) {
console.warn('onError 回调执行失败:', err)
}
}
}
} catch (error) {
console.error('上传失败:', error)
const errorMsg = error?.message || '上传失败,请稍后重试'
message.error(errorMsg)
// 安全调用 onError
if (onError && typeof onError === 'function') {
try {
onError(error)
} catch (err) {
console.warn('onError 回调执行失败:', err)
}
}
} finally {
uploading.value = false
}
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
const handleFileListChange = (info) => {
// 处理文件列表变化,避免直接修改导致 DOM 错误
const { fileList: newFileList } = info
// 只更新文件列表,不直接修改文件项的状态
// 让组件自己管理状态
if (newFileList) {
fileList.value = newFileList.filter(item => item.status !== 'removed')
}
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
const handleRemoveFile = () => {
formData.fileId = null
fileList.value = []
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
// ========== 表单操作 ==========
const handleSubmit = async () => {
try {
await formRef.value.validate()
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
}
: {
id: formData.id,
name: formData.name,
language: formData.language,
gender: formData.gender,
note: formData.note,
transcription: formData.transcription
}
const res = isCreateMode.value
? await VoiceService.create(params)
: await VoiceService.update(params)
if (res.code === 0) {
message.success(isCreateMode.value ? '创建成功' : '更新成功')
modalVisible.value = false
// 如果开启了自动识别,开始轮询识别结果
if (isCreateMode.value && formData.autoTranscribe && res.data) {
const voiceId = res.data
message.info('自动识别已启动,正在识别中...')
startPollingTranscription(voiceId)
}
loadVoiceList()
} else {
message.error(res.msg || '操作失败')
}
} catch (error) {
if (error?.errorFields) {
// 表单验证失败,不显示错误
return
}
console.error('提交失败:', error)
message.error('操作失败,请稍后重试')
} finally {
submitting.value = false
}
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
const handleCancel = () => {
modalVisible.value = false
resetForm()
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
const resetForm = () => {
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
// ========== 生命周期 ==========
onMounted(() => {
loadVoiceList()
})
2025-11-16 19:35:55 +08:00
2025-11-19 00:12:47 +08:00
onUnmounted(() => {
stopPolling()
})
</script>
2025-11-16 19:35:55 +08:00
2025-11-19 00:12:47 +08:00
<style scoped>
.voice-copy-page {
padding: 24px;
2025-11-16 19:35:55 +08:00
background: var(--color-bg);
}
2025-11-19 00:12:47 +08:00
.page-header {
2025-11-16 19:35:55 +08:00
display: flex;
2025-11-19 00:12:47 +08:00
justify-content: space-between;
2025-11-16 19:35:55 +08:00
align-items: center;
2025-11-19 00:12:47 +08:00
margin-bottom: 24px;
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
.page-header .ant-btn {
display: inline-flex;
2025-11-16 19:35:55 +08:00
align-items: center;
}
2025-11-19 00:12:47 +08:00
.page-title {
font-size: 20px;
font-weight: 600;
2025-11-16 19:35:55 +08:00
color: var(--color-text);
2025-11-19 00:12:47 +08:00
margin: 0;
line-height: 1.5;
2025-11-16 19:35:55 +08:00
display: flex;
align-items: center;
}
2025-11-19 00:12:47 +08:00
.search-bar {
margin-bottom: 16px;
padding: 16px;
background: var(--color-surface);
2025-11-16 19:35:55 +08:00
border-radius: var(--radius-card);
}
2025-11-19 00:12:47 +08:00
.table-container {
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 16px;
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);
}
2025-11-19 00:12:47 +08:00
.transcription-text {
2025-11-16 19:35:55 +08:00
color: var(--color-text-secondary);
2025-11-19 00:12:47 +08:00
font-size: 13px;
line-height: 1.5;
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
.uploaded-file-info {
margin-top: 8px;
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
.upload-hint {
2025-11-16 19:35:55 +08:00
font-size: 12px;
color: var(--color-text-secondary);
2025-11-19 00:12:47 +08:00
margin-top: 8px;
line-height: 1.5;
2025-11-16 19:35:55 +08:00
}
2025-11-19 00:12:47 +08:00
.form-hint {
2025-11-16 19:35:55 +08:00
font-size: 12px;
color: var(--color-text-secondary);
2025-11-19 00:12:47 +08:00
margin-top: 4px;
2025-11-16 19:35:55 +08:00
}
</style>