566 lines
15 KiB
Vue
566 lines
15 KiB
Vue
<template>
|
||
<div class="voice-copy-page">
|
||
<!-- 页面头部 -->
|
||
<div class="page-header">
|
||
<h1>配音管理</h1>
|
||
<a-button type="primary" @click="handleCreate">
|
||
<PlusOutlined />
|
||
新建配音
|
||
</a-button>
|
||
</div>
|
||
|
||
<!-- 搜索栏 -->
|
||
<div class="search-bar">
|
||
<a-space>
|
||
<a-input
|
||
v-model:value="searchParams.name"
|
||
placeholder="搜索配音名称"
|
||
style="width: 250px"
|
||
@press-enter="handleSearch"
|
||
>
|
||
<SearchOutlined />
|
||
</a-input>
|
||
<a-button type="primary" @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>
|
||
<div v-else-if="column.key === 'transcription'" class="transcription-text">
|
||
{{ formatTranscription(record.transcription) }}
|
||
</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"
|
||
: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>
|
||
</a-table>
|
||
</div>
|
||
|
||
<!-- 表单 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"
|
||
:max-count="1"
|
||
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
|
||
@remove="handleRemoveFile"
|
||
@change="handleFileListChange"
|
||
>
|
||
<a-button type="primary" :loading="uploading">
|
||
<UploadOutlined v-if="!uploading" />
|
||
{{ uploading ? '上传中...' : (fileList.length > 0 ? '重新上传' : '上传音频文件') }}
|
||
</a-button>
|
||
</a-upload>
|
||
<div class="upload-hint">
|
||
支持格式:MP3、WAV、AAC、M4A、FLAC、OGG,单个文件不超过 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-item v-if="!isCreateMode" label="识别内容" name="transcription">
|
||
<a-textarea
|
||
v-model="formData.transcription"
|
||
:rows="4"
|
||
placeholder="识别内容,支持手动修改"
|
||
/>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<audio ref="audioPlayer" style="display: none" />
|
||
</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,
|
||
maxCount: 30,
|
||
transcriptionMaxLength: 50
|
||
}
|
||
|
||
const DEFAULT_FORM_DATA = {
|
||
id: null,
|
||
name: '',
|
||
fileId: null,
|
||
autoTranscribe: true,
|
||
language: 'zh-CN',
|
||
gender: 'female',
|
||
note: '',
|
||
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
|
||
})
|
||
|
||
const pagination = reactive({
|
||
current: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 条`
|
||
})
|
||
|
||
const formData = reactive({ ...DEFAULT_FORM_DATA })
|
||
|
||
// ========== 计算属性 ==========
|
||
const isCreateMode = computed(() => formMode.value === 'create')
|
||
|
||
// ========== 表格配置 ==========
|
||
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' }
|
||
]
|
||
|
||
// ========== 表单验证规则 ==========
|
||
const formRules = {
|
||
name: [{ required: true, message: '请输入配音名称' }],
|
||
fileId: [{ required: true, message: '请上传音频文件' }]
|
||
}
|
||
|
||
// ========== 工具函数 ==========
|
||
const formatTranscription = (transcription) => {
|
||
if (!transcription) return '未识别'
|
||
if (transcription.length <= POLLING_CONFIG.transcriptionMaxLength) return transcription
|
||
return transcription.substring(0, POLLING_CONFIG.transcriptionMaxLength) + '...'
|
||
}
|
||
|
||
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 || '',
|
||
transcription: data.transcription || ''
|
||
})
|
||
}
|
||
|
||
// ========== 数据加载 ==========
|
||
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
|
||
}
|
||
}
|
||
|
||
// ========== 搜索和分页 ==========
|
||
const handleSearch = () => {
|
||
pagination.current = 1
|
||
loadVoiceList()
|
||
}
|
||
|
||
const handleReset = () => {
|
||
searchParams.name = ''
|
||
pagination.current = 1
|
||
loadVoiceList()
|
||
}
|
||
|
||
const handleTableChange = (pag) => {
|
||
pagination.current = pag.current
|
||
pagination.pageSize = pag.pageSize
|
||
loadVoiceList()
|
||
}
|
||
|
||
// ========== 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)
|
||
fillFormData(res.code === 0 && res.data ? res.data : record)
|
||
} catch (error) {
|
||
console.error('获取配音详情失败:', error)
|
||
fillFormData(record)
|
||
}
|
||
modalVisible.value = true
|
||
}
|
||
|
||
const handleDelete = (record) => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除配音「${record.name}」吗?此操作不可恢复。`,
|
||
okText: '删除',
|
||
okButtonProps: { danger: true },
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
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('删除失败,请稍后重试')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// ========== 语音识别 ==========
|
||
const handleTranscribe = async (record) => {
|
||
transcribingId.value = record.id
|
||
try {
|
||
const res = await VoiceService.transcribe(record.id)
|
||
if (res.code !== 0) {
|
||
message.error(res.msg || '识别失败')
|
||
transcribingId.value = null
|
||
return
|
||
}
|
||
|
||
message.success('识别任务已提交,正在识别中...')
|
||
startPollingTranscription(record.id)
|
||
} catch (error) {
|
||
console.error('识别失败:', error)
|
||
message.error('识别失败,请稍后重试')
|
||
transcribingId.value = null
|
||
}
|
||
}
|
||
|
||
const stopPolling = () => {
|
||
if (pollingTimer) {
|
||
clearInterval(pollingTimer)
|
||
pollingTimer = null
|
||
}
|
||
transcribingId.value = null
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// ========== 音频播放 ==========
|
||
const handlePlayAudio = (record) => {
|
||
if (record.fileUrl && audioPlayer.value) {
|
||
audioPlayer.value.src = record.fileUrl
|
||
audioPlayer.value.play()
|
||
} else {
|
||
message.warning('音频文件不存在')
|
||
}
|
||
}
|
||
|
||
// ========== 文件上传 ==========
|
||
const handleBeforeUpload = (file) => {
|
||
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
message.error('文件大小不能超过 50MB')
|
||
return false
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
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) {
|
||
const errorMsg = res.msg || '上传失败'
|
||
message.error(errorMsg)
|
||
onError?.(new Error(errorMsg))
|
||
return
|
||
}
|
||
|
||
formData.fileId = res.data
|
||
message.success('文件上传成功')
|
||
|
||
await nextTick()
|
||
onSuccess?.(res, file)
|
||
} catch (error) {
|
||
console.error('上传失败:', error)
|
||
const errorMsg = error?.message || '上传失败,请稍后重试'
|
||
message.error(errorMsg)
|
||
onError?.(error)
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
const handleFileListChange = (info) => {
|
||
// 处理文件列表变化,避免直接修改导致 DOM 错误
|
||
const { fileList: newFileList } = info
|
||
|
||
// 只更新文件列表,不直接修改文件项的状态
|
||
// 让组件自己管理状态
|
||
if (newFileList) {
|
||
fileList.value = newFileList.filter(item => item.status !== 'removed')
|
||
}
|
||
}
|
||
|
||
const handleRemoveFile = () => {
|
||
formData.fileId = null
|
||
fileList.value = []
|
||
}
|
||
|
||
// ========== 表单操作 ==========
|
||
const handleSubmit = async () => {
|
||
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
|
||
}
|
||
: {
|
||
id: formData.id,
|
||
name: formData.name,
|
||
language: formData.language,
|
||
gender: formData.gender,
|
||
note: formData.note,
|
||
transcription: formData.transcription
|
||
}
|
||
|
||
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
|
||
|
||
if (isCreateMode.value && formData.autoTranscribe && res.data) {
|
||
message.info('自动识别已启动,正在识别中...')
|
||
startPollingTranscription(res.data)
|
||
}
|
||
|
||
loadVoiceList()
|
||
} catch (error) {
|
||
console.error('提交失败:', error)
|
||
message.error('操作失败,请稍后重试')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
const handleCancel = () => {
|
||
modalVisible.value = false
|
||
resetForm()
|
||
}
|
||
|
||
const resetForm = () => {
|
||
Object.assign(formData, { ...DEFAULT_FORM_DATA })
|
||
fileList.value = []
|
||
formRef.value?.resetFields()
|
||
}
|
||
|
||
// ========== 生命周期 ==========
|
||
onMounted(() => {
|
||
loadVoiceList()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopPolling()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.voice-copy-page {
|
||
padding: 24px;
|
||
background: var(--color-bg);
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.page-header .ant-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.search-bar,
|
||
.table-container {
|
||
background: var(--color-surface);
|
||
border-radius: var(--radius-card);
|
||
}
|
||
|
||
.search-bar {
|
||
margin-bottom: 16px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.table-container {
|
||
padding: 16px;
|
||
}
|
||
|
||
.voice-name {
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.transcription-text {
|
||
color: var(--color-text-secondary);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.upload-hint {
|
||
font-size: 12px;
|
||
color: var(--color-text-secondary);
|
||
margin-top: 8px;
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|