Files
sionrui/frontend/app/web-gold/src/views/dh/VoiceCopy.vue
2025-11-19 00:12:47 +08:00

650 lines
17 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>
<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>
<!-- 列表表格 -->
<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>
<!-- 新建/编辑表单 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,
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 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
} else {
message.error(res.msg || '加载失败')
}
} 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)
if (res.code === 0 && res.data) {
fillFormData(res.data)
} else {
fillFormData(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) {
message.success('删除成功')
loadVoiceList()
} else {
message.error(res.msg || '删除失败')
}
} 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.success('识别任务已提交,正在识别中...')
startPollingTranscription(record.id)
} else {
message.error(res.msg || '识别失败')
transcribingId.value = null
}
} 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) => {
// 检查文件大小100MB
const MAX_FILE_SIZE = 100 * 1024 * 1024
if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 100MB')
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) {
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
}
}
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()
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
}
}
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;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: var(--color-text);
margin: 0;
line-height: 1.5;
display: flex;
align-items: center;
}
.search-bar {
margin-bottom: 16px;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
}
.table-container {
background: var(--color-surface);
border-radius: var(--radius-card);
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;
}
.uploaded-file-info {
margin-top: 8px;
}
.upload-hint {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 8px;
line-height: 1.5;
}
.form-hint {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 4px;
}
</style>