feat: 功能优化

This commit is contained in:
2025-11-20 00:49:18 +08:00
parent cc5401e743
commit bd367c645b
23 changed files with 1631 additions and 110 deletions

View File

@@ -0,0 +1,77 @@
/**
* 数字人任务 API
*/
import request from './http'
/**
* 创建数字人任务
*/
export function createDigitalHumanTask(data) {
return request({
url: '/api/tik/digital-human/task/create',
method: 'post',
data
})
}
/**
* 查询任务详情
*/
export function getDigitalHumanTask(taskId) {
return request({
url: '/api/tik/digital-human/task/get',
method: 'get',
params: { taskId }
})
}
/**
* 分页查询任务列表
*/
export function getDigitalHumanTaskPage(params) {
return request({
url: '/api/tik/digital-human/task/page',
method: 'get',
params
})
}
/**
* 查询任务统计
*/
export function getTaskStatistics() {
return request({
url: '/api/tik/digital-human/task/statistics',
method: 'get'
})
}
/**
* 取消任务
*/
export function cancelTask(taskId) {
return request({
url: `/api/tik/digital-human/task/${taskId}/cancel`,
method: 'post'
})
}
/**
* 重试任务
*/
export function retryTask(taskId) {
return request({
url: `/api/tik/digital-human/task/${taskId}/retry`,
method: 'post'
})
}
/**
* 删除任务
*/
export function deleteTask(taskId) {
return request({
url: `/api/tik/digital-human/task/${taskId}`,
method: 'delete'
})
}

View File

@@ -5,19 +5,26 @@ import { message } from 'ant-design-vue'
import { InboxOutlined } from '@ant-design/icons-vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
const voiceStore = useVoiceCopyStore()
// 状态管理
const uploadedVideo = ref('')
const uploadedVideoFile = ref(null) // 存储原始文件对象
const previewVideoUrl = ref('')
const isGenerating = ref(false)
const generationProgress = ref(0)
const currentTaskId = ref(null)
const currentTaskStatus = ref('')
const currentTaskStep = ref('')
const isSynthesizing = ref(false)
const synthesizedAudio = ref(null)
const previewLoadingVoiceId = ref('')
const isPlayingPreview = ref(false) // 是否正在播放试听音频
const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频
const pollingInterval = ref(null) // 轮询间隔ID
// TTS 配置
const ttsText = ref('')
@@ -245,6 +252,7 @@ const handleVideoUpload = async (file) => {
try {
uploadedVideo.value = await toDataURL(file)
uploadedVideoFile.value = file // 保存文件对象
message.success('视频上传成功')
} catch (error) {
message.error('视频上传失败')
@@ -255,6 +263,7 @@ const handleVideoUpload = async (file) => {
const handleVideoDrop = (e) => console.log('Video drop:', e)
const clearVideo = () => {
uploadedVideo.value = ''
uploadedVideoFile.value = null
previewVideoUrl.value = ''
message.info('已清除视频')
}
@@ -270,26 +279,176 @@ const downloadPreview = () => {
// 视频生成
const generateVideo = async () => {
if (!canGenerate.value) return message.warning('请先完成配置')
if (!ttsText.value.trim()) return message.warning('请输入文本')
const voice = selectedVoiceMeta.value
if (!voice) return message.warning('请选择音色')
isGenerating.value = true
generationProgress.value = 0
currentTaskStatus.value = 'PENDING'
currentTaskStep.value = 'prepare_files'
try {
// 进度模拟
const progressInterval = setInterval(() => {
generationProgress.value += 10
generationProgress.value >= 100 && clearInterval(progressInterval)
}, 500)
// 1. 首先上传音频和视频文件到后端
message.loading('正在上传文件...', 0)
// 上传音频(使用合成后的音频或原始音频)
let audioFileId = null
let audioUrl = null
if (synthesizedAudio.value?.fileId) {
// 如果有已合成的音频使用其fileId
audioFileId = synthesizedAudio.value.fileId
} else {
// 否则使用voiceConfigId让后端处理
audioFileId = voice.rawId || extractIdFromString(voice.id)
}
// 上传视频文件
const videoFileId = await uploadVideoFile(uploadedVideoFile.value)
if (!videoFileId) {
throw new Error('视频上传失败')
}
await new Promise(resolve => setTimeout(resolve, 5000))
previewVideoUrl.value = uploadedVideo.value
generationProgress.value = 100
message.success('视频生成成功')
clearInterval(progressInterval)
message.destroy()
// 2. 创建数字人任务
const taskData = {
taskName: `数字人任务_${Date.now()}`,
audioFileId: audioFileId,
videoFileId: videoFileId,
speechRate: speechRate.value,
emotion: emotion.value,
guidanceScale: 1,
seed: 8888
}
message.loading('正在创建任务...', 0)
const createRes = await createDigitalHumanTask(taskData)
message.destroy()
if (createRes.code === 0) {
currentTaskId.value = createRes.data
message.success('任务创建成功,开始处理')
// 3. 开始轮询任务状态
startPollingTask()
} else {
throw new Error(createRes.msg || '任务创建失败')
}
} catch (error) {
message.error('视频生成失败')
} finally {
console.error('generateVideo error:', error)
message.destroy()
message.error(error.message || '视频生成失败')
isGenerating.value = false
currentTaskStatus.value = ''
currentTaskStep.value = ''
}
}
// 上传视频文件到后端
const uploadVideoFile = async (file) => {
try {
const res = await MaterialService.uploadFile(file, 'video')
if (res.code === 0 && res.data?.id) {
return res.data.id
} else {
throw new Error(res.msg || '上传失败')
}
} catch (error) {
console.error('uploadVideoFile error:', error)
throw error
}
}
// 开始轮询任务状态
const startPollingTask = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
}
pollingInterval.value = setInterval(async () => {
if (!currentTaskId.value) {
clearInterval(pollingInterval.value)
return
}
try {
const res = await getDigitalHumanTask(currentTaskId.value)
if (res.code === 0 && res.data) {
const task = res.data
currentTaskStatus.value = task.status
currentTaskStep.value = task.currentStep
generationProgress.value = task.progress || 0
// 如果任务完成或失败,停止轮询
if (task.status === 'SUCCESS') {
clearInterval(pollingInterval.value)
pollingInterval.value = null
previewVideoUrl.value = task.resultVideoUrl
isGenerating.value = false
currentTaskStatus.value = 'SUCCESS'
message.success('视频生成成功!')
} else if (task.status === 'FAILED') {
clearInterval(pollingInterval.value)
pollingInterval.value = null
isGenerating.value = false
currentTaskStatus.value = 'FAILED'
message.error(`任务失败:${task.errorMessage || '未知错误'}`)
} else if (task.status === 'CANCELED') {
clearInterval(pollingInterval.value)
pollingInterval.value = null
isGenerating.value = false
currentTaskStatus.value = 'CANCELED'
message.info('任务已取消')
}
}
} catch (error) {
console.error('polling error:', error)
}
}, 2000) // 每2秒轮询一次
}
// 取消任务
const handleCancelTask = async () => {
if (!currentTaskId.value) return
try {
const res = await cancelTask(currentTaskId.value)
if (res.code === 0) {
message.success('任务已取消')
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
pollingInterval.value = null
}
isGenerating.value = false
} else {
message.error(res.msg || '取消失败')
}
} catch (error) {
console.error('cancelTask error:', error)
message.error('取消任务失败')
}
}
// 重试任务
const handleRetryTask = async () => {
if (!currentTaskId.value) return
try {
const res = await retryTask(currentTaskId.value)
if (res.code === 0) {
message.success('任务已重启')
currentTaskStatus.value = 'PENDING'
currentTaskStep.value = 'prepare_files'
isGenerating.value = true
startPollingTask()
} else {
message.error(res.msg || '重试失败')
}
} catch (error) {
console.error('retryTask error:', error)
message.error('重试任务失败')
}
}
@@ -301,6 +460,29 @@ const toDataURL = (file) => new Promise((resolve, reject) => {
reader.readAsDataURL(file)
})
// 状态描述映射
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待处理',
'PROCESSING': '处理中',
'SUCCESS': '已完成',
'FAILED': '失败',
'CANCELED': '已取消'
}
return statusMap[status] || status
}
const getStepText = (step) => {
const stepMap = {
'prepare_files': '准备文件',
'synthesize_voice': '语音合成',
'sync_lip': '口型同步',
'finishing': '完成处理',
'canceled': '已取消'
}
return stepMap[step] || step
}
const playAudioPreview = (url, options = {}) => {
if (!url) return message.warning('暂无可试听的音频')
@@ -383,6 +565,11 @@ onUnmounted(() => {
// 重置播放状态
isPlayingPreview.value = false
isPlayingSynthesized.value = false
// 清理轮询
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
pollingInterval.value = null
}
})
// 监听器
@@ -578,21 +765,55 @@ let previewObjectUrl = ''
<!-- 生成按钮 -->
<div class="generate-section">
<a-button
type="primary"
size="large"
block
:disabled="!canGenerate"
:loading="isGenerating"
@click="generateVideo"
>
{{ isGenerating ? '生成中...' : '生成视频' }}
</a-button>
<div v-if="!currentTaskId || currentTaskStatus === 'SUCCESS' || currentTaskStatus === 'FAILED' || currentTaskStatus === 'CANCELED'" class="generate-actions">
<a-button
type="primary"
size="large"
block
:disabled="!canGenerate"
:loading="isGenerating"
@click="generateVideo"
>
{{ isGenerating ? '生成中...' : '生成视频' }}
</a-button>
</div>
<div v-else class="task-actions">
<div class="task-status">
<div class="status-row">
<span class="status-label">状态</span>
<span class="status-value">{{ getStatusText(currentTaskStatus) }}</span>
</div>
<div class="status-row">
<span class="status-label">步骤</span>
<span class="status-value">{{ getStepText(currentTaskStep) }}</span>
</div>
</div>
<div class="action-buttons">
<a-button
v-if="currentTaskStatus === 'PROCESSING'"
danger
block
@click="handleCancelTask"
>
取消任务
</a-button>
<a-button
v-if="currentTaskStatus === 'FAILED' || currentTaskStatus === 'CANCELED'"
type="primary"
block
@click="handleRetryTask"
>
重试任务
</a-button>
</div>
</div>
<a-progress
v-if="isGenerating"
:percent="generationProgress"
:show-info="true"
:status="currentTaskStatus === 'FAILED' ? 'exception' : 'active'"
/>
</div>
</section>
@@ -940,6 +1161,56 @@ let previewObjectUrl = ''
gap: 12px;
}
.generate-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-status {
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: var(--radius-card);
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.status-row:last-child {
margin-bottom: 0;
}
.status-label {
font-size: 13px;
color: var(--color-text-secondary);
font-weight: 600;
}
.status-value {
font-size: 13px;
color: var(--color-text);
}
.action-buttons {
display: flex;
gap: 8px;
}
.action-buttons .ant-btn {
flex: 1;
}
.preview-title {
font-size: 14px;
font-weight: 600;