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

566 lines
15 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>配音管理</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">
支持格式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-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>