Files
sionrui/frontend/app/web-gold/src/views/dh/VoiceCopy.vue
sion123 27d1c53b49 feat(material): remove video cover extraction and simplify upload API
- Remove extractVideoCoverOptional function and related video cover processing
- Update MaterialService.uploadFile method signature to remove coverBase64 parameter
- Simplify uploadAndIdentifyVideo function by removing cover generation logic
- Remove loading indicator from VideoSelector component during video preview
- Add presignGetUrlWithProcess method to FileClient interface for processed file URLs
- Add logging support to S3FileClient implementation
2026-03-04 22:37:31 +08:00

596 lines
16 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>
<a-tooltip v-else-if="column.key === 'note'" :title="record.note" placement="topLeft">
<span class="note-text">{{ record.note || '-' }}</span>
</a-tooltip>
<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="480"
ok-text="保存"
cancel-text="取消"
:confirm-loading="submitting"
:ok-button-props="{ disabled: isSubmitDisabled }"
:mask-closable="false"
centered
@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="请输入配音名称" allow-clear />
</a-form-item>
<a-form-item
v-if="isCreateMode"
label="音频文件"
name="fileId"
:rules="[{ required: true, message: '请上传音频文件' }]"
>
<div class="upload-area" :class="{ 'has-file': fileList.length > 0 }">
<a-upload-dragger
v-model:file-list="fileList"
:custom-request="handleCustomUpload"
:before-upload="handleBeforeUpload"
:max-count="1"
:show-upload-list="false"
accept="audio/*,.mp3,.wav,.aac,.m4a,.flac,.ogg"
@remove="handleRemoveFile"
>
<template v-if="!uploadState.uploading && fileList.length === 0">
<div class="upload-icon">
<CloudUploadOutlined />
</div>
<p class="upload-text">点击或拖拽音频文件到此区域</p>
<p class="upload-hint-inline">支持 MP3WAVAAC 等格式最大 5MB</p>
</template>
<template v-else-if="uploadState.uploading">
<a-progress type="circle" :percent="50" :width="60" status="active" />
<p class="upload-text" style="margin-top: 12px">正在上传...</p>
</template>
<template v-else-if="extractingText">
<a-progress type="circle" :percent="50" :width="60" status="active" />
<p class="upload-text" style="margin-top: 12px">正在识别语音...</p>
</template>
<template v-else>
<div class="file-preview">
<SoundOutlined class="file-icon" />
<div class="file-info">
<span class="file-name">{{ fileList[0]?.name || '音频文件' }}</span>
<span v-if="formData.text" class="text-status success">
<CheckCircleOutlined /> 已识别语音文本
</span>
<span v-else class="text-status warning">
<ExclamationCircleOutlined /> 未识别到语音文本
</span>
<a-button type="link" size="small" danger @click.stop="handleRemoveFile">
<DeleteOutlined /> 移除
</a-button>
</div>
</div>
</template>
</a-upload-dragger>
</div>
</a-form-item>
<a-form-item label="备注" name="note">
<a-textarea
v-model:value="formData.note"
:rows="2"
placeholder="备注信息(选填)"
:auto-size="{ minRows: 2, maxRows: 4 }"
allow-clear
/>
</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, PlayCircleOutlined, CloudUploadOutlined, SoundOutlined, DeleteOutlined, CheckCircleOutlined, ExclamationCircleOutlined } 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 '@/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 = 5 * 1024 * 1024 // SiliconFlow API 限制参考音频不超过 5MB
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 extractingText = ref(false) // 语音识别中状态
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 isSubmitDisabled = computed(function() {
if (!isCreateMode.value) {
return false
}
// 正在提取文本时禁用
if (extractingText.value) {
return true
}
// 已上传文件但文本为空时禁用
if (formData.fileId && !formData.text) {
return true
}
return false
})
// ========== 表格配置 ==========
const columns = [
{ title: '配音名称', key: 'name', dataIndex: 'name', width: 160 },
{ title: '备注', key: 'note', dataIndex: 'note', ellipsis: true },
{ title: '创建时间', key: 'createTime', dataIndex: 'createTime', width: 180 },
{ title: '操作', key: 'actions', width: 100, 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('文件大小不能超过 5MB')
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,
onSuccess: async function(id, fileUrl) {
formData.fileId = id
formData.fileUrl = fileUrl
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
extractingText.value = true
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
}
}
} catch (error) {
console.error('获取音频文本失败:', error)
message.error('语音识别失败,请重试')
} finally {
extractingText.value = false
}
}
function handleRemoveFile() {
formData.fileId = null
formData.text = ''
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 lang="less">
.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);
}
.note-text {
color: var(--color-text-secondary);
}
// 上传区域样式
.upload-area {
:deep(.ant-upload-drag) {
border: 2px dashed var(--color-border, #d9d9d9);
border-radius: 8px;
background: var(--color-bg-container, #fafafa);
transition: all 0.3s;
padding: 24px 16px;
&:hover {
border-color: var(--color-primary, #1890ff);
background: var(--color-primary-bg, #e6f7ff);
}
}
&.has-file :deep(.ant-upload-drag) {
border-style: solid;
border-color: var(--color-success-border, #b7eb8f);
background: var(--color-success-bg, #f6ffed);
}
}
.upload-icon {
font-size: 40px;
color: var(--color-primary, #1890ff);
margin-bottom: 12px;
}
.upload-text {
font-size: 14px;
color: var(--color-text, #333);
margin-bottom: 4px;
}
.upload-hint-inline {
font-size: 12px;
color: var(--color-text-secondary, #999);
}
.file-preview {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.file-icon {
font-size: 32px;
color: var(--color-primary, #1890ff);
}
.file-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.file-name {
font-size: 14px;
color: var(--color-text, #333);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-status {
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
&.success {
color: var(--color-success, #52c41a);
}
&.warning {
color: var(--color-warning, #faad14);
}
}
}
</style>